目錄
為什麼需要限流
常見限流策略
.NET Core 實作方案
分散式限流方案
最佳實踐建議
環境配置建議
效能測試方法
為什麼需要限流 API 限流(Rate Limiting)的核心需求只有一個:保護服務本身不被流量壓垮,同時讓合法使用者繼續正常運作。這件事比聽起來更微妙——設太緊會誤殺正常請求,設太鬆則保護不到位。
幾個實際場景:
單一 IP 在 10 秒內打了 5,000 次請求,幾乎可以確定是爬蟲或攻擊
雲端服務按 API 呼叫次數計費,沒有限流的話一個 bug 就能讓帳單爆表
資料庫連線有上限,突發高併發會讓所有請求一起逾時,而非只讓超額的那些失敗
常見限流策略
限流策略
實作複雜度
記憶體消耗
精確度
突發流量處理
分散式實作
固定窗口
低
低
低
差
易
滑動窗口
中
中
高
中
中
令牌桶
中
中
高
優
中
漏桶
高
高
高
差
難
1. 固定窗口計數器(Fixed Window Counter) 最直接的做法:每個時間窗口(例如 1 分鐘)內維護一個計數器,超過上限就拒絕。
核心問題是邊界效應。 假設限制每分鐘 100 次,用戶可以在 13:00:59 打 100 次,再在 13:01:01 打 100 次,2 秒內實際送出 200 次,系統卻全部放行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 public class FixedWindowRateLimiter { private readonly int _limit; private readonly TimeSpan _window; private int _counter; private DateTime _lastReset; private readonly object _lock = new object (); public FixedWindowRateLimiter (int limit, TimeSpan window ) { _limit = limit; _window = window; _lastReset = DateTime.UtcNow; } public bool ShouldAllowRequest () { lock (_lock) { var now = DateTime.UtcNow; if (now - _lastReset > _window) { _counter = 0 ; _lastReset += _window; } if (_counter >= _limit) return false ; _counter++; return true ; } } }
適合用在:對精確度要求不高的內部 API、快速原型。有突發流量疑慮或高併發的場景不建議用。
2. 滑動窗口計數器(Sliding Window Counter) 把時間窗口切成更小的 bucket,持續滾動統計最近 N 個 bucket 的總量。邊界效應消失,代價是要多存每個 bucket 的計數。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 public class SlidingWindowRateLimiter { private readonly Dictionary<long , int > _windows = new (); private readonly int _limit; private readonly int _windowMilliseconds; private readonly int _bucketSizeMillis; private readonly object _lock = new (); public SlidingWindowRateLimiter (int limit, int windowSeconds, int bucketSizeMillis = 1000 ) { _limit = limit; _windowMilliseconds = windowSeconds * 1000 ; _bucketSizeMillis = bucketSizeMillis; } public bool ShouldAllowRequest () { lock (_lock) { var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var currentBucket = now - (now % _bucketSizeMillis); var windowStart = now - _windowMilliseconds; foreach (var key in _windows.Keys.Where(k => k < windowStart).ToList()) _windows.Remove(key); var requestCount = _windows.Values.Sum(); if (requestCount >= _limit) return false ; if (!_windows.ContainsKey(currentBucket)) _windows[currentBucket] = 0 ; _windows[currentBucket]++; return true ; } } }
bucket 大小建議值:100ms~1s;總窗口依場景定,API 限流通常 1s~60s,爬蟲防護可以拉到分鐘級。
3. 令牌桶(Token Bucket) 系統以固定速率往桶裡放令牌,每次請求消耗一個令牌,桶空了就拒絕。桶本身有容量上限,最大允許突發量等於桶容量 ——這讓它很適合「平時流量不大、偶爾突發」的場景。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 public class TokenBucketRateLimiter { private readonly int _bucketCapacity; private readonly int _tokensPerSecond; private double _currentTokens; private DateTime _lastRefillTime; private readonly object _lock = new object (); public TokenBucketRateLimiter (int bucketCapacity, int tokensPerSecond ) { _bucketCapacity = bucketCapacity; _tokensPerSecond = tokensPerSecond; _currentTokens = bucketCapacity; _lastRefillTime = DateTime.UtcNow; } public bool ShouldAllowRequest (int tokens = 1 ) { lock (_lock) { RefillTokens(); if (_currentTokens >= tokens) { _currentTokens -= tokens; return true ; } return false ; } } private void RefillTokens () { var now = DateTime.UtcNow; var timeElapsed = (now - _lastRefillTime).TotalSeconds; var tokensToAdd = timeElapsed * _tokensPerSecond; _currentTokens = Math.Min(_bucketCapacity, _currentTokens + tokensToAdd); _lastRefillTime = now; } public double GetCurrentTokens () { lock (_lock) { RefillTokens(); return _currentTokens; } } } var rateLimiter = new TokenBucketRateLimiter(100 , 10 );if (rateLimiter.ShouldAllowRequest(5 )){ }
令牌桶與漏桶的核心差異:令牌桶允許突發(桶裡的令牌可以一次用光),漏桶強制恆速輸出(桶滿了才拒絕,但消費速率固定)。
4. 漏桶(Leaky Bucket) 請求進入佇列(桶),以固定速率 逐一處理。桶滿了新請求才被拒絕。不論進來多快,出去的速率永遠恆定。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 public class LeakyBucketRateLimiter { private int _queueSize = 0 ; private readonly int _bucketCapacity; private readonly TimeSpan _leakInterval; private readonly object _lock = new object (); private DateTime _lastLeakTime; public LeakyBucketRateLimiter (int bucketCapacity, TimeSpan leakInterval ) { _bucketCapacity = bucketCapacity; _leakInterval = leakInterval; _lastLeakTime = DateTime.UtcNow; } public bool ShouldAllowRequest () { lock (_lock) { var now = DateTime.UtcNow; LeakRequests(now); if (_queueSize < _bucketCapacity) { _queueSize++; return true ; } return false ; } } private void LeakRequests (DateTime now ) { var elapsedTime = now - _lastLeakTime; var leaksCount = (int )(elapsedTime.TotalMilliseconds / _leakInterval.TotalMilliseconds); if (leaksCount > 0 ) { _queueSize = Math.Max(0 , _queueSize - leaksCount); _lastLeakTime = now; } } public int GetCurrentQueueSize () { lock (_lock) { return _queueSize; } } } var rateLimiter = new LeakyBucketRateLimiter(100 , TimeSpan.FromMilliseconds(100 ));
最適合需要保護下游處理速率的場景,例如資料庫批次寫入、影片串流處理。缺點是不接受任何突發——即使桶是空的,請求也得排隊等漏出間隔。
.NET Core 實作方案 使用官方內建限流中介軟體(.NET 7+) .NET 7 起,Microsoft.AspNetCore.RateLimiting 已內建於框架,支援 Fixed Window、Sliding Window、Token Bucket、Concurrency 四種 limiter,不需要安裝第三方套件。新專案應優先評估這個選項。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 builder.Services.AddRateLimiter(options => { options.AddFixedWindowLimiter("fixed" , opt => { opt.PermitLimit = 100 ; opt.Window = TimeSpan.FromMinutes(1 ); opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; opt.QueueLimit = 0 ; }); options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.OnRejected = async (context, cancellationToken) => { context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; context.HttpContext.Response.Headers["Retry-After" ] = "60" ; context.HttpContext.Response.Headers["X-RateLimit-Limit" ] = "100" ; await context.HttpContext.Response.WriteAsync( "Too many requests. Please try again later." , cancellationToken); }; }); var app = builder.Build();app.UseRateLimiter();
套用到特定 Controller 或 Endpoint:
1 2 3 4 5 6 7 8 [EnableRateLimiting("fixed" ) ] [ApiController ] [Route("api/[controller]" ) ] public class ProductsController : ControllerBase { ... }app.MapGet("/api/products" , () => ...) .RequireRateLimiting("fixed" );
官方文件:Rate limiting middleware in ASP.NET Core
內建方案的限制:in-memory limiter 在多個 Pod 後面的 load balancer 環境下各自計數,無法做跨節點限流。需要跨節點一致性時,看下方 Redis 方案。
使用 AspNetCoreRateLimit 套件 如果需要依 IP 或 Client ID 做細緻的多規則管理(例如不同路徑設不同限額、動態白名單),第三方 AspNetCoreRateLimit 套件比內建方案更完整:
1 dotnet add package AspNetCoreRateLimit
在 Program.cs 設定:
1 2 3 4 5 6 7 8 9 10 11 12 var builder = WebApplication.CreateBuilder(args );builder.Services.AddMemoryCache(); builder.Services.Configure<IpRateLimitOptions>( builder.Configuration.GetSection("IpRateLimiting" )); builder.Services.Configure<IpRateLimitPolicies>( builder.Configuration.GetSection("IpRateLimitPolicies" )); builder.Services.AddInMemoryRateLimiting(); builder.Services.AddSingleton<IRateLimitConfiguration, RateLimitConfiguration>(); var app = builder.Build();app.UseIpRateLimiting();
appsettings.json 限流規則:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 { "IpRateLimiting" : { "EnableEndpointRateLimiting" : true , "StackBlockedRequests" : false , "RealIpHeader" : "X-Real-IP" , "ClientIdHeader" : "X-ClientId" , "HttpStatusCode" : 429 , "IpWhitelist" : [ "127.0.0.1" , "192.168.0.0/24" ] , "ClientWhitelist" : [ "dev-id" , "trusted-app" ] , "GeneralRules" : [ { "Endpoint" : "*" , "Period" : "1s" , "Limit" : 10 } , { "Endpoint" : "*" , "Period" : "1m" , "Limit" : 100 } ] } }
自訂中介軟體 需要在限流邏輯裡加自訂業務判斷(例如 VIP 使用者走不同閾值),可以自己實作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 public class RateLimitMiddleware { private readonly RequestDelegate _next; private readonly IMemoryCache _cache; private const string CacheKeyPrefix = "RateLimit" ; public RateLimitMiddleware (RequestDelegate next, IMemoryCache cache ) { _next = next; _cache = cache; } public async Task InvokeAsync (HttpContext context ) { var ipAddress = context.Connection.RemoteIpAddress?.ToString(); var cacheKey = $"{CacheKeyPrefix} _{ipAddress} " ; var rateLimiter = _cache.GetOrCreate(cacheKey, entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1 ); return new FixedWindowRateLimiter(100 , TimeSpan.FromMinutes(1 )); }); if (!rateLimiter!.ShouldAllowRequest()) { var resetTime = DateTimeOffset.UtcNow.AddMinutes(1 ).ToUnixTimeSeconds(); context.Response.StatusCode = StatusCodes.Status429TooManyRequests; context.Response.Headers["Retry-After" ] = "60" ; context.Response.Headers["X-RateLimit-Limit" ] = "100" ; context.Response.Headers["X-RateLimit-Remaining" ] = "0" ; context.Response.Headers["X-RateLimit-Reset" ] = resetTime.ToString(); await context.Response.WriteAsync("Too many requests. Please try again later." ); return ; } await _next(context); } }
分散式限流方案 單機 in-memory 方案在水平擴展後會失效,每個節點各自計數,限額實際上被乘以節點數。需要跨節點一致性時,Redis 是標準做法。
為什麼不能只用 INCR + DECR 直覺上的做法是 INCR 計數、超限後 DECR 回退:
1 2 3 4 count = INCR key if count > limit: DECR key # 試圖回退 return false
這有兩個問題:
不是原子操作 。INCR 和後續的判斷+DECR 之間,其他請求可能已經讀到錯誤的計數值。
DECR 回退本身也不可靠 。如果服務在 INCR 後、DECR 前崩潰,計數永遠偏高。
正確做法:Lua 腳本保原子性 Redis 執行 Lua 腳本是原子的——腳本執行期間不會有其他命令插入。固定窗口的正確實作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 public class RedisRateLimiter { private readonly IConnectionMultiplexer _redis; private readonly string _keyPrefix; private const string LuaScript = @" local key = KEYS[1] local limit = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local current = redis.call('INCR', key) if current == 1 then redis.call('EXPIRE', key, window) end return current " ; public RedisRateLimiter (IConnectionMultiplexer redis, string keyPrefix ) { _redis = redis; _keyPrefix = keyPrefix; } public async Task<bool > ShouldAllowRequestAsync ( string clientId, int limit, TimeSpan window ) { var db = _redis.GetDatabase(); var key = $"{_keyPrefix} :{clientId} " ; var windowSeconds = (int )window.TotalSeconds; var result = (long )await db.ScriptEvaluateAsync( LuaScript, new RedisKey[] { key }, new RedisValue[] { limit, windowSeconds }); return result <= limit; } }
為什麼 current == 1 時才設 EXPIRE? 因為 key 存在後再呼叫 EXPIRE 會重設 TTL,讓窗口一直往後延,無法正確計數。只在第一次建立 key 時設過期,窗口才能按固定間隔滾動。
使用範例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public class DistributedRateLimitMiddleware { private readonly RequestDelegate _next; private readonly RedisRateLimiter _rateLimiter; public DistributedRateLimitMiddleware (RequestDelegate next, IConnectionMultiplexer redis ) { _next = next; _rateLimiter = new RedisRateLimiter(redis, "ratelimit" ); } public async Task InvokeAsync (HttpContext context ) { var clientId = context.Connection.RemoteIpAddress?.ToString() ?? "unknown" ; if (!await _rateLimiter.ShouldAllowRequestAsync( clientId, 100 , TimeSpan.FromMinutes(1 ))) { context.Response.StatusCode = StatusCodes.Status429TooManyRequests; context.Response.Headers["Retry-After" ] = "60" ; await context.Response.WriteAsync("Rate limit exceeded" ); return ; } await _next(context); } }
最佳實踐建議 分層設限。 同一個請求可以同時受到多層限制:IP 層(防爆破)、使用者層(公平使用)、端點層(保護昂貴操作)。每層閾值不同,任一層觸發就拒絕。
回應 header 不能省。 429 只告訴客戶端「被限了」,Retry-After 告訴它什麼時候可以重試,X-RateLimit-Remaining 讓它知道還有多少額度。沒有這些,客戶端只能盲目重試,反而放大了流量。
閾值要從壓測資料來。 拍腦袋設的數字不可信。先在測試環境把目標 API 跑到極限,記錄下 p99 延遲開始明顯爬升的 RPS,限流設在這個值的 70%~80%。生產環境部署後繼續觀察被拒絕率,超過 1% 要查原因。
白名單要最小化。 內部服務、監控探針確實需要排除限流,但白名單每增加一條就是一個潛在的繞過入口。定期審查,過期條目要清掉。
環境配置建議 開發環境 1 2 3 4 5 6 7 8 { "RateLimiting" : { "Enabled" : false , "WhitelistEnabled" : true , "DefaultLimit" : 1000 , "Period" : "1m" } }
開發時關掉限流,避免調試時被自己擋住。
測試環境 1 2 3 4 5 6 7 8 9 { "RateLimiting" : { "Enabled" : true , "WhitelistEnabled" : true , "DefaultLimit" : 100 , "Period" : "1m" , "MonitoringEnabled" : true } }
生產環境 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 { "RateLimiting" : { "Enabled" : true , "WhitelistEnabled" : true , "DefaultLimit" : 60 , "Period" : "1m" , "MonitoringEnabled" : true , "AlertingEnabled" : true , "Rules" : [ { "Endpoint" : "/api/public/*" , "Limit" : 30 , "Period" : "1m" } , { "Endpoint" : "/api/authenticated/*" , "Limit" : 100 , "Period" : "1m" } ] , "ClientWhitelist" : [ "internal-service" , "monitoring-service" ] , "AlertThresholds" : { "RejectionRate" : 0.1 , "RequestCount" : 1000 } } }
公開端點限更嚴(30/min),已驗證使用者寬鬆一些(100/min),白名單只保留必要的內部服務。
效能測試方法 使用 Apache JMeter 基本測試計劃結構:
1 2 3 4 5 6 7 8 9 10 <?xml version="1.0" encoding="UTF-8" ?> <jmeterTestPlan version ="1.2" properties ="5.0" > <ThreadGroup guiclass ="ThreadGroupGui" testname ="API Rate Limit Test" > <elementProp name ="ThreadGroup.main_controller" > <stringProp name ="LoopController.loops" > 100</stringProp > <stringProp name ="ThreadGroup.num_threads" > 50</stringProp > <stringProp name ="ThreadGroup.ramp_time" > 10</stringProp > </elementProp > </ThreadGroup > </jmeterTestPlan >
測試要跑四種場景才有意義:正常負載(確認限流不誤殺)、突發負載(短時間大量請求)、持續高負載(確認限流持久有效)、邊界測試(打到剛好等於限額的 99%、100%、101%,確認閾值行為符合預期)。
使用 K6 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import http from 'k6/http' ;import { check, sleep } from 'k6' ;export const options = { stages : [ { duration : '30s' , target : 20 }, { duration : '1m' , target : 100 }, { duration : '30s' , target : 100 }, { duration : '30s' , target : 0 }, ], }; export default function ( ) { const res = http.get ('http://api.example.com/test' ); check (res, { 'is status 200' : (r ) => r.status === 200 , 'is rate limited' : (r ) => r.status === 429 , }); sleep (1 ); }
K6 的好處是可以用 JavaScript 寫複雜的測試邏輯,例如混合不同 IP、不同路徑、不同請求大小,比 JMeter 的 GUI 配置更靈活。
壓測後要看的三個數字:被拒絕率(429 比例)、p99 延遲(限流路徑本身不能太慢)、記憶體使用量(滑動窗口和漏桶的記憶體消耗會隨請求量線性成長)。
參考資料