先说结论:如果你还没认真考虑过给 Web API 加限流,这个项目离“被打爆”可能就差一次流量高峰,或者一个失控的调用方。.NET 7.0 在这件事上做了一个很关键的转变——把限流从“自己造轮子”变成了框架内置能力。System.Threading.RateLimiting 进入 ASP.NET Core 之后,中间件、限流算法、分区策略、拒绝响应处理都开箱即用,你不再需要维护一堆信号量、计数器或者 Redis 脚本。这篇文章就围绕一个实际的 .NET 7.0 Web API 项目,从算法选型、配置接入、响应定制到上线后踩过的坑,完整过一遍限流的落地思路。
1. 限流这件事,为什么每个 Web API 都绕不开
1.1 限流到底在解决什么问题
限流不是安全系统的专利,而是所有对外提供 HTTP 接口的服务都必须考虑的基础能力。往大了说有 DDoS 防护,往小了说就是一个用户手滑写了个死循环去请求,或者前端有个 bug 导致重复请求轰炸,都能把你的一台小服务器打满。我见过最典型的场景:一个对外提供查询接口的服务,平时 QPS 也就几十,结果某个合作方系统出了故障开始疯狂重试,一分钟内把一个共享数据库连接池打穿,连带其他正常用户全部超时。
限流和熔断、降级经常被混在一起说,但职责不一样。熔断关心的是下游故障时本服务如何止损,降级关心的是资源不够时怎么保核心功能,而限流关心的是“在单位时间内允许多少请求通过”,不管下游是否健康。限流解决的是流量入口的问题,你可以在网关层做,也可以在应用层做,.NET 7.0 内置方案做的就是应用层这件事。
这里有个容易被忽略的点:限流不等于拒绝所有超出的请求。配合队列机制,超出的请求可以先排队而不是立刻返回失败,这对很多削峰填谷的场景非常有用。比如秒杀,瞬时一万个请求打进来,你不可能让一万个并发同时去查库存,让它们排队进入,每秒处理 500 个,整个服务就不会被冲垮。.NET 7.0 的限流器里有 QueueLimit 这个配置,就是干这个用的。
1.2 .NET 7 之前,做限流为什么这么费劲
在 .NET 7 之前,ASP.NET Core 里没有官方的一等公民限流中间件。社区方案大概分成几类:一类是自己写计数拦截器,用内存字典加时间戳去数请求,简单粗暴但分布式下完全不靠谱,服务重启状态就没了;一类是引入第三方库,功能全但版本兼容、学习成本、License 都得评估;还有一类是在网关(比如 Nginx 层)做 limit_req,但这时限流策略就和业务状态脱钩了,比如你想按照登录用户 ID 而不是 IP 来限,网关层做起来就很别扭。
自己做计数听起来简单,但深入进去有很多细节坑。比如固定窗口计数器的临界点问题,每分钟允许 100 次,结果用户在 59 分 59 秒发了 100 次,又在 00 分 00 秒发了 100 次,那一秒内实际放过去了 200 次。再比如多实例部署时,每台机器的内存计数各自独立,负载均衡后总流量可能翻倍。这些问题不是不能解决,但要自己处理得比较完整,工作量就上来了,而且很容易在审查代码时被发现有漏洞。
.NET 7.0 把 System.Threading.RateLimiting 标准库带进来之后,这些基础问题框架都替你考虑过了。内置实现包含了固定窗口、滑动窗口、令牌桶、并发限流四种算法,并且每种算法都支持分区,也就是说可以针对不同的调用方使用不同的限流配置。对大多数业务团队来说,这意味着不用再维护自己的限流代码,把精力放在策略本身。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. .NET 7.0 内置限流方案全景
2.1 四种内置算法,先搞清楚选哪个
先把四种算法拉出来对比一下,这决定了你后面的所有配置。
| 算法 | 核心思想 | 适合场景 | 最关键参数 |
|---|---|---|---|
| 固定窗口 | 在一个固定时间段内允许 N 个请求 | 接口调用次数控制、简单粗暴 | PermitLimit、Window |
| 滑动窗口 | 把窗口切段,按段过期计数,平滑边界 | 需要比固定窗口更平滑,又不想上令牌桶 | PermitLimit、Window、SegmentsPerWindow |
| 令牌桶 | 以恒定速率往桶里放令牌,请求消耗令牌 | 允许突发流量、需要均速控制的 API | TokenLimit、TokensPerPeriod、ReplenishmentPeriod |
| 并发限流 | 同时最多允许 N 个请求在执行 | 保护数据库连接池、慢调用接口 | PermitLimit、QueueLimit |
选型上我给个经验:对外接口如果只是想防滥用,固定窗口足够,简单直白容易跟业务方解释;如果是承接高频内部调用,希望控制整体速率的同时允许突发,令牌桶是首选;如果接口本身耗时不稳定,你更担心的是“同时有多少请求在跑”而不是“每秒进来多少”,那就用并发限流。滑动窗口更像是固定窗口的平滑升级版,临界点问题比固定窗口好,但实现和理解成本略高。
这几种算法可以打个比方帮助理解。固定窗口像是地铁闸机每分钟重新计数,到点清零;滑动窗口是把这一分钟分成几段,每过一段就淘汰一段的计数,比固定窗口更平滑;令牌桶就是一个水龙头往水箱里匀速注水,请求来了从水箱里舀水,水满时能短时间多接几瓢,但长期看注水速度决定了总量。理解了这几个模型,后面配参数就不容易出大错。
要注意的是,这四种算法不是互斥的,可以叠加。比如全局限流用并发限流保护线程池,单个用户维度再用固定窗口限制调用频率,两层规则完全不冲突,框架会一起生效。
2.2 核心类型与中间件运作机制
用起来之前,先把几个概念搞清楚,否则后面配置很容易懵。
第一个概念是限流策略。你可以在 AddRateLimiter 里注册多个命名策略,比如注册一个叫 "fixed-10s" 的策略,然后在 Controller 或者 Action 上用 [EnableRateLimiting("fixed-10s")] 标注,这个接口就生效了。如果不打标签,默认策略不生效。也可以配置一个 GlobalLimiter,它会对所有请求生效,相当于全局的一道总闸。
第二个概念是分区。分区限流的意思是同一个策略内部,可以按不同的 key 把流量拆成多个独立的限流器。最常见的 key 就是客户端 IP,也可以按登录用户 ID、按 API Key、按请求头里的租户编号。这个设计非常实用,因为你往往不希望 100 个用户共享同一个限额,而是希望每个用户有自己的限额。
第三个概念是中间件管线位置。注册限流后,必须在管道里调用 app.UseRateLimiter(),而且位置有讲究:要在 UseRouting 之后、MapControllers/UseEndpoints 之前。原因是限流中间件需要知道当前请求对应的 Endpoint,才能读取该 Endpoint 上的 EnableRateLimiting 特性。如果你把 UseRateLimiter 放到了 UseRouting 之前,策略标签就识别不到,配置了半天会发现什么都没拦住。
3. 实战:给 Web API 接入固定窗口限流
3.1 最小接入三步走
我直接用一个最小示例说明,省略掉不必要的封装,先把跑通最重要。第一步,在 Program.cs 注册限流服务。
csharp复制using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("fixed-10s", limiterOptions =>
{
limiterOptions.PermitLimit = 10;
limiterOptions.Window = TimeSpan.FromSeconds(10);
limiterOptions.QueueLimit = 2;
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
});
var app = builder.Build();
app.UseRouting();
app.UseRateLimiter();
app.MapControllers();
app.Run();
这段配置的含义是:注册一个名为 fixed-10s 的策略,每 10 秒最多允许 10 个请求通过,如果当前窗口已经满了,允许 2 个请求进入等待队列,队列也满了就直接拒绝。RejectionStatusCode 我直接改成了 429,默认值是 503,很多人容易忽略,后面响应定制部分再说原因。
第二步,在 Controller 上启用策略。我把全局特性放在 Controller 上,这意味着这个控制器里所有接口都受限制。
csharp复制[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("fixed-10s")]
public class WeatherController : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
return Ok(new { Message = "ok", Time = DateTime.Now });
}
}
如果某个接口不希望被限流,比如健康检查接口,直接用 DisableRateLimiting 标签单独标注。
csharp复制[DisableRateLimiting]
[HttpGet("health")]
public IActionResult Health()
{
return Ok(new { Status = "Healthy" });
}
第三步,跑起来验证。启动项目后用 curl 连续请求,命令我长这样:
bash复制for i in $(seq 1 20); do
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/api/weather
done
正常情况下,前 10 个请求返回 200,后面 2 个请求由于队列里有位置会等待后返回 200,观察响应时间会发现有延迟,再往后的请求返回 429。这里有个细节你可能已经注意到了,QueueLimit 不等于队列里每个请求都会成功,如果前一批阻塞时间太长,客户端很可能已经等不及超时了。所以高并发场景下,QueueLimit 通常建议设小一点,或者干脆设 0。
3.2 按客户端 IP 分区限流与配置分离
固定窗口策略写死了限额,所有请求共享同一个 10 秒 10 次的额度,这个在真实业务里基本不够用。绝大多数情况你需要的是“每个用户/IP 独立限额”。这时候就要用分区限流器,把策略改成按 IP 拆分成独立的计数器。
csharp复制builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(ip, _ =>
new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = 20,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
});
});
});
这样配置之后,每个 IP 在一分钟内最多请求 20 次,互不干扰。分区 key 的选择值得多说两句:如果直接拿 RemoteIpAddress 当 key,有两点要注意。第一点是服务部署在负载均衡或者反向代理后面时,RemoteIpAddress 通常是你网关的内网 IP,所有用户都共享同一个 key,等于限流失效;第二点是服务直接暴露公网时,用户可能换 IP,对其限流意义不大。处理第一点,需要在管道里先启用 ForwardedHeaders 中间件,或者从 X-Forwarded-For 头里取真实客户端 IP。
配置分离也是一个很实际的需求。限流参数大概率要调,最好放进 appsettings.json 而不是代码里写死。用 IOptions 的方式做个简单的绑定。
csharp复制public class RateLimitSettings
{
public int PermitLimit { get; set; }
public double WindowSeconds { get; set; }
public int QueueLimit { get; set; }
}
csharp复制builder.Services.Configure<RateLimitSettings>(builder.Configuration.GetSection("RateLimit"));
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("config-driven", httpContext =>
{
var settings = httpContext.RequestServices
.GetRequiredService<IOptions<RateLimitSettings>>().Value;
return RateLimitPartition.GetFixedWindowLimiter("config", _ =>
new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = settings.PermitLimit,
Window = TimeSpan.FromSeconds(settings.WindowSeconds),
QueueLimit = settings.QueueLimit
});
});
});
appsettings.json 里对应加一节:
json复制{
"RateLimit": {
"PermitLimit": 100,
"WindowSeconds": 60,
"QueueLimit": 5
}
}
这样上线调参数不用发版,运维改配置重启就行。这是我在实际项目里非常推荐的做法。
4. 进阶:令牌桶与滑动窗口的正确打开方式
4.1 令牌桶参数配比与实际效果
令牌桶是我个人用得最多的一种算法,因为它的行为最接近真实业务需求:允许一定量的突发,但长期平均速率可控。配置起来也不复杂。
csharp复制builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("token-bucket", httpContext =>
RateLimitPartition.GetTokenBucketLimiter("token-bucket", _ =>
new TokenBucketRateLimiterOptions
{
TokenLimit = 50,
TokensPerPeriod = 10,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
AutoReplenishment = true,
QueueLimit = 5
}));
});
这组参数的含义是:桶里最多攒 50 个 token,每秒钟自动补充 10 个 token。对应到实际效果,请求刚启动的时候,最多可以立刻放行 50 个请求,之后长期来看每秒稳定放行约 10 个。如果持续请求超过每秒 10 个,桶里的存量会被耗尽,后续请求就只能等待或者被拒绝。TokenLimit 决定了你能容忍的最大突发量,TokensPerPeriod 和 ReplenishmentPeriod 一起决定了匀速速率。
配置的时候最容易犯的错是把 TokenLimit 调得特别大,以为突发量大是好事。结果一次大流量进来,突发全部放行,直接把下游数据库打满。令牌桶的精髓是“给突发留余地,但长期均速要有约束”,所以 TokenLimit 不是越大越好,而是应该根据下游能扛住的峰值来定。
这里还要提一句 AutoReplenishment。它控制的是“是否自动补充 token”。如果你设为 false,则只有在有请求进来时才会触发补充逻辑,这在某些非常规用法下有用,但绝大多数 Web API 场景都应该设置成 true,否则你会发现令牌消耗完了之后,过了一段时间还是没有恢复,请求一直被拒,排查半天发现是定时补充根本没开。
4.2 滑动窗口与队列处理方式
滑动窗口解决的是固定窗口的临界点问题。固定窗口里,算法只关心“当前这 10 秒内用了多少个”,所以窗口交界处可能瞬时涌入两倍流量。滑动窗口把窗口切成了若干个段,比如 60 秒的窗口切成 4 段,每段 15 秒,请求计数按照段进行过期清理,重叠的窗口计算的是最近 60 秒内所有段的总和,这样交界处的毛刺就被削掉了。
csharp复制options.AddSlidingWindowLimiter("sliding-60s", limiterOptions =>
{
limiterOptions.PermitLimit = 60;
limiterOptions.Window = TimeSpan.FromSeconds(60);
limiterOptions.SegmentsPerWindow = 4;
limiterOptions.QueueLimit = 2;
});
切分的段数越多,计数越平滑,但代价是内存占用增加,因为每个段都要保存独立的计数。SegmentsPerWindow 一般设 4 到 8 就足够了,没必要为了追求平滑把段数拉到几十上百。这是典型的用空间换时间场景,需要你在上线前根据 QPS 估一下内存开销。
关于队列处理,我再展开说一下。所有内置限流算法都支持 QueueLimit 和 QueueProcessingOrder 这两个参数。QueueLimit 为 0 时,超出限额的请求直接拒绝;大于 0 时,超出部分先进入队列,等前面的请求释放配额后依次放行。QueueProcessingOrder 支持 OldestFirst 和 NewestFirst,分别是先处理先来的和先处理新来的。绝大多数情况下用 OldestFirst 即可,NewestFirst 更适合那些“最新请求优先、老请求可以放弃”的场景,比如实时查询接口。
队列机制虽然能削峰,但必须警惕线程池饥饿。如果接口本身耗时较长,比如一次请求要 2 秒,而你把 QueueLimit 设成 100,那这 100 个排队请求都会占用线程池等待资源,一旦并发大起来,服务端线程池可能被打满,表现就是 CPU 没高但接口全部超时。所以队列长度一定要根据接口耗时和单机处理能力谨慎设置。我个人的习惯是,查库类接口 QueueLimit 不超过 10,纯内存计算类接口可以适当放宽。
5. 限流拒绝响应与客户端联调
5.1 把 503 改成 429,并返回友好消息
限流生效之后,被拒绝的请求默认返回的状态码是 503 Service Unavailable。严格来说这不太符合语义——503 通常表示“服务当前不可用,可能过一会儿就好了”,而限流拒绝更准确的语义是 429 Too Many Requests。429 状态码的好处是它可以带 Retry-After 响应头,告诉客户端“你被限流了,X 秒后再试”。把 RejectionStatusCode 设成 429 只是第一步,更完整的做法是自定义返回内容,因为客户端拿到的默认响应体是空的,这对排查问题很不友好。
用 OnRejected 回调可以完全控制被拒绝时的响应内容。
csharp复制builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.ContentType = "application/json; charset=utf-8";
var retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfterTimeSpan)
? retryAfterTimeSpan
: TimeSpan.FromSeconds(1);
context.HttpContext.Response.Headers.RetryAfter = retryAfter.TotalSeconds.ToString("0");
var payload = System.Text.Json.JsonSerializer.Serialize(new
{
code = 429,
message = "请求过于频繁,请稍后再试",
retryAfter = retryAfter.TotalSeconds
});
await context.HttpContext.Response.WriteAsync(payload, cancellationToken);
};
});
这里有个细节:context.Lease 是限流器分配到的“许可证”,通过 TryGetMetadata(MetadataName.RetryAfter) 可以拿到这个 limiter 建议的重试时间,不同算法这个值的计算方式不一样。如果不能拿到的场景就兜底给一个默认值。返回 JSON 结构最好和你们项目里其他错误响应的结构保持一致,前端才能统一处理。
注意,OnRejected 里不要再手动设置 StatusCode 了,因为中间件在调用 OnRejected 之前已经根据 RejectionStatusCode 设置好了响应码,你在里面改会覆盖,而且也没必要。如果你确实更习惯在回调里统一设置状态码和响应体,那也可以,但我更建议在注册中间件时直接改 RejectionStatusCode,逻辑更清晰。另外,如果响应已经开始了,再往 Body 里写内容会抛异常,所以 OnRejected 里的写响应逻辑要放在最前面,不要做耗时操作。
5.2 响应头、重试逻辑与前端配合
限流不只是后端的事,客户端也得知道“被限流后怎么办”。429 加 Retry-After 是一对约定,标准客户端会读取 Retry-After 然后再发起重试,避免被打回去之后立刻又撞上来。
前端或者说调用方接入限流时,我建议同步做三件事。第一,识别 429 状态码,不要把它当成普通错误弹“系统异常”;第二,读取 Retry-After 字段,按这个时间做退避重试,如果业务等不了这么久,就提示用户稍后操作;第三,在关键接口上做本地节流,比如按钮一分钟内只能点一次,这是最简单的限流,也能显著减少后端压力。
如果你在 OnRejected 里返回了统一的 JSON 结构,那前端拦截器里就可以统一识别。这里给一个简单的思路:所有被限流的响应统一走 code=429 的结构,前端 HTTP 拦截器里看到这个 code,直接走全局的重试提示逻辑,不用每个页面单独处理。这样一个接口调整限流阈值时,前端不用跟着发版。
我这里再补充一个容易被忽略的联调细节:Retry-After 的值是秒数,客户端拿到之后建议加一点随机抖动再重试,避免大量被限流的客户端在同一时刻再次打过来,形成第二次流量尖峰。这个抖动策略在对接外部系统时尤其重要,算是做限流联调的一个小套路。
6. 常见问题排查与性能实测心得
6.1 我踩过的几个典型坑
这套方案我用了小半年,踩过的坑整理成一张表,基本可以当速查手册用。
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 接口完全不受限,配置没生效 | UseRateLimiter 位置不对,或者 EnableRateLimiting 没打 | 把 UseRateLimiter 放到 UseRouting 后、MapControllers 前;检查特性名是否拼错 |
| 所有用户共享一个限额 | 分区 key 没有按用户/IP 拆分 | 改用 PartitionedRateLimiter,把连接 IP 或用户 ID 作为分区 key |
| 代理后面限流失效 | RemoteIpAddress 是代理内网 IP,所有请求共享同一 key | 启用 ForwardedHeaders 中间件,或从 X-Forwarded-For 读取真实 IP |
| 请求返回 503 而不是 429 | 没设置 RejectionStatusCode | 显式设置 options.RejectionStatusCode = 429 |
| 限流后客户端白屏或报错 | 返回体是空的,前端无法识别 | 在 OnRejected 里返回统一 JSON |
| 令牌桶一直拒绝,不恢复 | AutoReplenishment 被设成了 false | 设置 AutoReplenishment = true |
| 队列空洞,请求全部超时 | QueueLimit 太大,排队请求占满线程池 | 降低 QueueLimit,结合接口耗时评估 |
这里面最隐蔽的是代理导致 RemoteIpAddress 变成内网 IP 的问题。我在一个模拟项目里就遇到过:压测发现不管怎么调低限额,接口就是不够流控,查了半天发现所有请求都被网关转发成了同一个内网 IP,分区 key 永远只有一个。后来在管道最前面加了 ForwardedHeaders 中间件并用真实 IP 做 key,问题才解决。这也是为什么我建议分区 key 的选择要单独写成一个小函数统一返回值,方便后续调整取 IP 的策略。
另外一个容易忽视的问题是多个限流策略叠加后,实际生效的是最严格的那个。比如全局限制 1 分钟 100 次,接口策略又限制 10 秒 10 次,那这个接口实际的可用额度是两者都要满足,也就是 10 秒 10 次,同时 1 分钟不超过 100 次。设计策略时要有全局观念,不要单纯把每个策略独立调优,否则最后叠加出来的效果可能跟你预期完全不一样。
6.2 性能影响、监控与上线建议
性能方面,我实测过一轮。在普通的开发机上,给一个简单的 Web API 加上分区固定窗口限流,使用并发压测工具以每秒几百的 QPS 去打,限流中间件带来的额外延迟基本在个位数毫秒以内,对业务几乎无感。内存占用主要来自分区数量——每个分区都会创建独立的限流器实例,如果按 IP 分区且 IP 基数很大,需要留意内存。应对办法是给分区 key 做固化,比如只按 IP 前三位或者用户等级分组,而不是每个 IP 都开一个独立限流器。
监控方面,限流中间件没有内置可视化面板,但你可以利用 OnRejected 做日志埋点。我建议在回调里记录被拒绝的 IP、路径、限流策略名和 Retry-After,定期看这些日志,一是能发现是不是有某类客户端在异常重试,二是能用来评估当前阈值是否合理。如果被拒绝量长期为 0,说明阈值可能放得太宽,限流形同虚设;如果被拒绝量高得离谱,需要看是恶意的还是正常用户误伤,这两者的处理策略完全不同。
最后给一个上线建议:限流参数的调整一定要有降级手段。配置集中放在配置中心或者 appsettings.json,方便随时调;如果限流误伤了正常用户,第一时间把阈值调大或临时禁用比改代码快得多。还有,上线前一定要用压测工具实测,而不是只在浏览器里点几下。确保你的 PermitLimit、QueueLimit 和接口实际耗时是匹配的,否则上线后流量一大,要么打爆服务,要么把正常用户全拒了,这两个方向上限流都会变成事故源。
我个人在实际项目里的体会是,限流配置从来不是“配一次就不管了”,它是需要随着业务流量、下游容量持续调整的活。把参数配置化、把拒绝响应结构固定好、把日志埋点做好,这套方案才算真正落地。如果你也是刚接触 .NET 7.0 的限流,建议先从固定窗口加 IP 分区开始跑起来,再逐步尝试令牌桶和其他策略组合,踩几次坑之后自然就有感觉了。
