做后端开发的,基本都躲不开一个问题:API 被刷爆。平时好好的接口,突然来一波流量,数据库连接池被打穿,下游服务被拖垮,线上告警一条接一条。限流(Rate Limiting)的核心诉求很简单,在请求进入业务逻辑之前,把流量控制在一个合理范围内,让系统在能力边界内稳定提供服务。以前做 .NET Web API 限流,要么引第三方库,要么自己写内存计数器,要么套一层网关,方案各有各的麻烦。.NET 7 把限流做进了框架基础能力,基于 System.Threading.RateLimiting 从底层提供了固定窗口、滑动窗口、令牌桶、并发限制四种算法,并且有配套的 ASP.NET Core 中间件,开箱即可用。
这篇文章就围绕“.NET 7.0 在 .NET Core Web API 中实现限流”这个场景,把从零接入限流的完整思路、算法选型、实操配置、常见坑位都讲透。适合正在做 .NET 服务端开发、想快速给接口加保护、或者想从第三方限流方案迁移到框架内置方案的团队和个人。文章里的代码都基于 .NET 7.0 与 ASP.NET Core Web API 模板,你照着敲就能跑起来。
1. 为什么限流这道题,要从框架层面解
1.1 限流解决的真实问题
先看一个典型的线上事故现场。一个社区类应用,晚上八点有运营活动上线,用户集中去刷某个聚合接口,每个请求背后要查缓存、调三个下游服务,QPS 从平时的几十涨到几千。数据库连接池先扛不住,紧接着下游短信服务也被打挂,然后整个集群的请求开始排队,所有业务接口跟着变慢,最后运维只能重启实例来止血。
这种场景里,限流不是说让用户不要用,而是让系统在能力范围内尽可能多地服务用户,超出的部分快速拒绝,让客户端知道“现在忙不过来,等会儿再来”。限流解决的是稳定性问题:保护数据库、保护下游、保护线程池、保护整个调用链不因流量洪峰而雪崩。
从另一个角度看,限流也承担着公平性和成本控制的作用。比如开放平台对外提供的 API,不同的调用方有不同的配额,免费用户一天只能调 100 次,付费用户一分钟可以调 120 次。这就是典型的按调用方维度限流。没有这套机制,一个滥用方就能占满所有资源,影响其他正常用户。
限流和熔断、降级经常被放在一起讨论,但它们的侧重点不一样。熔断是当下游已经出问题时,快速失败防止连锁反应;降级是牺牲非核心功能保核心功能;而限流是在流量入口就做“闸门控制”,从源头避免系统进入过载状态。三者配合才是完整的韧性保障体系,限流通常是第一道闸门。
1.2 以前做限流的几类土办法
在 .NET 7 内置限流之前,Web API 项目里常见的做法大致有三类。
第一类是引第三方限流库。社区里有一些比较成熟的库,功能也全,支持各种算法,但引入第三方库意味着要维护依赖、跟进版本兼容,遇到和框架中间件顺序冲突时排查成本也不低。
第二类是自己写中间件,用内存字典存每个客户端 IP 的请求时间戳,超过阈值就拒绝。这种做法实现起来不难,代码也就几十行,但有几个很难绕开的问题:内存计数器在多实例部署下各自为政,总量不可控;时间窗口边界容易出双倍突发;多线程并发更新需要做锁或原子操作,处理不好就丢计数;做集群限流还得自己接集中式存储。
第三类是交给网关或反向代理做限流,比如在 Nginx 层按 IP 限流。网关方案对南北向流量有效,但服务内部的调用、以及带不同 API Key 的精细化配额,反而不太好在网关一层统一管理。服务端自己有一层限流能力,永远是更直接、更可控的兜底。
.NET 7 的框架级方案把“算法实现 + 中间件 + 配额策略 + 响应处理”整套能力组合在一起,而且底层的 RateLimiter 被设计成独立于 ASP.NET Core 的通用组件,想用在别的场景也能直接拿 System.Threading.RateLimiting 包里的类。这就是为什么我建议优先考虑框架内置能力,而不是马上引第三方库。
1.3 .NET 7内置实现的选型逻辑
.NET 7 引入的限流能力,核心是 System.Threading.RateLimiting 这个类库。它本身不依赖 ASP.NET Core,定义了 RateLimiter 抽象基类,往下有四个现成实现。ASP.NET Core 侧则提供了 RateLimiterOptions、限流中间件、以及针对终结点的策略注入机制。
设计上有几个值得注意的点。算法和策略分离:RateLimiter 只负责“能不能放行”和“放行了记一笔”,而谁是同一个限流维度(同一个 IP、同一个用户、同一个 API Key)由 RateLimitPartition 决定。配额和算法配置解耦:一个策略可以动态决定不同分区用不同的算法和参数。中间件和终结点元数据结合:在 Controller 上用特性标注策略,或者在 Minimal API 上链式调用 WithRateLimiting,就能实现接口级别的精确控制。
这套设计的好处是,你不需要为了“全局限流、接口级限流、按用户限流”分别写三套代码,它们都是同一个策略系统下的不同用法。选型逻辑上,只要用的是 .NET 7 及以后的版本,Web API 项目默认就能拿到这套能力,不用额外下载中间件包,普通场景下完全没有必要再引入额外重量级依赖。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四种内置限流算法拆解与选型对比
2.1 固定窗口
固定窗口是最直观的算法。设定一个时间窗口,比如一分钟,记录这个窗口内已经放行的请求数。窗口开始时的计数清零,窗口内每来一个请求就加一,超过预设的 PermitLimit 就拒绝,直到窗口结束才重置。
它的优点是逻辑简单、内存占用极低,只需要一个计数变量和一个窗口过期时间。适合对流量精确度要求不高的场景,比如按小时、按天统计调用量的配额控制。
缺点也很明显,就是窗口边界问题。比如限流规则是每分钟 100 次,第一个窗口的最后 1 秒涌进 100 个请求,第二个窗口的前 1 秒又涌进 100 个,实际在 2 秒内放行了 200 个请求。虽然平均下来每分钟没超,但瞬时压力翻倍,对于后端来说这就是洪峰。
在 .NET 7 里配置固定窗口:
csharp复制builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("fixed", windowOptions =>
{
windowOptions.PermitLimit = 100;
windowOptions.Window = TimeSpan.FromMinutes(1);
windowOptions.QueueLimit = 0;
});
});
PermitLimit 是窗口内允许的请求数,Window 是窗口时长,QueueLimit 是窗口打满后允许排队等候的请求数量。默认情况下 QueueLimit 为 0,意味着超出就立刻拒绝,而不是排队等待。这个默认行为很关键,后面我会专门讲排队队列带来的坑。如果你直接把这个策略绑定到接口上,那这个接口在一分钟内最多只会放行 100 个请求,超过就返回 429。
2.2 滑动窗口
滑动窗口是对固定窗口边界问题的修正。算法把时间窗口切成若干个等长的段,比如 60 秒窗口切成 6 段,每段 10 秒。判断是否能放行时,不是看本段单独的计数,而是把“当前段计数”和“上一段的部分计数”按时间权重汇总,跟 PermitLimit 比较。
实际效果是,限流速率在窗口内是逐步恢复的,不会出现固定窗口那样在边界处瞬间放行一整批请求。对于绝大部分 Web API 来说,滑动窗口是默认推荐,因为它在平滑度和实现成本之间取得了很好的平衡。
csharp复制options.AddSlidingWindowLimiter("sliding", windowOptions =>
{
windowOptions.PermitLimit = 100;
windowOptions.Window = TimeSpan.FromMinutes(1);
windowOptions.SegmentsPerWindow = 6;
windowOptions.QueueLimit = 0;
});
SegmentsPerWindow 是切分的段数。段数越多,滑动效果越平滑,但内存和计算开销也会增加,一般 4 到 10 段足够。需要注意,滑动窗口并不能把速率控制成严格匀速,它只是消除了窗口边界的明显突变。如果你的场景允许一定突发、又希望平滑恢复,选它准没错。
2.3 令牌桶
令牌桶是另一种常见的平滑限流算法。桶里最多存放 TokenLimit 个令牌,系统按固定速率向桶里补充令牌,比如每秒补充 5 个。每个请求进来必须先拿走一个令牌,桶空了就拒绝或排队。
令牌桶最有特点的价值是允许突发。比如令牌上限 20,系统空闲了一分钟,桶里攒满了 20 个令牌,突然来 20 个并发请求可以全部放行,然后后续请求就只能按每秒 5 个的补充速率进入。这个特性特别适合“平时流量低、峰值来得快”的业务,比如秒杀入口前的准备阶段,或者批量数据导出的定时任务。
csharp复制options.AddTokenBucketLimiter("token", bucketOptions =>
{
bucketOptions.TokenLimit = 20;
bucketOptions.TokensPerPeriod = 5;
bucketOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(1);
bucketOptions.QueueLimit = 0;
});
TokenLimit 是桶容量,TokensPerPeriod 是每个周期补充的令牌数,ReplenishmentPeriod 是补充周期。想实现“平均每秒 5 个、突发最多 20 个”,上面这三行就是标准答案。AutoReplenishment 默认是 true,框架会自动补充令牌;如果设成 false,就需要自己手动触发 TryReplenish,一般只有特殊场景才需要这么干。
这里容易搞混的一点是:滑动窗口和令牌桶都追求平滑,到底选哪个?滑动窗口控制的是“单位窗口内最多多少个”,令牌桶控制的是“长期平均速率 + 短期突发容量”。令牌桶更贴近流量的真实形态,很多网关产品的底层实现就是它。
2.4 并发限制
并发限制和前三种算法有本质区别,它不关心时间窗口,而是限制同时正在处理的请求数量。PermitLimit 设为 10,就意味着同一时刻最多只有 10 个请求在执行业务逻辑,其余全部拒绝或排队。
什么时候该用并发限制?当你保护的资源是“并发敏感型”而不是“速率敏感型”时。典型场景:接口背后调用慢速的外部服务、操作共享的本地资源、或执行重量级的 CPU 密集型计算。这时候 1 秒钟来 100 个请求可能无所谓,但如果 100 个请求同时卡在一个慢 SQL 上,连接池就扛不住了。
csharp复制options.AddConcurrencyLimiter("concurrency", concurrencyOptions =>
{
concurrencyOptions.PermitLimit = 10;
concurrencyOptions.QueueLimit = 0;
});
并发限制没有自动补充周期的概念,请求处理完就释放一个配额,思路非常简单。但要注意它的判定时机,中间件会在请求进入时获取一个并发配额,整个请求生命周期都占着这个配额,直到响应完成。如果你的一个接口内部又发起了多个子请求,这些子请求也会被父请求占用的流控维度覆盖,设计时要考虑清楚。
2.5 四种算法的横向对比
| 算法 | 控制维度 | 是否允许突发 | 内存开销 | 适用场景 |
|---|---|---|---|---|
| 固定窗口 | 单位时间请求数 | 窗口边界可突发 | 极低 | 按时间维度的简单配额 |
| 滑动窗口 | 单位时间请求数 | 少量平滑突发 | 低到中 | 默认推荐的常规接口 |
| 令牌桶 | 长期速率+突发容量 | 允许桶容量内突发 | 低 | 流量波动明显的业务 |
| 并发限制 | 同时处理数 | 不相关 | 极低 | 保护慢速外部依赖或稀缺资源 |
从实际选择来看,没有绝对最好的算法,只有跟场景匹配的算法。拿不准的时候从滑动窗口开始,它应对绝大多数 Web API 已经够了;如果明确知道系统要承受突发流量,选令牌桶;如果瓶颈在资源并发而非速率,选并发限制。固定窗口更适合宽松的运营配额控制,用在核心业务接口上要谨慎。
3. 实操:在Web API里一步步配好限流
3.1 最小配置与中间件顺序
先准备一个 .NET 7.0 的 ASP.NET Core Web API 项目。可以是空模板,也可以用带 Controller 的默认模板。内置限流不需要额外安装 ASP.NET Core 侧的 NuGet 包,如果你要在非 Web 项目里直接用 RateLimiter 类,才需要引用 System.Threading.RateLimiting。
项目结构准备好之后,修改 Program.cs:
csharp复制var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("default", fixedOptions =>
{
fixedOptions.PermitLimit = 30;
fixedOptions.Window = TimeSpan.FromSeconds(10);
fixedOptions.QueueLimit = 0;
});
});
var app = builder.Build();
app.UseRouting();
app.UseRateLimiter();
app.MapControllers();
app.Run();
这里有三件事要重点说明。
第一,AddRateLimiter 注册了中间件服务和策略配置,UseRateLimiter 把中间件放进管线。如果只加服务不调用 UseRateLimiter,策略定义得再完整也不会生效。
第二,中间件顺序很关键。UseRateLimiter 必须排在 UseRouting 之后、终结点执行之前,这样中间件才能拿到当前请求对应的 Endpoint 元数据,才会知道“这个接口要不要限流、用哪个策略”。如果你把它放在最前面,在少见的情况下可能会出现元数据不可见导致策略不生效的问题。
第三,RejectionStatusCode 我一般直接改成 429。框架默认的拒绝状态码是 503,对于语义上“请求过多”的场景,429 更准确。前端看到 429 就知道是限流了,不会误以为是服务端故障而进行无意义的重试。
3.2 接口级策略的绑定方式
这里要澄清一个常见误解:注册了策略,不代表所有接口自动限流。在 .NET 7 里,你必须把策略显式绑定到终结点,限流中间件才会对具体请求生效。
在 Controller 场景,用 EnableRateLimiting 特性标注:
csharp复制[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("default")]
public class DemoController : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
return Ok(DateTime.UtcNow);
}
}
EnableRateLimiting 特性也可以打在 Action 上,粒度更细。对应地,DisableRateLimiting 特性用于豁免某个接口,比如健康检查。在 Minimal API 里,绑定方式是用链式调用:
csharp复制app.MapGet("/api/ping", () => Results.Ok("pong"))
.RequireRateLimiting("default");
那全局兜底怎么做?我的习惯是分两步:给每个 Controller 类都加上同一个默认策略特性,作为第一道闸门;关键业务接口单独注册更严的策略,通过 EnableRateLimiting 指定不同的策略名覆盖默认值。这样既能兜底,又保留灵活性。虽然没有一步到位的“全局开关”,但这种显式绑定反而能让你时刻清楚每个接口的限流状态,不会出现“以为加了限流其实没加”的乌龙。
3.3 按IP、API Key做分区限流
前面讲的都是“所有请求共用一个计数器”。真实项目里,经常需要按调用方维度隔离配额,比如不同 API Key 的调用方互不影响,每个 Key 有自己的速率限制。这就是分区限流(Partitioned Rate Limiting)。
分区限流的核心是 RateLimitPartition。每个请求进来,系统先根据你定义的规则算出它属于哪个分区,再对那个分区独立计数。看一个按客户端 IP 分区限流的例子:
csharp复制builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("per-ip", httpContext =>
RateLimitPartition.CreateFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromSeconds(10),
AutoReplenishment = true
}));
});
partitionKey 就是分区键。同一个 IP 进来的请求会落到同一个分片,各自计数。这里有一个非常容易踩的坑:如果你的服务部署在反向代理后面,httpContext.Connection.RemoteIpAddress 拿到的是代理的 IP,所有用户都会被视为同一个分区。这时必须配置中间件,让它使用转发的真实 IP,也就是 ForwardedHeaders 相关配置,解析 X-Forwarded-For 头。反向代理部署非常常见,这一步不做,分区限流就形同虚设。
再看按 API Key 分区的例子。假设客户端在请求头 X-Api-Key 里携带自己的 Key:
csharp复制options.AddPolicy("per-key", httpContext =>
{
var apiKey = httpContext.Request.Headers["X-Api-Key"].ToString();
if (string.IsNullOrEmpty(apiKey))
{
return RateLimitPartition.CreateNoLimiter("anonymous");
}
return RateLimitPartition.CreateTokenBucketLimiter(
partitionKey: apiKey,
factory: _ => new TokenBucketRateLimiterOptions
{
TokenLimit = 50,
TokensPerPeriod = 10,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
AutoReplenishment = true
});
});
这里有个小技巧:没有带 Key 的匿名请求,我用 RateLimitPartition.CreateNoLimiter 直接放行,或者你也可以给它一个很低的限额。生产上我倾向于给匿名请求一个独立且较低的限额,避免匿名流量把资源占完,同时又不至于因为匿名入口直接放行而被绕过限制。这里的关键是分区键必须稳定且不可伪造。如果分区键能从请求头随意改,调用方换个 Key 就绕过了限流,等于白做。
3.4 自定义拒绝响应与基础请求头
默认情况下,请求被拒绝时,中间件会返回 RejectionStatusCode 指定的状态码,响应体是空的。对客户端来说,“429 + 空 body”虽然能识别状态码,但缺少重试时间的提示。生产环境我一般会用 OnRejected 把错误响应和 Retry-After 头补上。
csharp复制builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, cancellationToken) =>
{
var retryAfter = "10";
context.HttpContext.Response.Headers["Retry-After"] = retryAfter;
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(new
{
code = 429,
message = "请求过于频繁,请稍后再试"
}, cancellationToken);
};
// 具体策略...
});
Retry-After 头是 HTTP 标准里的字段,客户端看到后会按照它指示的秒数等待。配合客户端退避重试,能有效降低“越被拒绝越重试、越重试越被拒”的死循环概率。至于 X-RateLimit-Limit、X-RateLimit-Remaining 这类常规限额头部,框架默认不会自动输出。如果需要,只能在响应中间件里自行记录和输出。说实话,对多数内部 API 来说,有 429 + Retry-After 就已经足够,额外头部属于锦上添花。
另外,OnRejected 里一定要处理 cancellationToken。如果客户端已经断开,你再往响应流里写内容会抛异常,这个异常会让服务器侧记录一条无意义的错误日志。在回调里判断 token 或者直接让 WriteAsJsonAsync 带上 token 是最稳的。
3.5 配置化与生产参数调优
把限流参数写死在代码里,虽然能跑,但每次调参都要改代码重新发布。更好的做法是把参数放到 appsettings.json,让配置系统接管。
csharp复制public class RateLimitPolicyOptions
{
public int PermitLimit { get; set; }
public int WindowSeconds { get; set; }
}
对应配置文件:
json复制{
"RateLimits": {
"Default": {
"PermitLimit": 30,
"WindowSeconds": 10
}
}
}
然后在注册策略时绑定:
csharp复制var rateLimitConfig = builder.Configuration.GetSection("RateLimits:Default")
.Get<RateLimitPolicyOptions>();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("default", fixedOptions =>
{
fixedOptions.PermitLimit = rateLimitConfig.PermitLimit;
fixedOptions.Window = TimeSpan.FromSeconds(rateLimitConfig.WindowSeconds);
fixedOptions.QueueLimit = 0;
});
});
这样调整阈值只需要改配置下发,不需要动代码。需要注意,配置方案适合常规调参,但如果策略结构特别复杂(比如按 Key 动态设置不同阈值),还是要在代码里做分区工厂,配置只能承载静态部分。
参数怎么定?我常用的经验是先根据压测结果测算单实例的真实处理能力,比如单实例能稳定处理 200 QPS、内存和下游都撑得住,那就把限流阈值设在 150 左右,留 25% 余量。阈值设得比能力低太多,容易误伤正常流量;设得比能力高,限流就成了心理安慰。宁可先紧后松,上线后观察拒绝比例再逐步放宽。
4. 部署与排查:限流最常见的坑和定位办法
4.1 限流不生效的原因排查
很多朋友第一次接完限流,压测发现一点效果都没有,请求全部正常返回。我盘点一下最常见的几个原因。
一是没有调用 UseRateLimiter。这个看起来低级,但真的经常发生。AddRateLimiter 只是注册服务和策略,真正拦截请求要靠中间件。二是中间件位置不对。UseRateLimiter 放在不能用访问到 Endpoint 的位置,策略里的按终结点绑定自然也就拿不到。三是策略绑定缺失。注册了策略但没有在 Controller 上用 EnableRateLimiting,也没有在 Minimal API 上调用 RequireRateLimiting,所有请求都不带策略元数据,中间件无从下手。
排查思路很简单,先在任意接口上明确加 EnableRateLimiting("default"),然后本地用脚本连续点 20 次,看超过阈值后是否返回 429。如果还是全 200,那就检查中间件顺序和策略名是否完全一致,包括大小写。策略名不一致是最隐蔽的坑,配置里写 default,特性里写 Default,肉眼很难看出来。再有一个容易被忽略的点:如果你在多个地方调用了 AddRateLimiter,后面一次调用会覆盖前面的配置,只有最后一次的配置是有效注册。
4.2 多实例部署下的一致性
内置限流是进程内的,每个实例各记各的账。假设你有 3 个实例,每实例限流 100 QPS,整个集群实际能扛 300 QPS。对单实例保护来说这没问题,但如果你希望的是“整个服务对外只有 100 QPS”,这就失效了。
解决思路分两种。第一种是接受每实例限流,把阈值按实例数切分,3 个实例就把 100 除以 3,每个实例 33。这种方式简单但不够精确,实例重启、扩缩容都会让分配变得不准确。
第二种是上分布式限流,用集中式存储记录计数,比如 Redis。.NET 7 内置的 RateLimiter 没有直接提供 Redis 实现,需要自己做或者用社区方案。实现的核心思路是:拿到配额时在 Redis 里执行原子递增/递减,通过 Lua 脚本或 Redis 原子命令保证并发安全,再按固定窗口或令牌桶逻辑判断是否放行。技术上是完全可行的,但对团队运维能力有一定要求。
我的建议是,先想清楚你的“总量一致性”是不是硬需求。多数内部服务,单实例限流配合合理的实例水位已经足够;只有开放平台、对外网关这类需要严格总量控制的场景,才值得为分布式限流引入额外的基础设施和复杂度。
4.3 排队队列的正确使用姿势
限流策略里的 QueueLimit 参数,很多人第一次看到不知道是干什么的。它的作用是在配额用完时,把请求放进队列而不是直接拒绝,等释放配额后再处理。QueueProcessingOrder 控制队列里谁先拿到配额,OldestFirst 是先来后到,NewestFirst 是新请求优先。
队列看起来挺美,但用起来要谨慎。队列本质上是把瞬时压力往后挪,而不是消除。如果 QueueLimit 设得很大,上游疯狂打请求时,你的服务会积压大量等待的请求,内存上涨、客户端超时、业务响应变慢,最后限流变成“延迟放行的雪崩”。绝大多数后端接口,我建议 QueueLimit 保持 0,也就是超限就立刻拒绝,让客户端自行退避重试。只有在你确认请求的成本很低、客户端具备等待耐心(比如离线任务回调)时,才考虑开小队列,比如 QueueLimit 设为 5,并且一定结合超时控制。
一个很现实的经验:与其在服务端排队,不如让客户端主动退避。服务端 429 加上 Retry-After 头,客户端按指数退避重试,整个系统的吞吐反而更稳定。排队这件事,看着是在“帮客户端”,实际很容易把服务端自己也拖下水。
4.4 压测与验证的实用方法
验证限流是否生效,不需要重型压测工具。我经常直接写一段简单的控制台代码,循环请求目标接口,把状态码打出来看。
csharp复制using var httpClient = new HttpClient
{
BaseAddress = new Uri("https://localhost:5001")
};
for (int i = 0; i < 25; i++)
{
var response = await httpClient.GetAsync("/api/demo");
var retryAfter = response.Headers.RetryAfter?.Delta?.TotalSeconds;
Console.WriteLine($"请求[{i + 1}]: {response.StatusCode}, Retry-After={retryAfter}");
await Task.Delay(100);
}
如果策略是每 10 秒 30 次,连续发 25 次请求,前 30 次返回 200、从第 31 次开始 429,就说明固定窗口正常。再用不同 IP 或不同 API Key 分开发请求验证分区逻辑,确认彼此不共享配额。
更严谨一点的做法是用压测工具在短时间内打固定并发。但要注意,压测请求本身也会占用系统资源,压测机器和服务在同一台机器时,测出来的数据仅供参考。判断依据始终是:响应状态码分布是否符合预期的限流阈值,而不是纯粹看 QPS 数字。
4.5 分区Key的选择陷阱
分区限流的效果,完全取决于分区键的选择。最常见的三个坑,我逐个说。
第一个坑是分区键会变。用 IP 做分区键,但用户的 IP 是动态的,同一用户换个网络就换了分区,配额被重复计算;反过来,NAT 出口下大量用户共享一个 IP,又会被误伤。API Key 比 IP 稳定得多,前提是调用方都规范携带。
第二个坑是分区键可伪造。API Key 放在请求头里,如果没有配套的鉴权机制,调用方完全可以自己造不同的 Key 来绕过限制。分区键必须和身份绑定,比如从已经通过认证的 Claims 里取用户 ID,而不是直接信请求头。
第三个坑是分区键为空。请求头缺失、IP 拿不到,分区键就成了 null 或空字符串,所有异常请求都聚到一个分区里。这个分区要么把正常请求误伤,要么被异常请求打爆。所以分区工厂里一定要对空值做兜底分流,比如给一个 anonymous 分区并设置合理限额。
另外,如果分区键是你自定义的复杂对象,要确保它的 Equals 和 GetHashCode 语义正确,否则两个同一个分区的请求可能被错误地分到两个区,限流效果直接打折扣。
5. 生产落地经验:豁免、客户端配合与监控
5.1 健康检查与内部接口豁免
健康检查接口不能限流,否则负载均衡器发现服务不健康,会把实例摘掉,形成“越忙越被摘”的恶性循环。在 Controller 场景,给健康检查接口加 DisableRateLimiting;在 Minimal API 场景,调用 DisableRateLimiting() 链式方法。
同样需要豁免的还有内部定时任务回调、回调通知类接口。这些接口往往是上游系统主动调用,且频率本来就可控,限流可能造成回调丢失,反而引入业务问题。我的经验是,先列清楚哪些接口属于“基础设施类”和“信任链路类”,明确豁免,避免上线后大面积误伤。
5.2 客户端如何配合(Retry-After + 指数退避)
限流永远不只是服务端的事。服务端拒绝只是第一步,客户端如果还是拼命重试,系统依然会持续高负载。正确的配合方式是:客户端收到 429 后,必须读取 Retry-After 头,等待指定时间再重试;如果没有 Retry-After,就采用指数退避,比如第一次等 1 秒、第二次等 2 秒、第三次等 4 秒,直到上限再等待。
在 .NET 客户端这边,可以结合 Polly 之类的重试库实现策略:
csharp复制Policy
.HandleResult<HttpResponseMessage>(r => r.StatusCode == HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(new[]
{
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(4),
TimeSpan.FromSeconds(8)
});
背后的逻辑很简单:让服务端从限流状态中恢复,而不是在它最忙的时候继续加压。指数退避在分布式系统里是通用做法,前后端配合好了,限流对用户体验的影响会小很多。
5.3 监控与日志的最小闭环
限流不能只做不盯。至少要有三项监控:拒绝量、拒绝率、各分区命中情况。拒绝量反映限流是否在工作;拒绝率过高说明阈值设置过紧,需要调参;分区命中情况则能看出是不是某个调用方异常打流量。
日志方面,OnRejected 回调里记一行日志,包含请求路径、分区键、IP、被拒时间,就足够定位大多数问题。不要在这个回调里做大块同步写库操作,否则限流回调本身会变成性能瓶颈。用结构化日志框架异步输出即可。
我个人还会在响应里加一个自定义头比如 X-RateLimit-Partition,方便调试时直接看到当前请求被分到哪个分区,排查问题效率高不少。
最后分享一点我实际用下来的体会:别一上来就追求复杂的分布式限流,先在单实例把固定窗口、滑动窗口、令牌桶这几种用顺手,理解透了四种算法各自的行为差异,再考虑集群一致性。很多系统真正需要的只是入口处一道合理的闸门,而不是一套复杂的配额计算系统。上线限流策略时,先把阈值设为压测能力的 70% 左右观察一两天,再根据拒绝率和业务反馈微调,比拍脑袋给数字靠谱得多。这个思路,不管你是刚接触 .NET 7 限流,还是准备从老方案迁移,都适用。
