SpringBoot中@Async异步透传问题内部分享

项目背景为SpringCloud多个微服务互相请求,服务里有不少 @Async 标注的异步方法,主要用来做任务完成后的记账、冲红、生成宽表这些不需要阻塞主流程的操作。这些方法内部会调 Feign 接口、查数据库,依赖请求入口处设置好的标识(flag)和 Spring 的 RequestAttributes。
问题在于一旦跑到子线程里,这些 ThreadLocal 变量全丢了。数据源找不到,请求上下文拿不到,业务直接报错。
网上搜了一圈,大部分文章讲的都是用 TaskDecorator 做上下文透传,方案本身没问题,Spring 也提供了这个扩展点。但实际落地的时候,几个细节没处理好,踩了不少坑。
先上一版能跑的代码
下面是简化后的 ContextCopyingDecorator,只保留最核心的两个透传项:RequestAttributes 和 flag。
public class ContextCopyingDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
try {
// 1. 在父线程中把上下文取出来
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
String flag= Utils.flagTL.get();//业务需要的标识flag
return () -> {
try {
// 2. 在子线程中把上下文塞回去
RequestContextHolder.setRequestAttributes(attributes);
Utils.flagTL.set(flag);
runnable.run();
} finally {
// 3. 执行完了清掉,防止线程池复用时串数据
RequestContextHolder.resetRequestAttributes();
Utils.flagTL.remove();
}
};
} catch (IllegalStateException e) {
return runnable;
}
}
}
其中Utils.flagTL定义为 public static final ThreadLocal
线程池配置那边,把这个 Decorator 设进去就行了:
@Bean("commonExecutor")
public ThreadPoolTaskExecutor getCommonExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(50);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(200);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("Async-Service-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setTaskDecorator(new ContextCopyingDecorator());
executor.initialize();
return executor;
}
用法没什么特别的,@Async 指定线程池名字就行:
@Async("commonExecutor")
public void asyncStockModify(List taskDtlList) {
// 子线程里也能拿到 flag 和 RequestAttributes 了
this.syncStockModify(taskDtlList);
}
跑起来看着没问题,但仔细过一遍代码,有好几个地方经不起推敲。
第一个坑:catch (IllegalStateException) 把什么都吞了
} catch (IllegalStateException e) {
return runnable;
}
当初写这个 catch 的想法是:万一是从 MQ Consumer 或者定时任务触发的,没有 HTTP 上下文,getRequestAttributes() 会抛异常,兜个底直接返回原始 runnable。
但 RequestContextHolder.getRequestAttributes() 这个方法在没有上下文的时候返回的是 null,根本不会抛 IllegalStateException。真正会抛这个异常的是 currentRequestAttributes(),两个方法名字长得像,行为完全不同。
那这个 catch 实际兜住的是什么?是 Utils.flagTL.get() 内部可能抛出的异常。比如 flagTL的初始化逻辑出了问题,或者 ThreadLocal 本身被意外回收了,这些情况下抛出的 IllegalStateException 也被一并吞掉了。
结果就是:子线程拿不到 flag,走了默认数据源,数据写到了错误的库里,但没有任何报错。线上排查这种问题的时候,你根本不会往上下文透传这个方向想,因为日志里一切正常。
改法很简单,把这个 try-catch 去掉,或者至少只包 RequestAttributes 那一行,别把后面的 flag 也包进去:
public Runnable decorate(Runnable runnable) {
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
String flag = Utils.flagTL.get();
return () -> {
try {
if (attributes != null) {
RequestContextHolder.setRequestAttributes(attributes);
}
if (flag != null) {
Utils.flagTL.set(flag);
}
runnable.run();
} finally {
RequestContextHolder.resetRequestAttributes();
Utils.flagTL.remove();
}
};
}
第二个坑:flag 为 null 时,set(null) 和不 set 是两回事
看这一行:
Utils.flagTL.set(flag);
如果父线程的 flag 就是 null(比如这个请求根本不需要走多数据源),那子线程执行 set(null) 之后,ThreadLocal 里面是有值的,值就是 null。
这跟不 set 有什么区别?区别大了。如果 flagTL用了 ThreadLocal.withInitial(() -> "default_ds") 这种带默认值的初始化方式,set(null) 会把默认值覆盖掉。下游代码 get() 拿到的是 null,不是 "default_ds"。
这个问题在测试环境很难复现,因为测试环境一般就一个数据源,flag 始终有值。但到了多租户、多数据源的生产环境,有些请求确实不需要设置 flag,这时候就炸了。
加个 null 判断就行了:
if (flag != null) {
Utils.flagTL.set(flag);
}
第三个坑:透传的是"快照",不是"原始值"
decorate() 方法是在父线程中执行的,它取到的是那一刻 ThreadLocal 的值。如果业务代码在执行过程中修改了 flagTL,然后在这个线程里又触发了一个嵌套的 @Async,那第二层异步拿到的就不是请求入口时的值,而是被修改过的值。
举个栗子:
HTTP 线程 (flag = "W_A")
└─ @Async 方法1
├─ 子线程1 拿到 flag = "W_A" 正确
├─ 方法1 内部切换数据源: Utils.flagTL.set("W_B")
└─ @Async 方法2 (嵌套调用)
└─ 子线程2 拿到 flag = "W_B"
大部分情况下我们期望子线程2拿到的还是 W_A,因为那是请求入口时的数据源。但代码实际透传的是"当前线程此刻的值"。
这个没有标准答案,要看业务语义。如果你确实需要"原始值",就得在请求入口的时候把值存到一个不变的地方,而不是每次都从 ThreadLocal 里现取。
第四个坑:@Async 同类调用直接失效
这个跟 TaskDecorator 没关系,是 @Async 本身的机制问题,这也是在面试中经常提到的异步失效的问题,实际项目中太容易踩了,这里顺便再说一下。
Spring 的 @Async 是靠 AOP 代理实现的。当你从外部调用一个 @Async 方法时,Spring 会拦截这个调用,把它丢到线程池里异步执行。但如果是在同一个类里面,一个方法直接调另一个 @Async 方法,走的是 this.xxx(),不经过代理,@Async 注解就等于没写。
我们项目里就有这个情况。TaskFinishHandleService 里面有个方法:
@Async(value = CommonConstants.COMMON)
public void asynCreateAndUpdateTaskReport(List oshTaskHdrDTOList) {
this.asynCreateTaskReport(oshTaskHdrDTOList); // ← 同类调 @Async 方法
try {
Thread.sleep(1000); // ← 等1秒"确保异步执行完"
} catch (InterruptedException e) {
e.printStackTrace();
}
for (OshTaskHdrDTO oshTaskHdrDTO : oshTaskHdrDTOList) {
OshTaskHdr oshTaskHdr = BeanConvertUtils.convertBean(oshTaskHdrDTO, OshTaskHdr.class);
this.asynUpdateTaskReport(oshTaskHdr); // ← 同类调 @Async 方法
}
}
asynCreateTaskReport 和 asynUpdateTaskReport 都是 @Async 方法,但这里用 this. 调用,根本不会走代理,就是同步执行的。那个 Thread.sleep(1000) 大概是当时调的时候发现时序不对,以为远程接口是异步的所以加了延时,实际上这里所有的调用都是同步的。
解决失效的问题无非以下几种:
方式一:注入自己
@Autowired
private ApplicationContext applicationContext;
public void asynCreateAndUpdateTaskReport(List list) {
TaskFinishHandleService proxy = applicationContext.getBean(TaskFinishHandleService.class);
proxy.asynCreateTaskReport(list); // 走代理,@Async 生效
}
方式二:用 AopContext
项目里已经引了 AopContext,在 wholeConsumeOshBillInfo 方法里就有注释掉的代码用了这个方式:
((TaskFinishHandleService) AopContext.currentProxy()).asynCreateTaskReport(hdrDTOS);
需要在启动类或配置类上加 @EnableAspectJAutoProxy(exposeProxy = true)。
方式三:把 @Async 方法拆到独立的 Service 里
最干净的做法。把异步方法放到一个新的 TaskFinishAsyncService 里,原来的 Service 注入它来调用,从根本上避免同类调用的问题。
还有一个容易忽略的:private 方法上写 @Async
@Async 只能加在 public 方法上。如果不小心加在 private 或 protected 方法上,Spring 的代理类根本覆盖不到这个方法,注解直接失效,不会报错也不会有任何提示。这个只能靠 code review 或者加个检查规则来防。
从网上很多方案来看,TaskDecorator 这个方案本身是靠谱的,Spring 的扩展点设计得挺好。但真正用的时候,不能只关注"怎么把上下文传过去",还得考虑:
-
异常处理别太大:catch 的范围越大,静默失败的概率越高
-
null 值要当回事:
set(null)和不set在 ThreadLocal 里是有区别的 -
嵌套异步的语义:透传的是快照值还是原始值,业务上要想清楚
-
@Async 的生效前提:必须走代理,同类调用、private 方法都不会生效
这几个点单独看都不复杂,但组合到一起,再加上多租户、多数据源的生产环境,稍不注意就是一个"不报错但数据不对"的问题。这类问题比 NPE 难查多了,因为从日志上看一切正常,你很难第一时间想到是上下文透传的问题。
原文地址: https://www.cveoy.top/t/topic/qHVP 著作权归作者所有。请勿转载和采集!