09:00, một đối tác mở chiến dịch và traffic tăng gấp mười. 09:01, vòng lặp retry của một tenant chiếm hết worker. 09:02, API trả 503 cho cả những khách hàng không gây ra vấn đề. Rate limiter đáng lẽ phải ngăn câu chuyện này, nhưng counter nằm riêng trong từng process không thể làm được: mỗi replica chỉ thấy một phần traffic, replica liên tục được thêm bớt, còn retry có thể chạy sang máy khác.

Rate limiter phân tán biến chính sách như “120 lần ghi mỗi phút cho mỗi tenant” thành quyết định chung cho nhiều bên gọi. Trong production, đây còn là bài toán về thời gian, danh tính, tính nhất quán, tính sẵn sàng và chống lạm dụng. Thiết kế cần cơ chế nhỏ nhất có sai số phù hợp với rủi ro của thao tác được bảo vệ.

Bài viết sẽ xây token bucket dựa trên Redis bằng TypeScript. Cùng thiết kế này có thể bảo vệ HTTP gateway, queue consumer hoặc một năng lực nội bộ tốn kém, miễn là policy và identity được xác định rõ.

Bắt đầu từ policy rồi mới chọn thuật toán

Một giới hạn cần đối tượng, thao tác, sức chứa và tốc độ nạp. “Theo IP” chưa đủ; “mỗi tenant đã xác thực được export 100 báo cáo mỗi giờ, burst 5 lần” mới có thể triển khai. Tách policy cho đọc, ghi, đăng nhập và export để endpoint rẻ không tranh ngân sách với endpoint đắt.

Các thuật toán phổ biến đưa ra những bảo đảm khác nhau:

Thuật toán Trạng thái Hành vi khi burst Độ chính xác Trường hợp phù hợp
Fixed window Một counter và thời điểm hết hạn Có thể cho qua gấp đôi giới hạn quanh biên cửa sổ Thô Quota rủi ro thấp, cần chi phí nhỏ
Sliding log Timestamp của từng request Chính xác trong cửa sổ Chính xác nhưng tốn bộ nhớ Giới hạn bảo mật nhỏ và nghiêm ngặt
Sliding window counter Counter hiện tại và liền trước Giảm spike tại biên Xấp xỉ Quota API thông thường
Token bucket Số token và lần nạp gần nhất Cấu hình rõ dung lượng burst Chính xác nếu update nguyên tử API và worker phân tán
Leaky bucket Queue hoặc mực nước Tạo tốc độ đầu ra đều Điều tiết chính xác Nhận việc và làm mượt traffic

Với token bucket có sức chứa CC, tốc độ nạp rr token mỗi mili giây, thời gian đã qua Δt\Delta t và chi phí request ww, số dư khả dụng là

T=min(C,T+rΔt)T' = \min(C, T + r\Delta t)

Request được chấp nhận khi TwT' \ge w; số dư được lưu sau đó là TwT' - w. Sức chứa quyết định burst cho phép, còn tốc độ nạp quyết định lưu lượng duy trì. Chi phí có trọng số cho phép một lần đọc cache tốn một token trong khi một tác vụ transcode video tốn năm mươi.

Token bucket không bảo đảm đầu ra đều. Nếu downstream chỉ được nhận mười job mỗi giây, hãy dùng leaky bucket hoặc queue scheduler. Với “không quá năm lần đoán mật khẩu trong bất kỳ khoảng mười phút nào”, sliding log có thể đáng với chi phí bộ nhớ.

Tip

Hãy viết policy bằng ngôn ngữ sản phẩm trước khi chọn lệnh Redis. Triển khai chính xác một quota mơ hồ vẫn chỉ tạo ra hành vi sản phẩm mơ hồ.

Để key biểu diễn đúng đối tượng cần giới hạn

Storage key là một phần của mô hình bảo mật. Một cấu trúc hữu ích chứa phiên bản policy, thao tác và subject đã chuẩn hóa:

text
rl:v3:{tenant_7f2}:reports.export

Dấu ngoặc nhọn là hash tag của Redis Cluster, đưa key cùng tenant vào một slot để script có thể update nhiều bucket. Không đưa API key thô, email hay session token vào key hoặc metric. Hãy dùng định danh nội bộ đã chuẩn hóa, giới hạn độ dài và hash giá trị không tin cậy.

Thứ tự chọn identity phải có chủ ý. Với API đã xác thực, ưu tiên tenant hoặc account ID, rồi mới thêm user ID nếu cần. Dùng API key ID nếu từng key có quota riêng. IP chỉ nên là phương án dự phòng cho traffic ẩn danh, không phải danh tính phổ quát: carrier NAT có thể đặt hàng nghìn người sau một địa chỉ, trong khi kẻ tấn công có thể xoay IPv6 hoặc proxy. Chỉ đọc client IP từ header do proxy tin cậy ghi; chấp nhận tùy ý X-Forwarded-For đồng nghĩa cho client tự chọn bucket.

Nhiều lớp thường mạnh hơn một key “hoàn hảo”. Route đăng nhập có thể giới hạn theo account, network prefix và trần toàn hệ thống; route export kiểm tra cả tenant lẫn user. Kẻ tấn công có thể tạo hàng triệu subject ID để lấp Redis, nên hãy xác thực trước khi cấp trạng thái, từ chối identity sai, đặt TTL và giới hạn identity ẩn danh tại edge.

Policy cũng cần version. Thành phần v3 ngăn số dư token cũ bị diễn giải theo capacity hoặc đơn vị mới. Key cũ tự biến mất nhờ TTL, không cần thao tác scan rồi delete đầy rủi ro.

Gộp nạp và tiêu token thành một thao tác nguyên tử

Cách ngây thơ là GET, tính token rồi SET. Hai replica có thể cùng đọc một số dư và cho phép token cuối. Lua script đơn giản hơn optimistic transaction: Redis chạy nguyên tử cạnh dữ liệu trong một network round trip.

lua
-- token_bucket.lua
-- KEYS[1]: bucket key
-- ARGV: capacity, refill tokens/ms, request cost, idle TTL ms
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_per_ms = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local idle_ttl_ms = tonumber(ARGV[4])

if not capacity or not refill_per_ms or not cost or
   capacity <= 0 or refill_per_ms <= 0 or cost <= 0 then
  return redis.error_reply('invalid token bucket arguments')
end

local redis_time = redis.call('TIME')
local now_ms = redis_time[1] * 1000 + math.floor(redis_time[2] / 1000)
local state = redis.call('HMGET', key, 'tokens', 'refilled_at_ms')
local tokens = tonumber(state[1]) or capacity
local refilled_at_ms = tonumber(state[2]) or now_ms
local elapsed_ms = math.max(0, now_ms - refilled_at_ms)

tokens = math.min(capacity, tokens + elapsed_ms * refill_per_ms)

local allowed = 0
local retry_after_ms = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
else
  retry_after_ms = math.ceil((cost - tokens) / refill_per_ms)
end

redis.call('HSET', key,
  'tokens', tostring(tokens),
  'refilled_at_ms', tostring(now_ms))
redis.call('PEXPIRE', key, idle_ttl_ms)

return { allowed, math.floor(tokens), retry_after_ms, now_ms }

Dùng TIME của Redis cho các replica một đồng hồ chung; timestamp từ client có thể khiến clock skew nạp hai lần. math.max(0, ...) phòng clock bị chỉnh lùi. Vì đây không phải monotonic clock tuyệt đối, cần theo dõi đồng bộ thời gian và không dùng riêng trạng thái limiter để tính phí.

Lớp TypeScript kiểm tra policy, tạo key, chạy script đã cache và trả đủ dữ liệu cho HTTP header cùng telemetry. Ví dụ dùng ioredis; code production nên load script rồi ưu tiên EVALSHA, chỉ quay về EVAL khi gặp NOSCRIPT.

ts
import type Redis from 'ioredis';
import tokenBucketLua from './token-bucket.lua.js';

export interface RateLimitPolicy {
  readonly name: string;
  readonly version: number;
  readonly capacity: number;
  readonly refillPerSecond: number;
  readonly idleTtlSeconds: number;
}

export interface RateLimitDecision {
  readonly allowed: boolean;
  readonly remaining: number;
  readonly retryAfterMs: number;
  readonly decidedAtMs: number;
}

function safeSubject(subject: string): string {
  if (!/^[a-zA-Z0-9_-]{1,96}$/.test(subject)) {
    throw new Error('Invalid rate-limit subject');
  }
  return subject;
}

export class RedisTokenBucket {
  constructor(private readonly redis: Redis) {}

  async consume(
    policy: RateLimitPolicy,
    subject: string,
    cost = 1,
  ): Promise<RateLimitDecision> {
    if (!Number.isSafeInteger(cost) || cost <= 0) {
      throw new Error('Cost must be a positive integer');
    }

    const identity = safeSubject(subject);
    const key = `rl:v${policy.version}:{${identity}}:${policy.name}`;
    const refillPerMs = policy.refillPerSecond / 1000;
    const result = (await this.redis.eval(
      tokenBucketLua,
      1,
      key,
      policy.capacity,
      refillPerMs,
      cost,
      policy.idleTtlSeconds * 1000,
    )) as [number, number, number, number];

    return {
      allowed: result[0] === 1,
      remaining: result[1],
      retryAfterMs: result[2],
      decidedAtMs: result[3],
    };
  }
}

Idle TTL phải đủ dài để bucket không dùng đến có thể nạp đầy trước khi bị xóa. Khi đó, xóa key tương đương một bucket đầy chứ không vô tình tăng quota. Nếu cost lớn hơn capacity, hãy từ chối ngay khi kiểm tra policy vì request đó không bao giờ có thể thành công.

Đặt điểm thực thi gần traffic và chọn ngữ nghĩa đa vùng

Áp giới hạn chống lạm dụng thô tại CDN hoặc gateway trước bước xác thực tốn kém, sau đó áp quota theo identity sau khi xác thực nhưng trước khi chạy nghiệp vụ. Queue consumer nên giữ chỗ trong ngân sách trước thao tác không thể hoàn tác. Tránh tính phí nhiều lần cho retry idempotent: hoặc kiểm tra idempotency record trước limiter, hoặc nói rõ quota đếm số lần thử thay vì số thao tác thành công.

flowchart LR Client[Client] --> Edge[CDN và giới hạn tại edge] Edge --> Gateway[API gateway trong vùng] Gateway --> Auth[Xác thực] Auth --> Limiter[Rate-limit service] Limiter --> Redis[(Redis cluster trong vùng)] Auth --> App[Application service] App --> Queue[(Work queue)] Queue --> Worker[Worker có admission limit]
sequenceDiagram participant C as Client participant G as Gateway participant R as Redis participant A as API C->>G: POST /reports G->>G: Xác định tenant và policy G->>R: EVALSHA token bucket R-->>G: allowed, remaining, retryAfter alt Được phép G->>A: Chuyển request đã xác thực A-->>G: 202 Accepted G-->>C: 202 kèm RateLimit header else Bị giới hạn G-->>C: 429 kèm Retry-After end

Redis toàn cầu cho quota nghiêm ngặt nhưng tăng độ trễ và failure domain. Bucket độc lập theo vùng nhanh hơn nhưng có thể cho burst xấp xỉ NN lần với NN vùng. Thường nên chia ngân sách cho từng vùng, tái cân bằng chậm và sticky-route tenant. Thao tác giá trị cao có thể cần home region hoặc store nhất quán toàn cầu.

Replication bất đồng bộ không thể bảo đảm admission global nghiêm ngặt: hai vùng có thể tiêu cùng một token logic trước khi trạng thái hội tụ. Đây phải là tradeoff sản phẩm được công bố, không phải chú thích nhỏ trong implementation.

Hot key cũng giới hạn scale: bucket global tuần tự hóa traffic trên một shard. Dùng key theo tenant, chia giới hạn global với tổng allowance bảo thủ, hoặc bảo vệ tại edge. Có thể pipeline kiểm tra độc lập; các kiểm tra phải cùng thành công cần chung script và cluster slot.

Định nghĩa hành vi lỗi và HTTP contract

Redis rồi sẽ timeout. “Fail open hay fail closed?” phải được trả lời theo từng thao tác. Public read có thể fail open trong thời gian ngắn để giữ availability, với một emergency bucket nhỏ trong process. Login, reset mật khẩu, thanh toán và tạo tài nguyên thường phải fail closed hoặc chuyển sang local limit nghiêm ngặt hơn. Không bao giờ âm thầm fail open cho thao tác nhạy cảm với abuse.

Warning

Limiter là dependency trên request path. Nó cần timeout ngắn, không retry vô hạn, có circuit breaker và degraded mode rõ ràng. Retry Redis lâu hơn deadline của API không bảo vệ được service cũng chẳng giúp được khách hàng.

Quota đã được thực thi phải trả 429 Too Many Requests, không phải 503. Gửi Retry-After theo số giây nguyên và các field nhất quán như RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset. Tài liệu phải nói reset là số giây tới khi đủ token hay biên fixed window. Không để lộ tenant ID nội bộ hoặc cấu trúc key.

Response thành công cũng nên cho client biết ngân sách còn lại khi hữu ích. Chặn giá trị thấp nhất ở zero, làm tròn thời gian reset lên trên và đừng hứa độ chính xác tuyệt đối nếu việc chia ngân sách theo vùng chỉ là xấp xỉ. Error code máy đọc được như RATE_LIMITED giúp SDK phân biệt throttling với lỗi authorization.

Quan sát, kiểm thử và bảo vệ chính limiter

Đo decision theo policy và region: allow, deny, Redis latency, script error, degraded mode và retry-after. Không dùng subject hoặc raw key làm label metric vì gây cardinality vô hạn và lộ định danh. Log lấy mẫu có thể chứa policy, region, cost, decision và subject hash một chiều; trace lời gọi Redis để tìm latency spike.

Test mô hình trước hạ tầng. Reference implementation với clock được inject kiểm tra refill, capacity, weighted cost, thời gian lùi, TTL và biên. Integration test phải chứng minh hàng trăm consumer tranh bucket 100 token chỉ được đúng 100 lần. Cũng cần test script reload sau failover, cluster slot, timeout và policy migration; mô phỏng đa vùng phải đo overshoot thay vì đòi độ chính xác bất khả thi.

Load test phải có traffic lệch, không chỉ random key đồng đều. Hãy thử một tenant cực nóng, rất nhiều identity chỉ gửi một request, burst đồng bộ tại biên phút, request có cost lớn, Redis bị chèn latency và client phớt lờ Retry-After. Xác minh memory ổn định sau TTL và limiter vẫn bảo vệ application khi chính nó đang bị tấn công.

Rate limiting chỉ là một lớp chống abuse. Kết hợp nó với authentication, giới hạn kích thước request, concurrency limit, idempotency, bot detection và rule phát hiện bất thường theo account. Kẻ tấn công kiên nhẫn có thể luôn nằm dưới rate đơn giản nhưng vẫn tiêu tốn tài nguyên đắt; cần chặn cả số việc đồng thời và tổng ngân sách ngày, không chỉ tần suất request.

Những điểm cần nhớ

Limiter production bắt đầu từ policy và identity chính xác, không phải một Redis counter. Token bucket là mặc định tốt vì biểu diễn được cả sustained rate lẫn burst capacity. Hãy lưu phiên bản policy cùng subject đã chuẩn hóa trong key có giới hạn và TTL. Dùng thời gian của Redis cùng một Lua script để refill và consume nguyên tử giữa nhiều replica.

Cần quyết định rõ mức overshoot đa vùng chấp nhận được, cách phân tán hot key và thao tác nào fail open hoặc fail closed. Biến response 429 thành hướng dẫn hữu ích qua header, quan sát decision mà không tạo label cardinality cao, rồi test race, traffic lệch, failover và việc tạo identity thù địch. Limiter thành công khi overload trở thành hành vi sản phẩm được kiểm soát thay vì một outage bất ngờ.