为什么不能随手建线程池
“一个请求里要调三个下游,开个线程池并行一下”——很多人第一次优化接口时都会这么想,然后随手建了个固定 10 线程的池子。本地压测很快,上线后问题才暴露:任务队列无界,下游变慢时任务堆在队列里,内存一路涨到 OOM;线程没有名字,jstack 里全是 pool-1-thread-3,根本不知道是哪个业务;应用停机时线程池不等待,正在执行的任务直接被中断。
Spring Boot 3.2 默认提供了 applicationTaskExecutor,但它的参数是通用配置,不适合直接承载核心业务。正确做法是按业务隔离线程池,显式指定核心数、最大数、有界队列、拒绝策略和线程名前缀。
参数不是拍脑袋定的
核心数取决于任务类型。CPU 密集型任务,线程数接近核数即可,开太多只会增加上下文切换;IO 密集型任务,大部分时间在等网络或磁盘,可以按 核数 × 2 起步,再用压测结果修正。最大数不要为了“弹性”设得过大,线程创建和栈内存都有成本。
队列必须有界。无界队列会让 maximumPoolSize 永远不生效,因为任务全堆在队列里,线程数停在 core。队列长度要结合可接受延迟来定:队列越长,排队时间越不可控。拒绝策略则决定了系统过载时的行为,CallerRunsPolicy 让调用线程自己执行,能形成天然背压,但会拖慢接口;AbortPolicy 直接抛异常,适合让上游快速失败。
代码:一个可观测的业务线程池
下面用 ThreadPoolExecutor 显式创建,线程名前缀交给 Spring 的 CustomizableThreadFactory,避免手写 new Thread:
@Configuration
public class ExecutorConfig {
private ThreadPoolExecutor executor;
@Bean(name = "orderExecutor", destroyMethod = "")
public ThreadPoolExecutor orderExecutor(MeterRegistry meterRegistry) {
executor = new ThreadPoolExecutor(
8, 16, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new CustomizableThreadFactory("order-"),
new ThreadPoolExecutor.CallerRunsPolicy());
ExecutorServiceMetrics.monitor(meterRegistry, executor, "order.executor");
return executor;
}
@PreDestroy
public void shutdown() throws InterruptedException {
executor.shutdown();
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
}
}
@PreDestroy 里先 shutdown 停止接收新任务,再等待 30 秒;超过时间才 shutdownNow 中断。顺序反了,正在执行的任务就会只做一半。
使用时通过 @Async("orderExecutor") 或直接注入执行器提交任务。前者要求开启 @EnableAsync,并且方法必须从另一个 Bean 调用,自调用不会走代理。
监控看哪些指标
只配线程池不看指标,等于闭着眼睛调参。Micrometer 会暴露活跃线程数、池大小、队列长度和已完成任务数,Actuator 打开后可以直接查:
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
tags:
application: ${spring.application.name}
curl -s http://localhost:8080/actuator/metrics/executor.active
curl -s http://localhost:8080/actuator/metrics/executor.queue.size
executor.active 长期贴近最大线程数、queue.size 持续上涨,说明池子已经扛不住,要么扩容,要么限制入口流量。反过来,如果活跃线程数一直很低、队列却经常满,先检查是不是任务被同步阻塞在锁上,而不是盲目加线程。指标要配合告警和基线看,单次快照说明不了问题。
常见坑
1. 用了无界队列
new LinkedBlockingQueue<>() 不传容量就是 Integer.MAX_VALUE。核心线程满了之后任务全进队列,最大线程数形同虚设,过载时直接 OOM。
2. @Async 方法没有生效
自调用、方法 private、类没有被 Spring 管理,都会让异步注解失效。表现是接口耗时没下降,日志里仍是同一个线程名。先打印 Thread.currentThread().getName() 确认。
3. 异步方法把异常吞了
返回 void 的 @Async 方法抛出的异常不会传给调用方,只会进日志。需要感知结果就用 CompletableFuture,并配置 AsyncUncaughtExceptionHandler 兜底记录。
4. ThreadLocal 上下文丢失
traceId、登录用户、租户信息通常放在 ThreadLocal,切到线程池就没了。用 TaskDecorator 在任务提交时复制上下文,执行完在 finally 里清理,避免线程复用时串数据。
5. 停机时丢任务
默认 shutdownNow 会中断正在跑的任务,@Async 里的业务可能只执行了一半。优雅停机要配合 server.shutdown=graceful,并给线程池设置足够的等待时间。
小结
线程池的核心不是“开多少线程”,而是“过载时系统怎么表现”。参数显式、队列有界、拒绝策略明确、指标可查,这四点做到,比事后调大最大线程数有用得多。
评论