Mục lục
- Mục Tiêu Bài Học
- Bài Toán Race Condition
- Vì Sao Redis Single-Threaded Không Đủ
- Cách 1: Single Atomic Command (INCR)
- Cách 2: MULTI/EXEC Pipeline
- Cách 3: Lua Script (Recommended)
- Code Hoàn Chỉnh — redis-py
- Code Hoàn Chỉnh — ioredis (Node)
- EVAL vs EVALSHA & NOSCRIPT Handling
- Quy Tắc Viết Lua Script An Toàn
- Redis Functions (7.0+)
- So Sánh Ba Cách
- Pitfalls & Anti-patterns
- Tổng Kết & Quiz
Mục Tiêu Bài Học
- Hiểu tại sao pattern GET → check → INCR tạo ra race condition và cho phép vượt giới hạn rate limit.
- Giải thích được vì sao Redis single-threaded (atomic per-command) không đủ khi có nhiều command liên tiếp.
- Biết ba cách đạt atomicity cho rate limiter: INCR đơn, MULTI/EXEC, Lua script — và khi nào dùng cái nào.
- Viết được Lua script rate limiter cơ bản và gọi từ redis-py, ioredis.
- Phân biệt EVAL vs EVALSHA, hiểu cơ chế cache script server-side và xử lý lỗi NOSCRIPT.
- Nắm các quy tắc viết Lua script an toàn cho Redis: độ dài, time, KEYS declaration, replication.
- Biết sơ lược Redis Functions (Redis 7.0+) — thay thế hiện đại cho EVALSHA.
Bài Toán Race Condition
Một rate limiter naive thường được viết theo ba bước:
# KHÔNG an toàn — race condition
count = int(redis.get(key) or 0) # bước 1: đọc
if count >= limit: # bước 2: kiểm tra
return DENY
redis.incr(key) # bước 3: tăng
Đoạn code trên tạo ra một khoảng thời gian nguy hiểm giữa bước 1 và bước 3. Xét kịch bản có 2 request đến đồng thời khi count = 99 và limit = 100:
- Request A đọc
count = 99, thấy 99 < 100, tiếp tục. - Request B đọc
count = 99(vẫn chưa bị A tăng), thấy 99 < 100, tiếp tục. - Request A chạy
INCR→ count trở thành 100. - Request B chạy
INCR→ count trở thành 101.
Kết quả: cả hai request đều được ALLOW dù tổng đã vượt limit. Khi hàng chục request đến cùng một microsecond (burst), số lần vượt limit có thể lớn hơn nhiều — thường gặp với API call từ load balancer, retry storm, hay DDoS nhỏ.
Vấn đề không nằm ở logic — logic đúng hoàn toàn. Vấn đề nằm ở chỗ ba bước đó không được thực thi như một đơn vị không thể chia cắt (atomic).
Vì Sao Redis Single-Threaded Không Đủ
Redis có event loop single-threaded (cho command processing): tại mỗi thời điểm chỉ xử lý đúng một command. Điều này có nghĩa là mỗi command riêng lẻ — GET, INCR, SET — là atomic: không command nào khác có thể chen vào giữa khi nó đang thực thi.
Nhưng đây là điểm quan trọng: GET và INCR là hai command riêng biệt. Chúng được gửi lên Redis trong hai round-trip khác nhau. Giữa lúc GET trả về kết quả cho application và lúc application gửi INCR, Redis đã có thể xử lý hàng trăm command từ các client khác.
# Timeline (thời gian từ trái sang phải)
# Client A: GET key ------- INCR key
# Client B: GET key ------- INCR key
# ↑
# Redis đọc cùng giá trị cho cả hai
Kết luận: atomic per-command ≠ atomic per-transaction. Redis đảm bảo từng command là atomic, nhưng không có gì đảm bảo chuỗi command của bạn là atomic. Để có chuỗi command atomic, cần một trong ba cách ở các mục sau.
Cách 1: Single Atomic Command (INCR)
INCR là một command duy nhất, atomic: nó vừa tăng giá trị vừa trả về giá trị mới trong một bước. Vì vậy có thể bỏ GET trước:
count = redis.incr(key) # atomic: tăng và trả về giá trị mới
if count == 1:
redis.expire(key, window) # lần đầu ghi key, đặt TTL
if count > limit:
return DENY
return ALLOW
Đây là cải tiến đáng kể so với GET → check → INCR. Không có race condition trong phần "tăng và đọc". Tuy nhiên có một edge case quan trọng: INCR và EXPIRE vẫn là hai command riêng. Nếu process crash hoặc kết nối mất ngay sau INCR nhưng trước EXPIRE, key sẽ tồn tại mãi mãi không có TTL. Mỗi lần bị deny, counter cũ vẫn còn đó → user bị block vĩnh viễn.
Trường hợp này xác suất thấp nhưng đủ để gây ra incident thực tế. Với rate limiter đơn giản (không cần logic phức tạp), có thể chấp nhận edge case này nếu có monitoring phát hiện key không TTL. Với yêu cầu cao hơn, dùng Lua script.
Cách 2: MULTI/EXEC Pipeline
Redis có cơ chế transaction thông qua MULTI và EXEC. Khi gọi MULTI, Redis bắt đầu queue các command. Khi gọi EXEC, toàn bộ queue được thực thi như một khối atomic — không command nào từ client khác có thể chen vào giữa.
pipe = redis.pipeline()
pipe.multi() # bắt đầu transaction
pipe.incr(key)
pipe.expire(key, window)
results = pipe.execute() # EXEC — chạy cả hai command atomic
count = results[0]
if count > limit:
return DENY
return ALLOW
MULTI/EXEC giải quyết vấn đề INCR + EXPIRE không atomic. Nhưng có một hạn chế căn bản: không thể dùng conditional logic bên trong transaction. Không có "nếu count > limit thì rollback" hay "nếu key chưa tồn tại thì mới set TTL". Toàn bộ các command được queue từ trước khi thực thi, không có điều kiện nào được kiểm tra trong lúc thực thi.
Redis có WATCH cho optimistic locking: nếu key thay đổi giữa WATCH và EXEC, toàn bộ transaction bị hủy (EXEC trả về nil). Nhưng với rate limiting, điều này chỉ dẫn đến retry loop — vừa phức tạp vừa dễ gây livelock dưới tải cao.
MULTI/EXEC phù hợp khi cần batch commands không có điều kiện phức tạp. Với rate limit cần conditional (ví dụ: sliding window với ZRANGEBYSCORE rồi quyết định có ZADD không), MULTI/EXEC không đủ.
Cách 3: Lua Script (Recommended)
Redis thực thi Lua script như một command đơn, atomic. Điều này có nghĩa là toàn bộ script — dù bao nhiêu Redis call bên trong — chạy xong trước khi bất kỳ command nào từ client khác được xử lý. Không có race condition, không có partial execution từ góc nhìn của client khác.
Điểm khác biệt quan trọng so với MULTI/EXEC: Lua script có thể chứa conditional logic đầy đủ (if/else, vòng lặp, biến cục bộ). Script có thể đọc giá trị từ Redis, ra quyết định dựa trên giá trị đó, rồi ghi kết quả — tất cả trong một khối atomic.
-- KEYS[1] = rate limit key (ví dụ: "rl:user:42")
-- ARGV[1] = limit (ví dụ: 100)
-- ARGV[2] = window seconds (ví dụ: 60)
local current = redis.call('INCR', KEYS[1])
if current == 1 then
-- key mới tạo, đặt TTL ngay trong cùng script
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
return 0 -- denied
end
return 1 -- allowed
Script này giải quyết cả hai vấn đề trước đó: INCR và EXPIRE chạy atomic trong cùng script (không crash giữa chừng), và có điều kiện if current > limit được kiểm tra bên trong script.
Code Hoàn Chỉnh — redis-py
import redis
from typing import Optional
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
RATE_LIMIT_SCRIPT = """
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
return 0
end
return 1
"""
# register_script tự handle EVALSHA/EVAL — xem mục EVAL vs EVALSHA
rate_limiter = r.register_script(RATE_LIMIT_SCRIPT)
class TooManyRequests(Exception):
pass
def check_rate_limit(user_id: str, limit: int = 100, window: int = 60) -> None:
"""
Raise TooManyRequests nếu user vượt giới hạn.
key format: rl:{user_id}
"""
key = f"rl:{user_id}"
allowed = rate_limiter(keys=[key], args=[limit, window])
if not allowed:
raise TooManyRequests(f"Rate limit exceeded for {user_id}")
# Sử dụng trong API handler
def handle_api_request(user_id: str, payload: dict) -> dict:
check_rate_limit(user_id, limit=100, window=60)
# ... xử lý request
return {"status": "ok"}
register_script của redis-py tự handle việc dùng EVALSHA khi script đã được cache, fallback EVAL khi gặp NOSCRIPT. Không cần tự quản lý SHA1.
Code Hoàn Chỉnh — ioredis (Node)
import Redis from "ioredis";
const redis = new Redis({ host: "localhost", port: 6379 });
const RATE_LIMIT_LUA = `
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
return 0
end
return 1
`;
// defineCommand đăng ký lệnh tùy chỉnh với ioredis
// ioredis tự dùng EVALSHA lần đầu sau SCRIPT LOAD, fallback EVAL khi cần
redis.defineCommand("rateLimit", {
numberOfKeys: 1,
lua: RATE_LIMIT_LUA,
});
// Mở rộng TypeScript interface
declare module "ioredis" {
interface RedisCommander {
rateLimit(key: string, limit: number, window: number): Promise;
}
}
class TooManyRequestsError extends Error {
constructor(userId: string) {
super(`Rate limit exceeded for ${userId}`);
this.name = "TooManyRequestsError";
}
}
async function checkRateLimit(
userId: string,
limit = 100,
window = 60
): Promise {
const key = `rl:${userId}`;
const allowed = await redis.rateLimit(key, limit, window);
if (!allowed) {
throw new TooManyRequestsError(userId);
}
}
// Middleware Express ví dụ
async function rateLimitMiddleware(req: any, res: any, next: any) {
const userId = req.headers["x-user-id"] as string;
try {
await checkRateLimit(userId, 100, 60);
next();
} catch (err) {
if (err instanceof TooManyRequestsError) {
res.status(429).json({ error: "Too Many Requests" });
} else {
next(err);
}
}
}
EVAL vs EVALSHA & NOSCRIPT Handling
Redis cung cấp hai command để thực thi Lua script:
- EVAL: gửi toàn bộ nội dung script mỗi lần gọi. Đơn giản nhưng tốn bandwidth — với script 500 byte và 10.000 req/s, bạn gửi thêm 5 MB/s chỉ để truyền script.
- EVALSHA: gửi SHA1 hash (40 ký tự) của script thay vì toàn bộ nội dung. Redis dùng hash để tra script đã cache từ
SCRIPT LOADhoặc lầnEVALtrước.
# Cache script và lấy SHA1
sha=$(redis-cli SCRIPT LOAD "local current = redis.call('INCR', KEYS[1]); ...")
# Gọi bằng SHA1
redis-cli EVALSHA $sha 1 "rl:user:42" 100 60
Vấn đề NOSCRIPT: script cache bị mất khi Redis restart hoặc khi chạy SCRIPT FLUSH. Khi đó EVALSHA trả về lỗi NOSCRIPT No matching script. Cần xử lý bằng cách retry với EVAL đầy đủ rồi cache lại.
# Xử lý NOSCRIPT thủ công (redis-py register_script đã làm điều này)
import hashlib
script_sha = hashlib.sha1(RATE_LIMIT_SCRIPT.encode()).hexdigest()
def call_rate_limit_safe(r, key, limit, window):
try:
return r.evalsha(script_sha, 1, key, limit, window)
except redis.exceptions.NoScriptError:
# Script cache mất — reload và retry
script_sha_new = r.script_load(RATE_LIMIT_SCRIPT)
return r.evalsha(script_sha_new, 1, key, limit, window)
redis-py register_script() và ioredis defineCommand() đều tự handle flow này. Nếu dùng raw evalsha, phải tự handle NOSCRIPT.
Quy Tắc Viết Lua Script An Toàn
Giữ script ngắn
Lua script chạy atomic có nghĩa là nó block toàn bộ Redis server trong suốt thời gian thực thi. Script dài hoặc có vòng lặp nhiều iteration sẽ tăng latency cho mọi client khác. Giữ script dưới vài mili-giây. Không dùng loop lặp qua hàng nghìn phần tử trong Lua.
Không dùng TIME trong Lua — truyền thời gian qua ARGV
Redis cho phép gọi redis.call('TIME') trong Lua, nhưng đây là thao tác non-deterministic: kết quả thay đổi theo thời gian thực. Khi Redis replication ghi lại Lua script để replay trên replica, kết quả TIME sẽ khác nhau giữa primary và replica, gây inconsistency.
# ĐÚNG: lấy time từ app, truyền vào ARGV
import time
now_ms = int(time.time() * 1000)
rate_limiter(keys=[key], args=[limit, window, now_ms])
-- Trong script: dùng ARGV[3] thay vì redis.call('TIME')
local now_ms = tonumber(ARGV[3])
Nếu bắt buộc phải dùng TIME trong Lua (ví dụ: script phức tạp hơn), thêm redis.replicate_commands() ở đầu script. Lệnh này chuyển replication mode sang "effect replication" (chỉ replicate kết quả, không replay script), cho phép non-deterministic calls — nhưng làm tăng traffic replication. Truyền time qua ARGV là cách đơn giản và ít rủi ro hơn.
Khai báo đầy đủ KEYS
Tất cả Redis key mà script truy cập phải được truyền qua KEYS[], không hardcode trong script. Redis Cluster dùng KEYS[] để xác định slot nào script sẽ truy cập và đảm bảo tất cả key nằm cùng slot. Nếu key hardcode trong script, Cluster không thể route đúng và sẽ trả lỗi CROSSSLOT.
-- SAI với Redis Cluster
local other = redis.call('GET', 'some:hardcoded:key')
-- ĐÚNG
local other = redis.call('GET', KEYS[2])
Redis Functions (7.0+)
Redis 7.0 giới thiệu Redis Functions như một thay thế có cấu trúc hơn cho EVAL/EVALSHA. Thay vì gửi script mỗi lần, bạn load một thư viện hàm vào server và gọi hàm theo tên.
# Load thư viện (một lần, persist qua restart)
redis-cli FUNCTION LOAD "#!lua name=rate_lib
local function check_limit(keys, args)
local current = redis.call('INCR', keys[1])
if current == 1 then
redis.call('EXPIRE', keys[1], args[2])
end
if current > tonumber(args[1]) then
return 0
end
return 1
end
redis.register_function('rate_limit', check_limit)"
# Gọi hàm
redis-cli FCALL rate_limit 1 "rl:user:42" 100 60
Ưu điểm của Functions so với EVALSHA:
- Persistent: Functions được lưu như một phần của dataset Redis — survive restart và được replicate, không bị mất như script cache.
- Có namespace: tổ chức theo thư viện, dễ manage hơn SHA1 hash.
- Không NOSCRIPT: không cần retry logic khi server restart.
Nhược điểm: cần Redis 7.0+, API deploy phức tạp hơn (cần FUNCTION LOAD khi deploy), client library support chưa đồng đều. EVALSHA vẫn là lựa chọn phổ biến nhất trong production hiện tại.
So Sánh Ba Cách
| Cách | Atomic toàn bộ | Conditional logic | Multi-key | Độ phức tạp | Phù hợp |
|---|---|---|---|---|---|
| INCR đơn | Một phần (INCR atomic, EXPIRE riêng) | Không (phải check sau) | Không | Thấp | Counter đơn giản, chấp nhận edge case TTL |
| MULTI/EXEC | Có | Hạn chế (WATCH + retry) | Có | Trung bình | Batch commands không cần điều kiện phức tạp |
| Lua script | Có | Đầy đủ (if/else, loop) | Có | Trung bình | Mọi rate limit algorithm có logic phức tạp |
Với fixed window counter đơn giản, INCR + EXPIRE trong Lua là cách dùng phổ biến nhất. Với sliding window log hay token bucket — cần đọc nhiều key, tính toán, rồi ghi theo điều kiện — Lua là lựa chọn duy nhất thực sự an toàn.
Pitfalls & Anti-patterns
- GET → check → INCR: anti-pattern kinh điển gây race condition. Không dùng trong rate limiter dù traffic thấp — low traffic chỉ giảm xác suất race, không loại bỏ.
- INCR rồi EXPIRE riêng, không có safeguard: nếu crash giữa hai command hoặc mạng ngắt, key không có TTL và tồn tại vĩnh viễn. User bị block sau một window rồi không bao giờ reset. Dùng Lua để đảm bảo atomic.
- Lua script dài hoặc có loop lớn: block toàn bộ Redis. Script rate limit cơ bản nên dưới 10 dòng. Không dùng Lua để xử lý logic nghiệp vụ phức tạp — chỉ dùng cho atomic Redis operations.
- Dùng TIME trong Lua không có replicate_commands: gây inconsistency trên replica trong môi trường có replication. Truyền timestamp qua ARGV là cách an toàn hơn.
- EVAL full script mỗi request thay vì EVALSHA: tốn bandwidth không cần thiết. Luôn dùng
register_script(redis-py) hoặcdefineCommand(ioredis) để tự động tối ưu. - Không xử lý NOSCRIPT khi dùng raw EVALSHA: server restart sẽ làm rate limiter ngừng hoạt động đột ngột và trả lỗi 500 thay vì 429.
- Hardcode key trong Lua script: mất tương thích với Redis Cluster. Mọi key phải đi qua
KEYS[].
Tổng Kết & Quiz
Tổng kết
- Pattern GET → check → INCR tạo race condition: nhiều request đọc cùng giá trị trước khi bất kỳ request nào INCR, dẫn đến vượt limit.
- Redis single-threaded đảm bảo atomic per-command, không phải atomic per-transaction. GET và INCR là hai command riêng, có thể bị command khác chen giữa.
- Ba cách đạt atomicity: (1) INCR đơn — đơn giản nhưng INCR + EXPIRE vẫn không hoàn toàn atomic; (2) MULTI/EXEC — atomic nhưng không có conditional logic; (3) Lua script — atomic hoàn toàn, có conditional logic đầy đủ.
- Lua script là lựa chọn phù hợp nhất cho rate limiter có logic phức tạp. Dùng EVALSHA (qua
register_scripthoặcdefineCommand), không EVAL thủ công. - Xử lý NOSCRIPT khi dùng raw EVALSHA: script cache mất sau restart, phải reload và retry.
- Quy tắc Lua an toàn: giữ script ngắn, truyền time qua ARGV, khai báo đầy đủ KEYS, không loop lớn.
- Redis Functions (7.0+) là thay thế persistent cho EVALSHA — không NOSCRIPT, nhưng chưa phổ biến bằng.
Quiz 5 câu
- Mô tả kịch bản cụ thể khiến pattern GET → check → INCR cho phép vượt giới hạn rate limit khi có 10 request đến cùng lúc với
count = 98vàlimit = 100. - Redis xử lý command single-threaded. Tại sao điều đó không ngăn được race condition trong GET → INCR?
- Tại sao MULTI/EXEC không đủ để triển khai sliding window rate limiter nhưng đủ cho fixed window counter?
- Nếu Redis restart và bạn gọi EVALSHA với SHA1 cũ, điều gì xảy ra? Mô tả cách xử lý đúng.
- Tại sao không nên gọi
redis.call('TIME')trực tiếp trong Lua script khi Redis có replica? Thay vào đó làm thế nào?
Đáp án gợi ý
- Cả 10 request GET trước khi INCR nào chạy → tất cả đọc được 98 → tất cả pass check (98 < 100) → tất cả INCR → count = 108. Vượt limit 8 lần.
- Single-threaded chỉ đảm bảo mỗi command hoàn thành trước command tiếp theo. Nhưng GET và INCR là hai command riêng biệt từ góc nhìn Redis. Giữa GET (của request A) và INCR (của request A), Redis đã xử lý GET của request B. Atomic per-command ≠ atomic per-transaction.
- Sliding window cần: đọc list timestamps → tính số timestamp trong window → nếu đủ mới thêm. Quyết định có thêm hay không phụ thuộc vào kết quả đọc — đây là conditional không thể làm trong MULTI/EXEC. Fixed window counter chỉ cần INCR + EXPIRE không có điều kiện phức tạp — MULTI/EXEC đủ.
- Redis trả lỗi
NOSCRIPT No matching script. Xử lý đúng: catch lỗi đó, gọiSCRIPT LOADvới nội dung script đầy đủ để reload vào cache, lưu lại SHA1 mới, gọi lại EVALSHA. redis-pyregister_scripttự làm điều này. - TIME là non-deterministic — kết quả thay đổi theo thời gian thực. Khi replica replay Lua script, TIME trả về thời điểm replay, không phải thời điểm primary chạy → state diverge. Giải pháp: app lấy
time.time()/Date.now()và truyền vàoARGV[n]— deterministic hoàn toàn.
Bài tiếp theo
Bài 33 áp dụng atomicity vừa học vào thuật toán cụ thể đầu tiên: Fixed Window Counter — đơn giản nhất trong các rate limit algorithm, nhưng có nhược điểm burst traffic ở ranh giới window cần hiểu rõ.
