Skip to content

分布式熔断降级:Hystrix 线程池隔离 vs Sentinel 信号量隔离,半开状态恢复

问题

2020 年我接手过一个线上事故:订单服务依赖库存服务,库存服务的一个 SQL 慢查询导致 RT 从 5ms 飙升到 15s。订单服务的 Tomcat 线程池(默认 200 线程)在 30 秒内被全部阻塞,新请求排队等线程,Tomcat 的 acceptCount 队列塞满后开始拒绝连接。订单服务挂了,上游的网关也跟着超时,最终波及 6 个服务——这就是故障级联扩散(Cascading Failure)。

熔断降级要解决的核心问题:如何用最小的代价,把一个下游故障隔离在局部,不让它扩散到整个调用链

熔断器基础

熔断器(Circuit Breaker)是一个状态机,三个状态:

请求到达 → 判断熔断器状态
  ├─ Closed (关闭) → 放行请求,统计指标
  │    当错误率/慢调用比例/异常数达阈值 → 切换到 Open
  ├─ Open (熔断) → 直接快速失败,不调用下游
  │    经过熔断超时时间 → 切换到 Half-Open
  └─ Half-Open (半开) → 放行少量探活请求
        ├─ 探活成功 (成功率达标) → 回到 Closed,恢复正常
        └─ 探活失败 → 回到 Open,重新计时

关键参数:

  • 熔断阈值:错误率(如 50% 请求失败)、慢调用比例(如 50% 请求 RT > 500ms)、异常数(如 1 分钟内 5 个异常)
  • 熔断超时时间:Open 持续多久后尝试 Half-Open,单位秒
  • 探活请求数:Half-Open 时放行几个请求做检测
  • 最小请求数:触发熔断前,统计窗口内至少要有多少请求(防止偶发请求误触)

Hystrix 线程池隔离 vs Sentinel 信号量隔离

Hystrix 线程池隔离:原理与代价

Hystrix 为每个依赖分配独立线程池。请求进入 Hystrix 命令后,流程如下:

Tomcat 线程 (HTTP 请求线程)

  ├─ 创建 HystrixCommand
  ├─ 提交到 UserServicePool 线程池(独立线程池)
  │     ├─ 线程池队列未满 → 放入队列,等待线程执行
  │     │    线程池线程执行:调用下游服务
  │     │    下游慢 → 该线程阻塞,但 Tomcat 线程已返回
  │     │    等待超时 → 走 fallback
  │     └─ 队列已满 → 直接走 fallback(线程池隔离拒绝)
  └─ 获取结果(同步等待或异步回调)
java
// Hystrix 线程池隔离配置
@HystrixCommand(
    groupKey = "UserService",
    commandKey = "getUserById",
    threadPoolKey = "UserServicePool",
    threadPoolProperties = {
        @HystrixProperty(name = "coreSize", value = "10"),
        @HystrixProperty(name = "maximumSize", value = "20"),
        @HystrixProperty(name = "maxQueueSize", value = "20"),
        @HystrixProperty(name = "keepAliveTimeMinutes", value = "1"),
        @HystrixProperty(name = "allowMaximumSizeToDivergeFromCoreSize", value = "true")
    },
    commandProperties = {
        @HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "1000"),
        @HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"),
        @HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50"),
        @HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "5000")
    },
    fallbackMethod = "getUserFallback"
)
public User getUserById(Long id) {
    return restTemplate.getForObject("http://user-service/users/" + id, User.class);
}

优点:隔离性最强。下游挂了,最多耗尽自己的线程池,其他依赖不受影响。上文的订单事故,如果引入 Hystrix 线程池隔离,库存服务慢只会阻塞库存线程池的那 10 个线程,订单线程池的 200 个 Tomcat 线程不受影响,订单流程照常走 fallback(返回缓存库存或提示暂时不可用)。

代价分析

  1. 线程切换开销:每个请求多一次线程上下文切换。线程切换耗时约 1-3μs(上下文保存/恢复寄存器、TLB 刷新)。一个 QPS 1000 的服务,10 个依赖,每秒 10000 次切换,10μs/次 = 100ms/秒的 CPU 开销,在 1ms 级请求中占比 10%。

  2. 内存开销:每个线程池 10 个线程,10 个依赖 = 100 个线程。每个线程栈默认 1MB(-Xss 设置),仅线程栈 100MB。加上线程池队列中的请求对象,轻松 150MB+。

  3. 调优复杂:coreSize 取决于依赖的 QPS 和平均 RT。公式:coreSize = QPS × P99 RT / 1000。如果 QPS=500,P99 RT=50ms,则 coreSize ≥ 25。调不对,要么线程池不足导致频繁拒绝,要么线程池过大浪费资源。

正因为这些代价,Hystrix 官方在 2018 年进入维护模式,Netflix 不再推荐新项目使用。

Sentinel 信号量隔离:轻量级方案

Sentinel 在调用线程中直接执行下游调用,不创建新线程,只通过信号量控制并发数。

Tomcat 线程 (HTTP 请求线程)

  ├─ 请求到达 Sentinel 的 DegradeSlot
  ├─ 尝试获取信号量(Semaphore.tryAcquire)
  │     ├─ 获取成功 → 在当前线程直接调用下游
  │     │    下游慢 → 当前线程被阻塞
  │     └─ 获取失败 → 走 blockHandler(信号量拒绝)
  └─ 释放信号量(finally 块中 Semaphore.release)
java
// Sentinel 信号量 + 熔断降级配置
@SentinelResource(
    value = "getUserById",
    blockHandler = "handleBlock",
    fallback = "getUserFallback"
)
public User getUserById(Long id) {
    return restTemplate.getForObject("http://user-service/users/" + id, User.class);
}

// 配置熔断降级规则(慢调用比例)
DegradeRule rule = new DegradeRule("getUserById");
rule.setGrade(RuleConstant.DEGRADE_GRADE_RT);
rule.setCount(500);        // 最大 RT 500ms
rule.setTimeWindow(10);    // 熔断后 10 秒进入半开
rule.setMinRequestAmount(5);
rule.setStatIntervalMs(1000);
rule.setSlowRatioThreshold(0.5);
DegradeRuleManager.loadRules(Collections.singletonList(rule));

// 配置并发线程数控制
FlowRule threadRule = new FlowRule();
threadRule.setResource("getUserById");
threadRule.setGrade(RuleConstant.FLOW_GRADE_THREAD);
threadRule.setCount(20);    // 最大并发 20 个线程
FlowRuleManager.loadRules(Collections.singletonList(threadRule));

优点:无线程切换、无额外内存、配置简单。在同等 QPS 下,Sentinel 的延迟比 Hystrix 低 30-50%(官方压测数据)。

缺点:下游慢会直接阻塞调用线程。如果 Tomcat 线程池 200 个线程,库存服务慢调用让 20 个线程被阻塞,剩下 180 个线程还能处理其他请求。但如果信号量设得太高(如 150),下游慢调用会占满大部分 Tomcat 线程。

选型对比

维度Hystrix 线程池隔离Sentinel 信号量隔离
线程模型独立线程池,请求切换线程调用线程直接执行
隔离粒度完全隔离——下游慢只影响本线程池部分隔离——下游慢会阻塞调用线程
线程切换开销~3μs/次,高 QPS 下不可忽略0
内存开销每个线程池 ~1MB/线程 x 线程数几乎为 0
超时控制独立线程可精确超时需配合 Future.get(timeout)HttpClient 超时
适用场景下游不可靠、强隔离需求低延迟、高吞吐、大多数业务场景
维护状态2018 年进入维护模式活跃开发,社区支持

新项目选型建议:首选 Sentinel 信号量隔离,对大多数场景够用。如果确实需要线程隔离(比如下游极其不稳定,且不能承受调用线程被阻塞),用 Resilience4j 的线程池隔离——它是 Hystrix 的轻量替代品,依赖 CircuitBreaker + ThreadPoolBulkhead 组合实现。

半开状态恢复机制:最容易被忽视的细节

半开状态是熔断器能否正确恢复的关键。设计不好,会出现反复熔断震荡——下游刚恢复,大量探活请求涌入,又被压垮,熔断器反复切换 Open ↔ Half-Open,服务永远无法恢复。

Sentinel 的滑动窗口统计与半开实现

核心流程:

1. 统计阶段(Closed 状态)
   Sentinel 使用 LeapArray(滑动窗口)记录每个请求的 RT 和结果
   窗口:1 秒一个桶,共 10 个桶(10 秒统计窗口)
   ├─ 请求到达 → 写入当前时间窗口的桶
   ├─ 统计过去 10 秒的慢调用比例
   └─ 慢调用比例 ≥ 50% + 请求数 ≥ 5 → 切换到 Open

2. 熔断阶段(Open 状态)
   ├─ 所有请求直接抛出 DegradeException
   ├─ 触发 blockHandler 或 fallback
   └─ 计时器开始,timeWindow 秒后切换到 Half-Open

3. 半开阶段(Half-Open 状态)
   ├─ 放行 1 个探活请求(最多 1 个,不可配置)
   ├─ 探活成功 → 计数器清零,切换到 Closed
   └─ 探活失败 → 回到 Open,重新计时

探活请求数为什么是 1?

假设架构:10 个实例调用同一个下游服务 B。Half-Open 时:

  • 如果每个实例放行 5 个探活请求 → B 瞬间收到 50 个请求
  • 如果 B 刚恢复(缓存重建中、连接池预热中),50 个并发请求可能直接把它打回原形
  • 探活请求数 = 1,10 个实例同时探活也才 10 个请求,B 能扛住

生产踩坑:有次我们把探活请求数从 1 改为 3(为了"更快地恢复"),结果下午高峰熔断器震荡了 40 分钟。下游 Redis 集群主从切换后,Sentinel 每次 Half-Open 放 3 个请求,10 个实例 × 3 = 30 个请求,Redis 集群的 RDB 持久化还没完成,QPS 超过阈值,又被熔断。最后手动关闭了熔断才恢复。

熔断超时时间设置

超时时间 = 下游平均恢复时间 × 2~3 倍。

场景典型恢复时间建议熔断超时
数据库主从切换5-10 秒30 秒
外部 HTTP 服务5-30 秒60 秒
Redis 集群故障转移3-10 秒30 秒
磁盘满清理1-5 分钟5 分钟

反复熔断震荡的应对

如果熔断器反复切换 Closed → Open → Half-Open → Open,说明:

  1. 熔断超时时间太短:下游还没恢复就尝试探活,失败后立即再试,恶性循环。加大 timeWindow。
  2. 探活请求数太多:一台机器放 1 个,多台机器加起来还是太多。考虑分布式熔断(如 Sentinel 的集群流控)统一控制探活总请求数。
  3. 下游恢复慢热:数据库连接池、缓存预热需要时间。探活成功后不要立即恢复全量流量,用渐进式恢复——先恢复 10% 流量,观察 30 秒,再逐步增加到 100%。
java
// 渐进式恢复示例:基于 Sentinel 的 EventListener
@EventListener
public void onCircuitBreakerStateChange(CircuitBreakerStateChangeEvent event) {
    if (event.getNewState() == CircuitBreakerState.HALF_OPEN) {
        // 探活成功,进入 CLOSED 后的限流
        // 后续 30 秒内限制最大并发为正常值的 10%
        FlowRule tempRule = new FlowRule(event.getResource());
        tempRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
        tempRule.setCount(normalQps * 10 / 100);
        FlowRuleManager.loadRules(Collections.singletonList(tempRule));
        
        // 30 秒后恢复全量
        scheduler.schedule(() -> {
            tempRule.setCount(normalQps);
            FlowRuleManager.loadRules(Collections.singletonList(tempRule));
        }, 30, TimeUnit.SECONDS);
    }
}

总结

方案隔离机制性能适用场景
Hystrix 线程池隔离独立线程池低(线程切换 + 内存开销大)下游不可靠、需要强隔离
Sentinel 信号量隔离信号量并发控制高(无额外开销)低延迟、高吞吐、大多数场景
Resilience4j 线程池隔离独立线程池中(比 Hystrix 轻量)Hystrix 迁移替代方案

核心原则

  • 新项目选 Sentinel,信号量隔离对 90% 的场景够用
  • 半开状态的探活请求数 = 1,不要改成 2 或 3
  • 熔断超时时间设置为下游恢复时间的 2-3 倍
  • 考虑渐进式恢复,防止熔断震荡
  • 记住:恢复速度要慢于熔断速度,刚恢复的服务最脆弱

面试时如果被问到熔断降级,不要只背出三个状态。把线程池隔离的代价(线程切换 3μs、线程栈 1MB/线程、QPS 调优公式)和半开状态的探活设计(为什么是 1、熔断震荡的案例)说清楚,面试官会觉得你真正踩过坑。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。