承接的问题
13.1 的扇出有一个隐含前提:三个下游都会在可接受的时间内给出结果。真实系统里最先出问题的恰恰是这个前提:
push网关卡住不返回,整条聚合链跟着挂住;sms返回 502,allOf.join()抛出的异常没人接,用户看到 500;- 页面已经等了 3 秒,前端超时了,后端还在傻等。
异步代码的「正常路径」只有几行,剩下的大部分代码都在处理这三种情况。这一节按「异常 → 超时 → 取消」的顺序,把每个 API 的真实行为用本机输出量出来。
三个异常钩子的差别
三个钩子都会在异常时被调用,差别在它们能不能改变结果:
| 钩子 | 签名 | 何时调用 | 返回 | 能否恢复 |
|---|---|---|---|---|
exceptionally | Function<Throwable, T> | 仅异常 | 新 stage | 能,返回替代值 |
handle | BiFunction<T, Throwable, U> | 总是 | 新 stage | 能,成功/失败都经过它 |
whenComplete | BiConsumer<T, Throwable> | 总是 | 原结果的 stage | 不能,异常继续往下传 |
CompletableFuture<String> ok = CompletableFuture.completedFuture("mail:ok");
ok.exceptionally(e -> "fallback"); // 不调用,结果仍是 mail:ok
failing().exceptionally(e -> "fallback"); // 调用,结果变成 fallback
ok.handle((v, e) -> v + "/err=" + e); // v=mail:ok, e=null
failing().handle((v, e) -> "v=" + v + ",err=" + e); // v=null, e 非空
ok.whenComplete((v, e) -> log(v, e)); // 记录后结果不变
failing().whenComplete((v, e) -> log(v, e)); // 记录后异常继续传
本机输出:
== 三个异常钩子的调用条件 ==
exceptionally on success = mail:ok
exceptionally on failure = fallback(CompletionException)
handle on success = mail:ok/err=null
handle on failure = v=null,err=CompletionException
whenComplete success 看到 v=mail:ok, e=null
whenComplete failure 看到 v=null, e=CompletionException
whenComplete 之后 join 仍抛出 IllegalStateException
最后一行是 whenComplete 的关键:它跑完了,日志也写了,但后面 join 依然抛异常。这正是它该有的行为——whenComplete 是观察者,不是处理器。埋点、记日志用它;要改变结果用 exceptionally 或 handle。
钩子里拿到的异常可能被包了一层
注意 exceptionally on failure = fallback(CompletionException):原始异常是 IllegalStateException,钩子看到的却是 CompletionException。而 whenComplete 之后 join 抛出的又是解包后的 IllegalStateException。
规则是:异常在 stage 之间传递时会被 CompletionException 包一层,终结操作(join/get)会解开一层。所以钩子里按业务异常类型做判断,很容易全部落到 else 分支:
static Throwable unwrap(Throwable t) {
Throwable cur = t;
while (cur instanceof CompletionException && cur.getCause() != null) {
cur = cur.getCause();
}
return cur;
}
这个 while 是必要的:多层 stage 叠加时可能不止包一层。13.1 的 handle 把异常压成值的写法,也应该先 unwrap 再分类,否则降级策略永远匹配不到 LoanException。
orTimeout 与 completeOnTimeout
两个方法都要求 CompletableFuture(CompletionStage 接口上没有),都会在超时后把 future 置为已完成,区别是完成成什么:
cf.orTimeout(200, TimeUnit.MILLISECONDS); // 置为 TimeoutException
cf.completeOnTimeout("sms:degraded", 200, TimeUnit.MILLISECONDS); // 置为给定值
实测:任务体固定 sleep(800),超时值 200ms。
== orTimeout 实测 ==
round1 触发时刻 - 提交时刻 = 203ms(超时值 200ms),cause=TimeoutException
800ms 后:底层任务跑完? true,被中断? false,future 仍异常? true
round2 触发时刻 - 提交时刻 = 205ms(超时值 200ms),cause=TimeoutException
800ms 后:底层任务跑完? true,被中断? false,future 仍异常? true
round3 触发时刻 - 提交时刻 = 208ms(超时值 200ms),cause=TimeoutException
800ms 后:底层任务跑完? true,被中断? false,future 仍异常? true
== completeOnTimeout 实测 ==
返回值 = sms:degraded,触发时刻 - 提交时刻 = 200ms(超时值 200ms)
800ms 后:底层任务跑完? true,被中断? false,future 最终值 = sms:degraded
四个可核对的量:
- 触发时刻减提交时刻 = 203/205/208ms,比 200ms 多 3–8ms,这是调度抖动,不是超时值不准。三次数值都贴着 200ms,说明计时基准是「提交时刻」而不是「任务开始执行」。
- 底层任务没有被中断(
被中断? false),800ms 后照常跑完。orTimeout只改变 future 的状态,不碰执行中的线程。 - future 不会因为底层后来完成而恢复(
future 仍异常? true)。超时是一次性的终态。 completeOnTimeout的降级值会盖住真实结果:800ms 后任务返回了sms:ok,但 future 的值仍然是sms:degraded。晚到的结果被丢弃,不会二次完成。
还有一个容易忽略的点:计时从提交时刻开始,哪怕任务还在队列里没开始跑。把唯一的线程占住 600ms,再把一个任务排进去并设 100ms 超时:
排队中的任务,超时触发于 +104ms(超时值 100ms)
任务一次都没被执行,future 已经在 104ms 变成 TimeoutException。所以「超时」保护的是调用方的等待预算,不是任务的执行时长。
第 2 条最要命:超时不等于释放资源。线程还占着、连接还开着、下游还在被调用。如果每个请求都超时 200ms 但底层要跑 30 秒,线程池会在几分钟内被占满。所以 orTimeout 必须和下游客端自己的连接/读超时配合,CompletableFuture 这一层只是「不再等」,不是「让它停」。
实现上,超时依赖一个共享的调度线程。注册一个 orTimeout 之后,线程列表里会多出一个实测名为 ForkJoinPool.commonPool-delayScheduler 的线程;三个超时任务共用它,而不是每个 future 一个线程。
取消:cancel 不中断底层任务
CompletableFuture.cancel(boolean) 的参数叫 mayInterruptIfRunning,这个命名极具误导性——它在 CompletableFuture 上的实现没有任何效果。
CompletableFuture<String> running = slow("mail", 500, finished, interrupted);
Thread.sleep(50);
boolean cancelled = running.cancel(true);
实测:
== cancel(true) 实测 ==
cancel(true) 返回 true,isCancelled = true
join 抛出 CancellationException
600ms 后:底层任务跑完? true,被中断? false(取消后经过 610ms)
cancel 做三件事:把 future 置为已取消、让后续 join 抛 CancellationException、让依赖它的 stage 收到取消。它不会给执行任务的线程发中断,任务照样跑完。
这和 Future.cancel(true) 不一样:FutureTask 会真的去 interrupt 工作线程。CompletableFuture 的异步任务跑在外部执行器里,future 本身拿不到那个线程的引用,所以无从中断。
| 类型 | cancel(true) 是否中断执行线程 | 取消后任务是否继续 |
|---|---|---|
FutureTask(Executors 返回的 Future) | 是,interrupt 工作线程 | 取决于任务是否响应中断 |
CompletableFuture | 否,参数无效果 | 继续跑完 |
ScheduledFuture | 是 | 取决于任务是否响应中断 |
真要停下来只有两条路:
- 协作式取消:任务内部检查一个
volatile标志或响应中断,把「停止」写进任务体(第 6 章的两阶段终止); - 让任务可被中断:自己持有底层
Future或使用结构化并发的作用域,由作用域统一取消(15.2 展开)。
public final class CancellableSend {
private final NotificationGateway gateway;
private final AtomicBoolean cancelled = new AtomicBoolean();
public CancellableSend(NotificationGateway gateway) {
this.gateway = gateway;
}
public CompletableFuture<String> send(String channel, String message) {
return CompletableFuture.supplyAsync(() -> {
for (int attempt = 0; attempt < 100; attempt++) {
if (cancelled.get() || Thread.currentThread().isInterrupted()) {
return channel + ":cancelled"; // 协作点,主动退出
}
if (trySend(channel, message)) {
return channel + ":ok";
}
sleepQuietly(20);
}
return channel + ":timeout";
});
}
public void cancel() {
cancelled.set(true); // 只是置标志,不碰线程
}
private boolean trySend(String channel, String message) {
return gateway.send(channel, "member", message) != null;
}
private static void sleepQuietly(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
注意这个 cancel() 不是 CompletableFuture.cancel:它自己维护标志,任务在循环里检查,所以真的能停。代价是每个耗时点都要埋一个协作点,下游阻塞在 socket 读上时仍然停不下来——那要靠 socket 超时。
顺带一句:取消一个 allOf 的返回值也不会取消它的成员,和 13.1 的失败语义一致——CompletableFuture 没有取消传播。
超时降级链
把上面的东西拼起来:三路通知,每路 200ms 预算,超时或失败都降级为可重试的占位结果。
CompletableFuture<String> mail = CompletableFuture.supplyAsync(() -> send("mail", 60))
.orTimeout(200, TimeUnit.MILLISECONDS)
.exceptionally(e -> "mail:degraded(" + e.getClass().getSimpleName() + ")");
CompletableFuture<String> sms = CompletableFuture.supplyAsync(() -> {
throw new IllegalStateException("sms 502");
})
.orTimeout(200, TimeUnit.MILLISECONDS)
.exceptionally(e -> "sms:degraded(" + e.getClass().getSimpleName() + ")");
CompletableFuture<String> push = CompletableFuture.supplyAsync(() -> send("push", 800))
.orTimeout(200, TimeUnit.MILLISECONDS)
.exceptionally(e -> "push:degraded(" + e.getClass().getSimpleName() + ")");
本机输出:
降级链结果 = [mail:sent, sms:degraded(CompletionException), push:degraded(TimeoutException)]
整体耗时 = 217ms(预算 200ms,串行应 >= 860ms)
三个数字值得对照:
- 整体 217ms,约等于「最慢的一路 = 200ms 超时」加调度抖动,而不是 60 + 0 + 800 的串行和(≥860ms)。
- 同样是失败,
sms拿到的是CompletionException,push拿到的是TimeoutException。前者是任务体里抛的异常经 stage 传递被包装,后者是orTimeout自己完成的。所以exceptionally里必须用unwrap归一化,否则按类型分支的降级逻辑会漏掉一半。 mail在 60ms 就已经完成,但整条链还是等到了 200ms——因为allOf要等齐。这和 13.1 的结论一致。
预算怎么定:每路的超时值应该由「调用方愿意等的总时间」倒推,而不是每路都抄同一个常量。总预算 300ms、三路并行,每路给 250ms 是合理的;如果某一层内部还要再调两次下游,这 250ms 要往下分,不能每层都重新给 250ms,否则实际最坏耗时是各层之和。
三层超时各管一段
| 层 | 典型设置 | 负责什么 | 缺了会怎样 |
|---|---|---|---|
| 下游客户端(HTTP/socket) | connect 200ms / read 500ms | 真正释放连接与线程 | 线程被占住,池耗尽 |
CompletableFuture.orTimeout | 每路 200ms | 让调用方不再等 | 调用方挂死,请求堆积 |
| 调用方总预算 | 前端或网关 1s | 端到端兜底 | 用户看到转圈,重试放大流量 |
只有第一层能真正止损。orTimeout 只做到「不再等」,没做到「不再跑」——两层都要有,缺一层都会在故障时放大成资源问题。
在 libraryd-conc 里落地
ResilientNotifier 在 13.1 的扇出上加两层:每路独立预算,降级结果进重试队列。
public final class ResilientNotifier {
private final ExecutorService pool;
private final NotificationGateway gateway;
private final RetryQueue retryQueue;
private final Duration budget;
public NotifyReport notify(String memberId, String message) {
Map<String, CompletableFuture<ChannelResult>> futures = new LinkedHashMap<>();
for (String channel : List.of("mail", "sms", "push")) {
futures.put(channel, CompletableFuture
.supplyAsync(() -> gateway.send(channel, memberId, message), pool)
.orTimeout(budget.toMillis(), TimeUnit.MILLISECONDS)
.handle((receipt, error) -> {
if (error == null) {
return ChannelResult.ok(channel, receipt);
}
Throwable cause = unwrap(error);
if (!(cause instanceof TimeoutException)) {
return ChannelResult.failed(channel, cause); // 不可重试
}
retryQueue.enqueue(channel, memberId, message);
return ChannelResult.degraded(channel, cause);
}));
}
CompletableFuture.allOf(futures.values().toArray(new CompletableFuture[0])).join();
return new NotifyReport(memberId, futures.values().stream()
.map(CompletableFuture::join).toList());
}
}
四个设计点:
handle放在orTimeout之后:顺序反了,超时异常就绕过了兜底,allOf会直接抛。这类 stage 的顺序错误在 code review 里很难看出来,建议固定写成「异步 → 超时 → 兜底」三段。- 降级不是静默丢弃,而是进
RetryQueue(11.2 的延迟队列)。用户拿到的是「已受理」,补偿是异步的。 - 重试队列必须有上限和退避,否则下游一次故障会换来一次超时风暴,队列被打爆只是把故障推迟十分钟(卷三第 10 章展开)。
unwrap之后再判断可重试性:这里只把TimeoutException视为可重试,其他异常直接失败,避免把参数错误也塞进重试队列。同一个unwrap在预约路径上用于识别LoanException的ErrorCode。
RetryQueue、ChannelResult、NotifyReport 是端口和返回值类型;领域侧的 LoanService.reserve 仍然同步返回 Reservation,通知是它之后的一步,不改变领域契约。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
在 exceptionally 里按具体异常类型判断 | 异常被包成 CompletionException,分支永远不命中 | 先 unwrap 再分类 |
指望 whenComplete 吞掉异常 | 异常继续下传,上层仍然失败 | 用 handle/exceptionally 改变结果 |
handle 失败分支返回 null | 下游 NPE,错误位置被推远 | 返回显式失败值 |
认为 orTimeout 会停掉任务 | 线程和连接继续被占用,池被拖满 | 配合下游超时 + 协作式取消 |
| 以为超时后真实结果还能补上 | 晚到结果被丢弃,状态不一致 | 超时即终态,用重试/对账兜底 |
cancel(true) 期待中断 | 参数无效,任务照跑 | 自己持有任务句柄或用作用域取消 |
| 每层都设同样的超时 | 最坏耗时是各层之和 | 总预算逐层递减 |
| 所有下游共用一个超时常量 | 快的下游被拖慢,慢的照样拖垮 | 按下游 SLA 分别设定 |
| 降级后不记录 | 用户以为发了,实际没发 | 落重试队列 + 可观测指标 |
什么时候不要用超时降级。 有副作用的写操作要特别小心:orTimeout 之后你并不知道下游到底执行了没有——请求可能已经在对方那里成功,只是响应慢了。把这种情况降级成「失败」然后重试,就会产生重复扣款、重复发券。正确做法是幂等键(11.3)+ 状态查询,而不是加一个 orTimeout 就当解决了。另外,纯计算任务不要设 orTimeout:它既不能中断计算,也不能释放 CPU,只是让你提前拿到一个 TimeoutException,然后把仍然在跑的任务变成孤儿。
小结
exceptionally只在异常时调用并返回替代值;handle成功失败都经过、可转换;whenComplete只观察,异常继续下传(实测join仍抛IllegalStateException)。- 钩子里看到的异常常被包成
CompletionException,终结操作会解开一层;按类型分支前必须先unwrap。 orTimeout(200ms)实测在 203/205/208ms 触发;底层任务不中断、照常跑完,future 保持异常终态。completeOnTimeout会把降级值冻结,晚到的真实结果被丢弃。cancel(true)的mayInterruptIfRunning在CompletableFuture上无效,任务不会被中断;取消也不会传播给allOf的成员。- 三路通知的降级链把整体耗时从串行 860ms 压到 217ms;同一个
exceptionally里,任务体失败给的是CompletionException,超时给的是TimeoutException。
超时和降级都依赖「有一个自己的池」。可一旦换了池,ThreadLocal 里的请求上下文就没了——这是下一节的主题。
阅读导航:上一节 13.1 CompletableFuture 的创建与组合 | 下一节 13.3 上下文传递与线程池隔离
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。