有个客户的登录端点稳定跑了两年,然后一个撞库脚本在周末发现了它,每秒几千个请求砸上去。端点没有以有趣的方式崩溃——它只是把整个数据库一起拖垮了,所有功能都慢到爬行,因为它们共享同一个连接池。
修复方案不是加服务器,是加一个限制。那个端点从任何单个 IP 每分钟接收请求都不该超过个位数,但当时什么都没拦。修复手段就是限流。在 ASP.NET Core 里,这件事不需要再装第三方库了。
ASP.NET Core 内置限流从 .NET 7 开始就内置在框架里。Microsoft.AspNetCore.RateLimiting 里的 RateLimiter 中间件提供四种算法——固定窗口、滑动窗口、令牌桶和并发——用这几行配置就能保护端点。
四种限流器,各自适合什么场景
内置中间件提供了四种算法,它们不可互换。每种对流量塑形的方式不同,选错的结果要么太松,要么无意中伤到正常用户。
固定窗口按固定时间片计数——每分钟 100 次,每个整分钟清零。简单,但有个已知弱点:调用方可以在 11:59:59 发 100 次,12:00:00 再发 100 次,一秒多钟内溜过 200 次。
滑动窗口通过把窗口切分成多个段来修复这个问题,段随时间向前滚动,窗口边界的突发会被近期的计数兜住。开销稍高,公平性更好。
令牌桶模拟一个以稳定速率补充令牌的桶。每个请求消耗一个令牌,桶空了就受限。适合需要允许突发的场景——用户可以一次花光整桶令牌,然后等它慢慢补充。
并发完全不按时间计数。它限制的是同时运行的请求数。适合昂贵的端点——报表生成、文件导出——这里的问题不是速率,而是一次跑十个。
最小可用配置
两步:注册服务,加中间件。一个固定窗口限流器,对所有端点生效:
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", limiter =>
{
limiter.PermitLimit = 100; // 100 次请求
limiter.Window = TimeSpan.FromMinutes(1); // 每分钟
limiter.QueueProcessingOrder =
QueueProcessingOrder.OldestFirst;
limiter.QueueLimit = 0; // 立即拒绝,不排队
});
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/reports", () => Results.Ok("数据就绪"))
.RequireRateLimiting("fixed");
app.Run();
RequireRateLimiting("fixed") 把命名策略绑定到端点。中间件存在不等于限流生效——你得显式为端点选择策略。
一个容易踩的坑:UseRateLimiter 要放在路由之后,但又得早到能保护后面真正干活的部分。在 Minimal API 里路由是隐式的,所以放在 var app = builder.Build() 之后就行。如果你有 UseAuthentication / UseAuthorization,要主动决定在认证前还是认证后限流——认证前限流可以保护认证系统本身免于被攻击,这对登录端点通常是你想要的。
换成滑动窗口只需要一行改动:
builder.Services.AddRateLimiter(options =>
{
options.AddSlidingWindowLimiter("sliding", limiter =>
{
limiter.PermitLimit = 100;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.SegmentsPerWindow = 6; // 6 个滚动段,每段 10 秒
limiter.QueueLimit = 0;
});
});
真正重要的部分:分区
单个全局限流器几乎不是你想要的。“所有用户加起来每分钟 100 次”意味着一个吵闹的客户端可以吃光整个预算,饿死所有人。你需要的是按用户、或按 IP、或按 API Key 来限流。这就是分区做的事——给每个调用方自己的配额桶。
下面是一个全局限流器,按已认证用户分区,匿名调用方回退到 IP:
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter =
PartitionedRateLimiter.Create<HttpContext, string>(
context =>
{
var partitionKey = context.User.Identity?.Name
?? context.Connection.RemoteIpAddress?.ToString()
?? "anonymous";
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey, _ =>
new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
});
});
options.RejectionStatusCode =
StatusCodes.Status429TooManyRequests;
});
GlobalLimiter 在每个请求上执行,不需要 RequireRateLimiting。分区键是整件事的关键:选一个能标识你要限流的调用方的东西。已登录用户用用户名,匿名用 IP,公开 API 用 API Key。搞错了——比如选了一个每个请求都相同的值——你就又回到单一全局桶了。
注意 RejectionStatusCode。默认拒绝状态码是 503,这具有误导性——被限流不是服务器错误,是客户端请求频率太高。设成 429 Too Many Requests,客户端和监控系统才能正确解读。
分区键里还有两件事要搞对。第一,RemoteIpAddress 在反向代理或 CDN 后面拿到的是代理的 IP 而不是客户端的——于是所有匿名调用方落进同一个分区,你的按 IP 限流悄无声息地变成了全局限流。加上 UseForwardedHeaders 并信任你的代理,让 RemoteIpAddress 反映 X-Forwarded-For 里的真实客户端。第二,User.Identity?.Name 只有在认证执行后才有值,所以按用户分区需要 UseRateLimiter 放在 UseAuthentication 之后——和前面登录端点限流的顺序正相反。按端点选顺序,或者跑两个策略。
给客户端一个正经的 429
一个裸 429 空 body 是不礼貌的。好的 429 告诉调用方要等多久。令牌桶和窗口限流器通过元数据暴露重试延迟,用 OnRejected 来捕获:
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode =
StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, cancellationToken) =>
{
if (context.Lease.TryGetMetadata(
MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString();
}
context.HttpContext.Response.ContentType =
"application/json";
await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "请求过于频繁,请稍后重试。"
}, cancellationToken);
};
options.AddTokenBucketLimiter("api", limiter =>
{
limiter.TokenLimit = 50; // 桶容量 50
limiter.TokensPerPeriod = 10; // 每 10 秒...
limiter.ReplenishmentPeriod =
TimeSpan.FromSeconds(10); // ...补充 10 个
limiter.QueueLimit = 0;
limiter.AutoReplenishment = true;
});
});
Retry-After 头决定了客户端是体面地退避还是持续撞墙重试。
排队还是直接拒绝
每个限流器都有 QueueLimit。设为 0,超限请求立即拒绝——是公开 HTTP 端点的正确默认值,因为排队等待的调用方就是占用连接的调用方。设为大于零,超限请求会等一个许可证,按 QueueProcessingOrder 指定的顺序。
排队对并发限流器有意义——你宁愿让第十一个请求等一下,也不想直接拒绝它:
options.AddConcurrencyLimiter("expensive-export", limiter =>
{
limiter.PermitLimit = 5; // 最多 5 个导出同时跑
limiter.QueueLimit = 20; // 最多 20 个排队等
limiter.QueueProcessingOrder =
QueueProcessingOrder.OldestFirst;
});
但对于登录端点,你需要 QueueLimit = 0。给撞库攻击排队等于让几千个连接开着等放行——你把限流变成了缓慢的连接泄露。
在端点上应用策略
两种方式:流式 API 或特性标注。Minimal API 用流式:
var api = app.MapGroup("/api").RequireRateLimiting("api");
api.MapGet("/products", GetProducts); // 继承 "api"
api.MapPost("/orders", CreateOrder); // 继承 "api"
app.MapPost("/auth/login", Login)
.RequireRateLimiting("login-strict"); // 自己的更严策略
Controller 上用特性,[DisableRateLimiting] 可以给受限制的 Controller 里某个 Action 打孔:
[EnableRateLimiting("api")]
public class ProductsController : ControllerBase
{
[HttpGet]
public IActionResult List() => Ok(_service.All());
[HttpGet("health")]
[DisableRateLimiting] // 健康检查不应该被限流
public IActionResult Health() => Ok("healthy");
}
诚恳的限制:这是单实例的
内置限流器把计数器存在内存里,单进程内。在负载均衡器后面跑三个 API 实例,每个实例独立执行限流——“每分钟 100 次”的上限在全集群变成实际约 300 次,而且取决于均衡器怎么分发流量还会不均匀。
对于单实例,或者”大致这么多就行”的粗粒度保护,这完全够用,也是我大多数时候用的。但如果你需要硬性的、集群范围的限制——比如付费 API 层级,限制数字写在合同里——内存限流器做不到。你需要 Redis 之类的分布式计数器来支持自定义 RateLimiter。在上线之前搞清楚你处于哪种情况,别等超额账单来的时候才发现。