限流算法深入(令牌桶/滑动窗口)
大约 12 分钟约 3714 字
限流算法深入(令牌桶/滑动窗口)
简介
限流(Rate Limiting)用于保护服务不被瞬时流量打垮,同时保证核心资源能够优先服务关键请求。ASP.NET Core 7+ 已内置固定窗口(Fixed Window)、滑动窗口(Sliding Window)、令牌桶(Token Bucket)和并发限制(Concurrency Limiter)四种算法,理解它们的差异,才能在 API 网关、后台任务入口、文件上传、登录接口等场景里做出合理选择。
限流在系统架构中的位置
客户端请求
|
v
[CDN/WAF] -- 第一层:DDoS 防护、IP 黑名单
|
v
[API 网关] -- 第二层:全局限流、用户级限流、租户级限流
|
v
[应用层限流] -- 第三层:接口级限流、资源保护型限流
|
v
[业务逻辑] -- 实际处理请求
|
v
[数据库/外部服务] -- 需要保护的下游资源限流的两大目标
| 目标类型 | 说明 | 典型场景 |
|---|---|---|
| 防攻击型 | 防止恶意用户暴力尝试 | 登录、短信、验证码 |
| 资源保护型 | 防止合法流量压垮系统 | 文件上传、报表导出、订单创建 |
特点
算法详解
固定窗口(Fixed Window)
时间轴:
|---- 窗口 1 (0-60s) ----|---- 窗口 2 (60-120s) ----|
| 请求: ||||||||||| | ||||||||||||||||||||| |
| 计数: 50 | 150 |
问题:窗口边界突刺
- 59s 时来了 50 个请求
- 60s 时窗口重置,又来了 100 个请求
- 1 秒内处理了 150 个请求,远超预期
优点:实现简单,内存占用少
缺点:窗口边界有突刺,实际通过量可能是限额的 2 倍滑动窗口(Sliding Window)
时间轴(6 段,每段 10 秒):
|-- 段1 --|-- 段2 --|-- 段3 --|-- 段4 --|-- 段5 --|-- 段6 --|
当前时间在段 4:
权重计算:
段1: 30% (距今 30s) -> 计数 x 0.3
段2: 20% (距今 20s) -> 计数 x 0.2
段3: 70% (距今 10s) -> 计数 x 0.7
段4: 当前段 -> 计数 x 1.0
段5: 未来 -> 0
段6: 未来 -> 0
有效计数 = 加权求和
优点:统计平滑,消除边界突刺
缺点:实现稍复杂,需要维护多个段的计数器令牌桶(Token Bucket)
令牌桶示意:
添加速率: 10 tokens/s
|
v
+---------------+
| Token | 桶容量: 20 tokens
| Bucket | 当前: 15 tokens
| [|||||||||] |
+---------------+
|
v
请求到达 -> 消耗 1 token
桶空 -> 请求被拒绝/排队
特点:
- 桶中有令牌 -> 请求立即通过
- 桶空 -> 请求等待或拒绝
- 恒定速率补充令牌
- 允许短时间突发(桶中积累的令牌可以一次性使用)
优点:允许突发,平均速率可控,最常用
缺点:需要维护令牌计数和补充定时器并发限制(Concurrency Limiter)
并发限制示意:
处理槽位(3 个):
[槽1: 处理中...] [槽2: 处理中...] [槽3: 空闲]
新请求到达:
- 有空闲槽 -> 立即分配
- 无空闲槽 -> 进入队列(最多 20 个)
- 队列也满 -> 拒绝请求
与速率限制的区别:
- 速率限制:限制"单位时间内的请求数"
- 并发限制:限制"同时处理的请求数"
适用场景:
- 文件上传(每个上传占用一个线程和内存)
- 报表导出(CPU 密集型任务)
- 数据库连接保护(防止连接池耗尽)实现
四种内置算法的配置方式
// Program.cs
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRateLimiter(options =>
{
// 全局默认拒绝状态码
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// ============================================
// 策略 1:固定窗口 — 登录接口防暴力尝试
// ============================================
options.AddFixedWindowLimiter("login-fixed", limiterOptions =>
{
limiterOptions.PermitLimit = 5; // 每窗口最多 5 次
limiterOptions.Window = TimeSpan.FromMinutes(1); // 窗口大小 1 分钟
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 0; // 不排队,直接拒绝
});
// ============================================
// 策略 2:滑动窗口 — 公开 API 平滑限流
// ============================================
options.AddSlidingWindowLimiter("api-sliding", limiterOptions =>
{
limiterOptions.PermitLimit = 100; // 每窗口最多 100 次
limiterOptions.Window = TimeSpan.FromMinutes(1); // 窗口大小 1 分钟
limiterOptions.SegmentsPerWindow = 6; // 分 6 段(每段 10 秒)
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 10; // 最多排队 10 个
});
// ============================================
// 策略 3:令牌桶 — 订单创建接口
// ============================================
options.AddTokenBucketLimiter("token-bucket", limiterOptions =>
{
limiterOptions.TokenLimit = 20; // 桶容量 20
limiterOptions.TokensPerPeriod = 5; // 每秒补充 5 个
limiterOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(1);
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 5; // 最多排队 5 个
limiterOptions.AutoReplenishment = true; // 自动补充令牌
});
// ============================================
// 策略 4:并发限制 — 文件上传接口
// ============================================
options.AddConcurrencyLimiter("upload-concurrency", limiterOptions =>
{
limiterOptions.PermitLimit = 3; // 最多同时 3 个上传
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 20; // 最多排队 20 个
});
// ============================================
// 策略 5:短信发送 — 更严格
// ============================================
options.AddFixedWindowLimiter("sms-fixed", limiterOptions =>
{
limiterOptions.PermitLimit = 1; // 每分钟 1 条
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
});
});
builder.Services.AddControllers();
var app = builder.Build();
app.UseRateLimiter();
app.MapControllers();
app.Run();控制器使用
[ApiController]
[Route("api/demo")]
public class RateLimitDemoController : ControllerBase
{
/// <summary>
/// 登录 — 固定窗口限流(每分钟 5 次,防暴力尝试)
/// </summary>
[HttpPost("login")]
[EnableRateLimiting("login-fixed")]
public IActionResult Login(LoginRequest request)
{
return Ok(new { message = "登录请求已受固定窗口限流保护" });
}
/// <summary>
/// 商品列表 — 滑动窗口限流(每分钟 100 次,平滑限制)
/// </summary>
[HttpGet("products")]
[EnableRateLimiting("api-sliding")]
public IActionResult Products()
{
return Ok(new { message = "商品接口使用滑动窗口限流" });
}
/// <summary>
/// 创建订单 — 令牌桶限流(允许小突发,平均 5/s)
/// </summary>
[HttpPost("order")]
[EnableRateLimiting("token-bucket")]
public IActionResult CreateOrder()
{
return Ok(new { message = "订单接口使用令牌桶限流" });
}
/// <summary>
/// 文件上传 — 并发限制(最多同时 3 个)
/// </summary>
[HttpPost("upload")]
[EnableRateLimiting("upload-concurrency")]
public IActionResult Upload()
{
return Ok(new { message = "上传接口使用并发限制" });
}
/// <summary>
/// 发送短信 — 严格固定窗口(每分钟 1 条)
/// </summary>
[HttpPost("sms")]
[EnableRateLimiting("sms-fixed")]
public IActionResult SendSms()
{
return Ok(new { message = "短信已发送" });
}
}分区限流:按用户、IP、租户做隔离
// ============================================
// 全局分区限流 — 按 IP 或用户 ID
// ============================================
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
// 分区键:已认证用用户名,未认证用 IP
var partitionKey = httpContext.User.Identity?.IsAuthenticated == true
? $"user:{httpContext.User.Identity!.Name}"
: $"ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"}";
return RateLimitPartition.GetTokenBucketLimiter(partitionKey, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = 30, // 桶容量 30
TokensPerPeriod = 10, // 每秒补充 10
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = 0, // 不排队
AutoReplenishment = true
});
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// ============================================
// 按租户限流 — 多租户 SaaS 场景
// ============================================
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("tenant-policy", context =>
{
var tenantId = context.Request.Headers["X-Tenant-Id"].ToString();
tenantId = string.IsNullOrWhiteSpace(tenantId) ? "default" : tenantId;
// 根据租户套餐返回不同的限流配置
return tenantId switch
{
"premium" => RateLimitPartition.GetSlidingWindowLimiter(
$"tenant:{tenantId}", _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 1000, // 高级套餐:1000 次/分钟
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueLimit = 50,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}),
"basic" => RateLimitPartition.GetSlidingWindowLimiter(
$"tenant:{tenantId}", _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 100, // 基础套餐:100 次/分钟
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueLimit = 10,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}),
_ => RateLimitPartition.GetSlidingWindowLimiter(
$"tenant:{tenantId}", _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 30, // 默认:30 次/分钟
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueLimit = 5,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
})
};
});
});
app.MapGet("/api/tenant/reports", () => Results.Ok())
.RequireRateLimiting("tenant-policy");
// ============================================
// 按接口 + IP 联合限流
// ============================================
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("endpoint-ip-policy", context =>
{
var path = context.Request.Path.Value ?? "/";
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var partitionKey = $"{path}:{ip}";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 50, // 每个接口 + IP:50 次/分钟
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
});
});
});自定义拒绝响应与监控日志
builder.Services.AddRateLimiter(options =>
{
// 自定义被限流时的响应
options.OnRejected = async (context, cancellationToken) =>
{
// 获取重试时间
var retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var value)
? value
: TimeSpan.FromSeconds(30);
var httpContext = context.HttpContext;
// 设置响应头
httpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
httpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
httpContext.Response.Headers["X-RateLimit-Reset"] =
DateTimeOffset.UtcNow.Add(retryAfter).ToUnixTimeSeconds().ToString();
// 记录结构化日志
var logger = httpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("RateLimiter");
var userId = httpContext.User.Identity?.Name;
var ip = httpContext.Connection.RemoteIpAddress?.ToString();
var path = httpContext.Request.Path;
logger.LogWarning(
"限流拒绝: Path={Path}, User={User}, IP={IP}, " +
"Policy={Policy}, RetryAfter={RetryAfter}s, TraceId={TraceId}",
path, userId, ip,
context.PolicyName,
retryAfter.TotalSeconds,
httpContext.TraceIdentifier);
// 返回结构化 JSON 响应
await httpContext.Response.WriteAsJsonAsync(new
{
type = "https://tools.ietf.org/html/rfc7231#section-6.5.29",
title = "Too Many Requests",
status = 429,
detail = "请求过于频繁,请稍后重试",
instance = path,
retryAfterSeconds = (int)retryAfter.TotalSeconds,
traceId = httpContext.TraceIdentifier
}, cancellationToken);
};
});限流指标监控
/// <summary>
/// 限流指标收集器
/// </summary>
public class RateLimitMetrics
{
private static long _totalRejected;
private static long _totalAcquired;
private static long _totalQueued;
private static readonly ConcurrentDictionary<string, long> _rejectedByPolicy = new();
public static void RecordRejected(string policyName)
{
Interlocked.Increment(ref _totalRejected);
_rejectedByPolicy.AddOrUpdate(policyName, 1, (_, count) => count + 1);
}
public static void RecordAcquired()
{
Interlocked.Increment(ref _totalAcquired);
}
public static void RecordQueued()
{
Interlocked.Increment(ref _totalQueued);
}
public static object GetMetrics() => new
{
TotalRejected = Interlocked.Read(ref _totalRejected),
TotalAcquired = Interlocked.Read(ref _totalAcquired),
TotalQueued = Interlocked.Read(ref _totalQueued),
RejectedByPolicy = _rejectedByPolicy.ToDictionary(kv => kv.Key, kv => kv.Value),
RejectionRate = Interlocked.Read(ref _totalRejected) /
(double)(Interlocked.Read(ref _totalAcquired) +
Interlocked.Read(ref _totalRejected) + 1) * 100
};
}
// 暴露指标端点
app.MapGet("/metrics/rate-limit", () => RateLimitMetrics.GetMetrics());自定义限流策略
/// <summary>
/// 自定义限流策略 — 基于用户角色的动态限流
/// </summary>
public class RoleBasedRateLimiterPolicy : IRateLimiterPolicy<string>
{
public RateLimitPartition<string> GetPartition(HttpContext httpContext)
{
var role = httpContext.User.FindFirst(ClaimTypes.Role)?.Value ?? "anonymous";
var userId = httpContext.User.Identity?.Name ?? "anonymous";
var partitionKey = $"{role}:{userId}";
return role switch
{
"Admin" => RateLimitPartition.GetNoLimiter(partitionKey),
"Premium" => RateLimitPartition.GetTokenBucketLimiter(
partitionKey, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = 100,
TokensPerPeriod = 50,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = 10
}),
"Basic" => RateLimitPartition.GetTokenBucketLimiter(
partitionKey, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = 20,
TokensPerPeriod = 5,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = 5
}),
_ => RateLimitPartition.GetFixedWindowLimiter(
partitionKey, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
})
};
}
}Redis 分布式限流
/// <summary>
/// 基于 Redis 的分布式滑动窗口限流
/// </summary>
public class RedisSlidingWindowRateLimiter
{
private readonly IDatabase _redis;
private readonly ILogger<RedisSlidingWindowRateLimiter> _logger;
public RedisSlidingWindowRateLimiter(
IConnectionMultiplexer redis,
ILogger<RedisSlidingWindowRateLimiter> logger)
{
_redis = redis.GetDatabase();
_logger = logger;
}
/// <summary>
/// 尝试获取许可
/// </summary>
public async Task<(bool Allowed, long Remaining, long ResetTime)> TryAcquireAsync(
string key, int maxRequests, TimeSpan window)
{
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var windowStart = now - window.TotalMilliseconds;
var redisKey = $"ratelimit:{key}";
// Lua 脚本:原子操作
var script = @"
local key = KEYS[1]
local now = tonumber(ARGV[1])
local windowStart = tonumber(ARGV[2])
local maxRequests = tonumber(ARGV[3])
-- 移除窗口外的记录
redis.call('ZREMRANGEBYSCORE', key, '-inf', windowStart)
-- 获取当前窗口内的请求数
local current = redis.call('ZCARD', key)
if current < maxRequests then
redis.call('ZADD', key, now, now .. ':' .. math.random(1000000))
redis.call('EXPIRE', key, math.ceil(tonumber(ARGV[4])))
return {1, maxRequests - current - 1}
else
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local resetTime = 0
if #oldest > 0 then
resetTime = tonumber(oldest[2]) + tonumber(ARGV[4]) * 1000 - now
end
return {0, 0, math.ceil(resetTime / 1000)}
end
";
var result = (RedisResult[])await _redis.ScriptEvaluateAsync(
script,
new RedisKey[] { redisKey },
new RedisValue[] { now, windowStart, maxRequests, window.TotalSeconds });
var allowed = (long)result[0] == 1;
var remaining = (long)result[1];
var resetTime = result.Length > 2 ? (long)result[2] : 0;
if (!allowed)
{
_logger.LogWarning(
"分布式限流拒绝: Key={Key}, 当前请求数={Current}, 最大={Max}",
key, maxRequests - remaining, maxRequests);
}
return (allowed, remaining, resetTime);
}
}
// 使用中间件
app.Use(async (context, next) =>
{
var limiter = context.RequestServices.GetRequiredService<RedisSlidingWindowRateLimiter>();
var key = $"api:{context.Request.Path}:{context.Connection.RemoteIpAddress}";
var (allowed, remaining, resetTime) = await limiter.TryAcquireAsync(
key, maxRequests: 100, window: TimeSpan.FromMinutes(1));
context.Response.Headers["X-RateLimit-Remaining"] = remaining.ToString();
context.Response.Headers["X-RateLimit-Reset"] = resetTime.ToString();
if (!allowed)
{
context.Response.StatusCode = 429;
context.Response.Headers.RetryAfter = resetTime.ToString();
await context.Response.WriteAsJsonAsync(new { error = "Too many requests" });
return;
}
await next();
});算法选择指南
决策树:
1. 限制的是什么?
|
+-- "同时处理的请求数" -> 并发限制(Concurrency Limiter)
| 适用:上传、导出、长任务
|
+-- "单位时间的请求数" -> 继续
|
+-- 是否允许突发?
| |
| +-- 是 -> 令牌桶(Token Bucket)
| | 适用:订单创建、支付、通用 API
| |
| +-- 否 -> 继续
| |
| +-- 精度要求高? -> 滑动窗口(Sliding Window)
| | 适用:公开 API、搜索接口
| |
| +-- 精度要求低 -> 固定窗口(Fixed Window)
| 适用:登录、短信、验证码| 算法 | 突发支持 | 精度 | 内存 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|---|
| 固定窗口 | 无 | 低 | 低 | 低 | 登录、短信 |
| 滑动窗口 | 弱 | 中 | 中 | 中 | 公开 API |
| 令牌桶 | 强 | 中 | 低 | 中 | 订单、支付 |
| 并发限制 | N/A | 高 | 低 | 低 | 上传、导出 |
优点
缺点
总结
限流算法没有绝对最优,只有是否适合当前资源模型。登录类接口常用固定窗口防暴力尝试,公开 API 常用滑动窗口或令牌桶平滑限制,高成本接口常结合并发限制保护下游资源;真正落地时,还要把分区键、拒绝响应、监控指标和分布式场景一起考虑进去。429 不是目的,保护系统才是目的。如果大量请求被限流,同时下游仍在超时,说明限流位置、阈值或粒度可能不对。
关键知识点
- 固定窗口简单但边界突刺明显,适合低频粗粒度控制。
- 令牌桶适合允许小突发的业务接口,是 API 场景最常见选择。
- 并发限制更像资源闸门,适合上传、导出、复杂报表等重任务。
- 分区键设计直接决定公平性和误伤率。
项目落地视角
- 登录接口按 IP + 用户名联合限流,降低撞库风险。
- 订单创建接口用令牌桶,避免大促时瞬间冲爆数据库。
- 文件上传和导出接口使用并发限制,保护磁盘和线程资源。
- 多租户 SaaS 系统按租户单独限流,避免大客户挤占公共资源。
常见误区
- 只配全局限流,不区分接口价值和资源成本。
- 限流后没有返回 Retry-After 和 traceId,客户端难以处理。
- 把健康检查、内部回调等关键通道也一起限流。
- 以为限流能代替缓存、队列、异步化和数据库优化。
进阶路线
- 研究 Redis / Lua 实现分布式限流。
- 在网关层与应用层分别配置粗粒度和细粒度限流。
- 结合用户等级、套餐、租户 SLA 设计动态限流策略。
- 把限流与熔断、隔离、重试联合设计为完整的弹性治理体系。
适用场景
- 登录、短信、验证码等安全敏感入口。
- 商品、搜索、订单等高频公共 API。
- 文件上传、报表导出、批量操作等高成本接口。
- 多租户或开放平台需要做公平资源分配的场景。
落地建议
- 先区分"防攻击型限流"和"资源保护型限流"两类目标。
- 每种策略都记录命中次数、被拒次数、队列堆积情况。
- 对关键接口先小范围上线,再根据真实流量逐步调参。
- 分布式多实例场景不要误以为单机内置限流已经足够。
排错清单
- 检查是否已启用
app.UseRateLimiter()。 - 检查策略名是否与
RequireRateLimiting()/EnableRateLimiting()一致。 - 检查分区键是否稳定,是否把大量用户错误聚合到同一桶里。
- 检查 429 数量、下游超时、连接池耗尽是否同时出现,判断限流是否真正生效。
复盘问题
- 当前接口更需要限制"单位时间请求数"还是"同时处理中请求数"?
- 分区键应该按 IP、用户、租户还是接口组合来设计?
- 429 出现时,客户端与前端是否有明确重试/提示逻辑?
- 如果业务扩展到多实例或多地域,现有限流策略是否还能成立?
