场景:Dubbo 消费端配置 async=true 异步调用,通过 RpcContext.getFuture() 拿到 CompletableFuture 注册 whenComplete 回调,但回调始终不执行。100 QPS 时正常,500 QPS 时大面积静默,滚动重启恢复,15 分钟后又复现。 路径:Dubbo 2.7.5 DubboInvoker → AllChannelHandler 线程池排队 → DefaultFuture.complete → ThreadlessExecutor 副作用
Dubbo 异步调用回调未执行:一个线程上下文的静默事故
上篇我们聊了 Dubbo 泛化调用序列化异常,翻到 GenericFilter 源码才发现类型抹除导致的参数错位。这次的事故比上次隐蔽得多——没有异常堆栈,没有 timeout,甚至没有错误日志。
如果回调不执行,Dubbo 不会告诉你。 回调注册了、provider 正常返回了、future.isDone() 是 true——每一步看起来都正确,但回调内的 log.info 就是不出现。
翻到 DefaultFuture 和 AsyncRpcResult 的源码才发现:回调确实执行了,但执行的线程上下文不对,日志框架在 Dubbo 线程池上找不到应用类加载器——异常被 CompletableFuture 吞了。
【遗迹】7 分钟、300 条告警、一个相同的症状
14:00 发布新版本,网关层引入 Dubbo 异步回调处理订单状态变更。
14:07 第一波告警:P99 从 50ms 飙升到 12s,同时在 7 秒内触发 300 条告警。滚动重启恢复正常。
14:15 告警再次爆发。重启只恢复了 8 分钟。
14:22 确认业务代码逻辑本身正常——回调内的订单状态更新代码在单测中通过。问题不是回调内的逻辑错了,是回调根本没执行回调内的逻辑。
配置代码——线上 Dubbo 2.7.5:
<dependency>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo</artifactId>
<version>2.7.5</version>
</dependency>
@Reference(async = true, timeout = 5000)
private OrderService orderService;
// 异步调用
String result = orderService.updateStatus(orderId, newStatus);
// result == null(异步立即返回)
CompletableFuture<Object> future = RpcContext.getContext().getFuture();
future.whenComplete((resp, t) -> {
// ← 这一块逻辑在 500 QPS 下大面积不执行
log.info("Order {} status updated: {}", orderId, resp);
notifyWebSocket(orderId, resp);
});
同一个根因, 三种表象,取决于回调体写了什么:
| 回调体写法 | 表面现象 | 根因链路 |
|---|---|---|
log.info(...) |
日志不出现,无 error | logback 初始化通过 TCCL 找配置文件 → TCCL=AppClassLoader → 找不到 → 退化到无输出 |
userDao.update(...) |
NPE 或 AOP 异常 | MyBatis Mapper 代理需要 TCCL 获取 SqlSessionFactory → 无 TCCL → 代理创建失败 |
ThreadLocal.get() |
永远 null | Dubbo 线程池没有 Web 容器的 ThreadLocal → 上层链路追踪 ID 丢失 |

【发掘】源码追踪:响应的 8 步旅程
Dubbo 消费端收到响应后的完整处理链。重点关注 第 3 步——线程从这里切换。
Step 1 Netty IO 线程收到响应字节
Step 2 MultiMessageHandler / HeartbeatHandler(IO 线程透传)
Step 3 AllChannelHandler.received() ← 转折点
→ executor.execute(ChannelEventRunnable) ← 提交到 Dubbo 线程池
Step 4 ChannelEventRunnable.run()(Dubbo 线程池线程)
Step 5 DecodeHandler.received() ← 反序列化响应体
Step 6 HeaderExchangeHandler.handleResponse()
Step 7 DefaultFuture.received() → doReceived()
→ this.complete(res.getResult()) ← CompletableFuture.complete
Step 8 whenComplete 回调执行
→ 在 Dubbo 线程池线程上运行
Source 1:DubboInvoker.doInvoke() — 异步调用入口
// Dubbo 2.7.5 DubboInvoker.java#L91
// https://github.com/apache/dubbo/blob/dubbo-2.7.5/dubbo-rpc/dubbo-rpc-api/src/main/java/org/apache/dubbo/rpc/protocol/dubbo/DubboInvoker.java#L91
protected Result doInvoke(Invocation inv) throws Throwable {
RpcInvocation invocation = (RpcInvocation) inv;
InvokeMode invokeMode = RpcUtils.getInvokeMode(invocation);
// ... 编码请求、创建 CompletableFuture ...
CompletableFuture<AppResponse> responseFuture =
(CompletableFuture<AppResponse>) requestFuture;
AsyncRpcResult asyncResult = new AsyncRpcResult(
responseFuture, invocation);
asyncResult.setExecutor(executor);
if (InvokeMode.FUTURE == invokeMode) {
// 把 future 设到 RpcContext,后续 getFuture() 拿到
RpcContext.getContext().setFuture(
asyncResult.getResponseFuture());
}
return asyncResult;
}

Source 2:AllChannelHandler.received() — 线程切换之墙
// Dubbo 2.7.5 AllChannelHandler.java
// https://github.com/apache/dubbo/blob/dubbo-2.7.5/dubbo-remoting/dubbo-remoting-api/src/main/java/org/apache/dubbo/remoting/dispatch/all/AllChannelHandler.java
public void received(Channel channel, Object message)
throws RemotingException {
ExecutorService executor = getExecutorService(); // Dubbo 共享线程池
executor.execute(new ChannelEventRunnable( // ← 提交到线程池
channel, handler, ChannelState.RECEIVED, message));
}
getExecutorService() 返回 Dubbo 框架创建的共享线程池,默认是 cached 或 limited 类型。这里的线程没有 Web 容器的 TCCL 和 ThreadLocal。提交后,Step 4-8 全部跑在这个线程池上。
这就是 500 QPS 下大面积失效的原因——线程池忙的时候,ChannelEventRunnable 在队列里排队,等它执行时 TCCL 早就不是 Web 容器的了。低 QPS 下线程池空闲,任务被立即执行,问题藏起来了。

Source 3:DefaultFuture.doReceived() — 线程上的 complete
// Dubbo 2.7.5 DefaultFuture.java#L193
// https://github.com/apache/dubbo/blob/dubbo-2.7.5/dubbo-remoting/dubbo-remoting-api/src/main/java/org/apache/dubbo/remoting/exchange/support/DefaultFuture.java#L193
private void doReceived(Response res) {
if (res.getStatus() == Response.OK) {
this.complete(res.getResult());
} else if (res.getStatus() == Response.CLIENT_TIMEOUT
|| res.getStatus() == Response.SERVER_TIMEOUT) {
this.completeExceptionally(new TimeoutException(...));
} else {
this.completeExceptionally(new RemotingException(...));
}
}

DefaultFuture extends CompletableFuture<Object>。this.complete() 被调用时,所有 whenComplete 回调同步执行在调用 complete() 的线程上——就是 Step 4 中从队列取到这个 ChannelEventRunnable 的 Dubbo 线程池线程。
Source 4:AsyncRpcResult.whenCompleteWithContext() — 恢复只做了一半
// Dubbo 2.7.5 AsyncRpcResult.java#L248
// https://github.com/apache/dubbo/blob/dubbo-2.7.5/dubbo-rpc/dubbo-rpc-api/src/main/java/org/apache/dubbo/rpc/AsyncRpcResult.java#L248
public Result whenCompleteWithContext(BiConsumer<Result, Throwable> fn) {
this.responseFuture = this.responseFuture.whenComplete((v, t) -> {
beforeContext.accept(v, t); // 恢复 RpcContext
fn.accept(v, t); // 用户回调
afterContext.accept(v, t);
});
return this;
}
private BiConsumer<Result, Throwable> beforeContext = (v, t) -> {
tmpContext = RpcContext.getContext();
tmpServerContext = RpcContext.getServerContext();
RpcContext.restoreContext(storedContext); // 恢复 Dubbo 上下文
RpcContext.restoreServerContext(storedServerContext);
// 没有恢复 Thread.currentThread().getContextClassLoader()
};

Dubbo 恢复了 RpcContext(attachment、group、version),但没有恢复 TCCL。原因:Dubbo 框架不知道 Web 容器的 ClassLoader 是什么——它不持有 Web 容器的引用。
【路径】生产级排查:从 IDE 到 Arthas
源码定位路径:
Ctrl+Shift+N → DubboInvoker → 搜 doInvoke → L91 asyncResult 创建 + setFuture
Ctrl+Shift+N → AllChannelHandler → 看 received → executor.execute
Ctrl+Shift+N → DefaultFuture → 看 doReceived → this.complete()
Ctrl+Shift+N → AsyncRpcResult → 搜 whenCompleteWithContext → beforeContext
生产环境不改代码的诊断(jstack + Arthas):
# Step 1: jstack 抓 Dubbo 线程池线程堆栈
$ jstack {pid} | grep -E 'DubboClientHandler' -A 3 | head -20
# 预期输出:看到 ChannelEventRunnable.run() 说明线程池正在处理响应
"DubboClientHandler-10.0.0.1-thread-1" prio=5 tid=0x...
at org.apache.dubbo.remoting.transport.dispatcher
.ChannelEventRunnable.run(ChannelEventRunnable.java:58)
# Step 2: Arthas 运行时查 TCCL
$ curl -O https://arthas.aliyun.com/arthas-boot.jar && java -jar arthas-boot.jar {pid}
[arthas@1]$ thread -n 3
# 显示 CPU 占用最高的 3 个线程,确认回调在哪个线程组
[arthas@1]$ ognl '@java.lang.Thread@currentThread().getContextClassLoader()'
# 返回 @AppClassLoader@3d4eac69 → 确认 TCCL 不是 WebAppClassLoader
# Step 3: 线程池指标
# 将 ThreadPoolExecutor 的 getActiveCount / getQueue 暴露到 Prometheus
# 关键阈值:activeCount > corePoolSize * 0.8 时回调开始大面积静默

线程上下文对比:
# Dubbo 线程池(问题):
Thread: DubboClientHandler-10.0.0.1-thread-3
TCCL: jdk.internal.loader.ClassLoaders$AppClassLoader@3d4eac69
# Web 容器业务线程(正常):
Thread: http-nio-8080-exec-5
TCCL: org.apache.catalina.loader.WebappClassLoaderBase@1a2b3c
Dubbo 线程名带 DubboClientHandler,TCCL 是 AppClassLoader;Web 线程名带 http-nio-*,TCCL 是 WebappClassLoaderBase。区别一眼可见。
【解读】版本演进 + 设计权衡
这个问题的根源不是一个 bug,是 Dubbo 消费端线程模型两次大改的副作用。
版本演进时间线:

Dubbo 2.7.0 ──────────────────────────────────────
消费端有独立线程池。线程池线程创建时继承调用方的 TCCL。
异步回调的 TCCL 是"碰巧正确的"——因为线程池建在 Web 应用启动时。
Dubbo 2.7.5 ── ThreadlessExecutor 引入 ──────────
PR #5490 为解决 Issue #2013(消费端线程数爆炸)。
同步调用:业务线程阻塞在 ThreadlessExecutor,复用业务线程处理响应。
异步调用:不走 ThreadlessExecutor,走 AllChannelHandler 共享线程池。
→ 共享线程池的 TCCL 未正确继承 → 问题开始出现。
Dubbo 2.7.12 ── whenCompleteWithContext 引入 ────
AsyncRpcResult 添加了 whenCompleteWithContext,在回调前恢复 RpcContext。
但只恢复了 attachment/group/version,**没有恢复 TCCL**。问题半修复。
Dubbo 3.0.x ── ClassLoaderCallbackFilter 引入 ────
PR #9464 添加 ClassLoaderFilter,在回调执行前恢复调用方的 ClassLoader。
同时 RpcContext API 大改,不再是 ThreadLocal 模式。
Dubbo 3.2+ ── CompletableFuture<T> 原生支持 ─────
接口方法直接返回 CompletableFuture<T>,不再需要 async=true + getFuture。
上下文传递由框架在同一个 CompletableFuture 链上完成。
两个故障模式:
| 模式 | 触发条件 | 后果 | 定位方法 |
|---|---|---|---|
| TCCL 丢失(当前问题) | 任何 QPS,回调体使用 TCCL | 回调内 log 不输出、MyBatis NPE | jstack + TCCL 检测代码 |
| 线程池耗尽 | QPS > 阈值,ChannelEventRunnable 排队 |
回调排队延迟、大面积超时 | ThreadPoolExecutor.getActiveCount() > corePoolSize * 0.8 |
低 QPS 时只有模式 1(TCCL 丢失),高 QPS 时两个模式叠加——线程池排队让回调变得慢,TCCL 丢失让回调变得静默。这就是生产故障"每小时固定发作,滚动重启恢复"的原因——重启让线程池空闲,任务不排队;15 分钟后 QPS 上来,排队 + TCCL 同时爆发。
3 种设计方案的本质权衡:
| 方案 | 线程切换 | TCCL | 延迟 | Dubbo 为什么不选 |
|---|---|---|---|---|
| IO 线程上 complete | 0 | ❌ 无 Web 上下文 | 最低 | 阻塞 Netty EventLoop |
| Dubbo 线程池执行(当前) | 1 | ❌ 框架线程无上下文 | 中 | 默认路径,均衡 |
| 回调排队到业务线程池 | 2 | ✅ 业务线程上下文 | 高 | 框架不知道哪个是"业务线程池" |
第三方案最理想但 Dubbo 做不到——它没有 Web 容器的引用,不知道回调该提交到哪个线程池。
【收获】排查锚点 + 4 种修复方案
排查决策树:
Dubbo 异步回调"不执行" → future.isDone()=true
→ 回调内打印 TCCL
→ TCCL = AppClassLoader
→ 根因:DefaultFuture.doReceived() 在 Dubbo 线程池 complete
→ 修复方案 A-D 选一个
→ TCCL = WebappClassLoader
→ 不在线程上下文
→ 检查:回调是否注册?future 取对了吗?(RpcContext 被覆盖?)
排查锚点速查:
Dubbo 异步回调不执行 → DefaultFuture.doReceived() 中 this.complete() 的线程上下文
修复方案 A:手动恢复 TCCL(单点最快)

ClassLoader webCl = Thread.currentThread().getContextClassLoader();
future.whenComplete((result, t) -> {
ClassLoader old = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(webCl);
try {
log.info("Result: {}", result);
} finally {
Thread.currentThread().setContextClassLoader(old);
}
});
修复方案 B:自定义 Executor(回调有 IO 操作时)
ExecutorService callbackExecutor = new ThreadPoolExecutor(
5, 10, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
r -> {
Thread t = new Thread(r, "callback-executor");
t.setContextClassLoader(
Thread.currentThread().getContextClassLoader());
return t;
});
future.whenCompleteAsync((result, t) -> {
userDao.update(result);
notificationService.send(result);
}, callbackExecutor);
修复方案 C:升级到 Dubbo 3.x(框架层)
Dubbo 3.x 的 ClassLoaderCallbackFilter 默认启用。但注意 API 变动:RpcContext.getContext() 不再是 ThreadLocal 模式。
修复方案 D:Spring Boot Starter + AOP 全量拦截(企业级)
在 20 个 Dubbo 服务上逐个加 try/finally 不现实。用注解 + AOP 一次性解决:
// 定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DubboAsyncCallback { }
// AOP 切面:在回调方法执行前恢复 TCCL
@Aspect
@Component
public class DubboCallbackClassLoaderAspect {
@Around("@annotation(DubboAsyncCallback)")
public Object restoreTCCL(ProceedingJoinPoint pjp) throws Throwable {
ClassLoader webCl = Thread.currentThread()
.getContextClassLoader();
try {
return pjp.proceed();
} finally {
Thread.currentThread()
.setContextClassLoader(webCl);
}
}
}
// 使用:在回调方法上标注注解即可
@Component
public class OrderCallbackHandler {
@DubboAsyncCallback
public void onOrderUpdated(OrderResp resp) {
log.info("Order updated: {}", resp); // TCCL 已恢复
userDao.update(resp.getUserId()); // MyBatis 可以找 Mapper 了
}
}

4 种方案的选型依据:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 回调仅 log + 无 IO | A:手动恢复 TCCL | 改动最小,一行存 + 一行设 |
| 回调含 DB/网络调用 | B:自定义 Executor | 防止阻塞 Dubbo 线程池 |
| 项目计划升级 Dubbo | C:升级到 3.x | 框架层修复,但要适配 API |
| 20+ 服务全量修复 | D:Starter AOP | 引入 starter 即可,零改动业务代码 |
你花 10 分钟跟读完 DefaultFuture.doReceived() 这个方法,下次遇到 Dubbo 异步回调不执行的问题,不用搜 Google——Ctrl+Shift+N 搜 DefaultFuture,看第 193 行的 doReceived 方法,排查入口就在这里。
一个异常堆栈 = 一个类的某个方法出了问题。Dubbo 异步回调"不执行" = DefaultFuture.doReceived() 的 this.complete() 在 Dubbo 线程池线程上执行,TCCL 不对,异常被 CompletableFuture 吞没。
下篇我们聊 Dubbo 路由规则配置错误导致服务不可用——从 RouterChain 源码追踪路由规则的加载与匹配逻辑。
🔗 个人博客:https://opencao.cn 📺 公众号:Ai拆代码的曹操 🌟 知识星球:Ai拆代码的曹操