本节目标:把慢依赖的并发占用限制在一个可计算的配额里,并保证超时、异常、取消三条路径都能把配额还回去。
适用版本: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 令牌桶与漏桶
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。