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ở

Ảnh chụp đoạn mã nền tối token bucket trên Redis một script Lua và header báo chờ với Go net/http go-redis v9 Redis 7 bucket dung lượng cap nạp rate token mỗi giây, kiểm tra giới hạn một lệnh nguyên tử không có khoảng hở đọc ghi gồm script Lua lấy key cap rate now từ KEYS và ARGV, HMGET key tokens ts, tokens bằng giá trị đọc được hoặc cap nếu chưa có khoá tức bucket đầy, ts bằng giá trị đọc được hoặc now, tokens bằng min cap và tokens cộng now trừ ts nhân rate tức nạp token theo thời gian trôi qua, allowed bằng 0, nếu tokens lớn hơn hoặc bằng 1 thì trừ 1 và allowed bằng 1, HSET key tokens ts now, PEXPIRE key theo cap chia rate nhân 1000 cộng 1000, wait bằng 0 và nếu không allowed thì wait bằng 1 trừ tokens chia rate tức còn bao lâu nữa mới có token, trả về allowed floor tokens wait, server trả header chuẩn 429 kèm Retry-After và thân problem+json gồm ok con wait bằng bucketLua rl cộng clientID 20 20 tức 20 token nạp 20 mỗi giây, đặt RateLimit-Limit 20 và RateLimit-Remaining con, nếu không ok thì đặt Retry-After bằng wait làm tròn lên theo giây, WriteHeader 429 và ghi thân type /loi/qua-tan-suat title Quá giới hạn tần suất status 429 retry_after_ms rồi return, bản sai hay gặp cùng thuật toán nhưng hai lệnh rời nhau gồm HMGet đọc rồi tính tokens và ok rồi HSet ghi với ghi chú 50 goroutine cùng đọc 20 cùng ghi 19

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í

Ảnh chụp output thật nền tối phớt lờ 429 52.853 request để lấy 100 thành công với Go 1.23 go-redis v9 Redis 7 server net/http client Go đo thật, mục 1 bucket 20 token 50 request đến cùng lúc bao nhiêu được qua cho thấy đọc rồi ghi HMGET cộng HSET lần 1 cho qua 20 trên 50 lần 2 50 trên 50 lần 3 50 trên 50 với ghi chú bỏ qua giới hạn còn Lua nguyên tử một EVALSHA cả ba lần đều 20 trên 50, mục 2 giới hạn 100 trên 10 giây gửi 100 ngay trước biên cửa sổ 100 ngay sau cho thấy fixed window INCR theo cửa sổ 100 lọt trước biên cộng 100 lọt sau biên bằng 200 trong 0.6 giây còn sliding log ZSET 10 giây gần nhất 100 lọt trước biên cộng 0 lọt sau biên bằng 100 trong 0.6 giây, mục 3 server 20 request mỗi giây client cần 100 request thành công gửi bao nhiêu cho thấy phớt lờ 429 gửi lại ngay gửi 52853 nhận 429 52753 lần thời gian 4.00 giây request thừa 52753, retry cố định 50 ms gửi 175 nhận 429 75 lần 4.05 giây thừa 75, tôn trọng Retry-After gửi 104 nhận 429 4 lần 4.04 giây thừa 4 với ghi chú ít hơn 500 lần, mục 4 chi phí một lần kiểm tra 10.000 lần tuần tự qua mạng container cho thấy Lua token bucket p50 43 µs p99 92 µs 1 round-trip, fixed window INCR cộng EXPIRE p50 77 µs p99 97 µs 2 round-trip rẻ nhất lại chậm nhất, sliding log ZSET pipeline 4 lệnh p50 41 µs p99 50 µs 1 round-trip, nói thẳng ba client đều mất đúng 4 giây tốc độ do server quyết không phải do client gửi nhanh hơn khác biệt duy nhất là 52.753 request vô ích mà server vẫn phải nhận phân tích và từ chối

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ân problem+json có thêm retry_after_ms cho 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ề

  1. 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).
  2. 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.
  3. Trả Retry-After và RateLimit-Remaining, rồi mong client đọc chúng: client tôn trọng Retry-After gử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

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.