Thiết kế rate limiter phân tán (token bucket trên Redis)
Thiết kế đầu cuối rate limiter phân tán: token bucket vs sliding window vs fixed window, Lua atomic trên Redis, và wrapper .NET mọi service dùng.
Mục lục
Limiter sẵn ASP.NET Core trong chương 14 là in-memory; chỉ đếm những gì một instance thấy. Case study ở đây là phiên bản phân tán - cái cho enforce thật xuyên đội replica. Phỏng vấn hỏi; production cần. Câu trả lời là một Lua script Redis và wrapper .NET mỏng.
Khi nào limiter phân tán thành cần thiết?
Ba tín hiệu.
Hơn một replica. Ba máy với 100 RPS mỗi cái cộng lại cho
300 RPS. Nếu ý định thật là 100 RPS, limiter in-memory đã lừa
bạn.
State xuyên process quan trọng. Endpoint login cho phép
5 lần thử mỗi IP mỗi 15 phút. Nếu request attacker nhảy giữa
replica, mỗi replica thấy phần nhỏ; limit không enforce.
Nhất quán đa service. Cùng user không vượt
1000 call/phút tổng xuyên web, mobile, partner. Mỗi điểm vào là
service riêng; cần counter chia sẻ.
Số nào nên ngân sách?
Thuật toán Memory/key Chính xác CPU/check
Fixed window counter 16 byte burst mép O(1) Lua
Sliding window log ~24 B mỗi req chính xác O(N) - N lưu
Sliding window counter 32 byte ~95% O(1) Lua
Token bucket 24 byte mượt O(1) Lua
Token bucket và sliding-window counter là default thực dụng. Cả hai vừa một Lua script dưới 30 dòng. Sliding-window log cho enforce chính xác nhất nhưng đổi memory tăng dưới tải.
Kiến trúc trông thế nào?
flowchart LR
App1[ASP.NET Core 1] -->|EVAL Lua| Redis[(Redis)]
App2[ASP.NET Core 2] -->|EVAL Lua| Redis
App3[ASP.NET Core 3] -->|EVAL Lua| Redis
Redis -->|allowed/denied| App1
Redis -->|allowed/denied| App2
Redis -->|allowed/denied| App3
Mọi replica chạy cùng Lua script với cùng Redis. Script là nguồn sự thật cho counter; code .NET là wrapper typed.
Triển khai .NET 10 ra sao?
Token bucket làm default. Lua script:
-- KEYS[1] = key bucket
-- ARGV[1] = capacity
-- ARGV[2] = refill_rate mỗi giây
-- ARGV[3] = unix time hiện tại (giây)
-- ARGV[4] = cost (thường 1)
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(bucket[1]) or tonumber(ARGV[1])
local last_ts = tonumber(bucket[2]) or tonumber(ARGV[3])
local now = tonumber(ARGV[3])
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local cost = tonumber(ARGV[4])
local elapsed = math.max(0, now - last_ts)
tokens = math.min(capacity, tokens + elapsed * refill)
if tokens >= cost then
tokens = tokens - cost
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / refill) * 2)
return 1
else
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / refill) * 2)
return 0
end
Wrapper .NET:
public class DistributedRateLimiter(IConnectionMultiplexer redis)
{
private const string TokenBucketScript = "..."; // Lua ở trên
private static readonly LoadedLuaScript Script =
LuaScript.Prepare(TokenBucketScript).Load(/* server */);
public async Task<bool> TryAcquireAsync(
string key, int capacity, double refillPerSecond, int cost = 1)
{
var db = redis.GetDatabase();
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var result = (long)await db.ScriptEvaluateAsync(
Script,
new RedisKey[] { $"rl:{key}" },
new RedisValue[] { capacity, refillPerSecond, now, cost });
return result == 1;
}
}
// Sử dụng trong middleware hoặc endpoint filter:
public class DistributedLimiterMiddleware(DistributedRateLimiter limiter) : IMiddleware
{
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
var userId = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anon";
var allowed = await limiter.TryAcquireAsync($"user:{userId}", capacity: 100, refillPerSecond: 100.0 / 60);
if (!allowed)
{
ctx.Response.StatusCode = 429;
ctx.Response.Headers.RetryAfter = "1";
return;
}
await next(ctx);
}
}
Ba chi tiết. Script load một lần trên Redis (Redis cache theo
SHA1) nên call sau là EVALSHA, không phải EVAL. TTL ngăn rò
memory từ key idle. Wrapper lộ một method - mọi thứ khác là cấu
hình.
Lắp với các block khác ra sao?
flowchart LR
Client --> CDN
CDN --> LB[Load Balancer<br/>limit L4 theo IP]
LB --> Edge[ASP.NET Core]
Edge --> Mid[Middleware limiter<br/>per user/tenant]
Mid --> Endpoint[Handler]
Mid -.gọi.-> Redis[(Redis)]
Endpoint --> Cache[(Redis cache)]
Endpoint --> DB[(Postgres)]
Limiter chia sẻ cùng Redis với
cache - khác namespace key
(rl: vs cache:). Middleware ngồi trước xác thực cho path
không xác thực và sau cho path đã xác thực, đúng như sẵn có.
Tạo ra failure mode nào?
- Outage Redis - Redis down, limiter không quyết được. Phòng: fail-open (cho mọi request nhưng page on-call) hoặc fail-closed với fallback circuit breaker về in-memory.
- Lệch đồng hồ - script dùng thời gian process .NET. Nếu một
replica đi trước 30 giây, tính refill nhiều hơn mong. Phòng:
truyền
redis.call('TIME')server-side từ trong script. - Hot key - một tenant sinh nhiều call limiter làm quá tải
một CPU Redis. Phòng: shard key
(
tenant:{id}:{shard%4}) và check tất cả shard. - Reload script sau restart - Redis flush cache script thi
thoảng; call đầu sau đó nhận
NOSCRIPT. Phòng: bắt lỗi và fallbackEVALmột lần.
Khi nào limiter in-memory vẫn đúng?
Khi bạn có một replica hoặc dung sai cho limit hiệu lực 2-3x cao. Side-project deploy như một instance Azure App Service không cần Redis. Limiter tuỳ là cho đội production nơi khoảng cách giữa ý định và thực tế quan trọng.
Đi tiếp đâu từ đây?
Case study kế tiếp: thiết kế news feed - bài fan-out kinh điển, nơi cùng mẹo hot-key từ URL shortener bị đẩy đến giới hạn. Sau đó chương chat realtime đưa WebSocket và SignalR vào câu chuyện.
Câu hỏi thường gặp
Sao xây limiter riêng khi ASP.NET Core có sẵn?
Token bucket hay sliding window?
Sao check-and-decrement phải atomic?
Size bucket bao nhiêu?
bucket_size = peak_rps * 1 giây (một giây dung sai burst). Tune lên nếu user thật bị limit - xem tỉ lệ reject trong observability. Tune xuống nếu abuse lọt. Số đúng là cái bạn bảo vệ được bằng đồ thị.