熔断、降级与雪崩效应
一个非核心的推荐服务变慢,最后拖垮了整个下单链路——雪崩通常不是被「打垮」的,而是被「等垮」的。这篇讲清熔断器的三态转换、Sentinel 与 Hystrix 的差异、舱壁隔离和超时该怎么设。
一、雪崩通常不是被「打垮」的
设想一个场景:订单详情页要调用商品、优惠券、推荐三个服务。某天推荐服务的数据库出了点问题,响应时间从 50ms 涨到 5 秒。它没挂,只是变慢了。
然后订单服务挂了。
不是被流量打垮的——推荐服务变慢,订单服务里调用它的那些线程全部卡住等响应。Tomcat 的 200 个工作线程很快被占满,后续请求连线程都拿不到,开始排队。紧接着,连「查看订单列表」这种完全不依赖推荐的接口也访问不了了。
一个非核心服务的缓慢,拖垮了整个应用。
这就是雪崩。它的传导链条是:
依赖服务变慢
→ 调用方线程被长时间占用(不是被拒绝,是被挂住)
→ 调用方线程池耗尽
→ 调用方的所有接口(包括不依赖该服务的)全部不可用
→ 上一层的调用方重复这个过程
→ 整条链路崩溃
理解了这一点,就知道熔断要防的不是「服务挂了」——服务直接挂掉反而是好事,连接立刻失败,线程瞬间释放。真正致命的是「服务还活着但很慢」。熔断的核心目标是:在依赖变慢的早期就把请求切断,不让线程被挂住。
二、熔断器的三个状态
熔断器的命名来自电路里的保险丝:电流过载时保险丝熔断,切断电路,防止火灾。软件熔断器做的是同一件事——依赖故障率超标时切断调用,给依赖恢复的时间,同时保护自己。
它有三个状态:
| 状态 | 行为 | 如何离开 |
|---|---|---|
| CLOSED(关闭) | 正常放行所有请求,同时统计成功/失败/慢调用 | 指标超过阈值 → 转 OPEN |
| OPEN(打开) | 拒绝所有请求,不发起真实调用,直接走降级 | 等待熔断时长结束 → 转 HALF_OPEN |
| HALF_OPEN(半开) | 放行少量探测请求,试探依赖是否恢复 | 探测成功 → CLOSED;失败 → 回到 OPEN |
失败率超阈值
┌──────────────────────────────┐
│ ↓
┌────────┐ ┌──────────┐
│ CLOSED │ │ OPEN │
│ 正常 │ │ 拒绝所有 │
└────────┘ └──────────┘
↑ │
│ 探测成功 │ 熔断时长结束
│ ↓
│ ┌────────────┐
└──────────────────────│ HALF_OPEN │
探测失败 → 回 OPEN │ 放行少量探测│
└────────────┘
三个状态里最容易被忽略的是 HALF_OPEN。没有它,熔断器就只能在「全开」和「全关」之间跳——熔断时长一到就全部放行,如果依赖其实还没好,瞬间的流量又会把它打挂,形成「熔断—放行—再熔断」的周期性震荡。半开状态用一个或几个探测请求做试探,把恢复过程变得平滑可控。
熔断的三种触发策略
以 Sentinel 的 DegradeRule 为例,它支持三种降级策略:
| 策略 | grade |
触发条件 | 适合什么故障 |
|---|---|---|---|
| 慢调用比例 | 0 | 请求数 ≥ 最小请求数,且 RT > 阈值的请求占比 > 比例阈值 | 依赖变慢(最适合防雪崩) |
| 异常比例 | 1 | 请求数 ≥ 最小请求数,且异常占比 > 阈值 | 依赖大量抛异常 |
| 异常数 | 2 | 请求数 ≥ 最小请求数,且异常数 > 阈值 | 异常总量绝对值敏感的场景 |
DegradeRule rule = new DegradeRule("queryRecommend")
.setGrade(CircuitBreakerStrategy.SLOW_REQUEST_RATIO.getType()) // 慢调用比例
.setCount(1000) // 最大 RT:超过 1000ms 算慢调用
.setSlowRatioThreshold(0.5) // 慢调用占比超过 50% 触发熔断
.setMinRequestAmount(100) // 至少 100 个请求才统计,避免样本太少误判
.setStatIntervalMs(1000) // 统计窗口 1 秒
.setTimeWindow(5); // 熔断 5 秒后进入半开
慢调用比例是最应该优先配置的。异常比例只在依赖明确抛错时才起作用,而雪崩的元凶恰恰是「不抛错、只是慢」的那种故障——这类故障用异常比例永远监测不到,必须靠 RT 指标。
minRequestAmount 这个参数同样重要。不设它的话,启动后第一个请求超时,失败率就是 100%,熔断器会立刻打开。最小请求数保证了统计样本量,避免低流量时段的误触发。
Sentinel 与 Hystrix 的对比
| 维度 | Hystrix | Sentinel |
|---|---|---|
| 维护状态 | 停止维护(2018 年后进入维护模式) | 活跃维护 |
| 隔离方式 | 线程池隔离为主,信号量为辅 | 信号量隔离(并发线程数控制) |
| 隔离代价 | 每个依赖一个线程池,线程上下文切换开销大 | 无额外线程,开销极小 |
| 熔断策略 | 异常比例(仅此一种) | 慢调用比例 / 异常比例 / 异常数 |
| 半开控制 | 支持,但熔断时长固定 | 支持,可定义任意熔断时长 |
| 规则动态调整 | 需要接入 Archaius 配置 | 原生支持 Nacos / Apollo / ZooKeeper 等 |
| 限流能力 | 无(只做熔断) | 有,流量控制与熔断降级统一 |
| 实时监控 | Hystrix Dashboard(已停止维护) | Sentinel Dashboard,可实时改规则 |
两者最本质的差异是隔离模型。
Hystrix 的线程池隔离给每个下游依赖分配独立的线程池。好处是隔离彻底:推荐服务的线程池满了,不影响商品服务的线程池,而且天然支持超时(主线程 Future.get(timeout) 超时后直接放弃,不等下游)。代价是每个依赖都要维护一批线程,上下文切换成本高,一台机器上依赖多了线程数会爆炸。
Sentinel 用信号量隔离,本质是统计当前并发调用数,超过阈值就拒绝,不额外创建线程。开销小得多,但没有 Hystrix 那种「主线程可以随时放弃等待」的能力——超时控制要靠底层 HTTP/RPC 客户端自己的超时配置。所以用 Sentinel 的话,客户端超时必须自己配好,这是两套方案迁移时最容易漏掉的一点。
三、降级:熔断之后返回什么
熔断器打开之后,请求不再发往依赖,那用户看到什么?这就是降级要回答的问题。
降级不是「报错」,而是用一个可接受的次优结果代替失败。常见的降级策略:
| 策略 | 做法 | 适用 |
|---|---|---|
| 兜底数据 | 返回固定的默认值或空列表 | 展示型数据,推荐列表、排行榜 |
| 读缓存 | 从本地缓存/Redis 拿历史数据 | 商品详情、配置数据 |
| 默认值 | 返回业务上安全的默认结果 | 风控评分默认通过、库存默认充足 |
| 简化逻辑 | 跳过非核心步骤,只走主流程 | 下单时跳过积分计算、跳过风控 |
| 异步化 | 把同步调用改成写 MQ,稍后补偿 | 通知、日志、积分发放 |
| 友好提示 | 返回「当前排队人数过多,请稍后再试」 | 无合适兜底数据的场景 |
写降级逻辑时,这三条最容易踩坑:
第一,降级逻辑本身不能失败。 兜底数据要从本地缓存或固定值取,绝不能再去调一个远程服务——那等于把降级变成了又一次远程调用,依赖故障时会连环失败。常见事故是:降级方法里查了一次数据库,数据库也在同一个故障域里,降级链路跟着一起挂。
第二,降级要能开关。 用一个配置项控制降级逻辑是否生效,出问题时能随时切换,而不是重新发版。
@SentinelResource(
value = "queryRecommend",
fallback = "queryRecommendFallback", // 业务异常降级
blockHandler = "queryRecommendBlock" // 被限流/熔断时的降级
)
public List<Product> queryRecommend(Long userId) {
return recommendClient.query(userId);
}
// blockHandler:必须和原方法参数一致,末尾多一个 BlockException
public List<Product> queryRecommendBlock(Long userId, BlockException e) {
log.warn("推荐服务被熔断,userId={}", userId);
return hotProductCache.getTop10(); // 从本地缓存取兜底数据
}
注意 fallback 和 blockHandler 的区别:fallback 处理业务逻辑抛出的异常,blockHandler 专门处理 Sentinel 的 BlockException(限流、熔断触发)。两者都配才能覆盖完整——只配 fallback 的话,被熔断时的 BlockException 会直接抛给用户。
第三,降级要有监控。 降级是「悄悄发生」的,如果没埋点,依赖已经挂了两小时可能都没人知道。必须对降级次数做监控告警,降级率超过阈值就通知。
四、舱壁隔离
名字来自轮船的舱壁设计:船体被分隔成多个独立舱室,一个舱室进水不会导致整船沉没。软件里的舱壁隔离,是把不同的依赖/业务隔离到独立的资源池里,防止一个出问题拖垮全部。
前面提到的线程池隔离(Hystrix)就是一种舱壁。还有一种更轻量、更常见的做法:按业务维度拆线程池。
// 为不同的下游依赖配置独立线程池
@Bean("recommendExecutor")
public ThreadPoolTaskExecutor recommendExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("recommend-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
@Bean("couponExecutor")
public ThreadPoolTaskExecutor couponExecutor() {
// 独立配置,和 recommend 互不影响
...
}
关键点是这两个线程池没有任何共享。推荐服务变慢时,recommendExecutor 的线程被占满,但 couponExecutor 完全不受影响,优惠券查询照常工作。
线程池参数怎么设:
| 参数 | 取值思路 |
|---|---|
| corePoolSize | 下游服务的 TPS × 平均 RT(秒)。比如 TPS 200、RT 50ms → 10 个线程 |
| queueCapacity | 允许的排队深度。关键:队列不能太大,积压越多、超时越严重,还占内存 |
| rejectedExecutionHandler | 用 CallerRunsPolicy 让调用线程自己执行,形成自然的背压;或用 AbortPolicy 快速失败 |
| keepAliveTime | 应对峰值后回收线程 |
队列容量的取舍特别容易被做错。很多人觉得队列大一点能「缓冲」流量,实际上队列里排队的请求也要计入总超时。一个 RT 100ms 的服务,如果队列里积压了 500 个请求、只有 10 个线程处理,第 500 个请求要等到 5 秒后才开始执行——它早就超过客户端超时了,执行完也是白执行。这是典型的「做了无用功还占着资源」。
队列应该设小一点,快速拒绝比慢速堆积好得多。
五、超时:最基础也最容易被忽略的一环
熔断、隔离都是「故障发生之后」的补救。超时设置是故障发生时的第一道止损。
一个没有配超时的 HTTP 客户端,默认行为可能是无限等待。下游不响应时,线程就一直挂着——这正是雪崩链条的第一环。
超时设置有两条铁律:
第一,超时必须逐层收敛。 上游的超时必须大于下游超时之和,否则上游会在下游还没返回时就放弃,而下游还在继续执行——产生「上游以为失败、下游实际成功」的不一致。
网关超时 10s
└→ 订单服务超时 3s
└→ 库存服务超时 1s
└→ 数据库查询超时 800ms
每往下一层,超时都要明显缩小,留出「下层失败后上层还能重试一次」的余量。
第二,连接超时和读取超时要分开设。连接超时(timeout connect)是 TCP 握手的时间,应该很短(几百毫秒)——握手都慢的话说明网络或对端已经异常了。读取超时(read timeout)是一次请求的响应时间,按业务实际 RT 的 2~3 倍设。
# Feign + OkHttp 的超时配置
feign:
okhttp:
enabled: true
client:
config:
default:
connectTimeout: 500 # 连接超时 500ms
readTimeout: 2000 # 读取超时 2s
order-service: # 可以按服务名单独配置
readTimeout: 3000
还有一个常被忽略的点:重试会放大流量。一个请求失败后重试 3 次,下游故障时这股流量会变成原来的 4 倍,本来只是慢的服务会被重试打垮。所以重试必须满足三个条件:只在幂等接口上重试、配合指数退避、且总重试次数要少(通常 1~2 次)。更好的是配合熔断——熔断打开后请求根本不会发出去,重试也就无从放大。
六、完整的防护组合
把这几个手段串起来看,一条请求链路上的保护是分层的:
| 层次 | 手段 | 解决的问题 |
|---|---|---|
| 最外层 | 限流 | 流量超过系统容量,提前拒绝 |
| 中间层 | 舱壁隔离 | 限制单个依赖能占用的最大资源 |
| 调用时 | 超时 + 有限重试 | 单次调用不会无限挂住 |
| 持续故障 | 熔断 | 依赖故障时不再发起无效调用 |
| 故障之后 | 降级 | 给用户一个可接受的结果 |
| 事后 | 监控告警 | 让降级和熔断被看见 |
顺序不能倒过来。只配熔断不配超时,熔断器要等到统计数据够多才会打开,中间那段时间线程已经被挂满了。超时是最低成本的止损手段,应该最先配。
七、小结
- 雪崩的本质是线程被慢调用挂住,不是被流量打垮;防雪崩的重点是防「慢」而不是防「挂」
- 熔断器三态:CLOSED 正常统计 → OPEN 拒绝所有请求 → HALF_OPEN 少量探测;半开是避免「熔断—放行—再熔断」震荡的关键
- 熔断策略优先选慢调用比例:异常比例监测不到「不抛错只是慢」这类最危险的故障
minRequestAmount不能省,否则低流量时第一个超时请求就会触发熔断- Sentinel 用信号量隔离,Hystrix 用线程池隔离;前者开销小但没有内建超时能力,客户端超时必须自己配
- 降级逻辑本身不能依赖远程调用,必须能从本地缓存或固定值取到兜底数据,并且要可开关、有监控
- 舱壁隔离的核心是资源池不共享;线程池队列要设小,快速拒绝优于慢速堆积
- 超时是止损第一道防线,必须逐层收敛,并且区分连接超时和读取超时;重试要少而克制
熔断和限流经常被放在一起讲,但它们的性格相反:限流是主动拒绝多余的流量,熔断是承认下游已经不行、暂时不再调用。前者防止自己被打垮,后者防止被拖垮。两者都配齐,系统在依赖故障时才有一层能提供部分服务的缓冲垫。
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。