CompletableFuture 踩坑:异步任务静默失败,异常被吞了的复盘

商品聚合详情接口用 CompletableFuture 并发调下游,上线后页面偶发少一块内容,接口却返回 200、日志干净。排查发现是异常被 future 静默吞掉、getNow 没等结果取到 null、共用 commonPool。几天治理:exceptionally 接异常、独立线程池、allOf 等齐 + orTimeout 降级、MDC/TTL 传上下文。

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 天

避坑清单

  1. CompletableFuture 里抛的异常默认是沉默的,不 get/不接就永远不暴露
  2. 每条异步链路都要用 exceptionally/handle 显式接异常,并且一定要记日志
  3. supplyAsync/thenApplyAsync 不传线程池会落到全局共享的 commonPool
  4. commonPool 线程极少且全局共享,跑阻塞 IO 会拖垮整个 JVM 的并行任务
  5. 异步任务必须显式传入业务自己的线程池,并按业务隔离
  6. getNow 不等任务完成就取值,聚合主数据用它会拿到 null,慎用
  7. get/join 不带超时会无限阻塞,用带超时的 get 或 orTimeout/completeOnTimeout
  8. 多任务编排用 allOf 等齐后再取值,每个子任务自带降级,互不拖累
  9. 任务跨线程后 ThreadLocal/MDC/TraceId 全丢,需 MDC 快照或 TTL 传递
  10. 异步降级是"静默"的,务必把降级率、超时数做成监控指标

总结

这次"详情页偶尔少一块内容、接口却一切正常"的排查,过程格外折磨人,因为它几乎违背了我们排查问题的所有直觉——通常一个功能出错,要么接口报错,要么日志里有异常堆栈,可这次接口稳稳返回 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Full GC 频繁:接口每隔几分钟卡顿一次的排查复盘

2026-5-20 13:02:21

技术教程

时区踩坑:下单时间差 8 小时,一次时间错乱的复盘

2026-5-20 13:07:41

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索