5.4 CompletableFuture异常吞没


文档摘要

5.4 CompletableFuture 异常吞没 本节摘要:异步链上任何一环抛出的异常,如果链尾没人消费,就会消失在 CompletableFuture 的内部——没有日志、没有堆栈、没有指标。本节从一次"监控全绿但丢数据"的事故讲起,覆盖异常传播规则、exceptionally/handle/whenComplete 三者差异、默认线程池的坑、超时与取消语义,最后给出异步编排的收口纪律。 事故现场:监控全绿,数据丢了三分之一 商品聚合服务重构为异步编排:并行查价格、库存、评价,然后聚合落库。上线后监控一片绿——RT 下降,错误率零。两周后数据组发现评价表的数据量比预期少了三分之一。 重构时把原来的同步异常处理丢了。 偶发超时抛异常,异常沿链传播到链尾的 ——链尾没有任何消费者。

5.4 CompletableFuture 异常吞没

本节摘要:异步链上任何一环抛出的异常,如果链尾没人消费,就会消失在 CompletableFuture 的内部——没有日志、没有堆栈、没有指标。本节从一次"监控全绿但丢数据"的事故讲起,覆盖异常传播规则、exceptionally/handle/whenComplete 三者差异、默认线程池的坑、超时与取消语义,最后给出异步编排的收口纪律。

事故现场:监控全绿,数据丢了三分之一

商品聚合服务重构为异步编排:并行查价格、库存、评价,然后聚合落库。上线后监控一片绿——RT 下降,错误率零。两周后数据组发现评价表的数据量比预期少了三分之一。

CompletableFuture.supplyAsync(() -> fetchReviews(sku)) // 查评价 .thenApply(this::normalize) .thenAccept(this::persist); // 落库 // 主流程立刻返回 没有任何 join get 或异常处理

重构时把原来的同步异常处理丢了。fetchReviews 偶发超时抛异常,异常沿链传播到链尾的 thenAccept——链尾没有任何消费者。CompletableFuture 的异常被存进 future 内部,等一个永远不会来的 get。没有线程崩溃(工作线程吞了它)、没有日志、没有指标,失败静默到统计口径才能发现。

这是异步编程与传统代码最大的语义差异:同步代码里未捕获异常会打穿到线程的未捕获处理器,日志起码有一条堆栈;supplyAsync 链上异常是数据,不是事件——没人读它,它就不存在。

异常传播规则与三个处理入口

链上任何一环抛异常,跳过后续的 thenApply/thenAccept,直达最近的异常处理环节。三个入口语义不同,用错就是新的坑:

future .exceptionally(ex -> fallback()) // 只处理异常 转成恢复值 链继续走正常分支 .handle((ok, ex) -> ex != null ? rescue(ok) : ok) // 正常异常都走 可以改写结果 .whenComplete((ok, ex) -> { // 只观察不改结果 适合打日志埋点 if (ex != null) log.error("aggregate failed", ex); });

exceptionally 像 catch,handle 像 catch + finally 的合体(可转换结果),whenComplete 像 finally(不改值)。事故代码的最小修复就是链尾挂一个 whenComplete 记日志——每条异步链必须有终端消费者,这条纪律写进评审清单。

异步链的异常传播路径

默认线程池:又一个公共池受害者

supplyAsync(fn) 不传 executor 时跑在 ForkJoinPool.commonPool() 上——第 3 章并行流的老熟人,同样的病:全局共享、容量固定(核数-1)、跑 IO 就是灾难。评价查询是 RPC 调用(IO 密集),放进 commonPool 等于让所有使用公共池的代码(并行流、其他异步链)互相挤兑。正解是显式传业务专属线程池:

CompletableFuture.supplyAsync(() -> fetchReviews(sku), reviewIoPool) .orTimeout(500, TimeUnit.MILLISECONDS) // JDK 9+ 超时 .exceptionally(ex -> { if (ex instanceof CompletionException && ex.getCause() instanceof TimeoutException) { return ReviewResult.empty(); // 超时降级 返回空评价 } throw new CompletionException(ex); // 其他异常继续抛 }) .thenAccept(this::persist);

多路聚合的典型写法 allOf 还有一层包装坑:CompletableFuture.allOf(a, b, c) 本身不携带结果,join 时抛的是 CompletionException真正的原因异常在 cause 里,并且它只抛第一个完成的异常——排查时要意识到可能不止一个分支失败了。惯用收口:

CompletableFuture.allOf(priceFuture, stockFuture, reviewFuture) .thenRun(() -> merge( priceFuture.join(), stockFuture.join(), reviewFuture.join())) .exceptionally(ex -> { log.error("aggregate failed", ex.getCause()); return null; });

anyOf 则是"最快者胜",其余 future 的结果与异常都被忽略——用在竞速(多机房读,取先回)场景,别用在"都要成功"的聚合上。

超时、取消与中断

同步调用有天然的调用栈等待,异步链的超时必须显式声明:orTimeout(到时抛 TimeoutException)与 completeOnTimeout(到时给默认值,直接内置降级)。cancel 的语义是"阻止未启动的环节启动",已跑起来的任务不会被中断——异步链里没有强抢占,取消标志位式的协作式取消(传一个 token 进 lambda)才是可靠手段。与第 5.1 节呼应:join/get 的 InterruptedException 是线程级的协作信号,抓到要么上抛要么恢复中断位。

编排深度也要克制。三四层以上的 thenCompose 嵌套,可读性会低于等价的同步代码加一个池。异步的价值在并行扇出非阻塞等待,不在"把所有同步代码改成链式"——I/O 并行三路查再聚合是黄金场景,串行业务逻辑硬扭成异步链是自我折磨。

⚠️ 常见坑:thenApply(xxx) 里抛出的异常与 supplyAsync 里的传播路径相同,但开发者常在中间环节打日志后不重抛,把"处理过"误当"已恢复",下游拿到空值继续算。

💡 关键直觉:CompletableFuture 把异常从"控制流事件"降格为"容器里的数据"——数据的规矩是必须有人读。每条链一个终端消费者,是最小成本的保险。

防坑清单

  • 每条异步链终端必须有消费者(join/get/whenComplete 至少其一)
  • IO 任务显式传专属线程池,禁用默认 commonPool
  • 全链配置超时(orTimeout 或 completeOnTimeout),超时降级路径写明
  • allOf 聚合记得解包 CompletionException 的 cause
  • 嵌套超过三层的编排回归同步 + 池的实现

本节要点回顾

  • 事故机理:异常存于 future 内部无人读取,失败静默化
  • 三入口:exceptionally 恢复、handle 改写、whenComplete 观察
  • 线程池:默认 commonPool 挤兑风险,IO 场景显式专属池
  • 超时语义:orTimeout 抛异常,completeOnTimeout 内置降级
  • 取消边界:只拦未启动环节,协作式取消才可靠

第 5 章收束。下一章下沉到 JVM——内存布局、GC 停顿与类加载的故障现场。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U