目錄

  1. 為什麼需要限流
  2. 常見限流策略
  3. .NET Core 實作方案
  4. 分散式限流方案
  5. 最佳實踐建議
  6. 環境配置建議
  7. 效能測試方法

為什麼需要限流

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;

// 清除過期的 bucket
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;
}
}
}
// 桶容量 100,每秒補充 10 個令牌
// 空桶時新請求要等 0.1s 才能過;但積累了令牌後可以處理短暫高峰
var rateLimiter = new TokenBucketRateLimiter(100, 10);

// 大請求可以消耗多個令牌(例如上傳大檔計 5 個)
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;
}
}
}
// 桶容量 100,每 100ms 處理一個請求(等效每秒最多 10 個)
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;
});

// 超限時回傳 429,並附上 Retry-After header
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 { ... }

// 或在 minimal API endpoint 上
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

這有兩個問題:

  1. 不是原子操作。INCR 和後續的判斷+DECR 之間,其他請求可能已經讀到錯誤的計數值。
  2. 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;

// Lua 腳本:INCR + 首次請求時設 TTL,整個操作原子執行
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 延遲(限流路徑本身不能太慢)、記憶體使用量(滑動窗口和漏桶的記憶體消耗會隨請求量線性成長)。

參考資料