一、背景:你组的线程池真的配对了吗?
线程池是 Spring Boot 服务中最基础也最容易出问题的组件之一。绝大多数项目初期只靠 ThreadPoolTaskExecutor 的默认配置就上了线,等到双十一大促流量一冲——队列积压、核心线程打满、拒绝策略触发,甚至服务直接卡死。本文从一个电商中台的实际案例入手,覆盖线程池的隔离设计、参数调优、异常处理、上下文传递与优雅关闭的完整链路。
二、核心配置:ThreadPoolTaskExecutor 与 ThreadPoolExecutor
Spring Boot 推荐使用 ThreadPoolTaskExecutor 而非裸 ThreadPoolExecutor,前者由 Spring 管理生命周期并自动绑定 DelegatingSecurityContextExecutor。一个生产级的配置:
@Configuration
public class ThreadPoolConfig {
@Bean("commonExecutor")
public ThreadPoolTaskExecutor commonExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数:CPU 密集型 2-4,IO 密集型酌情放大
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
// 队列容量:200 意味着瞬时突发可缓冲,超出触发 rejection
executor.setQueueCapacity(200);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("common-");
// 拒绝策略:CallerRunsPolicy 让调用的主线程自己跑,不丢任务
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 等待所有任务完成再 shutdown
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
@Bean("asyncExecutor")
public ThreadPoolTaskExecutor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(15);
executor.initialize();
return executor;
}
}
⚠️ 两个最容易踩的坑:
- @Async 默认使用
SimpleAsyncTaskExecutor:它为每个请求新开线程,完全不复用,高并发下无限创建导致 OOM。必须显式声明自定义 Executor Bean 并指定@Async("asyncExecutor")。 - CallerRunsPolicy 不是银弹:它虽不丢任务,但让调用线程(如 Tomcat 的请求处理线程)去跑异步任务,响应时间飙升。对延迟敏感接口应改用
DiscardPolicy+ 补偿机制。
三、线程隔离:不同业务用不同的池
核心原则:一个池死掉不影响另一个池。推荐按业务维度拆分:
- 订单处理池:core=4,max=8,queue=100,独立名称
- 推送通知池:core=2,max=4,queue=50,失败可重试
- 数据分析池:core=2,max=4,queue=200,不要求实时
配合 @Async 注解指定 Bean 名称即可:
@Service
public class OrderService {
@Async("orderExecutor")
public CompletableFuture<OrderResult> processOrder(OrderDTO dto) {
// 实际的订单处理逻辑
return CompletableFuture.completedFuture(result);
}
}
四、异步异常处理:别让异常静默消失
默认 @Async 方法抛出的异常不会进入 Controller 的异常处理。必须注册 AsyncUncaughtExceptionHandler:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
return asyncExecutor(); // 引用上面的 Bean
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("异步方法执行异常: {}#{},参数: {}",
method.getDeclaringClass().getSimpleName(),
method.getName(), params, ex);
// 发送告警通知(钉钉/微信/邮件)
alertService.sendAlert("异步任务异常: " + ex.getMessage());
};
}
}
⚠️ CompletableFuture 的异常陷阱
使用 CompletableFuture.supplyAsync() 或 runAsync() 时,异常被封装在返回的 CompletableFuture 里。调用 future.get() 或 future.join() 才会抛出 ExecutionException。如果不调用 get/join,异常完全不会触发 AsyncUncaughtExceptionHandler——这就是为什么线上日志里"任务跑完了但结果丢了"。
五、上下文传递:MDC & SecurityContext 跨线程
主线程的 MDC(日志链路追踪)和 SecurityContext 在异步线程中默认丢失。你需要装饰器:
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable task) {
Map<String, String> mdcContext = MDC.getCopyOfContextMap();
return () -> {
if (mdcContext != null) {
MDC.setContextMap(mdcContext);
}
try {
task.run();
} finally {
MDC.clear();
}
};
}
}
在 ThreadPoolTaskExecutor 上设置:executor.setTaskDecorator(new MdcTaskDecorator());。这样异步方法也能输出完整的 traceId 日志链路,排查问题时不必跨线程瞎翻。
同理,SecurityContext 用 SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL) 或自定义 TaskDecorator 手动传递。
六、优雅关闭:拒绝"停机就丢请求"
滚动发布或下线时,如果直接 kill 进程,正在处理的异步任务全部丢失。工程化做法:
- 配合 Spring Boot Actuator:先通过 /actuator/shutdown 或 /actuator/pause 停止接受新请求;
- 等线程池 drain 完:设置
setWaitForTasksToCompleteOnShutdown(true)+setAwaitTerminationSeconds(30); - K8s 或自定义脚本中给足宽限:preStop hook 等待 30-45 秒,让 Spring 完成线程池的 drain。
@PreDestroy
public void onShutdown() {
log.info("==== 开始优雅关闭线程池 ====");
// 显式 shutdown 所有自定义 Executor
// Spring 管理的 Bean 会自动调用,但日志输出确认很有必要
}
血的教训:某项目只配了 5 秒 awaitTermination,大促时线程池里堆积了上千个任务,5 秒根本跑不完,直接丢弃。实测经验:给线程池 30 秒缓冲,配合 PreStop 45 秒的 K8s 配置,才做到无损发布。
七、最佳实践总结
- 按业务隔离池:订单、推送、日志分开,互相不可见;
- 监控线程池状态:暴露
ThreadPoolExecutor的 activeCount/queueSize/rejectedCount 到 Prometheus + Grafana; - 拒绝策略选型:对延迟敏感用 AbortPolicy + 重试补偿,对完成性要求高用 CallerRunsPolicy;
- 异常必须兜底:注册 AsyncUncaughtExceptionHandler + 告警;
- MDC 传递不可少:否则线上排查像无头苍蝇;
- 优雅关闭是底线:waitForTasks + awaitTermination + PreStop 三重保障。