ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C# API限流计数一次扣2?从请求重复与中间件顺序定位修复

2026/9/25 13:57:00 拓冰建站 浏览量
C# API限流计数一次扣2?从请求重复与中间件顺序定位修复 C# API项目里出现X-Rate-Limit-Remaining一次请求直接减2这个问题我最近一个月里被问到了好几次。AspNetCoreRateLimit、.NET内置的RateLimiter甚至自己写的简单计数中间件都可能出现同一个表象前端明明只点击了一次配额却从100硬生生掉到98。很多人的第一反应是“限流中间件有Bug”然后开始翻源码、换方案折腾半天还是没搞定。实际上扣2这件事几乎从来不是算法的问题它是一个非常强力的信号告诉你“这个请求被计数了两次”。至于为什么被计两次一半的锅在客户端另一半在服务端中间件管道本身的执行方式。这篇文章我把整个排查链路完整走一遍从计数原理讲起到抓包、日志定位、修复方案最后给一个可用的自定义精准限流中间件方便你在自己项目里做对照实验。1. 限流计数是怎么变成“减2”的先把扣减链路捋清楚1.1 一次请求经过限流中间件的完整生命周期要理解为什么扣2先得知道在C#的ASP.NET Core项目里一个请求从进入到返回中间件管道的执行顺序是什么样的。ASP.NET Core的中间件就是一层套一层的洋葱结构请求从外层进入依次穿过每一层UseXxx到达路基/控制器之后响应再原路返回。限流中间件挂在其中的某一层。如果用的是AspNetCoreRateLimit处理流程通常是这样中间件拿到请求后先识别客户端身份默认取IP也可以取X-ClientId之类的请求头然后拿这个身份去匹配你配置的限流规则匹配到了就读取或创建计数器判断当前时间段内是否已超限最后把X-Rate-Limit-Limit、X-Rate-Limit-Remaining、X-Rate-Limit-Reset这些响应头写回去。计数器可以用内存缓存也可以用Redis。每一步逻辑本身都不复杂一个请求经过一次限流中间件正常情况下只可能生产一次计数。如果用的是.NET 7内置的System.Threading.RateLimiting走的是UseRateLimiter()中间件加分区策略底层算法是固定的窗口、滑动窗口或者令牌桶。它的实现更严谨内部计数用的是原子操作一个请求经过一次几乎不可能自己把计数变成两次。所以当剩余量一次减2结论很容易收窄到两个方向要么是同一个请求在服务端被计数了两次中间件重复注册、管道重放、多条规则同时命中要么是客户端或网关同一个时间点里实际发过来了两个请求CORS预检、React严格模式、重试、防抖失效。这两个方向在排查思路上完全不同先别急着动中间件代码把下面这段看完再做判断。1.2 剩余量减2在数学上意味着什么逻辑上“剩余量减2”只有两种可能。第一种一个请求被同一个计数器递增了两次。这种情况典型对应中间件注册了两次或者一次请求经过了两条限流规则两条规则又恰好指向同一个计数器。第二种两个请求各自递增了一次同一个计数器。这种情况对应的是客户端发了两个请求或者网关/代理重发了一次甚至健康检查把配额偷了。还有一个很容易忽略的细节HTTP响应头是允许被重复设置的。假设管道里有两个限流中间件第一个把X-Rate-Limit-Remaining写成了98第二个又把同样的响应头重写了一遍屏幕上你看到的是第二个中间件写出的值。也就是说就算你最终看到的是“98”也不代表这个请求只扣了一次它可能已经累计扣了两三次只是最后一次被“覆盖显示”了。这点在排查时很重要不能因为响应头数字看着合理就排除重复扣减。1.3 先排除最“傻”的原因重复发请求很多刚遇到这个问题的同学会直接怀疑中间件库本身有Bug我先说一个大概率让你哭笑不得的事实AspNetCoreRateLimit和内置RateLimiter都是成熟得不能再成熟的库单次请求计数这种事它们不太可能出错。真正错得最多的地方在项目自己的代码里。所以排查的第一步不是翻库源码而是先把请求数量数清楚。开浏览器DevTools的Network面板勾上Preserve log然后正常点击一次API看看到底发出了几条请求。如果你发现一条OPTIONS加上一条正常业务请求那就是CORS预检在作怪。如果发现两条一模一样的GET请求间隔只有几十毫秒那八成就是React的StrictMode在开发环境里把Effect跑了两次或者前端的防抖没做好。这一步五分钟就能排除一大半的“中间件减2”问题。2. 六个常见根因逐个拆开看为什么被扣两次2.1 中间件被注册了两次管道里有两份限流这是服务端错误里最直接的一种Program.cs或者Startup.Configure里同一种限流中间件被注册了两份。比如原本项目里已经有过一行app.UseClientRateLimiting()后来加功能时又复制了一行或者别人在某个分支里用app.UseWhen又注册了一次。每个请求传过第一份限流中间件时扣1继续往洋葱内部走穿过第二份中间件时再扣1剩余量当场从100变成98。// 错误示范同一个中间件注册了两次 app.UseClientRateLimiting(); // ... 中间隔了一些别的Use... app.UseClientRateLimiting();这种问题肉眼就能看出来直接全局搜索UseClientRateLimiting、UseIpRateLimiting、UseRateLimiter这几个关键字就能把重复注册揪出来。顺便说一句两份中间件还会各自维护一份计数器状态响应头在第二次实例处理时再次被覆盖最终返回的值跟真实余额并不完全是一回事这也是为什么你看到的剩余量“看着有点道理”的原因。2.2 异常处理中间件“重放”了管道这个根因隐蔽得多。ASP.NET Core的UseExceptionHandler在处理未捕获异常时可能触发管道重新执行或者创建一条子请求进入错误处理分支。如果这条子请求也会经过限流中间件而限流规则又匹配了这个路径那计数器就会被再扣一次。判断标志很明确打开日志找到同一个RequestId或TraceId发现它被限流中间件命中了两回且第二条日志和第一条日志路径相同、时间相隔极短。如果出现这种情况去看看app.UseExceptionHandler和限流中间件的注册顺序。通常建议把限流中间件放在异常处理分支的内层避免错误页相关的子请求也进入限流计数。另外如果错误页本身是个动态路径比如/Home/Error而这个路径匹配了*:/规则也会被额外扣一次所以规则一定要限定到实际业务API不能一把抓。2.3 CORS预检请求和React严格模式CORS跨域时有个经常被忽略的请求类型叫OPTIONS。浏览器在发起真正的跨域请求前会先发一条OPTIONS预检请求来试探服务器允不允许跨域。如果限流规则写的是*:/api/*或*:*那么这条OPTIONS也会被计入计数。于是前端眼里“只点击了一次”网络面板里却是OPTIONS加业务请求两条计数消耗直接翻倍。React项目类的单页应用还会叠一层Buff开发环境下React.StrictMode会故意让Effect执行两次具体表现就是mount、unmount、再mountuseEffect里发起的请求会真实执行两次。所以一个跨域POST在开发环境下完整请求序列可能是OPTIONS、GET、OPTIONS、GET扣4次。如果你在生产环境没开StrictMode那这个问题只在开发时出现如果生产环境开了那就要认真处理。对于这种问题我的建议很直接限流规则不要覆盖OPTIONS也不要覆盖健康检查和Swagger相关的路径。不同语义的请求放在不同的限流分区里避免预检把业务配额吃掉。2.4 客户端自动重试与重定向跟随HTTP客户端的默认行为里藏着一个不太起眼的坑HttpClient的HttpClientHandler默认允许自动跟随重定向也就是AllowAutoRedirect true。假设你的API因为未鉴权返回了302跳转HttpClient会自动再向Location发一次GET。如果跳转后的地址也匹配限流规则这一次点击就会产生两条计数。更常见的是重试策略。很多团队会给所有请求统一加分Polly重试比如“遇到超时就重试3次”。第一个请求到了服务端计数器已经扣了但客户端因为网络波动没等到响应自动重试又发第二个请求计数器再扣一次。如果重试策略里对429也做重试那被限流的系统会被自己的客户端重试逻辑打得更惨配额消耗按指数上升。// 重试策略里对限流状态码不加区分的典型写法不推荐 var retry HttpPolicyExtensions .HandleTransientHttpError() .WaitAndRetryAsync(3, retryAttempt TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));这种“客户端帮倒忙”的情况特别适合那种“一次性点击但接口返回慢”的高延迟场景。服务端没Bug客户端也没写错就是两边策略叠加出了问题。重试策略应当对非幂等请求关闭对429和5xx要区分对待能识别Retry-After头更好。2.5 网关层重复转发与健康检查偷走配额上到生产环境问题可能不在应用代码而是前面还有一层网关。K8s的探针会定期请求/healthz或/health如果这些路径没被限流规则排除探针每几秒来一次也在消耗配额你看到的“剩余量莫名减少”很可能就是探针干的。另外Nginx、IIS ARR这类网关如果配置了上游重试策略后端返回超时或5xx时网关会向上游重发请求后端的限流中间件对这两次请求都会正常计数。还有一个多实例场景很容易踩限流计数器用的是进程内内存比如AspNetCoreRateLimit默认的IMemoryCache如果前面有负载均衡、流量被分到多个实例每个实例都在各自计数等于配额被放大了好几倍。这种现象不一定表现为精确的“减2”但会让你觉得剩余量时快时慢、非常不稳定。解决方向是两边都下手健康检查路径放进白名单网关不要对写请求做透明重试多实例时把计数器换成Redis。2.6 限流规则重复命中一个请求匹配多个计数器这个原因经常被忽略而且AspNetCoreRateLimit这类库在实现时确实会对所有匹配的规则逐条执行计数逻辑。也就是说如果配置里同时存在两个规则都匹配同一个请求且都指向同一个客户端分区那一个请求就会被递增两次甚至多次。举一个错误配置的典型例子GeneralRules: [ { Endpoint: *:/api/*, Period: 1m, Limit: 60 }, { Endpoint: GET:/api/*, Period: 1m, Limit: 60 } ]一个GET请求同时命中了上面两条规则两条规则窗口和限额一样看起来是写了两个“兜底”规则实际上每个请求都扣了2。配置限流规则时一定要检查规则之间是否互斥尽量精确到HTTP方法 路径前缀不要既写宽规则又写窄规则。判断方法很简单开限流的Debug日志看一条请求日志里匹配到了几个规则名。3. 实操排查按这个顺序一步步定位真凶3.1 第一步抓包确认服务端到底收到几个请求动手改代码之前先把“请求数量”这个客观事实确认清楚。浏览器DevTools的Network面板是最快的办法勾选Preserve log确保请求记录不被页面刷新清空。点击一次API后数一下请求条数看方法和路径是不是完全相同。如果从DevTools看不清直接在服务端临时加一个简单的访问日志中间件把所有到达请求的时间、方法和路径打出来。这一步能把“服务端到底收到了几个请求”钉死后面分析也就有了依据。app.Use(async (context, next) { var logger app.Logger; logger.LogInformation(Incoming: {Time} {Method} {Path}, DateTimeOffset.UtcNow, context.Request.Method, context.Request.Path); await next(); });3.2 第二步加请求Id追踪看是不是同一个请求被扣两次请求数量确认之后下一步要区分多出来的计数是同一条请求被重复计了还是不同请求各计了一次。这时候给每个请求一个唯一X-Request-Id非常管用。可以临时加一个中间件从请求头读取或生成请求Id然后写进响应头同时打个日志。public class RequestIdMiddleware { private readonly RequestDelegate _next; private readonly ILoggerRequestIdMiddleware _logger; public RequestIdMiddleware(RequestDelegate next, ILoggerRequestIdMiddleware logger) { _next next; _logger logger; } public async Task InvokeAsync(HttpContext context) { var requestId context.Request.Headers[X-Request-Id].FirstOrDefault() ?? Guid.NewGuid().ToString(N); context.Response.Headers[X-Request-Id] requestId; _logger.LogInformation(Request {RequestId} {Method} {Path}, requestId, context.Request.Method, context.Request.Path); await _next(context); } }注册好这个中间件再和限流中间件的日志对照。如果同一个RequestId在限流日志里出现了两次那就说明是服务端管道里同一个请求被计数了两次如果是两个不同的RequestId那就说明客户端/网关真的发了两个请求。这一步决定了后续改的方向是整个排查最重要的十字路口。3.3 第三步开日志让限流中间件“开口说话”很多限流中间件本身是有日志的只是默认级别太高你没看到。AspNetCoreRateLimit用「AspNetCoreRateLimit」这个日志类别.NET内置RateLimiter用「Microsoft.AspNetCore.RateLimiting」这个类别。把它们都调到Debug限流中间件的每一步决策都会打印出来包括匹配到了哪条规则、命中了哪个计数器键、递增后的值是多少。Logging: { LogLevel: { AspNetCoreRateLimit: Debug, Microsoft.AspNetCore.RateLimiting: Debug } }这类日志最大的价值在于它能直接告诉你一条请求到底被几条规则处理了。如果你的日志里出现“rule A matched”“rule B matched”那就是规则重复命中如果同一个RequestId出现两次“counter incremented to X”那就是重复执行如果完全没有重复日志那计数器就是被不同请求各扣了一次问题在客户端或网关。3.4 第四步检查注册与规则配置做完上面三步心里应该基本有数了。回过头来把代码和配置过一遍搜索UseClientRateLimiting、UseIpRateLimiting、UseRateLimiter确认只有一个全局注册。检查GeneralRules里有没有规则同时能匹配同一类请求尤其警惕*:/api/*和GET:/api/*这种重叠组合。确认OPTIONS、/health、/swagger没有被业务限流规则覆盖。如果用了.NET内置UseRateLimiter()确认中间件是否放在UseRouting()之后、UseAuthentication()之前顺序错了可能导致特性不生效或计数行为异常。看异常处理中间件的注册位置确认UseExceptionHandler不会触发额外的子请求再次经过限流。3.5 我建议的排查工具组合纯HTTP层面的请求重复Windows下直接开Fiddler或Postman的Console看请求列表Linux开发机用tcpdump加Wireshark也能看得很清楚。但针对中间件扣减问题最核心的还是三个东西访问日志中间件、请求Id中间件、限流库自身的Debug日志。三样合起来基本五分钟内就能把“扣2”的来源锁定到客户端还是服务端。别一上来就改代码先让数据说话。4. 修复与预防把剩余量拉回到一次减14.1 修正中间件注册只允许一份如果确认是中间件注册了两份直接删掉多余的那一行。这里要特别留意UseWhen或MapWhen分支里有没有嵌套注册因为分支里注册的中间件会和全局注册叠加。正确的做法是只有一个全局限流中间件端点差异用限流规则去区分不要用中间件的重复注册去区分。var builder WebApplication.CreateBuilder(args); builder.Services.AddMemoryCache(); builder.Services.ConfigureIpRateLimitOptions(builder.Configuration.GetSection(IpRateLimiting)); builder.Services.AddInMemoryRateLimiting(); var app builder.Build(); // 全局只注册这一份 app.UseIpRateLimiting(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();4.2 跳过非业务请求OPTIONS/健康检查/静态文件把不该计数的请求排除出限流规则这一步能解决掉一大类“明明只有一次点击却扣好几次”的问题。AspNetCoreRateLimit里可以通过EnableEndpointRateLimiting配合具体的Endpoint规则只对明确的业务路径生效如果你的限流规则是用自定义中间件写的也可以在中间件直接加一个ShouldSkip判断。private static bool ShouldSkip(HttpContext context) { return context.Request.Path.StartsWithSegments(/health) || context.Request.Method OPTIONS || context.Request.Path.StartsWithSegments(/swagger); }跳过逻辑放在中间件最前面匹配到就直接await _next(context)不经过任何计数逻辑。这样做业务请求的配额一点不少但预检、探针、文档请求不会再“偷”配额。4.3 正确放置限流中间件的顺序中间件顺序会影响计数行为尤其是配合Endpoint规则使用时。AspNetCoreRateLimit如果用EnableEndpointRateLimiting true中间件最好放在UseRouting()之后因为需要在请求里拿到Endpoint信息才能匹配Endpoint规则如果放在前面匹配不到Endpoint所有规则都失效限流直接不生效。如果你用的是.NET内置UseRateLimiter()推荐顺序是app.UseRouting(); app.UseRateLimiter(); // 在Routing之后、Auth之前 app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();放在UseRouting之后是为了让限流中间件能看到Endpoint元数据这样[EnableRateLimiting]特性才会生效。顺序错误可能会出现“限流不生效”或“计数行为异常”的玄学问题把顺序理正之后至少能减少一类排查成本。4.4 服务端要严谨、客户端要克制重试策略怎么写客户端重试是配额消耗最大的“隐藏杀手”。自己写重试策略之前必须想清楚几个问题这个请求幂等吗如果第一次已经成功但响应丢失重试会不会造成重复数据服务端返回429的时候客户端再发几次是不是在给被限流的系统雪上加霜我建议的写法是重试只针对幂等请求遇到429优先看Retry-After头别用固定重试次数对限流状态码进行无脑冲锋。下面是一个Polly重试的示例骨架重点是对429单独处理var retryPolicy HttpPolicyExtensions .HandleTransientHttpError() .OrResult(resp (int)resp.StatusCode StatusCodes.Status429TooManyRequests) .WaitAndRetryAsync( 2, retryAttempt TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)) TimeSpan.FromMilliseconds(Random.Shared.Next(0, 100)), onRetry: (outcome, timespan, retryAttempt, context) { // 如果服务器给了Retry-After以Retry-After为准 if (outcome.Result?.Headers.RetryAfter is { } retryAfter) { // 根据Retry-After决定本次等待时长或直接放弃 } });遇到429直接转发给前端“稍后再试”往往比重试三次更合理。真正需要重试的往往是网络瞬时抖动而不是服务端限流本身。4.5 自定义精准限流中间件示例代码如果你不想用AspNetCoreRateLimit或者想完全控制“什么时候扣一次”的细节可以自己写一个简单的限流中间件。下面这个示例用ConcurrentDictionary加锁实现了固定窗口计数只对业务路径计数跳过预检和健康检查并且在每次计数后打印日志方便你观察到扣减行为。public class SimpleRateLimitMiddleware { private static readonly ConcurrentDictionarystring, RateCounter Store new(); private readonly RequestDelegate _next; private readonly ILoggerSimpleRateLimitMiddleware _logger; private readonly int _limit; private readonly TimeSpan _window; public SimpleRateLimitMiddleware(RequestDelegate next, ILoggerSimpleRateLimitMiddleware logger, IConfiguration config) { _next next; _logger logger; _limit config.GetValueint(RateLimit:RequestsPerWindow); _window TimeSpan.FromSeconds(config.GetValueint(RateLimit:WindowSeconds)); } public async Task InvokeAsync(HttpContext context) { if (ShouldSkip(context)) { await _next(context); return; } var clientId context.Request.Headers[X-Client-Id].FirstOrDefault() ?? context.Connection.RemoteIpAddress?.ToString() ?? unknown; var now DateTimeOffset.UtcNow; var windowSeconds (long)_window.TotalSeconds; var currentWindow now.ToUnixTimeSeconds() / windowSeconds; var windowKey ${clientId}:{currentWindow}; var counter Store.GetOrAdd(windowKey, _ new RateCounter { WindowStart DateTimeOffset.FromUnixTimeSeconds(currentWindow * windowSeconds) }); lock (counter) { counter.Count; _logger.LogInformation(Rate counter {WindowKey} incremented to {Count}, windowKey, counter.Count); var remaining Math.Max(0, _limit - counter.Count); context.Response.Headers[X-Rate-Limit-Limit] _limit.ToString(); context.Response.Headers[X-Rate-Limit-Remaining] remaining.ToString(); context.Response.Headers[X-Rate-Limit-Reset] ((counter.WindowStart _window - now).TotalSeconds).ToString(0); if (counter.Count _limit) { context.Response.StatusCode StatusCodes.Status429TooManyRequests; context.Response.Headers[Retry-After] _window.TotalSeconds.ToString(0); return; } } await _next(context); } private static bool ShouldSkip(HttpContext context) { return context.Request.Path.StartsWithSegments(/health) || context.Request.Method OPTIONS || context.Request.Path.StartsWithSegments(/swagger); } } public sealed class RateCounter { public DateTimeOffset WindowStart { get; set; } public int Count { get; set; } }注册方式很简单app.UseMiddlewareSimpleRateLimitMiddleware();这个示例是固定窗口实现进程重启后计数会丢失只适合单实例的开发环境和小型服务。生产环境建议把计数逻辑换成Redis加Lua脚本或者直接用内置RateLimiter封装。它最大的价值是让你把扣减动作变成透明的、有日志的、完全可控的而不是“库悄悄帮你扣了但你看不见”。5. 常见问题速查表与避坑笔记5.1 按现象查原因的表这里把扣减异常的几种典型现象对应到原因和排查动作遇到问题直接对着表查就行。现象可能原因排查动作点击一次扣2网络面板两条相同请求React StrictMode / 防抖失效 / 手动重复点击看请求触发时间加防抖或请求锁网络面板两条请求第一条是OPTIONSCORS预检被限流规则命中限流规则排除OPTIONS或让CORS中间件优先处理服务端日志同一RequestId出现两条限流记录中间件重复注册或异常处理触发管道重放全局搜索UseXxxLimiting检查UseExceptionHandler位置剩余量不是固定减2而是周期性被扣健康检查探针或定时任务命中规则将健康检查路径加入白名单一个请求匹配了两条规则日志可见两个规则名规则重复命中检查GeneralRules确保规则之间互斥多实例部署后剩余量时快时慢进程内内存计数器各自独立改用Redis共享计数器或分区限流5.2 三个我踩过的坑第一个坑是异常处理中间件重放。有一次排查一个统计接口的配额异常日志里同一个RequestId出现了两次限流命中记录一开始以为是并发写坏了计数器后来才发现是UseExceptionHandler在处理某个异常时内部重新走了一遍管道把限流中间件再触发了一次。改成把限流中间件放到异常处理分支内侧之后计数立刻恢复正常。第二个坑是网关重试。生产环境某定时任务每次调用API都被扣好几份配额服务端日志看着是两三个不同的请求但客户端只发了一次。最后定位到是Nginx配置了上游重试策略后端响应超时后自动重发了一次。把网关对写请求的重试关闭并且在后端把幂等键设计好这个问题才彻底解决。遇到可疑的重复扣减不光要看应用日志还要看网关层的日志。第三个坑是开发环境的React StrictMode加CORS预检双重叠加。一个POST在开发环境下实际发出OPTIONS、GET、OPTIONS、GET四条请求限流计数扣了4次。当时团队所有人都以为限流中间件坏了结果我把浏览器Network面板打开请求列表摆在那里谁也不再说话了。后来配置里跳过OPTIONS生产环境没开StrictMode问题消失。这个事给我的教训很直接限流扣减异常先数请求数量再查服务端逻辑顺序反了会白白浪费时间。5.3 上线前限流配置检查清单最后整理一份清单配合上线前的自测能省很多事全局搜索UseClientRateLimiting/UseIpRateLimiting/UseRateLimiter确认中间件只注册一次。确认限流规则不会把OPTIONS、健康检查、Swagger 路径纳入计数。确认异常处理中间件不会触发管道重放或者限流中间件已调整到合理位置。多实例部署时确认计数器使用了Redis这类共享存储。客户端重试策略对非幂等请求关闭对429和5xx设置了合理的退避优先尊重Retry-After头。用一个简单脚本连续请求10次观察X-Rate-Limit-Remaining是否严格等于请求次数的扣除量。我在实际项目里碰到这个问题时的心情线基本都是“先怀疑中间件库、再怀疑框架、最后发现是自己把请求发重复了”。X-Rate-Limit-Remaining减2这个现象本身并不神秘它只是明确地告诉你“计数发生了两次”。顺着“请求到底经过了几个计数点”和“客户端到底发出来几个请求”这两条线索去查基本都能在半小时内落地。最后再分享一个小技巧给测试环境加一个只记录请求Id、路径和X-Rate-Limit-Remaining变化的响应头日志中间件联调的时候盯着它看限流相关的玄学问题能少掉一大半。