一次看似无害的
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
并不保证运行在"业务线程池"中。
它的执行线程取决于 上一个阶段是在哪个线程完成的。
在本例中,真实执行流程是:
- 发起异步 RPC
- Future 未完成,注册回调
- RPC 响应返回
- RPC / Netty IO 线程 标记 Future 完成
- 同一个 IO 线程立即执行 thenApply
- thenApply 回调中执行同步 RPC
- 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 设计"防呆"
这次事故暴露了一个现实问题:
"不要在回调里写阻塞代码"
但是这种规范,迟早会被违反。
真正可靠的治理方式是:
- IDE / 编译期即时反馈
- 不依赖人的自觉
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,
而是线程模型被错误理解,或者使用时有疏忽。
可以归结为三条经验:
- 异步不等于安全,线程模型比 API 更重要
- 任何系统线程,都不应该承载不确定耗时的非该线程职责内的业务逻辑
- 最好的开发规范,不是文档,是代码和工具链
如果你在系统中大量使用异步编排,这类问题迟早会出现。
区别只在于:你是提前设计防线,还是等事故帮你复习一遍。
公众号:DailyHappy 一位后端写码师,一位黑暗料理制造者。