Rate limit theo route, theo user, theo tenant hay cả hệ thống — thiết kế nhiều tầng và chọn key
Câu trả lời ngắn cho "rate limit cho từng route hay cả hệ thống?" là cả hai, và thêm vài tầng nữa — mỗi tầng một key, một ngân sách, một mục đích.
Câu trả lời dài là bài này: thiết kế các tầng cho shop của ví dụ (storefront, đăng nhập/OTP, thanh toán, API đối tác), và những chỗ framework làm khác điều bạn
nghĩ — middleware throttle của Laravel dùng chung bộ đếm giữa các route, thứ tự limit làm khách bị chặn oan, trustProxies('*') đổi nghĩa từ
Laravel 13, limit_req của nginx âm thầm mất tác dụng trong 3 trường hợp. Mọi điểm đều có lab.
Thuật ngữ dùng trong bài
phạm vi (scope) (cái được giới hạn: một route, một nhóm route, một hành động, cả hệ thống) · key (danh tính được đếm: IP, user id, API key, tenant, hằng số) · named limiter (limiter có tên trong Laravel:RateLimiter::for('tên', …)) · tenant (một khách hàng doanh nghiệp, có nhiều user bên trong) · NAT / CGNAT (nhiều thiết bị dùng chung một IP công khai — văn phòng, trường học, nhà mạng di động) · X-Forwarded-For (XFF) (header proxy ghi thêm IP của bên gửi tới nó) · trusted proxy (proxy mà app tin để đọc XFF) · credential stuffing (thử cặp email/mật khẩu bị lộ ở nơi khác, mỗi tài khoản vài lần, từ rất nhiều IP) · password spraying (thử vài mật khẩu phổ biến trên rất nhiều tài khoản) · noisy neighbor (một khách dùng quá nhiều làm khách khác chậm).
Kiểm chứng: Laravel docs 13.x (rate limiting, routing, requests/trusted proxies) qua Context7; source Laravel 12.64.0 và 13.34.0 (ThrottleRequests,ThrottleRequestsWithRedis,Cache\RateLimiting\Limit,Http\Middleware\TrustProxies, SymfonyRequest::getClientIps); GitHub laravel/framework PR #58707 (sửa issue #54386) và PR #60726; Livewire docs 4.x (security, installation) và source Livewire 4.3.3; MDN "X-Forwarded-For"; nginx docs (ngx_http_limit_req_module,ngx_http_realip_module); OWASP Authentication Cheat Sheet; Adam Pritchard "The scary state of IPv6 rate-limiting" (20/02/2022). Lab chạy thật: Laravel 12.64 (project laravel-livewire, read-only) và 13.34 (app tạm), Redis 8.8, nginx 1.31.3 —out/05-laravel.txt,05-laravel.laravel13.txt,05-nginx.txt,05-livewire.txt. Đọc ngày 02/10/2026.
Trả lời 30 giây
Mỗi limit là bộ ba (phạm vi, key, ngân sách), và request phải qua mọi limit áp lên nó. Shop cần ít nhất: một giới hạn toàn hệ thống ở nginx (concurrency, key hằng số) để sống sót; giới hạn theo API key/tenant cho đối tác theo gói; giới hạn theo user (đã đăng nhập) hoặc theo IP (khách vãng lai) cho công bằng; giới hạn theo route cho route đắt (search, export); và giới hạn theo hành động ở tầng nghiệp vụ cho đăng nhập, OTP, thanh toán — đếm lần sai theo tài khoản, không đếm request theo IP. Ba bẫy đã lab: trong Laravel, cùng một tên limiter hoặcthrottle:60,1trên nhiều route là một bộ đếm chung; nhiều limit trong một limiter thì request bị chặn ở limit sau vẫn ăn ngân sách limit trước (Laravel 12 — khách vô can bị 429; Laravel 13 sửa chothrottlenhưng chưa sửathrottleWithRedis); và key theo IP chỉ đúng khi cấu hình trusted proxy đúng —trustProxies(at: '*')của Laravel 13.34 lấy IP trái cùng trong X-Forwarded-For, thứ client tự điền được.
1. Bài toán gốc và bối cảnh ra đời
Một giới hạn duy nhất (kiểu "60 request/phút/IP cho mọi thứ") phải phục vụ cùng lúc năm bài toán ở bài 1 — và thất bại ở cả năm: quá rộng cho đăng nhập, quá chặt cho văn phòng sau NAT, vô dụng với botnet, không phân biệt được đối tác gói Pro với gói Free, không bảo vệ được hệ thống khi tổng request vượt sức chịu tải. RFC 6585 cố ý để ngỏ phạm vi: giới hạn "on a per-resource basis, across the entire server, or even among a set of servers" — chọn phạm vi là việc của người thiết kế.
Ba trục để mô tả bất kỳ limit nào: ai (key), cái gì (phạm vi: route, nhóm route, hành động, toàn bộ), bao nhiêu (ngân sách, có thể theo chi phí — bài 4).
2. Mô hình tư duy cốt lõi
Ngân sách lồng nhau: một request trừ vào nhiều ngân sách cùng lúc; ngân sách nào cạn trước thì chặn. Tầng ngoài bảo vệ hệ thống, tầng trong bảo vệ công bằng và chống lạm dụng.
GET /search của user 42 (thuộc đối tác ACME) bị chặn ở tầng route dù tenant, user và hệ thống còn ngân sách. Câu trả lời 429 nên nói
tầng nào chặn (header hoặc body), không thì đối tác không biết phải giảm loại request nào. Chú ý thứ tự kiểm tra: kiểm hết rồi mới trừ (mục 3.2).| Tầng | Key | Mục đích | Đặt ở | Bẫy chính |
|---|---|---|---|---|
| Toàn hệ thống | Hằng số ($server_name, chuỗi cố định) | Sống sót khi quá tải | nginx / gateway | Key rỗng thì nginx bỏ qua (mục 3.6) |
| Tenant / API key | API key → tenant | Gói cước, chống noisy neighbor | Gateway hoặc middleware | Cần tra gói từ DB → cache lại |
| User | User id; khách vãng lai: IP | Công bằng giữa khách | Middleware | IP sai do proxy, NAT, IPv6 |
| Route / nhóm route | Key của tầng user + tên route | Route đắt, route dễ bị cào | Middleware | Laravel dùng chung bộ đếm giữa các route (mục 3.1) |
| Hành động | Tài khoản, số điện thoại, thẻ, mã coupon | Chống dò, chống tốn tiền | Nghiệp vụ | Khoá theo tài khoản bị lợi dụng để khoá người khác |
3. Internals và quyết định thiết kế
3.1 Laravel: key thật sự của throttle là gì?
Đọc source Laravel 12.64 (ThrottleRequests): với named limiter, key là md5(tên limiter . $limit->key), trong đó $limit->key là giá trị
->by() — mặc định chuỗi rỗng. Với dạng throttle:60,1, key là prefix . sha1(user id), hoặc sha1(domain|IP) nếu là khách — không
có tên route. Ba hệ quả, đều chạy được trong lab (out/05-laravel.txt):
=== B. Quên ->by(): limit thành giới hạn CHUNG của mọi người === RateLimiter::for('lab-nokey', fn () => Limit::perMinute(3));
IP 198.51.100.1: 200 X-RateLimit-Limit: 3 | X-RateLimit-Remaining: 2
IP 198.51.100.1: 200 X-RateLimit-Limit: 3 | X-RateLimit-Remaining: 1
IP 198.51.100.1: 200 X-RateLimit-Limit: 3 | X-RateLimit-Remaining: 0
IP 198.51.100.2: 429 X-RateLimit-Limit: 3 | X-RateLimit-Remaining: 0 | Retry-After: 60 | X-RateLimit-Reset: 1790925860
IP 198.51.100.3: 429 …
=== C. throttle:60,1 và throttle:5,1 trên 2 route khác nhau — cùng 1 bộ đếm ===
user 7 xem /lab/browse 6 lần, rồi POST /lab/coupon (5/phút): 429 X-RateLimit-Limit: 5 | X-RateLimit-Remaining: 0 | Retry-After: 60
cùng user, POST /lab/coupon2 (throttle:5,1,coupon — có prefix): 200 X-RateLimit-Limit: 5 | X-RateLimit-Remaining: 4
=== D. Một named limiter gắn trên nhiều route = một ngân sách chung (kiểu 'storefront' của project) ===
/lab/c/1: 200 X-RateLimit-Remaining: 2
/lab/c/2: 200 X-RateLimit-Remaining: 1
/lab/p/1: 200 X-RateLimit-Remaining: 0
/lab/p/2: 429 …
- B — quên
->by(): limit của mọi người cộng lại. Docs Laravel dùng đúng dạng này có chủ đích (Limit::perMinute(500)chung cho đăng nhập), nên đây không phải bug — nhưng copy nhầm là cả site dùng chung 60 request/phút. - C —
throttle:60,1vàthrottle:5,1chung bộ đếm: user lướt sản phẩm 6 lần là hết lượt nhập coupon. Chữa: tham số thứ 3 là prefix (throttle:5,1,coupon), hoặc dùng named limiter. - D — một tên limiter trên nhiều route = một ngân sách: project thật dùng
throttle:storefront(120/phút theo IP) cho 3 route (danh mục, duyệt danh mục, chi tiết sản phẩm) — đó là "120 lượt xem storefront mỗi phút", không phải 120 cho mỗi route. Muốn tách theo route thì đưa tên route vàoby():->by($request->route()->getName() . '|' . $request->ip()).
3.2 Nhiều limit trong một limiter: thứ tự làm khách vô can bị chặn
Docs Laravel có ví dụ [Limit::perMinute(500), Limit::perMinute(3)->by($request->input('email'))] cho đăng nhập. Source 12.64 duyệt từng limit:
kiểm tra → tăng ngay → sang limit kế. Request bị limit thứ hai chặn đã kịp tăng limit thứ nhất. Lab: chung 10/phút, theo email 3/phút; kẻ tấn công dồn 10 lần
vào một email:
Laravel 12.64
ThrottleRequests, chung trước: kẻ tấn công 10 lần vào victim@ → 200 200 200 429 429 429 429 429 429 429
rồi khách khác (alice@, IP khác) đăng nhập → 429 X-RateLimit-Limit: 10 | X-RateLimit-Remaining: 0
ThrottleRequests, email trước: kẻ tấn công 10 lần vào victim@ → 200 200 200 429 429 429 429 429 429 429
rồi khách khác (alice@, IP khác) đăng nhập → 200 X-RateLimit-Limit: 3 | X-RateLimit-Remaining: 2
ThrottleRequestsWithRedis, chung trước: … → alice: 429
Laravel 13.34 (app tạm, cùng file lab)
ThrottleRequests, chung trước: … → alice: 200 ← đã sửa
ThrottleRequestsWithRedis, chung trước: … → alice: 429 ← CHƯA sửa
Issue #54386 (mở 28/01/2025, Laravel 11) mô tả đúng lỗi này; PR #58707 sửa bằng cách "splits the loop to check the limits first and then increment them",
merge 09/02/2026 vào nhánh 13.x — không có trên 12.x (đã đọc lịch sử commit 12.x). ThrottleRequestsWithRedis có vòng lặp riêng và vẫn giữ cách cũ ở 13.34
(đã diff source, lab xác nhận). Với Laravel 12, hoặc khi dùng throttleWithRedis(): đặt limit hẹp nhất (theo email) trước limit rộng.
->after() — chỉ đếm khi response thoả điều kiện (Laravel 12.x, "after" rate limiting, PR #57125): hợp với đăng nhập, chỉ đếm lần sai:
RateLimiter::for('login', fn (Request $r) => Limit::perMinute(3)->by($r->input('email'))
->after(fn ($response) => $response->getStatusCode() === 422));
right→200 right→200 right→200 right→200 wrong→422 wrong→422 wrong→422 right→429
4 lần đăng nhập đúng không tốn lượt; 3 lần sai thì khoá — kể cả lần sau nhập đúng. Đổi lại: khi tăng đếm sau response, khe race giữa kiểm tra và tăng rộng bằng cả thời gian xử lý request (bài 3).
3.3 Livewire: throttle trên route trang chỉ chặn lần tải trang đầu
Project thật gắn throttle:storefront lên các route trang Livewire. Nhưng sau lần tải đầu, mọi tương tác của component (lazy load, wire:click, phân trang)
đi vào một route khác. php artisan route:list trên project (read-only):
POST livewire-815fc5e4/update default-livewire.update › Livewire\Mechanisms › HandleRequests@handleUpdate
⇂ web
⇂ Livewire\Mechanisms\HandleRequests\RequireLivewireHeaders
GET|HEAD {name}-c.{category} .. category.show › App\Livewire\CategoryBrowse
⇂ web
⇂ Illuminate\Routing\Middleware\ThrottleRequests:storefront
Livewire có cơ chế "persistent middleware" chạy lại middleware của route gốc trên request update, nhưng danh sách mặc định (source 4.3.3 và docs 4.x) chỉ gồm
middleware xác thực/phân quyền/route binding — không có ThrottleRequests. Hệ quả: /livewire/update không bị giới hạn bởi
throttle:storefront. Docs 4.x cho cách gắn middleware lên chính route update:
Livewire::setUpdateRoute(function ($handle, $path) {
return Route::post($path, $handle)->middleware(['web', 'throttle:livewire']);
});
Chưa dựng lab gửi request update thật (cần snapshot hợp lệ); kết luận dựa trên route:list của project + source + docs. Cũng chưa kiểm
Livewire::addPersistentMiddleware() có so khớp được middleware có tham số (throttle:storefront) hay không.
3.4 Key theo IP: IP đó có đúng không?
Mọi limit theo IP phụ thuộc $request->ip(), và giá trị đó phụ thuộc cấu hình trusted proxy. Lab gọi middleware TrustProxies với từng cấu hình
(out/05-laravel.txt và 05-laravel.laravel13.txt):
| Tình huống | REMOTE_ADDR → X-Forwarded-For | Laravel 12.64 ip() | Laravel 13.34 ip() |
|---|---|---|---|
| 1. Không trust proxy nào, app sau LB | 10.0.1.5 → 1.2.3.4 | 10.0.1.5 (mọi khách chung 1 IP) | 10.0.1.5 |
2. at '*', kẻ tấn công gọi thẳng app | 198.51.100.66 → 1.2.3.4 | 1.2.3.4 (giả) | 1.2.3.4 (giả) |
3. at '*', qua nginx nối thêm IP thật | 10.0.1.5 → 1.2.3.4, 198.51.100.66 | 198.51.100.66 (đúng) | 1.2.3.4 (giả) |
4. at '*', CDN → LB → app | 10.0.1.5 → 203.0.113.50, 104.16.0.9 | 104.16.0.9 (IP của CDN) | 203.0.113.50 |
5. at [dải LB, dải CDN] | như 4 | 203.0.113.50 (đúng) | 203.0.113.50 (đúng) |
6. at [dải private] như project, container khác trong mạng tự đặt header | 172.18.0.9 → 1.2.3.4 | 1.2.3.4 | 1.2.3.4 |
Laravel 13 đổi nghĩa của at: '*'. Diff source 12.64 → 13.34: setTrustedProxyIpAddressesToTheCallingIp() đổi từ tin
[REMOTE_ADDR] sang tin ['0.0.0.0/0', '::/0'] — tức tin mọi địa chỉ, nên ip() lấy IP trái cùng của X-Forwarded-For. PR #60726 (merge
14/07/2026, nhánh 13.x) làm vậy để sửa ca nhiều proxy (dòng 4: 12.64 trả IP của CDN). Cái giá là dòng 3: client tự gửi
X-Forwarded-For: 1.2.3.4 qua một nginx nối thêm (append) là Laravel tin luôn. MDN nói thẳng: "Any security-related use of X-Forwarded-For (such
as for rate limiting or IP-based access control) must only use IP addresses added by a trusted proxy" và "Leftmost (untrusted) values must only be used for cases
where there is no negative impact from using spoofed values." — rate limit là trường hợp có tác động. Mỗi request đổi một IP giả là mỗi request một bộ đếm
mới: limit theo IP coi như không có.
'*'; và chặn bằng firewall
để app chỉ nhận kết nối từ proxy. Thêm một lớp: proxy ngoài cùng nên ghi đè X-Forwarded-For bằng IP nó thấy thay vì nối thêm. Bài scale
nginx + php-fpm có thêm ca LB làm mất IP thật.Ở nginx, module realip làm việc tương tự: set_real_ip_from <dải proxy>; real_ip_header X-Forwarded-For; real_ip_recursive on; — docs: khi bật
real_ip_recursive, "the replacement is the last non-trusted address found in the header field". Khi nginx nói chuyện với php-fpm qua FastCGI, PHP thấy
REMOTE_ADDR = $remote_addr của nginx — nên nếu nginx đã có realip đúng thì Laravel không cần đọc XFF nữa.
3.5 NAT, CGNAT và IPv6: một IP không phải một người
- Nhiều người một IP: văn phòng, trường học, nhà mạng di động (CGNAT) — một IPv4 có thể là hàng trăm khách. Ngưỡng theo IP phải rộng (dữ liệu bài 4: 120/phút), và khi đã đăng nhập thì chuyển sang key theo user id.
- Một người nhiều IP: IPv6 cấp cho một thuê bao cả dải /64 (2⁶⁴ địa chỉ), thường là /56 hoặc /48. Adam Pritchard (2022): "The best algorithm is to start blocking separate addresses. Then when multiple addresses are blocked in the same /64 you block the whole /64." và khuyên giới hạn bậc thang theo /64, /56, /48. Key theo địa chỉ IPv6 đầy đủ là vô dụng — đổi địa chỉ trong dải là có bộ đếm mới (và còn làm đầy zone của nginx — mục 3.6).
function rateKeyForIp(string $ip): string
{
$bin = inet_pton($ip);
if ($bin === false || strlen($bin) === 4) return $ip; // IPv4: giữ nguyên
return inet_ntop(substr($bin, 0, 8) . str_repeat("\0", 8)) . '/64'; // IPv6: chỉ lấy 64 bit đầu
}
2001:db8:abcd:12::1 → key 2001:db8:abcd:12::/64
2001:db8:abcd:12:a1b2:c3d4:e5f6:7788 → key 2001:db8:abcd:12::/64
2001:db8:abcd:13::1 → key 2001:db8:abcd:13::/64
203.0.113.10 → key 203.0.113.10
nginx $binary_remote_addr là từng địa chỉ, không gom theo /64 — muốn gom phải tự dựng key bằng map (chưa dựng lab cho phần nginx này).
3.6 Toàn hệ thống ở nginx: 6 hành vi phải biết
Lab nginx 1.31.3 (out/05-nginx.txt), client gửi request theo lịch chính xác tới mili-giây:
1. rate=10r/s, KHÔNG burst, limit_req_status 503 (giá trị mặc định của nginx):
2 request cách nhau 50ms → 200, 503 (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
2. rate=10r/s, burst=5 (KHÔNG nodelay) — 10 request cùng lúc, status/độ trễ:
200/4ms 429/6ms 429/6ms 429/6ms 429/7ms 200/104ms 200/205ms 200/304ms 200/404ms 200/504ms
3. rate=10r/s, burst=5 nodelay — 10 request cùng lúc, rồi 300ms sau thêm 5 request:
200/3ms 200/4ms 200/4ms 200/6ms 200/7ms 200/7ms 429/7ms 429/8ms 429/9ms 429/9ms
sau 300ms: 200/7ms 200/8ms 429/8ms 200/9ms 429/12ms
4. rate=10r/s, burst=5 delay=3 (2 tầng) — 10 request cùng lúc:
200/5ms 200/5ms 200/5ms 200/5ms 429/5ms 429/5ms 429/5ms 429/6ms 200/105ms 200/205ms
nodelay, nginx giữ request lại để đầu ra đúng 10/giây — với API, request chờ nửa giây chỉ vì tới cùng
lúc thường tệ hơn bị từ chối. delay=3 (từ 1.15.7) là lai: 3 request vượt đầu tiên qua ngay, phần vượt tiếp theo bị giữ, quá burst thì chặn. Các chấm về
ngay (3–9ms) được xếp cạnh nhau cho dễ đếm.5. limit_req_dry_run on — 10 request cùng lúc: 200×10 (log: PASSED, REJECTED_DRY_RUN ×9 — dùng cho shadow mode, bài 6)
6. zone key = $server_name nhưng server không khai báo server_name — 20 request cùng lúc: 200×20
7. limit_req + "return 200" trong cùng location — 20 request cùng lúc: 200×20
8. Kế thừa: server có limit_req global 5r/s burst=10 nodelay
/inherit (location không có limit_req riêng), 20 request: 200×11 429×9
/api (có limit_req per_client riêng), 20 request từ 20 client: 200×20 ← zone global bị bỏ qua
9. 2 instance nginx cùng config, LB chia đều — /nodelay (burst=5), 20 request cùng lúc:
1 instance nhận cả 20: 200×6 429×14
2 instance, mỗi cái 10: 200×12 429×8
10. Zone 32k đầy (rate=1r/m theo header X-Client):
client "victim" 2 lần liền: 200, 429
2000 client mới, mỗi client 1 request: 200×2000
"victim" gửi lại ngay (đáng lẽ còn bị chặn tới hết phút): 200
- (6) Key rỗng = không đếm. Cách "global limit" chép trên mạng là
limit_req_zone $server_name …— server block không cóserver_namethì key rỗng, nginx bỏ qua request (docs ghi request có key rỗng không được tính). Chính lab capacity của series lần đầu dính lỗi này: limit 70/giây không chặn gì cho tới khi thêmserver_name. - (7)
returnchạy trướclimit_req:returnthuộc phase rewrite,limit_reqthuộc phase preaccess sau đó — location trả lời bằngreturnkhông bao giờ bị giới hạn. - (8) Kế thừa "tất cả hoặc không": docs: "These directives are inherited from the previous configuration level if and only if there are no
limit_reqdirectives defined on the current level." Thêm một limit theo client vàolocation /apilà âm thầm mất limit toàn server cho location đó — phải khai báo lại cả hai trong location. - (9) Mỗi instance một bộ đếm (zone là shared memory giữa các worker của một nginx, không giữa các máy).
- (10) Zone đầy thì xoá trạng thái cũ nhất (docs: "the least recently used state is removed") — kẻ có nhiều IP (IPv6!) tạo key mới liên tục là xoá được trạng thái "đang bị chặn" của chính mình. Cỡ zone: docs ghi "One megabyte zone can keep about 16 thousand 64-byte states" — tính theo số key đang hoạt động, có dư.
3.7 Đăng nhập, OTP, thanh toán: đếm hành động, không đếm request
| Kiểu tấn công | Dấu hiệu | Key chặn được | Key vô dụng |
|---|---|---|---|
| Brute-force 1 tài khoản | 1 email, rất nhiều mật khẩu | Lần sai theo email | IP (kẻ tấn công đổi IP) |
| Credential stuffing | Rất nhiều email, mỗi email 1–2 lần, rất nhiều IP | Tỉ lệ đăng nhập sai toàn hệ thống, thiết bị, CAPTCHA | Email (mỗi email ít lần), IP |
| Password spraying | Rất nhiều email, cùng vài mật khẩu | Lần sai theo IP/ASN, theo mật khẩu (hash) | |
| Thử thẻ ở checkout | Nhiều thẻ khác nhau, số tiền nhỏ | Số thẻ khác nhau/user/giờ, theo thiết bị | Request/phút |
| SMS pumping qua OTP | Gửi OTP tới hàng loạt số lạ | Theo số điện thoại, theo đầu số, ngân sách SMS toàn hệ thống | IP |
- Project thật dùng key Fortify chuẩn
lower(email) . '|' . ip, 5/phút — chặn được brute-force từ 1 IP, không chặn được kẻ đổi IP. Thêm một limit chỉ theo email (đếm lần sai,->after()) và một limit theo IP. - Khoá theo tài khoản bị lợi dụng: OWASP cảnh báo khoá tài khoản "care must be taken to prevent it from being used to cause a denial of service by locking out other users' accounts". Thay vì khoá cứng: tăng độ trễ dần, CAPTCHA, cho phép "quên mật khẩu" vẫn chạy khi đang bị khoá.
- Với GraphQL, một request có thể chứa 10 000 lần thử (alias) — đếm ở resolver theo tài khoản, xem bài bảo mật GraphQL.
3.8 API đối tác: theo API key, theo gói, theo chi phí
- Key: API key → tenant. Nhiều key của cùng tenant dùng chung ngân sách tenant (không thì tạo thêm key để lách).
- Gói: limiter đọc gói từ DB nhưng phải cache (tra DB mỗi request là tự tạo tải). Ví dụ minh hoạ: Free 60/phút burst 10; Pro 600/phút burst 100; Enterprise theo hợp đồng.
- Quota tháng (100 000 request/tháng) là sổ sách tính tiền → lưu trong DB có transaction, không lưu trong Redis-limiter (soft state, failover mất đếm — bài 3).
- Tách hẳn khỏi storefront: đối tác đồng bộ 100 000 sản phẩm lúc 9h không được ăn vào ngân sách của khách lẻ — pool php-fpm riêng hoặc concurrency limit
riêng cho
/api/partner.
4. Đảm bảo và giới hạn
- Giới hạn theo key chỉ công bằng khi key không giả được. IP qua XFF tin sai (mục 3.4), API key bị chia sẻ, user tạo nhiều tài khoản.
- Giới hạn toàn hệ thống bảo vệ capacity nhưng không công bằng: một kẻ lạm dụng có thể ăn hết ngân sách chung trước người khác — vì vậy kiểm giới hạn theo key trước giới hạn chung, và không để request bị chặn ăn ngân sách chung (mục 3.2).
- Các tầng không cộng lại chính xác: mỗi tầng một thuật toán, một cửa sổ; header nên báo tầng chặt nhất (Laravel tự lấy
X-RateLimit-Remainingnhỏ nhất khi có nhiều limit). - Theo instance hay toàn cụm: nginx và RAM là theo instance (×N); Redis là toàn cụm (bài 3).
5. Failure modes
| Sai lầm | Hậu quả (lab) | Phát hiện | Chữa |
|---|---|---|---|
Quên ->by() | IP thứ 2, 3 bị 429 vì IP 1 dùng hết | 429 tăng đồng loạt mọi khách | Luôn có by(); test có 2 client |
throttle:X,Y trên nhiều route | Xem sản phẩm 6 lần → hết lượt coupon | 429 ở route ít dùng | Prefix hoặc named limiter |
Nhiều limit, limit rộng đứng trước (Laravel 12, throttleWithRedis) | Khách vô can bị 429 | 429 ở user không gửi nhiều | Limit hẹp trước; nâng cấp 13.x cho throttle |
| Throttle route trang Livewire | /livewire/update không bị giới hạn | route:list | setUpdateRoute + throttle |
trustProxies('*') (13.x) + proxy nối XFF | IP giả theo ý client | Nhiều IP lạ, mỗi IP 1 request | Liệt kê dải proxy |
| Không trust proxy khi đứng sau LB | Mọi khách chung IP của LB | Log toàn 1 IP | Trust dải LB / realip |
| Key theo IPv6 đầy đủ | Mỗi địa chỉ một bộ đếm | Nhiều IP cùng /64 | Gom theo /64 |
nginx: key rỗng / return / location có limit_req riêng | Không giới hạn gì (200×20) | Test tải từng location | server_name; không dùng return; khai báo lại limit |
| nginx nhiều instance | Gấp N lần giới hạn | So số qua với cấu hình | Chia hạn mức cho N hoặc limiter dùng chung |
| Zone nginx quá nhỏ | Kẻ tấn công xoá được trạng thái bị chặn | Log "could not allocate" / hành vi lạ | Zone đủ lớn, gom key (IPv6 /64) |
6. Vận hành trong production
- Một chỗ duy nhất khai báo mọi limit (một service provider, một bảng cấu hình), có cột "vì sao con số này" (bài 4). Tên key thống nhất, vd
rl:{scope}:{id}— xem bài thiết kế tên key Redis. - Metric theo tầng: 429 theo limiter + route + loại key; top key bị chặn; số key khác nhau mỗi phút ở limiter theo IP (tăng vọt = có ai đang xoay IP).
- Cần gạt khẩn cấp: danh sách cho qua (đối tác đang migrate dữ liệu, có thời hạn) và danh sách chặn; nới giới hạn một tenant không cần deploy.
- Test tự động cho limit: mỗi limit một test 2 client (bắt lỗi quên
by()), một test 2 route (bắt lỗi chung bộ đếm), một test có X-Forwarded-For giả (bắt lỗi trusted proxy). - Nâng cấp Laravel 12 → 13 là thay đổi hành vi rate limit: thứ tự nhiều limit đổi (tốt hơn) và nghĩa của
trustProxies('*')đổi (nguy hiểm hơn) — ghi vào checklist nâng cấp.
7. So sánh và ma trận quyết định
Bộ giới hạn đề xuất cho shop của ví dụ (con số từ bài 4 khi có; còn lại là minh hoạ, phải đo lại trên traffic thật):
| Phạm vi | Key | Thuật toán | Con số | Tầng |
|---|---|---|---|---|
| Toàn hệ thống | Hằng số | Concurrency limit | 8 đang xử lý / instance (4 worker + 4 chờ) | nginx |
| Storefront (danh mục, sản phẩm) | User id, khách: IP (IPv6 /64) | Token bucket | 120/phút, burst 30 | Laravel |
| Tìm kiếm | Như trên + route | Token bucket theo chi phí | 30/phút (minh hoạ) | Laravel |
| Livewire update | User id / IP | Token bucket | Theo số tương tác thật (đo) | Route update của Livewire |
| Đăng nhập | email+IP; email (lần sai); IP (lần sai) | Sliding log / Lua nguyên tử | 5/phút; 10 lần sai/giờ; 50 lần sai/giờ (minh hoạ) | Nghiệp vụ |
| OTP | Số điện thoại; IP; ngân sách SMS toàn hệ thống | Sliding log | 3/10 phút/số (minh hoạ) | Nghiệp vụ |
| Thanh toán | User; số thẻ khác nhau | Sliding log | 10/phút; 5 thẻ/giờ (minh hoạ) | Nghiệp vụ |
| API đối tác | API key → tenant | GCRA + quota tháng trong DB | Theo gói | Gateway hoặc middleware riêng |
Dùng giới hạn theo route khi route có chi phí hoặc rủi ro khác hẳn phần còn lại. Dùng giới hạn toàn hệ thống luôn luôn — nó là lớp duy nhất bảo vệ sức chịu tải. Tránh một limit theo IP cho mọi thứ, và tránh đếm request khi thứ cần đếm là hành động (lần đăng nhập sai, số thẻ, số SMS).
8. Góc nhìn tech lead: tác động tổ chức
- Phân quyền sở hữu theo tầng: platform giữ nginx/gateway và giới hạn toàn hệ thống; đội sản phẩm giữ tầng route và hành động; đội đối tác (partner) giữ gói cước. Bảng ở mục 7 là tài liệu chung.
- ADR cho key: "khách vãng lai theo IP (gom /64), đã đăng nhập theo user id; trusted proxy là danh sách tường minh X, Y; không dùng
'*'" — kèm lý do (mục 3.4) để lần nâng cấp framework sau không ai đổi lại. - Review checklist: limiter mới có
by()? dùng chung tên với route khác không? có mấy limit, limit hẹp có đứng trước? endpoint có phải Livewire update không? location nginx cólimit_reqriêng làm mất limit chung không? - Exit strategy: giữ định nghĩa limit (phạm vi, key, ngân sách) ở một chỗ tách khỏi nơi thực thi; chuyển một phần sang API gateway sau này chỉ là đổi nơi thực thi.
9. Anti-patterns và bài học thực tế
- Một
throttle:60,1cho cả group route — chung bộ đếm, chung con số, không theo chi phí. - Copy limiter quên
by(). - Limit rộng đứng trước limit hẹp trên Laravel 12 hoặc
throttleWithRedis. trustProxies(at: '*')"cho chạy được đã" — trên 13.x là mở cửa cho IP giả.- Khoá cứng tài khoản sau N lần sai không có lối thoát — kẻ tấn công khoá được tài khoản người khác.
- Tin throttle trên route trang Livewire bảo vệ cả các action của component.
- Thêm
limit_reqvào một location rồi tưởng limit chung của server vẫn còn. - Đối tác và khách lẻ chung một ngân sách.
10. Câu hỏi phụ interviewer hay hỏi tiếp
- Rate limit theo route hay toàn hệ thống?
- Cả hai, mục đích khác nhau: toàn hệ thống để sống sót (concurrency limit ở nginx), theo route cho route đắt/nhạy cảm; thêm tầng user, tenant, hành động.
- Khách chưa đăng nhập thì key là gì?
- IP — với ngưỡng rộng (NAT), gom IPv6 theo /64, và chỉ tin IP sau khi cấu hình trusted proxy bằng danh sách tường minh. Đăng nhập rồi thì chuyển sang user id.
- Hai route dùng
throttle:60,1có chung bộ đếm không? - Có — key là user id hoặc IP, không có route. Dùng prefix (
throttle:60,1,tên) hoặc named limiter có tên route trongby(). - Chống credential stuffing bằng rate limit được không?
- Một phần: theo IP và theo email đều yếu (nhiều IP, mỗi email ít lần). Cần tín hiệu toàn hệ thống (tỉ lệ đăng nhập sai), thiết bị, CAPTCHA, kiểm mật khẩu đã lộ.
- Đặt nhiều limit thì thứ tự có quan trọng không?
- Có, trên Laravel 12 và
throttleWithRedis: request bị limit sau chặn vẫn ăn limit trước. Laravel 13.x sửa chothrottle(PR #58707). - nginx có limit toàn server rồi, sao location /api vẫn không bị giới hạn?
limit_reqchỉ kế thừa khi level hiện tại không cólimit_reqnào; location có limit riêng phải khai báo lại limit chung.- Gói cước đối tác lưu ở đâu?
- Tốc độ (phút) ở limiter Redis/gateway; quota tháng (tính tiền) ở DB có transaction — Redis-limiter là soft state.
Phụ lục: lab đã chạy
Môi trường: Laravel 12.64.0 + PHP 8.3.33 (project laravel-livewire mount read-only, Livewire 4.3.3, Fortify 1.37.2); Laravel 13.34.0 (app tạm tạo bằng
composer create-project ngoài repo); Redis 8.8.0 tạm; nginx 1.31.3; container tmp-rl-* trên network riêng.
cd labs/rate-limit
./05-laravel.sh semantics # A–J → out/05-laravel.txt
./05-laravel13-setup.sh /tmp/l13app # app Laravel 13 tạm, ngoài repo
PROJECT=/tmp/l13app SUFFIX=.laravel13 ./05-laravel.sh semantics # cùng file lab → out/05-laravel.laravel13.txt
./05-laravel.sh livewire # route:list → out/05-livewire.txt
./05-nginx.sh # 10 hành vi của limit_req → out/05-nginx.txt
Chưa verify: request /livewire/update thật đi qua throttle (mới có route:list + source + docs); addPersistentMiddleware với middleware có tham số;
gom IPv6 /64 trong nginx bằng map; rate limit của CDN/WAF; nginx realip trong lab (chỉ đọc docs).