Rate limit dùng chung nhiều server — Redis, race condition, Lua, lệch đồng hồ, Redis chết, failover
Shop của ví dụ chạy nhiều container php-fpm sau nginx. Một limiter nằm trong bộ nhớ của từng process thì giới hạn bị nhân lên theo số process
(lab nginx ở bài 5: 2 instance cho qua 12 thay vì 6; bài scale queue
worker có ca RateLimited dùng cache file trên 3 VM thành 30 request/giây thay vì 10). Muốn đúng thì trạng thái phải nằm ở một chỗ dùng
chung — thường là Redis — và từ lúc đó mọi bài toán của hệ phân tán kéo tới: race, lệch đồng hồ, kho trạng thái chết, failover mất dữ liệu. Bài này
tái hiện từng cái, kể cả trong middleware throttle có sẵn của Laravel.
Thuật ngữ dùng trong bài
race condition (kết quả sai vì nhiều tiến trình chen vào giữa các bước của nhau) · check-then-act (kiểm tra rồi mới hành động bằng 2 bước riêng — khe hở cho race) · nguyên tử / atomic (thực thi trọn vẹn, không ai chen giữa được) · Lua script (EVAL/EVALSHA— đoạn code chạy bên trong Redis, nguyên tử) · TTL (thời gian sống của key) · hash slot / hash tag (Redis Cluster chia key vào 16 384 slot; phần trong{…}quyết định slot) · replication bất đồng bộ (master trả lời trước, gửi sang replica sau) · failover (replica lên làm master khi master chết) · clock skew (đồng hồ các máy lệch nhau) · offline queue (ioredis giữ lệnh trong RAM khi mất kết nối, gửi lại khi nối lại) · fail-open / fail-closed (limiter hỏng thì cho qua hết / chặn hết).
Kiểm chứng: tài liệu qua Context7 và đọc trực tiếp ngày 02/10/2026 — Redis docs (INCR"Pattern: rate limiter", "Scripting with Lua",EVAL,EXPIREtuỳ chọnNX); Stripe 2017 (fail-open); Envoy docs (global rate limiting) và READMEenvoyproxy/ratelimit; blog Cloudflare 2017. Đọc source: Laravel 12.64.0 và 13.34.0 (ThrottleRequests,ThrottleRequestsWithRedis,Redis\Limiters\DurationLimiter,Cache\RateLimiter), ioredis 5.11.1. Lab chạy thật: Redis 8.8.0, Node 22.23.3 + ioredis 5.11.1, Laravel 12.64 + PHP 8.3.33 (phpredis). Output:labs/rate-limit/out/03*.txt,05-race.txt,05-atomic.txt,05-skew.txt,05-redis-down.txt. Máy lab 4 vCPU dùng chung — số thời gian chỉ xem bậc độ lớn.
Trả lời 30 giây
Trạng thái limiter phải ở một chỗ dùng chung (Redis), và mỗi quyết định phải là một thao tác nguyên tử trên server Redis:INCRrồi xét giá trị trả về, hoặc một Lua script — không bao giờ "GET rồi mới INCR" (lab: 200 request lọt với giới hạn 10). Bốn cái bẫy hay gặp nhất: (1) INCR và EXPIRE tách rời — worker chết ở giữa là key không bao giờ hết hạn, user bị chặn vĩnh viễn; (2) lấy giờ từ app server — 2 server lệch 70 giây làmthrottleWithRediscủa Laravel mất hẳn tác dụng (600 request/phút qua với giới hạn 100); (3) Redis chết thìthrottlecủa Laravel trả 500 cho mọi người, Redis treo thì mỗi request kẹt 60 giây — phải đặt timeout ngắn và fail-open có cảnh báo; (4) chínhthrottlecủa Laravel cũng là check-then-act: 50 worker cùng tới cho qua tới 50 request với giới hạn 10. Trạng thái limiter là "soft state": mất khi failover thì chỉ cho qua thêm một ít — chấp nhận được với rate limit, không chấp nhận được nếu dùng nó để tính tiền.
1. Bài toán gốc và bối cảnh ra đời
| Cách giữ trạng thái | Ưu | Nhược |
|---|---|---|
| Trong RAM của từng process / instance | Nhanh nhất (lab: ~100 triệu quyết định/giây), không phụ thuộc gì | Giới hạn nhân theo số instance; mất khi restart |
| Sticky session (LB gửi cùng client về cùng instance) | Vẫn dùng RAM cục bộ | Mất cân bằng tải; client đổi IP/instance chết là lệch |
| Redis dùng chung | Lệnh nguyên tử, TTL sẵn, ~0,2ms mỗi lệnh | Thêm 1 dependency, thêm 1 vòng mạng mỗi request, failure modes của hệ phân tán |
| Bảng trong PostgreSQL | Không thêm hạ tầng | Mỗi request 1 lần ghi + khoá dòng; key nóng nghẽn ở khoá (chưa đo trong series này) |
| Đếm cục bộ + đồng bộ định kỳ | Gần như không tốn vòng mạng | Vượt giới hạn tỉ lệ với chu kỳ đồng bộ (lab mục 3.8) |
Redis thắng vì nó sinh ra cho đúng việc này: bộ đếm nguyên tử trong RAM có TTL. Chính docs của lệnh INCR có mục "Pattern: rate limiter".
Tối ưu cho: độ trễ thấp, lệnh nguyên tử trên một key. Không tối ưu cho: bền vững dữ liệu và nhất quán mạnh khi failover — đó là lý do
không dùng Redis-limiter làm sổ quota tính tiền.
2. Mô hình tư duy cốt lõi
- Mỗi quyết định là một phép đọc–sửa–ghi trên biến dùng chung → phải gói thành MỘT lệnh chạy trên Redis. Mọi cách tách nhiều bước, nhiều vòng mạng, hay lấy dữ liệu (kể cả giờ) từ app server đều mở khe cho tiến trình khác chen vào. Redis docs: "Redis guarantees the script's atomic execution. While executing the script, all server activities are blocked during its entire runtime."
- Trạng thái limiter là soft state. Mất nó thì client được cho qua thêm một chút — không mất đơn hàng, không sai sổ sách. Vì vậy mới chấp nhận được replication bất đồng bộ, fail-open, và đếm gần đúng. Nếu một ngày ai đó dùng chính bộ đếm này để tính tiền, mọi đánh đổi ở bài này thành lỗi.
GET rồi mới INCR). Lab A: 200 request cùng tới qua 20
kết nối, giới hạn 10 → 189–200 request lọt. Cùng tải đó, quyết định theo giá trị INCR trả về hoặc dùng Lua: đúng 10, cả 5 lượt.3. Internals và quyết định thiết kế
3.1 Race: kiểm tra rồi mới tăng
// mẫu "rate limiter 2" của docs Redis — KIỂM TRA bằng GET, rồi mới INCR
async function getThenIncr(c, key, limit, ttl) {
const cur = await c.get(key);
if (cur !== null && Number(cur) >= limit) return false;
const v = await c.incr(key);
if (v === 1) await c.expire(key, ttl);
return true;
}
=== A. Race: 200 request đến cùng lúc qua 20 kết nối, giới hạn 10 (5 lượt) ===
getThenIncr → số request được qua: 200, 200, 200, 200, 129
incrThenCheck → số request được qua: 10, 10, 10, 10, 10
lua → số request được qua: 10, 10, 10, 10, 10
Chữa đơn giản nhất: quyết định theo giá trị INCR trả về (incrThenCheck) — INCR tự nó nguyên tử. Nhưng cách này còn
một lỗ khác (mục 3.2).
3.2 INCR và EXPIRE tách rời: key không bao giờ hết hạn
async function incrThenCheck(c, key, limit, ttl) {
const v = await c.incr(key);
if (v === 1) await c.expire(key, ttl); // worker bị kill đúng giữa 2 lệnh → key KHÔNG có TTL
return v <= limit;
}
Docs Redis nhắc tới race này nhưng mô tả nhẹ: "the key will be leaked until we'll see the same IP address again". Lab cho thấy hậu quả nặng hơn: khi
IP đó quay lại, giá trị không bao giờ bằng 1 nữa nên EXPIRE không bao giờ được gọi lại — bộ đếm cứ tăng, và user bị chặn vĩnh viễn:
=== B. Process chết giữa INCR và EXPIRE (cửa sổ 1 giây, giới hạn 10) ===
sau "crash": GET=1 TTL=-1
incrThenCheck, 3 giây sau (đáng lẽ ~3 cửa sổ × 10 = 30): qua 9/43 | GET=44 TTL=-1
MULTI INCR + EXPIRE NX, cùng key rò: qua 29/53 | TTL ngay sau request đầu tiên=1
Ba cách chữa, đều đã chạy trong lab: (1) Lua script gói INCR + EXPIRE; (2) MULTI { INCR; EXPIRE key ttl NX } — tuỳ chọn
NX (Redis ≥ 7.0) chỉ đặt TTL khi key chưa có TTL, nên kể cả key đã "rò" từ trước cũng tự lành ở request kế tiếp; (3) với GCRA: SET … PX
trong cùng script (mục 3.3).
redis-cli --scan --pattern 'rl:*' rồi xem key nào TTL = -1.3.3 Lua script: viết thế nào, bẫy gì
GCRA trong Lua (lab so với bản JS ở bài 2: giống nhau 5 000/5 000 quyết định trên traffic ngẫu nhiên):
local T, tau = tonumber(ARGV[1]), tonumber(ARGV[2]) -- ms: khoảng cách chuẩn, độ sớm cho phép = T*(burst-1)
local now = tonumber(ARGV[3])
if not now then local t = redis.call('TIME'); now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000) end
local tat = tonumber(redis.call('GET', KEYS[1]) or now)
if tat < now then tat = now end
if tat - now > tau then return {0, math.ceil(tat - tau - now)} end -- chặn; trả số ms phải chờ
redis.call('SET', KEYS[1], tat + T, 'PX', math.ceil(tat + T - now)) -- key tự hết hạn khi đã "trả hết nợ"
return {1, 0}
mở trang 12 request/lần, 10/giây burst 12 | 120 request | JS qua 120 | Lua qua 120 | quyết định giống nhau 120/120
200/phút đều trong 3 phút, 100/phút burst 100 | 600 request | JS qua 399 | Lua qua 399 | quyết định giống nhau 600/600
5000 request ngẫu nhiên, 100/phút burst 20 | 5000 request | JS qua 3304 | Lua qua 3304 | quyết định giống nhau 5000/5000
giờ Redis (TIME), 15 request liền: ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✕98ms ✕98ms ✕98ms
- Mọi key phải đi qua
KEYS: docs: "all keys that a script accesses must be explicitly provided as input key arguments" — để Redis Cluster biết gửi script tới node nào (mục 3.6). - Đừng nhét giá trị vào thân script: mỗi biến thể là một script mới trong cache. Từ Redis 7.4, script nạp bằng
EVALbị đuổi khỏi cache khi cache đầy — dùngEVALSHA(ioredisdefineCommandtự làm, tự gửi lại khi gặpNOSCRIPT). - Script chặn cả Redis khi đang chạy — giữ O(1) hoặc O(log N). Sliding log với ZSET lớn là O(log N) mỗi lần thêm, nhưng
ZREMRANGEBYSCORExoá nhiều phần tử một lúc thì tốn theo số phần tử bị xoá. - Lấy giờ trong script được: docs: từ Redis 5.0 "script effects replication is the default mode", và khi đó "you can, for example, use
the TIME … commands inside your scripts freely". Lab chạy
TIMEtrong script trên Redis 8.8 bình thường.
3.4 Laravel: throttle có sẵn đúng tới đâu khi nhiều worker cùng tới?
Đọc source Laravel 12.64. ThrottleRequests::handleRequest() làm 2 bước rời: hỏi tooManyAttempts(), rồi mới hit():
foreach ($limits as $limit) {
if ($this->limiter->tooManyAttempts($limit->key, $limit->maxAttempts)) { // bước 1: đọc
throw $this->buildException(...);
}
if (! $limit->afterCallback) {
$this->limiter->hit($limit->key, $limit->decaySeconds); // bước 2: add timer, add key, INCRBY
}
}
Bản Redis (throttleWithRedis() → ThrottleRequestsWithRedis) dùng DurationLimiter viết bằng Lua — nghe như nguyên tử. Nhưng
middleware vẫn gọi 2 script rời: tooManyAttempts() (một EVAL chỉ đọc), rồi hit() gọi acquire() (một EVAL tăng đếm)
và bỏ qua giá trị trả về của acquire(). Lab: 50 process PHP (≈ 50 worker php-fpm) cùng xuất phát ở một mốc giờ, cùng một key, giới hạn 10:
50 process PHP cùng xuất phát (vạch chung), cùng 1 key, giới hạn 10. Số request được qua, 5 lượt:
45 48 49 38 50 ← throttle (ThrottleRequests, cache Redis)
43 34 43 40 33 ← throttleWithRedis (DurationLimiter Lua)
50 50 50 50 50 ← RateLimiter::attempt, callback 200ms
10 10 10 10 10 ← 1 lệnh Lua INCR+EXPIRE (tự viết)
10 10 10 10 10 ← middleware AtomicThrottle (Lua + fail-open)
- Cả hai middleware đều lọt khi traffic dồn: 33–50 request qua với giới hạn 10. Độ lọt bằng số request chen được vào khe giữa 2 bước — tức là tăng theo mức đồng thời (số worker php-fpm). Laravel 13.34 vẫn giữ cấu trúc 2 bước này cho cả hai middleware (đã đọc source).
RateLimiter::attempt()tệ nhất: source gọitooManyAttempts()→ chạy callback → rồi mớihit(). Callback càng chậm (gửi mail, gọi API) thì khe càng rộng: callback 200ms → cả 50/50 lọt. Đừng dùngattempt()để bảo vệ thao tác chậm.- Lọt bao nhiêu thì đáng lo? Với giới hạn "storefront 120/phút theo IP" chống cào — lọt vài chục request lúc dồn không đáng kể. Với "5 lần đăng nhập sai" hay "3 lần gửi OTP" — lọt 50/10 là thủng.
Middleware tối giản cho thao tác nhạy cảm — kiểm tra và tăng trong một lệnh Lua, fail-open có chủ đích (lab: đúng 10/10 trong cả 5 lượt race ở bảng trên):
final class AtomicThrottle
{
private const LUA = <<<'LUA'
local v = redis.call('INCR', KEYS[1])
if v == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return {v, redis.call('TTL', KEYS[1])}
LUA;
public function handle(Request $request, Closure $next, int $max = 60, int $decay = 60, string $prefix = 'rl')
{
$key = $prefix . ':' . ($request->user()?->getAuthIdentifier() ?? $request->ip());
try {
[$hits, $ttl] = Redis::connection('limiter')->eval(self::LUA, 1, $key, $decay);
} catch (Throwable $e) {
Log::warning('rate limiter fail-open', ['key' => $key, 'error' => $e->getMessage()]); // + metric + alert
return $next($request);
}
if ($hits > $max) {
return response()->json(['message' => 'Too Many Requests'], 429,
['Retry-After' => max(1, $ttl), 'X-RateLimit-Limit' => $max, 'X-RateLimit-Remaining' => 0]);
}
$response = $next($request);
$response->headers->add(['X-RateLimit-Limit' => $max, 'X-RateLimit-Remaining' => $max - $hits]);
return $response;
}
}
// config/database.php → 'redis' => [ 'limiter' => [ …như 'default'…, 'timeout' => 0.2, 'read_timeout' => 0.2 ] ]
11:429 X-RateLimit-Limit: 10 | X-RateLimit-Remaining: 0 | Retry-After: 60
local.WARNING: rate limiter fail-open {"key":"orders:203.0.113.10","error":"Connection refused"}
Redis tắt (cổng đóng) → HTTP 200 sau 0.03 giây (fail-open, có dòng cảnh báo ở log)
local.WARNING: rate limiter fail-open {"key":"orders:203.0.113.10","error":"read error on connection to 127.0.0.1:6379"}
Redis treo (docker pause) → HTTP 200 sau 0.23 giây (fail-open, có dòng cảnh báo ở log)
Bẫy của chính fail-open — gặp thật khi dựng lab: lần chạy đầu, kết nối limiter chưa được nạp vào RedisManager (manager chụp config lúc
khởi tạo). Kết quả: mọi request đều fail-open, 12/12 request được 200, limiter âm thầm không làm gì — log chỉ có dòng "Redis connection [limiter]
not configured." (out/05-atomic-misconfig.txt). Fail-open mà không có cảnh báo theo số lần fail-open thì lỗi cấu hình cũng bị nuốt.
3.5 Đồng hồ: giờ của app server hay giờ của Redis?
Thuật toán nào cần "bây giờ" (sliding log, GCRA, token bucket) mà lấy giờ từ app server thì mỗi server có một "bây giờ" riêng. Lab D: sliding log 10/giây, 2 server mỗi cái bắn 20 request/giây trong 4 giây:
giờ app (ARGV), server B lệch 0ms → qua 40 trong 4 giây (đúng ≈ 40) | nhiều nhất trong 1 giây thật: 10
giờ app (ARGV), server B lệch 300ms → qua 44 trong 4 giây (đúng ≈ 40) | nhiều nhất trong 1 giây thật: 14
giờ app (ARGV), server B lệch -300ms → qua 45 trong 4 giây (đúng ≈ 40) | nhiều nhất trong 1 giây thật: 15
giờ app (ARGV), server B lệch 1500ms → qua 50 trong 4 giây (đúng ≈ 40) | nhiều nhất trong 1 giây thật: 20
giờ Redis (TIME), server B lệch 1500ms → qua 40 trong 4 giây (đúng ≈ 40) | nhiều nhất trong 1 giây thật: 10
Laravel throttleWithRedis lấy giờ từ app: DurationLimiter::acquire() truyền microtime(true) và time() của PHP vào
Lua, script so now với start/end lưu trong hash — now nằm ngoài khoảng là đặt lại cửa sổ. Lab giả đồng hồ bằng cách khai báo
microtime()/time() trong namespace Illuminate\Redis\Limiters (PHP tra hàm trong namespace hiện tại trước), 2 server thay phiên nhận
request, tổng 10 request/giây, giới hạn 100/phút:
server B lệch +0 giây → số request qua mỗi phút thật: 100 / 100 / 100
server B lệch +2 giây → số request qua mỗi phút thật: 119 / 121 / 121
server B lệch -2 giây → số request qua mỗi phút thật: 119 / 121 / 121
server B lệch +30 giây → số request qua mỗi phút thật: 201 / 202 / 202
server B lệch +70 giây → số request qua mỗi phút thật: 600 / 600 / 600
ThrottleRequests bản cache thì đếm dựa vào TTL do Redis
giữ, nên đồng hồ app chỉ ảnh hưởng con số Retry-After — theo source; phần này chưa dựng lab lệch đồng hồ.3.6 Redis Cluster: key của một limiter phải cùng slot
slot(rl:user:42:minute)=1339 slot(rl:user:42:day)=2278 → EVAL: CROSSSLOT Keys in request don't hash to the same slot
slot(rl:{user:42}:minute)=15880 slot(rl:{user:42}:day)=15880 → EVAL: 1 1
Một script kiểm tra cả "phút" lẫn "ngày" của cùng user phải dùng hash tag {user:42}. Mặt trái: mọi key của user đó dồn vào một node — với
key toàn hệ thống (vd rl:{global}) là dồn mọi request vào một node: key nóng (hot key). Chia nhỏ (vd 16 key con, mỗi key 1/16 hạn mức, chọn ngẫu
nhiên) hoặc đưa giới hạn toàn hệ thống ra nginx/gateway.
3.7 Cái giá của một vòng mạng mỗi request
1 kết nối, gọi tuần tự: p50 0.228ms | p99 4.018ms
redis-benchmark EVALSHA (50 client): pipeline 1: 27386 lệnh/giây · pipeline 16: 154918 lệnh/giây
token bucket trong RAM của process: 102,728,966 lần/giây
Một Redis phục vụ cỡ 10⁴–10⁵ quyết định/giây — dư cho shop vài trăm request/giây, nhưng là trần thật khi hàng chục service cùng dùng, mỗi request kiểm 2–3 limiter. Gộp nhiều limiter của một request vào một script (một vòng mạng) thay vì gọi lần lượt.
3.8 Không gọi Redis mỗi request: đếm cục bộ, đồng bộ định kỳ
flowchart LR R["Request"] --> L["Limiter cục bộ
trong RAM mỗi node"] L -->|"chắc chắn vượt"| X["429 ngay, không gọi Redis"] L -->|"còn hạn mức cục bộ"| OK["Xử lý"] L -.->|"mỗi T ms: cộng số đã đếm,
lấy số chung mới"| G["Redis / global service"]
Mô phỏng (thời gian ảo): 10 node, giới hạn chung 600/phút, traffic 1 800/phút chia đều; mỗi node quyết định bằng "số chung lần đồng bộ trước + số tự đếm":
đồng bộ mỗi 0ms → số request qua mỗi phút: 600 / 600 / 600
đồng bộ mỗi 100ms → số request qua mỗi phút: 601 / 601 / 602
đồng bộ mỗi 1000ms → số request qua mỗi phút: 627 / 626 / 626
đồng bộ mỗi 5000ms → số request qua mỗi phút: 735 / 735 / 735
đồng bộ mỗi 15000ms → số request qua mỗi phút: 1006 / 1006 / 1005
chia tĩnh 60/node, traffic tổng 1800/phút chia đều → qua 600/phút
chia tĩnh 60/node, traffic tổng 600/phút, 1 node nhận 50% → qua 360/phút (dù tổng traffic chỉ bằng giới hạn: chặn oan)
Đánh đổi PACELC (khi không có sự cố, chọn giữa độ trễ và độ nhất quán) hiện ra bằng số: đồng bộ 1 giây chỉ lọt ~4,5%; 15 giây lọt 68%. Chia tĩnh hạn mức cho
từng node thì không lọt nhưng chặn oan khi traffic lệch. Hệ thống lớn làm kiểu lai: Envoy khuyên "local rate limiting can be used in conjunction with global
rate limiting to reduce load on the global rate limit service"; service envoyproxy/ratelimit lưu các key đã vượt vào cache cục bộ (freecache)
để khỏi hỏi Redis nữa; Cloudflare tăng bộ đếm bất đồng bộ ("the increment jobs are run asynchronously without slowing down the requests") và chỉ kiểm một cờ
"đang chặn" khi xử lý request.
4. Đảm bảo và giới hạn
- Một master + lệnh nguyên tử → trên từng key, mọi quyết định có thứ tự rõ ràng (linearizable) — khi master còn sống.
- Replication bất đồng bộ → failover có thể mất các lần đếm cuối. Lab F: master đếm tới 100, replica mới nhận tới 40, master chết, replica lên thay →
bộ đếm quay về 40, tức 60 lượt được cấp lại.
WAIT 1 500lúc mạng tới replica bị cắt trả0— nó chỉ cho biết, không cứu được. - Đòi an toàn hơn thì đổi lấy lỗi:
min-replicas-to-write 1khi không còn replica →INCRtrảNOREPLICAS Not enough good replicas to write.— limiter phải xử lý lỗi đó (fail-open hay fail-closed). - Khung CAP: khi mạng bị chia cắt, limiter chọn cho qua (giữ availability, vượt giới hạn) hoặc chặn (giữ đúng giới hạn, mất availability). Với rate limit thường chọn cho qua; với chống lạm dụng thao tác nhạy cảm có thể chọn chặn.
- Chỉ đúng bằng nguồn giờ: giờ của app thì lệch theo NTP; giờ Redis (
TIMEtrong script) thì mọi quyết định cùng một đồng hồ.
docker network disconnect). Với rate limit đây là cho qua thêm một ít trong
vài giây — chấp nhận được. Đừng dùng cùng cơ chế để giữ "số lượt còn lại của gói tháng": failover là khách được thêm hạn mức.5. Failure modes
Redis treo và Redis chết, phía Node (ioredis 5.11.1) — app gọi limiter 50 lần/giây, Redis bị docker pause từ giây 2 tới 5 và bị tắt process từ giây
7 tới 10:
--- client ioredis MẶC ĐỊNH (không timeout lệnh, có offline queue) ---
0–2s bình thường | 94 lần gọi | p50 1ms | max 6ms | allow 94
2–5s Redis TREO | 140 lần gọi | p50 1490ms | max 2964ms | allow 140
5–7s bình thường | 91 lần gọi | p50 1ms | max 51ms | allow 91
7–10s Redis CHẾT | 133 lần gọi | p50 1890ms | max 3384ms | allow 133
10–12s bật lại | 94 lần gọi | p50 1ms | max 538ms | allow 94
--- client ioredis commandTimeout 50ms, enableOfflineQueue false, maxRetriesPerRequest 1 → fail-open ---
2–5s Redis TREO | 139 lần gọi | p50 51ms | max 75ms | allow 2, fail-open 137 | vd: fail-open (Command timed out)
7–10s Redis CHẾT | 137 lần gọi | p50 0ms | max 23ms | allow 6, fail-open 131 | vd: fail-open (Stream isn't writeable and enableOffline)
10–12s bật lại | 92 lần gọi | p50 1ms | max 9ms | fail-open 23, allow 69
Mặc định, ioredis giữ lệnh trong offline queue và chờ — mọi request của app chờ theo Redis (tới 3,4 giây trong lab, lâu hơn nếu sự cố lâu hơn). Bẫy kèm theo
khi tắt offline queue: lệnh gửi trước khi kết nối ready bị từ chối ngay ("Stream isn't writeable and enableOfflineQueue options is false") — lần chạy
đầu của lab chết ngay ở lệnh ping() lúc khởi động; app mới deploy cũng sẽ fail-open trong vài trăm mili-giây đầu.
Phía Laravel (phpredis), middleware throttle mặc định:
Redis tắt (cổng đóng) | default_socket_timeout=60 | HTTP 500 sau 0.0 giây | RedisException: Connection refused
Redis treo (docker pause) | default_socket_timeout=3 | HTTP 500 sau 3.0 giây | RedisException: read error on connection to 127.0.0.1:6379
Redis treo (docker pause) | default_socket_timeout=60 | HTTP 500 sau 60.1 giây | RedisException: read error on connection to 127.0.0.1:6379
Chết hẳn: mọi route có throttle trả 500 — limiter biến thành điểm chết của cả site. Treo: mỗi request giữ một worker php-fpm 60 giây (giá trị
default_socket_timeout của PHP, phpredis dùng khi read_timeout bằng 0) → vài giây là hết worker → cả site đứng, kể cả route không có throttle.
| Sự cố | Hậu quả đo được | Phát hiện | Chữa |
|---|---|---|---|
| GET rồi INCR | 189–200/10 lọt | Load test có đồng thời | Quyết định theo giá trị INCR / Lua |
| INCR, EXPIRE tách rời + worker chết | Chặn vĩnh viễn (9/43, TTL −1) | Quét key TTL = -1 | Lua; EXPIRE … NX |
throttle Laravel khi dồn | 33–50/10 lọt | So số 429 với số request lúc cao điểm | Lua 1 lệnh cho thao tác nhạy cảm |
| Lệch đồng hồ app | +20% (±2s) tới mất hẳn giới hạn (70s) | Theo dõi độ lệch NTP từng node | TIME trong script |
| Redis chết | Laravel: 500 mọi route có throttle | Tỉ lệ 5xx | Bắt lỗi, fail-open + cảnh báo |
| Redis treo | 60,1 giây/request (phpredis), 1,5–3,4 giây (ioredis mặc định) | p99 độ trễ, worker bận | Timeout ngắn: read_timeout, commandTimeout |
| Failover | Mất 60 lần đếm | Log failover của Sentinel/Cluster | Chấp nhận (soft state); đừng dùng làm quota tính tiền |
Dùng chung Redis với cache allkeys-lru | Bộ đếm bị đuổi, đếm lại từ 1 | evicted_keys tăng | Redis riêng cho limiter, hoặc noeviction + xử lý lỗi OOM |
| Script 2 key khác slot trên Cluster | CROSSSLOT | Lỗi ngay khi chạy | Hash tag {user:42} |
Lab về chuyện dùng chung Redis với cache (Redis maxmemory 2 MB, thêm 4 000 key cache 1 KB sau khi limiter đã đếm 50):
allkeys-lru | GET bộ đếm limiter: | evicted_keys=3442 | lệnh SET cache bị từ chối (OOM): 0 | INCR limiter bây giờ: 1
noeviction | GET bộ đếm limiter: 50 | evicted_keys=0 | lệnh SET cache bị từ chối (OOM): 3401 | INCR limiter bây giờ: OOM command not allowed when used memory > 'maxmemory'.
Với allkeys-lru, kẻ tấn công chỉ cần làm đầy cache (vd gọi nhiều trang sản phẩm khác nhau) là xoá được bộ đếm đăng nhập sai của chính họ.
6. Vận hành trong production
- Kết nối Redis riêng cho limiter, timeout ngắn: phpredis
timeout+read_timeout(lab 0,2 giây → fail-open sau 0,23 giây); iorediscommandTimeout+enableOfflineQueue: false. Mặc định của cả hai thư viện là chờ rất lâu. - Redis riêng (hoặc ít nhất DB/instance có
maxmemory-policyphù hợp) — đừng chung với cache LRU. - Metric: độ trễ lệnh limiter (p99), số lần fail-open (phải có cảnh báo — lab cho thấy fail-open che cả lỗi cấu hình), lỗi kết nối, số key, RAM,
evicted_keys, độ lệch NTP của từng node, số lầnNOSCRIPT. - Capacity: lệnh Redis/giây = request/giây × số limiter mỗi request (gộp vào 1 script nếu được); RAM = key đang hoạt động × byte/key (bảng ở bài 2).
- Đổi định dạng key là reset mọi bộ đếm — làm lúc thấp điểm, hoặc đọc cả key cũ lẫn mới trong thời gian chuyển.
EXPIRE … NXcần Redis ≥ 7.0. - Runbook 3h sáng: Redis limiter chết → limiter fail-open (đã có cảnh báo) → xem traffic có tăng bất thường không → nếu đang bị tấn công, bật giới hạn tạm ở nginx (không phụ thuộc Redis).
7. So sánh và ma trận quyết định
| Cách | Độ chính xác | Độ trễ thêm | Khi kho trạng thái hỏng | Gánh vận hành |
|---|---|---|---|---|
| RAM từng instance | Sai ×N instance | ~0 | Không có kho để hỏng | Thấp |
| 1 Redis + Lua | Đúng (trừ failover) | 1 vòng mạng (~0,2ms) | Phải chọn fail-open/closed | Trung bình |
| Redis Cluster + hash tag | Đúng theo key | 1 vòng mạng | Mất 1 shard = mất 1 phần key | Cao |
| Cục bộ + đồng bộ định kỳ | Lọt theo chu kỳ (1s → ~4,5%) | ~0 | Vẫn chạy bằng số cục bộ | Trung bình |
| Gateway/global service (Envoy) | Đúng theo cấu hình | 1 gRPC mỗi request | Theo cấu hình failure mode của gateway | Cao, cần đội platform |
Dùng 1 Redis + Lua cho hầu hết hệ thống cỡ shop (vài trăm tới vài nghìn request/giây). Thêm lớp cục bộ khi Redis thành nút cổ chai hoặc độ trễ quan trọng hơn vài phần trăm chính xác. Tránh limiter trong RAM từng instance khi có hơn một instance và giới hạn có ý nghĩa bảo mật.
8. Góc nhìn tech lead: tác động tổ chức
- ADR cho từng loại limiter ghi rõ fail-open hay fail-closed và vì sao: storefront fail-open (mất limiter vài phút không sao); OTP/đăng nhập cân nhắc fail-closed hoặc fallback sang limiter cục bộ.
- Blast radius: Redis của limiter là dependency của mọi request. Tách khỏi Redis của queue và cache để một sự cố không kéo theo tất cả.
- Đừng tin tên hàm: "
throttleWithRedisdùng Lua nên nguyên tử" là giả định sai (lab). Thêm vào checklist review: limiter cho thao tác nhạy cảm phải có test đồng thời. - Exit strategy: bọc mọi limiter sau một interface (
allow(key, cost)); đổi Redis đơn sang Cluster hay sang gateway chỉ đổi một chỗ.
9. Anti-patterns và bài học thực tế
- GET rồi INCR (chép mẫu thứ hai của docs Redis mà bỏ qua đoạn nói về race).
- INCR rồi EXPIRE bằng 2 lệnh rời — nổ trễ, chặn vĩnh viễn.
- Lấy giờ từ app server cho thuật toán cần "bây giờ" — kể cả
throttleWithRedis. - Không đặt timeout cho kết nối Redis của limiter — Redis treo thành site treo.
- Fail-open không cảnh báo — lỗi cấu hình làm limiter tắt mà không ai biết.
- Limiter chung Redis với cache LRU.
RateLimiter::attempt()cho thao tác chậm — 50/50 lọt.- Dùng bộ đếm rate limit làm quota tính tiền.
Nguồn công khai: Stripe 2017 chủ ý fail-open: "Make sure that if there were bugs in the rate limiting code (or if Redis were to go down), requests
wouldn't be affected."; Envoy docs (global rate limit qua gRPC, kết hợp local); README envoyproxy/ratelimit (Redis, cửa sổ cố định theo giây/phút/giờ/ngày,
cache cục bộ key đã vượt, shadow_mode, ngưỡng near-limit 80%); Cloudflare 2017 (đếm bất đồng bộ + cờ chặn).
10. Câu hỏi phụ interviewer hay hỏi tiếp
- Vì sao cần Lua mà không dùng MULTI/EXEC?
- MULTI/EXEC gom lệnh lại chạy liền nhau nhưng không có điều kiện bên trong (không "nếu đếm > N thì…"). Lua đọc, tính, ghi trong một bước. Riêng cặp INCR + EXPIRE NX thì MULTI là đủ.
- Throttle của Laravel có đúng khi nhiều worker không?
- Không hoàn toàn: cả bản cache lẫn bản Redis đều kiểm tra rồi mới tăng bằng 2 lệnh; lab 50 worker cho qua 33–50 với giới hạn 10. Đủ cho chống cào thô, không đủ cho đăng nhập/OTP.
- Redis chết thì sao?
- Mặc định Laravel trả 500 mọi route có throttle; Redis treo thì 60 giây/request. Bắt lỗi + timeout ngắn + fail-open có cảnh báo, hoặc fallback limiter cục bộ.
- Lệch đồng hồ ảnh hưởng thế nào?
- Thuật toán lấy giờ app: lệch ±2 giây lọt ~20%, lệch hơn độ dài cửa sổ là mất giới hạn (lab DurationLimiter). Dùng
TIMEcủa Redis trong script. - Failover có mất dữ liệu không?
- Có — replication bất đồng bộ; lab mất 60 lần đếm. Với rate limit chấp nhận được; với quota tính tiền thì không.
- Một Redis chịu được bao nhiêu?
- Lab trên máy dùng chung: ~27 000 EVALSHA/giây không pipeline, ~155 000 với pipeline 16. Thực tế phụ thuộc phần cứng và độ phức tạp script.
- Làm sao không gọi Redis mỗi request?
- Đếm cục bộ, đồng bộ định kỳ (lọt tỉ lệ với chu kỳ), cache cục bộ các key đã vượt, hoặc token bucket cục bộ phía trước limiter toàn cục.
Phụ lục: lab đã chạy
Môi trường: Redis 8.8.0 (redis:alpine), Node 22.23.3 + ioredis 5.11.1, Laravel 12.64.0 + PHP 8.3.33 + phpredis (project laravel-livewire
mount read-only), container tạm tmp-rl-* trên network tmp-rl-net. Máy 4 vCPU dùng chung.
cd labs/rate-limit
./03-redis.sh # node (A–I) | outage | failover | cluster | gcra | evict → out/03*.txt
./05-laravel.sh race # 50 process PHP cùng lúc → out/05-race.txt
./05-laravel.sh atomic # AtomicThrottle: tuần tự, Redis tắt, Redis treo → out/05-atomic.txt
./05-laravel.sh skew # DurationLimiter với 2 đồng hồ lệch → out/05-skew.txt
./05-laravel.sh down # throttle mặc định khi Redis tắt / treo → out/05-redis-down.txt
docker rm -f tmp-rl-redis; docker network rm tmp-rl-net # dọn sau khi xong
Chưa verify: limiter bằng bảng PostgreSQL (khoá dòng, key nóng); Redis Sentinel tự failover (lab promote replica bằng tay); hành vi của
ThrottleRequests bản cache khi đồng hồ app lệch (suy từ source); key nóng trên Cluster ở tải lớn.