一次 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 一位后端写码师,一位黑暗料理制造者。

相关推荐
爱勇宝4 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
AskHarries5 小时前
工具失败时怎么办:重试、回滚、人工确认和风险提示
后端·程序员
苏三说技术6 小时前
Claude Code从失控到起飞,只用了这些技巧
后端
长栎7 小时前
写 for 循环写了十年,你却从没用过迭代器模式最狠的那一面
后端
LiaCode7 小时前
Redis 在生产项目的使用
前端·后端
用户559822481227 小时前
Docker Compose Down 导致容器数据误删——ext4 日志恢复全记录
后端
LiaCode7 小时前
一天学完 redis 的爽翻版核心知识总结
前端·后端
大刚测试开发实战7 小时前
如何内网穿透访问本地私有化部署的TestHub
前端·后端·github
xiaodaoluanzha8 小时前
迄今為止,最簡單的編程語言 Nolang
前端·后端
Csvn8 小时前
Docker 容器管理入门 — 从镜像到容器编排
后端