Spring @Async 异步无法获取当前登录用户?Sa-Token 1.34.0 终极踩坑解决方案

0 阅读4分钟

前言

开发中我们经常用 @Async 实现异步任务处理,但是只要一异步,就会遇到一个经典问题:异步线程里拿不到当前登录用户信息

尤其项目使用 Sa-Token 1.34.0 旧版本框架,再加上异步内部嵌套工作流、自定义权限拦截、内置事件监听器,底层到处调用 LoginHelper.getLoginUser(),根本没法逐个改方法传参,踩坑直接踩到底。

本文把根源、所有无效方案、最终可用落地解法一次性讲透,看完彻底解决异步上下文丢失问题。

一、问题现象

  1. 普通接口同步逻辑,LoginHelper.getLoginUser() 正常获取登录用户;
  2. 进入 @Async 异步方法后,直接报错:

plaintext

NotWebContextException: 非Web上下文无法获取Request
  1. 尝试在异步里使用 StpUtilSaHolder 全部报错;
  2. 特殊场景:异步内部调用工作流引擎,工作流内置事件、权限校验自动调用登录用户方法,改不了源码,只能被动适配

二、根本原因

  1. Web 请求的登录用户、Request、Sa-Token 上下文,都是存在主线程 ThreadLocal 中;
  2. Spring 异步线程池是独立子线程,默认不会继承主线程 ThreadLocal 上下文
  3. Sa-Token 1.34.0 老旧版本设计强依赖 Web Request 上下文,脱离 Web 环境直接抛非 Web 上下文异常;
  4. 线程池复用线程,就算临时设置上下文,不清理还会出现串用户、权限错乱问题。

三、网上常见无效踩坑方案(避坑)

方案 1:异步里直接 StpUtil.setTokenValue

看似能用,但有三大问题:

  • Sa-Token 1.34.0 没有官方清除方法,无法清理线程上下文;
  • 线程池复用会造成上下文污染、串登录用户
  • 异步里调用 SaHolder 依然报非 Web 上下文错误。

方案 2:传递 RequestContextHolder 上下文

java

运行

RequestAttributes attr = RequestContextHolder.getRequestAttributes();
// 异步传入设置
RequestContextHolder.setRequestAttributes(attr);

对 Sa-Token 1.34.0 无效,框架底层不走 Spring 上下文,依旧报错。

方案 3:自注入自己 AOP 代理

java

运行

@Autowired
@Lazy
private CaMainService self;

只能解决异步注解生效问题,解决不了登录用户上下文丢失

四、终极落地解决方案(适配工作流 + 旧版 Sa-Token)

核心思路

既然异步线程天生无 Web 上下文、旧版 Sa-Token 不支持手动绑定清理,且工作流内部硬编码调用 LoginHelper.getLoginUser() 改不动,最优解:

自定义全局 ThreadLocal 承载登录用户,改造 LoginHelper 兜底适配异步场景

步骤 1:改造 LoginHelper 工具类

新增异步专用 ThreadLocal 容器,原有逻辑不变,异步场景自动兜底取值:

java

运行

public class LoginHelper {

    // 异步线程专用:保存登录用户
    private static final ThreadLocal<LoginUser> ASYNC_USER_LOCAL = new ThreadLocal<>();
    private static final String LOGIN_USER_KEY = "loginUser";

    // 原有获取用户方法,改造加兜底
    public static LoginUser getLoginUser() {
        try {
            // 优先走Sa-Token Web上下文
            return (LoginUser) SaHolder.getStorage().get(LOGIN_USER_KEY);
        } catch (Exception e) {
            // 非Web上下文、异步场景:从本地ThreadLocal取值
            return ASYNC_USER_LOCAL.get();
        }
    }

    // 给异步线程设置登录用户
    public static void setAsyncUser(LoginUser user) {
        ASYNC_USER_LOCAL.set(user);
    }

    // 用完清理,防止线程池复用串用户
    public static void clearAsyncUser() {
        ASYNC_USER_LOCAL.remove();
    }
}

步骤 2:业务层异步调用改造

主线程提前获取登录用户,传给异步方法,异步入口设置上下文,finally 强制清理:

java

运行

@Service
public class CaMainServiceImpl implements CaMainService {

    @Autowired
    @Lazy
    private CaMainService self;

    public Boolean initiateBatch11(String queueKey) {
        // 主线程:Web上下文正常,提前取出登录用户
        LoginUser loginUser = LoginHelper.getLoginUser();
        // 传给异步任务
        self.handlerTaskAsync(loginUser, queueKey);
        return Boolean.TRUE;
    }

    @Async
    public void handlerTaskAsync(LoginUser loginUser, String queueKey) {
        try {
            // 给当前异步线程绑定登录用户
            LoginHelper.setAsyncUser(loginUser);
            
            // 此处调用工作流、任意内置事件、权限校验
            // 内部所有 LoginHelper.getLoginUser() 自动正常取值
            // 无需修改工作流任何源码

        } finally {
            // 必须清理!线程池复用防止串用户
            LoginHelper.clearAsyncUser();
        }
    }
}

五、方案优势

  1. 零侵入工作流、业务源码:不用改监听器、不用改权限拦截逻辑;
  2. 兼容 Sa-Token 1.34.0 老旧版本:不用升级框架、不用反射硬编码;
  3. 线程安全:finally 强制清除 ThreadLocal,杜绝线程池串用户;
  4. 写法极简:所有异步任务通用一套模板,可全局复用;
  5. 彻底避开非 Web 上下文异常:不依赖 SaHolder、不依赖 Request 上下文。

六、总结

  1. @Async 丢失登录用户本质是 ThreadLocal 上下文不继承
  2. 旧版 Sa-Token 1.34.0 不要强行用 setTokenValue、SaHolder 手动绑定,坑极多;
  3. 嵌套工作流、内置事件无法改源码时,改造 LoginHelper + 自定义 ThreadLocal是唯一稳妥生产方案;
  4. 异步任务务必用完清理上下文,避免线程池复用导致权限错乱、用户串号。