本节目标:把「下游坏了就先别打」从口号变成一个有明确边界、能在并发下正确切换的状态机。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded),纯 JDK 实现。
承接的问题
7.1 的重试默认下游还有救:失败一次,等 20 ms 再试。但如果 libraryd 已经整体不可用,重试只是把一次失败变成三次,还把客户端的线程和 deadline 一起耗掉。熔断器的职责是在连续失败达到阈值后直接拒绝,给下游一个恢复窗口,过一段时间只放少量探针试探。
难点不在「打开」和「关闭」两个状态,而在三个地方:窗口里到底统计什么、失败率的边界怎么算、状态切换与并发完成回调怎么不打架。一个只写了 if (failures > 5) open() 的熔断器,在压测里通常表现为「该开的时候不开,该关的时候关不掉」。
linkd-v3 的基线是窗口 20 次调用、失败率阈值 50%、打开 5 秒、半开最多 3 个探针。下面的实现围绕这四个数字展开,但每个数字都给出可替换的理由。
核心机制一:滑动窗口统计的是「最近完成的调用」
固定窗口在边界会放行两倍流量:前一个窗口最后一毫秒失败 10 次,窗口翻转后计数器清零,下游继续挨打。滑动窗口没有这个问题,它始终看最近 N 次完成的调用。
| 设计点 | 选择 | 理由 |
|---|---|---|
| 窗口容量 | 20 次调用 | 小到能快速反应,大到不会被单次抖动打开 |
| 统计对象 | 已完成的调用 | 在途调用还没有结果,不能提前计入失败 |
| 失败定义 | 非 OK 且非业务可忽略码 | 业务 NOT_FOUND 不应打开熔断 |
| 最小请求数 | 10 次 | 窗口未填满一半时不计算失败率,避免 1/1=100% |
| 失败率边界 | >= 50% | 恰好一半失败也打开,边界写死成 > 会漏掉最常见的 10/20 |
| 窗口满后 | 覆盖最旧一条 | 用环形数组,O(1) 写入,O(1) 维护计数 |
「已完成」这个限定很重要。一个调用发出后 300 ms 才超时,在这 300 ms 内它既不算成功也不算失败。如果一发出就计入成功,失败率会被在途请求稀释;如果一发出就计入失败,又会误开。正确做法是在 onSuccess / onFailure 回调里才写窗口。
哪些错误算失败也要约定。UNAVAILABLE、DEADLINE_EXCEEDED、INTERNAL 算下游故障;NOT_FOUND、INVALID_ARGUMENT、PERMISSION_DENIED 是业务结果,不应打开熔断。否则攻击者只要持续请求不存在的 ISBN,就能把熔断器打开,让正常用户也拿不到书。RESOURCE_EXHAUSTED 视来源而定:下游主动限流说明它还在工作,通常不计失败;下游队列满到拒绝,可以计失败。linkd-v3 的选择是只把 UNAVAILABLE、DEADLINE_EXCEEDED、INTERNAL 计为失败,其余一律算「成功完成」(对熔断器而言),但业务错误仍原样返回给调用方。
窗口实现用 boolean[] 环形数组,index 指向下一个要覆盖的位置,completed 记录已填数量,failures 维护当前窗口内的失败数。窗口未满时只递增 completed;窗口已满时,覆盖前先看旧值是不是失败,是则 failures--。这样每次完成都是 O(1),不需要每次扫描 20 个槽位。
核心机制二:三个状态、四条边
状态机只有三个状态和四条边:
| 当前 | 条件 | 下一个 | 动作 |
|---|---|---|---|
CLOSED | 窗口样本足够且失败率 >= 50% | OPEN | 记录打开时刻,清空窗口 |
OPEN | 距打开时刻满 5 秒 | HALF_OPEN | 允许最多 3 个并发探针 |
HALF_OPEN | 3 个探针全部成功 | CLOSED | 清空窗口,恢复正常放行 |
HALF_OPEN | 任一探针失败 | OPEN | 重新计时,继续拒绝 |
OPEN 的到期判断是惰性的:没有请求到来时,不需要一个定时器把状态改成 HALF_OPEN;下一次 tryAcquire 发现 now - openedAt >= openDuration,再原地切换。这样避免了一个后台线程和一堆定时任务,也让状态只在实际有流量时推进。
HALF_OPEN 的「3 个探针」是并发上限,不是「总共只放 3 个请求」。3 个探针同时在途时,第 4 个请求继续被拒绝;探针完成后,成功数才增加。只要有一个探针失败,立即回到 OPEN,不等其余两个完成;它们的迟到结果不能把状态改回去。
失败率边界要在两个地方同时注意。第一,分母是 completed,不是窗口容量 20。窗口里只有 10 条记录、5 条失败时,失败率是 50%,不是 25%。第二,比较用 >=。大量实现写 failureRate > threshold,在 10/20 这个最常见的边界上不会打开,压测时表现为「失败率明明到了 50%,熔断器还不动」。
核心机制三:AtomicReference 切状态,一把锁保护窗口
并发下最容易出的 bug 是「状态和计数不一致」:线程 A 看到 OPEN 准备拒绝,线程 B 同时把它改成 CLOSED 并清空窗口;或者一个在熔断打开前发出的旧请求,在 5 秒后成功返回,把刚进入 HALF_OPEN 的状态直接改成 CLOSED。
解决办法有两层。第一,用一个 AtomicReference<State> 提供无锁的状态快照,读路径不必抢锁。第二,所有会同时修改状态和窗口的操作都放在同一把对象锁里,保证「判断—修改—清空」是原子的。只有读 state() 这种诊断接口才直接读 AtomicReference。
第二层是 generation。每次状态切换都递增一个代数;tryAcquire 返回当前代数,调用完成时把代数带回来。如果完成时的代数与当前不一致,说明这个结果属于上一轮状态,必须丢弃。没有它,一个迟到的成功就能把刚打开的熔断器关掉,熔断等于没做。
package com.plume.linkd.v3.core;
import java.time.Duration;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicReference;
public final class CircuitBreaker {
public enum State { CLOSED, OPEN, HALF_OPEN }
private final int windowSize;
private final int minRequests;
private final double failureRateThreshold;
private final long openNanos;
private final int halfOpenMaxProbes;
private final Object lock = new Object();
private final AtomicReference<State> state = new AtomicReference<>(State.CLOSED);
private final boolean[] outcomes;
private int index;
private int completed;
private int failures;
private long openedAt;
private int halfOpenInFlight;
private int halfOpenCompleted;
private long generation;
public CircuitBreaker(int window, double failureRateThreshold, Duration openDuration) {
this(window, Math.max(1, window / 2), failureRateThreshold, openDuration, 3);
}
public CircuitBreaker(int window, int minRequests, double failureRateThreshold,
Duration openDuration, int halfOpenMaxProbes) {
if (window < 1 || minRequests < 1 || minRequests > window) {
throw new IllegalArgumentException("窗口或最小请求数非法");
}
if (failureRateThreshold <= 0 || failureRateThreshold > 1) {
throw new IllegalArgumentException("失败率阈值必须在 (0, 1]");
}
if (openDuration.isNegative() || openDuration.isZero()) {
throw new IllegalArgumentException("打开时长必须为正");
}
if (halfOpenMaxProbes < 1) {
throw new IllegalArgumentException("半开探针数必须为正");
}
this.windowSize = window;
this.minRequests = minRequests;
this.failureRateThreshold = failureRateThreshold;
this.openNanos = openDuration.toNanos();
this.halfOpenMaxProbes = halfOpenMaxProbes;
this.outcomes = new boolean[window];
}
public long tryAcquire(long nowNanos) {
synchronized (lock) {
if (state.get() == State.OPEN) {
if (nowNanos - openedAt < openNanos) {
return -1L;
}
transition(State.HALF_OPEN);
halfOpenInFlight = 0;
halfOpenCompleted = 0;
}
if (state.get() == State.HALF_OPEN) {
if (halfOpenInFlight >= halfOpenMaxProbes) {
return -1L;
}
halfOpenInFlight++;
}
return generation;
}
}
public void onSuccess(long acquiredGeneration) {
synchronized (lock) {
if (acquiredGeneration != generation) {
return;
}
if (state.get() == State.HALF_OPEN) {
halfOpenInFlight = Math.max(0, halfOpenInFlight - 1);
if (++halfOpenCompleted >= halfOpenMaxProbes) {
resetWindow();
transition(State.CLOSED);
}
return;
}
if (state.get() == State.CLOSED) {
record(false);
}
}
}
public void onFailure(long acquiredGeneration, long nowNanos) {
synchronized (lock) {
if (acquiredGeneration != generation) {
return;
}
if (state.get() == State.HALF_OPEN) {
halfOpenInFlight = Math.max(0, halfOpenInFlight - 1);
open(nowNanos);
return;
}
if (state.get() == State.CLOSED) {
record(true);
if (completed >= minRequests
&& (double) failures / completed >= failureRateThreshold) {
open(nowNanos);
}
}
}
}
private void record(boolean failure) {
if (completed == windowSize && outcomes[index]) {
failures--;
}
if (completed < windowSize) {
completed++;
}
outcomes[index] = failure;
if (failure) {
failures++;
}
index = (index + 1) % windowSize;
}
private void open(long nowNanos) {
openedAt = nowNanos;
resetWindow();
transition(State.OPEN);
}
private void resetWindow() {
Arrays.fill(outcomes, false);
index = 0;
completed = 0;
failures = 0;
}
private void transition(State next) {
state.set(next);
generation++;
}
public State state() {
return state.get();
}
public int failuresInWindow() {
synchronized (lock) {
return failures;
}
}
}
tryAcquire 返回 -1 表示拒绝,否则返回代数。调用方必须把代数原样传给 onSuccess / onFailure;这是这个类最重要的使用约束。record 里「覆盖旧值」和「窗口未满」两个分支必须分开处理:窗口满时先看被覆盖的旧槽位是不是失败,是则把计数减掉,再写入新结果;窗口未满时只递增样本数,不减计数。
transition 里递增 generation 覆盖了三个切换点:CLOSED → OPEN、OPEN → HALF_OPEN、HALF_OPEN → CLOSED。HALF_OPEN → OPEN 也走 open(),同样递增。任何跨代的结果都会被第一个 if 挡掉。
在 linkd-v3 里落地:按实例分桶
熔断器不能全局一个。libraryd 有两个实例时,一个实例重启不应该把另一个也熔断。分桶的 key 要和 6.2 的连接池 key 一致:InetSocketAddress。这样连接池、熔断器、负载均衡看到的是同一组下游身份。
package com.plume.linkd.v3.core;
import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.GetBookRequest;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
public final class BreakerRegistry {
private final ConcurrentHashMap<InetSocketAddress, CircuitBreaker> breakers =
new ConcurrentHashMap<>();
public CircuitBreaker get(InetSocketAddress address) {
return breakers.computeIfAbsent(address, key ->
new CircuitBreaker(20, 10, 0.50, Duration.ofSeconds(5), 3));
}
public void remove(InetSocketAddress address) {
breakers.remove(address);
}
public Book getBook(InetSocketAddress target, LibraryCall delegate, GetBookRequest request) {
CircuitBreaker breaker = get(target);
long generation = breaker.tryAcquire(System.nanoTime());
if (generation < 0) {
throw Status.UNAVAILABLE
.withDescription("UPSTREAM_UNAVAILABLE: circuit open for " + target)
.asRuntimeException();
}
try {
Book book = delegate.call(request);
breaker.onSuccess(generation);
return book;
} catch (StatusRuntimeException e) {
if (isDownstreamFailure(e.getStatus().getCode())) {
breaker.onFailure(generation, System.nanoTime());
} else {
breaker.onSuccess(generation);
}
throw e;
}
}
private static boolean isDownstreamFailure(Status.Code code) {
return switch (code) {
case UNAVAILABLE, DEADLINE_EXCEEDED, INTERNAL -> true;
default -> false;
};
}
@FunctionalInterface
public interface LibraryCall {
Book call(GetBookRequest request);
}
}
接入顺序要讲清楚。熔断判断放在每一次重试尝试之前,而不是整个逻辑请求之前:第一次尝试时熔断是关的,失败后重试,第二次尝试前再问一次熔断器;如果这期间别的线程已经把熔断器打开,这次重试就被拒绝。被熔断拒绝的请求不计入失败,因为它根本没有到达下游;把它计入失败会让熔断器在打开后自己续期,永远关不掉。
remove 在实例下线时调用,避免 map 随着临时地址无限增长。地址集合来自 5.x 的服务发现,正常情况下大小等于实例数;如果服务发现返回的是临时端口,必须有上限和淘汰。
常见错误与边界
错误一:熔断器全局一个。 多实例时,一个实例挂掉会让所有实例都被拒绝。按 InetSocketAddress 分桶,和连接池的 key 对齐;如果要按方法隔离,再在 key 里加方法名,但不要每个 RPC 一个熔断器,那样样本永远不够。
错误二:把被拒绝的请求计入失败。 拒绝发生在 tryAcquire 返回 -1 之后,调用根本没有发出。把它计入失败,熔断器会在 OPEN 期间不断给自己增加失败数,5 秒到期后一放探针就再次打开。拒绝只应该计数指标,不应该进入窗口。
错误三:HALF_OPEN 无限放行。 只把状态改成 HALF_OPEN 就 return true,等于没熔断。必须限制并发探针数,探针失败立即重新打开。
错误四:不用 generation 丢弃迟到结果。 熔断打开前发出的请求可能几十毫秒后才返回,它的成功会把刚打开的熔断器关掉。给每次状态切换编号,完成时校验编号,是成本最低的修法。
错误五:最小请求数设为 0 或 1。 窗口里只有一条失败记录时,失败率是 100%,熔断器会被单次抖动打开。linkd-v3 用 10 次作为最小样本,窗口 20 次的一半;这个比例可以调,但必须大于 1。
边界:失败率 50% 的边界要写 >=。 10 次完成、5 次失败,正好 50%。写成 > 时这个最常见的情形不会触发;这不是理论问题,压测脚本很容易构造出恰好一半的失败。
边界:熔断器不区分「快失败」和「慢失败」。 DEADLINE_EXCEEDED 和 UNAVAILABLE 都算失败,但前者说明下游还在处理,后者说明连接都没建立。恢复策略不同:连接拒绝可以短时间重试,超时可能需要更长的冷却。需要区分时,把窗口拆成两个计数器,或者为不同状态码设不同权重。
边界:打开的 5 秒是经验值,不是协议常量。 下游是进程重启,5 秒够它起来;下游是数据库主从切换,可能需要 30 秒。参数要按恢复时间调,不要照抄。打开时间太短,探针会把还没恢复的下游再次打挂;太长,恢复后的流量要等很久才回来。
边界:熔断器只保护调用方。 它拒绝的是「本进程发往这个下游的请求」,不会让下游自己恢复,也不替代容量规划。下游容量不足时,熔断只是把雪崩推迟到打开周期结束。
小结
- 滑动窗口统计最近 20 次已完成调用,窗口未满 10 次时不计算失败率,避免小样本误开。
- 失败率用
failures / completed,比较用>= 50%;恰好一半失败也要打开。 - 三个状态、四条边;
OPEN到期是惰性的,HALF_OPEN最多 3 个并发探针,任一失败立即重新打开。 AtomicReference提供状态快照,一把对象锁保证状态与窗口的原子切换;generation丢弃跨代完成结果。- 熔断器按
InetSocketAddress分桶,key 与连接池一致;被拒绝的请求不计入失败。 - 熔断判断放在每次重试尝试之前,重试与熔断的组合才不会互相放大。
熔断解决「下游坏了别再打」,但它假设调用方还有线程可用。如果大量请求同时卡在慢调用上,线程先被耗尽,熔断器再准也来不及执行。7.3 用 Bulkhead 把并发隔离出来,让慢依赖只吃掉自己那份配额。
阅读导航:上一节 7.1 重试预算与退避 | 下一节 7.3 Bulkhead 与线程隔离
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。