《Java 服务端通信工程》7.3 Bulkhead 与线程隔离

熔断挡住已经坏掉的下游,Bulkhead 挡住「还没坏但很慢」的下游。本节用 Semaphore 加有界等待队列实现并发隔离,给出 acquire/release 配对、CompletableFuture.orTimeout 与取消的完整代码,并对比线程池隔离的代价。

本节目标:把慢依赖的并发占用限制在一个可计算的配额里,并保证超时、异常、取消三条路径都能把配额还回去。
适用版本:JDK 25 + gRPC-Java 1.76.0(grpc-netty-shaded),纯 JDK 并发工具。

承接的问题

7.2 的熔断器处理「下游已经连续失败」。还有一种更隐蔽的情况:下游没有失败,只是变慢了。libraryd 的某个查询从 20 ms 涨到 400 ms,错误率还是 0,熔断器不会打开;调用方的线程却一个接一个卡在这条慢路径上。等到线程池被占满,其他本来很快的接口也开始排队,故障从一个下游扩散到整个进程。

Bulkhead 的思路是造船上的水密隔舱:给每类下游、每条重要路径分配独立的并发配额,一个舱进水不会沉掉整条船。实现上最轻的是 Semaphore,最重的是独立线程池。本节先把 Semaphore 版本写对,再说明线程池隔离什么时候值得。

linkd-v3 的客户端默认参数里没有单独的 bulkhead 数字,这里给 GetBook 路径取「并发 16、等待队列 32」。它和 8.2 服务端的「并发 64、队列 128」不是同一个方向的限额:前者限制本进程一次向下游发多少,后者限制服务端一次接多少。

核心机制一:Semaphore 限的是并发,不是速率

令牌桶限制「每秒多少个请求」,Bulkhead 限制「同一时刻多少个请求在途」。两者不能互相替代。一个请求耗时 1 秒、速率限制 10 rps,稳态在途就是 10 个;如果 Bulkhead 只给 5 个并发,第 6 个请求会被拒绝,尽管它的速率完全合规。

量令牌桶Bulkhead
控制对象单位时间的请求数同时在途的请求数
突发由 burst 决定由 maxConcurrent + queue 决定
拒绝时机取不到令牌并发与队列都满
与耗时的关系不关心单次耗时耗时越长,占用越久
典型错误把 rate 当成并发把 queue 当成并发

一个 Semaphore(maxConcurrent) 就能限制并发:tryAcquire() 成功才发请求,release() 在请求结束时归还。它不阻塞调用线程,失败立刻返回,适合「宁可拒绝也不排队」的入口。问题是没有排队能力:流量瞬时超过 16 个,第 17 个直接被拒,哪怕它只比前 16 个晚 1 ms。

要加一个有界队列,需要两个信号量:running 表示真正执行的并发,admitted 表示「执行 + 排队」的总容量。admitted 的许可数是 maxConcurrent + queueCapacity。请求先拿 admitted,拿到说明队列里还有位置;再拿 running,拿到才开始执行。running 用公平模式,先到先得,避免排队请求饿死。

package com.plume.linkd.v3.core;

import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

public final class Bulkhead {

    private final int maxConcurrent;
    private final int queueCapacity;
    private final Semaphore admitted;
    private final Semaphore running;
    private final AtomicLong rejected = new AtomicLong();
    private final AtomicLong timedOut = new AtomicLong();

    public Bulkhead(int maxConcurrent, int queueCapacity) {
        if (maxConcurrent < 1 || queueCapacity < 0) {
            throw new IllegalArgumentException("并发必须为正,队列不能为负");
        }
        this.maxConcurrent = maxConcurrent;
        this.queueCapacity = queueCapacity;
        this.admitted = new Semaphore(maxConcurrent + queueCapacity, true);
        this.running = new Semaphore(maxConcurrent, true);
    }

    public boolean tryAcquire() {
        return acquire(0L);
    }

    public boolean acquire(long waitNanos) {
        if (!admitted.tryAcquire()) {
            rejected.incrementAndGet();
            return false;
        }
        if (waitNanos <= 0L) {
            if (running.tryAcquire()) {
                return true;
            }
            admitted.release();
            rejected.incrementAndGet();
            return false;
        }
        try {
            if (running.tryAcquire(waitNanos, TimeUnit.NANOSECONDS)) {
                return true;
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        admitted.release();
        timedOut.incrementAndGet();
        return false;
    }

    public void release() {
        running.release();
        admitted.release();
    }

    public int inFlight() {
        return maxConcurrent - running.availablePermits();
    }

    public int queued() {
        return (maxConcurrent + queueCapacity - admitted.availablePermits()) - inFlight();
    }

    public long rejectedCount() {
        return rejected.get();
    }

    public long timedOutCount() {
        return timedOut.get();
    }
}

tryAcquire() 是「立即拒绝」模式:admitted 拿到但 running 拿不到,说明所有并发槽都被占,队列位置也不该占,直接归还 admitted 并计数拒绝。acquire(waitNanos) 是「有界排队」模式:最多等这么久,等不到就把 admitted 还回去。等待期间它确实占着一个队列位置,这正是队列容量的含义。

release() 必须与一次成功的 acquire 严格配对。它同时归还 running 和 admitted,少还一个会让容量永久缩小,多还一个会让许可数超过初始值,Bulkhead 形同虚设。后面用 try/finally 和 whenComplete 保证这一点。

inFlight() 和 queued() 是诊断用的近似值:availablePermits() 在并发下会变,两个数不是同一时刻的快照。它们适合打日志和画趋势,不适合做精确计费。

核心机制二:线程池隔离与信号量隔离的取舍

Semaphore 版本限制的是「调用方线程里同时有多少个下游调用」。如果调用方本身就是 Web 容器的线程池,一个慢调用仍然占着一个容器线程;Bulkhead 只是让占用数不超过 16,不会把慢调用从容器线程里挪走。线程池隔离则是给每类下游一个独立线程池,慢调用占的是这个池的线程,容器线程可以继续处理别的请求。

维度Semaphore Bulkhead独立线程池隔离
隔离强度逻辑并发隔离线程栈、队列、线程名都隔离
切换成本一次 CAS/锁线程上下文切换、任务排队
队列可见性自己维护ThreadPoolExecutor.getQueue()
取消调用方直接 cancel需要 Future.cancel,阻塞调用未必响应
内存几十字节每线程栈 + 队列对象
适用异步 RPC、非阻塞调用链阻塞 IO、第三方同步客户端

linkd-v3 的主路径是 gRPC 异步调用,Semaphore 足够:调用发起后不占线程,whenComplete 回来时释放许可。真正需要线程池隔离的是那些只能同步调用的老客户端,比如某个只提供阻塞 API 的第三方服务。给它们单独一个 ThreadPoolExecutor,队列用 ArrayBlockingQueue 并设上限,拒绝策略用 AbortPolicy,让拒绝显式暴露,而不是 CallerRunsPolicy 把任务退回调用线程——后者在事件循环上会把整个事件循环拖住。

线程池的队列长度不是并发数。new ThreadPoolExecutor(16, 16, 0, MILLISECONDS, new ArrayBlockingQueue<>(32)) 的并发上限是 16,队列能再放 32 个等待任务。把队列设成 LinkedBlockingQueue 无界,等于把 OOM 风险藏起来;Executors.newFixedThreadPool 的默认队列就是无界的,这是最常见的生产事故来源之一。

核心机制三:orTimeout 只结束等待,不结束工作

异步链路里经常看到这样的代码:

// 反例:超时后只让调用方拿到异常,底层调用还在跑
return CompletableFuture.supplyAsync(() -> slowCall())
        .orTimeout(300, TimeUnit.MILLISECONDS);

orTimeout 的作用是「给这个 CompletableFuture 注册一个定时器,到点用 TimeoutException 完成它」。它不会中断 supplyAsync 里的线程,也不会取消底层 gRPC 调用。如果这个 future 外面包着 Bulkhead,whenComplete 一执行就释放许可,而底层调用还在跑,Bulkhead 的并发计数立刻失真:看起来只有 16 个在途,实际可能有 30 个。

正确做法是把「调用方等待的超时」和「底层工作的完成」分开:

  • orTimeout 负责让调用方在 300 ms 内拿到失败,避免无限等待。
  • 底层 gRPC 调用必须设置自己的 deadline,且略小于 orTimeout,让它在定时器触发前以 DEADLINE_EXCEEDED 正常结束。
  • 许可的释放绑定在底层 future 的完成上,而不是绑定在 orTimeout 返回的派生 future 上。
  • 如果底层不是 gRPC、无法自己超时,就在 orTimeout 触发时显式调用取消钩子;钩子不保证立即生效,但至少发出信号。

在 linkd-v3 里落地:BulkheadExecutor

把上面的规则收进一个执行器:先拿许可,再启动任务;调用方拿到带超时的 future;许可在真实 future 完成时归还。任务用一个小接口暴露「启动」和「取消」,避免把 ClientCall 的类型泄漏到通用代码里。

package com.plume.linkd.v3.core;

import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public final class BulkheadExecutor {

    private final Bulkhead bulkhead;

    public BulkheadExecutor(Bulkhead bulkhead) {
        this.bulkhead = bulkhead;
    }

    public <T> CompletableFuture<T> submit(
            CancellableTask<T> task, Duration queueWait, Duration callTimeout) {
        if (!bulkhead.acquire(queueWait.toNanos())) {
            return CompletableFuture.failedFuture(new BulkheadFullException());
        }
        CompletableFuture<T> work;
        try {
            work = task.start();
        } catch (RuntimeException e) {
            bulkhead.release();
            return CompletableFuture.failedFuture(e);
        }
        CompletableFuture<T> bounded = work.orTimeout(
                callTimeout.toNanos(), TimeUnit.NANOSECONDS);
        bounded.whenComplete((value, error) -> {
            if (error instanceof TimeoutException) {
                task.cancel();
            }
        });
        work.whenComplete((value, error) -> bulkhead.release());
        return bounded;
    }

    public interface CancellableTask<T> {
        CompletableFuture<T> start();

        void cancel();
    }

    public static final class BulkheadFullException extends RuntimeException {
        public BulkheadFullException() {
            super("bulkhead queue full");
        }
    }
}

bounded 是给调用方看的 future,work 是真实工作。两个 whenComplete 分工明确:前者只在超时时发取消信号,后者在真实工作结束时归还许可。即使 orTimeout 先触发,许可也会等 work 完成才释放;如果 work 因为 gRPC deadline 在 280 ms 结束,许可就在 280 ms 归还,而不是 300 ms。

queueWait 建议设为 0 或很小(例如 10 ms)。Bulkhead 的队列不是用来吸收长排队的,而是吸收「毫秒级抖动」。在 300 ms 的单跳 deadline 下,排队 100 ms 已经吃掉了三分之一的预算;队列 32、并发 16 的最坏排队时间按 Little’s law 估算,是 32 / 16 × 平均服务时间,服务时间 100 ms 时约 200 ms,已经接近预算上限。

装配顺序是「重试循环在外,熔断和 Bulkhead 在每次尝试内」:

for attempt in 1..3:
    generation = breaker.tryAcquire(now)
    if generation < 0: return UNAVAILABLE
    if !bulkhead.acquire(queueWait): return RESOURCE_EXHAUSTED
    try:
        work = startCall(deadline)
        result = await(work)
        breaker.onSuccess(generation)
        return result
    catch e:
        breaker.onFailure(generation, now)
        if !retryable(e): throw
    finally:
        bulkhead.release()

Bulkhead 在每次尝试内获取、在尝试结束(成功或失败)时释放,退避睡眠期间不占许可。如果把 Bulkhead 包在重试循环外面,一次逻辑请求在三次尝试和两次退避期间一直占着一个并发槽,实际吞吐会下降一半以上。

常见错误与边界

错误一:release() 不在 finally 里。 任何一次异常、超时、取消都会漏还许可。漏几次之后,maxConcurrent 变成 0,所有请求都被拒绝,日志里却只有「bulkhead full」,看不出根因。

错误二:release() 调用两次。 常见于「超时回调释放一次、正常完成再释放一次」。许可数会超过初始值,Bulkhead 失去限制作用。一个许可只对应一个明确的完成路径;用 AtomicBoolean 或 whenComplete 的单次语义保证只释放一次。

错误三:把队列容量当成并发上限。 Bulkhead(16, 32) 的并发是 16,不是 48。admitted 的 48 个许可里,最多 16 个在执行,其余 32 个在等 running。如果把 admitted 的许可数直接当成并发,慢调用会同时压垮下游。

错误四:线程池用无界队列。 Executors.newFixedThreadPool(n) 的队列是 LinkedBlockingQueue,没有上限。请求堆积时不会拒绝,只会把内存吃光;拒绝策略永远不会触发。要显式构造 ThreadPoolExecutor 并传入有界队列。

错误五:orTimeout 后立刻释放许可。 这是异步链路最隐蔽的泄漏:调用方已经拿到超时,底层还在跑,Bulkhead 却认为槽位空了。许可必须绑定真实工作的完成。

错误六:在事件循环上做阻塞的 acquire。 Semaphore.acquire() 会阻塞线程;如果这个线程是 Netty 事件循环或 gRPC 传输线程,整个连接上的其他请求都会停住。异步链路只用 tryAcquire 或带超时的 tryAcquire(timeout),并且不要在回调线程里长时间等待。

边界:Bulkhead 是进程内的。 每个实例都有自己的 Bulkhead(16, 32),10 个实例合起来是 160 个并发,不是 16 个。要控制下游看到的总并发,要么按实例数分摊配额,要么在下游侧再做一次并发限制(8.2)。

边界:公平模式有成本。 new Semaphore(n, true) 维护 FIFO 队列,吞吐略低于非公平模式,但能避免排队请求饿死。Bulkhead 的竞争不激烈时,公平模式更可预测;如果压测发现锁竞争明显,再考虑非公平。

边界:排队时间也是 deadline 的一部分。 请求在 Bulkhead 队列里等待的时间,应该从总 deadline 里扣。等待 200 ms 后再发一个 300 ms 的调用,总耗时 500 ms,已经超过很多入口的 800 ms 预算。队列等待和超时预算要一起算,不能各管各的。

小结

  • 令牌桶限速率,Bulkhead 限并发;两者不能互相替代,慢调用会同时消耗两者。
  • Semaphore(maxConcurrent) 做立即拒绝,admitted = maxConcurrent + queueCapacity 加公平 running 做有界排队。
  • release() 必须与成功的 acquire 一一配对,异常、超时、取消三条路径都要还许可。
  • 线程池隔离适合阻塞客户端,代价是线程栈和上下文切换;异步 gRPC 主路径用信号量更轻。
  • orTimeout 只结束调用方的等待,不结束底层工作;许可要绑定真实 future 的完成,底层调用自己要有更短的 deadline。
  • 重试在 Bulkhead 外层,每次尝试单独获取和释放,退避期间不占并发槽。

到这里,重试、熔断、隔离三件套解决了「下游不可靠时调用方怎么活」。下一章换一个方向:流量本身太大时,怎么在入口按速率、并发和队列三层把请求挡住。8.1 从令牌桶和漏桶开始。

阅读导航:上一节 7.2 熔断状态机 | 下一节 8.1 令牌桶与漏桶

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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