《Java 服务端通信工程》7.2 熔断状态机

熔断不是布尔开关,而是滑动窗口、最小请求数、失败率边界与半开探针共同组成的并发状态机。本节用 AtomicReference 切状态、用一把锁保护窗口,给出完整 CircuitBreaker 与按下游实例分桶的接入代码,并说明迟到响应为什么必须用 generation 丢弃。

本节目标:把「下游坏了就先别打」从口号变成一个有明确边界、能在并发下正确切换的状态机。
适用版本: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_OPEN3 个探针全部成功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 与线程隔离

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收