Xây dựng Rate Limiter với Redis

Photo of author

Văn Ngọc Tân

Xây dựng Rate Limiter với Redis

Bạn có biết?

Khi bạn gọi API của Shopee hay Facebook quá nhiều lần trong 1 phút, hệ thống sẽ trả về lỗi 429 Too Many Requests. Đó chính là Rate Limiter đang hoạt động. Với Redis, bạn có thể xây dựng rate limiter chỉ với vài dòng lệnh — nhanh, chính xác, và xử lý hàng triệu request mỗi giây.

Rate Limiter là gì?

Rate Limiter giới hạn số lần một user hoặc IP có thể thực hiện hành động trong khoảng thời gian nhất định. Mục tiêu:

  • Bảo vệ API — Chống abuse, DDoS
  • Quản lý tài nguyên — Tránh quá tải server
  • Fair usage — Đảm bảo công bằng giữa các user
  • Giảm chi phí — Giới hạn call tới API trả phí (OpenAI, Stripe…)

Các thuật toán phổ biến

Thuật toán Cơ chế Ưu điểm Nhược điểm
Fixed Window Đếm trong khung thời gian cố định Đơn giản, dễ implement Burst ở biên cửa sổ
Sliding Window Log Lưu timestamp từng request Chính xác nhất Tốn bộ nhớ
Sliding Window Counter Kết hợp Fixed Window + tỷ lệ Cân bằng hiệu suất Không hoàn toàn chính xác
Token Bucket Thêm token theo thời gian Cho phép burst Phức tạp hơn

Bài này sẽ implement cả 3 cách phổ biến nhất với Redis.

Cách 1: Fixed Window Counter

Đơn giản nhất — đếm số request trong khung thời gian cố định (ví dụ: 1 phút).

Cơ chế

  • Key: rate:user:{id}:{window}
  • Mỗi request → INCR counter
  • Nếu counter > limit → từ chối
  • Key tự hết hạn sau window size

Redis CLI

# User 123 gửi request đầu tiên trong phút hiện tại
127.0.0.1:6379> INCR rate:user:123:1711785600
(integer) 1

# Đặt TTL lần đầu (chỉ set 1 lần)
127.0.0.1:6379> EXPIRE rate:user:123:1711785600 60
(integer) 1

# Request thứ 2, 3, 4...
127.0.0.1:6379> INCR rate:user:123:1711785600
(integer) 2

# Kiểm tra số request hiện tại
127.0.0.1:6379> GET rate:user:123:1711785600
"2"

# Nếu vượt quá 100 request/phút → trả lỗi 429

Python Implementation

import redis
import time

r = redis.Redis(host='localhost', port=6379)

def is_allowed_fixed_window(user_id, limit=100, window=60):
    current_window = int(time.time()) // window
    key = f"rate:user:{user_id}:{current_window}"
    
    pipe = r.pipeline()
    pipe.incr(key)
    pipe.expire(key, window)
    count, _ = pipe.execute()
    
    return count <= limit

# Sử dụng
if is_allowed_fixed_window("user_123", limit=100, window=60):
    print("Request được phép")
else:
    print("429 Too Many Requests")

Vấn đề của Fixed Window

Giới hạn 100 request/phút. User gửi 100 request ở giây cuối của phút 1, rồi 100 request ở giây đầu của phút 2 → 200 request trong 2 giây! Đây gọi là burst ở biên cửa sổ.

Cách 2: Sliding Window Log

Lưu timestamp của từng request. Khi có request mới, loại bỏ các timestamp cũ hơn window.

Redis CLI

# User 123 gửi request lúc timestamp 1711785600
127.0.0.1:6379> ZADD rate:log:user:123 1711785600 1711785600
(integer) 1

# Request thứ 2 lúc 1711785605
127.0.0.1:6379> ZADD rate:log:user:123 1711785605 1711785605
(integer) 1

# Xóa timestamp cũ hơn 60 giây
127.0.0.1:6379> ZREMRANGEBYSCORE rate:log:user:123 0 1711785540
(integer) 0

# Đếm số request trong window
127.0.0.1:6379> ZCARD rate:log:user:123
(integer) 2

# Đặt TTL
127.0.0.1:6379> EXPIRE rate:log:user:123 60
(integer) 1

Python Implementation

import redis
import time

r = redis.Redis(host='localhost', port=6379)

def is_allowed_sliding_window(user_id, limit=100, window=60):
    key = f"rate:log:{user_id}"
    now = time.time()
    window_start = now - window
    
    pipe = r.pipeline()
    # Xóa request cũ
    pipe.zremrangebyscore(key, 0, window_start)
    # Thêm request hiện tại
    pipe.zadd(key, {str(now): now})
    # Đếm request trong window
    pipe.zcard(key)
    # Đặt TTL
    pipe.expire(key, window)
    
    _, _, count, _ = pipe.execute()
    
    return count <= limit

Ưu điểm

Chính xác 100% — không có burst ở biên cửa sổ. Nhưng tốn bộ nhớ vì lưu từng timestamp.

Cách 3: Token Bucket

Mỗi user có một "bucket" chứa token. Token được thêm vào theo tốc độ cố định. Mỗi request tiêu tốn 1 token. Hết token → từ chối.

Cơ chế

  • Bucket có sức chứa tối đa (burst capacity)
  • Token được thêm theo rate (ví dụ: 10 token/giây)
  • Request lấy 1 token từ bucket
  • Bucket đầy → cho phép burst ngắn hạn

Redis CLI

# Lưu trạng thái bucket bằng Hash
127.0.0.1:6379> HSET rate:bucket:user:123 tokens 10 last_refill 1711785600
(integer) 2

# Khi có request → kiểm tra và cập nhật
# (Thường dùng Lua script để đảm bảo atomic)

# Lua script:
eval "
local key = KEYS[1]
local rate = tonumber(ARGV[1])       -- tokens per second
local capacity = tonumber(ARGV[2])   -- max tokens
local now = tonumber(ARGV[3])        -- current time

local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

-- Tính token mới
local elapsed = now - last_refill
local new_tokens = math.min(capacity, tokens + elapsed * rate)

if new_tokens >= 1 then
    redis.call('HMSET', key, 'tokens', new_tokens - 1, 'last_refill', now)
    redis.call('EXPIRE', key, 60)
    return 1  -- allowed
else
    redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
    redis.call('EXPIRE', key, 60)
    return 0  -- denied
end
" 1 rate:bucket:user:123 10 20 1711785610

Python Implementation

import redis
import time

r = redis.Redis(host='localhost', port=6379)

LUA_SCRIPT = """
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

local elapsed = now - last_refill
local new_tokens = math.min(capacity, tokens + elapsed * rate)

if new_tokens >= 1 then
    redis.call('HMSET', key, 'tokens', new_tokens - 1, 'last_refill', now)
    redis.call('EXPIRE', key, 60)
    return 1
else
    redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
    redis.call('EXPIRE', key, 60)
    return 0
end
"""

token_bucket = r.register_script(LUA_SCRIPT)

def is_allowed_token_bucket(user_id, rate=10, capacity=20):
    key = f"rate:bucket:{user_id}"
    now = time.time()
    result = token_bucket(keys=[key], args=[rate, capacity, now])
    return result == 1

# Sử dụng
for i in range(25):
    allowed = is_allowed_token_bucket("user_123", rate=10, capacity=20)
    print(f"Request {i+1}: {'✅ Allowed' if allowed else '❌ Denied'}")

So sánh 3 thuật toán

Tiêu chí Fixed Window Sliding Window Log Token Bucket
Độ chính xác Trung bình Cao nhất Cao
Bộ nhớ Ít nhất Nhiều nhất Trung bình
Performance Nhanh nhất Chậm nhất Nhanh
Cho phép burst Không kiểm soát Không Có kiểm soát
Phù hợp API đơn giản Cần chính xác Production

Use Cases thực tế

1. API Gateway

# Giới hạn 1000 request/giờ cho mỗi API key
def check_api_limit(api_key):
    return is_allowed_token_bucket(
        f"api:{api_key}",
        rate=1000/3600,  # ~0.28 token/giây
        capacity=1000
    )

2. Login Protection

# Giới hạn 5 lần đăng nhập sai trong 15 phút
def check_login_attempts(ip_address):
    return is_allowed_fixed_window(
        f"login:{ip_address}",
        limit=5,
        window=900  # 15 phút
    )

3. Per-user API Quota

# Free tier: 100 request/ngày
# Pro tier: 10000 request/ngày
def check_user_quota(user_id, tier):
    limits = {"free": 100, "pro": 10000}
    return is_allowed_fixed_window(
        f"quota:{user_id}",
        limit=limits[tier],
        window=86400  # 24 giờ
    )

Best Practices

  1. Dùng Lua script — Đảm bảo atomic khi check + update
  2. Trả header Rate LimitX-RateLimit-Remaining, X-RateLimit-Reset
  3. Phân tầng giới hạn — Global → Per-IP → Per-User
  4. Graceful degradation — Queue request thay vì reject ngay
  5. Monitor rate limit events — Log khi user bị limit để phát hiện abuse
  6. Redis Cluster — Nếu traffic lớn, dùng Cluster để phân tán load
Rate limiting API requests with Redis
Rate Limiter bảo vệ API khỏi bị quá tải và tấn công DDoS

Bước tiếp theo

Bạn đã xây dựng được Rate Limiter hoàn chỉnh! Tiếp theo, chúng ta sẽ tìm hiểu cách xây dựng Session Store — quản lý phiên đăng nhập user với Redis.

👉 Bài tiếp theo: Xây dựng Session Store với Redis

0 0 đánh giá
Đánh giá bài viết
Theo dõi
Thông báo của
guest
0 Góp ý
Cũ nhất
Mới nhất Được bỏ phiếu nhiều nhất
Phản hồi nội tuyến
Xem tất cả bình luận