引言
在基于 Spring Security 的应用程序中,我们经常需要在异步任务中获取当前用户信息。然而,当使用 CompletableFuture.runAsync() 或线程池执行异步任务时,经常会遇到 NullPointerException 异常,错误信息通常为:Cannot invoke "org.springframework.security.core.Authentication.getPrincipal()" because "authentication" is null。
本文将深入分析这个问题的根本原因,并提供多种实用的解决方案。
问题分析
为什么会出现这个问题?
Spring Security 默认使用 ThreadLocal 来存储安全上下文(SecurityContext)。每个线程都有自己的 ThreadLocal 变量副本,这意味着:
-
线程隔离 :主线程的
SecurityContext不会自动传递给新创建的线程 -
线程池复用:线程池中的线程可能包含之前任务的安全上下文,造成数据污染
-
上下文丢失 :异步任务开始时,新线程的
SecurityContextHolder是空的
错误示例
以下是典型的错误场景:
CompletableFuture.runAsync(() -> {
// 这里会抛出 NullPointerException
User user = SecurityUtils.getUser(); // 内部调用 SecurityContextHolder.getContext()
// ... 业务逻辑
}, threadPoolExecutor);
错误栈信息:
text
java.lang.NullPointerException: Cannot invoke "org.springframework.security.core.Authentication.getPrincipal()" because "authentication" is null
at com.tcl.jgcloud.common.security.util.SecurityUtils.getUser(SecurityUtils.java:57)
at com.tcl.jgcloud.indicator.handler.export.CommonExportService.lambda$executeDownload$0(CommonExportService.java:156)
解决方案
方案1:手动传递安全上下文(推荐)
这是最直接且灵活的方法,在异步任务开始前显式传递安全上下文。
// 在异步任务开始前获取当前安全上下文
SecurityContext securityContext = SecurityContextHolder.getContext();
CompletableFuture.runAsync(() -> {
// 设置安全上下文到当前线程
SecurityContextHolder.setContext(securityContext);
try {
log.info(">>> 开始异步导出任务, 日志ID{}", logId);
// 现在可以安全地获取用户信息
User currentUser = SecurityUtils.getUser();
// ... 原有的业务逻辑
// 更新日志
excelHandleLogService.updateLog(logId, handleLogStatusEnum.getCode(),
exportFileName, null, null, null, null,
CommonConstant.EMPTY_STRING, currentUser);
log.info(">>> 更新导出日志状态为{}", handleLogStatusEnum.getCode());
} finally {
// 必须清理安全上下文,避免内存泄漏和数据污染
SecurityContextHolder.clearContext();
}
}, threadPoolExecutor).exceptionally(ex -> {
// 异常处理中也设置安全上下文
SecurityContextHolder.setContext(securityContext);
try {
log.error(">>>用户执行导出异常,error:", ex);
excelHandleLogService.updateLog(logId, HandleExcelStatusEnum.FAILURE.getCode(),
CommonConstant.EMPTY_STRING, null, null, null, null,
CommonConstant.EMPTY_STRING, user);
throw BusinessException.of(ex.getMessage());
} finally {
SecurityContextHolder.clearContext();
}
});
优点:
-
简单直接,不需要额外配置
-
灵活控制上下文传递的时机
-
适用于各种异步场景
缺点:
-
需要在每个异步调用处重复代码
-
容易忘记清理上下文,导致内存泄漏
方案2:封装上下文传递的线程池
创建自定义线程池,自动传递安全上下文,实现代码复用。
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Component
public class SecurityContextAwareThreadPoolExecutor extends ThreadPoolTaskExecutor {
@Override
public void execute(Runnable task) {
SecurityContext context = SecurityContextHolder.getContext();
super.execute(() -> {
try {
SecurityContextHolder.setContext(context);
task.run();
} finally {
SecurityContextHolder.clearContext();
}
});
}
@Override
public <T> Future<T> submit(Callable<T> task) {
SecurityContext context = SecurityContextHolder.getContext();
return super.submit(() -> {
try {
SecurityContextHolder.setContext(context);
return task.call();
} finally {
SecurityContextHolder.clearContext();
}
});
}
@Override
public Future<?> submit(Runnable task) {
SecurityContext context = SecurityContextHolder.getContext();
return super.submit(() -> {
try {
SecurityContextHolder.setContext(context);
task.run();
} finally {
SecurityContextHolder.clearContext();
}
});
}
}
使用示例:
@Autowired
private SecurityContextAwareThreadPoolExecutor securityContextAwareThreadPoolExecutor;
CompletableFuture.runAsync(() -> {
// 现在可以直接使用 SecurityUtils.getUser() 了
User currentUser = SecurityUtils.getUser();
log.info(">>> 开始异步导出任务, 用户:{}", currentUser.getUserTenantId());
// ... 业务逻辑
}, securityContextAwareThreadPoolExecutor);
优点:
-
代码复用,一处配置多处使用
-
对业务代码透明,无需修改现有异步调用
-
自动清理上下文,避免内存泄漏
缺点:
-
需要替换现有的线程池配置
-
如果项目中有多个线程池,需要统一管理
方案3:使用 Spring Security 的委托执行器
Spring Security 提供了 DelegatingSecurityContextAsyncTaskExecutor 来包装现有的异步执行器。
配置类:
@Configuration
public class AsyncConfig {
@Bean
public ThreadPoolTaskExecutor threadPoolTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.initialize();
return executor;
}
@Bean
public DelegatingSecurityContextAsyncTaskExecutor delegatingSecurityContextAsyncTaskExecutor(
ThreadPoolTaskExecutor threadPoolTaskExecutor) {
return new DelegatingSecurityContextAsyncTaskExecutor(threadPoolTaskExecutor);
}
}
使用示例:
@Autowired
private DelegatingSecurityContextAsyncTaskExecutor securityContextExecutor;
CompletableFuture.runAsync(() -> {
// 安全上下文已自动传递
User currentUser = SecurityUtils.getUser();
// ... 业务逻辑
}, securityContextExecutor);
优点:
-
Spring Security 官方支持
-
配置简单,功能完善
-
支持多种异步执行器
缺点:
-
需要引入额外的 Spring Security 依赖
-
配置相对复杂
方案4:完全避免在异步中使用安全上下文
在某些场景下,我们可以重构业务逻辑,在异步任务开始前获取所有需要的数据。
// 在异步任务开始前获取用户信息和所有需要的数据
User currentUser = SecurityUtils.getUser();
List<String> requiredPermissions = getRequiredPermissions();
// 可以预先执行一些权限检查
CompletableFuture.runAsync(() -> {
log.info(">>> 开始异步导出任务, 日志ID{}, 用户:{}",
logId, currentUser.getUserTenantId());
// 使用传入的用户信息,而不是在异步中获取
// ... 业务逻辑
// 更新日志 - 使用外部传入的currentUser
excelHandleLogService.updateLog(logId, handleLogStatusEnum.getCode(),
exportFileName, null, null, null, null,
CommonConstant.EMPTY_STRING, currentUser);
}, threadPoolExecutor);
优点:
-
最安全的方案,完全避免上下文传递问题
-
代码清晰,数据流向明确
-
减少线程间的数据依赖
缺点:
-
可能需要重构现有业务逻辑
-
不适用于需要在异步中动态获取用户信息的场景
方案5:封装任务对象
将异步任务封装成对象,在构造时捕获安全上下文。
public class ExportTask implements Runnable {
private final SecurityContext securityContext;
private final Long logId;
private final User user;
private final DownloadData downloadData;
// ... 其他参数
public ExportTask(Long logId, User user, DownloadData downloadData) {
this.securityContext = SecurityContextHolder.getContext();
this.logId = logId;
this.user = user;
this.downloadData = downloadData;
}
@Override
public void run() {
SecurityContextHolder.setContext(securityContext);
try {
// 执行业务逻辑
executeExport();
} finally {
SecurityContextHolder.clearContext();
}
}
private void executeExport() {
// 这里可以直接使用 SecurityUtils.getUser() 或 this.user
log.info(">>> 开始异步导出任务, 用户:{}", user.getUserTenantId());
// ... 业务逻辑
}
}
// 使用
CompletableFuture.runAsync(
new ExportTask(logId, user, downloadData),
threadPoolExecutor
);
优点:
-
对象化封装,职责清晰
-
支持参数传递和上下文传递
-
可复用性高
缺点:
-
需要为每个任务类型创建类
-
增加了代码复杂度
最佳实践建议
1. 综合方案推荐
对于大多数项目,我推荐结合使用方案1和方案4:
@Component
public class AsyncSecurityHelper {
/**
* 执行需要安全上下文的异步任务
*/
public static <T> CompletableFuture<T> runWithSecurityContext(
Supplier<T> task,
Executor executor) {
SecurityContext securityContext = SecurityContextHolder.getContext();
return CompletableFuture.supplyAsync(() -> {
SecurityContextHolder.setContext(securityContext);
try {
return task.get();
} finally {
SecurityContextHolder.clearContext();
}
}, executor);
}
/**
* 执行需要安全上下文的异步任务(无返回值)
*/
public static CompletableFuture<Void> runWithSecurityContext(
Runnable task,
Executor executor) {
SecurityContext securityContext = SecurityContextHolder.getContext();
return CompletableFuture.runAsync(() -> {
SecurityContextHolder.setContext(securityContext);
try {
task.run();
} finally {
SecurityContextHolder.clearContext();
}
}, executor);
}
}
// 使用示例
AsyncSecurityHelper.runWithSecurityContext(() -> {
User currentUser = SecurityUtils.getUser();
// 执行业务逻辑
return processData();
}, threadPoolExecutor).thenAccept(result -> {
// 处理结果
});
2. 线程池配置建议
yaml
# application.yml
spring:
task:
execution:
pool:
core-size: 10
max-size: 50
queue-capacity: 1000
keep-alive: 60s
thread-name-prefix: async-task-
3. 异常处理建议
@Slf4j
@Component
public class AsyncExceptionHandler {
public static <T> CompletableFuture<T> handleAsyncException(
CompletableFuture<T> future,
Long logId,
User user,
ExcelHandleLogService excelHandleLogService) {
return future.exceptionally(ex -> {
log.error("异步任务执行异常, 日志ID: {}, 用户: {}", logId, user.getUserTenantId(), ex);
// 更新日志状态
excelHandleLogService.updateLog(logId,
HandleExcelStatusEnum.FAILURE.getCode(),
CommonConstant.EMPTY_STRING, null, null, null, null,
ex.getMessage(), user);
throw new BusinessException("异步任务执行失败", ex);
});
}
}
4. 监控和日志
@Aspect
@Component
@Slf4j
public class AsyncExecutionAspect {
@Around("execution(@org.springframework.scheduling.annotation.Async * *.*(..))")
public Object logAsyncExecution(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
String methodName = joinPoint.getSignature().getName();
try {
log.info("开始执行异步方法: {}", methodName);
Object result = joinPoint.proceed();
log.info("异步方法执行完成: {}, 耗时: {}ms",
methodName, System.currentTimeMillis() - startTime);
return result;
} catch (Exception e) {
log.error("异步方法执行异常: {}", methodName, e);
throw e;
}
}
}
总结
Spring Security 在异步线程中丢失用户信息是一个常见但容易解决的问题。选择哪种方案取决于你的具体需求:
-
简单项目:使用方案1(手动传递)即可
-
中型项目:建议使用方案2(自定义线程池)
-
大型项目:推荐使用方案3(Spring Security官方方案)或综合方案
-
性能敏感:考虑方案4(避免在异步中使用安全上下文)
无论选择哪种方案,都要记住:
-
始终在
finally块中清理安全上下文 -
合理配置线程池参数
-
添加适当的监控和日志
-
进行充分的测试
通过正确的处理,我们可以在享受异步编程带来的性能优势的同时,确保安全上下文的正确传递,构建出既高效又安全的应用程序。