Case study Nâng cao 4 phút đọc

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
  1. Khi nào limiter phân tán thành cần thiết?
  2. Số nào nên ngân sách?
  3. Kiến trúc trông thế nào?
  4. Triển khai .NET 10 ra sao?
  5. Lắp với các block khác ra sao?
  6. Tạo ra failure mode nào?
  7. Khi nào limiter in-memory vẫn đúng?
  8. Đi tiếp đâu từ đây?

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?

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?
Vì limiter sẵn là in-memory (chương 14). Cho đội replica cần state chia sẻ. Limiter tuỳ là wrapper mỏng quanh Lua script Redis - 50 dòng code, deploy một lần, mọi service dùng.
Token bucket hay sliding window?
Token bucket cho phần lớn ca - cho phép burst thân thiện user và thuật toán tha thứ lệch đồng hồ giữa Redis và client. Sliding window log nghiêm hơn nhưng tốn memory tỉ lệ với RPS cho phép (một timestamp mỗi request). Sliding window counter là trung gian - sliding window xấp xỉ với hai bucket cố định.
Sao check-and-decrement phải atomic?
Vì hai request đồng thời làm 'GET, check, DECR' đều pass khi còn một token và đều decrement, đi xuống dưới không. Lua script trong Redis chạy atomic - cả check-and-decrement là một thao tác từ góc nhìn Redis. Không có nó, limiter rò khi đồng thời.
Size bucket bao nhiêu?
Bắt đầu với 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ị.