2024 年我们一个商品聚合详情接口,用 CompletableFuture 并发调好几个下游服务来拼数据。上线后陆续收到反馈:页面上偶尔少一块内容,比如"猜你喜欢"是空的,但接口返回 200,日志里也看不到任何报错。排查了很久才发现,问题出在 CompletableFuture 的几个经典坑上 —— 异常被悄悄吞掉、共用了默认线程池、跨线程上下文丢失。投了几天做异步编排专项治理,本文复盘这次实战。
问题背景
业务:商品详情聚合接口,并发调 5 个下游(基础信息、价格、
库存、评价、推荐),CompletableFuture 编排后拼装返回
事故现象:
- 详情页偶发"某一块数据为空",但接口 HTTP 200
- 应用日志里【没有任何异常】,像什么都没发生
- 下游服务的监控显示:推荐服务那时有少量超时
现场排查:
# 出问题的聚合代码(简化)
public DetailVO getDetail(Long id) {
CompletableFuture<BaseInfo> f1 =
CompletableFuture.supplyAsync(() -> baseClient.get(id));
CompletableFuture<Recommend> f5 =
CompletableFuture.supplyAsync(() -> recommendClient.get(id));
// ... f2 f3 f4 ...
DetailVO vo = new DetailVO();
vo.setBaseInfo(f1.join());
vo.setRecommend(f5.getNow(null)); // <- 问题点之一:getNow
return vo;
}
根因(后面逐条拆):
1. supplyAsync 里抛的异常,不主动处理就被"包"进 future,
不 get / 不 join 就永远不会暴露 -> 异常被静默吞掉
2. f5.getNow(null):没算完就立刻取,取不到直接返回 null,
推荐超时还没回来,这里直接给了 null,页面就空了
3. 所有 supplyAsync 没传线程池,全跑在默认 ForkJoinPool.commonPool,
下游一慢,commonPool 被占满,拖累整个 JVM 的并行任务
4. 异步线程里 TraceId / MDC 全丢了,日志串不起来,更难排查
修复 1:CompletableFuture 的异常会被静默吞掉
// === 坑:异步任务里抛异常,如果你不去"取"结果,异常就消失了 ===
CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("下游调用失败");
});
// 如果到此为止,既不 get 也不 join,这个异常【永远不会被打印】
// 它被静静地包在 f 里,JVM 不会提示你,日志里干干净净。
// === 正确方式 1:exceptionally —— 出异常时给一个兜底值 ===
CompletableFuture<Recommend> f5 = CompletableFuture
.supplyAsync(() -> recommendClient.get(id), bizPool)
.exceptionally(ex -> {
// 异常在这里被【接住】,一定要记日志,否则照样无声无息
log.error("推荐服务调用失败,降级为空推荐, id={}", id, ex);
return Recommend.empty(); // 降级:返回空推荐,而不是 null
});
// 这样即使推荐服务挂了,接口仍能正常返回,只是少了推荐块,
// 而且日志里有清清楚楚的错误记录。
// === 正确方式 2:handle —— 无论成功失败都能处理 ===
CompletableFuture<Price> f2 = CompletableFuture
.supplyAsync(() -> priceClient.get(id), bizPool)
.handle((result, ex) -> {
if (ex != null) {
log.error("价格服务异常, id={}", id, ex);
return Price.unknown();
}
return result;
});
// === 正确方式 3:whenComplete —— 只观察,不改变结果 ===
f2.whenComplete((result, ex) -> {
if (ex != null) log.error("价格任务最终失败", ex);
});
// whenComplete 不吞异常,异常会继续向后传;
// exceptionally/handle 会"消化"掉异常并给出新结果。
// === 关键认知 ===
// CompletableFuture 的异常不像同步代码那样会自动炸出来,
// 它默认是"沉默"的。每一条异步链路都必须显式接异常,
// 否则失败了你根本不知道 —— 这是它最坑的地方。
修复 2:不要用默认线程池 ForkJoinPool.commonPool
// === 坑:supplyAsync 不传线程池,默认跑在 ForkJoinPool.commonPool ===
CompletableFuture.supplyAsync(() -> recommendClient.get(id));
// 不传 Executor 时,任务在【全 JVM 共享】的 commonPool 里跑。
// commonPool 的两个致命问题:
// 1. 它的线程数默认 = CPU 核数 - 1,非常少(8 核机器只有 7 个)
// 2. 它是【全局共享】的 —— parallelStream、其他库都在用它
// 你的下游 RPC 一慢,这 7 个线程被 IO 阻塞占满,
// 整个 JVM 里所有依赖 commonPool 的并行任务全部跟着卡住。
// commonPool 是为"CPU 密集的短任务"设计的,绝不适合跑阻塞 IO。
// === 正确:为业务显式创建独立线程池 ===
private final ExecutorService bizPool = new ThreadPoolExecutor(
20, 40, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500),
new ThreadFactoryBuilder().setNameFormat("detail-agg-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy());
// 所有 supplyAsync / thenApplyAsync 都【显式传入】这个池:
CompletableFuture<BaseInfo> f1 =
CompletableFuture.supplyAsync(() -> baseClient.get(id), bizPool);
// === 注意:thenApplyAsync 这类 *Async 方法也要传池 ===
// 不传池的 thenApplyAsync 同样落到 commonPool
f1.thenApplyAsync(this::enrich, bizPool); // 记得带上 bizPool
// 而不带 Async 的 thenApply,会在【上一步完成的那个线程】里执行,
// 也要清楚它跑在哪 —— 别让一个重计算落在 IO 线程上。
// === 不同业务用不同池,别又退化成一个全局大池(参考线程池隔离)===
修复 3:join / get 的阻塞与超时
// === 坑 1:getNow 会"不等结果直接取" ===
Recommend r = f5.getNow(null);
// getNow:任务没完成就立刻返回你给的默认值(这里是 null)。
// 推荐任务还在路上,getNow 直接给 null,页面那块就空了 ——
// 这正是本次事故"内容偶尔为空"的直接原因。
// getNow 只适合"有就用、没有真的无所谓"的场景,聚合主数据别用它。
// === 坑 2:get() / join() 不带超时,会无限阻塞 ===
// f5.join(); // 下游永远不返回,这里就永远卡住
// f5.get(); // 同样无限等
// === 正确:用带超时的 get,或 orTimeout(JDK 9+) ===
try {
Recommend r = f5.get(500, TimeUnit.MILLISECONDS); // 最多等 500ms
} catch (TimeoutException e) {
log.warn("推荐 500ms 未返回,降级");
// 用降级值
}
// JDK 9+ 更优雅:给 future 本身设超时
CompletableFuture<Recommend> f5 = CompletableFuture
.supplyAsync(() -> recommendClient.get(id), bizPool)
.orTimeout(500, TimeUnit.MILLISECONDS) // 超时则以 TimeoutException 失败
.exceptionally(ex -> Recommend.empty()); // 接住超时,降级
// 或 completeOnTimeout:超时直接给默认值,不抛异常
// .completeOnTimeout(Recommend.empty(), 500, TimeUnit.MILLISECONDS);
// === join 与 get 的区别 ===
// get():抛受检异常 InterruptedException / ExecutionException
// join():抛非受检的 CompletionException,写 lambda / stream 里更顺手
// 聚合场景常用 join,但务必先用 orTimeout 或在 future 上接好降级。
修复 4:用 allOf 正确编排并发任务
// === 正确的聚合写法:所有任务先建好,再用 allOf 等齐 ===
public DetailVO getDetail(Long id) {
// 1. 每个下游一个 future,各自接好异常 + 超时降级
CompletableFuture<BaseInfo> f1 = call(() -> baseClient.get(id),
BaseInfo.empty(), "base");
CompletableFuture<Price> f2 = call(() -> priceClient.get(id),
Price.unknown(), "price");
CompletableFuture<Stock> f3 = call(() -> stockClient.get(id),
Stock.zero(), "stock");
CompletableFuture<Comment> f4 = call(() -> commentClient.get(id),
Comment.empty(), "comment");
CompletableFuture<Recommend> f5 = call(() -> recommendClient.get(id),
Recommend.empty(), "recommend");
// 2. allOf 等所有任务结束(成功或已降级)
CompletableFuture.allOf(f1, f2, f3, f4, f5)
.join(); // 此时每个 future 都已完成,join 不会再阻塞
// 3. 此刻所有 future 都完成了,join 取值是即时的
DetailVO vo = new DetailVO();
vo.setBaseInfo(f1.join());
vo.setPrice(f2.join());
vo.setStock(f3.join());
vo.setComment(f4.join());
vo.setRecommend(f5.join());
return vo;
}
// 统一封装:建任务 + 指定线程池 + 接异常降级 + 超时
private <T> CompletableFuture<T> call(Supplier<T> supplier,
T fallback, String name) {
return CompletableFuture.supplyAsync(supplier, bizPool)
.orTimeout(500, TimeUnit.MILLISECONDS)
.exceptionally(ex -> {
log.error("聚合子任务 [{}] 失败,降级", name, ex);
degradeCounter.increment(name); // 降级计数,做成监控
return fallback;
});
}
// === 要点 ===
// allOf 本身返回 CompletableFuture<Void>,不带结果,
// 要从各自的 future 里取值。每个子任务都自带降级,
// 所以单个下游挂掉只影响它那一块,不会让整个接口失败。
// anyOf 则是"任一完成即可",适合多个冗余源取最快的那个。
修复 5:跨线程上下文丢失(TraceId / MDC)
// === 坑:异步任务在别的线程跑,ThreadLocal 里的东西全丢了 ===
// 主线程 MDC 里有 traceId,但 supplyAsync 的任务在 bizPool 的
// 线程里执行,那些线程的 MDC 是空的 -> 异步任务打的日志没有 traceId
// -> 一个请求的日志被切成几段,根本串不起来。
// === 修复 1:提交任务前,把上下文"快照"带过去 ===
public <T> CompletableFuture<T> callWithCtx(Supplier<T> supplier) {
// 在【主线程】拿到当前的 MDC 快照
Map<String, String> ctx = MDC.getCopyOfContextMap();
return CompletableFuture.supplyAsync(() -> {
// 在【子线程】把快照设回去
if (ctx != null) MDC.setContextMap(ctx);
try {
return supplier.get();
} finally {
MDC.clear(); // 用完清掉,因为线程是池化复用的(参考 ThreadLocal 泄漏)
}
}, bizPool);
}
// === 修复 2:更省心 —— 用 TransmittableThreadLocal 包装线程池 ===
// 阿里 TTL:专门解决"线程池场景下 ThreadLocal 值传递"
ExecutorService ttlPool = TtlExecutors.getTtlExecutorService(bizPool);
// 用 ttlPool 提交的任务,会自动把提交时刻主线程的 TTL 值带到子线程,
// 不用每个任务手动 copy MDC,框架层一次性解决。
// === 关键认知 ===
// 只要任务跨了线程,所有基于 ThreadLocal 的隐式上下文
//(MDC 日志、TraceId、用户身份、事务上下文)都会断。
// 异步编排时必须显式处理上下文传递,否则排查问题时
// 日志断链会让你抓狂(这次事故排查慢,一半就慢在这)。
修复 6:异步聚合监控告警
# 异步聚合最怕"静默降级",必须把降级做成可观测指标
groups:
- name: async-aggregation
rules:
# 1. 子任务降级率(某个下游频繁降级 = 它出问题了)
- alert: SubTaskDegradeHigh
expr: |
rate(agg_degrade_total[5m]) / rate(agg_request_total[5m]) > 0.05
for: 5m
annotations:
summary: "聚合子任务 {{ $labels.name }} 降级率 > 5%"
# 2. 聚合接口整体耗时(并发后应接近最慢的那个子任务)
- alert: AggLatencyHigh
expr: http_server_requests_seconds{uri="/detail",quantile="0.99"} > 1
for: 5m
annotations:
summary: "详情聚合接口 P99 > 1s,排查最慢子任务"
# 3. 业务线程池队列堆积(异步任务排不过来)
- alert: AggPoolQueueHigh
expr: pool_queue_size{name="detail-agg"} > 400
for: 3m
annotations:
summary: "聚合线程池队列堆积,下游可能集体变慢"
# 4. 子任务超时次数突增
- alert: SubTaskTimeoutSurge
expr: increase(agg_timeout_total[5m]) > 100
annotations:
summary: "聚合子任务超时数突增,排查下游 {{ $labels.name }}"
优化效果
指标 治理前 治理后
=============================================================
详情页内容缺块 偶发(无日志) 0
异步任务异常 静默吞掉 exceptionally 接住 + 记日志
线程池 commonPool(共享) 独立 detail-agg 池
取结果方式 getNow(不等) allOf 等齐 + 带超时 join
子任务超时 无超时(可能卡死) orTimeout 500ms 降级
跨线程上下文 traceId 丢失日志断链 MDC 快照 / TTL 传递
降级可观测性 完全没有 降级率 + 超时数监控
单下游故障影响 排查困难 只影响对应块,自动降级
压测(详情接口 800 QPS,推荐服务注入 30% 超时):
- 治理前:大量请求返回不完整数据,无任何告警
- 治理后:推荐块按需降级为空,降级率监控实时可见,
接口 P99 稳定在 520ms(≈最慢子任务 + 少量开销)
治理过程:
- 复现并定位异常被吞 / getNow:1 天
- 全量补 exceptionally 降级 + 独立线程池:2 天
- allOf 重构编排 + orTimeout 超时:1 天
- MDC / TTL 上下文传递:1 天
- 降级监控接入 + 压测:1 天
避坑清单
- CompletableFuture 里抛的异常默认是沉默的,不 get/不接就永远不暴露
- 每条异步链路都要用 exceptionally/handle 显式接异常,并且一定要记日志
- supplyAsync/thenApplyAsync 不传线程池会落到全局共享的 commonPool
- commonPool 线程极少且全局共享,跑阻塞 IO 会拖垮整个 JVM 的并行任务
- 异步任务必须显式传入业务自己的线程池,并按业务隔离
- getNow 不等任务完成就取值,聚合主数据用它会拿到 null,慎用
- get/join 不带超时会无限阻塞,用带超时的 get 或 orTimeout/completeOnTimeout
- 多任务编排用 allOf 等齐后再取值,每个子任务自带降级,互不拖累
- 任务跨线程后 ThreadLocal/MDC/TraceId 全丢,需 MDC 快照或 TTL 传递
- 异步降级是"静默"的,务必把降级率、超时数做成监控指标
总结
这次"详情页偶尔少一块内容、接口却一切正常"的排查,过程格外折磨人,因为它几乎违背了我们排查问题的所有直觉——通常一个功能出错,要么接口报错,要么日志里有异常堆栈,可这次接口稳稳返回 200,应用日志干干净净,就好像什么都没发生过,但页面上"猜你喜欢"那一块就是空的。复盘下来,这背后是 CompletableFuture 的几个经典坑叠在了一起。第一个、也是最隐蔽的一个,是它对异常的处理方式:在同步代码里,一个异常抛出来,如果没人接,它会一路向上炸,最终被框架的全局处理器捕获、打进日志,你一定会知道;但在 CompletableFuture 里,异步任务里抛出的异常会被静静地"包装"进那个 future 对象里,只要你不去 get、不去 join 它,这个异常就永远不会有任何动静,JVM 不会提示你,日志里也不会留下一个字。所以使用 CompletableFuture 的第一条铁律就是:每一条异步链路,都必须用 exceptionally 或 handle 显式地把异常接住,而且接住之后一定要记日志——只是接住不记录,等于换了个地方继续沉默。第二个坑是线程池:supplyAsync 如果不显式传一个 Executor,任务就会跑在全 JVM 共享的 ForkJoinPool.commonPool 上,这个池的线程数少得可怜,而且是和 parallelStream、和其他各种库共用的,它是为 CPU 密集的短任务设计的,你拿它去跑会阻塞的下游 RPC 调用,下游一慢,这几个珍贵的线程就被 IO 死死占住,然后整个 JVM 里所有依赖 commonPool 的地方一起遭殃。所以做异步编排,一定要为业务创建并显式传入独立的线程池。第三个坑是取结果的方式,我们当时用了 getNow,它的语义是"任务没完成就别等了,直接给默认值",于是推荐服务稍微慢一点没返回,getNow 就干脆利落地给了个 null,页面那块自然就空了——正确的做法是用 allOf 把所有任务等齐之后再统一取值,并且给每个子任务都配上 orTimeout 超时和降级兜底。还有一个让排查雪上加霜的坑是上下文丢失:异步任务跑在别的线程上,主线程 MDC 里的 traceId 根本传不过去,一个请求的日志被切成互不相干的几段,想顺着 traceId 把链路串起来都做不到。归根结底,CompletableFuture 是一件很锋利的工具,它让并发编排变得简洁优雅,但它的"异步"和"沉默"是一体两面——它默认不会替你把失败喊出来。用好它的关键,就是把每一种"沉默的失败"都显式地变成"响亮的、可观测的信号":异常要接住并记录,超时要兜底并计数,降级要做成监控指标。只有当所有失败都不再沉默,异步系统才是可信赖的。
—— 别看了 · 2026