场景: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 就是不出现。

翻到 DefaultFutureAsyncRpcResult 的源码才发现:回调确实执行了,但执行的线程上下文不对,日志框架在 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 丢失

线程上下文检测:问题线程 vs Web 容器线程对比

【发掘】源码追踪:响应的 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;
}

DubboInvoker.doInvoke 异步分支代码

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 框架创建的共享线程池,默认是 cachedlimited 类型。这里的线程没有 Web 容器的 TCCL 和 ThreadLocal。提交后,Step 4-8 全部跑在这个线程池上。

这就是 500 QPS 下大面积失效的原因——线程池忙的时候,ChannelEventRunnable 在队列里排队,等它执行时 TCCL 早就不是 Web 容器的了。低 QPS 下线程池空闲,任务被立即执行,问题藏起来了。

AllChannelHandler.received 线程池分发

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.doReceived complete 在 Dubbo 线程上

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()
};

AsyncRpcResult 上下文恢复:只做了 RpcContext,漏了 TCCL

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 时回调开始大面积静默

生产诊断:jstack + Arthas + 线程池指标

线程上下文对比:

# 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 消费端线程模型两次大改的副作用

版本演进时间线:

回调调用链全图:IO线程→AllChannelHandler→DefaultFuture.complete→回调

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(单点最快)

修复方案 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 了
    }
}

修复方案 D:Spring Boot Starter AOP 全量拦截

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+NDefaultFuture,看第 193 行的 doReceived 方法,排查入口就在这里。

一个异常堆栈 = 一个类的某个方法出了问题。Dubbo 异步回调"不执行" = DefaultFuture.doReceived()this.complete() 在 Dubbo 线程池线程上执行,TCCL 不对,异常被 CompletableFuture 吞没。

下篇我们聊 Dubbo 路由规则配置错误导致服务不可用——从 RouterChain 源码追踪路由规则的加载与匹配逻辑。


🔗 个人博客:https://opencao.cn 📺 公众号:Ai拆代码的曹操 🌟 知识星球:Ai拆代码的曹操