半夜两点被电话叫醒说是订单接口挂了。我眯着眼打开笔记本监控图上那段红色的尖刺到现在都记得一分钟之内请求量翻了四十倍线程池全部被占满数据库连接池跟着被打穿连健康检查接口都在超时。后来查日志才知道不是攻击是上游系统重试风暴加促销流量刚好叠在一起。就是从那天起我开始认真做 WebApi 的限流最终折腾出了 YJFlowGuard 这套组件。这篇内容专门写给用 C# / .NET 写 WebApi、并且被流量打过或担心被流量打到的朋友。我会把限流这件事从头到尾拆开讲为什么非限流不可、算法怎么选、YJFlowGuard 怎么接进来、参数怎么配、多实例分布式场景下有哪些坑以及我压测时踩出来的几个边界问题。里面有完整的接入步骤和配置文件也能当一份限流组件的选型参考。1. 流量突刺的日常为什么WebApi非有限流不可很多人觉得限流是“大厂才需要做的事”小项目几百 QPS 根本用不上。这个想法我过去也有直到那次事故教做人。实际上流量突刺离普通项目并不远它不一定来自攻击更多时候来自业务本身和上下游的不确定性。1.1 流量为什么会突刺我总结了三个最常见的突刺来源都是真实发生过的。第一类是业务性波动。秒杀、大促、活动页上线这类流量特点是一瞬间爆发来得快去得也快。比如一个抽奖页面 10 点上线用户提前守在页面上疯狂刷新第一个请求进来后后面跟着的全是峰值。这类波动是可以预期的但规模往往超出预估。第二类是上游异常。最典型的是重试风暴。某个下游服务超时了上游网关按照策略 3 秒重试一次一秒钟其实只有几个请求但上游有几十个服务节点同时重试放大到你这儿就是几百倍流量。再比如第三方回调回调方网络闪断后会补推数据补推的时候往往是把一整段时间的数据集中推送过来直接撞穿接口。第三类是恶意或非恶意刷量。爬虫、脚本、竞争对手压测这些不一定想搞垮你但它们的请求特征是高频、低价值、不关心业务结果。如果接口没有护栏这类请求会持续消耗线程池和数据库连接。1.2 一个没有限流的典型故障链没有限流的 WebApi 是怎么被打死的我复盘那次事故时有条特别清晰的链条。流量进来后第一个被消耗的是 Kestrel 线程池。请求处理不过来线程池开始排队线程池队列深了以后新请求的等待时间迅速拉长。此时如果业务代码里还有同步等待下游调用的逻辑——比如查数据库、调第三方 HTTP——线程就会一直挂着不释放。线程池一旦饥饿连健康检查、日志上报这类轻量请求也跟着超时。接下来遭殃的是数据库连接池。连接池默认上限通常是 100 左右接口超时后连接不会立刻归还而是等待超时时间后才释放。请求越多连接被占用的时间越长连接的周转率越来越低最后池子被榨干。数据库连接池耗尽后所有依赖数据库的接口全部报错这不是慢慢变慢是瞬间雪崩。最致命的是恢复困难。流量风暴过去之后线程池和连接池不会立刻回满它们需要一个“喘息”的过程。但此时健康检查可能已经在报错负载均衡开始摘除节点摘除节点又触发上游重试形成二次风暴。整个系统在流量下降后还要挣扎很久才能缓过来。所以限流的意义不在于“挡住用户”而是让系统在极端流量下保留住处理核心请求的能力不至于整个进程死掉。1.3 限流、熔断、降级不是一回事这个必须掰开讲因为很多人把它们混为一谈导致方案设计时功能重叠、边界混乱。限流管的是入口流量核心是“控制单位时间内的请求数”它解决的是“来得太多怎么办”。熔断管的是出口调用核心是“下游已经不行了别再继续调用了”它解决的是“下游挂了怎么办”。降级管的是兜底体验核心是“核心服务不可用时给用户一个不那么差的响应”它解决的是“确实撑不住了怎么办”。三者的关系有点像家里的水系统限流是进水阀控制进水管流量熔断是漏水保护水管爆了自动关总阀降级是备用方案比如停掉热水只保留冷水。YJFlowGuard 明确只做“限流”这一件事熔断和降级交给其他组件或业务层去处理这样的边界最清晰也最容易维护。2. YJFlowGuard的定位为什么我放弃了几套现成方案决定认真做限流之后我不是一上来就写代码的先花了不少时间调研现成方案。市面上的选择其实不少但真正落到我的生产环境时多多少少都有让我难受的点。2.1 现成方案让我难受的几个点先说 AspNetCoreRateLimit这大概是 .NET 圈子里最常用的限流库了。它功能确实全IP 限流、客户端限流、并发限流都支持配置项也非常丰富。但用下来最大的问题是重。它的配置体系相当复杂规则、策略、标识解析器一整套小项目接进来光读文档就要一两天。而且它的计数器存储如果选内存模式多实例部署下各计各的限流形同虚设如果选 Redis 模式又需要自己维护额外的存储配置。滑动窗口的实现依赖后台定时清理任务精度和内存消耗的平衡也不是很好。再说反向代理层比如 Nginx 的 limit_req。这个方案的好处是性能极好不经过应用层但粒度太粗。它能按 IP 限能按 URL 限却很难按业务维度限比如“某个用户 ID 每分钟最多调用 10 次订单查询接口”这种规则落到 Nginx 配置里非常别扭。而且我们很多接口有鉴权、有参数校验真正需要限流的位置在业务语义层而不是网络入口。Java 生态里的 Sentinel 我研究过很久它的思路确实好流量控制、熔断降级、系统保护一体化还带控制台。但它和 .NET 的亲和度不高要用就得额外部署一套控制台和规则存储相当于给项目引入了一个外挂系统。在小团队、轻运维的场景下这个成本偏高了。2.2 组件要解决的四个问题调研一圈之后我确定了自己写一个组件的目标YJFlowGuard 要解决四个核心问题。第一进程内和分布式两种模式都要支持。单机部署时用内存计数零额外依赖多实例部署时切换到 Redis 计数一套 API 不变。第二算法要可插拔。固定窗口、滑动窗口、令牌桶、漏桶不能写死在中间件里配置里指定算法名就能切换因为不同接口对限流语义的要求不一样。第三规则要能热更新。改限流阈值不应该重启服务配置文件变了规则立刻生效。第四故障隔离。限流器绝对不能因为自身异常把业务拖垮Redis 挂了要有降级方案。这四个问题解决之后YJFlowGuard 的角色就清晰了它是个轻量中间件加一行注册代码就生效不侵入业务逻辑配置灵活故障不扩散。3. 限流算法选型固定窗口、滑动窗口、令牌桶与漏桶的取舍限流中间件的内核是算法。算法选的不好后面所有规则都是空中楼阁。我一开始也想简单了觉得限流不就是计数器嘛后来压测才理解为什么会有这么多种算法——每种算法本质上是在“精确性、内存开销、实现复杂度”三者之间做一个取舍。3.1 固定窗口和滑动窗口差在一个“边界”固定窗口是最朴素的实现把时间切成固定长度的小段比如一分钟一段每段维护一个计数器计数超过阈值就拒绝。实现简单到没有什么可出错的地方一个 ConcurrentDictionary 加一个定时清理就够。但固定窗口有个臭名昭著的边界问题。举个例子限制每分钟 100 次。假设用户在 10:00:59 秒发了 100 个请求这个窗口满了但实际上这个请求只用了 1 秒紧接着 10:01:00 新窗口开始他又发了 100 个请求。结果是在 2 秒内系统被打了 200 个请求而这 200 个请求都通过了限流。固定窗口的统计是“静态的窗口”它不关心请求在窗口内部如何分布。滑动窗口解决的就是这个问题。它的思路是把窗口再切成更小的粒度比如把 1 分钟切成 6 个 10 秒的槽位每个槽位独立计数窗口随时间向后滑动统计的是最近 1 分钟内的总请求数。这样 10:00:50 到 10:01:10 这 20 秒内的请求会被准确计算边界突刺就被抹平了。代价是每个 key 要维护多个槽位的计数内存占用更高计算逻辑也更复杂。YJFlowGuard 对这两种算法的默认策略是普通接口用滑动窗口因为它的统计最接近真实流量只有对精度要求不高、又特别追求性能的场景才建议用固定窗口比如访问量极大的静态资源接口。3.2 令牌桶与漏桶削峰还是填谷令牌桶算法是很多网关默认使用的方案思想是系统以固定速率向桶里放令牌桶有容量上限请求来了先取令牌取到就放行取不到就拒绝。它的特点是既限制了长期平均速率又允许一定程度的突发流量因为桶里可以积攒最多“容量”个令牌。这个特性非常契合大多数 WebApi 的语义平时流量低桶里令牌是满的大促流量来了可以先把积攒的令牌用完短时间内的突发是被允许的但长期来看速率还是被限制住的。所以令牌桶是我给 YJFlowGuard 选的默认算法绝大多数业务接口都适用。漏桶算法则是完全相反的思路请求先进入一个缓冲区桶然后以恒定速率流出桶满了直接丢弃新请求。它的效果是无论入口流量怎么波动出口流量永远是一条平稳的直线。缺点也很明显不允许任何突发就算系统现在很空闲也只能按固定速率处理。它适合的场景是下游极其脆弱的系统比如对接外部老系统、短信通道、第三方支付回调。选型上我建议这样判断如果下游是你自己的服务、有一定的弹性用令牌桶如果下游是合同约定速率的外部服务或者下游一抖动就会引发资金风险的场景用漏桶。宁可自己多等也别把下游打爆。3.3 YJFlowGuard内部是怎么组织的组件内部我没有用一大坨 if-else 去区分算法而是定义了一个算法处理接口每种算法一个实现类运行时根据配置动态解析。这个做法直接抄自策略模式好处是新加一种算法不需要改动中间件主体代码只加一个实现类和一个注册项就行。实现令牌桶时我参考了 .NET 官方 RateLimiter 包的设计没有用真正的定时任务去持续放令牌而是维护一个“桶内令牌数”和“上次放令牌的时间戳”每次请求来的时候根据时间差计算应该补充多少令牌。这样可以避免引入后台线程纯粹靠请求驱动性能和简洁性都兼顾了。桶的积分计算有个公式补充令牌数 (当前时间戳 - 上次补充时间戳) * 每秒速率算完之后再和桶容量取最小值。这套无定时的实现方式在压测下表现很稳定单机百万级请求的计数开销可以忽略不计。滑动窗口的实现则是维护一个环形槽位数组槽位数量根据配置的窗口粒度计算。请求来的时候计算当前时间属于哪个槽位更新槽位计数同时把窗口外所有槽位的计数归零。这样统计最近窗口的总数只需要做一次数组遍历开销可控。4. 接入YJFlowGuard的最小工程注册、声明规则、跑通一个限流理论讲完得落地。这一节我按“最小可跑通”的标准走一遍从建一个空的 .NET WebApi 项目开始到第一条限流规则生效。4.1 中间件注册的顺序比想象中重要NuGet 安装没什么好说的包管理器或命令行都行dotnet add package YJFlowGuard安装完后的注册代码分两步。先在服务容器里注册builder.Services.AddFlowGuard(options { options.Algorithm AlgorithmType.TokenBucket; options.Capacity 100; options.Rate 10; });然后在管道里挂中间件app.UseFlowGuard();这里我要特别强调中间件的位置。很多人随手把 UseFlowGuard 放在 app.MapControllers() 前面就算了但如果你在 UseStaticFiles() 之后挂那静态资源的请求就不会被限流如果你挂在了业务异常处理中间件之后异常在到达限流器之前就被记录了日志量在攻击时照样会被打爆。我建议的顺序是UseExceptionHandler 这类异常处理中间件放在最前面然后是 UseFlowGuard之后再做路由和鉴权。原因有两个一是限流要尽可能靠近请求入口这样被拒的请求不会继续往下走不消耗后续任何资源二是异常处理要先于限流保证限流器自身异常能被全局捕获。顺便说一句如果你发布后遇到/swagger/v1/swagger.json返回 404 的问题大概率也是中间件顺序没排对把 UseSwagger 放到 UseFlowGuard 之前就好。4.2 声明规则特性标注与全局配置文件YJFlowGuard 支持两种声明限流规则的方式。第一种是特性标注适合对单个接口做差异化限流[FlowGuard(Algorithm AlgorithmType.TokenBucket, Capacity 200, Rate 20)] [HttpGet(orders)] public async TaskIActionResult GetOrders() { // ... }第二种是全局配置文件适合对一类接口或全局限流。在 appsettings.json 里加一段{ FlowGuard: { Default: { Algorithm: TokenBucket, Capacity: 100, Rate: 10 }, Rules: [ { Path: /api/orders, KeyStrategy: ClientIP, Capacity: 50, Rate: 5 } ] } }Path 支持通配符比如/api/reports/*。配置项的优先级我是这么设计的特性标注优先于规则配置规则配置优先于默认配置。这样的好处是全局兜底、局部调优不需要为了某个接口的特殊需求把全局配置改得面目全非。4.3 一个跑起来的最小配置如果你想最快看到限流效果按这个流程走新建项目、安装包、注册中间件、在控制器加一行特性、启动后用压测工具或者循环请求去测。我测试时用了一段简单的并发脚本200 个并发请求打一个限速 10 的接口预期的结果是大部分请求返回 429少量请求成功。跑通第一版之后要第一时间验证三件事限流是否生效状态码是否为 429、被限流的请求是否包含 Retry-After 响应头、以及未限流的路径是否完全不受影响。这三件事验证完说明中间件没白接。5. 规则参数与核心API每个配置项背后都在解决什么问题跑通之后大多数人的困惑是参数怎么调。YJFlowGuard 的配置参数不算多但每个参数都对应一个具体问题。下面这张表是我整理的核心参数速查。参数必填说明我的默认建议Algorithm否限流算法类型TokenBucketCapacity是桶容量/窗口允许的最大请求数按预估 QPS 的峰值定Rate是每秒补充速率/每秒请求数按接口正常负载的 1.5 倍定Window否固定/滑动窗口的时间跨度秒60KeyStrategy否限流维度策略ClientIPBehavior否触发限流后的行为RejectWhiteList否白名单路径或 IP空5.1 限流维度按IP、按用户还是按接口KeyStrategy 这个参数容易被忽略但它决定了限流的公平性。默认是 ClientIP也就是每个 IP 独立计数一个 IP 超了只挡这个 IP不影响其他用户。这个策略适合大多数对外的匿名接口尤其适合挡爬虫。如果你的接口有登录态那更合理的维度是 UserID每个用户独立配额防止某个用户误操作或者恶意脚本刷爆全局配额。自定义策略也支持你可以传一个委托自己从 HttpContext 或请求体里提取维度标识。比如我们有个接口是按订单维度限的一个订单最多只能查三次物流状态这个用 ClientIP 实现不了只能走自定义策略。全局维度也就是 Global整个接口共享一个计数器是最严格的策略。我一般只用在非常核心、非常脆弱的接口上比如支付回调全局限流确保任何来源都不能打爆它。5.2 触发后的行为设计Behavior 参数同样重要。默认的 Reject 是直接返回 429这是最常见的行为但你要注意两点第一429 响应要带 Retry-After 响应头告诉调用方“多久之后可以重试”否则上游不知道什么时候该重试容易引发二次重试风暴第二429 的响应体尽量简单给个 JSON 错误码就行不要返回一大段 HTML因为被限流的请求本身就是压力的一部分响应体越大压力越大。第二种行为是 Queue请求不直接拒绝而是进入一个带超时时间的等待队列按令牌桶的速率排队放行。这个适合那种“用户操作不能被中断但可以慢一点”的场景比如文件导出、批量查询。Queue 必须配超时时间否则流量大时队列会把内存堆满限流变成一个新的故障源。第三种行为是 Fallback触发限流后走你自定义的降级逻辑比如返回本地缓存的旧数据、空列表、或者一个提示页。这个和熔断降级的配合最紧密我通常建议在核心链路上配 Fallback保证用户体验的连续性。5.3 动态获取剩余配额与响应头给调用方透明化限流状态是很多团队忽略的细节。YJFlowGuard 在响应头里默认带了三项信息X-RateLimit-Limit总配额、X-RateLimit-Remaining剩余配额、X-RateLimit-Reset窗口重置时间。这三项的作用是让上游和前端知道自己的请求处于什么状态可以在客户端主动做本地限速而不是等到 429 才被动处理。内部也提供了获取剩余配额的 API业务代码里如果需要“这个用户还能发几次”这类提示可以直接查询不需要自己再维护一套计数。6. 多实例与Redis模式下踩出来的三个坑单机跑通只是开始。真正让我把 YJFlowGuard 从“能用”打磨到“生产级”的是压测和灰度时踩出来的一堆坑。这里挑三个最有代表性的讲每个都是真实的排查链路。6.1 单机压测正常多实例一上就穿第一次灰度上线三台机器压测时发现限流完全不生效每秒请求数轻松超过阈值好几倍。第一反应是规则没加载对查日志发现每台机器都打印了加载规则成功于是排查方向转向负载均衡。排查链路是这样的先看 Nginx 配置确认没有开会话保持请求是轮询分发到三台机器的然后看应用日志发现三台机器各自计数A 机器显示当前窗口 30 个请求B 机器也显示 30 个但全局其实已经 90 个了。这个现象一出来就定位了YJFlowGuard 默认用的是进程内计数器多实例部署下每个实例各统计各的限流阈值被实例数放大了一倍多。解决方式不复杂切换到 Redis 计数模式builder.Services.AddFlowGuard(options { options.CounterStore CounterStore.Redis; options.RedisConnectionString redis-server:6379; });这里要提醒的是切换 Redis 模式后一定要确认 Redis 的延迟和稳定性。限流器每次请求都要和 Redis 交互一次Redis 抖一下整个接口的 P99 就会上升。所以生产环境我建议 Redis 单独部署或者至少使用独立的 DB 编号不要和业务缓存混在一起避免互相影响。6.2 Redis计数必须用Lua别用GetSet切到 Redis 模式后压测又发现了第二个问题限流在并发高的时候会“漏检”。看起来限流了但实际通过的请求数比预期多。问题出在读写不是原子的。新手最容易写的代码是这样的先从 Redis GET 当前计数判断是否超过阈值没超过就 INCR。这段代码在并发场景下等于没写。两个请求同时 GET 到同一个值 99都判断没超过 100然后都 INCR 放行结果就变成 101 了。这不是 YJFlowGuard 的 bug是所有基于“读-改-写”模式计数器的通病。正确的做法是把计数和判断合并成一个原子操作用 Redis 的 Lua 脚本。YJFlowGuard 内部就是这样实现的local current redis.call(INCR, KEYS[1]) if current 1 then redis.call(EXPIRE, KEYS[1], ARGV[1]) end if current tonumber(ARGV[2]) then return 0 end return 1这段脚本做的事情是INCR 加一如果是第一次加则设置过期时间然后比较是否超过阈值返回 1 或 0。整个流程在 Redis 端原子执行不存在并发竞态。这里有个边界细节给 key 设置过期时间时窗口时间建议比实际窗口多几十秒避免窗口交界处 key 提前过期导致计数丢失。比如窗口是 60 秒过期时间设 90 秒保证边界请求能被正确统计到上一个窗口。6.3 限流器自身故障时的隔离策略第三个坑不是并发问题是故障隔离问题。有一次线上 Redis 抖动持续了十几秒结果大量接口跟着超时。排查发现是限流器在 Redis 不可用时没有降级策略请求在等待 Redis 响应时卡住了。限流器是保护系统的它自己绝不能变成系统的故障源。YJFlowGuard 在这个问题上做了三个设计决策。第一所有 Redis 操作都设置了超时时间默认 100 毫秒超过就当本次限流判定失败。第二提供 FailOpen 和 FailClose 两个选项。FailOpen 是失败时放行FailClose 是失败时拒绝。我的默认建议是 FailOpen核心链路宁可偶尔多放几个请求也不能在 Redis 抖动时把整个接口卡死。FailClose 只适合那种“一秒钟都不能多放”的极端安全场景比如支付接口但使用前必须想清楚后果。第三Redis 不可用时自动退化到本进程内计数作为临时兜底虽然多实例下计数会不准但至少给了系统一个缓冲时间。这三个设计合在一起保证限流器在异常状态下是“透明”的它可能暂时失效但绝不让业务感知到额外故障。7. 进阶方向动态阈值、预热与容量预估基础功能稳定之后我开始琢磨怎么让限流更智能。YJFlowGuard 目前的版本支持了一些进阶能力虽然不是每个人都需要但值得讲一下思路。7.1 动态阈值让限流跟着系统健康度走固定阈值的缺点是它假设系统容量永远不变但系统容量是会变的。CPU 占用高了、线程池排队多了、GC 频繁了系统的实际处理能力是在下降的这时候仍然按固定阈值放流量还是会出问题。动态阈值的思路是监听系统的健康指标比如 CPU 使用率、线程池队列长度、最近 1 分钟的请求平均耗时当指标超过某个阈值时自动把限流阈值下调一个档位。实现上 YJFlowGuard 提供一个阈值计算委托默认是按固定值算你可以传入自己的调整逻辑options.CapacityProvider (context) { var cpu GetCurrentCpuUsage(); if (cpu 80) return 50; return 200; };这个思路参考了 Sentinel 的系统自适应限流不需要人工干预就能平滑地控制流量。我自己的使用经验是这套机制特别适合那种流量波动大、又没法随时盯着监控敲命令调整配置的团队。7.2 令牌桶预热的必要性第二个进阶功能是预热。大多数令牌桶实现启动时桶是满的意味着服务刚启动就能接受最大突发流量。这个特性平时没问题但在“服务重启后马上有大流量”的场景下会带来隐患新实例刚起来线程池还是冷的JIT 还没优化各种缓存还没加载这时候来一波满配额流量很容易把进程打崩。更合理的做法是让桶从空开始按速率逐渐填满或者从一个较小的容量开始逐步放大这就是预热。YJFlowGuard 的令牌桶算法支持配置预热时间启动后在一段时间内逐步把桶充满给系统留出加载缓存、预热连接池的时间窗口。我建议任何重启后需要加载大量数据的服务都配上这个成本很低收益很明显。7.3 容量预估先算清楚再写配置最后说个实操层面的方法。很多人问我 Capacity 和 Rate 到底填多少我只能说上限靠压测下限靠常识。压测方法不复杂用 wrk、JMeter 或 k6 对单台机器打压找到 CPU 在 70% 左右时单实例能扛住的 QPS这个值就是单节点容量。然后套一个公式集群限流阈值 单节点容量 × 节点数 × 冗余系数冗余系数建议取 0.7 到 0.9留出的余量是为了应对单节点故障下线、自动扩容延迟、以及流量分布不均。比如单节点 500 QPS三台机器冗余系数 0.8那集群阈值就是 1200。Capacity 取峰值突发量Rate 取长期平均流量。这套估算方法的核心逻辑是先算业务需要多少吞吐再算系统能扛多少吞吐两个数之间取一个保守值。没有压测数据支撑的限流参数全靠猜上线后早晚出事。写 YJFlowGuard 这几年我最深的感受是限流方案里最难的不是算法而是“符合业务直觉”。算法再精巧如果规则配置不能让同事一看就懂、一改就会最终也会被弃用。所以我在这套组件上花了很多功夫在参数命名、默认值、中间件顺序这些“细节”上——恰恰是这些细节决定了它的可用性。你永远无法预判流量什么时候来但至少可以决定系统以什么姿势倒下。限流不是为了挡住用户而是为了在极端流量下保留住核心服务的一口气。个人建议环境越复杂规则越要从简先把保护能力落到最前面的中间件再把动态调优的补丁迭代跟上。限流组件这种东西平时看不见它的存在但它会在最关键的时刻帮你接住那一拳。