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, EXPIRE tuỳ chọn NX); Stripe 2017 (fail-open); Envoy docs (global rate limiting) và README envoyproxy/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: INCR rồ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àm throttleWithRedis củ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ì throttle củ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ính throttle củ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ƯuNhược
Trong RAM của từng process / instanceNhanh 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 chungLệnh nguyên tử, TTL sẵn, ~0,2ms mỗi lệnhThê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 PostgreSQLKhông thêm hạ tầngMỗ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ạngVượ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

  1. 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."
  2. 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 INCR cho lọt, một lệnh Lua thì không Bên trái: ba worker cùng gửi GET, cùng đọc được 9, cùng thấy còn dưới giới hạn 10 nên cùng INCR; bộ đếm lên 12, ba request đều qua. Bên phải: ba worker gửi lệnh Lua; Redis chạy từng lệnh một, worker đầu nhận 10 và được qua, hai worker sau nhận 11 và 12 nên bị chặn. GET → so sánh → INCR (2 lệnh) 1 lệnh Lua: INCR + so sánh worker 1worker 2worker 3 worker 1worker 2worker 3 Redis Redis 9101112 9101112 đọc 9 < 10 → quađọc 9 < 10 → quađọc 9 < 10 → qua 3 request cùng qua → bộ đếm 12, vượt giới hạn 10 nhận 10 → qua nhận 11 → 429 nhận 12 → 429 Redis chạy từng script một → đúng 1 request qua
Hình 1 — Bên trái là mẫu "rate limiter 2" trong docs Redis (kiểm tra bằng 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).

Worker chết giữa INCR và EXPIRE Hàng trên: INCR trả về 1, worker bị kill trước khi gọi EXPIRE, key không có TTL; các request sau đẩy bộ đếm lên mãi, từ request thứ 11 bị chặn vĩnh viễn. Hàng dưới: MULTI gồm INCR và EXPIRE NX; request kế tiếp đặt lại TTL nên key hết hạn và đếm lại bình thường. INCR rồi EXPIRE2 lệnh rời MULTI {INCR;EXPIRE NX} INCR → 1 ✕worker bị killEXPIRE không chạy TTL = −1: key sống mãi, đếm 2, 3 … 44 từ request thứ 11: 429 mãi mãi INCR → 1 ✕key rò, không TTL request kế: EXPIRE NX đặt TTL hết hạn đúng hẹn → đếm lại: 29/53 qua
Hình 2 — Số liệu lab B. Lỗi kiểu này nổ trễ và khó tìm: không có exception nào, chỉ một vài user than "bị chặn mãi không hết" sau lần deploy có worker bị kill giữa chừng. Kiểm tra nhanh trên production: 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

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)

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
Hai server lệch 70 giây thay nhau đặt lại cửa sổ Trục thời gian 0 tới 200 giây. Server A thấy bây giờ là 140, server B chậm 70 giây thấy bây giờ là 70. Request từ A thấy now nằm ngoài cửa sổ 70 tới 130 nên đặt lại cửa sổ thành 140 tới 200. Request từ B thấy now nhỏ hơn start 140 nên đặt lại thành 70 tới 130. Hai bên thay nhau đặt lại, bộ đếm luôn quay về 1, không request nào bị chặn. 0s70s130s140s200s cửa sổ 60s · count = 1 now của B (chậm 70s) now của A A: now ngoài cửa sổ → đặt lại · B: now < start → đặt lại · lặp mãi → 600/600 request qua (giới hạn 100)
Hình 3 — Lệch nhỏ (±2 giây) chỉ làm lọt ~20% mỗi phút; lệch lớn hơn độ dài cửa sổ thì limiter coi như tắt. Lệch 70 giây nghe hiếm, nhưng gặp thật khi một VM mới chưa chạy NTP, container dùng đồng hồ host sai, hoặc ai đó giả giờ để test rồi quên. 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

Failover làm bộ đếm quay ngược Master và replica cùng ở 40. Mạng tới replica bị cắt, master tiếp tục đếm tới 100. Master chết, replica được nâng lên làm master mới với giá trị 40. Sáu mươi lượt đã đếm bị mất, client được cấp hạn mức lần nữa. master replica 4070100 40 ✕mạng tới replica bị cắtWAIT 1 500 → 0 ✕ master chết REPLICAOF NO ONE → master mới bộ đếm quay về 40: 60 lượt đã dùng được cấp lại
Hình 4 — Số liệu lab F (Redis 8.8, master + replica, cắt mạng bằng 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.

Độ trễ của lệnh limiter khi Redis treo và chết Năm pha: bình thường, Redis treo, bình thường, Redis chết, bật lại. Client mặc định: độ trễ trung vị 1490 mili giây khi treo và 1890 khi chết. Client có timeout 50 mili giây và fail-open: 51 mili giây khi treo, 0 khi chết. 0–2s2–5s Redis treo5–7s7–10s Redis chết10–12s ioredis mặc địnhtimeout 50ms+ fail-open p50 1 490msp50 1 890ms 51ms, fail-open0ms, fail-open
Hình 5 — Mỗi lần gọi limiter là một lời hứa "trả lời trong vài mili-giây". Không có timeout, sự cố của Redis truyền thẳng thành độ trễ của mọi request. Với php-fpm, độ trễ đó còn chiếm worker — nên timeout ngắn cho kết nối Redis của limiter quan trọng ngang chính thuật toán.
Sự cốHậu quả đo đượcPhát hiệnChữa
GET rồi INCR189–200/10 lọtLoad test có đồng thờiQuyết định theo giá trị INCR / Lua
INCR, EXPIRE tách rời + worker chếtChặn vĩnh viễn (9/43, TTL −1)Quét key TTL = -1Lua; EXPIRE … NX
throttle Laravel khi dồn33–50/10 lọtSo số 429 với số request lúc cao điểmLua 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 nodeTIME trong script
Redis chếtLaravel: 500 mọi route có throttleTỉ lệ 5xxBắt lỗi, fail-open + cảnh báo
Redis treo60,1 giây/request (phpredis), 1,5–3,4 giây (ioredis mặc định)p99 độ trễ, worker bậnTimeout ngắn: read_timeout, commandTimeout
FailoverMất 60 lần đếmLog failover của Sentinel/ClusterChấp nhận (soft state); đừng dùng làm quota tính tiền
Dùng chung Redis với cache allkeys-lruBộ đếm bị đuổi, đếm lại từ 1evicted_keys tăngRedis riêng cho limiter, hoặc noeviction + xử lý lỗi OOM
Script 2 key khác slot trên ClusterCROSSSLOTLỗi ngay khi chạyHash 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

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

CáchĐộ chính xácĐộ trễ thêmKhi kho trạng thái hỏngGánh vận hành
RAM từng instanceSai ×N instance~0Không có kho để hỏngThấp
1 Redis + LuaĐúng (trừ failover)1 vòng mạng (~0,2ms)Phải chọn fail-open/closedTrung bình
Redis Cluster + hash tagĐúng theo key1 vòng mạngMất 1 shard = mất 1 phần keyCao
Cục bộ + đồng bộ định kỳLọt theo chu kỳ (1s → ~4,5%)~0Vẫn chạy bằng số cục bộTrung bình
Gateway/global service (Envoy)Đúng theo cấu hình1 gRPC mỗi requestTheo cấu hình failure mode của gatewayCao, 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

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

  1. GET rồi INCR (chép mẫu thứ hai của docs Redis mà bỏ qua đoạn nói về race).
  2. INCR rồi EXPIRE bằng 2 lệnh rời — nổ trễ, chặn vĩnh viễn.
  3. Lấy giờ từ app server cho thuật toán cần "bây giờ" — kể cả throttleWithRedis.
  4. Không đặt timeout cho kết nối Redis của limiter — Redis treo thành site treo.
  5. 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.
  6. Limiter chung Redis với cache LRU.
  7. RateLimiter::attempt() cho thao tác chậm — 50/50 lọt.
  8. 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 TIME củ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.