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

ae96d45d19e056e7b84cbd158a27ea5c_70eukzYbhUAAAAAElFTkSuQmCC

项目背景为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 flagTL= new 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<TaskDtl> 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<OshTaskHdrDTO> 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<OshTaskHdrDTO> 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 的扩展点设计得挺好。但真正用的时候,不能只关注"怎么把上下文传过去",还得考虑:

  1. 异常处理别太大:catch 的范围越大,静默失败的概率越高

  2. null 值要当回事:set(null) 和不 set 在 ThreadLocal 里是有区别的

  3. 嵌套异步的语义:透传的是快照值还是原始值,业务上要想清楚

  4. @Async 的生效前提:必须走代理,同类调用、private 方法都不会生效

这几个点单独看都不复杂,但组合到一起,再加上多租户、多数据源的生产环境,稍不注意就是一个"不报错但数据不对"的问题。这类问题比 NPE 难查多了,因为从日志上看一切正常,你很难第一时间想到是上下文透传的问题。

posted @ 2026-11-11 08:09  一个码农的日常  阅读(18)  评论(0)    收藏  举报