
1. 为HTTP API加上闸门限流诉求与方案边界1.1 限流要解决的三个实际问题做接口的时候我几乎不会早晚去主动考虑限流直到第一次在线上出了事故。某个促销节点的查询接口突然被几倍的流量打进来数据库连接池瞬间耗尽整个应用跟着假死和它对端的服务也一路连环超时。事后复盘问题不在SQL慢也不在代码性能而是没有一个闸门来控制进入后端的请求速度。限流真正要解决的事远远不止防刷这一项。第一是保护下游资源数据库连接、外部HTTP调用、文件IO、消息队列生产端这些资源都有明确的吞吐上限超出之后不是变慢而是崩溃。第二是平滑突发流量业务侧可能在某一个秒级窗口内涌入大量请求但应用本身的扩容是分钟级的需要限流把超出的部分挡在外面。第三是隔离故障当某个客户端或某个调用方出现异常重试风暴时限流能把它对整体系统的影响控制在局部其它正常用户不受牵连。明白了这些场景你就不难理解为什么限流不是做一个功能而是给系统设计一道可调节的闸门。这道闸门放行多少、拒绝多少、以什么粒度区分调用方、超出的请求是排队还是直接拒绝这些决策背后都有明确的业务含义。本文以.NET 7和.NET Core Web API为背景从内置中间件、手写实现、分布式方案到压测调参完整走一遍生产可用的限流落地路线。适合正在给现有API补限流、或者从零设计高并发服务的开发者参考。1.2 算法选型速览固定窗口、滑动窗口、令牌桶、漏桶限流算法是这个领域最基础也最容易混淆的部分。很多人以为选个中间件装上就完了其实算法选错了线上表现会差很多。固定窗口是按固定时间长度比如10秒分割成多个窗口每个窗口内只放行N个请求。实现很简单一次内存计数就能搞定。但它有一个无法忽略的缺陷窗口边界处的突发流量。假设窗口是10秒第10秒最后的5个请求和第11秒开始的前5个请求实际间隔只有几毫秒却被分到了两个窗口各自放行短时间内容易打出两倍于阈值的流量。滑动窗口是对固定窗口的修正。它不是按整块的时间切片计数而是记录每个请求的时间戳统计当前时间点往前推一个时间窗口内有多少请求。精度高突发被摊平代价是需要存储请求时间戳内存占用高于固定窗口。生产环境里很多库提供的滑动窗口其实是滑动日志的近似版本用子窗口聚合来降低存储成本。令牌桶的思路完全不同。有一个容量为N的桶按照固定速率往里放令牌每个请求必须拿到一个令牌才能放行。桶里攒下的令牌允许短时间内的突发流量同时又能约束长期平均速率是目前互联网服务用得最广的算法。实现时需要注意令牌的补充时机和桶容量的配合稍后讲Redis实现时再展开。漏桶更像是恒定速率的管道。请求像水滴一样进入管道管道以固定的速率向下滴水无论你以多快的速度倒水进去出水的速率都不变。漏桶能严格削峰完全不允许突发但代价是体验上不够灵活日常API场景用得较少更多出现在流量整形和限流日志等需要绝对平滑的场景。算法允许突发平滑程度存储开销实现复杂度典型场景固定窗口边界处可翻倍差极低低简单防护、内部服务滑动窗口较平滑中中中用户登录、短信接口令牌桶可配置好低中常规API、网关限流漏桶几乎无最好低中高流量整形、消息发送选型上我的习惯是单机小规模的临时保护用固定窗口绝大多数需要落库或稳定控制的API用令牌桶对计数精度有洁癖的用滑动窗口只是做下游保护不让它打爆的用漏桶。1.3 从.NET Core到.NET 7限流基建有什么变化早期做.NET Core限流要么引入第三方库要么自己写中间件。第三方库的问题是和框架版本绑定程度高自己写的又容易在并发、时间轮转这些细节上踩坑。.NET 7在这一块的改变是决定性的微软把System.Threading.RateLimiting正式纳入了框架提供了RateLimiter抽象、FixedWindowRateLimiter、SlidingWindowRateLimiter、TokenBucketRateLimiter、ConcurrencyLimiter四种内置算法实现同时为ASP.NET Core提供了AddRateLimiter和UseRateLimiter的中间件接入方式。这套东西的核心价值不是多了几个类而是把限流纳入了框架级的标准抽象。底层算法和HTTP管道解耦你可以在一个纯控制台程序里测试限流逻辑也可以在Web API里、在SignalR的Hub里、在后端服务调用里分别复用同一个RateLimiter实例。分区限流Partition的设计也让按IP限按用户限按接口限这类常见需求有了统一的表达方式。. NET 7还引入了MetadataName.RetryAfter这样的约定元数据限流器在被拒绝时可以把建议多久之后重试随拒绝结果一起带出来方便中间件把它写进HTTP响应的Retry-After头。这些细节说明限流不是孤立的技术点而是HTTP服务端和客户端之间需要协作的协议行为。下面我先把内置中间件的用法完整过一遍再讲手写和分布式扩展。2. 用内置RateLimiter中间件最快的接入路径2.1 注册与启用中间件的完整代码在.NET 7的Web API项目里内置限流接入只要两段代码一段在Program.cs里注册服务一段在管道里启用中间件。这里以一个最低可用的固定窗口限流为例var builder WebApplication.CreateBuilder(args); builder.Services.AddRateLimiter(options { options.RejectionStatusCode StatusCodes.Status429TooManyRequests; options.AddFixedWindowLimiter(default, fixedOptions { fixedOptions.PermitLimit 100; fixedOptions.Window TimeSpan.FromSeconds(10); fixedOptions.QueueLimit 0; }); }); var app builder.Build(); app.UseRateLimiter(); app.MapGet(/weather, () Results.Ok(new { City Beijing, Temp 28 })); app.Run();AddFixedWindowLimiter是最省事的入口第一个参数是策略名后面的配置项里PermitLimit是窗口内最大放行的请求数Window是窗口长度QueueLimit是允许排队等待的请求数。上面的配置表示每10秒最多放行100个请求超出后HTTP状态码返回429不排队直接拒绝。需要注意的是中间件在管道中的位置。app.UseRateLimiter()必须放在需要限流的终结点映射之前。假如你在认证中间件UseAuthentication之后、UseAuthorization之前调用它那么限流拿到的HttpContext.User会包含登录身份适合做按用户限流如果放在最前面就只能拿到IP和路由信息。管道顺序直接决定你能基于什么维度来分区这是很多人漏掉的细节。2.2 分区限流按IP、按用户还是按全局上面那种直接AddFixedWindowLimiter的方式是全局限流所有请求共享同一个计数器。真实场景里一个IP的爬虫或一个异常用户不应该拖垮全部流量所以需要分区。内置的AddPolicy就是把HttpContext映射到某个限定分区的方式builder.Services.AddRateLimiter(options { options.AddPolicy(per-ip, httpContext RateLimitPartition.GetFixedWindowLimiter( partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? unknown, factory: _ new FixedWindowRateLimiterOptions { PermitLimit 30, Window TimeSpan.FromSeconds(10), QueueLimit 0 })); });RateLimitPartition.GetFixedWindowLimiter的第一个参数是分区键第二个参数是根据分区键创建具体限流器的工厂方法。这里按RemoteIpAddress做分区每个IP拥有独立的30次/10秒额度即便某个IP疯狂刷其它IP完全不受影响。分区键的选择直接决定隔离粒度。按IP适合无登录态的场景但要注意同一出口NAT比如公司统一出口网关会共享同一个IP几个办公室上百人互相挤占额度。按用户user.Id或ClaimsPrincipal里的名称更适合有登录体系的业务API但匿名用户无法区分通常要配合一个匿名用户分区和IP分区兜底。按接口路由分区适合管理对不同业务线的差异化配额但防不住某个IP单独刷爆接口。我的建议是组合使用外层按IP内层按用户或接口分区键设计成${ip}:{route}“这样的复合键在生产中最耐用。2.3 队列与AutoReplenishment的行为细节内置限流器带了一个QueueLimit配置它让超限的请求不是立刻被拒绝而是进入队列等待。很多第一次用的人误以为队列能提升吞吐实际恰恰相反队列只是把请求往后推延如果窗口内额度已经被占满后续请求会堆在队列里等下一个窗口释放额度后继续处理。设置QueueLimit5意味着最多有5个请求等待第6个超出的请求仍然立即拒绝。队列行为里有个重要配置叫QueueProcessingOrder默认是OldestFirst也就是先进入队列的先被处理。若改成NewestFirst新请求会插队老请求一直等待直到超时。这个选项在实现最新数据优先的场景比如实时状态查询有意义但常规业务不建议改默认值否则容易造成等待请求的饥饿。AutoReplenishment是另一个容易误会的点。它的默认值是true表示限流器内部会自动按时补充额度如果手动设为false你就需要自行创建ReplenishingRateLimiter或调用AttemptAcquire之外的补充接口。手动补充适合需要和业务状态联动的情况比如充值后立刻恢复额度。绝大多数场景保持默认即可手动补充会引入更多并发边界问题不值得为了省几个计时器回调而去动它。内置中间件最大的优势是省心策略注册、分区管理、拒绝状态码都替你安排好了。但它也有限制——内存实现只在单进程内有效。如果你的服务部署了多个副本每个实例独立计数流量在负载均衡下分摊到多个实例总放行数会变成单实例阈值乘以实例数限流形同虚设。这正是我们接下来要处理的问题。3. 手写固定窗口中间件不引第三方包的轻量方案3.1 基于ConcurrentDictionary的计数实现内置中间件足够应付很多场景但有时候你不想引入框架级依赖或者只是想在某个老项目里快速加一道防护手写一个小中间件反而更轻量。固定窗口的实现思路很直接每个分区键保存一个包含窗口开始时间和窗口内计数的对象请求进来时判断当前时间是否已经超出窗口超出就重置计数。完整实现大致是这样public class FixedWindowLimiterMiddleware { private readonly RequestDelegate _next; private readonly ConcurrentDictionarystring, WindowCounter _counters new(); private readonly int _limit; private readonly TimeSpan _window; public FixedWindowLimiterMiddleware(RequestDelegate next, int limit, TimeSpan window) { _next next; _limit limit; _window window; } public async Task InvokeAsync(HttpContext context) { var key context.Connection.RemoteIpAddress?.ToString() ?? unknown; var now DateTimeOffset.UtcNow; var counter _counters.GetOrAdd(key, _ new WindowCounter { Start now }); lock (counter.Lock) { if (now - counter.Start _window) { counter.Start now; counter.Count 0; } if (counter.Count _limit) { context.Response.StatusCode StatusCodes.Status429TooManyRequests; return; } counter.Count; } await _next(context); } private class WindowCounter { public DateTimeOffset Start; public int Count; public readonly object Lock new(); } }调用时在管道里加上app.UseMiddlewareFixedWindowLimiterMiddleware(30, TimeSpan.FromSeconds(10));这个实现能直接跑但有几个细节你必须注意。第一ConcurrentDictionary的GetOrAdd本身是线程安全的但返回的对象读写不是线程安全的所以我用lock把窗口判断和计数递增包在一起防止并发下同一窗口内出现重复放行。第二窗口重置和计数递增必须表现为一个原子操作否则会出现两个线程同时读到旧窗口又同时重置的竞态。第三字典里的条目永远不会被清理长时间运行下会随着IP数量增长一直膨胀生产环境最好定期清理超过一个窗口未活跃的键或者在分区键本身有界的情况下比如固定的几个内部服务才使用。3.2 固定窗口在边界处的倍数冲击陷阱手写实现最大的价值不是代码本身而是让你直观看到固定窗口的问题它只在窗口起点到终点的范围内做计数但窗口边界两侧的请求却可能连接紧密地越过阈值。举例来说窗口为10秒阈值为100。在第9.999秒来了99个请求第10秒整窗口重置又来了99个请求。这198个请求实际都集中在不到几毫秒的时间段内但分别命中了前后两个窗口的配额全部放行。对下游数据库来说这等于在一瞬间打出了接近两倍于设计上限的流量。所以当你用固定窗口时阈值必须留有余量。比如你的下游能扛100 QPS固定窗口的请求阈值不要设100设6070比较稳妥。这个经验不是我空想出来的是实际压测时看到数据库连接数瞬间翻倍后总结出来的。如果你对精确性有更高要求别用固定窗口直接用滑动窗口。3.3 精简版滑动窗口用时间戳列表替代计数滑动窗口的朴素实现是给每个分区键维护一个请求时间戳列表每次请求到达时把窗口范围之外的时间戳剔除然后判断列表长度是否超过阈值。这种实现代码比固定窗口复杂不了太多public class SlidingWindowLimiterMiddleware { private readonly RequestDelegate _next; private readonly ConcurrentDictionarystring, ConcurrentQueueDateTimeOffset _requests new(); private readonly int _limit; private readonly TimeSpan _window; public SlidingWindowLimiterMiddleware(RequestDelegate next, int limit, TimeSpan window) { _next next; _limit limit; _window window; } public async Task InvokeAsync(HttpContext context) { var key context.Connection.RemoteIpAddress?.ToString() ?? unknown; var now DateTimeOffset.UtcNow; var queue _requests.GetOrAdd(key, _ new ConcurrentQueueDateTimeOffset()); while (queue.TryPeek(out var ts) now - ts _window) queue.TryDequeue(out _); if (queue.Count _limit) { context.Response.StatusCode StatusCodes.Status429TooManyRequests; return; } queue.Enqueue(now); await _next(context); } }注意这里queue.Count和Enqueue之间没有原子锁高并发下可能超出几个请求。要严格精确的话同样需要lock包裹整个剔除、判断和入队的操作。时间戳队列的缺点是内存占用窗口期内每个请求一条记录如果窗口长、阈值大内存压力会显著高于固定窗口。实际生产里建议做一个折中把1秒窗口划分成若干子窗口按子窗口聚合计数既保留滑动窗口的平滑度又控制内存开销。这种方案业界叫做滑动窗口计数器你可以在理解了朴素实现后自行扩展。手写方案的适用边界很清晰单实例、快速接入、不引入额外框架依赖。一旦走入多实例部署手写内存方案就无能为力了。4. 集群不放假基于Redis的分布式限流实战4.1 为什么单机内存限流在多实例下会漏放假设服务部署了3个实例每个实例的固定窗口阈值是100次/10秒负载均衡把请求分摊到各实例。某个时刻总流量到达100个请求很可能每个实例各自收到3040个没有任何一个实例触达自己的100阈值于是全部放行。等到总流量到300左右实例才可能各自达到阈值。也就是说系统实际承载能力变成了单实例阈值乘以实例数限流阈值被悄悄放大三倍。这不是配置错误而是内存计数器的天然局限——它只能看到本进程内的请求。要全局一致地控制放行总量计数器必须放到所有实例共享的地方。Redis就是这个共享计数器最常用的载体。但Redis本身就是外部依赖所以设计分布式限流时必须权衡限流失败Redis不可用和限流生效拒绝请求之间的策略要怎么选我通常选择倾向放行——Redis抖动时宁可多放一些请求也不能让整个服务因为拿不到计数器而全部拒绝前提是下游扛得住短暂冲击。4.2 Lua脚本保证原子性固定窗口落地方案用Redis实现固定窗口的核心是INCR计数 EXPIRE设置窗口过期。这里最大的坑是INCR和EXPIRE必须原子执行否则两个请求同时进来一个执行了INCR另一个还没来得及执行计数就乱了。Redis的EVAL执行Lua脚本天然具备原子性所以标准做法是把两个操作放进一段脚本local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local current redis.call(INCR, key) if current 1 then redis.call(EXPIRE, key, window) end if current limit then return 0 end return 1对应的C#调用代码如下使用StackExchange.Redispublic class RedisFixedWindowLimiter { private readonly IDatabase _db; private readonly string _script; public RedisFixedWindowLimiter(IDatabase db) { _db db; _script local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local current redis.call(INCR, key) if current 1 then redis.call(EXPIRE, key, window) end if current limit then return 0 end return 1 ; } public async Taskbool AllowAsync(string key, int limit, TimeSpan window) { var result await _db.ScriptEvaluateAsync(_script, new RedisKey[] { key }, new RedisValue[] { limit, (int)window.TotalSeconds }); return (int)result 1; } }这里有个值得注意的细节键的命名要带上窗口时间戳比如ratelimit:{ip}:{当前窗口起点UnixTime}。如果只用固定键名每次窗口重置时必须显式重置计数不如直接把窗口起点写进键名来得干净。窗口起点可以取UnixTimeSeconds / windowSeconds乘以windowSeconds的结果这样每个窗口自然对应一个独立的键走了就过期删除。还有一点EXPIRE只在current 1时设置是为了避免频繁调用EXPIRE带来的额外开销。但只要第一个请求触发了过期设置该窗口键的存活时间就是从第一个请求到第一个请求加窗口长度早期到达的请求会提前过期。严格的话可以在每次计数后都设置EXPIRE但要付出多余的Redis调用成本。这个取舍取决于你对窗口精度的要求。4.3 令牌桶的Redis实现与参数换算固定窗口在分布式场景已经能解决大部分问题但边界突发问题依旧存在。如果业务要求平滑限流应该考虑令牌桶。Redis里实现令牌桶的思路是用一个Key保存桶的令牌数和上次补充时间请求到达时先按时间差补令牌再判断是否足够。对应的Lua脚本local key KEYS[1] local capacity tonumber(ARGV[1]) local refillRate tonumber(ARGV[2]) -- 每秒补充令牌数 local requested tonumber(ARGV[3]) local now tonumber(ARGV[4]) local bucket redis.call(HGETALL, key) local tokens capacity local lastRefill now if #bucket 0 then tokens tonumber(bucket[2]) lastRefill tonumber(bucket[4]) end local elapsed now - lastRefill tokens math.min(capacity, tokens elapsed * refillRate) if tokens requested then tokens tokens - requested redis.call(HSET, key, tokens, tokens, lastRefill, now) redis.call(PEXPIRE, key, math.ceil(capacity / refillRate * 1000)) return 1 end return 0C#调用时now需要使用毫秒时间戳因为refillRate按秒补充时间差小于1秒时计算出的令牌数会是0会导致限流器过于迟钝。将refillRate换算为每秒补充量是令牌桶参数设计的核心。假如你希望长期平均速率是100 QPS桶容量是50那么refillRate就是100单个瞬时突发最多50个令牌。这组配置的含义是平均每秒100个请求稳定放行最激进的突发不超过50个。反之若桶容量设成200而refillRate保持100高峰期会在很短时间内放出大量请求虽然长期平均不变但突发幅度变大。所以桶容量决定突发上限补充速率决定长期平均速率这两个参数一定要分开来设不要只调其一。Redis方案需要注意的另一点是网络开销。每个请求一次到两次Redis操作脚本执行可能包含多个命令在超高QPS下Redis本身可能成为瓶颈。业界常见的优化是使用本地内存预判比如先通过Redis拿到配额在本地缓存一个短时间窗口毫秒级的额度期间直接本地放行减少Redis调用频率。这类优化适合有明确性能瓶颈的场景普通规模不必过度设计。5. 不要只给一个429限流响应和前端协作细节5.1 状态码语义与Retry-After头限流生效时返回什么状态码、什么响应体直接决定了客户端能不能正确理解并恢复。最常见的错误是返回429 Too Many Requests之外的语义不明的状态码比如400或500。正确的做法是明确区分请求本身不合法和请求数量过多。429是HTTP标准里专门为限流设计的语义前端、网关、浏览器都能识别所以内置中间件默认的RejectionStatusCode就是429。光有429还不够客户端最想知道的是我什么时候可以重试。HTTP规范里有Retry-After响应头字段值可以是秒数也可以是HTTP日期。返回Retry-After: 5表示请5秒后再试。内置限流器在被拒绝时内部的Lease会携带MetadataName.RetryAfter元数据你可以通过OnRejected回调把它写入响应头builder.Services.AddRateLimiter(options { options.OnRejected async (context, cancellationToken) { context.HttpContext.Response.StatusCode StatusCodes.Status429TooManyRequests; if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) { context.HttpContext.Response.Headers.RetryAfter ((int)retryAfter.TotalSeconds).ToString(); } await context.HttpContext.Response.WriteAsJsonAsync( new { Message 请求过于频繁请稍后重试。 }, cancellationToken); }; });这里context.Lease是限流器放行/拒绝后的结果对象。TryGetMetadata能取到建议的等待时间写进Retry-After后设计良好的客户端会自动做退避重试而不是死磕请求。5.2 用OnRejected定制拒绝内容内置中间件默认在拒请求时直接返会空白页面或简单状态码对API消费者不够友好。生产环境里强烈建议通过OnRejected统一返回结构化的Json错误体。除了Retry-After头响应体里可以包含当前时间戳、请求ID、错误码方便前端和运维快速定位。一个可用的错误体结构大概是{ code: RATE_LIMIT_EXCEEDED, message: 请求过于频繁请在5秒后重试, retryAfterSeconds: 5, requestId: abc-123-def }前端拿到429后不应该简单弹出服务器错误而应该显示操作太频繁请稍后再试同时根据Retry-After禁用按钮或倒计时。被动等用户手动重试只是兜底显式告知等待时间能大幅提升用户体验。我在实际项目中还发现一个容易踩的坑OnRejected里如果再次抛异常或者写响应时发生异常会导致连接被中断前端收到的是空响应而不是429。所以OnRejected回调内部一定要做防御性编码不要在这个回调里做数据库查询、远程调用之类的耗时操作它应该只负责组装响应。5.3 给白名单、内部服务和健康检查开绿灯不是所有请求都应该被限流。健康检查探针、内部微服务之间固定频率的调用、以及管理员后台操作往往不能被API网关层的限流误伤。设计限流策略时要在分区键或策略注册阶段把这些高信任的调用方识别出来。最简单的做法是配置多个策略然后在AddPolicy中根据条件跳转options.AddPolicy(mixed, httpContext { var path httpContext.Request.Path.Value ?? ; if (path.StartsWith(/health, StringComparison.OrdinalIgnoreCase)) { return RateLimitPartition.CreateNoLimiter(health-check); } if (httpContext.Request.Headers.TryGetValue(X-Internal-Token, out var token) token your-internal-secret) { return RateLimitPartition.CreateNoLimiter(internal-service); } return RateLimitPartition.GetFixedWindowLimiter( httpContext.Connection.RemoteIpAddress?.ToString() ?? unknown, _ new FixedWindowRateLimiterOptions { PermitLimit 100, Window TimeSpan.FromSeconds(10), QueueLimit 0 }); });CreateNoLimiter是不限流的特殊策略。用它做白名单比把所有白名单IP列表在每次请求时查一遍高效得多而且语义更清晰。健康检查这个点特别要小心容器编排平台的探针频率往往不低如果你把探针请求也计入限流实例可能因为探针超时而被判定不健康进而被重启引发连锁故障。这类问题在实施限流后很常见属于加了限流反而制造事故的典型坑。另外就算白名单内不限流也建议把健康检查路径放在UseRateLimiter之外或使用独立策略避免中间件参数解析本身增加不必要的开销。性能上这是微优化但在高密度的容器环境中这样的细节会让运维省很多心。6. 上线前的压测与阈值调参经验6.1 用并发请求验证计数逻辑限流器写完之后第一件事不是上线而是压测验证逻辑对不对。我习惯用简单的并发脚本先打一波看计数是否和预期一致。以固定窗口为例阈值设为10次/10秒用并发请求打20个请求断言最终成功的请求数恰好是10个其余返回429。实际压测中你会发现一个容易被忽略的问题本地用localhost的多个并发请求RemoteIpAddress可能是::1所有请求共享同一个分区键计数互相折叠。这在验证逻辑上没问题但如果你只按IP分区同一台测试机上跑出来的结果不能代表真实的多用户场景。压测时要模拟不同的源IP或者干脆用用户分区来验证。另一个压测重点是窗口重置行为。在窗口边界附近打请求确认重置后的第一个请求不会被误判为超过阈值。对于手写滑动窗口实现尤其要验证窗口内请求时间戳的剔除逻辑——我见过不少实现因为时间戳精度问题比如用DateTime.Now而没用UTC在日志和边界判断上出现偏差。压测工具上我喜欢先用dotnet-counters观察进程内的内存和线程使用再用并发工具从外部打流量。你会发现限流本身的开销其实很小真正的瓶颈往往在写日志、判断分区键时的字符串拼接、以及Redis往返耗时上。能把这些热点提前定位出来上线后心里就有底。6.2 单位时间内平滑突发的取舍压测不只是验证正确性还要帮你确定阈值。阈值的设定经常被拍脑袋决定但正确的思路应该从下游能力反推。假设一个数据库连接池最大支持60个并发查询每个查询平均耗时50毫秒那它每秒钟理论能处理的请求数大约是60 / 0.05 1200。但这是理论上限真实环境还有锁竞争、慢查询、事务阻塞等问题一般按50%60%的利用率设阈值比较安全也就是700左右。这里有个和限流算法相关的关键概念阈值应该属于平均速率还是突发容量?如果你是令牌桶长期速率和突发容量可以分别设置如果是固定窗口你设的其实是每个窗口内允许的请求总量这个总量除以窗口长度就是平均速率。窗口越短对突发的容忍度越低窗口越长短期波动越大。实际业务里我们经常把窗口设为1秒、阈值为100这就近似100 QPS的平均速率比10秒窗口1000个请求更可控。平滑突发的另一个手段是把队列限流配置用起来。QueueLimit0时超出阈值的请求不是立刻被429而是进入队列延时处理相当于把尖峰流量削平后延时放行。队列长度和等待时间要联动考虑如果Window是10秒、QueueLimit是50最坏情况下最后一个排队请求要等10秒才能处理这对实时性要求高的接口是不可接受的。所以队列只适合对延迟不敏感的场景比如数据上报、消息推送。6.3 排查限流误伤的几个现场限流上线后最典型的故障是误伤正常用户。排查这类问题我有一个固定的排查顺序。第一步看日志里的分区键。限流中间件如果在OnRejected里记录了请求的IP、用户ID和路由就能快速判断是哪个分区被触发。经常出现的情况是某个办公楼出口IP的所有人都被限了因为NAT出口共享一个公网IP几百号人共用了一个IP的配额进而触发误伤。这种情况下把分区策略从纯IP改成IP加浏览器标识或者换成用户ID优先、IP兜底才合理。第二步看Retry-After和窗口时间是否匹配。我见过有人把Retry-After写死成60秒但窗口只有10秒客户端收到60秒后再重试白白损失了50秒的服务能力。反之写短了客户端重试太频繁又触发新一轮429。正确做法是直接从Lease元数据里读取实际等待时间保证客户端和限流器对时间语义理解一致。第三步看Redis侧的键生命周期。分布式限流里如果键没有正确设置PEXPIRE计数键永远不删除累积到几十万后Redis内存暴涨如果EXPIRE设置太保守又会提前过期导致限流失效。我习惯在Redis的monitor或日志里观察限流键的TTL变化确认它的生命周期和窗口一致。这个检查在手写脚本时尤其重要内置中间件或第三方库通常已经处理好了。说到排查最后提醒一句限流不是银弹它挡不住所有问题。如果下游因为SQL慢而响应变慢限流只会把压力挡住一部分但请求处理时间拉长Redis和内存计数器的判断仍然会基于已放行请求数而不是正在处理的并发数。这时候需要的是ConcurrencyLimiter并发限流器它专门限制同时处理的请求数和按时间窗口的限流是互补关系。.NET 7内置的AddConcurrencyLimiter可以直接用这属于另一个话题但你在设计整体保护策略时应该把它也纳入考虑。我自己的习惯是每个对外API都至少配一层基于速率的限流保护下游对内部依赖数据库或第三方服务的接口再加一层并发限制。两层配合无论是突发流量还是慢请求堆积都能兜住。方案落地之后记得把所有限流阈值写进配置中心而不是硬编码这样线上调整不需要重新编译发布。为参数留好调节空间比任何预测都靠谱。