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, Symfony Request::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ặc throttle:60,1 trê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 cho throttle nhưng chưa sửa throttleWithRedis); 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.

Một request trừ vào nhiều ngân sách lồng nhau Một request tìm kiếm của user 42 thuộc đối tác ACME đi xuống qua năm ngân sách: toàn hệ thống, đối tác ACME, user 42, route tìm kiếm, và hành động. Mỗi tầng trừ đi một phần. Tới tầng route tìm kiếm thì ngân sách cạn nên request bị trả 429, dù các tầng khác vẫn còn. Toàn hệ thống8 đang xử lý / instance Tenant / API keyACME gói Pro 600/phút Useruser 42: 120/phút Route/search: 30/phút Hành độngđăng nhập sai: 5/giờ không áp cho /search ✕ 429route cạn trước thanh = phần ngân sách còn lại; mỗi request đi xuống trừ ở mọi tầng áp lên nó
Hình 1 — Request 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ầngKeyMục đíchĐặt ởBẫy chính
Toàn hệ thốngHằng số ($server_name, chuỗi cố định)Sống sót khi quá tảinginx / gatewayKey rỗng thì nginx bỏ qua (mục 3.6)
Tenant / API keyAPI key → tenantGói cước, chống noisy neighborGateway hoặc middlewareCần tra gói từ DB → cache lại
UserUser id; khách vãng lai: IPCông bằng giữa kháchMiddlewareIP sai do proxy, NAT, IPv6
Route / nhóm routeKey của tầng user + tên routeRoute đắt, route dễ bị càoMiddlewareLaravel dùng chung bộ đếm giữa các route (mục 3.1)
Hành độngTài khoản, số điện thoại, thẻ, mã couponChống dò, chống tốn tiềnNghiệ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  …

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
Request bị chặn vẫn ăn ngân sách chung Hai bộ đếm: chung tối đa 10 và theo email victim tối đa 3. Kẻ tấn công gửi 10 request vào victim. Bộ đếm email lên 3 rồi chặn, nhưng bộ đếm chung vẫn tăng ở mọi request vì được tăng trước khi kiểm tra email: 1, 3, 6, rồi 10. Alice, một khách khác, đăng nhập và bị 429 vì ngân sách chung đã hết. Laravel 12: [chung 10/phút, theo email 3/phút] — kiểm tra limit 1 → tăng → kiểm tra limit 2 → … bộ đếm chung (tối đa 10) theo email victim@ (tối đa 3) 13610 3 request 4–10 bị chặn ở đây… …nhưng đã kịp tăng ở đây alice@ (IP khác, email khác) đăng nhập → 429 Laravel 13.34 sửa cho throttle: kiểm tra hết các limit rồi mới tăng (PR #58707)
Hình 2 — Số liệu lab E. Kẻ tấn công không cần biết mật khẩu của ai: dồn request vào một email bất kỳ là đủ làm cạn ngân sách đăng nhập chung của cả site.

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ốngREMOTE_ADDR → X-Forwarded-ForLaravel 12.64 ip()Laravel 13.34 ip()
1. Không trust proxy nào, app sau LB10.0.1.5 → 1.2.3.410.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 app198.51.100.66 → 1.2.3.41.2.3.4 (giả)1.2.3.4 (giả)
3. at '*', qua nginx nối thêm IP thật10.0.1.5 → 1.2.3.4, 198.51.100.66198.51.100.66 (đúng)1.2.3.4 (giả)
4. at '*', CDN → LB → app10.0.1.5 → 203.0.113.50, 104.16.0.9104.16.0.9 (IP của CDN)203.0.113.50
5. at [dải LB, dải CDN]như 4203.0.113.50 (đúng)203.0.113.50 (đúng)
6. at [dải private] như project, container khác trong mạng tự đặt header172.18.0.9 → 1.2.3.41.2.3.41.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ó.

X-Forwarded-For dài dần qua từng proxy, và IP mà Laravel chọn Request đi từ client qua CDN, load balancer tới app. Client tự gửi X-Forwarded-For 1.2.3.4. CDN nối thêm IP thật của client 203.0.113.50, load balancer nối thêm IP của CDN 104.16.0.9. Laravel 12 với at sao chọn 104.16.0.9, IP của CDN. Laravel 13 với at sao chọn 1.2.3.4, IP giả. Danh sách proxy tường minh chọn 203.0.113.50, đúng. client203.0.113.50 CDN104.16.0.9 LB10.0.1.5 Laravel XFF: 1.2.3.4 ← client tự điền XFF: 1.2.3.4, 203.0.113.50 ← CDN nối IP nó thấy XFF: 1.2.3.4, 203.0.113.50, 104.16.0.9 ← LB nối IP nó thấy Laravel 12, at '*'→ 104.16.0.9 — IP của CDN, mọi khách qua edge đó chung 1 bộ đếm Laravel 13, at '*'→ 1.2.3.4 — IP giả, client đổi tuỳ ý mỗi request at [dải LB, dải CDN]→ 203.0.113.50 — đi từ phải, bỏ proxy tin cậy, gặp IP đầu tiên không tin
Hình 3 — Cách đúng ở mọi phiên bản: liệt kê dải IP của proxy (LB, CDN — CDN công bố dải IP của họ), không dùng '*'; 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

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
10 request cùng lúc vào nginx limit_req 10r/s với ba cách đặt burst Trục thời gian 0 tới 600 mili giây. burst bằng 5 không nodelay: 1 request qua ngay, 5 request bị giữ lại và qua lần lượt ở 100, 200, 300, 400, 500 mili giây, 4 request bị 429. burst bằng 5 nodelay: 6 request qua ngay, 4 bị 429. burst 5 delay 3: 4 request qua ngay, 2 request qua ở 100 và 200 mili giây, 4 bị 429. 10 request cùng lúc · rate=10r/s (1 request / 100ms) · xanh = qua, đỏ = 429 · vị trí = lúc nhận response burst=5giữ lại, xếp hàng burst=5 nodelayqua ngay hoặc chặn burst=5 delay=3lai 2 tầng 0100ms200ms300ms400ms500ms chờ tới 504ms
Hình 4 — Số liệu lab nginx. Không có 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

3.7 Đăng nhập, OTP, thanh toán: đếm hành động, không đếm request

Kiểu tấn côngDấu hiệuKey chặn đượcKey vô dụng
Brute-force 1 tài khoản1 email, rất nhiều mật khẩuLần sai theo emailIP (kẻ tấn công đổi IP)
Credential stuffingRất nhiều email, mỗi email 1–2 lần, rất nhiều IPTỉ lệ đăng nhập sai toàn hệ thống, thiết bị, CAPTCHAEmail (mỗi email ít lần), IP
Password sprayingRất nhiều email, cùng vài mật khẩuLần sai theo IP/ASN, theo mật khẩu (hash)Email
Thử thẻ ở checkoutNhiề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 OTPGử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ốngIP

3.8 API đối tác: theo API key, theo gói, theo chi phí

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

5. Failure modes

Sai lầmHậu quả (lab)Phát hiệnChữa
Quên ->by()IP thứ 2, 3 bị 429 vì IP 1 dùng hết429 tăng đồng loạt mọi kháchLuôn có by(); test có 2 client
throttle:X,Y trên nhiều routeXem sản phẩm 6 lần → hết lượt coupon429 ở route ít dùngPrefix hoặc named limiter
Nhiều limit, limit rộng đứng trước (Laravel 12, throttleWithRedis)Khách vô can bị 429429 ở user không gửi nhiềuLimit hẹp trước; nâng cấp 13.x cho throttle
Throttle route trang Livewire/livewire/update không bị giới hạnroute:listsetUpdateRoute + throttle
trustProxies('*') (13.x) + proxy nối XFFIP giả theo ý clientNhiều IP lạ, mỗi IP 1 requestLiệt kê dải proxy
Không trust proxy khi đứng sau LBMọi khách chung IP của LBLog toàn 1 IPTrust dải LB / realip
Key theo IPv6 đầy đủMỗi địa chỉ một bộ đếmNhiều IP cùng /64Gom theo /64
nginx: key rỗng / return / location có limit_req riêngKhông giới hạn gì (200×20)Test tải từng locationserver_name; không dùng return; khai báo lại limit
nginx nhiều instanceGấp N lần giới hạnSo số qua với cấu hìnhChia 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ặnLog "could not allocate" / hành vi lạZone đủ lớn, gom key (IPv6 /64)

6. Vận hành trong production

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 viKeyThuật toánCon sốTầng
Toàn hệ thốngHằng sốConcurrency limit8 đ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 bucket120/phút, burst 30Laravel
Tìm kiếmNhư trên + routeToken bucket theo chi phí30/phút (minh hoạ)Laravel
Livewire updateUser id / IPToken bucketTheo số tương tác thật (đo)Route update của Livewire
Đăng nhậpemail+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ụ
OTPSố điện thoại; IP; ngân sách SMS toàn hệ thốngSliding log3/10 phút/số (minh hoạ)Nghiệp vụ
Thanh toánUser; số thẻ khác nhauSliding log10/phút; 5 thẻ/giờ (minh hoạ)Nghiệp vụ
API đối tácAPI key → tenantGCRA + quota tháng trong DBTheo góiGateway 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

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

  1. Một throttle:60,1 cho cả group route — chung bộ đếm, chung con số, không theo chi phí.
  2. Copy limiter quên by().
  3. Limit rộng đứng trước limit hẹp trên Laravel 12 hoặc throttleWithRedis.
  4. trustProxies(at: '*') "cho chạy được đã" — trên 13.x là mở cửa cho IP giả.
  5. 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.
  6. Tin throttle trên route trang Livewire bảo vệ cả các action của component.
  7. Thêm limit_req vào một location rồi tưởng limit chung của server vẫn còn.
  8. Đố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,1 có 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 trong by().
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 cho throttle (PR #58707).
nginx có limit toàn server rồi, sao location /api vẫn không bị giới hạn?
limit_req chỉ kế thừa khi level hiện tại không có limit_req nà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).