Bài 12 kết thúc bằng bốn cách để một hệ bất đồng bộ nói không, và hẹn rằng cách cuối cùng — giảm tốc chính người gửi — là nội dung của bài này. Rate limiting là cơ chế để hệ thống từ chối có kiểm soát, thay vì nhận hết rồi vỡ, hoặc nhận hết rồi hứa một tồn đọng sáu tiếng.

Lab của bài này chạy cả bốn thuật toán rate limit thật trên Redis, mỗi thuật toán là một script Lua. Ba phép đo đáng chú ý. Thứ nhất, với cùng hạn mức 100 request/giây, fixed window cho lọt 200 request trong một giây ở đúng mốc giao cửa sổ — gấp đôi hạn mức, và con số này lặp lại y hệt qua cả ba lần chạy. Thứ hai, viết limiter bằng INCR rồi EXPIRE ở hai lệnh riêng làm 20 trên 200 khoá mắc kẹt không có TTL — tức 20 người dùng bị chặn vĩnh viễn. Thứ ba, bốn thuật toán chênh nhau không đáng kể về tốc độ (p50 đều quanh 0,08 ms), nhưng chênh nhau hơn 10 lần về bộ nhớ.

ℹ️ Cấu hình máy đo
Apple M1 Max, 10 lõi, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Container native arm64: Redis 7-alpine và Node v22 (node:22-alpine), mỗi container giới hạn 1 CPU, không dependency — bốn thuật toán nằm trong worker/ratelimit.js, nạp vào Redis bằng SCRIPT LOAD rồi gọi bằng EVALSHA.

Một lưu ý quan trọng khi đọc mọi số độ trễ trong bài: Redis ở đây nằm cùng máy với app, nối qua mạng nội bộ của Docker, nên một vòng mạng chỉ tốn khoảng 0,07 ms. Redis thật trong production thường nằm khác máy, có khi khác vùng sẵn sàng, và một vòng mạng ở đó là 1–2 ms — tức đắt hơn 15–30 lần. Kết luận về tỉ lệ giữa các thuật toán vẫn đúng, còn kết luận "limiter gần như miễn phí" thì chỉ đúng trong điều kiện đo này.

13.1 Rate limit bảo vệ bạn khỏi ai

Câu trả lời quen thuộc là "khỏi kẻ tấn công". Câu trả lời đó không sai nhưng nó dẫn người ta đặt rate limit sai chỗ. Trong phần lớn sự cố thật, thứ đánh sập hệ thống không phải người lạ mà là chính client của bạn: một ứng dụng di động vừa ra bản mới có vòng retry không jitter, một job cron của đội khác chạy lúc 0 giờ, hoặc một service nội bộ đang thử lại điên cuồng vì service phía sau nó chậm. Người lạ thì bị chặn ở tầng khác (WAF — web application firewall, tường lửa lọc lưu lượng độc hại trước khi nó vào tới app — và chống DDoS); còn client của chính bạn thì có khoá API hợp lệ và đi thẳng vào đường chính.

Ba khái niệm hay bị gộp làm một, trong khi chúng phục vụ ba mục đích khác hẳn nhau và thường được cài ở ba chỗ khác nhau:

Khái niệm Mục đích Ai quyết định con số Vượt ngưỡng thì sao
Rate limit Bảo vệ hệ thống khỏi nhận nhiều hơn khả năng xử lý Kỹ thuật — suy từ năng lực thật đo được (Bài 2) Từ chối ngay, 429
Quota Phân bổ tài nguyên theo gói cước Thương mại — nằm trong hợp đồng Từ chối hoặc tính tiền thêm, thường theo tháng
Throttle Làm phẳng đầu ra thay vì từ chối Kỹ thuật — theo năng lực của bên nhận Xếp hàng và chậm lại, không mất request

Khác biệt đáng nhớ nhất nằm ở cột cuối. Rate limit nói không; throttle nói chờ. Throttle nghe tử tế hơn, và đó chính là cái bẫy: mỗi request bị làm chậm vẫn đang chiếm một kết nối, một luồng, một chỗ trong hàng đợi. Ở Bài 1 ta đã thấy điều gì xảy ra khi hàng đợi dâng — throttle không xoá tải, nó chỉ đổi tải thành độ trễ. Khi thật sự quá tải, từ chối nhanh là hành động tử tế hơn.

Đặt limiter ở đâu — và chỗ nào hay bị bỏ quên nhất client đang retry limiter ① ở biên gateway service A limiter ② ? thường KHÔNG có service B ① CÓ: chặn được lưu lượng từ ngoài vào Nhưng nó KHÔNG chặn được lưu lượng sinh ra bên trong: service A retry service B, job nền, migration dữ liệu. ② THIẾU: đây là nơi cascade failure thật sự bắt đầu (Bài 17) B chậm → A retry → B chậm hơn → A retry nhiều hơn. Vòng lặp này nằm HOÀN TOÀN sau limiter ở biên, nên limiter ở biên không nhìn thấy gì cả — biểu đồ lưu lượng vào vẫn phẳng trong khi hệ thống đang cháy.
Limiter ở biên đo lưu lượng đi vào. Cascade failure sinh ra lưu lượng bên trong — hai thứ đó không nhìn thấy nhau.
⚠️ Cạm bẫy: chỉ đặt rate limit ở biên
Đây là cấu hình mặc định gần như ở mọi nơi, vì nó là cấu hình dễ nhất: một limiter ở API gateway, xong. Vấn đề là hầu hết sự cố quá tải nội bộ không đi qua biên. Khi service B chậm và service A thử lại ba lần cho mỗi request, lưu lượng tới B tăng gấp bốn mà lưu lượng vào biên không đổi một chút nào — biểu đồ bạn đang nhìn vẫn phẳng.

Câu hỏi kiểm tra rẻ nhất: nếu một service nội bộ đột nhiên gọi bạn gấp mười lần, có gì chặn nó lại không? Nếu câu trả lời là "chắc là không ai làm thế đâu", thì bạn chưa có câu trả lời — bạn đang có một giả định.

13.2 Bốn thuật toán, và cái giá thật của từng cái

Bốn thuật toán dưới đây trả lời cùng một câu hỏi — "request này có được đi qua không" — nhưng chúng định nghĩa chữ "một giây" theo bốn cách khác nhau, và toàn bộ khác biệt nằm ở đó.

Thuật toán Cách hiểu "một giây" Lưu gì trong Redis Điểm yếu
Fixed window Từ giây thứ N đến giây thứ N+1 theo đồng hồ tuyệt đối Một số nguyên cho mỗi (người dùng × cửa sổ) Bộ đếm reset đột ngột ở mốc giao cửa sổ → burst gấp đôi
Sliding window log 1000 ms vừa qua tính từ lúc này Dấu thời gian của từng request (ZSET) Bộ nhớ tỉ lệ với số request được cho qua
Sliding window counter Xấp xỉ: phần còn lại của cửa sổ trước + cửa sổ này Hai số nguyên Là ước lượng — giả định lưu lượng rải đều trong cửa sổ trước
Token bucket Không có cửa sổ nào cả — token rót đều theo thời gian Số token + dấu thời gian lần cập nhật cuối Cho phép burst bằng đúng sức chứa của xô

Điểm yếu của fixed window được nhắc tới ở khắp nơi, nhưng gần như luôn dưới dạng lời kể. Lab của bài này dựng đúng kịch bản đó và đếm: hạn mức 100 request mỗi 1000 ms, bắn 100 request ngay trước mốc giao cửa sổ rồi 100 request ngay sau mốc đó, và đo con số duy nhất mà server phía sau thật sự quan tâm — số request lọt qua nhiều nhất trong một cửa sổ trượt 1 giây bất kỳ.

Hạn mức 100/giây — số lọt qua thật trong một giây tệ nhất (số đo lab) mốc giao cửa sổ fixed window 100 qua 100 qua → 200 trong 1 giây (200% hạn mức) sliding window log 100 qua 0 qua → 100 (100% — chính xác tuyệt đối) sliding window counter 100 qua 5 → 105 (105% — xấp xỉ, rẻ như fixed) token bucket 100 qua 19 → 119 (119% — burst CÓ CHẶN TRÊN) Khác biệt then chốt: 200% của fixed window đến từ chỗ bộ đếm reset đột ngột — không cấu hình nào làm nó nhỏ đi. 119% của token bucket đến từ sức chứa của xô — hạ sức chứa xuống là hạ burst xuống, đổi lại mất khả năng hấp thụ đỉnh ngắn.
Số đo thật, lặp lại ba lần: fixed 200/200/200 · log 100/100/100 · counter 105/104/105 · token 119/118/117.

Con số 200% của fixed window không phải trường hợp xấu hiếm gặp — nó xảy ra mỗi lần lưu lượng dồn quanh mốc giao cửa sổ, và mốc đó là một thời điểm cố định trong ngày nên rất dễ bị dồn: các job cron đều hẹn giờ tròn, các client retry đều làm tròn về giây. Điều tệ hơn là không có tham số nào chỉnh được: 2× là hệ quả trực tiếp của việc bộ đếm reset đột ngột.

Điểm cần nói cho đúng: token bucket cũng cho burst — 119% trong phép đo trên, chứ không phải 100%. Nhiều bài viết trình bày token bucket như thể nó "không có vấn đề burst", điều đó không đúng. Khác biệt thật nằm ở chỗ burst của token bucket có chặn trên và chỉnh được: nó tối đa bằng sức chứa của xô, nên nếu bạn cần đầu ra phẳng thì hạ sức chứa xuống dưới tốc độ rót. Còn burst của fixed window là 2× bất kể bạn cấu hình thế nào.

Vậy tại sao không dùng sliding window log cho mọi thứ, khi nó chính xác tuyệt đối? Câu trả lời không nằm ở tốc độ mà nằm ở bộ nhớ. Đo 20.000 lời gọi cho mỗi thuật toán, cả bốn cho ra thời gian gần như y hệt nhau — vì thứ tốn thời gian là vòng mạng tới Redis chứ không phải phép tính bên trong:

Thuật toán p50 (ms) p99 (ms) Lời gọi/giây Bộ nhớ mỗi người dùng
mốc so sánh: một lệnh GET rỗng 0,073 0,142 12.682
Fixed window 0,082 0,148 11.527 121 B
Sliding window log 0,084 0,141 11.223 1.499 B
Sliding window counter 0,083 0,143 11.442 136 B
Token bucket 0,084 0,148 11.274 171 B

Bốn cột đầu gần như không phân biệt được; cột cuối chênh nhau hơn 10 lần. Đó là vì sliding window log phải nhớ dấu thời gian của từng request còn hiệu lực, nên bộ nhớ của nó tỉ lệ với hạn mức chứ không phải với số người dùng: hạn mức 100 tốn 1.499 byte mỗi người, hạn mức 10.000 sẽ tốn gấp trăm lần. Với một triệu người dùng hoạt động, đó là khác biệt giữa vài trăm megabyte và vài chục gigabyte — tức khác biệt giữa "một node Redis" và "một cụm Redis phải vận hành".

💡 Chọn thế nào cho nhanh
Token bucket là mặc định đúng cho API công khai: cho phép burst ngắn (người dùng thật bấm nhanh vài cái là chuyện bình thường) nhưng burst có chặn trên. Sliding window counter khi bạn muốn đầu ra phẳng và không cần chính xác tuyệt đối. Sliding window log chỉ khi con số phải chính xác vì nó nằm trong hợp đồng — và khi đó hãy tính trước hoá đơn bộ nhớ. Fixed window khi hạn mức rộng đến mức vượt gấp đôi cũng không sao (ví dụ "1000 request/giờ cho mỗi khoá API"), lúc đó sự đơn giản của nó là ưu điểm thật.

13.3 Trong hệ phân tán, bộ đếm phải là nguyên tử

Bài 3 đã cho hệ thống ba app replica, và điều đó làm hỏng ngay cách viết limiter tự nhiên nhất: giữ bộ đếm trong bộ nhớ tiến trình. Với ba replica, mỗi replica đếm riêng, và hạn mức thật trở thành ba lần hạn mức bạn cấu hình. Bộ đếm phải nằm ở chỗ dùng chung — nhưng chỉ dùng chung thôi chưa đủ.

Đây là cách viết limiter phổ biến nhất trên mạng, và nó có một lỗi nghiêm trọng:

cách viết phổ biến — và sai
// WRONG: two separate commands, with a gap in between
const n = await redis.incr(key);
if (n === 1) {
  await redis.expire(key, 60);   // (!) if the process dies BEFORE this line...
}
if (n > limit) return deny();

Khoảng trống giữa INCREXPIRE nhỏ, nhưng nó có thật. Nếu tiến trình chết đúng lúc ấy — deploy, OOM kill, container bị siết CPU rồi bị thanh lý — thì khoá đó tồn tại vĩnh viễn không có TTL. Bộ đếm không bao giờ reset, và người dùng đó bị chặn mãi mãi cho tới khi có người vào xoá khoá bằng tay. Lab tái tạo đúng chuyện này với 200 người dùng, "chết" ở 1 trên 10 lần:

kết quả đo — ROLE=nonatomic
{
  "role": "nonatomic",
  "crashEvery": 10,
  "report": [
    { "mode": "two-commands", "keyCount": 200, "keysWithoutTtl": 20,
      "consequence": "20 users blocked FOREVER" },
    { "mode": "lua", "keyCount": 200, "keysWithoutTtl": 0,
      "consequence": "no keys stuck" }
  ]
}

Cách sửa là gộp toàn bộ "đọc — quyết định — ghi" vào một script Lua. Redis chạy trọn một script như một lệnh đơn, nên hoặc cả hai lệnh cùng chạy, hoặc không lệnh nào chạy. Đây là bản token bucket thật đang chạy trong lab:

worker/ratelimit.js — script Lua của token bucket
-- A bucket of capacity `cap`, refilled steadily at `cap` per `win`.
-- There are no window boundaries at all, so there is no cliff to exploit. Bursts are
-- ALLOWED but capped by exactly the bucket capacity — the difference from fixed window.
--
-- The time comes from redis.call('TIME'), NOT from the client: many app instances call
-- the same limiter and their clocks differ. If the client sent `now`, a machine running
-- 200 ms fast would move itself into the next window early.
local cap  = tonumber(ARGV[1])
local win  = tonumber(ARGV[2])
local k    = KEYS[1]
local t    = redis.call('TIME')
local now  = t[1] * 1000 + math.floor(t[2] / 1000)
local d    = redis.call('HMGET', k, 'tokens', 'ts')
local tokens = tonumber(d[1])
local ts     = tonumber(d[2])
if tokens == nil then
  tokens = cap
  ts = now
end
local delta = now - ts
if delta < 0 then delta = 0 end
tokens = math.min(cap, tokens + delta * cap / win)
local ttl = win * 2
if tokens < 1 then
  redis.call('HSET', k, 'tokens', tokens, 'ts', now)
  redis.call('PEXPIRE', k, ttl)
  return '0|0|' .. math.ceil((1 - tokens) * win / cap)
end
tokens = tokens - 1
redis.call('HSET', k, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', k, ttl)
return '1|' .. math.floor(tokens) .. '|0'

Bản này chạy trên cả ba app replica cùng lúc trong lab, sau load balancer, với hạn mức 500 request/giây toàn cục. Bắn 20 kết nối trong 10 giây:

Chỉ số Giá trị đo được Đối chiếu lý thuyết
Request được cho qua (200) 5.493 500 (xô đầy lúc bắt đầu) + 500/giây × 10 giây = 5.500
Request bị từ chối (429) 185.660
Throughput thật được phục vụ 549,4 req/s Đúng hạn mức, dù ba replica không hề nói chuyện với nhau

Sai lệch 7 trên 5.500 — tức 0,13%. Đó là bằng chứng đo được rằng bộ đếm nguyên tử dùng chung thật sự giữ được hạn mức toàn cục, chứ không phải hạn mức nhân với số replica.

🔬 Vì sao script gọi redis.call('TIME')
Tài liệu Redis cũ (trước 5.0) cấm hẳn việc này: hồi đó script được nhân bản sang replica nguyên văn, nên một script gọi TIME sẽ cho kết quả khác nhau ở primary và replica, làm dữ liệu hai bên lệch nhau. Từ Redis 5.0, chế độ mặc định đổi sang nhân bản hiệu ứng (chỉ các lệnh ghi thực tế được truyền đi), nên gọi TIME trong script là an toàn.

Vì sao chi tiết này đáng bận tâm: lựa chọn thay thế là để mỗi app gửi đồng hồ của chính nó vào script. Với ba replica chạy trên ba máy, đó là ba đồng hồ khác nhau — và bạn vừa đưa clock drift vào đúng thành phần cần một nguồn thời gian duy nhất. Đây là phiên bản nhẹ hơn của vấn đề đã gặp ở Bài 10.

Còn một quyết định nữa quan trọng không kém thuật toán: giới hạn theo chiều nào. Theo người dùng đã đăng nhập là chính xác nhất nhưng không bảo vệ được endpoint đăng nhập (lúc đó chưa có người dùng nào). Theo IP thì bảo vệ được endpoint công khai nhưng đụng NAT. Theo endpoint thì bảo vệ được đúng chỗ đắt tiền. Thực tế bạn cần vài chiều cùng lúc, và mỗi chiều là một khoá Redis riêng.

⚠️ Cạm bẫy: giới hạn theo IP khi client nằm sau NAT
NAT (network address translation) là cơ chế cho nhiều máy trong một mạng nội bộ cùng đi ra Internet qua một địa chỉ IP công cộng — và nó phổ biến hơn bạn nghĩ. Một công ty, một trường học, một nhà mạng di động — tất cả có thể đi ra Internet bằng một địa chỉ IP. Đặt hạn mức 100 request/phút theo IP nghĩa là toàn bộ nhân viên của công ty đó chia nhau 100 request, và khi hết, tất cả cùng bị chặn. Từ phía bạn nhìn vào thì đó là "một client hung hăng"; từ phía họ thì đó là "dịch vụ này hỏng".

Tệ hơn, IPv6 lật ngược vấn đề: một máy khách có thể có sẵn cả một dải /64 địa chỉ và đổi địa chỉ sau mỗi request, nên giới hạn theo IPv6 đơn lẻ gần như vô dụng — phải giới hạn theo prefix. Và nhớ lại Bài 4: nếu bạn lấy IP từ X-Forwarded-For mà không đếm đúng số chặng proxy tin cậy, kẻ tấn công tự đặt header đó và vượt qua limiter một cách hợp lệ.

13.4 Từ chối cho đúng cách

Từ chối là một hành động giao tiếp. Client bị từ chối sẽ làm gì đó, và cái nó làm phụ thuộc hoàn toàn vào những gì bạn nói với nó. Đây là phản hồi thật của lab khi xô đã cạn:

phản hồi 429 thật của lab
$ curl -si "localhost:3001/limited?user=drain2"

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 500          # your quota
X-RateLimit-Remaining: 0        # how much is left
X-RateLimit-Reset: 1            # how long until it refills (seconds)
Retry-After: 1                  # WAIT EXACTLY THIS MANY SECONDS
Content-Type: application/json; charset=utf-8

{"error":"rate_limited","retryAfterMs":2}

Bốn header đó là hợp đồng. Retry-After nói cho client biết chính xác khi nào quay lại — không có nó, client chỉ còn cách đoán, và cách đoán phổ biến nhất là thử lại ngay lập tức, đúng thứ bạn đang cố ngăn. Ba header X-RateLimit-* còn cho phép client tự điều tiết trước khi chạm hạn mức: một SDK tử tế thấy Remaining: 3 thì tự chậm lại thay vì lao vào tường.

Phản hồi thật ở trên phơi bày một chi tiết đáng chú ý: thời gian chờ thật là 2 mili giây (token bucket rót 500 token mỗi giây nên token kế tiếp có sau 2 ms), nhưng Retry-After tính bằng giây nên giá trị nhỏ nhất nó nói được là 1 — chậm hơn 500 lần so với sự thật. Đó là lý do phần thân phản hồi kèm thêm retryAfterMs: header giữ đúng chuẩn HTTP cho client phổ thông, còn client hiểu API của bạn thì đọc con số chính xác. Nếu chỉ có header, mọi client bị từ chối sẽ cùng chờ tròn 1 giây rồi cùng quay lại một lúc — bạn vừa tự tạo ra một đợt đồng bộ hoá, đúng vấn đề mà jitter ở Bài 17 sinh ra để giải.

⚠️ Cạm bẫy: trả 500 hoặc 503 khi vượt hạn mức
Đây là lỗi phổ biến đến mức đáng ngạc nhiên, thường vì limiter được cài như một exception handler chung. Hậu quả rất cụ thể: mọi thư viện HTTP client đều coi 5xxlỗi tạm thời của server và thử lại ngay — thường kèm cả retry tự động mà lập trình viên bên đó không biết là đang bật. Bạn vừa nói với client "server tôi đang trục trặc, thử lại đi" trong khi ý bạn là "bạn đang gửi quá nhanh, hãy chậm lại".

429 nói rõ lỗi nằm ở phía client (lớp 4xx) và kèm hạn chờ. Khác biệt giữa hai mã này là khác biệt giữa hệ thống tự ổn định lại và hệ thống tự khuếch đại tải lên chính mình — Bài 12 đã gọi tên hiện tượng đó, và Bài 17 sẽ đo nó.

Một câu hỏi thường bị bỏ qua: limiter tốn bao nhiêu? Nó nằm trên đường đi của mọi request, kể cả request được cho qua, nên chi phí của nó cộng thẳng vào p99 của toàn hệ thống. Đo bằng cách so cùng một endpoint có và không có limiter, đặt hạn mức cao đến mức không request nào bị từ chối (bốn cặp lần chạy, 20 kết nối, 10 giây mỗi lần):

Endpoint Throughput p50 p95 p99
/fast — không limiter 25.849 – 27.115 req/s 0,64 – 0,66 ms 1,26 – 1,32 ms 1,59 – 1,75 ms
/limited — có limiter 26.554 – 28.354 req/s 0,67 – 0,69 ms 1,04 – 1,16 ms 1,41 – 1,50 ms

p50 tăng khoảng 0,04 ms — đúng bằng một vòng mạng tới Redis, không có gì bất ngờ. Điều bất ngờ là p95 và p99 lại thấp hơn một cách nhất quán, ở cả bốn cặp lần chạy. Giải thích khả dĩ: /fast trả lời hoàn toàn đồng bộ nên nhiều request được xử lý dồn thành cụm trong một lượt event loop, còn /limited có một điểm nhường (await chờ Redis) chia nhỏ các cụm đó ra. Đây là giả thuyết chứ không phải kết luận đã chứng minh — lab này không đo trực tiếp độ trễ event loop, nên mình ghi lại đúng những gì đo được.

🚨 Đừng mang con số 0,04 ms này ra khỏi lab
Redis ở đây nằm cùng máy với app. Trong production, Redis thường nằm khác máy — đôi khi khác vùng sẵn sàng — và một vòng mạng ở đó là 1–2 ms. Với dịch vụ có p99 khoảng 20 ms, thêm 2 ms là thêm 10%; với dịch vụ có p99 khoảng 5 ms, đó là thêm 40%.

Đó là lý do các limiter hiệu năng cao thật sự thường dùng kiến trúc lai: đếm cục bộ trong bộ nhớ mỗi instance với một phần hạn mức được chia sẵn, chỉ đồng bộ với Redis định kỳ. Đổi lại là mất tính chính xác tuyệt đối — lại đúng cái đánh đổi ở Bài 9, lần này xuất hiện trong một bài toán rất cụ thể.

13.5 Load shedding: khi từ chối phải có chọn lọc

Rate limit trả lời câu hỏi "client này đã dùng quá phần của mình chưa". Load shedding trả lời một câu hỏi khác hẳn: "hệ thống đang quá tải, giờ bỏ cái gì". Câu thứ hai chỉ xuất hiện khi mọi client đều trong hạn mức mà tổng vẫn vượt năng lực — và lúc đó rate limit không giúp được gì, vì không ai vi phạm cả.

Số liệu từ phép đo toàn cục ở mục 13.3 cho thấy điều đầu tiên cần biết về từ chối: nó không miễn phí, nhưng nó rẻ hơn rất nhiều so với phục vụ. Trong 10 giây, lab phục vụ 5.493 request và từ chối 185.660 — tức nó xử lý được khoảng 18.500 lần từ chối mỗi giây, gấp 34 lần số request nó thật sự phục vụ. Đó là lý do "từ chối nhanh" là một chiến lược sống được: bạn vẫn trả một cái giá, nhưng cái giá đó nhỏ hơn một bậc độ lớn.

Nhưng cái giá đó chỉ nhỏ khi từ chối xảy ra sớm. Từ chối sau khi đã truy vấn database để kiểm tra hạn mức thì bạn đã trả gần hết chi phí rồi mới nói không. Nguyên tắc: limiter phải nằm trước mọi thứ đắt tiền — trước xác thực nếu có thể, chắc chắn là trước database.

Chỉ còn 40% năng lực — bỏ đều hay bỏ có ưu tiên? BỎ ĐỀU — giữ 40% của mỗi loại health check 40% thanh toán 40% xem sản phẩm 40% gợi ý cá nhân 40% 60% health check trượt → load balancer tưởng node chết → rút node ra → 40% năng lực còn lại thành 0. 60% thanh toán trượt → mất doanh thu thật, trong khi vẫn đang phục vụ 40% các gợi ý không ai cần lúc này. BỎ CÓ ƯU TIÊN — cùng tổng năng lực, phân bổ khác health check 100% thanh toán 100% xem sản phẩm 55% gợi ý cá nhân 0% Health check luôn qua → không bị rút node oan. Thanh toán luôn qua → không mất doanh thu. Thứ bị bỏ là thứ có thể sống thiếu: gợi ý cá nhân tắt hẳn, trang sản phẩm rơi về bản không cá nhân hoá. Điều kiện tiên quyết: mỗi request phải MANG THEO mức ưu tiên của nó — thứ phải thiết kế từ đầu.
Cùng một lượng tải bị bỏ, hai kết quả hoàn toàn khác nhau. Khác biệt duy nhất là thứ tự ưu tiên.

Chi tiết dễ bỏ sót nhất trong hình trên là dòng đầu: health check. Nếu load shedding bỏ cả health check thì load balancer kết luận node đã chết và rút nó ra khỏi vòng phục vụ (Bài 3), dồn tải sang các node còn lại, khiến chúng cũng bắt đầu shed, cũng trượt health check, cũng bị rút ra. Cơ chế sinh ra để cứu hệ thống trở thành cơ chế giết hệ thống. Đây là dạng cascade failure sẽ được mổ xẻ đầy đủ ở Bài 17.

⚠️ Cạm bẫy: shed đồng đều tất cả
Bỏ ngẫu nhiên 60% request nghe có vẻ công bằng, và nó dễ cài đặt đến mức thường được chọn mặc định. Nhưng "công bằng" ở đây nghĩa là bạn phá SLO (service level objective — mục tiêu chất lượng dịch vụ bạn tự cam kết, ví dụ "99,9% request thanh toán thành công"; sẽ nói kỹ ở Bài 16) của nhóm quan trọng nhất đúng bằng mức bạn phá SLO của nhóm ít quan trọng nhất. Người đang thanh toán bị mất giao dịch với xác suất y hệt người đang xem gợi ý.

Load shedding có ưu tiên đòi hỏi một thứ phải có từ trước khi sự cố xảy ra: mỗi request mang theo mức ưu tiên của nó (header, loại endpoint, phân hạng người dùng). Không thể thêm chiều thông tin đó vào lúc 3 giờ sáng khi hệ thống đang cháy. Nếu hôm nay bạn không phân loại được request nào quan trọng hơn request nào, thì phương án shed duy nhất bạn có là phương án tệ nhất.

Tự làm lại các phép đo

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab

# --- Window boundary: same 100/s limit, count the worst 1-second window ---
./tools/ratelimit-test.sh boundary    # fixed=200 · log=100 · counter=105 · token=119

# --- Time vs memory: the 4 algorithms are near-identical in speed... ---
./tools/ratelimit-test.sh bench       # p50 all ~0.08ms (empty GET baseline: 0.073ms)
# --- ...but differ more than 10-fold in memory ---
./tools/ratelimit-test.sh memory      # log 1499 B/user vs 121-171 B/user

# --- INCR + EXPIRE as two commands: keys stuck forever ---
./tools/ratelimit-test.sh nonatomic   # two-commands: 20/200 keys with no TTL · lua: 0/200

# --- What the limiter adds to every request ---
./tools/ratelimit-test.sh overhead    # /fast vs /limited: p50 +0.04ms

# --- 3 replicas behind the LB, a GLOBAL 500/s limit, 10 seconds ---
./tools/ratelimit-test.sh e2e 500     # 5493 allowed (expected 5500) · 185,660 x 429

Tóm lại

Rate limit chủ yếu bảo vệ bạn khỏi chính client của mình, không phải khỏi kẻ tấn công — nên đặt nó chỉ ở biên là bỏ trống đúng chỗ cascade failure bắt đầu.

Về thuật toán, phép đo cho một kết luận gọn: chọn thuật toán không phải chuyện tốc độ. Cả bốn đều cho p50 quanh 0,08 ms vì thứ tốn thời gian là vòng mạng chứ không phải phép tính. Khác biệt thật nằm ở hai chỗ khác — hành vi ở mốc giao cửa sổ (fixed window cho lọt 200% và không cấu hình nào sửa được) và bộ nhớ (sliding window log tốn 1.499 byte mỗi người dùng, gấp hơn 10 lần ba cái còn lại). Token bucket cũng cho burst, 119% chứ không phải 100% — nhưng burst của nó có chặn trên và chỉnh được, và đó mới là điểm khác biệt đáng nói.

Về cài đặt, một điều gần như không có ngoại lệ: gộp toàn bộ quyết định vào một script Lua. Bản INCR rồi EXPIRE tách rời làm 20 trên 200 khoá mắc kẹt không TTL trong phép đo — tức 20 người dùng bị chặn vĩnh viễn bởi chính cơ chế bảo vệ họ. Bù lại, bộ đếm nguyên tử dùng chung giữ được hạn mức toàn cục rất sát: 5.493 trên kỳ vọng 5.500 với ba replica không hề nói chuyện với nhau.

Và về cách từ chối: 429 kèm Retry-After, không bao giờ 5xx. Từ chối rẻ hơn phục vụ khoảng 34 lần trong lab này — nhưng chỉ khi nó xảy ra sớm, và chỉ khi nó có chọn lọc. Shed đồng đều là cách chắc chắn để phá SLO của nhóm quan trọng nhất.

Bài 14 đổi hướng: thay vì lưu trạng thái hiện tại rồi ghi đè liên tục, ta lưu chuỗi sự kiện đã xảy ra và tính lại trạng thái từ đó. Nó mở ra khả năng tua lại lịch sử để tìm lỗi — và đòi hỏi một thứ đã học ở Bài 11 làm điều kiện bắt buộc: tính idempotent.

📖 Tài liệu tham khảo

Tải mã nguồn thực hành

Cả bốn thuật toán ở mục 13.2 dưới dạng script Lua nguyên tử, cộng với các chế độ đo đã sinh ra mọi con số trong bài — biên cửa sổ, thời gian, bộ nhớ, khoá mắc kẹt, và chi phí thêm vào mỗi request:

Tải về ratelimit.js (4 thuật toán, Lua nguyên tử, 0 dependency)

Bài viết liên quan trong series

Bài 12: Message Queue & Xử Lý Bất Đồng Bộ Bài 14: Event Sourcing & CQRS Quay lại Lộ trình Thiết Kế Hệ Thống

Bình luận