微服务

熔断、降级与雪崩效应

一个非核心的推荐服务变慢,最后拖垮了整个下单链路——雪崩通常不是被「打垮」的,而是被「等垮」的。这篇讲清熔断器的三态转换、Sentinel 与 Hystrix 的差异、舱壁隔离和超时该怎么设。

2026-03-11 约 16 分钟 6280 字 微服务 熔断 降级 Sentinel

一、雪崩通常不是被「打垮」的

设想一个场景:订单详情页要调用商品、优惠券、推荐三个服务。某天推荐服务的数据库出了点问题,响应时间从 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_* 四个变量后自动开启。