微服务架构中,当某个下游依赖持续故障时,熔断器会进入打开状态快速失败请求,避免故障蔓延。但熔断器不能永远打开,它需要周期性地放行少量试探请求去探测下游服务是否恢复,这就是半开状态的核心职责。问题在于:放行的试探请求和正常用户流量在代码层面完全一样,系统如何知道当前这个请求是试探请求,从而只让它一个通过、其余继续快速失败?如果不做精准标记,可能出现成百上千个请求同时涌入半开状态的熔断器,把还没恢复的下游服务再次打崩,或者反过来,一个试探失败就重新打开熔断器,导致恢复周期被无限拉长。

为什么半开状态的请求标记如此重要
熔断器的状态机通常包含三个状态:关闭(正常放行所有请求)、打开(快速失败所有请求)、半开(有限度放行请求)。从打开切换到半开的时机一般是冷却期结束,但切换那一刻系统面临的流量并未减少。假设电商支付服务依赖的风控接口崩溃了,熔断器打开,10秒后进入半开状态。此时如果每秒有500个支付请求打到熔断器,没有标记机制的话,这500个请求全部会被当成试探请求放行。风控服务可能刚刚重启,处理能力有限,瞬间被打垮,熔断器只能再次打开,形成死循环。
另一个极端是标记过于严格,比如只允许第一个请求作为试探请求,但这个请求恰好因为网络抖动失败了,熔断器立即回到打开状态,又要等待完整的冷却期。即使下游服务实际上已经恢复,也得反复折腾多次才能真正关闭熔断器。理想的情况是:半开状态下,系统以受控的速率(比如每秒1个)放行试探请求,其余请求继续走快速失败逻辑,试探成功达到一定次数后彻底关闭熔断器。
要实现这种受控放行,前提就是能够在请求处理链路中准确识别出“这个请求是试探请求”。识别出来之后,才能对它做特殊处理:给它分配有限的探测许可、记录它的执行结果、根据结果驱动状态机流转。标记方法的可靠性直接决定了熔断器恢复机制的可靠性。
三种主流的试探请求标记方案对比
方案一:ThreadLocal标记。这是单机环境下最简单的实现。熔断器在决定放行试探请求时,往当前线程的ThreadLocal变量里写入一个标记位,后续的请求处理逻辑通过读取这个标记位判断是否是试探请求。这种方式实现简单、无侵入,但有个致命缺陷:在异步编程模型或线程池隔离场景下,请求的接入线程和业务处理线程往往不是同一个。比如Servlet容器把请求交给业务线程池后,ThreadLocal里的标记就丢失了,除非使用InheritableThreadLocal并且严格保证父子线程关系,这在Reactor、Kotlin协程等响应式框架中几乎无法实现。
方案二:请求上下文透传。在请求入口处(比如网关或熔断器拦截层)生成一个试探标记,通过HTTP Header或RPC附件传递给下游服务。下游服务的熔断器客户端解析这个标记,决定是否纳入试探统计。这种方式天然支持分布式环境,标记跟着请求走,不会因为线程切换丢失。但需要改造所有涉及的服务调用链路,基础设施成本较高,而且要防止外部恶意伪造试探标记头,绕过熔断保护直接冲击下游服务,因此必须配合内网信任或签名校验机制。
方案三:专用探测类与许可管理。不标记具体请求,而是熔断器主动构造一个独立的探测任务,用AtomicBoolean之类的原子变量管理探测许可。半开状态期间,只有成功CAS获取到许可的探测任务才能真正执行,其他请求一律快速失败。这是Resilience4j采用的核心思路,探测任务可以是直接调用下游服务的独立线程,也可以是包装在真实请求外层的代理逻辑。它的优势是把并发控制收敛到一个原子变量上,简单可靠,无需修改请求链路,对业务代码零侵入。
Resilience4j实战:用原子许可实现精准标记
下面通过一段可运行的代码演示原子许可方案的核心实现。假设有一个订单服务依赖库存接口,库存接口不稳定,需要配置熔断器。重点关注半开状态下的许可管理逻辑。
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
public class HalfOpenProbeDemo {
// 用于控制半开状态试探许可的原子变量
private final AtomicBoolean probePermit = new AtomicBoolean(false);
private final CircuitBreaker circuitBreaker;
public HalfOpenProbeDemo() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 失败率阈值50%
.waitDurationInOpenState(Duration.ofSeconds(10)) // 打开状态持续10秒
.permittedNumberOfCallsInHalfOpenState(3) // 半开状态最多放行3个试探请求
.minimumNumberOfCalls(5) // 至少5次调用才统计失败率
.slidingWindowSize(10) // 滑动窗口大小
.build();
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);
this.circuitBreaker = registry.circuitBreaker("inventoryService");
}
/**
* 模拟调用库存服务,带熔断保护和试探标记
*/
public String checkInventory(String orderId) {
// 先检查熔断器是否允许调用
if (!circuitBreaker.tryAcquirePermission()) {
return "FALLBACK: 熔断器已打开,快速返回降级结果";
}
// 关键逻辑:如果是半开状态,尝试获取试探许可
boolean isProbeCall = false;
if (circuitBreaker.getState() == CircuitBreaker.State.HALF_OPEN) {
// CAS获取许可,只有拿到true的请求才是真正的试探请求
isProbeCall = probePermit.compareAndSet(false, true);
if (!isProbeCall) {
// 没抢到许可,说明试探请求数已满,直接快速失败
circuitBreaker.releasePermission(); // 归还熔断器许可
return "FALLBACK: 试探请求数已满,等待现有探测结果";
}
System.out.println("请求 " + orderId + " 被标记为试探请求");
}
long start = System.currentTimeMillis();
try {
// 真实调用库存服务(模拟)
String result = circuitBreaker.executeSupplier(() -> callInventoryService(orderId));
System.out.println("调用成功,耗时: " + (System.currentTimeMillis() - start) + "ms");
return result;
} catch (Exception e) {
System.out.println("调用失败: " + e.getMessage());
throw e;
} finally {
// 无论成功失败,如果是试探请求必须释放许可
if (isProbeCall) {
probePermit.set(false);
System.out.println("试探请求 " + orderId + " 已释放许可");
}
}
}
private String callInventoryService(String orderId) {
// 模拟库存服务:80%概率成功,20%概率超时
if (Math.random() < 0.8) {
return "库存充足";
} else {
throw new RuntimeException("库存服务超时");
}
}
public static void main(String[] args) throws InterruptedException {
HalfOpenProbeDemo demo = new HalfOpenProbeDemo();
// 模拟持续调用,观察状态变化
for (int i = 0; i < 30; i++) {
String result = demo.checkInventory("ORDER-" + i);
System.out.println("第" + (i+1) + "次调用结果: " + result);
Thread.sleep(500);
}
}
}代码中有几个容易踩坑的点需要特别说明。首先是许可释放的位置:必须在finally块中释放试探许可,因为试探请求可能因为下游异常而抛出异常,如果释放逻辑写在try块末尾,异常路径下许可无法归还,后续的试探请求全部被拒绝,熔断器会永久卡在半开状态。其次是熔断器自身的许可(tryAcquirePermission)与试探许可(probePermit)是两层控制:前者控制熔断器级别的请求准入,后者专门用于半开状态下的试探请求并发控制,不能混为一谈。
运行这段代码,你会观察到三个阶段:初期失败率未达阈值,熔断器关闭,所有请求正常执行;当失败率超过50%,熔断器打开,所有请求快速失败返回降级结果;10秒冷却期后进入半开状态,日志中会出现“被标记为试探请求”的输出,且同一时刻最多只有一个请求拿到试探许可,其他请求即使通过熔断器检查也会被拒绝。试探成功3次后,熔断器彻底关闭,恢复正常放行。
分布式场景下的额外考量
上述原子许可方案在单实例服务中工作良好,但现代微服务往往是多实例部署。假设订单服务部署了5个实例,每个实例的熔断器独立统计,半开状态各自为政,可能同时有5个试探请求打到库存服务。如果库存服务的恢复能力有限,这仍然是潜在的冲击风险。解决思路有两条:一是引入集中式的熔断状态存储(比如Redis),所有实例共享同一个熔断器状态和试探许可,通过分布式锁保证同一时刻全局只有一个试探请求;二是使用服务网格(如Istio)的能力,把熔断逻辑下沉到Sidecar,由控制面统一协调试探请求的发放。
集中式方案要重点考虑Redis故障时的降级策略:如果状态存储不可用,各实例应该回退到本地熔断器逻辑,宁可多放行几个试探请求,也不能让熔断器完全失效。此外,试探请求的标记如果需要跨服务传递(比如A服务调用B服务,B服务调用C服务,试探请求需要穿透到最底层的C服务),就要结合前面提到的请求头透传方案,在HTTP Header中携带X-Probe-Request标识,下游服务的熔断器识别这个标识后将其纳入试探统计,同时清理该标识避免继续向更下层透传造成统计混乱。
无论选择哪种方案,核心原则是不变的:试探请求必须是受控的、可识别的、结果可观测的。受控意味着并发数严格限制,可识别意味着能与其他流量区分,结果可观测意味着成功失败都要驱动熔断器状态机正确流转。这三个原则满足,熔断器的半开状态才能真正发挥“安全试探、稳妥恢复”的设计初衷,而不是变成新的故障放大器。
熔断器模式半开状态Resilience4j修改时间:2026-09-13 14:22:52