一次 CompletableFuture 误用,如何耗尽 IO 线程池并拖垮整个系统

一次看似无害的 CompletableFuture.thenApply 使用,因为在回调中执行了同步 RPC,导致 RPC 框架线程被阻塞, 最终引发 CompletableFuture 无法完成、HTTP 线程池耗尽的级联雪崩。


一、故障现象:不是慢,而是"系统不动了"

线上服务突然出现大面积超时,QPS 急剧下降,但 CPU 使用率并不高。

第一反应是慢接口,但很快发现事情不对:

  • Tomcat 线程池大量线程处于 WAITING
  • 请求并没有明显的慢点
  • 重启后短时间恢复,流量上升再次雪崩

Thread Dump 中,几乎所有业务线程都卡在同一个地方:

bash 复制代码
java.lang.Thread.State: WAITING (parking)
    at sun.misc.Unsafe.park(Native Method)
    at java.util.concurrent.locks.LockSupport.park
    at java.util.concurrent.CompletableFuture.waitingGet
    at java.util.concurrent.CompletableFuture.join
    at UserListFetcher.getInfos

二、初步排查:谁在等?在等什么?

UserListFetcher.getInfos 使用了典型的并行聚合模式:

scss 复制代码
CompletableFuture.allOf(cfA, cfB, cfC).join();

乍一看逻辑完全合理:

并行请求多个下游,全部完成后聚合结果。

但问题在于:

其中有一部分 CompletableFuture 很久没有完成。


三、致命代码:回调里写了"看起来很正常"的逻辑

问题出在某个子调用的实现中:

typescript 复制代码
public CompletableFuture<Map<Long, UserReturnDto>> getUserReturnMapCf(...) {
    return asyncExecutor.async(...)
        .thenApply(result -> {
            return dealAfter(result);
        });
}

private Map<Long, UserReturnDto> dealAfter(...) {
    // 同步 RPC 调用
    return extServer.getInfos(...);
}

代码本身没有明显错误

  • 使用了异步 RPC
  • 使用了 CompletableFuture
  • 逻辑清晰、可读性也不错

但这里隐藏着一个

对 CompletableFuture 线程模型的致命误解


四、thenApply 到底在哪个线程执行?

CompletableFuture.thenApply / thenAccept
并不保证运行在"业务线程池"中。

它的执行线程取决于 上一个阶段是在哪个线程完成的

在本例中,真实执行流程是:

  1. 发起异步 RPC
  2. Future 未完成,注册回调
  3. RPC 响应返回
  4. RPC / Netty IO 线程 标记 Future 完成
  5. 同一个 IO 线程立即执行 thenApply
  6. thenApply 回调中执行同步 RPC
  7. RPC 线程进入 WAITING 状态 ❌

五、雪崩是如何发生的?

从系统视角看,这是一条非常典型的线程耗尽链路:

bash 复制代码
HTTP Thread
    |
    v
CompletableFuture.join()
    |
    v
等待子 Future 完成
    |
    v
子 Future 回调运行在 IO 线程
    |
    v
IO 线程被同步 RPC 阻塞
    |
    v
更多 RPC 回调无法执行
    |
    v
更多 Future 无法完成
    |
    v
HTTP 线程池耗尽

当RPC IO 线程池被业务逻辑占满时:

  • 新的 RPC 响应无法被处理
  • Future 永远不会完成
  • 上游 join() 永远不会返回

这不是"慢",而是"吞吐量归零"。


六、紧急修复:IO 线程隔离(立刻止血)

最直接、也是最安全的修复方式:

明确指定回调执行线程池

❌ 修复前(危险)

less 复制代码
asyncExecutor.async(...)
    .thenApply(res -> otherRpcService.getData(res.getId()));

✅ 修复后(安全)

less 复制代码
asyncExecutor.async(...)
    .thenApplyAsync(
        res -> otherRpcService.getData(res.getId()),
        userBizExecutor
    );

七、长期治理:用 API 设计"防呆"

这次事故暴露了一个现实问题:

"不要在回调里写阻塞代码"

但是这种规范,迟早会被违反。

真正可靠的治理方式是:

  1. IDE / 编译期即时反馈
  2. 不依赖人的自觉

7.1 安全版 CompletableFuture 封装

我们对 RPC 框架使用的 CompletableFuture 做了一层封装:

  • 禁用 thenApply / thenAccept

  • 强制区分:

    • 阻塞逻辑(Async + Executor)
    • 非阻塞逻辑(明确声明)
typescript 复制代码
public class RPCCompletableFuture<T> extends CompletableFuture<T> {

/**
     * 将标准的 CompletableFuture 包装为 RPCCompletableFuture。
     * 这样可以确保链式调用的每一步都受到 RPCCompletableFuture 的安全检查。
     */
    private static <U> RPCCompletableFuture<U> wrap(CompletableFuture<U> original) {
        RPCCompletableFuture<U> wrapper = new RPCCompletableFuture<>();
        original.whenComplete((res, ex) -> {
            if (ex != null) {
                wrapper.completeExceptionally(ex);
            } else {
                wrapper.complete(res);
            }
        });
        return wrapper;
    }
    
     /**
     * ⛔️ <b>已禁用提示</b>
     * <p>
     * 请确认回调逻辑是否包含 IO 操作:
     * - 有 IO 操作:请改用 {@link #thenApplyAsync(Function, Executor)}。
     * - 无 IO 操作(仅对象组装):请改用 {@link #thenApplyNonBlocking(Function)}。
     */
    @Override
    @Deprecated
    public <U> RPCCompletableFuture<U> thenApply(
            Function<? super T, ? extends U> fn) {
        return wrap(super.thenApply(fn));
    }
 /**
     * ✅ <b>无阻塞安全调用</b>
     * <p>
     * 仅当确信回调逻辑为<b>纯内存操作</b>(如对象组装、数据转换)且<b>执行极快</b>时使用。
     */
      public <U> RPCCompletableFuture<U> thenApplyNonBlocking(Function<? super T, ? extends U> fn) {
        return wrap(super.thenApply(fn));
    }
}

IDE 中,错误用法会被明确标红,强制开发者思考线程模型,选择正确用法。


八、配套治理:监控与 Code Review

8.1 必须监控的指标

  • RPC IO 线程池:ActiveCount、QueueSize
  • 业务线程池:队列长度
  • HTTP 线程池:WAITING 比例

8.2 Code Review Checklist

  • 回调中是否包含 RPC / DB / Redis?
  • 是否显式指定 Executor?
  • 是否存在隐式阻塞?
  • 是否有上下文丢失风险?

九、总结

这次事故的本质不是 CompletableFuture 的 bug,

而是线程模型被错误理解,或者使用时有疏忽

可以归结为三条经验:

  1. 异步不等于安全,线程模型比 API 更重要
  2. 任何系统线程,都不应该承载不确定耗时的非该线程职责内的业务逻辑
  3. 最好的开发规范,不是文档,是代码和工具链

如果你在系统中大量使用异步编排,这类问题迟早会出现。

区别只在于:你是提前设计防线,还是等事故帮你复习一遍。

公众号:DailyHappy 一位后端写码师,一位黑暗料理制造者。

相关推荐
恩创软件开发15 小时前
创业日常2026-1-8
java·经验分享·微信小程序·小程序
用户83562907805115 小时前
用Python轻松管理Word页脚:批量处理与多节文档技巧
后端·python
想用offer打牌16 小时前
一站式了解Spring AI Alibaba的流式输出
java·人工智能·后端
Lonely丶墨轩16 小时前
从登录入口窥见架构:一个企业级双Token认证系统的深度拆解
java·数据库·sql
秋说16 小时前
华为 DevKit 25.2.rc1 源码迁移分析使用教程(openEuler + ARM64)
后端
ServBay16 小时前
C# 成为 2025 年的编程语言,7个C#技巧助力开发效率
后端·c#·.net
行百里er16 小时前
一个还没写代码的开源项目,我先来“画个饼”:Spring Insight
后端·开源·github
威哥爱编程16 小时前
2026年的IT圈,看看谁在“裸泳”,谁在“吃肉”
后端·ai编程·harmonyos
码事漫谈17 小时前
当多态在构造中“失效”的那一刻
后端