Rate limiter không phải bài toán chọn thuật toán: một buổi phỏng vấn đào mãi không đáy

Mình vào phỏng vấn architect, được hỏi "thiết kế một rate limiter". Mình bắt đầu kể tên thuật toán — và nhận ra người phỏng vấn chẳng quan tâm mình chọn cái nào. Mỗi câu trả lời chỉ mở ra một câu hỏi sâu hơn: chạy trên hai mươi server thì đếm ở đâu, lấy đồng hồ của ai, Redis chết thì cho qua hay chặn, và cuối cùng — rate limiting có phải là tất cả không. Thuật toán là câu trả lời mười phút; phần còn lại mới là buổi phỏng vấn.

$ git log --oneline --stat
✍️ author: duthaho 📅 date: 05/07/2026 ⏱️ read: ...
designing-a-rate-limiter.md readonly

"Thiết kế cho mình một rate limiter đi." Câu hỏi mở màn buổi phỏng vấn architect nghe dễ tới mức mình mừng thầm. Mình biết mấy thuật toán này mà — token bucket, sliding window, mình nhớ hết. Thế là mình bắt đầu kể tên, thao thao.

Được một lúc thì mình nhận ra: người ngồi đối diện chẳng buồn ghi lại tên thuật toán nào. Cứ mỗi lần mình tưởng đã trả lời xong, họ lại hỏi thêm một câu — và câu sau bao giờ cũng sâu hơn câu trước. Buổi phỏng vấn không đi ngang qua danh sách thuật toán; nó đi xuống, mỗi lần một tầng.

Người phỏng vấn không hỏi mình chọn thuật toán nào. Họ hỏi mình hiểu tới đâu — và mỗi câu trả lời của mình chỉ là cái cửa mở ra một câu hỏi khó hơn.

Về nhà mình ngồi xâu chuỗi lại cả cái cầu thang câu hỏi đó, vì hoá ra nó chính là bản đồ để phân biệt "biết thuật toán" với "thiết kế được hệ thống". Mình kể lại theo đúng thứ tự nó đã đào xuống.

Tầng 0: rate limiter để làm gì?

Trước khi hỏi làm thế nào, câu đầu tiên là để làm gì — và mình suýt bỏ qua nó vì tưởng hiển nhiên. Nhưng trả lời được cái "tại sao" mới lộ ra là có mấy động cơ rất khác nhau nấp chung dưới một cái tên:

  • Chống quá tải — đừng để một client (hay một bug retry điên loạn) làm ngộp cả hệ thống.
  • Chống lạm dụng — chặn scraping, brute-force, spam.
  • Công bằng — một người dùng tham lam không được nuốt hết tài nguyên của phần còn lại.
  • Kiểm soát chi phí — mỗi request là tiền (compute, gọi API bên thứ ba); có trần thì có hoá đơn dự đoán được.

Nghe thì gom làm một, nhưng bốn động cơ này kéo thiết kế về bốn hướng khác nhau — và chính chúng quyết định các câu trả lời ở những tầng dưới. Giữ chúng trong đầu.

Tầng 1: "Bạn biết những thuật toán nào?"

Đây là tầng mình tự tin nhất, và cũng là tầng người phỏng vấn dành ít thời gian nhất. Có năm cái tên kinh điển, xếp thành một phổ đánh đổi giữa chính xáctốn tài nguyên:

Phổ thuật toán rate limiting — từ rẻ-mà-thô đến chính xác-mà-nặng Fixed Window đếm theo ô thời gian cố định rẻ nhất, dính "burst biên" Sliding Window Log lưu timestamp của từng request chính xác tuyệt đối, ngốn RAM Sliding Window Ctr nội suy hai ô kề nhau gần đúng, đủ tốt — Cloudflare Token Bucket tích token, CHO PHÉP burst EC2 / API Gateway / Stripe Leaky Bucket rò ra đều, LÀM PHẲNG traffic nginx limit_req

Fixed Window đơn giản nhất: một biến đếm cho mỗi ô thời gian (100 request / phút). Rẻ, nhưng có cái bẫy kinh điển gọi là burst biên. Figma có ví dụ số học sạch nhất: giới hạn 5 request/phút, khách bắn 5 request lúc 11:00:59, rồi 5 request nữa lúc 11:01:00 — ô phút này qua, ô phút kia tính riêng — và bạn vừa cho qua 10 request trong chưa đầy một giây, gấp đôi giới hạn.

Burst biên: hai ô cạnh nhau, giới hạn 5/phút 11:00:59 ▉▉▉▉▉ 5 req → ô phút 11:00 vừa đủ trần 11:01:00 ▉▉▉▉▉ 5 req → ô phút 11:01 là ô MỚI, lại vừa đủ trần ───────────────────────────────────────────── 10 req trong <1 giây — gấp ĐÔI giới hạn, mà luật nào cũng "đúng"

Sliding Window Log chữa triệt để: lưu timestamp của từng request, đếm đúng những cái nằm trong cửa sổ trượt. Chính xác tuyệt đối — và tốn bộ nhớ tuyệt đối, vì key nào nóng thì log dài theo. Sliding Window Counter là đường trung dung, và là chỗ mình học được nhiều nhất: thay vì lưu từng timestamp, nó giữ hai biến đếm của ô hiện tại và ô trước, rồi nội suy.

Cloudflare tả rất rõ trong bài về cách họ scale rate limiting tới hàng triệu domain. Giả sử giới hạn 50/phút, ô trước có 42 request, ô này có 18, và mình đang ở giây thứ 15 của ô hiện tại. Ước lượng: 42 × (45/60) + 18 = 42 × 0,75 + 18 = 49,5 — vẫn dưới trần, cho qua. Điểm mình thích là Cloudflare thẳng thắn nói đây chỉ là xấp xỉ (nó giả định request trong ô trước rải đều). Và họ đưa số để chứng minh xấp xỉ đó đủ tốt: trên 400 triệu request từ 270.000 nguồn, chỉ 0,003% bị cho qua/chặn sai, sai lệch trung bình so với con số thật khoảng 6%, không một false positive nào. Rẻ hơn log rất nhiều mà sai số nằm trong mức chấp nhận được.

Figma đi đúng con đường sliding-window-counter này và công bố luôn chi phí: họ chẻ cửa sổ thành nhiều ô nhỏ bằng 1/60 kích thước giới hạn, lưu trong một Redis hash. Với 10.000 user, 60 ô, mỗi ô 4 byte — tốn vỏn vẹn khoảng 2,4 MB. Đáng chú ý: họ chủ động không dùng Lua script "để khỏi rước thêm một ngôn ngữ vào codebase" — một lời nhắc rằng giải pháp gọn nhất không phải lúc nào cũng là giải pháp fancy nhất.

Còn Token BucketLeaky Bucket là hai mô hình "dòng chảy". Token bucket tích token đều đặn vào một cái xô có sức chứa cố định; có token thì đi, hết thì chặn — nên nó cho phép burst tới bằng sức chứa xô (token dồn lại lúc rảnh). Đây là lựa chọn của rất nhiều hệ thống lớn: Amazon EC2 API, AWS API Gateway (mặc định 10.000 request/giây steady, xô burst 5.000), và Stripe. Leaky bucket thì ngược lại: request xếp hàng, rò ra ở một nhịp cố định, nên output luôn phẳng — nginx limit_req ghi thẳng trong doc là dùng "phương pháp leaky bucket". Chọn cái nào? Nếu muốn nuốt được burst ngắn thì token bucket; nếu muốn bảo vệ downstream bằng một dòng đều tăm tắp thì leaky bucket.

Hỏi tiếp: mình kể xong năm cái tên, thấy khá ổn. Người phỏng vấn gật gù rồi hỏi: "Ừ. Giờ bạn deploy service này trên hai mươi cái server thì cái biến đếm đó nằm ở đâu?" Và cả buổi phỏng vấn rẽ hướng từ đúng câu đó.

Tầng 2: hai mươi server, đếm ở đâu?

Đây là ranh giới thật giữa "biết thuật toán" và "thiết kế hệ thống". Mọi thuật toán ở trên đều giả định có một biến đếm. Nhưng production chạy hai mươi instance sau load balancer. Nếu mỗi instance đếm riêng trong RAM của nó, thì giới hạn "100/phút" của bạn thực chất thành 2.000/phút. Biến đếm phải nằm ở một chỗ dùng chung — thường là Redis. Và khoảnh khắc nó thành dùng-chung, ba cái bẫy hiện ra.

01
Atomicity — "kiểm tra rồi tăng" không phải một bước

Bản năng đầu tiên là: đọc counter, so với trần, nếu còn chỗ thì tăng lên. Nhưng ba thao tác đó tách rời, và hàng nghìn server chạy đồng thời sẽ chen vào giữa.

Race condition: đọc và tăng tách rời (counter = 99, trần = 100) T1 server A: GET counter → 99 T2 server B: GET counter → 99 ← cả hai cùng thấy "vẫn còn chỗ" T3 server A: INCR → 100 ✔ cho qua T4 server B: INCR → 101 ✔ cho qua ← lọt! vì check và incr không dính liền

Cách chữa gọn nhất tận dụng đúng cái tính chất mình từng viết trong bài về caching: Redis xử lý lệnh trên một luồng duy nhất, và khi chạy một Lua script nó chạy trọn vẹn đoạn đó, không cho lệnh nào chen ngang. Gói "đọc counter — so trần — quyết định — tăng" vào một script, cả cụm thành một bước không thể tách. Race condition biến mất, không cần khoá phân tán rườm rà. (Nếu ngại nhúng Lua, các module như redis-cell của Brandur Leach gói sẵn cả thuật toán GCRA vào một lệnh CL.THROTTLE nguyên tử — GCRA vốn ra đời từ mạng ATM, chỉ lưu đúng một mốc "thời điểm đến lý thuyết" cho mỗi key thay vì cả một đống counter.)

02
Clock — lấy đồng hồ của ai?

Thuật toán nào cũng cần "bây giờ là mấy giờ". Nếu mỗi app server dùng đồng hồ của chính nó, thì hai mươi cái đồng hồ lệch nhau vài trăm mili-giây là đủ để cửa sổ thời gian nhảy loạn, key hết hạn không thống nhất. Câu trả lời: đừng tin đồng hồ local. Lấy thời gian từ một nguồn duy nhất — chính con Redis đang giữ counter, qua lệnh TIME. Một đồng hồ cho tất cả, hết lệch.

03
Failure mode — Redis chết thì cho qua hay chặn?

Giờ counter nằm ở Redis, tức là Redis thành đường đi bắt buộc của mọi request. Nó chớp một cái thì sao? Hai lựa chọn, và không có đáp án đúng phổ quát — chỉ có lựa chọn có ý thức: fail-open (Redis chết thì cho request đi hết, ưu tiên còn sống, chấp nhận rủi ro quá tải) hay fail-close (chặn hết, ưu tiên bảo vệ, chấp nhận tự gây downtime).

Stripe kể thẳng là họ bọc lỗi để fail open: nếu Redis sập thì request không bị ảnh hưởng, vì với họ rate limiter là lớp bảo vệ, không phải đường sống. Envoy để field failure_mode_deny mặc định là false — cũng là fail-open. Xu hướng chung là fail-open, trừ khi cái bạn đang giới hạn mang tính bảo mật (chống brute-force chẳng hạn) thì mới fail-close. Điểm mấu chốt người phỏng vấn muốn nghe không phải bạn chọn cái nào, mà là bạn biết mình đang chọn.

Hỏi tiếp: "Ok, giả sử bạn quyết định chặn một request. Bạn trả về cho client cái gì?" Mình định nói "trả lỗi" rồi khựng lại — lỗi gì, kèm thông tin gì?

Tầng 3: chặn rồi thì nói với client thế nào?

Tầng này tưởng nhỏ mà lộ ra ai từng vận hành API thật. Chặn không phải là đóng sầm cửa, mà là nói cho client biết chuyện gì đang xảy ra và khi nào quay lại:

  • HTTP 429 Too Many Requests — đúng status cho tình huống này, định nghĩa ở RFC 6585 (2012). Đừng trả 500, cũng đừng trả 403.
  • Header Retry-After — nói thẳng "chờ chừng này giây rồi thử lại". Nó nằm trong RFC 9110 (bản hiện hành, thay cho RFC 7231 cũ). Cái header này quý ở chỗ nó biến client từ kẻ spam mù thành kẻ biết điều — retry đúng lúc thay vì dội liên hồi.
  • Nhóm header RateLimit — cho client biết còn bao nhiêu quota, reset khi nào. Thú vị là chuẩn IETF cho mấy header này (draft-ietf-httpapi-ratelimit-headers) tới giữa 2026 vẫn còn là draft, chưa lên RFC — nên ngoài đời phần lớn vẫn xài bộ X-RateLimit-* de-facto (GitHub là ví dụ quen thuộc), dù tiền tố X- về lý thuyết đã bị khuyến cáo bỏ từ lâu.

Chi tiết nhỏ nhưng nói lên nhiều: một rate limiter tử tế không chỉ chặn, nó cộng tác với client để cả hệ thống cùng hạ nhiệt.

Khoảnh khắc vỡ òa: rate limiting không phải là tất cả

Rồi tới câu làm mình đứng hình — câu mình nghĩ là đích thật của cả buổi: "Rate limiter bảo vệ bạn khỏi một client gửi quá nhiều. Thế còn khi tất cả client đều ngoan, mà tổng tải vẫn làm server sắp gục thì sao?"

Lúc đó mình mới thấy rate limiting chỉ là một công cụ trong một họ lớn hơn, và mình đã lẫn lộn chúng làm một:

Ba thứ hay bị gộp làm một

Rate limitingchủ động: đặt trần cho từng client/khoá, bất kể server đang khoẻ hay yếu. Load sheddingphản ứng: khi chính server thấy mình sắp quá tải, nó chủ động vứt bớt request — thường vứt theo mức ưu tiên, giữ cái quan trọng, bỏ cái có thể bỏ. Backpressure là truyền tín hiệu "chậm lại" ngược lên thượng nguồn để cả chuỗi ghìm nhịp. Ba tầng phòng thủ khác nhau, cho ba câu hỏi khác nhau — và một hệ thống nghiêm túc cần cả ba.

Sách Google SRE (chương "Handling Overload") mô tả rất đẹp cái ranh giới này. Rate limiting per-client không cứu bạn khi tổng tải vượt ngưỡng; lúc đó cần load shedding, thậm chí graceful degradation (giảm chất lượng mỗi response thay vì bỏ hẳn). Họ còn có công thức throttling phía client: khi backend bắt đầu từ chối, client tự giảm tốc theo tỉ lệ đại loại max(0, (requests − 2 × accepts) / (requests + 1)) — nghĩa là càng bị từ chối nhiều thì tự nguyện gửi càng ít, để không hùa nhau dí chết một server đang ngoi ngóp.

Chính lúc này mình nhớ lại bài blog của Stripe. Hoá ra họ không xây "một rate limiter", mà xây bốn lớp: một request rate limiter (trần request/giây mỗi user), một concurrent requests limiter (trần số request đang chạy đồng thời), rồi hai load shedder — một để dành phần fleet cho request quan trọng, một để cắt traffic ưu tiên thấp khi worker bắt đầu nghẽn. Hai cái đầu là rate limiting, hai cái sau là load shedding. Cùng một mục tiêu "đừng gục", nhưng đánh vào hai loại nguy hiểm hoàn toàn khác nhau.

Giống hệt cái cảm giác mình từng có khi nhận ra Dual Write Problem nấp dưới nhiều lớp vỏ: khi thấy được cái khung "chủ động chặn — phản ứng vứt — truyền tín hiệu ngược", mình bớt học vẹt từng công cụ, và bắt đầu nghĩ theo tầng phòng thủ. Đó mới là thứ người phỏng vấn dò tìm.


Thuật toán là câu trả lời mười phút

Ngồi ghép lại cả cầu thang câu hỏi, mình mới thấy trò chơi thật của buổi phỏng vấn. Tên thuật toán là phần dễ nhất — search năm phút là có. Cái người ta thật sự đo là bạn có thấy được những gì bên dưới nó không: đếm ở đâu khi có hai mươi server, atomic bằng cách nào, tin đồng hồ của ai, hạ tầng chết thì cho qua hay chặn, nói gì với client, và biết khi nào rate limiting là sai công cụ mà phải chuyển sang load shedding.

Người phỏng vấn không hỏi "bạn biết thuật toán nào", họ hỏi "bạn nhìn thấy được bao nhiêu tầng". Thiết kế một rate limiter không phải là chọn giữa token bucket và sliding window — mà là biết mọi câu trả lời đều mở ra một câu hỏi sâu hơn, và không dừng lại ở tầng đầu tiên.

Mình bước ra khỏi buổi phỏng vấn đó không nhớ nổi mình đã "đúng" được bao nhiêu câu. Nhưng mình nhớ rõ cái cảm giác mỗi lần bị hỏi sâu thêm một tầng — vừa toát mồ hôi, vừa thấy rõ khoảng cách giữa chỗ mình đang đứng và chỗ một architect thật sự đứng. Và lần này, khoảng cách đó mình nhìn thấy được. Thế là đủ để về nhà ngồi viết.

Đọc thêm

comments.md