慎用 @Async:从 ThreadLocal 失效到 Cookie 丢失的连环坑

慎用 @Async:从 ThreadLocal 失效到 Cookie 丢失的连环坑

上周线上导出接口突然集体报错,测试反馈点击导出后一直转圈,最终提示 "文件生成失败"。排查发现,竟是 @Async 注解在作祟 —— 文件中心刚修复了上传接口的 Cookie 鉴权漏洞,而异步调用时 Cookie 神秘消失了。

一、看似完美的异步改造

三个月前为解决导出超时问题,我用 @Async 做了异步改造:



// 改造前:同步执行超时
@RequestMapping("/export")
public void exportData(HttpServletRequest request) {
    List<Data> dataList = dataService.queryLargeData();
    File file = excelService.generateExcel(dataList);
    fileCenterClient.upload(file, request);
}

// 改造后:异步执行
@RequestMapping("/export")
public String exportData(HttpServletRequest request) {
    exportService.asyncExport(request); // 异步调用
    return "文件正在生成,请稍后下载";
}

@Service
public class ExportService {
    @Async
    public void asyncExport(HttpServletRequest request) {
        List<Data> dataList = dataService.queryLargeData();
        File file = excelService.generateExcel(dataList);
        fileCenterClient.upload(file, request); // 传入request保留Cookie
    }
}

当时考虑到异步线程会丢失上下文,特意将 HttpServletRequest 作为参数传递。测试显示接口不再超时,ThreadLocal 里的用户信息也能通过 request 获取,一切看似完美。

二、漏洞修复引发的血案

问题出在文件中心的安全加固:

// 修复后的上传接口
@PostMapping("/upload")
public String uploadFile(MultipartFile file, HttpServletRequest request) {
    String token = CookieUtils.getCookieValue(request, "auth_token");
    if (!authService.verify(token)) { // 新增Cookie鉴权
        throw new AccessDeniedException("未授权访问");
    }
    // 执行上传...
}

修复本身没问题,但上线后导出接口全面瘫痪,日志满是 "未授权访问"—— 明明传了 request,Cookie 却不见了!

三、为什么 Cookie 会丢失?

3.1 快速定位:Request 对象的时效性

调试发现诡异现象:asyncExport 方法里能看到 request 中的 Cookie,但传到文件中心就消失了。原来 HttpServletRequest 的生命周期与当前请求绑定,主线程处理完请求后,容器会回收其资源。异步线程拿到的只是个 "过期" 对象,属性可能已被清空。

简单测试即可验证:

@RequestMapping("/test")
public String test() {
    HttpServletRequest request = getRequestAttributes().getRequest();
    System.out.println("主线程Cookie:" + Arrays.toString(request.getCookies())); // 有值
    
    executorService.submit(() -> {
        Thread.sleep(1000); // 等待主线程结束
        System.out.println("异步线程Cookie:" + Arrays.toString(request.getCookies())); // 可能为null
    });
    return "success";
}

3.2 根本原因:线程上下文隔离

Spring 的 @Async 会将方法提交到独立线程池执行,与 Tomcat 请求线程完全隔离:

  1. 异步线程无法继承主线程的 ThreadLocal 变量
  1. HttpServletRequest 在主线程结束后被销毁
  1. 依赖请求上下文的信息(如 Cookie、Session)无法传递

之前能正常工作,只是因为文件中心接口没校验 Cookie。一旦启用鉴权,异步调用必然失败。

四、正确的解决方案

核心原则:不传递 Request 对象,只提取必要信息。

方案 1:提取 Cookie 构建请求头

@Service
public class ExportService {
    @Async
    public void asyncExport(String authToken) { // 直接传token
        List<Data> dataList = dataService.queryLargeData();
        File file = excelService.generateExcel(dataList);
        
        HttpHeaders headers = new HttpHeaders();
        headers.add("Cookie", "auth_token=" + authToken); // 手动构建Cookie
        fileCenterClient.uploadWithHeaders(file, headers);
    }
}

// 控制器提取Cookie
@RequestMapping("/export")
public String exportData(HttpServletRequest request) {
    String authToken = CookieUtils.getCookieValue(request, "auth_token");
    exportService.asyncExport(authToken);
    return "文件正在生成,请稍后下载";
}

方案 2:用 InheritableThreadLocal 传递上下文

public class AsyncContextHolder {
    // 子线程可继承的ThreadLocal
    private static ThreadLocal<Map<String, Object>> context = new InheritableThreadLocal<>();
    
    public static void set(String key, Object value) { ... }
    public static Object get(String key) { ... }
    public static void clear() { context.remove(); } // 务必清理
}

// 使用方式
@RequestMapping("/export")
public String exportData(HttpServletRequest request) {
    AsyncContextHolder.set("auth_token", getCookieValue(request, "auth_token"));
    exportService.asyncExport();
    return "文件正在生成,请稍后下载";
}

@Async
public void asyncExport() {
    try {
        String authToken = (String) AsyncContextHolder.get("auth_token");
        // 业务处理...
    } finally {
        AsyncContextHolder.clear(); // 防止内存泄漏
    }
}

方案 3:重构逻辑避免跨服务异步调用

@RequestMapping("/export")
public String exportData(HttpServletRequest request) {
    String fileId = UUID.randomUUID().toString();
    exportService.asyncGenerateFile(fileId); // 异步生成文件
    fileCenterClient.prepareUpload(fileId, request); // 主线程调用上传
    return "文件正在生成,请稍后下载";
}

五、@Async 的其他隐藏陷阱

除了上下文丢失,@Async 还有这些坑:

5.1 异常处理黑洞

异步方法的异常不会直接抛出,而是被线程池吞噬:



@Async
public void asyncMethod() {
    int i = 1 / 0; // 异常不会被控制器捕获
}

解决:实现 AsyncUncaughtExceptionHandler 接口统一处理

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> {
            log.error("异步方法{}执行失败", method.getName(), ex);
        };
    }
}

5.2 事务管理失效

@Async 与 @Transactional 一起使用时,事务可能不生效:



@Service
public class UserService {
    @Async
    @Transactional // 此事务可能无效
    public void asyncSaveUser(User user) {
        userMapper.insert(user);
        throw new RuntimeException("故意出错"); // 数据可能不会回滚
    }
}

原理:异步方法在独立线程执行,事务上下文无法传递。

解决:将事务逻辑抽为同步方法,异步调用它:

@Service
public class UserService {
    @Async
    public void asyncSaveUser(User user) {
        saveUser(user); // 调用带事务的同步方法
    }
    
    @Transactional
    public void saveUser(User user) {
        userMapper.insert(user);
    }
}

5.3 线程池耗尽风险

默认线程池(SimpleAsyncTaskExecutor)会无限创建线程,高并发下可能导致 OOM。

解决:自定义线程池控制资源:



@Bean
public Executor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(10); // 核心线程数
    executor.setMaxPoolSize(50); // 最大线程数
    executor.setQueueCapacity(200); // 队列容量
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    return executor;
}

六、总结:@Async 使用守则

  1. 最小化上下文依赖:只传递必要参数,不传递 Request/Session
  1. 明确异常处理:实现 AsyncUncaughtExceptionHandler
  1. 自定义线程池:避免使用默认线程池
  1. 事务分离:异步方法不直接包含事务逻辑
  1. 谨慎使用场景:跨服务调用、权限校验、事务操作尽量避免异步

最后分享排查技巧:打印线程名快速定位上下文切换问题:


public void logThread(String message) {

log.info("{} - 线程:{}", message, Thread.currentThread().getName());

}

@Async 虽好,但滥用会埋下隐患。记住:异步不是银弹,合理使用才能发挥其价值。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

练习时长两年半的程序员小胡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值