ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微服务稳定性三件套:熔断、降级与限流的原理及落地实践

微服务稳定性三件套:熔断、降级与限流的原理及落地实践 线上出问题的时候大部分都不是因为你代码逻辑多复杂而是你依赖的那个下游服务先扛不住了。我有一次遇到的情况是一个核心链路上的订单服务响应时间从50ms涨到了5s紧接着所有调用它的接口开始排队线程池被打满请求越积越多最后连数据库连接也被耗尽半个系统跟着瘫了。事后复盘问题不难定位但更扎心的是我们其实早就知道这个服务有隐患却一直没有把熔断、降级、限流这三件套完整落地。这三件事做起来并不神秘但要做对、做好需要把概念、参数、场景全部串起来理解。很多团队要么只配了个超时时间要么把熔断和降级混为一谈要么限流阈值拍脑袋填一个数字上线之后要么误伤正常流量要么该拦住的时候没拦住。这篇文章我把这些年踩过的坑、调过的参数、复盘过的故障一起整理出来从原理讲到落地再给出一套可以直接套用的实现方案。无论你是在做微服务改造还是已经上了微服务但防护手段还不完整这篇都值得看完。1. 先理清概念三个关键词不是同义词1.1 雪崩是怎么发生的微服务架构下一个请求往往会经过多个服务A调用BB调用CC又调用D。只要链路里有一个节点的响应变慢上游服务就得一直占着线程等结果。线程不够用了新的请求继续进来就只能排队队列满了请求开始被拒绝被拒绝后客户端会重试重试又带来更多流量于是下游更慢形成一个正反馈的恶性循环。这个过程中最可怕的是你以为只是“某个接口变慢了”实际上它正在把整个系统的资源一点点耗尽。当某个核心服务的线程池被占满所有依赖它的上游接口都会跟着超时而超时又会引发用户侧的重试最终把压力传导到数据库、缓存这些更底层的组件上。这就是典型的雪崩效应一个局部小故障被放大成全局大事故。所以微服务的防护思路核心就一句话任何一个依赖都不能无限占用你的资源。要么快速失败要么降级处理要么在源头就把流量控制住。熔断、降级、限流这三件事各自解决的是不同层面的问题但又互相配合缺一不可。1.2 三者的边界与关系很多初学者会把熔断和降级搞混其实它们是两个完全不同维度的事情。限流是站在“入口”维度控制进入系统的请求速率。就好比一个景区每天的售票上限是1万张不管外面有多少人排队今天就是只能进1万人。它保护的是系统自身的处理能力防止瞬时流量把服务打垮。熔断是站在“依赖”维度控制对下游服务的调用。当下游服务故障率过高时直接切断对它的调用不让请求继续打到已经快挂掉的服务上。它保护的不只是系统自身还包括那个已经脆弱的依赖方。降级是站在“业务”维度当系统资源不足或依赖出问题时主动放弃非核心功能保证核心功能的可用性。比如高峰期购物车页面加载失败可以降级成只显示基础文案而不是让整个页面报错。用一个电商场景来理解大促瞬间流量巨大限流在网关层把超过预期的请求挡在外面紧接着订单服务发现库存服务的错误率飙升熔断器打开不再调用库存服务与此同时商品详情页的实时推荐模块挂了系统自动降级为显示默认推荐位页面依然能打开用户主要流程不受影响。机制核心对象触发条件目标典型手段限流请求入口请求速率超过阈值保护自身处理能力令牌桶、漏桶、计数器熔断下游依赖错误率/慢调用率超阈值快速失败保护依赖断路器状态切换降级业务功能资源不足或异常保证核心业务可用兜底数据、简化逻辑1.3 先做哪个还是三个一起上我的建议是三件事都要做但实施顺序有讲究。如果你们团队资源有限先从超时和限流入手这两个是基础能挡住大部分突发流量第二步加熔断针对核心链路的关键依赖做保护最后再逐步完善降级策略尤其是那些有实时性要求但不强依赖的场景。限流做不好熔断和降级都白搭因为流量太大时系统已经被打瘫了根本没机会执行熔断和降级的逻辑。熔断做不好限流也只能救自己救不了下游。降级做不好系统虽然活着用户体验却断崖式变差。三者配合才是完整的防护体系。2. 核心细节解析与实操要点2.1 熔断的三个状态与关键参数熔断器的状态机是一个闭合回路关闭Closed→ 打开Open→ 半开Half-Open→ 关闭循环往复。关闭状态下请求正常调用下游同时统计最近一个时间窗口内的调用成功率和耗时。当错误率超过预设阈值比如最近10秒内错误率超过50%熔断器就切换到打开状态。打开状态下所有对该下游的调用直接快速失败不再真正发起请求。这个状态会持续一个固定的熔断时长比如5秒之后进入半开状态。半开状态允许少量试探性请求通过如果这些请求成功了说明下游已经恢复熔断器关闭恢复正常调用如果试探请求还是失败熔断器立刻重新打开再等一个熔断周期。这个设计的精妙之处在于它不会在故障还在的时候就贸然放全部流量过去。三个最关键的参数我直接给出建议的配置起点滑动窗口大小一般取10秒意味着统计最近10秒的调用情况。窗口太短容易误判窗口太长反应太慢。错误率阈值50%是一个常见起点。如果你们的业务对成功率要求极高可以调到30%但也要忍受更频繁的熔断触发。熔断时长5到10秒比较合适。太短的话下游还没来得及恢复就又被打进去了太长的话用户体验受影响的时间会拉长。以Resilience4j为例配置文件的示例failure-rate-threshold: 50表示错误率阈值50%sliding-window-size: 10表示窗口大小wait-duration-in-open-state: 5s表示熔断时长5秒。这些都是可以直接抄的起点参数但实际值一定要根据你的下游服务能力来调。2.2 降级的兜底策略设计降级不是简单地在catch块里返回一个null而是要设计一套有业务意义的兜底方案。我见过太多团队把降级做成“返回空对象”结果前端页面一片空白用户看到的是比报错还糟糕的体验。好的降级策略分三个层次。第一层是静态兜底如果推荐服务挂了返回一组预先配置好的热门推荐商品这些商品可以提前缓存哪怕数据不是最新的页面至少是完整的。第二层是本地缓存兜底如果下游缓存服务挂了使用本地缓存最后一份可用数据数据时效性差一点但总比直接失败强。第三层是流程裁剪支付时如果优惠券服务超时可以选择跳过优惠计算按原价支付保证交易能完成损失的只是个别用户的优惠利益。这里有个容易忽略的点降级一定要区分核心链路和非核心链路。比如登录是核心发短信验证码是非核心。短信服务挂了不应该让用户登录失败可以提示“验证码发送失败请稍后重试”但登录流程本身不能被拖着。降级的本质是主动放弃一部分功能来保住更大的业务目标。2.3 限流算法对比限流算法有好几种每种都有不同的适用场景没有绝对的优劣。我在实际项目中分别用过固定窗口、滑动窗口、漏桶和令牌桶这里把它们的核心差异拆开讲清楚。固定窗口最简单每1秒一个窗口窗口内计数达到阈值就拒绝后续请求。缺点很致命窗口切换的瞬间可能出现两倍流量的尖峰。比如阈值是100第0.9秒来了100个请求第1.0秒窗口重置后又来了100个实际1秒内进来了200个系统直接被打穿。滑动窗口解决了固定窗口的边界问题把时间粒度切得更细比如把1秒分成10个小格随着时间推移窗口一格一格滚过去统计的是当前时刻往前1秒的真实流量。实现复杂度高一些但能消除毛刺。它适合大多数API网关限流场景。漏桶的主要特点是匀速输出请求进来后先入桶桶底的出水口以固定速率放行。不管涌入多少流量流向下游的速率是恒定的。优点是保护下游非常稳定缺点是无法应对突发流量所有超过桶容量的请求都会被丢弃哪怕系统其实还能处理更多。令牌桶则允许一定程度的突发系统以固定速率往桶里放令牌每个请求需要取一个令牌才能放行但桶里可以积累最多一定数量的令牌。这意味着如果一段时间流量较低桶里攒了100个令牌突然来了一波流量可以一次性消耗这些令牌允许瞬间的突发流量通过。这是目前业界用得最多的方案比如Sentinel默认的限流模式就是基于令牌桶思想。算法允许突发实现复杂度适用场景固定窗口窗口边界有漏洞低简单场景可接受临界毛刺滑动窗口较平滑中API网关、通用接口限流漏桶不允许低保护数据库等脆弱组件令牌桶允许一定突发中业务接口限流、峰值容忍场景3. 实操过程与核心环节实现3.1 环境与依赖准备以Spring Cloud生态为例Sentinel是目前使用最广、功能最全的选择。相比早期常用的HystrixSentinel在控制台、动态规则、熔断策略方面都丰富不少。另一个轻量级选择是Resilience4j它更贴近Spring Boot原生风格但需要自己处理限流的动态配置控制台能力也偏弱。我建议新项目直接上手Sentinel。在项目中引入对应的starter依赖然后配置好控制台地址即可。控制台可以本地用Docker拉一个镜像跑起来默认端口是8858登录后就能看到各个服务的实时指标。除了依赖还需要在配置文件里指定应用名和端口这是因为Sentinel的客户端要上报数据给控制台。配置完成的标准是启动服务后在控制台的“机器列表”里能看到你当前的服务实例。3.2 使用Sentinel落地限流与熔断Sentinel的核心思路是“资源”加“规则”。你可以把任何一个方法、一段代码、一个URL声明为一个资源然后给这个资源配置限流规则、熔断规则、降级规则。声明资源最优雅的方式是注解。比如订单服务里有一个查询库存的方法这个方法就是下游依赖需要做熔断保护。代码如下Service public class InventoryService { SentinelResource(value queryInventory, blockHandler blockHandlerForQueryInventory, fallback fallbackForQueryInventory) public InventoryDTO queryInventory(Long skuId) { // 调用远程库存服务 return restTemplate.getForObject(http://inventory-service/api/inventory/{skuId}, InventoryDTO.class, skuId); } // 限流/熔断被触发时进入的方法 // blockHandler处理的是规则限制场景比如触发了限流 public InventoryDTO blockHandlerForQueryInventory(Long skuId, BlockException ex) { // 记录日志返回降级库存数据 return new InventoryDTO(skuId, 0, StockStatus.UNKNOWN); } // fallback处理的是业务异常场景比如下游报错 public InventoryDTO fallbackForQueryInventory(Long skuId, Throwable throwable) { return new InventoryDTO(skuId, 0, StockStatus.UNKNOWN); } }注意blockHandler和fallback的区别blockHandler处理的是Sentinel规则层面的拦截比如限流、熔断fallback处理的是业务代码抛出的异常。两者可以同时配置优先级是blockHandler大于fallback。这里有一个常见的错误只配fallback不配blockHandler导致限流触发后直接抛异常而不是走降级逻辑。对应的规则可以通过控制台动态配置也可以启动时用代码加载。比如熔断规则ListCircuitBreakerRule rules new ArrayList(); CircuitBreakerRule rule new CircuitBreakerRule(); rule.setResource(queryInventory); rule.setGrade(CircuitBreakerRule.STRATEGY_EXCEPTION_RATIO); rule.setCount(0.5); rule.setStatIntervalMs(10000); rule.setTimeWindow(10); rules.add(rule); CircuitBreakerRuleManager.loadRules(rules);这段代码的含义是对queryInventory这个资源最近10秒内错误率达到50%时触发熔断熔断持续10秒。具体的参数含义要和业务容量对得上后面我会讲参数推算方法。3.3 参数计算QPS预估与线程池大小限流阈值填多少很多人靠拍脑袋这是大忌。一个合理的阈值应该结合容量评估和压测结果来定。先算基础QPS。假设你有一个接口线上峰值PV是每分钟1万次折算下来大约是每秒170次。考虑到大促或异常流量可能是平时的5到10倍你就得在1万PV的基础上预留缓冲。比如设计容量按峰值的3倍来评估那限流阈值可以定为每秒500次超过部分直接拒绝。更精细的做法是结合经验公式阈值 (单机QPS能力) × (服务实例数) × 冗余系数。单机QPS能力通过压测得出比如你的服务单机能扛500 QPS部署了10个实例冗余系数0.8那么整体限流阈值就是500 × 10 × 0.8 4000 QPS。线程池大小也有一个经典公式核心线程数 (QPS峰值) × (平均响应时间) 预留缓冲。举个例子假设接口QPS峰值是200平均响应时间是100ms那么必须同时持有的线程数大约是200 × 0.1 20个再加一些缓冲比如设置30个核心线程。如果平均响应时间恶化到1秒需要的线程数就变成200个涨了10倍这也能反向说明为什么响应时间变慢会这么快拖垮系统。我强烈建议每次调整完参数后都做一次压测验证不要只在测试环境看数据最好在预发环境跑一轮确认限流效果和降级路径都符合预期。3.4 验证与调优压测与监控规则配好了怎么确认真的生效了最简单的验证方式是直接压。用压测工具把一个接口的QPS打到阈值以上观察被拒绝的请求占比是否为预期值。如果配置了500 QPS限流你打1000 QPS应该有大约一半的请求返回限流提示控制台对应资源上也会显示拒绝的次数。更关键的验证是熔断。压测时故意让下游服务接口报错或者把响应时间拉到很长观察熔断器是否在错误率达到阈值之后自动打开。这里有个小技巧让下游响应变慢比直接报错更容易触发熔断因为Sentinel的慢调用比例策略会把超时请求计入熔断统计。如果你们还在用只统计异常比例的策略建议加上慢调用比例维度因为下游服务长时间响应超时往往比直接报错更常见也更具有隐蔽性。监控方面Sentinel控制台可以看到每个资源的实时QPS、拒绝QPS、异常指标和响应时间。建议把它接入你现有的监控告警系统比如当某个资源的拒绝率突然升高或者熔断器打开直接触发告警通知到值班群。等出事了再打开控制台看永远是慢一步的。4. 常见问题与排查技巧实录4.1 限流误伤正常流量怎么办这是最常见的翻车场景。明明配置了限流阈值结果流量高峰还没到正常用户先开始报错了。排查思路是先看拒绝的请求到底是什么特征。如果拒绝量大但是QPS还没到阈值检查是不是配置了多个规则叠加比如接口级限流和方法级限流同时生效实际生效的阈值比你以为的低。还有一种可能是token bucket的预热问题Sentinel的令牌桶模式也分为冷启动和匀速器两种如果配置了冷启动刚启动时令牌桶是空的系统会自动控制通过的流量从一个小阈值慢慢爬升到目标阈值。如果你的应用频繁发布重启冷启动阶段就会表现为“明明没多少流量却在限流”。遇到这种问题第一步不是改阈值而是把被限流请求的时间点和日志捞出来和发布、重启时间做对照。大概率会发现其实是发布后的冷启动或规则叠加导致而不是阈值设低了。4.2 熔断恢复后瞬间被打爆熔断器在打开状态持续一段时间后进入半开只放少量试探请求。如果下游服务实际还没完全恢复这些试探请求大概率也是失败的熔断器重新打开这没问题。但如果下游已经恢复试探请求成功了熔断器关闭此时如果积压了大量客户端重试请求会在熔断关闭的瞬间集中涌入反而又把刚刚恢复的下游打瘫。针对这个问题两个思路。一个是客户端要有限流和退避机制不要把重试请求一下子全部放出来。另一个是把熔断时长稍微调长一点让半开状态的试探窗口能更从容地确认下游状态。理论上半开状态允许的请求数也可以配置比如Resilience4j里可以设置permitted-number-of-calls-in-half-open-state建议配5到10个宁可多试探几次也不要贸然放流量。4.3 降级兜底数据不一致问题降级返回的兜底数据最普遍的是缓存数据。但缓存数据有个天然问题可能已经过期了。比如库存服务的降级返回“有货”用户下单后才发现其实已经没货最终导致超卖或者用户投诉。解决方式要分业务性质。强一致性的场景比如库存扣减绝不能依赖降级数据做决策这种情况下宁可下单失败也不要返回虚假的“有货”。弱一致性的场景比如推荐列表、用户等级展示用缓存兜底完全没问题只要在返回数据上标记一个“降级数据”的标识方便追踪问题。另外一个原则是降级逻辑本身也要做监控。如果降级频繁触发说明下游问题已经持续一段时间了这时候要发出告警而不是让系统默默地在降级模式下运行。降级是为了争取时间修复问题时间到了问题还没修复就不是技术问题了是管理问题。4.4 几个容易忽视的坑先说超时配置。很多系统只给RPC调用的超时时间配了值但没注意连接池的连接超时导致请求在获取连接时就阻塞住白白占着线程。正确的配置是连接超时、读取超时、连接池获取超时都分别设置并且尽量设置成连接超时小于读取超时。然后是重试。重试不是越多越好默认的重试策略往往是“一个请求失败了立刻再试三次”这会给下游造成放大效应。更合理的做法是开启“仅对幂等接口重试”并且采用指数退避策略比如第一次失败等200ms第二次等400ms。如果下游本身已经故障重试只会加速它的崩溃这一点在配置里就要想清楚。还有日志和线程上下文。熔断降级触发的时候系统会走blockHandler或者fallback逻辑这些逻辑里如果有日志记录要注意不要引入复杂的远程调用否则降级逻辑本身可能成为新的故障点。降级路径要越简单越好最好只做本地缓存读取和固定数据返回。阻塞队列、线程池这些有界资源在降级路径里也别用避免互相等待。写在最后的经验这几套机制真正发挥作用靠的不是配置得多花哨而是你对自己的系统容量和依赖边界有多清楚。我个人在操作中最受益的一件事是把每个核心接口的容量数据、依赖关系和兜底策略做成了一张简单的表每次发布前对照检查一遍。线上出了故障第一件事就是看这张表快速判断是入口流量超了还是某个依赖出了问题然后再决定调整限流还是调整熔断参数。另外一个小建议规则和参数不要只放在控制台上手动配置尽量把规则持久化到配置中心比如Nacos这类服务里这样可以统一管理、审计和灰度发布。我们早期把规则写在代码里每次调优都要发版后来把规则挪到配置中心之后调整阈值只需要改配置再等几秒生效省了很多不必要的发布窗口。限流、熔断、降级这三个词我在各种分享里听过无数遍但真正落地的时候每个细节都值得反复琢磨。希望这篇能帮你少走一些弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进