# Rate limiting là gì? Cửa sổ cố định, cửa sổ trượt và xô token

> Ba cách từ chối request khi khách gửi quá nhiều. Chạy cả ba trên cùng một luồng request, xem cách nào để lọt cú dồn, rồi trả mã 429 cho đúng.

Canonical URL: https://learn.chabulab.com/vi/system-design/gioi-han-toc-do-rate-limiting. Kiểm tra thông tin: 2026-11-03.

- Rate limiter đếm request theo từng khoá, ví dụ người dùng hoặc API key, và từ chối phần vượt giới hạn bằng mã 429 kèm header Retry-After.
- Cửa sổ cố định đơn giản nhất nhưng để lọt gấp đôi giới hạn ở chỗ giáp hai cửa sổ. Cửa sổ trượt thì không. Xô token cho phép một cú dồn ngắn, sau đó giữ bạn ở tốc độ nạp lại.
- Chọn khoá trước (người dùng hoặc API key, đừng chỉ dựa vào IP), rồi chọn thuật toán, rồi báo cho client biết khi nào quay lại.

Quán nhỏ chỉ có một người nghe điện thoại. Nếu một trăm người gọi cùng lúc, cách lịch sự là nói "xin gọi lại sau một phút", chứ không để đường dây sập. **Rate limiter** làm đúng việc đó cho máy chủ: nó đếm mỗi người gọi hỏi bao nhiêu lần và từ chối phần thừa.

## Tự chạy thử: cùng một luồng request gặp ba bộ giới hạn thì sao?

Ở bản demo dưới đây, ba bộ giới hạn cùng nhận một chuỗi request. Chấm đặc là request được cho qua. Dấu chéo là request bị từ chối. Giới hạn ban đầu là 5 request mỗi 10 giây.

*Ba bộ giới hạn nhận cùng các request. Chọn một kiểu lưu lượng, kéo giới hạn, hoặc bấm "Gửi một request" để thêm request của riêng bạn vào thời điểm hiện tại.*

Thử theo thứ tự:

1. Giữ **Dồn sát mép cửa sổ** và bấm Chạy. Cửa sổ cố định cho qua cả 10 request, gấp đôi giới hạn, vì 5 request đến ngay trước giây 10 và 5 request ngay sau đó. Cửa sổ trượt chỉ cho qua 5.
2. Chọn **Gấp đôi mức cho phép**. Cả ba dần ổn định quanh tốc độ cho phép, nhưng xô token trước tiên xả hết xô đầy của nó.
3. Chọn **Dồn một lúc rồi nhỏ giọt**. Sau cú dồn, xô token cho request nhỏ giọt đi qua theo tốc độ nạp lại, còn cửa sổ trượt từ chối chúng cho đến khi cú dồn cũ hết hạn.
4. Chọn **Tự bấm**, bấm Chạy và bấm **Gửi một request** thật nhanh. Xem số token của xô giảm xuống rồi từ từ đầy lại.

## Rate limiter là gì và vì sao dịch vụ cần nó?

Rate limiter quyết định, với mỗi request, người gọi này đã hỏi quá nhiều chưa. Nó bảo vệ ba thứ:

- **Năng lực.** Một client chạy vòng lặp không nên làm chậm mọi người khác.
- **Chi phí.** Mỗi request có thể tốn tài nguyên tính toán, một truy vấn cơ sở dữ liệu hoặc một lần gọi dịch vụ trả phí.
- **An toàn.** Đoán mật khẩu hay cào toàn bộ dữ liệu cần rất nhiều request. Giới hạn làm việc đó chậm đi.

Mỗi bộ giới hạn có hai phần: **khoá** (đếm theo ai) và **luật** (bao nhiêu request trong bao lâu). Khoá đi trước, nên phần kế tiếp nói về khoá.

Request → Khoá → Bộ giới hạn → Cho qua → 429

## Nên đếm theo khoá nào?

Hãy đếm theo đối tượng bạn muốn đối xử công bằng. Chọn sai khoá sẽ phạt nhầm người.

| Khoá | Hợp với | Cần để ý |
| --- | --- | --- |
| Địa chỉ IP | Lưu lượng ẩn danh, như trang đăng nhập | Nhiều người trong cùng một văn phòng hay mạng di động dùng chung địa chỉ. Một người cũng có thể đổi địa chỉ |
| ID người dùng | Người dùng đã đăng nhập | Phải nhận ra người gọi trước khi bộ giới hạn chạy |
| API key | Lập trình viên bên thứ ba, mỗi key một gói | Key bị lộ sẽ tiêu hết hạn mức |
| Tenant hoặc tài khoản | Khách doanh nghiệp có nhiều người dùng | Một người dùng nặng ăn hết phần của cả nhóm |
| Route cộng khoá | Route đắt hoặc nhạy cảm, như đăng nhập hay xuất dữ liệu | Phải giữ nhiều bộ đếm hơn |

Tài liệu của Cloudflare khuyên dùng định danh ổn định như ID người dùng, ID tenant hoặc route thay vì địa chỉ IP. Bạn cũng có thể kết hợp hai giới hạn: một giới hạn rộng theo IP cho mọi người và một giới hạn chặt hơn theo người dùng hoặc key.

## Ba thuật toán hoạt động thế nào và cái nào hợp với việc gì?

Mã bên dưới chính là thứ bản demo chạy, để bạn đọc được. Mỗi hàm nhận thời điểm của một request (tính bằng giây) và trả `true` (cho qua) hoặc `false` (từ chối).

**Cửa sổ cố định.** Chia thời gian thành các lát 10 giây theo đồng hồ. Đếm request trong lát hiện tại. Khi lát mới bắt đầu, bộ đếm về không.

```ts
export type Limiter = (t: number) => boolean;

export function fixedWindow(limit: number, windowSec: number): Limiter {
  let current = -1;
  let count = 0;
  return (t) => {
    const w = Math.floor(t / windowSec);
    if (w !== current) {
      current = w;
      count = 0;
    }
    if (count >= limit) return false;
    count++;
    return true;
  };
}
```

Mỗi khoá chỉ cần một con số, nên rất rẻ. Điểm yếu nằm ở mép: 5 request ở giây 9 và 5 request ở giây 11 thuộc hai cửa sổ khác nhau, nên 10 request qua trong 2 giây.

**Cửa sổ trượt.** Nhớ thời điểm của từng request đã được cho qua. Với request mới, chỉ đếm những request trong 10 giây gần nhất, tính lùi từ bây giờ.

```ts
export function slidingWindow(limit: number, windowSec: number): Limiter {
  const log: number[] = [];
  return (t) => {
    while (log.length > 0 && log[0]! <= t - windowSec) log.shift();
    if (log.length >= limit) return false;
    log.push(t);
    return true;
  };
}
```

Cách này giữ được cam kết "không bao giờ quá `limit` trong bất kỳ 10 giây nào". Cái giá là bộ nhớ: nó lưu một mốc thời gian cho mỗi request được qua, tối đa `limit` mốc cho mỗi khoá.

**Xô token.** Một cái xô chứa tối đa `capacity` token và nhận thêm `perSec` token mỗi giây. Mỗi request lấy đi một token. Hết token thì không được vào.

```ts
export function tokenBucket(capacity: number, perSec: number): Limiter {
  let tokens = capacity;
  let last = 0;
  return (t) => {
    tokens = Math.min(capacity, tokens + (t - last) * perSec);
    last = t;
    if (tokens < 1) return false;
    tokens -= 1;
    return true;
  };
}
```

Mỗi khoá chỉ lưu hai con số. Người đã im lặng một lúc có xô đầy và được gửi một cú dồn. Sau đó họ bị giữ ở tốc độ nạp lại. Vì xô đầy cộng với phần nạp thêm trong một cửa sổ có thể cho qua tới khoảng gấp đôi sức chứa, cam kết của nó là "tốc độ trung bình này, kèm cú dồn tối đa bằng cỡ xô", chứ không phải "không bao giờ quá N trong bất kỳ cửa sổ nào".

| Tình huống | Hợp với | Vì sao |
| --- | --- | --- |
| Mức trần cứng không được vượt, như API bên trên tính phí | Cửa sổ trượt | Không đoạn nào của cửa sổ chứa quá giới hạn |
| API công khai nơi các cú dồn ngắn là bình thường, như một trang tải 8 lời gọi cùng lúc | Xô token | Cho phép cú dồn nhưng vẫn giữ mức trung bình |
| Hàng triệu khoá, bộ nhớ quan trọng hơn độ chính xác | Cửa sổ cố định | Mỗi khoá một bộ đếm, và cú dồn gấp đôi ở mép chấp nhận được |
| Lần đăng nhập | Cửa sổ trượt, giới hạn nhỏ | Cú dồn ở mép của cửa sổ cố định chính là thứ kẻ tấn công lợi dụng |

### Đã kiểm tra bằng cách chạy thật

Ngày 3/11/2026, chúng tôi chạy ba hàm này trên Node 26.11.1 với đoạn mã mẫu dưới đây (giới hạn 5 mỗi 10 giây, không thư viện ngoài). Với 10 request giáp ranh cửa sổ, kết quả là: cửa sổ cố định 10, cửa sổ trượt 5, xô token 6. Với một request mỗi giây trong 60 giây: cửa sổ cố định 30, cửa sổ trượt 30, xô token 34 (xô bắt đầu đầy nên cho qua 5 request đầu ngay lập tức). Hãy lưu ba hàm ở trên vào `limiters.ts` cạnh file này.

```ts
// run.ts: node run.ts
import { fixedWindow, slidingWindow, tokenBucket } from "./limiters.ts";

const spread = (from: number, to: number, n: number) =>
  Array.from({ length: n }, (_, i) => from + ((to - from) * i) / n);
// 5 request ngay trước giây 10 và 5 request ngay sau
const edge = [...spread(8, 9.99, 5), ...spread(10, 11.99, 5)];

const count = (allow: (t: number) => boolean) => edge.filter((t) => allow(t)).length;
console.log("fixed  ", count(fixedWindow(5, 10)));
console.log("sliding", count(slidingWindow(5, 10)));
console.log("bucket ", count(tokenBucket(5, 0.5)));
```

## Request bị từ chối nên báo gì cho client?

Hãy nói rõ: mã **429 Too Many Requests**, kèm header **`Retry-After`** là số giây cần chờ. [RFC 6585](https://www.rfc-editor.org/rfc/rfc6585#section-4) định nghĩa 429 và cho biết phản hồi có thể kèm `Retry-After`. [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#name-retry-after) định nghĩa header này, giá trị là số giây hoặc một thời điểm.

Một bản thảo của IETF (vẫn là bản thảo tới tháng 5/2026) thêm hai header `RateLimit-Policy` và `RateLimit`, để client thấy được luật và phần còn lại trước khi chạm trần. Vì chưa phải chuẩn cuối, hãy coi chúng là phần bổ sung và để `Retry-After` mang thông điệp chính.

```ts
export function tooManyRequests(opts: {
  limit: number;
  windowSec: number;
  retryAfterSec: number;
}): Response {
  const retry = Math.max(1, Math.ceil(opts.retryAfterSec));
  return new Response("Too many requests", {
    status: 429,
    headers: {
      "Retry-After": String(retry),
      "RateLimit-Policy": `"default";q=${opts.limit};w=${opts.windowSec}`,
      "RateLimit": `"default";r=0;t=${retry}`,
    },
  });
}
```

Với lời gọi `limit: 5, windowSec: 10, retryAfterSec: 6.2`, phản hồi chúng tôi nhận được là mã 429, `retry-after: 7`, `ratelimit-policy: "default";q=5;w=10` và `ratelimit: "default";r=0;t=7`. Trong bản thảo, `q` là hạn mức, `w` là độ dài cửa sổ tính bằng giây, `r` là phần còn lại và `t` là số giây tới khi làm mới.

Client nên chờ ít nhất bằng thời gian `Retry-After` và cộng thêm một khoảng ngẫu nhiên nhỏ, để nhiều client không cùng quay lại đúng một giây.

## Thêm vào một Cloudflare Worker thế nào?

Cloudflare Workers có sẵn Rate Limiting binding, nên bạn không phải tự viết bộ đếm. Trong `wrangler.jsonc`:

```jsonc
{
  "name": "rl-demo",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-01",
  "ratelimits": [
    { "name": "LIMITER", "namespace_id": "1001", "simple": { "limit": 5, "period": 10 } }
  ]
}
```

Và trong Worker:

```ts
interface Env {
  LIMITER: { limit(opts: { key: string }): Promise<{ success: boolean }> };
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    // Đếm theo một định danh ổn định, không theo địa chỉ IP
    const key = req.headers.get("x-api-key") ?? "anonymous";
    const { success } = await env.LIMITER.limit({ key });
    if (!success) {
      return new Response("Too many requests", {
        status: 429,
        headers: { "Retry-After": "10" },
      });
    }
    return new Response("ok");
  },
} satisfies ExportedHandler<Env>;
```

Kiểm tra bằng `npx wrangler dev`, rồi chạy:

```bash
for i in 1 2 3 4 5 6 7 8; do
  curl -s -o /dev/null -w '%{http_code}\n' -H 'x-api-key: alice' localhost:8787
done
curl -s -o /dev/null -w 'bob: %{http_code}\n' -H 'x-api-key: bob'  localhost:8787
```

Ngày 3/11/2026, với wrangler 4.156.2 chạy trên máy cục bộ, `alice` nhận năm mã 200 rồi ba mã 429 kèm `Retry-After: 10`. `bob`, có key riêng, vẫn nhận 200. Sau khoảng 11 giây, `alice` lại nhận 200. Phép thử này chỉ chạy trên máy cục bộ, chưa phải một Worker đã triển khai.

Ba giới hạn cần biết, theo [tài liệu của binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/):

- `period` chỉ có thể là 10 hoặc 60 giây.
- Bộ đếm được giữ **theo từng địa điểm của Cloudflare**, nên giới hạn không áp dụng toàn cầu.
- Con số đếm là xấp xỉ và nhất quán dần (eventually consistent), nên request có thể vượt giới hạn đôi chút. Muốn giới hạn toàn cầu chặt, tài liệu gợi ý tự xây bộ giới hạn bằng Durable Objects.

Binding chỉ trả về `success`, nên nó không cho biết còn lại bao nhiêu request. Vì vậy gửi `Retry-After` thì dễ, còn `RateLimit: r=...` thì cần bộ giới hạn do bạn tự viết.

## Những lỗi hay gặp

- **Chỉ đếm theo IP trên API có đăng nhập.** Địa chỉ dùng chung có thể khoá nhầm người vô tội, trong khi kẻ tấn công có thể đổi địa chỉ liên tục.
- **Giữ bộ đếm trong bộ nhớ của một tiến trình khi chạy nhiều tiến trình.** Mỗi tiến trình đếm riêng, nên giới hạn thật là giới hạn nhân với số tiến trình. Hãy dùng kho lưu chung, hoặc chấp nhận cách đếm theo địa điểm nói ở trên.
- **Gửi 429 mà không có `Retry-After`.** Client thử lại ngay và tình trạng quá tải nặng thêm.
- **Một giới hạn cho mọi route.** Đăng nhập và đặt lại mật khẩu cần giới hạn nhỏ hơn nhiều so với đọc danh sách.
- **Không quyết định trước chuyện gì xảy ra khi chính bộ giới hạn hỏng.** Mở cửa (vẫn cho request qua) giữ dịch vụ chạy nhưng không được bảo vệ. Đóng cửa (từ chối) thì bảo vệ được nhưng làm dịch vụ ngừng. Hãy chọn có chủ đích, theo từng route.
- **Chỉ thử với lưu lượng đều.** Cú dồn và mép cửa sổ là nơi các thuật toán khác nhau, nên hãy thử đúng chỗ đó.

## Giao việc này cho AI thế nào?

Chép đoạn dưới, điền vào chỗ trống rồi đưa cho AI:

```text
Thêm rate limiting cho dịch vụ này.

Nền tảng: [Cloudflare Workers, Node + Redis, ...]
Route và giới hạn: [ví dụ: POST /login 5 lần mỗi phút cho mỗi tài khoản; GET /api/* 100 lần mỗi phút cho mỗi API key]
Khoá: [ID người dùng, API key hay IP, và xử lý thế nào với request ẩn danh]
Cú dồn: [cam kết chặt "không quá N trong bất kỳ cửa sổ nào" (cửa sổ trượt) hay "cho phép cú dồn ngắn" (xô token)]

Yêu cầu:
- Phản hồi 429 kèm header Retry-After tính bằng số giây nguyên.
- Giới hạn khác nhau cho từng route, đặt ở một chỗ duy nhất.
- Quyết định và ghi lại chuyện gì xảy ra nếu kho lưu của bộ giới hạn không truy cập được.
- Một bài test gửi cú dồn giáp ranh cửa sổ và cho thấy kết quả.

Chứng minh trước khi báo xong:
1. Chạy dịch vụ trên máy cục bộ.
2. Gửi request nhanh hơn giới hạn và cho thấy các mã 429 cùng giá trị Retry-After.
3. Cho thấy một khoá thứ hai không bị ảnh hưởng bởi khoá thứ nhất.
4. Cho thấy khoá thứ nhất dùng lại được sau khi chờ.
```

## Kiểm tra lại

1. Giới hạn là 5 request mỗi cửa sổ cố định 10 giây. Một client gửi 5 request ở giây thứ 9 và 5 request nữa ở giây thứ 11. Bao nhiêu request lọt qua?
   Xem đáp án: 10. Giây 9 nằm trong cửa sổ đầu, giây 11 nằm trong cửa sổ kế tiếp. Mỗi cửa sổ đếm riêng, nên cả 10 request đều qua trong vòng 2 giây. Đó là điểm yếu của cửa sổ cố định.
2. Bạn muốn một cam kết chặt: không bao giờ quá 100 request trong bất kỳ 60 giây nào. Chọn cách nào?
   Xem đáp án: Cửa sổ trượt. Chỉ cửa sổ trượt đếm 60 giây gần nhất tính từ lúc mỗi request đến, nên không đoạn 60 giây nào chứa quá 100. Xô 100 token nạp đầy lại trong 60 giây có thể cho qua tới khoảng 200 trong một đoạn: 100 từ xô đầy cộng 100 vừa nạp thêm.
3. Vì sao địa chỉ IP là khoá yếu cho API có đăng nhập?
   Xem đáp án: Nhiều người dùng chung một IP, và một người có thể dùng nhiều IP. Một văn phòng hay một mạng di động có thể đưa hàng nghìn người ra internet qua cùng một địa chỉ, nên một người ồn ào sẽ khoá cả nhóm. ID người dùng hoặc API key cho biết ai thật sự đang gọi.
4. Một request bị từ chối. Phản hồi nên nói gì với client?
   Xem đáp án: Mã 429 và header Retry-After. 429 cho biết client gửi quá nhiều, còn Retry-After cho biết cần chờ bao nhiêu giây. Thiếu nó, client tự đoán và thường thử lại ngay, làm quá tải nặng thêm.

## Nguồn

- [RFC 6585, mục 4: 429 Too Many Requests](https://www.rfc-editor.org/rfc/rfc6585#section-4)
- [RFC 9110: header Retry-After](https://www.rfc-editor.org/rfc/rfc9110#name-retry-after)
- [Bản thảo IETF: RateLimit header fields for HTTP (draft 11, tháng 5/2026)](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)
- [Cloudflare Workers: Rate Limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/)
