慎用 @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 请求线程完全隔离:
- 异步线程无法继承主线程的 ThreadLocal 变量
- HttpServletRequest 在主线程结束后被销毁
- 依赖请求上下文的信息(如 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 使用守则
- 最小化上下文依赖:只传递必要参数,不传递 Request/Session
- 明确异常处理:实现 AsyncUncaughtExceptionHandler
- 自定义线程池:避免使用默认线程池
- 事务分离:异步方法不直接包含事务逻辑
- 谨慎使用场景:跨服务调用、权限校验、事务操作尽量避免异步
最后分享排查技巧:打印线程名快速定位上下文切换问题:
public void logThread(String message) {
log.info("{} - 线程:{}", message, Thread.currentThread().getName());
}
@Async 虽好,但滥用会埋下隐患。记住:异步不是银弹,合理使用才能发挥其价值。