Rate Limiting cho .NET 10 năm 2026 - Token Bucket, Sliding Window và Distributed Limiter với Polly v8

Posted on: 4/16/2026 6:08:33 PM

1. Vì sao rate limiting vẫn là bài toán nóng năm 2026

Mặc định ai làm backend lâu năm cũng nghĩ rate limiting là "việc của reverse proxy", cấu hình vài dòng nginx rồi quên đi. Nhưng giai đoạn 2025–2026 đã đảo ngược giả định đó. Ba lực kéo đồng thời biến rate limiting thành một tầng kiến trúc độc lập, cần được thiết kế chủ động ngay từ đầu dự án:

  • Chi phí API LLM leo thang — một request tới mô hình reasoning có thể tốn 10–50 xu. Một client viết sai retry loop trong 10 phút đủ để xóa sổ margin của cả một feature. Giới hạn theo token, không chỉ theo request, trở thành yêu cầu mặc định.
  • Agent tự động gọi API — không còn "người dùng gõ phím" làm nhịp sinh học tự nhiên. Một background agent vòng lặp có thể đẩy 500 RPS chỉ trong vài giây và không có ý định xấu — chỉ là logic sai.
  • Public API + monetization tiered — nếu bạn bán API, tier Free/Pro/Enterprise bắt buộc phải phân biệt được ngay ở lớp middleware, không thể đẩy xuống nghiệp vụ.

Bài viết này mổ xẻ kiến trúc rate limiting dưới góc nhìn hệ thống thực chiến trên nền .NET 10: từ thuật toán cổ điển (Token Bucket, Leaky Bucket, Sliding Window), qua middleware Microsoft.AspNetCore.RateLimiting được nâng cấp trong .NET 10, sang Polly v8 ở tầng client outbound, rồi đến distributed rate limiter dùng Redis Lua script khi bạn scale-out nhiều instance. Kết thúc bằng một pattern đặc trưng 2026 — token-cost-based limiting cho API LLM.

10–50xChi phí mỗi request reasoning vs request REST thông thường
4Algorithm chính: Token, Leaky, Fixed Window, Sliding Window
3Tầng cần limit: Gateway, Application, Outbound client
< 1msĐộ trễ mục tiêu cho check rate limiter in-process

Rate limit, throttling và backpressure khác nhau ra sao?

Rate limit là giới hạn cứng có chính sách (ví dụ 100 req/phút) — vượt thì chặn. Throttling là làm chậm lại (delay, giãn nhịp) thay vì từ chối. Backpressure là cơ chế phản hồi ngược dòng xuyên suốt pipeline, thường dùng trong streaming (Reactive, Dataflow). Một hệ thống thực tế thường kết hợp cả ba: gateway chặn cứng, tầng app throttle theo tier, và kênh bus có backpressure để bảo vệ consumer.

2. Mô hình đe dọa và mục tiêu rate limiting

Trước khi chọn thuật toán, hãy ghi rõ bạn đang bảo vệ chống cái gì. Cùng một API có thể cần nhiều policy song song:

Mối đe dọaVí dụChiến lược phù hợp
Noisy neighborMột tenant sinh 90% lưu lượng, làm nghẽn tenant khácPer-tenant quota + fair queueing
Runaway clientAgent retry vô hạn khi gặp lỗi 500Per-client bucket + exponential backoff bắt buộc
Credential stuffingThử nghiệm mật khẩu hàng loạt trên login endpointPer-IP + per-account sliding window nghiêm
Cost explosionClient free dùng model reasoning đắt nhấtToken-cost limiter + tier policy
Volumetric DDoSBotnet 10k IP flood /healthEdge CDN/WAF — KHÔNG xử ở application
Scraping có chủ đíchBot thu thập dữ liệu danh bạ sản phẩmFingerprint + adaptive limit theo behavior

Rate limit không thay thế WAF hay CDN

Một sai lầm cổ điển: coi rate limiter trong app là tuyến phòng thủ đầu tiên chống DDoS. Khi lưu lượng rác đã vào tới process .NET nghĩa là bạn đã tiêu CPU để từ chối nó — vẫn thua. Rate limit ở ứng dụng chỉ nên xử lý traffic hợp pháp về mặt mạng nhưng cần giới hạn nghiệp vụ. DDoS volumetric phải chặn ở edge (Cloudflare, Akamai, AWS Shield).

3. Bốn thuật toán cơ bản và đánh đổi

Các thư viện .NET 10 đều built-in bốn thuật toán kinh điển dưới dạng PartitionedRateLimiter. Hiểu rõ trade-off quan trọng hơn là học API:

flowchart LR
    subgraph Req["Requests theo thoi gian"]
        R1["R1"] --> R2["R2"] --> R3["R3"] --> R4["R4"] --> R5["R5"]
    end
    subgraph Algo["4 algorithm"]
        A1["Fixed Window
Don gian - Burst sat bien"] A2["Sliding Window
Muot - Toan bo nho hon"] A3["Token Bucket
Cho phep burst - Refill lien tuc"] A4["Leaky Bucket
Lam muot output - Queue co han"] end Req --> A1 Req --> A2 Req --> A3 Req --> A4 A1 --> O1["Allow or Reject"] A2 --> O2["Allow or Reject"] A3 --> O3["Allow or Reject"] A4 --> O4["Allow or Queue or Reject"]

Hình 1: Bốn thuật toán rate limiting phổ biến — cùng input nhưng đặc tính output khác nhau

3.1. Fixed Window — đơn giản nhưng cạnh cửa nguy hiểm

Chia thời gian thành các cửa sổ cố định (ví dụ mỗi phút), đếm request trong cửa sổ, reset về 0 khi qua cửa. Ưu điểm là rất rẻ: chỉ một counter per key. Nhược điểm chí mạng là hiệu ứng rìa cửa sổ — client có thể gửi 100 request ở giây thứ 59 của phút thứ nhất và 100 request ở giây thứ 1 của phút thứ hai, đạt 200 request trong 2 giây dù limit là 100/phút.

3.2. Sliding Window — ước lượng mượt, khuyến nghị cho HTTP API

Chia cửa sổ N thành k segment nhỏ hơn (ví dụ 60 giây → 6 segment 10 giây). Khi một segment mới bắt đầu, loại bỏ segment cũ nhất. Tổng request trong window là tổng các segment. Độ chính xác cao hơn Fixed, chi phí vẫn O(k). Đây là default tốt cho HTTP API.

3.3. Token Bucket — cho phép burst có kiểm soát

Một bucket có dung lượng C, được nạp tokens với tốc độ r mỗi giây. Mỗi request tiêu một (hoặc nhiều) token. Hết token → từ chối. Ưu điểm lớn: cho phép burst tới C request ngay lập tức khi bucket đầy, rồi về nhịp ổn định r. Phù hợp với API cho phép user thao tác nhanh sau thời gian idle.

3.4. Leaky Bucket — làm mượt output, bảo vệ downstream

Ngược với Token: request đi vào bucket, rò rỉ ra downstream với tốc độ cố định. Bucket đầy → request mới bị drop. Mô hình này làm mượt dòng request gửi xuống hệ thống phía sau, hữu ích khi downstream là DB không chịu được burst. Trade-off: tăng độ trễ (queue) thay vì từ chối ngay.

Thuật toánCho phép burst?Bộ nhớ/keyEdge effectPhù hợp với
Fixed WindowCó (xấu, ở rìa)O(1)Counter thô, metrics nội bộ
Sliding WindowHạn chếO(k)KhôngHTTP public API (default)
Token BucketCó (có kiểm soát)O(1)KhôngUser action, API tier
Leaky BucketKhông (queue)O(queue size)KhôngBảo vệ downstream chậm

4. Middleware Rate Limiting trong .NET 10 — những gì mới

Namespace Microsoft.AspNetCore.RateLimiting xuất hiện từ .NET 7, nhưng bản .NET 10 LTS mang về ba cải tiến đáng chú ý: chained limiter (áp nhiều policy tuần tự), metadata tùy biến trên endpoint, và tích hợp Problem Details RFC 9457 cho response 429.

// Program.cs — .NET 10
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(options =>
{
    // Global partition theo IP cho mọi endpoint chưa khai báo policy
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "anon",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 200,
                Window = TimeSpan.FromMinutes(1),
                QueueLimit = 0,
                AutoReplenishment = true
            }));

    // Policy per-tier — Token Bucket
    options.AddPolicy("per-user-tier", ctx =>
    {
        var user = ctx.User;
        var tier = user.FindFirst("tier")?.Value ?? "free";
        var (permits, refillPerSec) = tier switch
        {
            "enterprise" => (1000, 50),
            "pro"        => (200, 10),
            _            => (60, 2)
        };
        var key = $"{tier}:{user.FindFirst("sub")?.Value ?? "anon"}";

        return RateLimitPartition.GetTokenBucketLimiter(key, _ => new TokenBucketRateLimiterOptions
        {
            TokenLimit = permits,
            TokensPerPeriod = refillPerSec,
            ReplenishmentPeriod = TimeSpan.FromSeconds(1),
            AutoReplenishment = true,
            QueueLimit = 0
        });
    });

    options.OnRejected = async (ctx, token) =>
    {
        ctx.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        if (ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retry))
            ctx.HttpContext.Response.Headers.RetryAfter = ((int)retry.TotalSeconds).ToString();

        await ctx.HttpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = 429,
            Title  = "Too Many Requests",
            Detail = "Rate limit exceeded. See Retry-After header.",
            Type   = "https://httpstatuses.io/429"
        }, cancellationToken: token);
    };
});

var app = builder.Build();
app.UseRateLimiter();

app.MapGet("/search", () => Results.Ok("ok"))
   .RequireRateLimiting("per-user-tier");
app.Run();

Pattern chained limiter cho API công cộng

Một public API nên có ít nhất ba policy tầng tuần tự: global (per-IP, thô), per-user (per-API-key, mịn), per-endpoint (hot endpoint nhạy cảm như /login). Trong .NET 10 bạn compose bằng PartitionedRateLimiter.CreateChained(). Khi request bị chặn, middleware trả về policy nào chặn qua RateLimitPartition.PartitionKey để client biết hạn chế nào đang áp.

5. Polly v8 — rate limit ở tầng outbound client

Middleware ASP.NET bảo vệ server khỏi client xấu. Nhưng code của bạn cũng là client của các API ngoài: OpenAI, Stripe, Slack, Google... Gửi quá nhiệt ra ngoài sẽ bị chính provider cắt. Polly v8 (GA cuối 2023, ổn định trên .NET 10) tái kiến trúc quanh ResiliencePipeline và có RateLimiterStrategy dùng chung System.Threading.RateLimiting.

var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddRateLimiter(new SlidingWindowRateLimiterOptions
    {
        PermitLimit = 20,
        Window = TimeSpan.FromSeconds(1),
        SegmentsPerWindow = 5,
        QueueLimit = 50,
        QueueProcessingOrder = QueueProcessingOrder.OldestFirst
    })
    .AddRetry(new RetryStrategyOptions<HttpResponseMessage>
    {
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .HandleResult(r => r.StatusCode == HttpStatusCode.TooManyRequests)
            .Handle<HttpRequestException>(),
        BackoffType = DelayBackoffType.Exponential,
        UseJitter  = true,
        MaxRetryAttempts = 3,
        DelayGenerator = args =>
        {
            // Tôn trọng Retry-After của upstream
            if (args.Outcome.Result?.Headers.RetryAfter?.Delta is { } ra)
                return ValueTask.FromResult<TimeSpan?>(ra);
            return ValueTask.FromResult<TimeSpan?>(null);
        }
    })
    .AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
    {
        FailureRatio = 0.5, MinimumThroughput = 10,
        SamplingDuration = TimeSpan.FromSeconds(30),
        BreakDuration    = TimeSpan.FromSeconds(15)
    })
    .Build();

// Gắn vào HttpClient
builder.Services.AddHttpClient("openai")
    .AddResilienceHandler("std", b => b
        .AddRateLimiter(new SlidingWindowRateLimiterOptions { /* như trên */ })
        .AddRetry(/* ... */)
        .AddCircuitBreaker(/* ... */));

Điểm tinh tế ở Polly v8 là thứ tự strategy quan trọng: rate limiter phải đặt TRƯỚC retry. Nếu không, mỗi retry sẽ bypass limiter và khi provider đang "nóng" bạn làm cho nó nóng hơn. Tương tự, circuit breaker nên nằm sau cùng — nó phản ứng với mẫu lỗi, không nên "thấy" các request bị chính ta bóp lại.

6. Distributed rate limiter — vì sao in-process không đủ khi scale-out

Khi service chạy 10 replica trên Kubernetes, một limiter in-process đặt tại mỗi replica thực chất cho phép 10 × permit mỗi phút. Nếu bạn bán SLA "100 req/phút cho tier Free", limiter in-process không đúng hợp đồng. Bạn cần một counter chia sẻ — Redis là lựa chọn kinh điển vì O(1) + script atomic.

sequenceDiagram
    participant C as Client
    participant R1 as Replica 1
    participant R2 as Replica 2
    participant Rds as Redis
    C->>R1: Request API (api-key=K)
    R1->>Rds: EVAL rate_limit.lua K 100 60
    Rds-->>R1: allow, remaining=74
    C->>R2: Request khac (same key)
    R2->>Rds: EVAL rate_limit.lua K 100 60
    Rds-->>R2: allow, remaining=73
    C->>R1: Request thu 101
    R1->>Rds: EVAL rate_limit.lua K 100 60
    Rds-->>R1: deny, retry_after=42
    R1-->>C: 429 Retry-After 42

Hình 2: Distributed rate limiter — counter đặt ở Redis, mọi replica đọc/ghi atomic qua Lua script

Script Lua Sliding Window đơn giản, chạy atomic trên Redis:

-- rate_limit.lua — sliding window log
-- KEYS[1] = "rl:{api-key}"
-- ARGV[1] = max requests
-- ARGV[2] = window seconds
-- ARGV[3] = now (ms)
local key    = KEYS[1]
local limit  = tonumber(ARGV[1])
local window = tonumber(ARGV[2]) * 1000
local now    = tonumber(ARGV[3])
local cutoff = now - window

redis.call('ZREMRANGEBYSCORE', key, 0, cutoff)
local count = redis.call('ZCARD', key)
if count >= limit then
  local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
  local retry  = math.ceil((tonumber(oldest[2]) + window - now) / 1000)
  return {0, 0, retry}
end
redis.call('ZADD', key, now, now .. ':' .. math.random())
redis.call('PEXPIRE', key, window)
return {1, limit - count - 1, 0}

Trong .NET, gọi qua StackExchange.Redis:

public sealed class RedisSlidingLimiter(IConnectionMultiplexer mux, string scriptSha)
{
    public async Task<RateLimitResult> TryAcquireAsync(string key, int limit, TimeSpan window)
    {
        var db = mux.GetDatabase();
        var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        var result = (RedisResult[])await db.ScriptEvaluateAsync(
            scriptSha, new RedisKey[] { $"rl:{key}" },
            new RedisValue[] { limit, (int)window.TotalSeconds, now });

        return new RateLimitResult(
            Allowed:    (long)result[0] == 1,
            Remaining:  (long)result[1],
            RetryAfter: TimeSpan.FromSeconds((long)result[2]));
    }
}
public record RateLimitResult(bool Allowed, long Remaining, TimeSpan RetryAfter);

Fail-open hay fail-closed khi Redis mất liên lạc?

Khi Redis down, bạn đứng trước hai lựa chọn đau đớn: fail-closed (chặn hết mọi request — bảo vệ SLA nhưng tự gây downtime) hay fail-open (cho qua — giữ trải nghiệm nhưng có thể vỡ giới hạn). Best practice 2026: fail-open có giám sát, đi kèm circuit breaker trên chính kết nối Redis, và fallback về limiter in-process với hạn mức tạm thời thấp hơn. Ghi log rõ ràng mỗi lần fallback để on-call biết.

7. Pattern 2026: rate limit theo token-cost cho API LLM

Các endpoint AI không đồng nhất về chi phí. Một call /chat/completions với 200 input token chat nhanh khác hẳn một call reasoning với 50k token context. Rate limit theo số request là sai đơn vị đo. Provider lớn (OpenAI, Anthropic, Google) công khai giới hạn TPM — Tokens Per Minute song song với RPM — Requests Per Minute. Khi bạn tự host cổng gọi LLM cho team, cần tái hiện cả hai.

Ý tưởng: trừ bucket theo ước lượng token trước khi gọi, điều chỉnh sau khi có số thật:

// Ước lượng token = prompt tokens + max_output_tokens
// Trước request — giữ lease có kích thước động
var estimated = TokenEstimator.Estimate(prompt) + opts.MaxOutputTokens;

using var lease = await _tpmLimiter.AcquireAsync(
    permitCount: estimated, cancellationToken: ct);
if (!lease.IsAcquired)
    return Results.StatusCode(429);

var response = await _llm.ChatAsync(prompt, opts, ct);

// Sau request — điều chỉnh chênh lệch (refund hoặc thêm nợ)
var actual = response.Usage.TotalTokens;
var diff   = actual - estimated;
if (diff != 0) _tpmLimiter.Adjust(diff);

return Results.Ok(response);

Bản PartitionedRateLimiter mặc định của .NET không cho phép "điều chỉnh sau lease". Trong thực tế ta tự viết một TokenBudgetLimiter wrap lại TokenBucketRateLimiter với một counter bù: nếu ước lượng dư, refund bằng cách +tokens; nếu ước lượng thiếu, ghi nợ bằng cách -tokens ngay lập tức. Counter lưu trên Redis nếu multi-instance. Sai số 10–20% chấp nhận được; mục đích là tránh overshoot hợp đồng với provider, không phải đo chuẩn xác.

flowchart TB
    REQ["Request LLM"] --> EST["Estimate tokens
prompt + max output"] EST --> ACQ{"Acquire lease
permitCount=estimate"} ACQ -->|"Denied"| R429["429 + Retry-After"] ACQ -->|"Acquired"| CALL["Call LLM API"] CALL --> USAGE["Response usage
thuc te"] USAGE --> ADJ{"diff = actual - estimate"} ADJ -->|"diff > 0"| DEBIT["Tru them tokens"] ADJ -->|"diff < 0"| REFUND["Hoan lai tokens"] ADJ -->|"diff = 0"| DONE["Done"] DEBIT --> DONE REFUND --> DONE

Hình 3: Token-cost limiter — ước lượng trước, điều chỉnh sau khi có usage thật

8. Response headers và hợp đồng với client

RFC 9239 và draft RateLimit header family của IETF đã gần đi đến đồng thuận năm 2025. Khuyến nghị trả về:

  • RateLimit-Limit: 100 — hạn mức của window
  • RateLimit-Remaining: 42 — còn lại
  • RateLimit-Reset: 37 — giây đến khi reset (tương đối, không tuyệt đối)
  • Retry-After: 37 — khi status = 429

Tránh trả tuyệt đối (Unix timestamp) — đồng hồ client lệch sẽ gây retry sớm. Luôn tương đối. Khi có nhiều policy song song (global + per-user + per-endpoint), trả về policy sắp chạm ngưỡng nhất, không phải tất cả, để client biết cái gì đang là bottleneck.

9. Observability — không limit thì không biết mất gì

Metric tối thiểu phải expose qua OpenTelemetry:

  • ratelimit.requests.allowed{policy, partition} — counter
  • ratelimit.requests.rejected{policy, partition, reason} — counter; reason = exhausted/queued/timeout
  • ratelimit.queue.length{policy} — gauge, phục vụ capacity planning
  • ratelimit.lease.wait.duration{policy} — histogram

Alert không nên báo "có 429" — 429 là mong muốn. Thay vào đó:

  • Tỉ lệ 429 tăng đột biến 5x so với baseline 24h — dấu hiệu attack hoặc client bug.
  • Hàng đợi persist > 30s — downstream không theo kịp, cần scale hoặc nâng limit.
  • Cùng một partition key bị reject >95% trong 5 phút — client có thể đang kẹt vòng lặp retry.

10. Pattern production đầy đủ

Bước 1 — Phân tầng limiter
Edge (CDN/WAF) chặn flood. Gateway (YARP hoặc Nginx) limit thô per-IP. Application limit tinh per-user/per-endpoint. Outbound (Polly) limit client gọi ra ngoài. Mỗi tầng làm một việc, không chồng chéo.
Bước 2 — Key partition đúng
KHÔNG dùng IP làm key cho authenticated API (NAT, VPN gộp nhiều user). Dùng sub claim hoặc API key hash. IP chỉ cho unauthenticated endpoint như /login, /signup.
Bước 3 — Chọn algorithm theo đặc tính traffic
HTTP public API → Sliding Window. User-triggered UI → Token Bucket (cho phép burst). Ghi ngược vào DB/downstream chậm → Leaky Bucket. Counter nội bộ đơn giản → Fixed Window.
Bước 4 — Distributed khi > 1 replica
Redis + Lua atomic. Luôn có plan fail-open có giám sát. Nếu không chịu thêm dependency, chấp nhận limit "per-replica" và khai báo rõ trong contract.
Bước 5 — Client nice-by-default
SDK nội bộ tự động đọc Retry-After + exponential backoff + jitter. Đừng để logic retry do từng caller tự viết — sai nhiều hơn đúng.
Bước 6 — Load test trên đường biên
Test với k6 / NBomber ở đúng ngưỡng limit + 1, + 10%, + 100%. Kiểm tra cả hành vi dưới lỗi Redis (kill redis và xem hệ thống có fail-open như thiết kế).
Bước 7 — Doc hóa contract với client
Public docs phải ghi rõ algorithm, window, header, behavior khi vượt. Client mới cần 15 phút để integrate đúng, không phải 3 ngày debug.

Checklist trước khi publish API ra ngoài

  • Đã có policy per-endpoint cho /login, /signup, /password-reset với window ngắn và permit thấp
  • Đã test hành vi khi Redis mất kết nối (fail-open + alert)
  • Đã expose metric allowed/rejected/queue với đủ label
  • Đã trả về RateLimit-* headers và Retry-After đúng chuẩn
  • Đã viết sample code hoặc SDK nội bộ xử lý backoff tự động
  • Đã có dashboard monitor tỉ lệ 429 per tenant để phát hiện client bug
  • Đã kiểm tra partition key không bị DoS nhân tạo (ví dụ attacker gửi header X-Forwarded-For giả để phân tán key)

11. Kết luận

Rate limiting năm 2026 không còn là vài dòng cấu hình sao chép từ blog cũ. Nó là một tầng kiến trúc với mô hình đe dọa rõ ràng, thuật toán phù hợp ngữ cảnh, partition key thiết kế cẩn trọng và đường lùi khi phụ thuộc bên ngoài lung lay. Trong .NET 10, hệ công cụ built-in đã đủ mạnh — Microsoft.AspNetCore.RateLimiting ở tầng server, Polly v8 ở tầng client — để bạn không phải tự viết lại bánh xe. Việc của kiến trúc sư là ghép các mảnh đó thành chính sách đi đúng mô hình đe dọa cụ thể của sản phẩm, đo được, và thay đổi được mà không phải deploy lại hàng tá service.

Khi bạn bắt đầu tính tiền theo token, khi agent tự động trở thành client lớn nhất, khi SLA có penalty tài chính — mỗi quyết định rate limit sai không còn là "nhỏ, sửa sau". Nó là một hợp đồng. Đầu tư đúng chỗ ngay từ đầu.

12. Tham khảo