1. 从一次线上事故说起限流降级到底在防什么做后端的人几乎都经历过那种半夜三点被报警电话叫醒的时刻。上个月我就刚处理过一次某个核心接口的RT从正常的50ms一路飙到3000ms紧接着依赖它的上游服务开始超时堆积然后整个链路像多米诺骨牌一样倒下。事后review发现罪魁祸首只是某个活动页的一次流量突刺但整个系统花了四十分钟才缓过来。那次事故之后我重新梳理了一遍限流降级这整套东西。其实很多人对限流降级的理解停留在接口加个计数器或者熔断就是抛异常这种层面真正到线上就发现根本不是那么回事。2024年了微服务架构早就普及一个请求从网关到下游可能要经过四五层服务任何一层没有保护整条链路的稳定性都会被打穿。这篇就把我这些年做稳定性治理的经验整理出来从算法选型到参数推导从降级设计到线上坑点一次性说透。先说清楚概念边界。限流管的是进来的流量控制单位时间内允许通过的请求数目的是别让流量超过系统的处理能力。降级管的是出去的依赖当某个下游不稳定时主动牺牲非核心功能保住核心链路。熔断则是降级的一种自动化形态——连续错误率达到阈值就自动断开过一段时间再试探恢复。三者配合才能构成一套完整的自我保护机制。适合看这篇的人正在建设系统稳定性体系的后端工程师、被线上流量突刺打懵过的运维或SRE、以及准备系统设计面试的同学。我不讲虚的全部是可落地的方案和参数。2. 四种主流限流算法参数怎么定才能不误伤限流算法这个词大家多少都听过但真正用的时候很多人分不清固定窗口和滑动窗口的区别也不知道令牌桶和漏桶到底选哪个。我先逐个拆开讲再给一张对比表最后说说实际参数怎么推导。2.1 固定窗口实现最简单但边界流量会打穿固定窗口的逻辑一句话就能说清比如设定QPS上限为100那就以1秒为一个窗口窗口内计数器到100就拒绝后续请求下一秒计数器清零重新计数。优点是实现极其简单一个原子变量加一个定时重置就搞定。缺点也很致命窗口边界会出现双倍流量突刺。假设QPS限制100窗口是每秒一清那么在第999ms来了100个请求被放行第1000ms窗口重置新的窗口又放行100个请求——这200个请求实际发生在几乎同一瞬间如果后端处理能力只有每秒100个这200个并发很可能把服务打挂。固定窗口适合的场景是对突刺容忍度较高或者限流阈值远低于系统真实容量的情况。我见过一些网关层面的兜底限流阈值设成系统容量的50%即使窗口边界翻倍也还在安全范围内这种情况下固定窗口是性价比最高的选择。2.2 滑动窗口精确到毫秒级的流量控制滑动窗口把时间切成更细的格子。假设窗口是1秒切成10个100ms的格子每格单独计数。要判断当前时刻是否超限就把当前格子以及之前9个格子的计数加起来看是否超过100。每个请求进来格子随时间滚动最新数据永远取代最旧数据。这个方案的精度取决于格子的数量。10个格子的精度就是100ms如果把1秒切成100个格子精度就能到10ms但每个key需要维护100个计数器内存开销和计算开销都会上来。我常用的做法是在网关层用100个格子做精细化控制在应用层用10个格子做准实时控制精度够用且成本可控。在分布式环境下滑动窗口的计数通常放在Redis里用Hash结构存每个格子的计数一次请求需要取多个字段做求和然后用Lua脚本保证原子性。这个下面第三节会细说。2.3 令牌桶允许突刺但别让请求全堆在桶空那一刻令牌桶的原理是系统以恒定速率向桶里放令牌桶容量有限请求进来必须取到令牌才能通过取不到就被限流。注意令牌桶允许一定程度的突发流量——如果桶里有100个令牌瞬间来100个请求可以全部放行。这里有一个常见的参数误区。很多人以为令牌桶的速率就是接口的QPS上限其实两者不能直接画等号。令牌产生速率决定的是长期平均速率而桶容量决定的是最大突发量。比如接口实际容量是200QPS你可以把令牌速率设成150桶容量设成50这样既能缓冲一定突刺又不会让真正的突发流量击穿后端。如果桶容量设成200等于允许在空桶状态下瞬间放行200个请求这200个请求的瞬时并发依然可能超过下游的真实处理能力限流就形同虚设了。实现令牌桶时我一般用延迟计算方案而非真正的定时放令牌记录上一次放令牌的时间戳请求进来时用当前时间减去上次时间乘以速率算出这段时间应该生成多少令牌再更新桶内令牌数。这样不需要任何后台线程一个Redis key或一个本地原子变量就能搞定。2.4 漏桶削峰填谷适合保护下游脆弱系统漏桶的逻辑是请求进来先进入桶里桶以恒定速率向外漏出请求桶满就丢弃新请求。注意这和令牌桶的关键差异——漏桶的出口速率恒定完全平滑无论上游怎么突刺下游看到的流量都是恒定速率。漏桶适合的场景是下游系统对流量速率极其敏感。比如某个老旧的数据库同步服务每秒最多处理30个请求多一个就会超时甚至死锁这时候漏桶就是最佳选择——直接把上游所有突发都削平以30个/秒的速率漏出去。漏桶在分布式场景下的实现要复杂一些因为需要一个队列来承接流量。单机方案可以用内存队列加单线程消费者但队列积压太多会导致请求等待时间过长所以通常还要加上排队超时策略。集群方案一般依赖消息队列或专门的缓冲中间件成本较高所以除非下游真的碰不得否则我更倾向于用令牌桶加适当超时控制来近似实现漏桶的效果。2.5 算法怎么选我常用的判断路径算法允许突发流量平滑度实现成本适用场景固定窗口窗口边界双倍突发差最低兜底保护、容量远低于极限滑动窗口可控突发取决于粒度中等中网关层精确限流令牌桶允许桶容量的突发中等中大部分API限流、流量有自然波动漏桶不允许最好较高下游性能脆弱、必须恒定速率我的选择路径是这样的大多数业务接口限流用令牌桶因为它允许正常的流量抖动用户体验最好网关层做全局限流用滑动窗口因为要精确控制如果是保护某个非常脆弱的依赖比如数据库、外部限速API用漏桶把流量磨平。参数推导方面有一个关键原则限流阈值不能根据后端最大容量来设必须留出50%以上的余量。如果接口压测极限是200QPS把限流设成190就是给自己找麻烦——线上环境的CPU波动、GC停顿、网络抖动都会让实际处理能力低于200一旦服务降到180就触发误限流用户感受到的是服务端主动拒绝对外服务。我通常的做法是容量压测得到极限值后乘以0.5到0.6作为日常限流阈值再单独放一个告警阈值在80%超过告警阈值不拦截但通知值班人。3. 单机还是分布式限流落地的两套方案与取舍算法选好了还得解决放在哪跑的问题。很多人在单机限流跑得好好的上线后发现流量一分布到多台机器总体的实际流量就是单机限流的N倍整条链路依然被打穿。这一节我把两套方案都讲清楚附带代码骨架。3.1 单机限流适合本地防御但不解决全局问题单机限流最简单在应用进程内维护一个计数器比如用Guava的RateLimiter或自研令牌桶。它的核心价值不是控制全局流量而是防止单台实例被冲垮。举个例子集群有10台机器每台只允许100QPS理论上集群能扛1000QPS。但如果负载均衡器分配到各台的流量不均衡——比如某台机器被热点流量打中接收了400QPS那么单机限流会在那台机器上触发拒绝多余请求。这在负载均衡策略不够智能的场景下是非常实用的兜底。但单机限流有一个致命问题如果请求入口是同一个网关或入口服务网关本身成为单点限流逻辑就必须在网关这一层实现。如果入口是多活的、流量可以打到任何一台每台的限流阈值就要除以机器数一台机器挂了之后其他机器的限流阈值必须动态调整否则整个集群的总容量会下降。这个动态调整逻辑非常复杂不如直接上分布式限流。3.2 分布式限流Redis Lua 的经典实现分布式限流的核心是用一个中心化存储通常是Redis保存全局计数器所有实例共享同一份状态。在Java技术栈里我用的最多的是Redis加Lua脚本的方式原因很简单Lua脚本在Redis中执行是原子的不需要额外处理分布式锁问题。下面这个Lua脚本是滑动窗口的核心逻辑我用的是按毫秒时间戳分桶的方案-- KEYS[1]: 限流key如 ratelimit:api:order -- KEYS[2]: 当前毫秒时间戳 -- KEYS[3]: 窗口大小如1000表示1000ms -- KEYS[4]: 格子数量如10 -- KEYS[5]: 限流阈值 local key KEYS[1] local now tonumber(KEYS[2]) local window tonumber(KEYS[3]) local bucketNum tonumber(KEYS[4]) local limit tonumber(KEYS[5]) local bucketSize math.floor(window / bucketNum) local currentBucket math.floor(now / bucketSize) -- 删除超出当前窗口的旧桶 local startBucket currentBucket - bucketNum 1 local keys redis.call(HKEYS, key) for _, k in ipairs(keys) do local bk tonumber(k) if bk startBucket then redis.call(HDEL, key, k) end end -- 统计窗口内总请求数 local count redis.call(HGETALL, key) local total 0 for i 1, #count, 2 do total total tonumber(count[i 1]) end if total limit then return 0 end redis.call(HINCRBY, key, currentBucket, 1) redis.call(EXPIRE, key, math.ceil(window / 1000) 1) return 1调用方用Jedis或Lettuce执行这个脚本返回值1代表放行0代表拒绝。需要注意几个细节过期时间必须比窗口多一个单位否则请求都还没出窗口key就被Redis清掉了会导致一个完整的流量周期超出限流控制。HGETALL在大流量下会有一点点性能开销如果要压榨性能可以把计数器拆成几个更小的滑动窗口或者改用Redis的INCR加一个ZSET的方案。但绝大多数业务场景HGETALL的复杂度是可控的毕竟一个key对应的bucket才10个左右。Redis自身的抖动会直接传导到限流逻辑。如果Redis超时了请求是放行还是拒绝我建议这里用快速失败策略Redis异常期间直接放行宁可让流量进来靠后端的熔断保护也不要在限流这一层把正常用户全部拦住。3.3 分布式限流的性能瓶颈与缓存优化纯Redis的方案在极高QPS下会遇到性能瓶颈——每个请求都走一次网络往返即使Lua脚本在Redis内部是原子的网络延迟依然存在。2022年之后很多大流量系统开始用本地缓存加Redis定期同步的双层限流方案。具体的做法是每个实例维护一个本地令牌桶同时每隔一个固定的同步周期比如1秒从Redis拉取全局配额重新计算本地桶的容量。这种方案的难点在于本地桶和全局配额如何保持一致我见过比较成熟的实现是把全局配额按实例数均分每台实例的本地桶容量就是配额/实例数实例数变化时短暂允许配额偏差靠下一轮的Redis同步来纠正。这种方案在压测下能把限流本身的额外延迟控制在1ms以内很适合那种对延迟极度敏感、且单机QPS已经超过5000的服务。如果你的系统QPS还不到1000直接用纯Redis方案就足够了别为了优化而优化平白增加复杂度。我个人的建议是先用纯Redis方案上线等真的出现Redis成为瓶颈时再演进到双层方案。稳定性建设的核心是够用就好按需演进而不是一上来就上最复杂的架构。4. 降级设计的关键步骤阈值、开关、预案缺一不可限流管住流量入口降级管住依赖出口。真实的线上故障里80%的事故不是入口流量太大而是某个下游依赖数据库、缓存、外部API出了故障错误顺着调用链传染开。这一节我拆解降级方案设计里最容易忽视的几个环节。4.1 降级不等同于熔断你要先分清被动和主动很多人把熔断和降级混为一谈。熔断是被动的——下游连续报错熔断器自动打开后续请求直接短路返回fallback降级是主动的——我知道某个功能不是核心链路提前把它的调用关掉省出资源给核心链路。一个典型例子下单页需要同时展示商品详情、库存信息、推荐商品、用户评价。商品详情和库存是核心没有它们用户没法下单推荐和评价是辅助就算挂了也不影响下单主流程。主动降级的思路就是在流量高峰或依赖抖动时直接关闭推荐和评价的调用让请求不再等待这两个服务。被动熔断的fallback则是运行时触发的——比如评价服务连续报错5次熔断器打开后续请求走本地缓存或直接返回默认数据。设计降级方案时我遵循的顺序是第一步梳理所有依赖并分级哪些是核心、哪些是辅助第二步给每个辅助依赖配置主动降级开关第三步配置被动熔断器的参数第四步设计降级时的fallback策略返回缓存数据、返回默认值、还是直接忽略第五步针对每个降级场景做预案演练。4.2 熔断器的三个关键参数滑动窗口大小、错误率阈值、半开探测以主流熔断器实现比如Hystrix或Resilience4j为例有三个参数决定熔断行为滑动窗口大小统计最近多长时间或多少个请求内的错误率。窗口太小偶发抖动就会触发熔断窗口太大故障持续很久才被发现。错误率阈值当窗口内请求的错误率超过这个值比如50%熔断器打开。要注意错误率的计算方式各框架不同有的是超时加异常/reject都算错误有的只算异常这直接影响参数怎么定。半开探测熔断器打开后等待一段时间Sleep Window然后放少量试探请求如果试探请求成功熔断器从打开转为关闭恢复流量如果失败继续打开。我给出的经验参考值标准HTTP调用场景下滑动窗口用10秒或100次请求错误率阈值用50%Sleep Window用5秒到10秒。如果下游恢复很慢比如是重连外部系统Sleep Window可以加大到30秒否则试探请求会把下游再次打死。还有一个容易被忽视的点熔断器打开期间fallback的耗时也要算进总RT。如果fallback是查一个已经阻塞的数据库连接池那每个请求还是会阻塞很久。所以降级方案里fallback必须是快路径——本地缓存、默认值、直接返回空任何可能再次卡住的操作都不适合做fallback。4.3 主动降级的开关设计与灰度下发主动降级的开关我强烈建议做成配置中心里的一个动态配置项而不是部署代码里的静态变量。原因很简单线上故障时你希望是改一个配置就能生效而不是重新发一版代码等审批等发布。我常用的开关设计是一个JSON配置{ degradeRules: [ { resource: remoteRecommendService, action: return_empty, active: true, reason: recommend service unstable, temporarily disabled, expireTime: 2024-11-20T18:00:0008:00 }, { resource: remoteReviewService, action: use_local_cache, active: true, reason: review service timeout, fallback to cached data, expireTime: 2024-11-20T18:00:0008:00 } ] }这里我需要特别提一下expireTime字段。很多人设计降级开关时不加过期时间结果就是开关打开后人人都知道要关上但人人实际上没人关——某个降级配置在配置中心里躺了三个月直到一次故障后大家才发现核心功能被降级了。这个字段我踩过坑所以现在强制要求所有降级开关必须带过期时间来确保即使操作者遗忘系统也会自动恢复。灰度的逻辑是开关不只是一个全局Boolean而是一个规则范围的结构。比如对内部测试账号生效、对流量前5%生效、对非核心接口生效。这样你打开降级开关时不是一刀切而是先在小流量下观察影响确认无误后再扩大范围。4.4 依赖分级一张表搞清降级优先级做降级方案的第一步永远是梳理依赖并分级。我建议以表格形式维护一份依赖清单每季度review一次依赖服务调用方式核心程度降级动作Fallback熔断策略商品详情DB同步DB核心禁止降级-无库存服务同步RPC核心禁止降级-快速失败后重试超时2s推荐服务同步RPC辅助主动关闭返回空列表熔断错误率50%10s窗口评价服务同步RPC辅助主动关闭返回本地缓存熔断错误率60%10s窗口用户行为上报异步MQ非核心异步降级丢弃消息无这张表就是降级预案的底盘。核心依赖禁止降级辅助依赖明确降级动作非核心依赖可以直接丢弃。每次故障来临团队不需要现场讨论要不要降级推荐服务——预案早就定好了只需要按表执行。这里有一个原则很关键核心依赖不能降级但核心依赖必须有超时和重试策略。因为核心依赖挂掉时如果请求一直在等待线程池会被占满整个服务就死了。所以即使不能降级也要给核心依赖设置合理的超时一般不超过2秒、限制重试次数最多1次用快速失败替代无限等待。5. 限流降级上线后最容易踩的四个坑方案设计得再完善落地时还是会碰到各种实际问题。这四条是我在不同项目里真实踩过的每条背后都有一段电话被报警打爆的经历。5.1 坑一限流参数直接套压测结果忽略了GC停顿和网络抖动这是最常见的坑。压测环境网络干净、数据量小、没有其他服务抢占资源压出来的极限值是2000QPS直接就把限流设成2000。上线后发现生产环境的GC停顿时间比压测环境长了一倍实际处理能力只有1500QPS结果正常流量刚到1800限流就触发了——用户端看到的是系统主动拒绝服务。正确的做法我前面说过限流阈值设置成压测极限的50%到60%。这样会牺牲一小部分容量上限但换来的是一整条链路的稳定。记住一句话限流是保护机制不是压榨工具。5.2 坑二滑动窗口时间格和时间戳对不齐导致限流提前失效用Redis做滑动窗口时如果你使用的是系统当前毫秒时间戳除以窗口大小取整作为桶编号比如1000ms窗口分成10个100ms格子第100ms的桶编号是100ms/100ms1第150ms的桶编号是150ms/100ms1第199ms的桶编号也是1。这里逻辑是对的。但有个陷阱如果某个桶编号的key一直没被删除下一轮窗口开始时旧桶还在统计范围内会突然多出一批请求数导致限流误触发。解决方式就是每轮窗口开始时主动清理旧桶——就是前面Lua脚本里HKEYS遍历删除的那一步。这个清理逻辑如果漏了你会在流量低谷期发现限流莫名其妙地触发。5.3 坑三降级开关打开后fallback反而成了新的瓶颈一个很典型的场景评价服务超时了熔断器打开fallback返回本地缓存。但本地缓存的读取本身没有做超时控制而缓存层在同一时刻也出现了抖动fallback的读取耗时从5ms变成200ms请求依然在一个个地堆积。解决方法是fallback路径也要有超时和单独的线程池隔离。我用的是两层隔离降级开关控制的fallback逻辑跑在一个独立的线程池里线程池大小独立配置并且fallback内部的每次IO都套上超时时间。如果fallback自身也超时了就直接返回默认值不再往下执行。5.4 坑四全局限流把所有流量都挡在外面忽略了白名单需求不少系统有限流白名单的需求内部监控系统的健康检查请求不能被限流某些重要的B端客户不能因为其他C端流量突刺而被拒之门外。这个坑在于很多人都知道要做白名单但很少有人给白名单设置一个独立于主限流的配额。也就是说白名单流量也走同一个限流计数器只是优先放行。但业务高峰时白名单流量本身也可能触达限流阈值导致白名单请求在限流触发时被错误拒绝。正确做法是分层限流入口处先判断是否白名单白名单请求走一个单独的、配额更宽的限流器比如限制是总限流的10倍非白名单走正常限流器。这样既能保护主链路又能保证关键渠道不被误伤。6. 上线前的验证与监控让限流降级真正可控方案落地后最后一步也是很多人忽略的一步验证与监控。限流降级不是写完了就能上线的不验证的限流就是定时炸弹。6.1 容量压测的注意细节压测不是拿压测工具打一波看看数值就结束了。我发现最有效的做法是阶梯式压测从50%阈值开始每5分钟提高10%的流量观察系统的RT、错误率、线程池活跃度。这样做有两个目的一是确认限流阈值在真实流量下不会误触发二是找到系统真正的容量天花板为后续调阈值提供数据依据。压测时还要记得模拟下游故障场景把某个依赖服务的接口故意改成一个返回500的mock验证熔断器是否按预期打开、降级开关是否生效、fallback是否正确。没有故障演练的监控就像没有消防演练的楼——等到真正起火时你根本不知道现场是什么样。6.2 监控指标与告警设置限流降级这套系统需要监控的核心指标有四个被限流请求数如果这个值从0变成有值说明流量触到了保护层需要关注原因。熔断器状态变化次数频繁开合说明下游不稳定要立即排查依赖服务。降级开关打开期间的核心链路成功率这个是检验降级效果的关键——降级之后核心链路成功率是否上升了。线程池活跃度和队列深度如果线程池持续高水位说明系统正在接近容量上限。告警策略我建议设置两层阈值告警比如被限流数超过100/min触发告警和趋势告警比如连续5分钟线程池活跃度超过80%触发告警。趋势告警比单纯的阈值告警更有价值因为很多故障在阈值到达前就已经有迹象了。6.3 最后的实操清单写到这里我把自己现在做一个新接口时限流降级这套流程会怎么走整理成一份清单梳理接口的所有依赖明确核心和非核心。压测确定容量极限设置60%作为限流阈值。根据流量模式选择算法一般接口用令牌桶边界敏感用滑动窗口下游脆弱用漏桶。接入Redis加Lua的分布式限流做好Redis异常时快速失败的兜底。为非核心依赖配置主动降级开关并带过期时间。为核心依赖配置超时和重试策略为辅助依赖配置熔断器参数。设计并实现fallbackfallback必须快、必须带超时、必须独立线程池。配置监控指标和告警先做一轮故障演练再上线。这套流程走下来我不敢说你的系统就永远不会出故障——但至少流量突刺不会再轻易打穿你下游故障也不会再像滚雪球一样把整条链路拽下水。稳定性这件事靠的不是事后的补救而是事前的这套层层设防的笨功夫。