1. 问题现场剩余配额为什么一次跳两格先说个我最近被同事拉过去看的典型场景后端用的是 ASP.NET Core 8加了官方限流中间件配置了一个固定窗口每分钟 100 次。前端点击一次业务按钮接口确实只被调了一次但响应头里的 X-Rate-Limit-Remaining 从 99 直接变成 98而不是预期的 99 变完再减到 98。再点一下变成 96。人家问我是不是限流器写错了每次消耗两个配额我当时的第一反应也是先怀疑代码配置但看完 Program.cs 之后发现配置很简单就是 AddRateLimiter 加 UseRateLimiter并没有做什么特殊处理。于是只能把请求级日志打开一步一步看。排查完才发现这个现象根本不是一个“限流算法”问题而是多个计数点在叠加、或者一个点击动作背后其实出现了两个请求。这篇文章我会把这个问题的排查流程完整写出来先帮你建立“剩余头是谁写的”的底层认知再列几个最容易导致“减 2”的根因最后给出可以直接抄的修复代码与验证方法。不管你是刚接触 C# 限流中间件还是已经在生产环境里踩了坑都应该能从里面找到对应的解法。1.1 X-Rate-Limit-Remaining 到底是谁写的要排查这种问题先把响应头的来源搞清楚。ASP.NET Core 从 .NET 7 开始内置了 Rate Limiting 中间件使用时分两步builder.Services.AddRateLimiter(...) 负责注册限流策略app.UseRateLimiter() 把限流中间件挂进请求管道当请求进入管道时RateLimiterMiddleware 会从你配置的策略里获取一个 RateLimiter 实例然后调用 acquire 相关方法去申请许可。如果允许放行中间件会继续往下传如果拒绝就直接返回 429。与此同时它会把当前限制器的窗口信息写到响应头里其中就包括 X-Rate-Limit-Limit总配额、X-Rate-Limit-Remaining剩余配额、X-Rate-Limit-Reset重置时间。这就能推出一个基本结论一个请求如果只通过一次限流中间件那剩余数量只会减 1。你看到减 2要么是这个请求在管道里被限流了两次要么是同一个“点击”动作在更早的层面生成了两个 HTTP 请求。后面所有排查方向都围绕这两句话展开。1.2 “减 2”只有两个大方向很多人在群里问这个问题时第一反应就是去找“限流算法是不是有 bug”。实际上内置限流器在单机单策略场景下非常稳定不太会出现一次请求扣两次的情况。真正的问题基本都落在两个大方向方向一一个请求被限流逻辑处理了两次 比如全局策略和端点策略同时生效请求先过了全局限流器、又过了端点限流器。每个限流器都会申请一次许可每申请一次就会把对应响应头更新一次。先写 99再写 98最后你看到的就是 98。方向二一次业务点击产生了多个 HTTP 请求 比如跨域请求前自动带的 OPTIONS 预检请求、浏览器自己请求的 favicon.ico、或者前端代码因为按钮没禁用而连续提交了两次。这些请求同样会命中全局限流器所以明明你只在界面上点了一次后端却收到了两个请求两个请求各减一次。这两个方向的处理思路完全不一样。方向一是改后端配置方向二是看调用链与前端行为。如果上来就把 OPTIONS 请求排除掉但实际问题却是双限流在叠加那这问题永远修不好。所以我强烈建议按下面第 2 节的顺序逐项排查。2. 五个偷走配额的“小偷”结合我实际看到的生产案例能造成“一次点击减 2”的原因大概有这么五类。严重程度不同但有一个共同特点都很隐蔽。2.1 第一名全局限流和端点限流双管齐下这是代码层面上最容易被忽略的“双计数”问题。场景很常见先在 AddRateLimiter 里配了 GlobalLimiter给整个服务做兜底限流后来某个接口需要更严格的策略又在这个接口上加了 [EnableRateLimiting(strict)] 或者 [RequestSizeLimit] 一类标签。结果同一个请求进来时会先被全局限流器扣一次再被端点限流器扣一次。我见过一份代码是这样的builder.Services.AddRateLimiter(options { options.GlobalLimiter PartitionedRateLimiter.CreateHttpContext, string( context RateLimitPartition.GetFixedWindowLimiter( global, _ new FixedWindowRateLimiterOptions { PermitLimit 100, Window TimeSpan.FromMinutes(1), AutoReplenishment true })); }); builder.Services.AddRateLimiter(options { options.AddFixedWindowLimiter(strict, opt { opt.PermitLimit 10; opt.Window TimeSpan.FromMinutes(1); opt.AutoReplenishment true; }); }); app.UseRateLimiter();控制器里再加一句[EnableRateLimiting(strict)] [HttpGet(order)] public IActionResult GetOrder() Ok();这种写法的问题在于同一份请求管道里实际存在两个限流策略第一个是全局限流器作用于所有请求第二个是端点限流器作用于打了标签的接口。当一个请求打到这个接口时两个限制器都会执行 acquire都会消耗配额也都会尝试往响应头里写 X-Rate-Limit-Remaining。后写的覆盖先写的所以表现就是剩余数直接跳 2。还有更隐蔽的变体你在 Program.cs 里调用了两次app.UseRateLimiter()。中间件被挂进管道两次哪怕用的是同一个 GlobalLimiter 对象请求进来也会被同一个限制器扣两次配额。这种重复注册的代码经历两遍中间件之后剩余数同样会减 2。2.2 第二名CORS 预检请求 OPTIONS如果你把减 2 的问题放到浏览器环境里重现而用 Postman、curl 测又是正常的那大概率就是 OPTIONS 预检请求在作怪。原理不复杂浏览器发起跨域请求时只要请求满足“复杂请求”条件就会先自动发送一个 OPTIONS 请求到服务端询问允许哪些方法、哪些头、哪些来源。服务端通过 CORS 中间件返回 204然后浏览器才真正发出你代码里的 GET、POST、PUT 请求。什么算复杂请求请求方法不是 GET、HEAD、POST请求头里带了自定义头比如 Authorization、X-Custom-HeaderContent-Type 不是 application/x-www-form-urlencoded、multipart/form-data 或 text/plain请求里带了一些特殊头或使用了非规范内容类型最常见的一个场景就是前端用 axios 或 fetch 往你的 API 发 JSONfetch(/api/order, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ id: 1 }) });这个请求带了 application/json属于复杂请求。浏览器不会只发一次 POST而是先发一次 OPTIONS等服务器返回允许以后再发真正的 POST。你在后端接口里打的日志只记录业务 POST没注意浏览器 Network 面板里其实躺着两条请求。这两条请求都进到了 API 管道都经过了限流中间件于是配额一次就减了 2。2.3 第三名favicon.ico 和静态资源自动请求还有一个看起来有点蠢、但真实存在的坑浏览器在打开网页时会自动请求 /favicon.ico。如果你没有单独处理这个路径它同样会命中全局限流器。于是地址栏输入一次网址实际到达后端的有首页请求和 favicon 请求两个配额又减 2。Swagger 页面更明显。Asp.NET Core 用 Swagger 调试接口时Swagger 的静态资源、甚至它内部发出的几个探测请求都会进管道。如果全局限流策略比较紧你打开 Swagger 都会把配额消耗掉一大截更不用说去测接口了。我这篇文章写完前就接到过一个类似的咨询现象是“用浏览器访问 10 次就触发 429用 Postman 却没事”最后定位到 favicon.ico 和 Source Map 一类静态请求上。2.4 第四名客户端重复提交与自动重试这类问题更偏前端或网关但同样能让剩余数“跳 2”。常见情况有提交按钮没有在请求发出后立即禁用用户连点两下后端收到两个相同请求axios 或 fetch 配了超时重试第一个请求超时后自动重发网关层Nginx、应用网关对 5xx 或超时请求做了自动重试前端在路由守卫、生命周期函数里重复触发了同一接口从后端视角看每个请求都是正常的、合法的配额也是一个一个减但用户的角度就是“我只点击了一次”。这里没有任何魔法纯粹是同一个动作产生了两次请求。2.5 第五名多策略叠加共用一套响应头最后这类根因最容易被当成“玄学”你用的是自定义 RateLimiter而且同时注册了多个策略比如同时用 FixedWindow 和 SlidingWindow或者自研限制器里嵌套了另一个限制器。每个限制器都独立计数独立写响应头。多个 limit header 合并到最后时你只能看到一个剩余数但配额消耗是叠加的。简而言之只要请求管道里存在一个以上的“计数点”并且这些计数点都往 X-Rate-Limit-Remaining 里写值现象就一定是“减 2 或减更多”。所以排查的核心就是把请求管道里到底有几个计数点、每次点击会产生几个请求全部找出来。3. 排查实录三步把隐藏请求抓出来下面这组排查步骤是我在实际定位这类问题时反复用到的效率很高。你也按这个顺序做基本上十分钟内能找到根因。3.1 第一步用 Postman 或 curl 做对照试验先把浏览器因素摘出去。用 Postman 或 curl 直接请求同一个接口连续请求两次观察 X-Rate-Limit-Remaining 的递减规律curl -i http://localhost:5000/api/order如果 curl 场景下每次也是减 2那问题基本可以锁定在后端管道本身双注册、双重策略、或者多限制器。如果 curl 场景下是正常减 1而浏览器场景下减 2那问题就在浏览器自动请求上优先看 OPTIONS 和 favicon。这个对照试验太关键了能把你排查范围砍掉一半。3.2 第二步打开浏览器 Network 面板数请求确认了是浏览器环境的问题之后按 F12 打开 DevTools切到 Network 面板在 Fetch/XHR 过滤下点击一次业务按钮。逐条看请求列表重点找 OPTIONS 请求。我敢说大部分“点击一次减 2”的现场都能在这里看到一条 OPTIONS 加一条 POST 或 GET 的记录。同时看一眼有没有 favicon.ico 和其他自动发起的请求。如果 Network 里只有一条业务请求但后端日志里出现了两条那就要考虑网关重试或前端代码层面的重复触发了。3.3 第三步在后端加临时审计日志如果你觉得看请求列表不够直观可以在 Program.cs 里加一段临时日志中间件把每个请求的方法、路径、剩余头实时打印出来。我通常这样写app.Use(async (context, next) { await next(); if (context.Response.Headers.TryGetValue(X-Rate-Limit-Remaining, out var remaining)) { Console.WriteLine($[RATE] {context.Request.Method} {context.Request.Path} Remaining: {remaining}); } });注意这段中间件的注册位置。放在 app.UseRateLimiter() 之前也可以因为 await next() 执行完毕后会回到这里此时响应头已经被限流中间件写入。运行一次点击操作你会看到日志里列出了所有进入管道的请求。如果同一秒内同时出现 OPTIONS 和 POST或者出现两条相同的业务请求那根因就一目了然。3.4 第四步扫一遍有没有重复注册最后回到代码本身在 Program.cs 和 Startup.cs 里搜两样东西UseRateLimiter 出现了几次是否同时配置了 GlobalLimiter 和 EnableRateLimiting 端点策略如果 UseRateLimiter 出现两次直接删掉其中一个。如果既有全局策略又有端点策略就要考虑合并成一个计数源方法我在下面展开。4. 实战修复让剩余数恢复“一次减一”找到根因之后修复方案就清晰了。这里给出几套我实际用过的做法按场景选即可。4.1 拆掉双限流只保留一个计数源如果是全局加端点双重限流导致的问题最稳妥的做法是选一个作为唯一计数源把另一个去掉。业务上需要整体限流就用全局个别接口需要更严格限制就单独建策略不要两个同时作用于同一个请求。一个比较推荐的统一配置方式是只用全局分区限流把不同接口都映射到不同的分区策略。分区可以按路径、用户、IP、API Key 任意组合builder.Services.AddRateLimiter(options { options.RejectionStatusCode StatusCodes.Status429TooManyRequests; options.GlobalLimiter PartitionedRateLimiter.CreateHttpContext, string(context { var path context.Request.Path; if (path.StartsWithSegments(/api/auth)) { return RateLimitPartition.GetFixedWindowLimiter(auth-api, _ new FixedWindowRateLimiterOptions { PermitLimit 20, Window TimeSpan.FromMinutes(1), AutoReplenishment true }); } if (path.StartsWithSegments(/api/order)) { return RateLimitPartition.GetFixedWindowLimiter(order-api, _ new FixedWindowRateLimiterOptions { PermitLimit 100, Window TimeSpan.FromMinutes(1), AutoReplenishment true }); } return RateLimitPartition.GetNoLimiter(other); }); }); app.UseRateLimiter();这样请求无论打到哪个接口都只经过一个全局限流器它内部按路径分区计数。剩余头也只用写一次递减一定是 1。这种写法的好处是管道里只有一个中间件逻辑清晰排查也容易。4.2 给预检请求单独分流如果你确认减 2 是 OPTIONS 请求导致的那么最简单的方式是在分区策略里把 OPTIONS 请求分到一个无限制分区或者直接让它走“无限制”的 RateLimiter 逻辑。options.GlobalLimiter PartitionedRateLimiter.CreateHttpContext, string(context { if (HttpMethods.IsOptions(context.Request.Method)) { // 预检请求不计入业务配额 return RateLimitPartition.GetNoLimiter(preflight); } var clientKey context.User.Identity?.IsAuthenticated true ? context.User.Identity.Name! : context.Connection.RemoteIpAddress?.ToString() ?? unknown; return RateLimitPartition.GetFixedWindowLimiter(clientKey, _ new FixedWindowRateLimiterOptions { PermitLimit 100, Window TimeSpan.FromMinutes(1), AutoReplenishment true }); });这里用到了RateLimitPartition.GetNoLimiter这个 API 在 .NET 8 中可用。如果你还在用 .NET 7可以退而求其次给预检请求分一个 PermitLimit 为 int.MaxValue 的固定窗口或者直接写一个自定义的 RateLimiter 返回 AllowLimit 不消耗配额。4.3 用中间件管道筛选直接不让 OPTIONS 进限流除了分区策略分流还可以用 UseWhen 让限流中间件只处理非 OPTIONS 请求。App 管道写法如下app.UseWhen( context !HttpMethods.IsOptions(context.Request.Method), appBuilder appBuilder.UseRateLimiter());这种做法的好处是简单直接OPTIONS 请求根本不会走到 RateLimiter 里自然也不会消耗配额。缺点是它把限流逻辑限定在分叉管道里如果后面还单独写了 app.UseRateLimiter() 就会冲突。我的建议是二选一不要同时用。4.4 把 favicon 和静态资源请出限流名单同理在分区策略里把 /favicon.ico、/assets、/css、/js、/swagger 等路径排除即可if (context.Request.Path.StartsWithSegments(/favicon.ico) || context.Request.Path.StartsWithSegments(/assets) || context.Request.Path.StartsWithSegments(/swagger)) { return RateLimitPartition.GetNoLimiter(static); }更推荐的做法是用静态文件中间件 app.UseStaticFiles() 优先处理静态资源并且把限流中间件放在它后面。但静态文件中间件只处理存在的物理文件像 /favicon.ico 这类没有真实文件的请求还是会继续往下走所以路径判断还是最稳妥的。4.5 处理客户端重复提交如果根因在客户端那要做的就是防重复。最基础的是在点击后立刻禁用按钮请求结束再恢复button.disabled true; try { await fetch(/api/order, { method: POST }); } finally { button.disabled false; }另外也可以给请求加一个幂等 key服务端根据 key 判断是否为重复请求。网关层如果是 Nginx也要注意有没有配置超时重试。我见过一个生产事故就是 Nginx proxy_next_upstream 配置了 error timeout下游接口偶发超时后自动重试一次导致一个业务操作被后端执行两次连带配额多消耗一次。5. 一套可以直接落地的完整配置示例下面这段代码把“单一计数源 预检分流 静态资源排除”合并到一起是我目前比较推荐的模板。如果你是第一次配置限流中间件可以直接在此基础上改。var builder WebApplication.CreateBuilder(args); builder.Services.AddRateLimiter(options { options.RejectionStatusCode StatusCodes.Status429TooManyRequests; options.OnRejected async (context, token) { context.HttpContext.Response.Headers[Retry-After] 60; await Task.CompletedTask; }; options.GlobalLimiter PartitionedRateLimiter.CreateHttpContext, string(context { if (HttpMethods.IsOptions(context.Request.Method)) { return RateLimitPartition.GetNoLimiter(preflight); } if (context.Request.Path.StartsWithSegments(/favicon.ico) || context.Request.Path.StartsWithSegments(/assets) || context.Request.Path.StartsWithSegments(/swagger)) { return RateLimitPartition.GetNoLimiter(static); } var clientKey context.User.Identity?.IsAuthenticated true ? context.User.Identity.Name! : context.Connection.RemoteIpAddress?.ToString() ?? unknown; return RateLimitPartition.GetFixedWindowLimiter(clientKey, _ new FixedWindowRateLimiterOptions { PermitLimit 100, Window TimeSpan.FromMinutes(1), AutoReplenishment true, QueueLimit 0 }); }); }); var app builder.Build(); app.UseRateLimiter(); app.MapGet(/api/order, () Results.Ok(new { code 0, data ok })); app.Run();在这个配置下正常业务请求每分钟最多 100 次OPTIONS 预检和静态资源不占配额所有请求只有一个限流计数点。实测连续点击时X-Rate-Limit-Remaining 会按 99、98、97 的顺序递减不会出现跳 2 的情况。5.1 验证修复是否生效改完配置后我习惯做一轮三重验证第一轮curl 连续请求 10 次观察响应头剩余数递减规律确认每次减 1第二轮浏览器打开页面点击业务按钮打开 Network 面板确认请求列表里没有多余的 OPTIONS 请求第三轮检查后端日志确认一个点击动作只对应一条请求记录如果三轮都通过这个问题就算彻底解决了。5.2 限流中间件的常见误区速查表我把这次排查过程中遇到过的所有现象整理成一张表方便你以后对照现象可能根因处理方式浏览器点击减 2Postman 点击减 1CORS 预检 OPTIONS 进入限流给 OPTIONS 分流或排除浏览器打开首页就消耗多个配额favicon.ico 或静态资源排除静态路径Postman 和浏览器点击都减 2全局端点双策略或 UseRateLimiter 注册两次合并为单一计数源或删除重复注册偶发减 2日志出现两条相同请求客户端重复提交、网关重试按钮防抖、幂等设计、调整网关重试策略请求被 429 后重试也消耗配额客户端自动重试重试策略要遵守 Retry-After不要立刻重发5.3 分区 key 的选择与几个易踩的坑最后补充一个日常容易踩的坑分区 key 的选择。很多人喜欢直接按客户端 IP 做分区这确实最简单但在公司网络或小区宽带场景下一个出口 IP 可能对应几十个操作人员一个人猛烈请求会把所有人都限住。按用户身份分区也有坑未登录用户会全部落在一个 anonymous 分区里互相影响。我常用的做法是登录用户用 User ID未登录用户用 IP组合成复合 key。就像上面代码里那样先判断有没有认证再决定用什么作为 key。另一个容易忽视的参数是 QueueLimit。如果你在 FixedWindowRateLimiterOptions 里把 QueueLimit 改成了大于 0那么超过窗口配额的请求会进入排队而不是直接拒绝。排队中的请求不算剩余次数但一旦排队成功并被处理它会占用后续窗口的配额现象会变得很诡异。所以很多 API 前置限流都不建议开排队直接让超出的请求拿 429 反而更符合直觉。还有 AutoReplenishment 这个参数它代表是否需要后台定时重置窗口。固定窗口和滑动窗口都强烈建议设置为 true。生产环境如果图省事设成 false窗口配额一旦用完就不会自动恢复除非调用代码手动触发重置那等于是把限流器变成了永久熔断器。最后分享一个我自己的排查习惯凡是看到剩余数“跳着减”我从来不先去翻算法源码而是先回答两个问题——请求管道里有几个限流器一个点击动作产生了几条请求这两个问题答案明确之后问题基本就已经解决一半。剩下的就是对着上面的速查表做减法。希望这篇也能帮你少走几个小时的弯路。