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 IETFdraft-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 docsngx_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án | Ví 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ồn | Toàn hệ thống | php-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áng | Theo đối tác / tenant | Đối tác B và khách lẻ chậm theo |
| Chống lạm dụng | Dò 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ượt | Theo số điện thoại, theo user | Hoá đơn SMS tăng vọt (SMS pumping) |
| Hợp đồng / gói cước | API đối tác gói Free 60/phút, Pro 600/phút | Theo API key | Khô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ỏ.
- Tối ưu cho: từ chối rẻ và sớm theo một danh tính đã biết (IP, user, API key).
- Không tối ưu cho: chống DDoS khối lượng lớn (phải chặn ở mạng/CDN trước khi tới máy bạn); đảm bảo độ trễ khi backend chậm đi (một con số cố định không biết DB đang chậm — bài 4); tính tiền chính xác từng request.
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ên | Câu hỏi nó trả lời | Ví dụ |
|---|---|---|---|
| Rate limit | Lịch sử request của client | "Client này gửi quá nhanh chưa?" | 600 request/phút/API key |
| Quota | Tổ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 limit | Số request đang chạy | "Còn chỗ để xử lý thêm không?" | Stripe: tối đa 20 request đang chạy/user |
| Load shedding | Trạ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 |
| Backpressure | Tí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 breaker | Tỉ 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.
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
- Từ chối phải rẻ hơn phục vụ. Google SRE nhắc: "those requests still consume some resources" — limiter đặt sau một câu SQL nặng là
vô nghĩa. Trong Laravel,
throttlechạy trước controller; thứ tự giữa các middleware do$middlewarePriorityquyết định — trong Laravel 12.64 danh sách là… AuthenticatesRequests, ThrottleRequests, ThrottleRequestsWithRedis, AuthenticatesSessions, SubstituteBindings, Authorize: throttle chạy sau xác thực (nên key theo user được) và trước route model binding (chưa tốn câu SQL nạp model). - Đọc và ghi bộ đếm phải là một thao tác nguyên tử. Đọc rồi mới ghi là race (bài 3: 200 request lọt với giới hạn 10).
- Kho trạng thái phải dùng chung giữa các server, nếu không giới hạn bị nhân với số server (lab nginx bài 5: 2 instance → 12 thay vì 6).
3.2 Hợp đồng HTTP: 429, Retry-After, header còn lại bao nhiêu
- RFC 6585 §4: "The 429 status code indicates that the user has sent too many requests in a given amount of time"; response "MAY include a Retry-After header"; và "Responses with the 429 status code MUST NOT be stored by a cache." RFC cố ý không quy định đếm theo ai: "on a per-resource basis, across the entire server, or even among a set of servers".
- RFC 9110 §10.2.3:
Retry-After = HTTP-date / delay-seconds, và "A delay-seconds value is a non-negative decimal integer" — chỉ số nguyên giây. Hệ quả ở bài 6: mọi client bị chặn trong cùng một giây nhận cùng một con số và quay lại cùng lúc. - Header "còn bao nhiêu": chưa có chuẩn chính thức. Các bên tự đặt
X-RateLimit-*(GitHub, Discord, Laravel). IETF đang soạnRateLimit-Policy+RateLimit(draft 11, 23/05/2026), ví dụ trong draft:RateLimit-Policy: "burst";q=100;w=60,"daily";q=1000;w=86400.
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ầng | Biết danh tính gì | Chi phí từ chối | Hợp cho | Không hợp cho |
|---|---|---|---|---|
| CDN / WAF | IP, header, mẫu request | Gần như 0 với bạn | Botnet, DDoS khối lượng, cào giá thô | Logic theo user/gói cước |
| nginx / LB / gateway | IP, API key (nếu gateway hiểu) | Rất thấp, chưa chạm PHP | Giới hạn toàn hệ thống, chặn 1 IP dồn dập | Key cần tra DB (gói của tenant) |
| Middleware app | User, API key, gói cước, route | Thấp (1 lệnh Redis) | Gói cước đối tác, giới hạn theo route | Lũ 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 coupon | Bảo vệ sức chịu tải |
4. Đảm bảo và giới hạn
- Tổng hạn mức theo user không bảo vệ được hệ thống. Dữ liệu giả lập ở bài 4: 2 000 khách × 120 request/phút = 4 000 request/giây "được phép" về lý thuyết, trong khi một máy php-fpm 4 worker chỉ chịu ~76/giây. Giới hạn theo user giải bài toán công bằng và lạm dụng; giới hạn toàn hệ thống mới giải bài toán sức chịu tải.
- Rate limit cố định không biết backend đang chậm. Lab bài 4: rate limit 70/giây đủ an toàn khi mỗi request mất 50ms, nhưng khi DB chậm thành 100ms thì cũng con số đó làm goodput sập còn ~7/giây. Concurrency limit thì tự co theo (Little's law: tốc độ = số chỗ ÷ thời gian xử lý).
- Không chống được DDoS khối lượng. RFC 6585 tự cảnh báo: "responding to each with a 429 status code will consume resources" — khi bị dội thật, cắt kết nối ở tầng mạng/CDN rẻ hơn trả 429.
- Chỉ chính xác bằng danh tính. Key theo IP sai khi sau NAT (cả văn phòng chung 1 IP), sau proxy cấu hình sai, hoặc với IPv6 (1 thuê bao có cả dải /64) — bài 5.
- Chạy nhiều server thì chỉ gần đúng — race, lệch đồng hồ, failover mất bộ đếm (bài 3).
5. Failure modes
| Sự cố | Biểu hiện | Bằng chứng | Chữa |
|---|---|---|---|
| Kho trạng thái (Redis) chết | Laravel: mọi route có throttle trả 500 ngay — limiter vô tình thành fail-closed | Lab: HTTP 500 sau 0.0 giây | RedisException: Connection refused | Bọ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-fpm | Lab: 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ống | Quá tải 1,5× → goodput sập, server làm việc vô ích | Lab bài 4: 2 012 request làm phí ở 120 rps | Load shedding / concurrency limit ở nginx |
| Trả 503 thay vì 429 | Dashboard báo lỗi server, client retry sai kiểu | Lab nginx: 200×1 503×9 | limit_req_status 429 |
| Chặn nhầm (false positive) | Cả văn phòng/trường học sau NAT bị chặn | Bà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ố instance | 2 nginx → gấp đôi giới hạn | Lab nginx bài 5: 12 thay vì 6 | Trạ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/100ms | Jitter, giới hạn số lần retry |
6. Vận hành trong production
- Metric tối thiểu: số 429 theo route × lý do (limiter nào) × loại client; tỉ lệ client chạm 80% hạn mức ("near limit" — Envoy ratelimit
mặc định 0,8); độ trễ của chính limiter (thêm 1 vòng Redis mỗi request); lỗi kết nối Redis của limiter; với nginx: số dòng
limiting requeststrong error log. - Cấu hình là code: con số nằm trong repo, đổi qua review, có lịch sử. Nhưng cần một cần gạt khẩn cấp (biến môi trường, feature flag) để nới cho một đối tác lúc 3h sáng mà không phải deploy.
- Ra mắt an toàn: Stripe khuyên "Dark launch each rate limiter to watch the traffic they would block" — chạy chế độ chỉ ghi log
trước (nginx có
limit_req_dry_run), xem ai sẽ bị chặn, rồi mới bật thật (bài 6). - Tài liệu cho đối tác: giới hạn từng gói, header trả về, phải làm gì khi gặp 429. GitHub ghi thẳng: "Continuing to make requests while you are rate limited may result in the banning of your integration."
7. So sánh và ma trận quyết định
| Bài toán | Cơ chế chính | Tầng | Key |
|---|---|---|---|
| Bảo vệ sức chịu tải | Concurrency limit / load shedding toàn hệ thống | nginx, gateway, LB | Không theo client (toàn server) |
| Công bằng giữa khách/đối tác | Rate limit token bucket/GCRA | App hoặc gateway | Tenant / API key |
| Chống lạm dụng thao tác nhạy cảm | Đếm lần sai, sliding log | Nghiệp vụ | Tài khoản + IP, số điện thoại, thẻ |
| Chặn chi phí | Quota + rate limit | Nghiệp vụ | Số điện thoại, user |
| Gói cước | Quota tháng + rate limit phút | App / gateway | API key |
| Botnet, DDoS | WAF, chặn ở mạng | CDN | IP, 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
- Phân vai rõ: nhóm platform/hạ tầng giữ lớp CDN + nginx + giới hạn toàn hệ thống; nhóm sản phẩm giữ giới hạn nghiệp vụ (đăng nhập, OTP, coupon) và gói cước. Không ai "giữ" thì sẽ có 3 limiter chồng nhau, không ai biết khách bị chặn ở tầng nào.
- Một mục trong checklist review cho mọi endpoint mới: endpoint này đắt không (DB, API trả phí)? có dùng để dò được không? key là gì? con số lấy từ đâu? Khi bị 429 client thấy gì?
- Khung ADR: Bối cảnh (bài toán nào trong 5 bài toán) → Quyết định (tầng, key, thuật toán, con số, fail-open/closed) → Hệ quả (ai có thể bị chặn oan, chi phí Redis, cách nới khẩn cấp) → Cách đo (metric, ngưỡng cảnh báo).
- Vendor lock-in và exit strategy: rule rate limit của CDN viết bằng ngôn ngữ riêng của từng hãng. Giữ phần logic nghiệp vụ (gói cước, đăng nhập) ở app, chỉ đẩy phần thô (IP, botnet) ra CDN — đổi CDN không phải viết lại luật kinh doanh.
9. Anti-patterns và bài học thực tế
- "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.
- 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.
- Đặ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.
- Trả 503/500 thay vì 429, không có
Retry-After. nginx mặc định 503 (lab). - 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."
- 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).
- 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ùngX-RateLimit-*; theo dõi draft IETFRateLimit-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).