Rate limit là gì thật sự — bài toán gốc, đặt ở tầng nào, và hợp đồng HTTP 429

Hỏi "rate limit là gì" thì ai cũng trả lời được: "giới hạn số request". Câu hỏi phỏng vấn thật nằm ở chỗ khác: giới hạn để bảo vệ cái gì, theo ai, ở tầng nào, và khi bị giới hạn thì client phải làm gì. Bài này dựng nền cho cả series bằng ví dụ xuyên suốt: shop Laravel (project laravel-livewire — trang chủ, danh mục, chi tiết sản phẩm, đăng nhập, giỏ hàng, thanh toán) có thêm một API cho đối tác (sàn liên kết, đơn vị vận chuyển) gọi vào.

Thuật ngữ dùng trong bài
rate limit (giới hạn số request của một key trong một khoảng thời gian) · quota (hạn mức tổng trong kỳ dài — ngày, tháng — thường gắn với gói cước) · concurrency limit (giới hạn số request đang xử lý cùng lúc, không quan tâm tốc độ) · load shedding (server tự bỏ bớt request khi chính nó quá tải, không quan tâm ai gửi) · backpressure (tín hiệu ngược bảo nơi gửi chậm lại) · throttling (làm chậm thay vì từ chối — nhiều nơi dùng lẫn với rate limit) · admission control (kiểm soát cho vào: quyết định nhận hay từ chối trước khi tốn tài nguyên) · goodput (phần request được xử lý thành công và kịp trước khi client bỏ đi) · fail-open / fail-closed (khi chính bộ limiter hỏng: cho qua hết / chặn hết) · WAF (Web Application Firewall — tường lửa tầng HTTP, thường ở CDN).
Kiểm chứng: đọc ngày 02/10/2026 — RFC 6585 §4 (429 Too Many Requests); RFC 9110 §10.2.3 (Retry-After); draft IETF draft-ietf-httpapi-ratelimit-headers-11 (23/05/2026, đang là Internet-Draft); Stripe "Scaling your API with rate limiters" (2017); AWS Builders' Library "Using load shedding to avoid overload" (David Yanacek); Google SRE Book ch. 21 "Handling Overload"; GitHub Docs "Rate limits for the REST API"; Discord Developer Docs "Rate Limits"; nginx docs ngx_http_limit_req_module; source Laravel 12.64 (ThrottleRequests, Foundation\Http\Kernel::$middlewarePriority). Lab chạy thật dùng trong bài: header của Laravel 12.64 (out/05-laravel.txt), mã lỗi mặc định của nginx 1.31.3 (out/05-nginx.txt), php-fpm quá tải (out/04-capacity.run1.txt, run2.txt, run3.txt), Redis chết khi throttle đang dùng (out/05-redis-down.txt).

Trả lời 30 giây

Rate limit là admission control theo danh tính: trước khi tốn tài nguyên cho một request, hỏi "key này còn ngân sách không?". Nó giải 5 bài toán khác nhau — bảo vệ sức chịu tải, công bằng giữa khách, chống lạm dụng (dò mật khẩu, cào giá), chặn chi phí (SMS, API trả phí), và bán gói cước — nên mỗi bài cần key, con số và tầng đặt riêng. Nó không tự bảo vệ được hệ thống: tổng hạn mức của mọi user luôn lớn hơn sức chịu tải, nên phải có thêm một giới hạn toàn hệ thống (load shedding hoặc concurrency limit) — lab php-fpm của tôi cho thấy không có lớp này, tải 1,5 lần là goodput sập từ 76 xuống 19/giây. Hợp đồng với client là HTTP 429 + Retry-After (không phải 503 — mà nginx mặc định trả 503), và khi chính limiter hỏng thì phải chọn fail-open hay fail-closed có chủ đích: Laravel mặc định thành fail-closed, Redis chết là mọi route có throttle trả 500.

1. Bài toán gốc và bối cảnh ra đời

Trong shop của ví dụ, "rate limit" thật ra là năm nhu cầu khác nhau:

Bài toánVí dụ trong shopĐếm theo (key)Hậu quả nếu thiếu
Bảo vệ sức chịu tảiĐợt flash sale, bot gửi dồnToàn hệ thốngphp-fpm hết worker, mọi khách đều timeout (bài 4)
Công bằng (noisy neighbor)Đối tác A đồng bộ 100 000 sản phẩm lúc 9h sángTheo đối tác / tenantĐối tác B và khách lẻ chậm theo
Chống lạm dụngDò mật khẩu, dò mã giảm giá, thử thẻ ở checkout, cào giáTheo tài khoản, theo IP, theo thẻMất tài khoản, mất tiền, lộ dữ liệu giá
Chi phíGửi OTP qua SMS, gọi API bản đồ/LLM trả tiền theo lượtTheo số điện thoại, theo userHoá đơn SMS tăng vọt (SMS pumping)
Hợp đồng / gói cướcAPI đối tác gói Free 60/phút, Pro 600/phútTheo API keyKhông bán được gói, không có cơ sở thu tiền

Ý tưởng không mới: mạng viễn thông đã "cảnh sát" lưu lượng (traffic policing) bằng leaky bucket từ thời mạng ATM (bài 2). Với HTTP, mã 429 Too Many Requests chỉ được chuẩn hoá năm 2012 trong RFC 6585 — trước đó mỗi API tự trả 403, 503 hay 400 theo ý mình, và client không biết nên chờ hay bỏ.

2. Mô hình tư duy cốt lõi

Rate limit = admission control theo danh tính. Mọi thiết kế trả lời bốn câu, mỗi câu là một bài trong series:

flowchart LR
  R["Request tới"] --> K["1. Của ai?
chọn key: IP, user, API key, tenant
(bài 5)"] K --> A["2. Đếm thế nào?
thuật toán
(bài 2)"] A --> N["3. Bao nhiêu là đủ?
con số, burst
(bài 4)"] N --> D{"Còn ngân sách?"} D -->|"còn"| OK["Xử lý"] D -->|"hết"| X["429 + Retry-After
(bài 6: client làm gì)"] S["4. Chạy ở đâu, dùng chung trạng thái thế nào?
(bài 3)"] -.-> A

Và phải tách bạch các "họ hàng" của nó — phỏng vấn hay hỏi đúng chỗ này:

Cơ chếQuyết định dựa trênCâu hỏi nó trả lờiVí dụ
Rate limitLịch sử request của client"Client này gửi quá nhanh chưa?"600 request/phút/API key
QuotaTổng dùng trong kỳ dài"Client này dùng hết gói tháng chưa?"100 000 request/tháng
Concurrency limitSố request đang chạy"Còn chỗ để xử lý thêm không?"Stripe: tối đa 20 request đang chạy/user
Load sheddingTrạng thái của chính server"Tôi có đang quá tải không?"Stripe giữ 20% hạ tầng cho request quan trọng
BackpressureTín hiệu từ nơi nhận"Bên nhận có theo kịp không?"Stream Node tạm dừng đọc khi buffer đầy
Circuit breakerTỉ lệ lỗi của dependency"Có nên ngừng gọi bên kia không?"Ngừng gọi API vận chuyển khi lỗi > 50%

Stripe dùng cả 4 loại đầu cùng lúc: request rate limiter, concurrent requests limiter, fleet usage load shedder, worker utilization load shedder — và nói thẳng "The request rate limiter is by far the most important one". Lý do cần cả bốn nằm ở mục 4.

Bốn tầng đặt limiter và loại traffic bị chặn ở mỗi tầng Sáu luồng traffic đi qua các tầng CDN hoặc WAF, nginx, middleware Laravel, nghiệp vụ, php-fpm và DB. Botnet bị chặn ở CDN. Một IP bắn 300 request mỗi giây bị nginx chặn. Đối tác vượt gói bị middleware Laravel trả 429. Kẻ dò mật khẩu một tài khoản, đổi IP liên tục, bị chặn ở tầng nghiệp vụ nhờ đếm lần sai theo tài khoản. Khi tổng tải vượt trần, nginx bỏ bớt bằng giới hạn toàn hệ thống. Khách thật đi tới DB. CDN / WAFtheo IP, mẫu nginxIP, toàn hệ thống Laraveluser, API key, route nghiệp vụtài khoản, hành động php-fpm + DBtài nguyên thật botnet 50k IP 1 IP: 300 req/s đối tác vượt gói dò mật khẩu 1 TKđổi IP liên tục tổng tải vượt trần khách thật ✕chặn trước khi tới máy bạn ✕429, rẻ, chưa chạm PHP ✕429 + header gói cước ✕khoá theo tài khoản ✕bỏ phần vượt trần, nhanh 200
Hình 1 — Mỗi tầng biết một loại danh tính khác nhau và từ chối với chi phí khác nhau. Càng ra ngoài càng rẻ nhưng càng "mù": nginx chỉ thấy IP, không biết user hay API key; tầng nghiệp vụ biết "đây là lần đăng nhập sai thứ 4 của tài khoản này" nhưng tới đó request đã tốn một worker PHP. AWS gọi đúng tên mâu thuẫn này: "Early rejection is important because it's the cheapest place to drop excess traffic, but it comes at a cost to visibility."

3. Internals và quyết định thiết kế

3.1 Một quyết định rate limit gồm những bước nào

sequenceDiagram
  participant C as Client
  participant M as Middleware limiter
  participant S as Kho trạng thái (Redis)
  participant A as Controller
  C->>M: request
  M->>M: xác định key (IP / user / API key)
  M->>S: đọc + cập nhật bộ đếm (1 lệnh nguyên tử)
  S-->>M: còn 3 lượt / hết, chờ 12 giây
  alt còn ngân sách
    M->>A: cho đi tiếp
    A-->>C: 200 + header còn lại bao nhiêu
  else hết
    M-->>C: 429 + Retry-After: 12
  end

3.2 Hợp đồng HTTP: 429, Retry-After, header còn lại bao nhiêu

Laravel 12.64 trả gì — lab, limiter 5/phút theo user (out/05-laravel.txt):

lần 1: 200  X-RateLimit-Limit: 5 | X-RateLimit-Remaining: 4
…
lần 5: 200  X-RateLimit-Limit: 5 | X-RateLimit-Remaining: 0
lần 6: 429  X-RateLimit-Limit: 5 | X-RateLimit-Remaining: 0 | Retry-After: 60 | X-RateLimit-Reset: 1790925860

Còn nginx — lab với limit_req không khai báo limit_req_status (out/05-nginx.txt):

2 request cách nhau 50ms → 200, 503   (rate=10r/s nghĩa là 1 request mỗi 100ms, không phải "10 request trong 1 giây")
10 request cùng lúc       → 200×1 503×9

nginx mặc định trả 503 (docs: Default: limit_req_status 503;) và không gửi Retry-After. 503 nghĩa là "server hỏng": dashboard báo lỗi server, load balancer/CDN có thể coi upstream là chết, client SDK retry theo kiểu lỗi server. Luôn đặt limit_req_status 429;.

3.3 Đặt ở tầng nào

TầngBiết danh tính gìChi phí từ chốiHợp choKhông hợp cho
CDN / WAFIP, header, mẫu requestGần như 0 với bạnBotnet, DDoS khối lượng, cào giá thôLogic theo user/gói cước
nginx / LB / gatewayIP, API key (nếu gateway hiểu)Rất thấp, chưa chạm PHPGiới hạn toàn hệ thống, chặn 1 IP dồn dậpKey cần tra DB (gói của tenant)
Middleware appUser, API key, gói cước, routeThấp (1 lệnh Redis)Gói cước đối tác, giới hạn theo routeLũ request khổng lồ (đã tốn worker)
Nghiệp vụTài khoản, số thẻ, số điện thoại, mã couponĐã tốn 1 request đầy đủĐăng nhập sai, OTP, thử thẻ, đổi couponBảo vệ sức chịu tải

4. Đảm bảo và giới hạn

Goodput khi tải vượt trần: có và không có giới hạn toàn hệ thống Trục ngang là tải gửi vào từ 40 tới 160 request mỗi giây, trục dọc là goodput. Không có giới hạn: goodput tăng tới khoảng 76 rồi sập còn 19 ở 120 và 14 ở 160. Có rate limit toàn hệ thống 70 mỗi giây: goodput giữ quanh 70 ở 160. php-fpm 4 worker × 50ms — goodput theo tải gửi vào (lab bài 4, lần chạy 2) 040607080 120160tải gửi vào (request/giây) 04070 80 = trần lý thuyết không limit: 120 rps → goodput 19,4 160 rps → 13,8 rate limit 70/s: 160 rps → goodput 70,9
Hình 2 — Không có giới hạn toàn hệ thống, vượt trần một chút là hàng đợi dài ra, request chờ quá timeout 2 giây, client bỏ đi nhưng php-fpm vẫn xử lý nốt: lần chạy 2 ở 160 rps, php-fpm chạy xong 3 200 request mà client chỉ nhận được 276. Đây là "congestion collapse" mà AWS mô tả: "all the progress that the server made so far on that request goes to waste". Chấm tròn là điểm đo thật; đường xanh nét đứt chỉ nối từ 70 rps tới điểm đo duy nhất ở 160 rps. Chi tiết, và vì sao vùng sát trần dao động giữa các lần chạy: bài 4.

5. Failure modes

Sự cốBiểu hiệnBằng chứngChữa
Kho trạng thái (Redis) chếtLaravel: mọi route có throttle trả 500 ngay — limiter vô tình thành fail-closedLab: HTTP 500 sau 0.0 giây | RedisException: Connection refusedBọc limiter, fail-open có cảnh báo (bài 3)
Redis treo (không chết hẳn)Mỗi request kẹt tới default_socket_timeout = 60 giây → hết worker php-fpmLab: HTTP 500 sau 60.1 giây | read error on connectionĐặt read_timeout ngắn cho kết nối Redis của limiter
Không có giới hạn toàn hệ thốngQuá tải 1,5× → goodput sập, server làm việc vô íchLab bài 4: 2 012 request làm phí ở 120 rpsLoad shedding / concurrency limit ở nginx
Trả 503 thay vì 429Dashboard báo lỗi server, client retry sai kiểuLab nginx: 200×1 503×9limit_req_status 429
Chặn nhầm (false positive)Cả văn phòng/trường học sau NAT bị chặnBài 5 (key theo IP)Key theo user khi đã đăng nhập; ngưỡng IP rộng
Giới hạn nhân theo số instance2 nginx → gấp đôi giới hạnLab nginx bài 5: 12 thay vì 6Trạng thái dùng chung, hoặc chia hạn mức
Client retry dồnĐỉnh tải đúng lúc hết hạn chờLab bài 6: 441 request/100msJitter, giới hạn số lần retry

6. Vận hành trong production

7. So sánh và ma trận quyết định

Bài toánCơ chế chínhTầngKey
Bảo vệ sức chịu tảiConcurrency limit / load shedding toàn hệ thốngnginx, gateway, LBKhông theo client (toàn server)
Công bằng giữa khách/đối tácRate limit token bucket/GCRAApp hoặc gatewayTenant / API key
Chống lạm dụng thao tác nhạy cảmĐếm lần sai, sliding logNghiệp vụTài khoản + IP, số điện thoại, thẻ
Chặn chi phíQuota + rate limitNghiệp vụSố điện thoại, user
Gói cướcQuota tháng + rate limit phútApp / gatewayAPI key
Botnet, DDoSWAF, chặn ở mạngCDNIP, mẫu request

Dùng rate limit khi biết rõ danh tính và cần công bằng, chống lạm dụng, hay bán gói. Đừng chỉ dùng rate limit khi mục tiêu là giữ hệ thống sống dưới tải — phải có thêm lớp toàn hệ thống; và đừng coi nó là chống DDoS.

8. Góc nhìn tech lead: tác động tổ chức

9. Anti-patterns và bài học thực tế

  1. "Có rate limit rồi là chống được DDoS." Không: 429 vẫn tốn tài nguyên (RFC 6585), và tầng app thì request đã vào tới server.
  2. Một giới hạn per-IP cho mọi thứ. Quá rộng cho đăng nhập, quá chặt cho văn phòng sau NAT, vô dụng với botnet.
  3. Đặt limiter sau phần việc đắt. Kiểm tra giới hạn trong controller sau khi đã query DB là đã trả giá rồi.
  4. Trả 503/500 thay vì 429, không có Retry-After. nginx mặc định 503 (lab).
  5. Fail-closed vô tình. Redis của limiter chết → toàn site 500 (lab Laravel). Stripe chủ ý làm ngược lại: "Make sure that if there were bugs in the rate limiting code (or if Redis were to go down), requests wouldn't be affected."
  6. Con số chép từ blog. Không đo capacity thì không biết 70/giây là an toàn hay là con số làm sập hệ thống (bài 4).
  7. Cache response 429 ở CDN. RFC cấm (MUST NOT be stored by a cache); cache nhầm là chặn luôn cả người dùng khác.

Bài học công khai: Discord ghi rõ ngưỡng "invalid request": quá 10 000 request lỗi (401, 403, 429) trong 10 phút thì IP bị chặn tạm thời ở Cloudflare — client không tôn trọng 429 sẽ tự làm mình bị cấm. GitHub có cả giới hạn phụ (secondary rate limit): không quá 100 request đồng thời, không quá 900 điểm/phút cho REST (GET 1 điểm, POST/PATCH/PUT/DELETE 5 điểm), trả 403 hoặc 429 — tức là kết hợp rate limit với concurrency limit và chi phí theo loại request, đúng như mô hình ở mục 2.

10. Câu hỏi phụ interviewer hay hỏi tiếp

Rate limit khác load shedding thế nào?
Rate limit nhìn lịch sử của client ("anh gửi nhiều quá"); load shedding nhìn trạng thái của server ("tôi đang quá tải"). Cần cả hai: client ngoan vẫn có thể cùng lúc làm sập server.
Vì sao 429 chứ không phải 503?
429 là lỗi phía client, kèm Retry-After để client chờ đúng; 503 là lỗi server, làm LB/CDN/monitoring hiểu sai. nginx mặc định 503 — phải đổi.
Đặt rate limit ở đâu?
Nhiều tầng: CDN cho botnet, nginx cho giới hạn toàn hệ thống và IP dồn dập, app cho gói cước/route, nghiệp vụ cho thao tác nhạy cảm theo tài khoản.
Limiter hỏng thì sao?
Chọn có chủ đích: endpoint thường fail-open + cảnh báo; endpoint chống lạm dụng (OTP, đăng nhập) có thể fail-closed. Mặc định của Laravel là 500 — tức fail-closed vô tình.
Rate limit có giúp chống brute-force không?
Có nếu key đúng: đếm lần sai theo tài khoản (và theo IP), không phải đếm request theo IP — kẻ tấn công đổi IP liên tục (bài 5).
Header nào nên trả?
Tối thiểu 429 + Retry-After. Thêm header còn lại/thời điểm đặt lại (đang dùng X-RateLimit-*; theo dõi draft IETF RateLimit-Policy / RateLimit).

Phụ lục: lab đã chạy

Môi trường: Laravel 12.64 + PHP 8.3.33 (project laravel-livewire mount read-only), Redis 8.8.0 tạm; nginx 1.31.3; php-fpm 8.3 (php:8.3-fpm-alpine), 4 worker. Mọi container tên tmp-rl-* trên network riêng tmp-rl-net.

cd labs/rate-limit
./05-laravel.sh semantics   # header của throttle (mục A) → out/05-laravel.txt
./05-laravel.sh down        # Redis tắt / treo khi throttle đang dùng → out/05-redis-down.txt
./05-nginx.sh               # limit_req mặc định 503 và các bẫy khác → out/05-nginx.txt
SUFFIX=.run4 ./04-capacity.sh   # php-fpm quá tải, có/không giới hạn toàn hệ thống → out/04-capacity.run4.txt (đã có run1–3)

Chưa verify: hành vi rate limit của CDN/WAF thương mại; số liệu Stripe, GitHub, Discord (trích tài liệu công khai đọc ngày 02/10/2026).