Tính con số rate limit — đo capacity, Little's law, headroom, percentile, burst, limit theo chi phí

"Đặt 60 request/phút được không?" là câu hỏi không có đáp án nếu thiếu hai con số: hệ thống chịu được bao nhiêu và người dùng thật cần bao nhiêu. Bài này đi cả hai chiều trên shop của ví dụ: chiều từ trên xuống — đo sức chịu tải thật của php-fpm rồi chọn giới hạn toàn hệ thống; chiều từ dưới lên — đọc log để biết khách thật, đối tác, bot gửi bao nhiêu, rồi chọn giới hạn theo user và theo route. Mọi con số có lab; phần dữ liệu log là dữ liệu giả lập (đánh dấu rõ ở mục 3.4).

Thuật ngữ dùng trong bài
capacity (sức chịu tải: số request/giây hệ thống xử lý được mà độ trễ còn chấp nhận được) · throughput / goodput (số request xử lý xong mỗi giây / phần trong đó thành công và kịp trước khi client bỏ đi) · Little's law (L = λ × W: số việc đang làm = tốc độ đến × thời gian ở lại) · utilization (phần trăm thời gian tài nguyên bận) · knee (điểm gãy: tải vượt qua đó thì độ trễ tăng vọt) · headroom (khoảng dư chừa lại dưới capacity) · percentile (p99 = 99% giá trị nhỏ hơn hoặc bằng nó) · open-loop / closed-loop load test (bắn tải theo lịch cố định bất kể server trả lời nhanh hay chậm / mỗi user ảo chờ response rồi mới gửi tiếp) · coordinated omission (công cụ đo tự chậm lại theo server nên không đo được thời gian chờ thật) · cost-based limit (mỗi request trừ ngân sách theo chi phí của nó, không phải 1 request = 1).
Kiểm chứng: AWS Builders' Library "Using load shedding to avoid overload" (David Yanacek); Google SRE Book ch. 21 "Handling Overload"; Stripe 2017; GitHub Docs (secondary rate limits, điểm theo loại request); Laravel docs 13.x (RateLimiter::increment(..., amount:)). Lab chạy thật: nginx 1.31.3 + php-fpm 8.3 (php:8.3-fpm-alpine, pm=static, 4 worker) + bộ bắn tải mở viết bằng Node 22, chạy 3 lần (out/04-capacity.run1.txt, run2.txt, run3.txt); câu SQL percentile chạy trên PostgreSQL 18.4 (out/04-percentile.txt); kịch bản burst từ lab bài 2. Máy lab 4 vCPU dùng chung — đúng lý do phần 3.1 phải chạy nhiều lần và so sánh.

Trả lời 30 giây

Hai chiều. Từ trên xuống: load test kiểu mở để tìm điểm gãy thật (lab: php-fpm 4 worker × 50ms, lý thuyết 80 rps theo Little's law, thật ~76–78), rồi đặt giới hạn toàn hệ thống ở 60–80% con số đó — vì sát điểm gãy độ trễ dao động mạnh (cùng 76 rps, lần đo này p50 55ms, lần khác 889ms) và vượt qua là sập: 120 rps làm goodput rơi còn ~19/giây, php-fpm làm phí 2 000 request cho client đã bỏ đi. Giới hạn theo tốc độ cố định vỡ khi backend chậm đi (DB chậm gấp đôi → goodput 7/giây), nên lớp toàn hệ thống nên là concurrency limit — Little's law tự co theo độ trễ (lab: vẫn 38/giây, không phí request nào). Từ dưới lên: đọc log, lấy p99/p99.9 số request mỗi user mỗi phút, đặt ngưỡng cao hơn hành vi thật nhưng thấp hơn bot (dữ liệu giả lập: 120/phút chặn cả 3 bot ~300/phút, chỉ chạm 1 khách thật 1 lần); burst lấy từ số request song song của một lần mở trang; route đắt thì trừ theo chi phí.

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

Con số rate limit thường được chọn bằng ba cách tệ: chép từ blog/đối thủ, đoán theo cảm giác, hoặc "để cao cho chắc". Cả ba đều thiếu một trong hai vế:

Khoảng giữa hai vế là nơi phân biệt khách thật với kẻ lạm dụng. Hai vế lại đo bằng hai công cụ khác nhau: load test cho vế hệ thống, log thật cho vế người dùng. Google SRE còn nhắc một điều nữa: "modeling capacity as 'queries per second' … often makes for a poor metric" — request khác nhau tốn tài nguyên khác nhau, nên con số tốt nhất đôi khi không tính bằng request (mục 3.7).

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

  1. Little's law: số request đang xử lý = tốc độ đến × thời gian xử lý. Với php-fpm có 4 worker, mỗi request chiếm 1 worker 50ms: capacity = 4 ÷ 0,05s = 80 request/giây. Hệ quả quan trọng nhất: capacity tính bằng request/giây thay đổi ngay khi thời gian xử lý đổi — DB chậm thành 100ms là còn 40/giây, dù số worker không đổi.
  2. Hai vế: giới hạn toàn hệ thống ≤ capacity đo được × hệ số an toàn; giới hạn mỗi key ≥ hành vi thật ở p99.9 × biên. Nếu tổng các giới hạn mỗi key lớn hơn capacity (gần như luôn vậy), vế hệ thống phải có lớp riêng.
Little's law: cùng 4 worker, thời gian xử lý gấp đôi thì capacity còn một nửa Bốn hàng worker trên trục thời gian 400 mili giây. Khi mỗi request mất 50 mili giây, mỗi worker xử lý 8 request trong 400 mili giây, tổng 80 request mỗi giây. Khi database chậm và mỗi request mất 100 mili giây, mỗi worker chỉ xử lý 4 request, tổng 40 request mỗi giây. worker 1worker 2worker 3worker 4 0100ms200ms300ms400ms 50ms/request: 4 worker × 20 request/giây = 80 request/giây DB chậm, 100ms/request: cùng 4 worker → chỉ còn 40 request/giây Giới hạn "70 request/giây" đúng ở trên, sai ở dưới. Giới hạn "8 request đang xử lý" (4 chạy + 4 chờ) đúng ở cả hai.
Hình 1 — Little's law (L = λ × W) là lý do một con số tốc độ cố định không bảo vệ được backend có độ trễ thay đổi, còn giới hạn số request đồng thời thì tự co theo: λ = L ÷ W, W tăng gấp đôi thì λ tự giảm một nửa. Lab ở mục 3.3 đo đúng hai trường hợp này.

3. Internals và quyết định thiết kế — từng bước tính

flowchart TD
  A["1. Load test kiểu mở
tìm điểm gãy thật"] --> B["2. Giới hạn toàn hệ thống
60–80% điểm gãy,
ưu tiên concurrency limit"] C["3. Đọc log thật
p99, p99.9 request/user/phút"] --> D["4. Giới hạn theo user, theo route
trên hành vi thật, dưới bot"] D --> E["5. Burst
từ số request song song
của 1 thao tác thật"] E --> F["6. Route đắt:
trừ theo chi phí"] B --> G["7. Kiểm tra chéo:
đỉnh traffic thật sau khi chặn bot
có dưới giới hạn toàn hệ thống?"] F --> G G --> H["8. Shadow mode,
rồi mới bật thật (bài 6)"]

3.1 Đo capacity bằng load test kiểu mở

Dựng một backend nhỏ có capacity biết trước: nginx → php-fpm pm=static, 4 worker; mỗi request usleep(50ms) (giả lập một câu query) rồi ghi một dòng vào file để đếm số request php-fpm thực sự đã làm. Bộ bắn tải gửi đúng N request/giây theo lịch, mỗi request timeout 2 giây (như app mobile hay proxy phía trước). Không dùng công cụ kiểu đóng (mỗi user ảo chờ response rồi mới gửi tiếp): khi server chậm, công cụ tự chậm theo, hàng đợi không bao giờ dài ra trong số đo — traffic thật từ Internet không lịch sự như vậy.

// labs/rate-limit/node/loadgen.mjs — phần lõi
for (let i = 0; i < rps * secs; i++) {
  const wait = t0 + (i * 1000) / rps - performance.now();     // lịch gửi cố định, KHÔNG chờ request trước
  if (wait > 1) await sleep(wait);
  inflight.push(fire());                                     // fire(): http.get + timeout 2s → 'timeout' nếu quá hạn
}
Tải gửi vàoLần 1: goodput · p50Lần 2: goodput · p50Lần 3 (máy rảnh): goodput · p50php-fpm làm phí (1 / 2 / 3)
40 rps (0,5×)40,0/s · 56ms40,0/s · 58ms40,0/s · 54ms0 / 0 / 0
60 rps60,0/s · 54ms60,0/s · 54ms60,0/s · 54ms0 / 0 / 0
70 rps70,0/s · 56ms70,0/s · 600ms70,0/s · 53ms0 / 0 / 0
76 rps76,0/s · 55ms76,0/s · 889ms, tăng dần76,0/s · 111ms (p99 1 634ms)0 / 0 / 0
80 rps (1×)12,1/s · sập sau 4 giây77,8/s · p50 tăng 185 → 1 951ms, bắt đầu timeout80,0/s · p50 tăng 163 → 1 374ms1 359 / 43 / 0
120 rps (1,5×)19,9/s19,4/s17,7/s2 002 / 2 012 / 2 046
160 rps (2×)12,4/s13,8/s13,9/s2 952 / 2 924 / 2 921

Đọc bảng: (1) trần thật ~76–78 rps, thấp hơn lý thuyết 80 vì mỗi request còn tốn vài mili-giây cho nginx, FastCGI, PHP; (2) vượt trần là sập chứ không phải chậm đều — 1,5× tải mà goodput chỉ còn 1/4; (3) vùng sát trần dao động mạnh giữa các lần đo: cùng 70–76 rps, lần 1 và 3 êm, lần 2 p50 lên 600–889ms; ở 80 rps cả ba lần đều thấy hàng đợi dài dần, chỉ khác nhau ở lúc chạm timeout (lần 1 sau 4 giây, lần 2 ở giây 18, lần 3 chưa tới trong 20 giây nhưng p50 đã lên 1 374ms). Máy lab dùng chung với các việc khác; production cũng vậy — node bên cạnh, cache lạnh sau deploy, GC, backup chạy đêm. Vì thế không bao giờ đặt giới hạn bằng điểm gãy.

Goodput theo tải gửi vào, hai lần đo, và các cách giới hạn ở 160 rps Hai đường đỏ cho hai lần đo không giới hạn: tăng đều tới 76 rồi sập; lần 1 sập ngay ở 80 còn 12, lần 2 ở 80 vẫn 78 nhưng ở 120 cả hai còn khoảng 19 và ở 160 còn 12 đến 14. Ở 160 rps, rate limit 70 mỗi giây giữ goodput 70,8 và 70,9; concurrency limit 8 giữ 76,0 và 71,5. Goodput (request/giây thành công trước timeout 2s) theo tải gửi vào 80 = trần lý thuyết (Little's law) 40607680120160 040 lần 1: 80 rps → 12,1lần 2: 80 rps → 77,8 (nét đứt)120 rps → ~19 rate limit 70/s: 70,8 · 70,9 · 71,0 concurrency 8: 76,0 · 71,5 · 74,8
Hình 2 — Hai lần đo chỉ khác nhau ở vùng sát trần (80 rps): lần 1 sập trong 4 giây, lần 2 chậm dần rồi mới bắt đầu timeout ở giây 18 (lần 3 giống lần 2, không vẽ cho đỡ rối). Ở 1,5× và 2× thì ba lần như nhau. Đường nét đứt của lần 2 hiện ra cùng các nhãn, không vẽ dần.
Chuyện gì xảy ra trong 20 giây ở 80 rps Mỗi cột là 2 giây, chiều cao là độ trễ trung vị. Lần 1: 127 mili giây, rồi 1383, từ giây thứ 4 mọi request đều timeout. Lần 2: độ trễ tăng đều từ 185 lên 1951 mili giây, giây cuối bắt đầu có 43 timeout. 80 rps, không limit — độ trễ trung vị mỗi 2 giây (cột cao nhất = 2 000ms = timeout) lần 1sập nhanhlần 2chết chậm mọi request timeout — php-fpm vẫn chạy nốt: 1 359 request làm phí 1851 88743 timeout 0–2s8–10s18–20s
Hình 3 — Tải chỉ nhỉnh hơn capacity thật vài request/giây thì hàng đợi dài thêm vài request mỗi giây, độ trễ tăng đều cho tới khi chạm timeout của client. Từ lúc đó, client bỏ đi nhưng request đã nằm trong hàng đợi php-fpm vẫn được chạy — server bận làm việc vô ích, request mới lại phải chờ sau chúng: đúng vòng luẩn quẩn AWS mô tả, "an overload creates its own feedback loop that results in overload as a steady state".

3.2 Chọn trần toàn hệ thống: chừa headroom

Rate limit toàn hệ thống 70 request/giây (≈ 90% trần đo được) đặt ở nginx với key hằng số, bắn 160 rps:

lần 1: rate 70/s, 160 rps (2×) | gửi 3200 | 200: 1415, 429: 1785 | goodput 70.8/s | 200: p50 284ms p99 494ms | làm phí 0
lần 2: rate 70/s, 160 rps (2×) | gửi 3200 | 200: 1418, 429: 1782 | goodput 70.9/s | 200: p50 83ms p99 277ms  | làm phí 0
lần 3: rate 70/s, 160 rps (2×) | gửi 3200 | 200: 1419, 429: 1781 | goodput 71.0/s | 200: p50 54ms p99 251ms  | làm phí 0
lần 2: rate 60/s, 160 rps (2×) | gửi 3200 | 200: 1046, 429: 2010, timeout: 144 | goodput 52.3/s | p99 2230ms | làm phí 144
lần 3: rate 60/s, 160 rps (2×) | gửi 3200 | 200: 1218, 429: 1982 | goodput 60.9/s | 200: p50 56ms p99 268ms  | làm phí 0

Quy tắc thực dụng: trần toàn hệ thống ≈ 60–80% điểm gãy đo được ở điều kiện xấu (cache lạnh, giờ cao điểm), và đo lại sau mỗi thay đổi lớn. Lý thuyết hàng đợi giải thích vì sao không lên cao hơn: thời gian chờ tăng theo kiểu ρ / (1 − ρ) — ở 50% utilization chờ ≈ 1 lần thời gian xử lý, 75% ≈ 3 lần, 90% ≈ 9 lần (mô hình M/M/1, chỉ để thấy hình dạng đường cong).

3.3 Khi capacity đổi: rate limit cố định vỡ, concurrency limit thì không

DB chậm đi: mỗi request 100ms → trần thật ~40 rps
lần 1: rate 70/s,  80 rps, xử lý 100ms | goodput  7.3/s | timeout 1188 | php-fpm làm phí 1188
lần 2: rate 70/s,  80 rps, xử lý 100ms | goodput  6.8/s | timeout 1280 | php-fpm làm phí 1280
lần 3: rate 70/s,  80 rps, xử lý 100ms | goodput  7.4/s | timeout 1270 | php-fpm làm phí 1270
lần 1: conn 8,     80 rps, xử lý 100ms | goodput 38.8/s | 429: 825 | p50 196ms p99 287ms | làm phí 0
lần 2: conn 8,     80 rps, xử lý 100ms | goodput 37.5/s | 429: 851 | p50 201ms p99 349ms | làm phí 0
lần 3: conn 8,     80 rps, xử lý 100ms | goodput 35.3/s | 429: 895 | p50 208ms p99 609ms | làm phí 0
lần 1: conn 8,    160 rps, xử lý  50ms | goodput 76.0/s | 429: 1681 | p50 100ms p99 133ms | làm phí 0
lần 2: conn 8,    160 rps, xử lý  50ms | goodput 71.5/s | 429: 1770 | p50 104ms p99 226ms | làm phí 0
lần 3: conn 8,    160 rps, xử lý  50ms | goodput 74.8/s | 429: 1705 | p50 102ms p99 152ms | làm phí 0

Cùng một dòng cấu hình nginx limit_conn global_conn 8; (4 đang chạy trên php-fpm + tối đa 4 chờ) cho goodput đúng bằng capacity thật trong cả hai trường hợp, không phí request nào, và p50 ≈ 2 × thời gian xử lý (một request chờ tối đa 4 request trước nó trên 4 worker). Đây là Little's law làm việc: giới hạn L (số request trong hệ thống) thì λ tự bằng L ÷ W. Chọn L = số worker + một hàng đợi ngắn; hàng đợi càng dài thì càng nhận được burst nhưng thời gian chờ tối đa càng lớn (L ÷ số worker × thời gian xử lý).

# nginx — giới hạn toàn hệ thống theo số request đồng thời (lab: nginx/04-capacity.conf)
limit_conn_zone $server_name zone=global_conn:1m;
server {
    server_name shop.example;          # BẮT BUỘC: không có server_name thì $server_name rỗng → nginx bỏ qua → không giới hạn gì
    location / {
        limit_conn global_conn 8;
        limit_conn_status 429;
        fastcgi_pass php-fpm:9000;
    }
}
DB chậm gấp đôi: rate limit cố định so với concurrency limit Mỗi cột là số request thành công trong 2 giây. Rate limit 70 mỗi giây: 137 request trong 2 giây đầu rồi 0 trong cả 18 giây còn lại. Concurrency limit 8: đều khoảng 68 đến 81 request mỗi 2 giây suốt 20 giây. DB chậm (100ms/request), gửi 80 rps — số request thành công mỗi 2 giây (lần 2) rate 70/scố địnhconn 8đồng thời 137 0 request thành công suốt 18 giây — chỉ còn timeout 8173 0–2s8–10s18–20s
Hình 4 — Rate limit 70/giây vẫn cho vào 70 request/giây trong khi backend chỉ còn xử lý được ~40: hàng đợi dài ra, mọi request quá timeout, php-fpm làm phí 1 280 request. Concurrency limit 8 không cần biết thời gian xử lý là bao nhiêu — nó chỉ không cho quá 8 request ở trong hệ thống.

Bẫy của concurrency limit: (1) nó bảo vệ hệ thống, không phân biệt client — một khách tham lam vẫn chiếm hết 8 chỗ; vẫn cần rate limit theo key cho công bằng; (2) đặt ở nginx thì đếm kết nối tới nginx, không biết request nào đắt (Amazon dùng max_conns của proxy "as a last resort instead of the default protection option" và để server tự shed với thông tin cục bộ chính xác hơn); (3) request dài (upload, report) giữ chỗ lâu — tách location riêng. Bước tiếp theo là concurrency limit tự điều chỉnh theo độ trễ (kiểu AIMD — tăng dần khi êm, giảm mạnh khi chậm), chưa dựng lab trong series này.

3.4 Từ dưới lên: giới hạn theo user từ percentile của log

Dữ liệu giả lập. 3 giờ traffic tối của một shop: 2 000 khách thường (số request/phút theo phân phối lệch phải — đa số vài request, ít người rất nhiều), 20 đối tác/power user gửi đều ~20/phút, 3 bot cào giá ~300/phút. Tổng 599 933 request, sinh bằng SQL (labs/rate-limit/sql/04-percentile.sql). Con số cụ thể là minh hoạ; phương pháp là thứ dùng được cho log thật.
WITH um AS (SELECT user_id, date_trunc('minute', ts) AS m, count(*) AS n
            FROM access_log GROUP BY 1, 2)                       -- số request mỗi user mỗi phút
SELECT nhom, count(*) AS user_phut,
       percentile_disc(0.5)   WITHIN GROUP (ORDER BY n) AS p50,
       percentile_disc(0.99)  WITHIN GROUP (ORDER BY n) AS p99,
       percentile_disc(0.999) WITHIN GROUP (ORDER BY n) AS p999,
       max(n)
FROM um … GROUP BY nhom;
         nhom          | user_phut | p50 | p99 | p999 | max
-----------------------+-----------+-----+-----+------+-----
 20 đối tác/power user |      3225 |  20 |  50 |   72 |  83
 2000 khách thường     |     90164 |   3 |  22 |   43 | 147
 3 bot cào giá         |       540 | 298 | 348 |  349 | 349

Thử từng ngưỡng trên chính log đó — câu hỏi đúng không phải "p99 là bao nhiêu" mà là "đặt X thì ai bị chặn, bao nhiêu request thật bị từ chối":

 limit/phút | khách bị chặn ≥1 lần | đối tác bị chặn | bot bị chặn | % request thật bị từ chối
------------+----------------------+-----------------+-------------+---------------------------
         20 |                  842 |              20 |           3 |                     5.634
         30 |                  307 |              20 |           3 |                     1.857
         60 |                   29 |               8 |           3 |                     0.154
        120 |                    1 |               0 |           3 |                     0.006
        240 |                    0 |               0 |           3 |                     0.000

3.5 Theo route

     route      | p99 | p999 | max          (khách thường, request/user/phút)
----------------+-----+------+-----
 GET /product   |  12 |   24 |  71
 GET /search    |   8 |   15 |  33
 POST /cart     |   6 |   11 |  31
 POST /checkout |   4 |    7 |  13

Route đắt hoặc nhạy cảm có hành vi thật thấp hơn nhiều so với trang sản phẩm → được phép đặt chặt hơn: vd tìm kiếm 30/phút, thêm giỏ 30/phút, thanh toán 10/phút (khoảng 2× p99.9, có burst). Thanh toán mà ai đó gửi 50 lần/phút thì đó là thử thẻ (card testing), không phải khách.

3.6 Burst: từ một thao tác thật

Token bucket cho phép trong mọi khoảng T tối đa b + r·T request (bài 2). Chọn r từ percentile (mục 3.4), chọn b từ số request song song của một thao tác thật. Lab bài 2 (trang mở ra bắn 12 request cùng lúc, giới hạn "10/giây"):

token bucket b=1, 10/giây    | qua 1/12 mỗi lần mở trang
token bucket b=20, 10/giây   | qua 12/12 mỗi lần mở trang

Ví dụ storefront: r = 2/giây (120/phút), b = 30. Khách mở trang bắn 12 request: qua hết. Bot cào giá 5 request/giây: dùng hết 30 token đầu trong ~10 giây, sau đó chỉ còn 2/giây — tức 120/phút, đúng ngưỡng bot bị chặn ở mục 3.4.

3.7 Request không bằng nhau: giới hạn theo chi phí

Một request tìm kiếm full-text có thể tốn gấp 10 lần một request xem sản phẩm đã cache. Đếm request thì kẻ tấn công chọn route đắt nhất. Cách làm: gán trọng số cho route theo chi phí đo được, mỗi request trừ đúng số đó.

Route (ví dụ minh hoạ)Thời gian xử lý p50Trọng số
GET /product (có cache)20ms1
GET /search120ms6
POST /checkout300ms (gọi cổng thanh toán)15

3.8 Kiểm tra chéo hai vế

Toàn hệ thống (dữ liệu giả lập), request mỗi giây:
 TB/giây | p99/giây | đỉnh/giây | TB/giây (bỏ bot) | đỉnh/giây (bỏ bot)
    55.5 |       74 |        87 |             40.6 |                 69

Ghép với lab capacity: đỉnh 87 rps > trần ~76 của một máy php-fpm 4 worker → nếu không có gì chặn bot, mỗi đợt dồn là một lần chạm vùng sập ở mục 3.1. Bỏ bot (giới hạn 120/phút/user ở mục 3.4 làm việc đó) thì đỉnh còn 69 — dưới trần nhưng chỉ chừa ~10% headroom, tức là vẫn nên thêm worker hoặc máy trước mùa khuyến mãi. Và tổng giới hạn theo user không bao giờ là "trần": 2 000 khách × 120/phút = 4 000 request/giây "được phép" trên giấy — lớp toàn hệ thống (mục 3.2, 3.3) mới là thứ giữ hệ thống sống.

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

5. Failure modes

Sai lầmHậu quả đo đượcPhát hiện
Không có lớp toàn hệ thống1,5× tải → goodput ~19/giây, 2 000+ request làm phíp99 tăng vọt, 499/504 ở nginx, worker bận 100%
Đặt trần sát điểm gãy (90%)p50 284ms (5,7× thời gian xử lý), hồi phục chậmĐộ trễ tăng dù không có 5xx
Rate limit cố định khi backend chậm điGoodput 6,8–7,3/giây, ~1 200 request làm phíĐộ trễ DB tăng trước, 429 không tăng
Ngưỡng quá thấp20/phút chặn 842 khách thật (dữ liệu giả lập)Ticket "không mua được hàng", tỉ lệ 429 ở user đã đăng nhập
Burst quá nhỏ1/12 request của một lần mở trang qua429 ngay lần tải trang đầu
Load test kiểu đóngKhông thấy hàng đợi, không thấy sậpKết quả load test đẹp, production sập ở tải thấp hơn
Quên retryMỗi request bị chặn quay lại 3–5 lần (bài 6: ×5,2)Tổng request tăng khi bắt đầu chặn

6. Vận hành trong production

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

Cách chọn con sốƯuNhượcDùng khi
Chép từ blog/đối thủNhanhKhông gắn với hệ thống của mìnhKhông bao giờ — tối đa làm điểm khởi đầu cho shadow mode
Từ trên xuống (capacity)Bảo vệ được hệ thốngKhông công bằng giữa clientGiới hạn toàn hệ thống
Từ dưới lên (percentile)Ít chặn oanKhông bảo vệ được hệ thống (tổng quá lớn)Giới hạn theo user, route
Theo chi phíChặn đúng thứ đắtPhải đo và duy trì trọng sốRoute chênh lệch chi phí lớn, GraphQL, API đối tác
Concurrency limitTự co theo độ trễKhông phân biệt clientLớp bảo vệ backend
Tự điều chỉnh (AIMD)Không cần đoánKhó hiểu, khó debug, dễ dao độngHệ thống lớn có đội platform

Kết luận: dùng cả hai chiều — concurrency limit (hoặc rate limit 60–80% capacity) cho vế hệ thống, percentile cho vế người dùng, trọng số cho route đắt. Tránh một con số duy nhất cho mọi thứ, và tránh mọi con số không đi kèm cách nó được tính.

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. Con số không có nguồn gốc ("60/phút vì Laravel mặc định vậy").
  2. Đặt trần bằng 100% capacity — lab: vùng sát trần là vùng sập.
  3. Chỉ có giới hạn theo user, coi tổng của chúng là bảo vệ hệ thống.
  4. Một ngưỡng cho mọi route — search và checkout bị đối xử như trang sản phẩm.
  5. Load test kiểu đóng rồi tin kết quả.
  6. Hạ ngưỡng chung vì một đối tác, thay vì cho đối tác giới hạn riêng theo API key.
  7. Quên phần làm phí: request đã vào hàng đợi vẫn được xử lý sau khi client bỏ đi (lab: tới 2 952 request). Amazon khuyên giới hạn cả thời gian một request được nằm trong hàng đợi: "we throw it out if it's too old".

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

Tính capacity thế nào?
Little's law cho con số lý thuyết (số worker ÷ thời gian xử lý), load test kiểu mở cho con số thật (lab: 80 lý thuyết, ~76–78 thật), đo ở điều kiện xấu.
Đặt giới hạn toàn hệ thống bao nhiêu phần trăm capacity?
60–80%. Sát hơn thì độ trễ dao động mạnh (lab 90%: p50 284ms với thời gian xử lý 50ms) và capacity tụt một chút là sập.
Rate limit hay concurrency limit để bảo vệ backend?
Concurrency limit — tự co theo độ trễ. Lab DB chậm gấp đôi: rate limit cố định goodput 7/giây, concurrency limit 38/giây. Rate limit vẫn cần cho công bằng giữa client.
Giới hạn theo user lấy từ đâu?
Log thật: phân phối request/user/phút, thử từng ngưỡng xem ai bị chặn. Đặt trên hành vi thật (p99.9 + biên), dưới hành vi lạm dụng; nhóm khác hành vi (đối tác) thì giới hạn riêng.
Burst đặt bao nhiêu?
Lớn hơn số request song song của một thao tác thật (một lần mở trang, một lần đồng bộ app).
Request đắt rẻ khác nhau thì sao?
Giới hạn theo chi phí: mỗi route một trọng số đo từ thời gian xử lý (GitHub: GET 1, POST 5), trừ đúng trọng số mỗi request.
Sao load test của tôi đẹp mà production vẫn sập?
Thường là load test kiểu đóng (tự chậm theo server), dữ liệu/cache không giống production, hoặc không đẩy tải qua điểm gãy.

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

Môi trường: nginx 1.31.3 + php-fpm 8.3 (php:8.3-fpm-alpine), pm=static, 4 worker; bộ bắn tải mở node/loadgen.mjs (Node 22.23.3, http.Agent keep-alive, timeout 2 giây); mỗi lượt 20 giây và dựng lại container cho sạch (request "chết" của lượt trước còn trong hàng đợi php-fpm sẽ làm hỏng lượt sau). PostgreSQL 18.4 (container test có sẵn), bảng tmp_rl_access_log tự tạo rồi tự xoá. Máy 4 vCPU dùng chung.

cd labs/rate-limit
./04-capacity.sh                       # cả bộ → out/04-capacity.txt (3 lần đã chạy, lưu ở 04-capacity.run1.txt, run2.txt, run3.txt)
SUFFIX=.run4 ./04-capacity.sh          # chạy thêm 1 lần, ghi file riêng
./04-capacity.sh conn                  # 1 phần: nolimit | rate | slowdb | conn
/var/www/chiennn/postgresql-learning/scripts/psql.sh < sql/04-percentile.sql    # → out/04-percentile.txt

Chưa verify: concurrency limit tự điều chỉnh (AIMD, gradient); giới hạn theo chi phí trong Lua (INCRBY theo trọng số); capacity khi DB là nút cổ chai thật (lab giả lập bằng usleep); số liệu log thật của shop (dữ liệu ở mục 3.4 là giả lập).