---
title: "Rate limiting là gì? So sánh Fixed Window, Sliding Window, Token Bucket"
description: "Rate limiting kiểm soát tần suất request và trả về mã HTTP 429 khi vượt ngưỡng. Xem so sánh 3 thuật toán cùng cách chọn key và đặt limiter."
canonical: "https://200lab.io/blog/rate-limiting-la-gi"
published: "2026-11-10T03:11:33Z"
updated: "2026-11-10T03:11:33Z"
authors: ["Việt Trần"]
tags: ["Backend", "System Design"]
reading_minutes: 12
access: "public"
---

Giả sử một đối tác gọi API của bạn bằng API key riêng để hỏi trạng thái đơn hàng. Một hôm, script bên họ bị lỗi vòng lặp nên gửi request liên tục, gấp nhiều lần bình thường. Vì server đang dùng chung tài nguyên và bạn chưa tách hạn mức cho từng phía gọi, riêng đối tác đó đã chiếm mất phần lớn sức xử lý. Người dùng khác cũng thấy API chậm, thậm chí nhận lỗi dù họ chẳng làm gì quá tay. Đây chỉ là ví dụ minh họa giả định, không phải sự cố thật. Bài này mình nói về rate limiting: nó là gì và khi nào nên bật.

## Rate limiting là gì và khi nào cần

Rate limiting là việc giới hạn số request mà một client được phép gửi tới server trong một khoảng thời gian. Hình dung đường ống nước chung: một vòi mở hết cỡ thì các vòi khác yếu đi. Rate limiting chính là chiếc van giúp một phía gọi không chiếm hết sức xử lý. Khi vượt ngưỡng, server thường trả về mã lỗi HTTP 429 Too Many Requests (theo [RFC 6585](https://www.rfc-editor.org/rfc/rfc6585)), đôi khi kèm header Retry-After để phía client biết nên chờ bao lâu rồi hãy gửi lại. Throttling là cách gọi hay gặp khi mình chủ động hãm tốc độ xử lý request.

Bạn nên nghĩ tới rate limiting khi một đối tác hoặc script gửi quá nhiều request, khi một user làm những người khác trên cùng hệ thống bị chậm theo, hoặc khi các tác vụ chạy nền (background job) chiếm nhiều tài nguyên khiến giao dịch quan trọng bị chậm lại. Cách này phù hợp khi phía gọi có thể chờ một chút rồi gửi lại mà không làm sai lệch kết quả nghiệp vụ. Cũng cần nói rõ giới hạn: rate limiting chỉ giúp hệ thống không bị quá tải, chứ không thay thế được auth, firewall hay WAF. Phần sau mình sẽ so sánh ba cách đếm request theo thời gian phổ biến; mỗi cách sẽ phản ứng khác nhau trước cùng một đợt gửi dồn.

![Ba sơ đồ so sánh cách đếm request của Fixed Window, Sliding Window và Token Bucket.](https://assets.200lab.io/sha256/37/372f16521ea0183a1c174d2a4ec5a22be5736c378a70932f47a47c7f6e35b71a/generations/0737118a6b642ef8b6569d20575c34ce603b2de20d8b361061b93ade4949507a)

## Fixed window, sliding window và token bucket khác nhau chỗ nào

Ba hướng đếm phổ biến khác nhau ở cách nhìn thời gian.

- **Fixed window**: Chia thời gian thành cửa sổ cố định không chồng, ví dụ mỗi 10 giây, rồi đếm trong cửa sổ hiện tại. Dễ viết, nhưng hai burst sát hai bên biên có thể cộng gần gấp đôi hạn mức trong một khoảng ngắn.
- **Sliding window log**: Giữ timestamp các request đã chấp nhận trong lookback, ví dụ 10 giây gần nhất. Bám hạn mức sát hơn, đổi lại tốn bộ nhớ khi lưu lượng lớn.
- **Token bucket**: Một bucket có capacity; token nạp đều theo thời gian; mỗi request lấy một token. Cho burst ngắn đúng bằng capacity rồi ổn định theo tốc độ refill.

Leaky bucket ép nhịp ra đều hơn và ít burst hơn; bài này mình chỉ chạy thử ba thuật toán trên. Token bucket hay được chọn khi cần cho burst ngắn rồi ổn định tốc độ, nên demo có nó cạnh hai cửa sổ thời gian. Chúng ta thử xem đoạn code dưới đây nha:

```go
// Compare three in-memory rate limiters on the same request timeline.
//
// Illustrative sample: partner client partner_acme_ops floods an order-status API.
// Configured limit: 5 requests per 10 seconds (token bucket capacity 5, refill 0.5/s).
// This program uses simulated timestamps and does not talk to a live network.
package main

import (
	"fmt"
	"strings"
)

const (
	limit        = 5
	windowSec    = 10.0
	refillPerSec = limit / windowSec // 0.5 tokens/sec
)

type limiter interface {
	allow(now float64) bool
}

// FixedWindow counts requests in non-overlapping wall-clock windows of windowSec.
// Window boundaries are floor(now / window) * window, not "from first request".
type FixedWindow struct {
	limit     int
	windowSec float64
	windowID  int
	hasWindow bool
	count     int
}

func newFixedWindow() *FixedWindow {
	return &FixedWindow{limit: limit, windowSec: windowSec}
}

func (f *FixedWindow) allow(now float64) bool {
	wid := int(now / f.windowSec)
	if !f.hasWindow || f.windowID != wid {
		f.windowID = wid
		f.hasWindow = true
		f.count = 0
	}
	if f.count < f.limit {
		f.count++
		return true
	}
	return false
}

// SlidingWindowLog keeps timestamps of accepted requests and drops ones older than windowSec.
type SlidingWindowLog struct {
	limit     int
	windowSec float64
	stamps    []float64
}

func newSlidingWindowLog() *SlidingWindowLog {
	return &SlidingWindowLog{limit: limit, windowSec: windowSec}
}

func (s *SlidingWindowLog) allow(now float64) bool {
	cutoff := now - s.windowSec
	kept := s.stamps[:0]
	for _, t := range s.stamps {
		if t > cutoff {
			kept = append(kept, t)
		}
	}
	s.stamps = kept
	if len(s.stamps) < s.limit {
		s.stamps = append(s.stamps, now)
		return true
	}
	return false
}

// TokenBucket refills tokens continuously; each allowed request spends one token.
type TokenBucket struct {
	capacity     float64
	refillPerSec float64
	tokens       float64
	last         float64
	started      bool
}

func newTokenBucket() *TokenBucket {
	return &TokenBucket{
		capacity:     float64(limit),
		refillPerSec: refillPerSec,
		tokens:       float64(limit),
	}
}

func (b *TokenBucket) allow(now float64) bool {
	if !b.started {
		b.last = now
		b.started = true
	} else {
		elapsed := now - b.last
		b.tokens += elapsed * b.refillPerSec
		if b.tokens > b.capacity {
			b.tokens = b.capacity
		}
		b.last = now
	}
	if b.tokens >= 1.0 {
		b.tokens -= 1.0
		return true
	}
	return false
}

type event struct {
	at     float64
	client string
}

func runScenario(name string, lim limiter, events []event) string {
	var b strings.Builder
	fmt.Fprintf(&b, "=== %s ===
", name)
	fmt.Fprintf(&b, "limit=%d per %gs
", limit, windowSec)
	allowed, denied := 0, 0
	for _, ev := range events {
		status := "DENY 429"
		if lim.allow(ev.at) {
			allowed++
			status = "ALLOW"
		} else {
			denied++
		}
		fmt.Fprintf(&b, "t=%5.1fs  client=%-18s  %s
", ev.at, ev.client, status)
	}
	fmt.Fprintf(&b, "summary: allowed=%d denied=%d

", allowed, denied)
	return b.String()
}

func main() {
	// Timeline chosen to show the fixed-window boundary burst:
	// five requests just before t=10, five just after t=10.
	// Sliding window still sees ~10 requests inside one 10s lookback.
	// Token bucket allows a short burst up to capacity, then refills smoothly.
	events := []event{
		{9.0, "partner_acme_ops"},
		{9.2, "partner_acme_ops"},
		{9.4, "partner_acme_ops"},
		{9.6, "partner_acme_ops"},
		{9.8, "partner_acme_ops"},
		{10.1, "partner_acme_ops"},
		{10.3, "partner_acme_ops"},
		{10.5, "partner_acme_ops"},
		{10.7, "partner_acme_ops"},
		{10.9, "partner_acme_ops"},
		{11.2, "partner_acme_ops"}, // 11th near the boundary cluster
		{14.0, "internal_ops"},
		{18.0, "internal_ops"},
	}

	fmt.Println("Sample order-status API rate limit demo")
	fmt.Println("Illustrative data only; not a production incident.")
	fmt.Println()
	fmt.Print(runScenario("Fixed window", newFixedWindow(), events))
	fmt.Print(runScenario("Sliding window (log)", newSlidingWindowLog(), events))
	fmt.Print(runScenario("Token bucket", newTokenBucket(), events))
}
```

Mình chạy thử bằng `go run .` với dữ liệu mẫu `partner_acme_ops` và `internal_ops`, kết quả như sau:

```text
Sample order-status API rate limit demo
Illustrative data only; not a production incident.

=== Fixed window ===
limit=5 per 10s
t=  9.0s  client=partner_acme_ops    ALLOW
t=  9.2s  client=partner_acme_ops    ALLOW
t=  9.4s  client=partner_acme_ops    ALLOW
t=  9.6s  client=partner_acme_ops    ALLOW
t=  9.8s  client=partner_acme_ops    ALLOW
t= 10.1s  client=partner_acme_ops    ALLOW
t= 10.3s  client=partner_acme_ops    ALLOW
t= 10.5s  client=partner_acme_ops    ALLOW
t= 10.7s  client=partner_acme_ops    ALLOW
t= 10.9s  client=partner_acme_ops    ALLOW
t= 11.2s  client=partner_acme_ops    DENY 429
t= 14.0s  client=internal_ops        DENY 429
t= 18.0s  client=internal_ops        DENY 429
summary: allowed=10 denied=3

=== Sliding window (log) ===
limit=5 per 10s
t=  9.0s  client=partner_acme_ops    ALLOW
t=  9.2s  client=partner_acme_ops    ALLOW
t=  9.4s  client=partner_acme_ops    ALLOW
t=  9.6s  client=partner_acme_ops    ALLOW
t=  9.8s  client=partner_acme_ops    ALLOW
t= 10.1s  client=partner_acme_ops    DENY 429
t= 10.3s  client=partner_acme_ops    DENY 429
t= 10.5s  client=partner_acme_ops    DENY 429
t= 10.7s  client=partner_acme_ops    DENY 429
t= 10.9s  client=partner_acme_ops    DENY 429
t= 11.2s  client=partner_acme_ops    DENY 429
t= 14.0s  client=internal_ops        DENY 429
t= 18.0s  client=internal_ops        DENY 429
summary: allowed=5 denied=8

=== Token bucket ===
limit=5 per 10s
t=  9.0s  client=partner_acme_ops    ALLOW
t=  9.2s  client=partner_acme_ops    ALLOW
t=  9.4s  client=partner_acme_ops    ALLOW
t=  9.6s  client=partner_acme_ops    ALLOW
t=  9.8s  client=partner_acme_ops    ALLOW
t= 10.1s  client=partner_acme_ops    DENY 429
t= 10.3s  client=partner_acme_ops    DENY 429
t= 10.5s  client=partner_acme_ops    DENY 429
t= 10.7s  client=partner_acme_ops    DENY 429
t= 10.9s  client=partner_acme_ops    DENY 429
t= 11.2s  client=partner_acme_ops    ALLOW
t= 14.0s  client=internal_ops        ALLOW
t= 18.0s  client=internal_ops        ALLOW
summary: allowed=8 denied=5
```

Trong ví dụ trên, hạn mức là 5 request mỗi 10 giây; `TokenBucket` có `capacity` 5 và `refill` 0.5 token/giây. Demo dùng chung một bộ đếm toàn cục cho mọi nhãn client để so thuật toán trên cùng timeline.

`FixedWindow` cho `ALLOW` cả 5 request cuối cửa sổ (`t=9.0`-`9.8`) và 5 request đầu cửa sổ mới (`t=10.1`-`10.9`): `allowed=10`, `denied=3`, tức 10 request lọt trong khoảng 1.9 giây quanh biên `t=10`.

`SlidingWindowLog` chỉ `allowed=5`, `denied=8`: sau năm request đầu, các stamp vẫn nằm trong lookback 10 giây nên mọi lần gọi tới `t=18.0` đều `DENY`.

`TokenBucket` hết token sau burst đầu nên đợt sau bị từ chối; tới `t=11.2` đã refill đủ một token, còn `internal_ops` ở `t=14.0` và `t=18.0` cũng `ALLOW` (`allowed=8`, `denied=5`).

## Chọn key rate limit: IP, user hay API key

Demo dùng chung một bộ đếm nên khi `partner_acme_ops` làm cạn hạn mức sliding window, `internal_ops` cũng bị vạ lây. Production cần tách bộ đếm theo key rõ ràng.

- **IP**: Hữu ích ở lớp ngoài để chặn flood thô từ một địa chỉ. NAT văn phòng hay mạng di động gom nhiều người vào một IP; VPN lại đổi IP dễ.
- **User / account id**: Khi đã đăng nhập, mỗi tài khoản một bucket thường công bằng hơn IP; ai gọi quá nhanh chỉ chạm quota của mình.
- **API key / client id**: Hợp public API và tích hợp B2B. Ở giả định đầu bài, limit theo API key thì script partner chủ yếu gây 429 cho đúng key đó; chỉ limit theo IP dùng chung dễ làm lây sang kết nối hợp lệ khác.

Không có một loại key đủ cho mọi hệ thống. Thực tế hay kết hợp: IP ở vòng ngoài, rồi API key hoặc user ở lớp trong. Mình nghiêng về ưu tiên API key cho kênh partner ngay từ đầu, vì đó là chỗ script tự động hay "chạy lạc" nhất.

## Đặt rate limiter ở app, API gateway hay Redis

Sau thuật toán và key, anh em cần chọn chỗ đặt bộ đếm trên đường đi của request.

- **App / middleware**: Sát business, dễ đọc user id hoặc độ nặng của từng tác vụ. Mỗi instance tự đếm trong RAM thì số liệu lệch khi scale ngang.
- **API gateway**: Một điểm chặn phía trước nhiều service. Bạn có thể xem thêm [API Gateway là gì](https://200lab.io/blog/api-gateway-la-gi) để thấy cổng điều phối request trước khi vào service.
- **Redis / store dùng chung**: Nhiều worker cùng một view hạn mức. Trong [bài rate limiter của Stripe](https://stripe.com/blog/rate-limiters), token bucket nằm trên Redis và họ chọn fail-open: store hoặc code limiter lỗi thì request vẫn đi, đổi lại lúc đó tạm mất lớp limit.

## Rate limiting khác load shedding và circuit breaker chỗ nào

Rate limiting dễ bị nhầm với load shedding và circuit breaker.

Rate limiter quyết định theo định danh client và hạn mức của họ, giữ nhịp ngày thường. Load shedder nhìn sức khỏe cả hệ thống (CPU, worker bận) và có thể bỏ request kém quan trọng, đôi khi bằng 503, khi khẩn cấp để giữ giao dịch cốt lõi.

Khi dependency phía sau chậm hoặc lỗi liên tục, bạn cần ngắt tạm để app gọi đi không bị treo: đó là việc của [Circuit Breaker](https://200lab.io/blog/circuit-breaker-la-gi), không phải bộ đếm quota đầu vào.

Phía client nhận `429` kèm `Retry-After` nên backoff, đừng spam. Request ghi khi retry sau 429 hoặc timeout nên kèm [idempotency key](https://200lab.io/blog/idempotency-key-la-gi) để giảm bản ghi trùng. Rate limiting bảo vệ phía server; idempotency bảo vệ phía nghiệp vụ khi cùng một thao tác bị gửi lại.

Gom lại thành bảng cho dễ nhìn:

| Tiêu chí | Rate limiting | Load shedding | Circuit breaker |
| --- | --- | --- | --- |
| Mục tiêu chính | Giữ công bằng quota giữa các client gọi vào | Giữ hệ thống sống sót khi tổng tải vượt ngưỡng | Bảo vệ app khỏi một dependency đang lỗi |
| Căn cứ quyết định | Số request theo từng key, user hoặc IP | Trạng thái tài nguyên toàn cục và độ ưu tiên | Tỷ lệ lỗi hoặc timeout từ dịch vụ phía sau |
| Hành động khi chạm ngưỡng | Trả về HTTP 429 và yêu cầu client giãn nhịp | Bỏ bớt các request không thiết yếu | Mở mạch, ngừng gọi sang dependency tạm thời |

> 💡 **Rate limiting là chiếc van điều tiết theo từng khách hàng ở cửa vào, load shedding là động thái xả bớt việc phụ khi cả xưởng quá tải, còn circuit breaker là cầu dao tự ngắt khi một mắt xích phía sau bị chập.**

> Đặt một bộ rate limiter vào hệ thống là bước khởi đầu tốt để quản lý dung lượng và ngăn một vài nguồn gọi làm ảnh hưởng tới phần còn lại, nhưng bạn hãy luôn nhớ test tải cho các ngưỡng giới hạn đó và chuẩn bị sẵn phương án dự phòng khi kho lưu trữ bộ đếm gặp sự cố.

Nếu bạn muốn đi sâu vào rate limiting, load shedding và circuit breaker khi hệ thống chịu tải cao, thì có thể tham khảo khóa học bên dưới tại 200Lab nhé.

![Thiết kế hệ thống chịu tải cao - rate limiting, load shedding và circuit breaker](https://assets.200lab.io/sha256/98/98f9ab12d1d9bd570c167eb7e82220e0d732a9ff523c6cf522cc384abc38c3de/generations/204dc6125612bbd3a440c9565e0b1f35c837a9fe90431a3fedf55e71c632da16)

- [Thiết kế hệ thống chịu tải cao - rate limiting, load shedding và circuit breaker](https://200lab.io/courses/thiet-ke-he-thong-chiu-tai-cao-rate-limiting-load-shedding-va-circuit-breaker.md)

Chúc các bạn thiết kế được những chiếc van điều tiết thật êm, giữ cho quota luôn dư dả và hiếm khi phải gửi tặng khách hàng tấm vé 429 nhé 🚀

## Đọc thêm

- [CRDT là gì? Cách đồng bộ dữ liệu khi nhiều người cùng sửa](https://200lab.io/blog/crdt-la-gi.md): CRDT giúp đồng bộ dữ liệu khi nhiều người cùng chỉnh sửa lúc mất mạng. Các thay đổi được kết hợp ra sao, và xung đột được xử lý thế nào?
- [Composite index là gì? Thứ tự cột và EXPLAIN trên MySQL, PostgreSQL](https://200lab.io/blog/composite-index-la-gi.md): Composite index là gì, vì sao tạo index rồi mà query vẫn Seq Scan? Cùng thử trên PostgreSQL, MySQL để nắm quy tắc đặt cột bằng trước, cột khoảng sau.
- [Transaction là gì, isolation level là gì? Chạy thử trên PostgreSQL, MySQL](https://200lab.io/blog/transaction-isolation-level-la-gi.md): Transaction là gì mà trừ kho vẫn sai? Chạy thử 4 mức isolation level trên PostgreSQL, MySQL để hiểu dirty read, phantom read, MVCC và cách chặn lost update.
- [Reverse proxy là gì? Cách cấu hình Nginx và phân biệt với API gateway](https://200lab.io/blog/reverse-proxy-la-gi.md): Reverse proxy là gì, khác forward proxy và API gateway ra sao? Cấu hình reverse proxy Nginx với proxy_pass, upstream, X-Forwarded-For và cách xử lý lỗi 502.
- [Idempotency là gì? Idempotency key chống trừ tiền hai lần khi retry](https://200lab.io/blog/idempotency-key-la-gi.md): Idempotency key giúp POST retry an toàn khi timeout. Middleware Go chạy thật: giữ chỗ atomic, 409 khi trùng đồng thời, replay đúng response cũ.
- [Mọi bài viết chủ đề Backend](https://200lab.io/blog/tags/backend.md)

Toàn bộ nội dung: https://200lab.io/llms.txt
