@Async 只负责换线程执行,不负责事务一致性、上下文复制和异常可靠投递。 生产环境里因此丢失数据、丢日志、吞异常的情况反复出现,根治只能靠明确事务边界、装饰线程池和持久化关键任务。

事务上下文不会随 @Async 传递到子线程

在 Spring 中,@Async 方法默认使用 SimpleAsyncTaskExecutor 或自定义的 ThreadPoolTaskExecutor 执行。事务由 TransactionSynchronizationManager 绑定在当前线程的 ThreadLocal 上。当主线程调用 @Async 方法时,代理先切换到新线程,新线程的 ThreadLocal 里没有事务上下文,因此 @Async 方法内即使加了 @Transactional 也无法加入已有事务。

实际项目中,一个订单服务在主线程开启事务,调用 @Async 发送积分和推送通知。积分服务里更新用户积分点数因为没有事务,执行后立刻回滚,导致用户积分始终为零。调用方却看不到任何异常,订单状态已标记为成功。

根因在于 @Async 只负责异步执行,不负责事务一致性。Spring 的事务传播机制依赖同一线程,跨线程时 PROPAGATION_REQUIRED 失效,子线程默认使用 PROPAGATION_REQUIRED 但没有父事务可加入,最终变成独立事务或无事务。

调用链上,主线程事务在 @Async 代理返回后立即提交,而子线程的事务边界完全独立。两者没有共享任何资源,这直接导致数据不一致。

MDC 上下文默认不会复制到新线程

MDC(Mapped Diagnostic Context)底层使用 ThreadLocal 存储 traceId、userId 等日志字段。@Async 创建新线程时,ThreadLocal 不会自动复制,因此子线程日志里 traceId 为空,日志平台无法聚合一次请求的所有链路。

某支付项目里,主线程设置 MDC.put(“traceId”, UUID.randomUUID().toString()),随后调用 @Async 做风控校验。风控模块打印的日志全部丢失 traceId,排查问题时无法把用户请求和异步任务关联起来,定位耗时从分钟级变成小时级。

信号明确指出 @Async 不负责上下文复制。ThreadLocal 的设计本身就是线程隔离的,线程池复用线程时旧线程的 MDC 残留还会造成日志错乱。

异常在 @Async 中会被静默吞掉

@Async 方法抛出异常后,默认由 AsyncUncaughtExceptionHandler 处理。这个 handler 的默认实现只是打印堆栈到 error 日志,然后什么都不做。调用方无法通过 try-catch 捕获,也不会向上传播,导致异常被静默吞掉。

一个定时任务异步生成报表,里面因为空指针崩溃。开发者第二天才从日志里发现报表从未生成,业务方已投诉数据缺失。整个过程没有告警,没有重试,任务彻底丢失。

信号指出 @Async 不负责异常可靠投递。默认机制只记录日志,不触发重试,不通知调用方,也不影响主流程状态。

事务边界必须在 @Async 调用前提交

修复事务丢失的核心是把需要事务保护的操作放在 @Async 调用之前完成,确保主线程事务先提交,再发起异步任务。

正确写法是:

1
2
3
4
5
@Transactional
public void createOrder(Order order) {
    orderRepository.save(order);  // 主事务提交
    asyncService.sendNotification(order.getId());  // 异步任务在事务外
}

如果异步任务本身也需要事务,应在异步方法上单独标注 @Transactional,让它成为独立事务。不要指望父子事务跨越线程。

生产中还可使用 TransactionTemplate 在明确位置手动提交,再调用异步方法,避免隐式边界带来的困惑。

用装饰器统一包装线程池复制上下文

一次性解决 MDC 和其他 ThreadLocal 丢失的最干净方式是自定义 TaskDecorator。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Component
public class ContextCopyingDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> contextMap = MDC.getCopyOfContextMap();
        return () -> {
            try {
                MDC.setContextMap(contextMap);
                runnable.run();
            } finally {
                MDC.clear();
            }
        };
    }
}

然后在配置线程池时注入:

1
2
3
4
5
6
7
@Bean
public Executor taskExecutor(ContextCopyingDecorator decorator) {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setTaskDecorator(decorator);
    executor.initialize();
    return executor;
}

这样所有 @Async 方法都会自动携带 MDC 上下文,同时也为后续扩展其他 ThreadLocal 留出空间。信号中提到的“统一线程池装饰”正是这一做法。

关键任务持久化加可观察异常才能兜底

即使上下文和事务都修复,异步任务仍可能因为线程池满、节点重启而丢失。信号建议对关键任务做持久化并配合可观察异常。

做法是:异步方法第一步把任务记录到数据库,状态为 PENDING。执行成功后更新为 SUCCESS,失败则更新为 FAILED 并记录异常堆栈。同时注册自定义 AsyncUncaughtExceptionHandler,把异常写入数据库并触发告警。

1
2
3
4
5
6
7
8
@Component
public class ObservableAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        taskRepository.recordFailure(method.getName(), ex.getMessage());
        alertService.send(ex);
    }
}

配合定时任务扫描长期 PENDING 的记录进行重试,形成闭环。持久化确保任务不因进程退出而消失,可观察异常则把原来静默的错误变成可追踪、可告警的事件。

避坑 checklist:从代码到运维的检查项

  1. 所有 @Async 方法是否都在事务外调用,或明确标注独立事务。
  2. 线程池是否设置了 ContextCopyingDecorator 复制 MDC 和其他上下文。
  3. 是否替换了默认 AsyncUncaughtExceptionHandler,实现持久化和告警。
  4. 关键异步任务是否落地到数据库,状态可查询、可重试。
  5. 线程池核心参数(核心数、最大数、队列容量)是否根据实际压测设定,避免拒绝策略导致任务丢失。
  6. 日志中是否能通过 traceId 完整串联主流程与所有异步环节。
  7. 生产环境是否开启异步任务监控指标(活跃线程、队列长度、失败率)。

逐条核对以上清单,可大幅降低 @Async 带来的隐蔽故障。实际项目中,完整实施这套方案后,异步任务相关的事故率从每月 3-5 起降至接近零。

整个修复思路围绕信号给出的四个根治手段展开:明确事务边界、统一线程池装饰、可观察异常、关键任务持久化。它们共同构成生产级异步处理的底线保障。

参考来源