Ba phần trước làm phản hồi rẻ hơn và ít hơn. Phần này từ chối bớt request — vì có những client gửi nhiều hơn mức server chịu được, do lỗi, do vòng lặp vô tận, hoặc do cố ý. Giới hạn tần suất là một hợp đồng ba phần: thuật toán đếm phía server, header để client biết mình đang ở đâu so với giới hạn, và hành vi của client khi nhận 429. Bài này đo cả ba, vì cả ba đều có cách làm sai trông rất giống cách làm đúng.
Một script Lua, không có khoảng hở

Hình 1: Script Lua chạy trọn vẹn trong Redis — đọc, nạp token, trừ, ghi — không request nào chen vào giữa. Phần dưới là bản "trông giống hệt" nhưng tách thành hai lệnh.
Token bucket là thuật toán phổ biến nhất vì nó cho phép bùng nổ ngắn (bucket đầy 20 token thì 20 request đến cùng lúc đều qua) nhưng giữ tốc độ trung bình (nạp 20 token/giây). Logic chỉ có bốn bước: đọc số token và mốc thời gian, nạp thêm theo thời gian trôi qua, trừ một nếu còn, ghi lại. Vấn đề là bốn bước đó phải là một thao tác — và Redis chỉ đảm bảo điều đó khi chúng nằm trong một script Lua (hoặc một MULTI/EXEC với WATCH, phức tạp hơn nhiều).
Script còn trả về wait: còn bao lâu nữa mới có token. Server dùng nó để điền Retry-After — thứ cho phép client chờ đúng thay vì đoán.
Đo thật: ba chỗ sai và một bảng chi phí

Hình 2: Kết quả thật. Mục 1 và 2 là lỗi phía server, mục 3 là lỗi phía client, mục 4 là chi phí.
Mục 1 — đọc rồi ghi cho qua 50/50. 50 goroutine cùng xin token từ bucket dung lượng 20. Bản hai lệnh rời: lần đầu tình cờ đúng 20 (pool kết nối chưa mở nên các goroutine bị xếp hàng), hai lần sau 50/50 — tất cả cùng đọc "20 token", cùng thấy còn, cùng ghi "19". Giới hạn tồn tại trong code nhưng không tồn tại trong thực tế đúng lúc cần nó nhất: khi lưu lượng dồn cục. Bản Lua: 20/50 cả ba lần. Đây là cùng bài học với ContentPlanService.claim ở phần 2 — đọc-rồi-ghi là chỗ để lọt đua tranh, và Redis cũng không ngoại lệ.
Mục 2 — fixed window lọt gấp đôi ở biên. Giới hạn "100 request mỗi 10 giây" cài bằng INCR trên khoá có hậu tố là số thứ tự cửa sổ — cách rẻ và phổ biến nhất. Client gửi 100 request ở giây thứ 9,5 và 100 request nữa ở giây thứ 10,1: 200 request lọt trong 0,6 giây, gấp đôi giới hạn, vì chúng rơi vào hai cửa sổ khác nhau. Sliding log (ZSET giữ mốc của 10 giây gần nhất) chặn đúng ở 100. Fixed window không sai về trung bình dài hạn; nó sai ở đúng kiểu bùng nổ mà giới hạn tần suất được dựng để chặn.
Mục 3 — client phớt lờ 429 gửi 52.853 request. Server cho 20 request/giây và client cần 100 request thành công. Ba client, cùng về đích sau 4 giây — vì tốc độ do bucket quyết, không client nào về sớm hơn được. Khác nhau ở số request server phải nhận: client gửi lại ngay khi bị 429 tạo ra 52.753 request thừa; client chờ cố định 50 ms tạo 75; client đọc Retry-After và chờ đúng bấy nhiêu tạo 4. Server có giới hạn tần suất vẫn phải nhận, phân tích header, chạy script Lua và trả 429 cho 52.753 request đó — giới hạn bảo vệ được nghiệp vụ phía sau, nhưng không bảo vệ được chính nó khỏi client xấu. Đó là lý do tầng chặn thứ hai (theo IP, ở nginx hoặc WAF) vẫn cần, kể cả khi ứng dụng đã có token bucket.
Mục 4 — chi phí. Mỗi lần kiểm tra là 41–77 µs qua mạng container. Đáng chú ý: fixed window — thuật toán rẻ nhất về mặt tính toán — lại chậm nhất (77 µs) vì INCR rồi EXPIRE là hai round-trip, trong khi Lua và pipeline là một. Ở Redis, số round-trip quyết định độ trễ, không phải độ phức tạp của lệnh.
Header: hợp đồng để client tự điều tiết
Server trong demo trả ba header theo IETF draft RateLimit header fields:
RateLimit-Limit: 20— giới hạn.RateLimit-Remaining: 7— còn bao nhiêu trước khi bị chặn. Client tốt dùng con số này để chậm lại trước khi nhận 429, không phải sau.Retry-After: 1— khi đã bị chặn, chờ bao lâu. RFC 9110 định nghĩa nó theo giây nguyên, nên demo làm tròn lên; thânproblem+jsoncó thêmretry_after_mscho client cần độ chính xác hơn.
Và 429 trả thân theo RFC 9457 đúng như phần 3: type ổn định để client rẽ nhánh, không phải chuỗi thông báo.
Đánh đổi cần cân nhắc
Giới hạn theo cái gì. Demo khoá theo X-Client; thực tế là theo API key, theo người dùng, theo IP, hoặc kết hợp (mỗi key 1.000/phút và mỗi IP 100/giây). Theo IP là tầng ngoài cùng và rẻ nhất nhưng dính bài học ở CLAUDE.md của blog này: văn phòng, trường học, mạng di động dùng chung một IP — giới hạn chặt theo IP là chặn nhầm người thứ hai. Theo API key thì chính xác nhưng chỉ có sau khi xác thực, tức là request đã đi qua một phần đáng kể của ứng dụng.
Sliding log tốn bộ nhớ tuyến tính theo giới hạn. Mỗi request là một phần tử ZSET; giới hạn 10.000/phút cho 100.000 client là một tỉ phần tử. Cách trung gian phổ biến là sliding window counter: hai bộ đếm fixed window kề nhau, trọng số theo vị trí trong cửa sổ — xấp xỉ sliding log với chi phí của fixed window. Token bucket với Lua ở Hình 1 là lựa chọn mặc định tốt: bộ nhớ hằng số, bùng nổ có kiểm soát, một round-trip.
Redis chết thì sao. Mọi request đều đi qua Redis trước khi được xử lý, nên Redis giờ nằm trên đường găng. Hai lựa chọn: fail open (Redis lỗi thì cho qua — mất giới hạn nhưng dịch vụ sống) hoặc fail closed (từ chối hết). Hầu hết API công khai chọn fail open với cảnh báo; API thanh toán có thể chọn ngược lại. Phải quyết trước, không phải lúc 3 giờ sáng.
Giới hạn tần suất không phải là load shedding. Token bucket bảo vệ khỏi một client gửi quá nhiều; nó không biết server đang quá tải vì mười nghìn client mỗi người gửi vừa đủ. Chuyện đó cần tín hiệu từ chính server (độ sâu hàng đợi, độ trễ p99) — phần 11 sẽ chạm tới, và sê-ri hệ thống chịu tải đã đo riêng ở bài load shedding.
Số đo qua mạng container, Redis cùng máy. 43 µs cho một lần kiểm tra là qua loopback ảo; qua mạng thật thường 200–500 µs, và lúc đó chênh lệch giữa một và hai round-trip (mục 4) tăng gấp đôi theo. Kết luận về đua tranh và biên cửa sổ thì không phụ thuộc phần cứng.
Ba ý mang về
- Kiểm tra giới hạn phải là một thao tác nguyên tử: bản đọc-rồi-ghi cho qua 50/50 khi giới hạn là 20 — đúng lúc lưu lượng dồn cục, tức đúng lúc cần nó. Một script Lua là cách rẻ nhất để có điều đó trên Redis, và còn nhanh hơn (một round-trip).
- Fixed window lọt gấp đôi ở biên cửa sổ (200 trong 0,6 giây với giới hạn 100/10 giây). Token bucket hoặc sliding window chặn đúng; chọn token bucket nếu cần bộ nhớ hằng số và cho phép bùng nổ ngắn.
- Trả
Retry-AftervàRateLimit-Remaining, rồi mong client đọc chúng: client tôn trọngRetry-Aftergửi 104 request thay vì 52.853 để lấy cùng 100 kết quả trong cùng 4 giây. Nhưng server vẫn phải tự bảo vệ mình trước client không đọc — giới hạn trong ứng dụng cần một tầng chặn rẻ hơn phía trước.
Nguồn
- IETF draft — RateLimit header fields for HTTP: https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/
- RFC 6585 — Additional HTTP Status Codes, mục 4 429 Too Many Requests: https://www.rfc-editor.org/rfc/rfc6585#section-4
- RFC 9110 — Retry-After: https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3
- Redis docs — Scripting with Lua (tính nguyên tử của EVAL): https://redis.io/docs/latest/develop/interact/programmability/eval-intro/
- Stripe — Scaling your API with rate limiters: https://stripe.com/blog/rate-limiters
- Cloudflare — How we built rate limiting capable of scaling to millions of domains (sliding window counter): https://blog.cloudflare.com/counting-things-a-lot-of-different-things/
Phần sau quay lại hình dạng dữ liệu: N+1 qua HTTP — client gọi một danh sách rồi gọi thêm N lần cho từng phần tử — đo thật nó tốn bao nhiêu, và hai cách chữa: cho client chọn trường, và endpoint bulk.