Rate limit phân tán bằng Redis và Lua: quota nguyên tử cho nhiều API instance

Cho ba connection tranh một quota chung, kiểm tra TTL và xử lý Redis mất kết nối. Phân biệt rate limit, concurrency limit và chính sách fail open/closed.
Đặt bộ đếm request trong từng process hoạt động tốt khi chỉ có một instance. Sau khi chạy ba replica, một tenant có thể dùng ba hạn mức riêng nếu load balancer chia đều lưu lượng. Khởi động lại process còn làm bộ đếm mất đi. Vấn đề nằm ở điểm phân xử chung, không chỉ ở cách viết middleware.
Lab này dùng một Lua script trong Redis để kiểm tra quota, tăng counter và đặt TTL nguyên tử. Ba connection Node.js gửi 30 yêu cầu song song vào cùng một phạm vi, với hạn mức 10. Sau đó bạn kiểm tra quota hồi phục khi key hết hạn và xem ứng dụng phải làm gì khi Redis lỗi.
1. Quyết định giới hạn ai và giới hạn việc gì
Quota mẫu áp dụng cho tenant đã xác thực và một route nghiệp vụ cố định như POST/orders. Không dùng URL đầy đủ có query tùy ý làm route vì mỗi query khác nhau có thể tạo một key mới. Không tin tenant ID do client tự điền nếu endpoint đã có phiên xác thực.

IP có thể hữu ích cho lớp bảo vệ ngoài cùng, nhưng nhiều người cùng NAT sẽ dùng một IP. Ngược lại, một người gọi có thể thay nhiều IP. Đọc X-Forwarded-For mà chưa xác định proxy tin cậy còn tạo cơ hội giả mạo danh tính quota. Lớp rate limit theo tenant không thay thế kiểm soát đăng nhập, WAF hay chống DDoS.
Trong bài, cửa sổ bắt đầu khi request đầu tiên tạo key; các request sau chia sẻ counter và thời gian sống còn lại. Đây là fixed window neo theo lần truy cập đầu, không phải token bucket. Nó dễ triển khai nhưng có thể cho một đợt burst ở cuối cửa sổ rồi thêm burst ngay đầu cửa sổ kế tiếp.
2. Dựng Redis riêng và cài client
Cần Docker Compose và Node.js 24. Cổng 16379 của host phải trống. Redis lab không có password vì chỉ publish trên loopback; không dùng nguyên cấu hình này trên máy có thể truy cập từ Internet hoặc mạng dùng chung.
Image được ghim bằng digest từ lần chạy lab để tránh tag thay đổi làm lệch kết quả. Chỉ chạy cấu hình này trong môi trường thử nghiệm riêng.
File: ratelimit.compose.yaml
name: thanhnh-architecture-20261020
services:
redis:
image: redis:8-alpine@sha256:3811787313eba226a2ef38658c6ccb91cd5e110edc89c37767de373120a0e5a0
ports: ["127.0.0.1:16379:6379"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 2s
retries: 30
File: ratelimit-setup.sh
node --version # dùng Node.js 24
npm init -y
npm install redis@6.3.0
docker compose up -d --wait
Lưu script bên dưới thành limit.lua. Limit và window là policy do server cung cấp, không lấy trực tiếp từ request client. Script chỉ chạm một key; tránh vòng lặp dài hoặc quét keyspace trong đường xử lý request.
File: limit.lua
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local n = tonumber(redis.call('GET', KEYS[1]) or '0')
if n >= limit then
local ttl = redis.call('PTTL', KEYS[1])
if ttl < 0 then redis.call('PEXPIRE', KEYS[1], window); ttl = window end
return {0, 0, ttl}
end
n = redis.call('INCR', KEYS[1])
local ttl = redis.call('PTTL', KEYS[1])
if ttl < 0 then redis.call('PEXPIRE', KEYS[1], window); ttl = window end
return {1, limit - n, ttl}
Redis giải thích Lua script chạy nguyên tử đối với các thao tác khác trên server. Điều này loại bỏ khoảng trống giữa đọc counter và tăng counter trong thuật toán nhỏ này. Nó không làm Redis cluster thành một transaction đa shard và không bảo đảm mọi lần failover đều giữ đủ trạng thái quota.
3. Vì sao không tách INCR và EXPIRE thành hai request?
Nếu process chết sau INCR nhưng trước EXPIRE, key có thể không hết hạn. Nếu kiểm tra GET ở client rồi quyết định tăng sau, nhiều replica có thể cùng nhìn thấy còn quota và cùng cho qua. Pipeline giảm round trip nhưng không tự biến một chuỗi quyết định ở client thành thao tác nguyên tử.

Mẫu kiểm tra n đã đạt limit thì trả về ngay, không tiếp tục tăng counter cho request bị từ chối. Khi tăng một key mới hoặc phát hiện key không có TTL, nó đặt PEXPIRE. Nhánh sửa key thiếu TTL là phương án tự phục hồi cho trạng thái bất thường, không phải cách thay đổi cửa sổ ở mỗi request.
PEXPIRE dùng mili giây; PTTL cũng trả thời gian sống theo mili giây và có giá trị âm cho những trạng thái đặc biệt. Code xử lý TTL âm, rồi HTTP adapter đổi thời gian còn lại sang giây làm tròn lên cho Retry-After. Đừng nhầm một quota theo phút với TTL tính bằng giây khi ghép cấu hình.
4. Tạo wrapper Node.js có deadline rõ ràng
Lưu ratelimit.mjs. Key được dựng từ mảng tenant/route rồi hash để tránh ký tự đầu vào làm thay đổi cấu trúc key. Việc hash không được coi là mã hóa dữ liệu riêng tư. Namespace và policy version cần được quản lý nếu có nhiều loại quota trong cùng Redis.
File: ratelimit.mjs
import { createClient } from 'redis';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
export const lua = await readFile(new URL('./limit.lua', import.meta.url), 'utf8');
export async function connectRedis() {
const client = createClient({ url: 'redis://127.0.0.1:16379',
disableOfflineQueue: true,
socket: { connectTimeout: 1000, reconnectStrategy: false },
});
client.on('error', () => {}); // Record a sanitized metric in a real application.
await client.connect(); return client;
}
export async function take(client, tenant, route, limit = 10, windowMs = 1000) {
if (typeof tenant !== 'string' || !tenant || typeof route !== 'string' || !route || !Number.isInteger(limit) || limit < 1 || !Number.isInteger(windowMs) || windowMs < 1) {
throw new Error('invalid server-side rate policy');
}
const identity = createHash('sha256').update(JSON.stringify([tenant, route])).digest('hex');
const result = await client.withCommandOptions({ abortSignal: AbortSignal.timeout(150) }).eval(lua, { keys: [`rate:{${identity}}`], arguments: [String(limit), String(windowMs)],
});
const [allowed, remaining, ttlMs] = result.map(Number);
return { allowed: allowed === 1, remaining, retryAfterSeconds: Math.max(1, Math.ceil(ttlMs / 1000)) };
}
Client dùng disableOfflineQueue để không xếp một loạt lệnh trong bộ nhớ khi Redis không sẵn sàng; lab cũng tắt tự reconnect để thấy lỗi trực tiếp. Tài liệu production của node-redis mô tả các quyết định về kết nối, lỗi và hàng đợi. Khi bật reconnect trong ứng dụng thật, cần backoff, metric và kiểm tra hành vi lúc phục hồi.
AbortSignal giới hạn thời gian chờ kết quả tại client. Nếu request đã được gửi sang Redis rồi timeout, không thể suy ra script chưa chạy. Quota có thể đã bị tiêu hao dù API trả 503; không “hoàn quota” bằng một DECR bù tùy tiện vì dễ đua với request khác và làm sai giới hạn.
Chuyển kết quả sang HTTP
Nếu allowed=true, chạy handler nghiệp vụ. Nếu false, trả 429 và Retry-After trước khi mở transaction nặng hoặc gọi dependency đắt tiền. Nếu Redis ném lỗi, đi vào nhánh chính sách hạ tầng riêng. Ví dụ adapter bên dưới minh họa cách trả status; request identity phải được xác thực trước khi gọi hàm này.
File: ratelimit-http.mjs
import { take } from './ratelimit.mjs';
// Call only after authentication; tenant comes from the verified identity.
export async function enforceLimit(redis, res, tenant) {
try {
const result = await take(redis, tenant, 'POST/orders', 10, 1000);
if (result.allowed) return true;
res.writeHead(429, { 'content-type': 'application/json',
'retry-after': String(result.retryAfterSeconds) });
res.end(JSON.stringify({ error: 'rate limit exceeded' }));
} catch {
// This route deliberately fails closed on infrastructure errors.
res.writeHead(503, { 'content-type': 'application/json', 'retry-after': '1' });
res.end(JSON.stringify({ error: 'rate limiter unavailable' }));
}
return false;
}
Deadline 150 ms được chọn cho lab, không phải mặc định cho mọi mạng. Có môi trường cần budget ngắn hơn để rate limiter không chiếm phần lớn thời gian request; cũng có mạng cần đo độ trễ trước khi đặt ngưỡng. Middleware còn phải giới hạn số lời gọi Redis đồng thời để một đợt lỗi không tạo hàng nghìn promise đang chờ.
5. Thử ba connection cùng tranh một quota
Lưu và chạy ratelimit-demo.mjs. Mỗi lần thử có tenant ngẫu nhiên, không cần FLUSHDB để dọn. Không chạy FLUSHDB trên một Redis dùng chung chỉ để đưa counter về không.
File: ratelimit-demo.mjs
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { connectRedis, take } from './ratelimit.mjs';
const clients = await Promise.all([connectRedis(), connectRedis(), connectRedis()]);
try {
const tenant = randomUUID();
const results = await Promise.all(Array.from({ length: 30 }, (_, i) =>
take(clients[i % 3], tenant, 'POST/orders', 10, 1000)));
assert.equal(results.filter((r) => r.allowed).length, 10);
await sleep(1100);
assert.equal((await take(clients[0], tenant, 'POST/orders', 10, 1000)).allowed, true);
console.log('PASS: 10 admitted across 3 connections; TTL restores the quota');
} finally { await Promise.all(clients.map((client) => client.close())); }
File: ratelimit-run.sh
node ratelimit-demo.mjs
Kiểm thử cục bộ nhận đúng 10 kết quả cho phép trong 30 lời gọi qua ba connection. Sau khi đợi hết TTL, lời gọi mới được phép. Một kiểm thử khác đóng connection rồi gọi limiter, xác nhận hàm ném lỗi thay vì âm thầm trả allowed. Kết quả này xác nhận tính năng của code mẫu trên lab một node, không đo năng lực phục vụ của cụm production.
6. Redis lỗi: fail open hay fail closed?

Fail closed phù hợp khi việc không kiểm soát được quota có thể gây tổn thất đáng kể, như endpoint tạo tác vụ đắt tiền. Nhưng lỗi hạ tầng nên là 503, không giả thành 429 vì người dùng chưa chắc đã hết hạn mức. Fail open ưu tiên khả dụng, chấp nhận tạm giảm kiểm soát; phải có giới hạn cục bộ và khả năng quan sát để tránh chuyển lỗi Redis thành quá tải database.
Chính sách có thể khác nhau theo route, nhưng phải được công bố trong code và runbook. Không bật fail open chỉ vì muốn giảm số lỗi trên dashboard. Khi test, dừng đúng container Redis của lab rồi quan sát adapter, sau đó khởi động lại. Thao tác dừng Redis thật sẽ ảnh hưởng các ứng dụng khác và có thể làm mất quota nếu kho này không được thiết kế lưu bền.
7. Các giới hạn cần xử lý trước khi mở rộng
Đổi policy giữa cửa sổ: cùng key nhưng replica dùng limit khác nhau sẽ cho kết quả khó giải thích. Cần rollout cấu hình nhất quán hoặc đưa policy version vào key, đồng thời tính đến việc version mới tạo quota mới. Đừng coi một rollout nhiều bước là nguyên tử.
Eviction và failover: nếu Redis xóa key do thiếu bộ nhớ, cửa sổ có thể bắt đầu lại sớm. Với quota quan trọng, tách kho quota khỏi cache có eviction tùy tiện, theo dõi bộ nhớ và quyết định bảo đảm khi failover. Lua nguyên tử không đồng nghĩa bộ đếm bền qua mọi lỗi replication.
Hot key và cardinality: một tenant cực lớn vẫn có thể tạo hot key. Quá nhiều scope khác nhau lại làm tăng số key. Chọn tenant, user hoặc API key theo mô hình thật, đặt TTL và quan sát số key sống cùng mức phân bố tải. Không thử giải quyết hot key bằng cách chia counter tùy tiện rồi vẫn gọi tổng là quota chính xác.
Nhiều key trong Redis Cluster: khi cần giới hạn cả tenant và user trong cùng script, các key phải theo điều kiện hash slot của cluster. Mẫu chỉ dùng một key và EVAL trực tiếp; nếu chuyển sang EVALSHA, cần xử lý NOSCRIPT sau khởi động lại hoặc đổi node.
8. Checklist vận hành cho lần rollout đầu
- Đo số request theo scope và thời gian script trước khi enforce. Có thể bắt đầu ở chế độ chỉ ghi nhận trong thời gian ngắn.
- Phân biệt allowed, rate-limited, timeout, connection error và fail-open trên metric. Không chỉ có một counter “lỗi Redis”.
- Gắn request ID vào log, giữ tenant ở dạng được phép ghi; tránh biến metric label thành danh sách mọi user có cardinality quá cao.
- Test boundary burst, hai replica khác policy và mất kết nối sau khi gửi lệnh. Xác nhận status và Retry-After của HTTP adapter.
- Rollback bằng cấu hình có chủ đích; không xóa hàng loạt key hoặc FLUSHDB để làm tỷ lệ 429 biến mất.
Nếu cần tốc độ trung bình mượt hơn hoặc hỗ trợ burst có kiểm soát, đánh giá token bucket hay sliding window bằng cùng bộ tình huống. Không nên gọi tên thuật toán mạnh hơn trong tài liệu khi code vẫn là một counter có TTL.