Rate limiting: chặn một client làm sập cả hệ thống

4 câu hỏi~30 phútTừ khóa: fixed window, sliding window log/counter, token bucket, leaky bucket, Redis Lua, atomic, 429, Retry-After, X-RateLimit, DDoS

Tiếp phần rate limiting nhé.

Bài trước shop-app đã scale out sau load balancer, có Redis và DB replica. Giờ ta bảo vệ nó khỏi một client gọi quá nhiều.

1. Bài toán

Bình thường: 10,000 users × 1–5 req/s

Client X (bug hoặc bot)
   │ 100,000 req/s 💥
   ▼
Load Balancer
   ├── A1 💥
   ├── A2 💥
   └── A3 💥

2. Vấn đề: scale thêm không phải câu trả lời

"Thì thêm A4, A5, A6..." Attacker tăng 100k → 500k → 1M, mình chạy theo thì vừa tốn tiền, vừa để một client chiếm tài nguyên của mọi user khác. Ta cần một cái cửa: Rate Limiter, chỉ cho phép ví dụ 100 req/s mỗi client.

3. Giải pháp: rate limiting

Rate limiting giới hạn số request một client được làm trong một khoảng thời gian. Vượt limit → HTTP 429 Too Many Requests.

Limit theo key nào? IP, user_id, API key, tenant, endpoint, gói subscription. Policy phụ thuộc business:

Chưa login      : 100 req/min/IP
Free user       : 100 req/min/user
Premium         : 1,000 req/min/user
POST /login     : 5 lần/min/IP
POST /send-otp  : 3 lần/min/số điện thoại

Thuật toán:

a) Fixed window: mỗi phút một counter rl:user:123:12:00, count++, quá 100 thì reject. Đơn giản, nhưng có bug ở biên:

12:00:59  → 100 request  (window 12:00: hợp lệ)
12:01:00  → 100 request  (window mới, counter = 0: hợp lệ)
→ 200 request trong ~2 giây dù rule là 100/phút

b) Sliding window log: lưu timestamp từng request (Redis ZSET), mỗi lần đếm số request trong 60 giây gần nhất. Chính xác nhưng tốn bộ nhớ (1 entry/request).

c) Sliding window counter: ước lượng bằng 2 counter cửa sổ liền kề:

count ≈ current + previous × (phần trăm cửa sổ trước còn nằm trong 60s)
Ví dụ 12:01:15: current=40, previous=80 → 40 + 80 × 0.75 = 100

Rẻ như fixed window, gần đúng như sliding log.

d) Token bucket: mỗi client một xô, capacity 10 token, refill 1 token/giây. Mỗi request lấy 1 token, hết token → 429. Capacity quy định burst tối đa, refill rate quy định tốc độ trung bình. User im lặng 10 giây rồi bắn 8 request liền vẫn được.

e) Leaky bucket: request vào hàng đợi, được xử lý ra với tốc độ cố định như nước rỉ khỏi xô thủng. Đầu ra rất đều, nhưng không cho burst; hàng đầy thì drop.

4. Code từng bước: distributed limiter với Redis + Lua

Nếu mỗi instance tự giữ counter trong RAM, 100 req/min mỗi instance × 20 instance = user thực tế được ~2,000 req/min. Cần state dùng chung → Redis.

Nhưng không được GET rồi SET:

Redis count = 99
A1 GET → 99      A2 GET → 99
A1: 99 < 100 ✅   A2: 99 < 100 ✅
A1 SET 100       A2 SET 100     → cả hai ALLOW ❌ (race condition)

Cần thao tác atomic. Fixed window chỉ cần INCR (atomic) + EXPIRE. Token bucket nhiều bước → dùng Lua script, Redis chạy cả script atomic.

Bước 1: Dependency: spring-boot-starter-data-redis, spring-boot-starter-web.

Bước 2: Lua script src/main/resources/token_bucket.lua. Dùng TIME của Redis để 20 instance không lệch đồng hồ.

local key      = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill   = tonumber(ARGV[2])          -- token / giây
local t   = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

local data   = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts     = tonumber(data[2]) or now

tokens = math.min(capacity, tokens + (now - ts) / 1000 * refill)
local allowed = 0
if tokens >= 1 then
  tokens = tokens - 1
  allowed = 1
end
redis.call('HSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, math.ceil(capacity / refill * 1000) + 1000)
return { allowed, math.floor(tokens) }

Bước 3: Code Java: filter trả 429 kèm header.

@Component
@RequiredArgsConstructor
public class RateLimitFilter extends OncePerRequestFilter {
    private static final int CAPACITY = 100;
    private static final int REFILL_PER_SEC = 10;
    private static final RedisScript<List> SCRIPT =
        RedisScript.of(new ClassPathResource("token_bucket.lua"), List.class);

    private final StringRedisTemplate redis;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        String client = Optional.ofNullable(req.getUserPrincipal())
                .map(Principal::getName).orElse(req.getRemoteAddr());
        List<?> r = redis.execute(SCRIPT, List.of("rl:" + client),
                String.valueOf(CAPACITY), String.valueOf(REFILL_PER_SEC));
        long allowed = ((Number) r.get(0)).longValue();
        long remaining = ((Number) r.get(1)).longValue();

        res.setHeader("X-RateLimit-Limit", String.valueOf(CAPACITY));
        res.setHeader("X-RateLimit-Remaining", String.valueOf(remaining));
        if (allowed == 0) {
            res.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            res.setHeader("Retry-After", "1");
            res.getWriter().write("{\"error\":\"too_many_requests\"}");
            return;
        }
        chain.doFilter(req, res);
    }
}

Nếu Redis lỗi, phải chọn fail-open (cho qua, ưu tiên availability) hay fail-closed (chặn, ưu tiên bảo vệ, ví dụ /login). Nhớ đặt timeout ngắn cho Redis.

Bước 4: Test bằng curl

for i in $(seq 1 120); do
  curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/products/1
done | sort | uniq -c
#  100 200
#   20 429
curl -i http://localhost:8080/products/1 | grep -i -E "ratelimit|retry-after"

5. Cách hoạt động: đặt limiter ở đâu

Internet
   │
   ▼
CDN / WAF        ← chặn DDoS lớn, IP xấu (tầng network/edge)
   │
   ▼
API Gateway      ← rate limit theo user/API key/endpoint, trước khi vào sâu
   │  Auth → Rate Limit → Routing
   ▼
Load Balancer
   │
   ▼
Services         ← limit theo business (3 OTP/phút), bảo vệ dependency
   │
Redis (counter dùng chung)

Chặn càng sớm càng rẻ: request #101 bị 429 ở gateway, không chạm tới Order, Payment, DB. Gateway làm rate limit thế nào xem 15.5 và Spring Cloud Gateway.

6. So sánh

Thuật toánƯuNhược
Fixed windowĐơn giản nhất, 1 counterBurst gấp đôi ở biên window
Sliding window logChính xácTốn bộ nhớ theo số request
Sliding window counterRẻ, gần đúngXấp xỉ
Token bucketCho burst có kiểm soát, phổ biến2 tham số cần tinh chỉnh
Leaky bucketĐầu ra đều, bảo vệ downstreamKhông burst, có thể tăng latency

Và đừng lẫn: Load Balancer hỏi "request đi server nào?"; Rate Limiter hỏi "request có được đi tiếp không?".

7. Khi nào nên / không nên

Vậy có rate limiter rồi thì coi như chống được DDoS luôn phải không?

Chưa đúng. Nếu traffic cực lớn, ví dụ 100 triệu req/s, thì chính rate limiter, gateway và đường mạng phía trước đã quá tải trước khi kịp trả 429. Mỗi request bị reject vẫn tốn kết nối, TLS handshake, một lần gọi Redis.

DDoS cần bảo vệ nhiều tầng: nhà cung cấp mạng, CDN, WAF, dịch vụ như AWS Shield / Cloudflare / Azure DDoS Protection. Rate limiting là một lớp bảo vệ tài nguyên backend và công bằng giữa client, không phải toàn bộ giải pháp DDoS.

Client nhận 429 thì nên xử lý thế nào? Retry ngay luôn được không?

Không nên retry ngay, vì retry dồn dập càng làm quá tải. Server nên trả Retry-After (giây hoặc thời điểm) và có thể kèm X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (nhiều API dùng tên như vậy; IETF đang chuẩn hóa header RateLimit). Client đọc Retry-After, chờ, rồi retry với exponential backoff + jitter để hàng nghìn client không cùng retry một lúc.

Phỏng vấn hỏi về rate limiting thì trả lời sao?

Q1. What is rate limiting and how would you implement it in a distributed system?

🇻🇳 Tiếng Việt:

Rate limiting giới hạn số request một client được làm trong một khoảng thời gian, để bảo vệ backend và không cho một client chiếm hết tài nguyên. Key có thể là IP, user, API key, endpoint hay tenant. Khi có nhiều instance, em không giữ counter trong RAM vì request rơi vào instance khác nhau, mà lưu ở Redis và cập nhật atomic bằng INCR hoặc Lua script. Vượt limit thì trả 429 kèm Retry-After.

🇬🇧 English:

Rate limiting controls how many requests a client can make in a period of time. It protects backend resources and stops one client from consuming too much capacity. The key can be IP, user ID, API key, endpoint or tenant. With many instances I don't keep counters in local memory, because requests land on different instances; I keep them in Redis and update them atomically with INCR or a Lua script. When the limit is exceeded the API returns 429 Too Many Requests with a Retry-After header.

Q2. Token bucket khác fixed window thế nào?

🇻🇳 Tiếng Việt:

Fixed window đếm theo từng phút cố định, đơn giản nhưng ở biên window client có thể gửi gấp đôi limit. Token bucket có capacity quy định burst tối đa và refill rate quy định tốc độ trung bình, nên vừa cho burst ngắn vừa giữ được giới hạn lâu dài. Sliding window thì chính xác hơn fixed window nhưng tốn bộ nhớ hơn.

🇬🇧 English:

Fixed window counts requests per fixed minute; it is simple, but at the window boundary a client can send twice the limit. Token bucket has a capacity that caps the burst and a refill rate that sets the long-term average, so it allows short bursts while enforcing the overall rate. Sliding window is more accurate than fixed window but uses more memory.

Q3. Đặt rate limiter ở đâu?

🇻🇳 Tiếng Việt:

Càng sớm càng rẻ. DDoS và IP xấu chặn ở edge (CDN, WAF). Limit theo user hay API key em đặt ở API Gateway, sau authentication, để request vượt limit không vào tới service. Limit đặc thù nghiệp vụ như số lần gửi OTP thì đặt trong service. Em cũng quyết định fail-open hay fail-closed khi Redis lỗi.

🇬🇧 English:

The earlier the cheaper. DDoS and bad IPs are blocked at the edge with a CDN or WAF. Per-user or per-API-key limits go in the API Gateway, after authentication, so rejected requests never reach the services. Business-specific limits such as OTP attempts live inside the service. I also decide whether to fail open or fail closed if Redis is unavailable.

Cách nhớ ngắn gọn


Tự kiểm tra

Fixed window 100/phút, client gửi được tối đa bao nhiêu trong 2 giây?

~200: 100 ở cuối window cũ và 100 ở đầu window mới.

Token bucket capacity 100, refill 10/s nghĩa là gì?

Burst tối đa 100 request khi xô đầy; lâu dài trung bình 10 req/s.

20 instance, mỗi cái giữ counter RAM limit 100/min. User thực tế được bao nhiêu?

Tới ~2,000/min. Cần counter chung ở Redis.

Vì sao dùng Lua script cho token bucket?

Nhiều bước đọc-tính-ghi phải atomic; Redis chạy cả script một lần, tránh race condition giữa các instance.

Leaky bucket khác token bucket ở đâu?

Leaky bucket đẩy request ra với tốc độ cố định, không cho burst; token bucket cho burst tới capacity.