记一次因线程池不合理使用导致生产拨测告警
关键词:ThreadPoolExecutor、核心线程回收、CompletableFuture
摘要
一个 导出逻辑中自建的临时线程池 承载文档导出与批量任务。某天拨测告警,Grafana 上线程的活跃线程数一路走高,告警时在400左右------不降、不报错、也不触发拒绝,服务看起来"还活着",但线程一直占着不放。
排查下来,问题不在"任务突然变多",而在线程创建了就不还:CLOSE_WAIT状态的TCP连接一直在增加,线程数也在一直增加。
本文分两部分:前半段复盘现象、排查路径与根因机制;后半段是可落地的代码建议,重点是线程池合理使用及隔离,以及线程池指标的监控。
一、现象
发现方式:拨测告警。 不是用户投诉,也不是 OOM,是拨测任务触发异常。
打开 Grafana 看服务监控,有三个很反直觉的特征:
- TCP连接数和线程数单调爬升,最终TCP连接数在300左右,线程数在400左右,CPU利用率和内存利用率都在一个正常的范围内;
- TCP连接中唯独CLOSE_WAIT连接一直增加;
- 线程池中线程名称有pool-N-thread开头编号累加,每个中间编号对应的线程数都是6。
线程名称的规律。pool-N-thread-1 到 pool-N-thread-6,每个编号固定 6 个线程,说明每次触发这个业务场景,线程池就创建一批线程,用完之后这批线程没有归还,下次再触发又创建一批新的。编号不断累加,线程数只升不降。
也就是说,从传统"线程池被打满"的经验去看,这里既没有拒绝、队列也没溢出,唯一异常的就是线程数只升不降。业务侧的表现是导出还能出结果,但整体变慢。
这组现象是后文定位的关键:线程数不回落,说明瓶颈在回收侧。
二、排查路径
2.1 先排除"任务提交速率突增"
第一反应是上游流量放大或者代码里出现了循环批量提交。
- 时间点: 周日晚上,受限于系统业务属性,这个点用户也都在周末状态,正常用户量很少;
- 监控: 看链路监控印证了时间点的猜测,只有3个用户在使用;
- 线程数对比: 线程数从 370 涨到 400左右,涨了 30 个左右,但同一时间段内的请求 QPS 几乎没有变化,任务提交速率远低于线程数的涨幅。
也就是说从用户量及使用量上来说提交速率没有出现与线程数涨幅匹配的量级跳变。问题不在"进来了太多任务",而在"线程用了没还"。
2.2 看业务逻辑点在哪
接下来是日志与代码审查这条线。
- 定位时间点: 看TCP连接数和线程数陡增的时间点,把这些时间点前后10分钟的日志down下来分析原因;
- 日志特征: 发现这些时间点内用户都有操作一个文档导出(文档内容较多),且导出操作的日志耗时明显偏长,单次导出耗时在 30 秒以上;
- 代码走读: 顺着导出接口的调用链往下看,发现导出过程中会并发调用多个下游服务获取用户信息,而这些并发调用是通过一个临时创建的线程池来完成的。
到这里, 问题已经集中到了这个线程池上------它创建了线程,但用完之后没有归还。
2.3 锁定根因:线程创建了就不还
结合之前看的线程池状态,大量线程都是pool-N-thread-1到pool-N-thread-6,发现代码中有一处写法是这样:
ini
// (简化示意,非源码原文)
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(6);
// 使用
CompletableFuture<Map<String,List<Bo>>> boFuture = CompletableFuture.supplyAsync(() -> infoService.searchInfo(finalIdsList), fixedThreadPool);
这段代码的问题在于:每次导出操作都会通过 Executors.newFixedThreadPool(6) 创建一个新的线程池,核心线程数设为 6。任务执行完毕后,没有任何关闭或回收线程池的动作------没有调用 shutdown(),也没有 shutdownNow()。
newFixedThreadPool 创建的核心线程一旦存在,除非显式配置了 allowCoreThreadTimeOut(true),否则即使空闲也不会自动退出 。而这些临时创建的线程池,在方法栈退出后,引用虽然丢失,但线程还在,阻塞在 workQueue.take() 上等待下一个永远不会到来的任务。
每一次导出操作,就留下 6 个永不退出的线程。用户操作 30 次,就多 180 个线程。这就解释了 Grafana 上看到的线程数单调爬升、编号不断累加的现象。
到这里,根因已经清晰:每次导出操作创建的线程池没有被正确关闭,核心线程默认不回收,导致线程数只升不降。
三、根因总结
这次故障是两个缺陷叠加的结果:
| 层次 | 具体因素 | 在故障中的作用 |
|---|---|---|
| 代码层 | 每次导出操作都 Executors.newFixedThreadPool(6),用完后没有调用 shutdown() / shutdownNow() |
线程池用完后无人关闭,6 个核心线程永久残留,每次操作累加 6 个 |
| JVM 层 | 核心线程默认终身制(allowCoreThreadTimeOut 默认 false) |
即便线程池引用丢失,线程本身也不会超时退出,阻塞在 workQueue.take() 上永不退场 |
两个缺陷缺一不可:如果使用后调了 shutdown(),线程池会被正确销毁,核心线程终身制也无所谓;如果核心线程能被超时回收,即便忘了 shutdown(),线程最终也会自行退出。偏偏两个都没做,线程数就只升不降。
CLOSE_WAIT 的持续增长与线程泄漏同源:任务执行期间创建的 HTTP 连接,因为代码里缺少 finally 式的资源清理,用完后没有被归还或关闭;对端(下游服务)空闲超时后主动发出 FIN,本端却再也没有代码去 close() 这个连接,TCP 状态机就卡在 CLOSE_WAIT。严格来说,CLOSE_WAIT 的直接原因是"连接没有被关闭",而不是"线程没有被回收"------线程阻塞在 workQueue.take() 时任务早已执行完,空闲线程本身并不持有连接。二者是同一段"缺少资源清理"的代码在连接维度和线程维度上的两个并存症状,共同指向资源泄漏。
需要特别指出的是,这类问题的隐蔽性在于它不产生异常 。没有 RejectedExecutionException、没有 OOM、没有错误堆栈,只有几百个空闲线程静静挂在 workQueue.take() 上,持续占用线程栈内存和调度资源。如果只盯着错误日志,是找不到它的。
四、代码建议
针对根因,下面给出线程池使用的建议。
4.1 修复线程池生命周期:从 per-request 创建改为单例 Bean
每次导出都 Executors.newFixedThreadPool(6) 创建一个线程池,用完后没有 shutdown()。修复思路有两个层次,按优先级排列。
4.1.1 核心修复:线程池改为 Spring 单例 Bean
问题的根源在于线程池的生命周期与请求生命周期不一致------每次请求都创建一个新池,请求结束后池的引用丢失,但线程还挂在 JVM 里。
修复方案是把线程池注册为 Spring Bean,让容器管理它的创建和销毁:
scss
@Configuration
public class ExecutorConfig {
@Bean("exportExecutor")
public ThreadPoolTaskExecutor exportExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(6);
executor.setMaxPoolSize(12);
executor.setKeepAliveSeconds(60);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("export-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setAllowCoreThreadTimeOut(true); // 兜底:即便忘了 shutdown,线程也能超时退出
executor.initialize();
return executor;
}
}
业务代码从"自己创建线程池"改为"注入单例 Bean":
less
@Autowired
@Qualifier("exportExecutor")
private ThreadPoolTaskExecutor exportExecutor;
try {
// 并发获取不同的用户信息
} catch (Exception e) {
throw new XXXException("****************");
} finally {
// 单例 Bean 由容器管理,不需要在这里 shutdown
// 注意:单例池一旦 shutdown 即进入终止状态、不可复用,
// 后续请求再提交任务会被拒绝,业务代码中绝不要对它调用 shutdown()
}
单例 Bean 的生命周期与应用一致:启动时创建,关闭时由容器销毁。不再每次请求创建新池,线程数也不会随请求次数累加。
4.1.2 兜底策略:allowCoreThreadTimeOut(true)
即便改成了单例,核心线程默认终身制仍然值得改掉。这是一个防御性配置------万一代码里还有遗漏的 shutdown(),allowCoreThreadTimeOut 能让线程在空闲超时后自行退出,而不是永远挂着。
allowCoreThreadTimeOut(true) 之后,getTask() 里的 timed 恒为 true,所有线程都走 poll(keepAliveTime) 分支,空闲超时即可退出。这是让核心线程在池存活期间能够自动超时回收的开关(shutdown() 同样能让线程退出,但那是一次性的销毁,不是自动回收)。
需要说明这里与"corePoolSize 按稳态定"的取舍:开启 allowCoreThreadTimeOut(true) 后,核心线程空闲超过 keepAliveSeconds 同样会退出,线程数最终会回落到 0,corePoolSize 实际只相当于"预热水位",并不能维持稳态线程数。如果希望低谷期也保留一定数量的常驻线程(例如避免突发流量时的线程创建开销),可以适当调大 keepAliveSeconds,或者接受低谷期线程归零、下一次请求冷启动的少量延迟------"核心线程常驻"与"空闲自动回收"不可兼得,需按业务取舍。
配套两个原则:
- corePoolSize 按日常稳态并发定,不要按峰值定。 峰值靠
maximumPoolSize和异步化削平。在开启allowCoreThreadTimeOut的前提下,它更多是"预热水位",实际维持的稳态线程数由keepAliveSeconds决定;核心数给大了,即便开了超时回收,也会长期维持一批不必要的线程。 - 队列容量要小、拒绝策略要有意义。 容量按「可接受的最大排队延迟 × 平均处理速率」倒推。
CallerRunsPolicy的价值在于把背压传回调用方,让上游自己慢下来,而不是无声堆积。
4.2 ✅ 线程池按职责隔离
出问题时,每次导出请求都会创建一个新的线程池,不存在共用问题。但 4.1 把线程池改为单例 Bean 之后,所有导出请求都复用同一个池------如果这个池还承担其他类型的任务(批量通知、定时回调等),一个业务的慢,就会传染成所有业务的慢。隔离在这个阶段就变得必要了。
隔离的原则是按「阻塞特征」而不是按「业务名字」拆:
arduino
@Configuration
public class ExecutorConfig {
/** 导出/批量:CPU + 内存密集,耗时长,并发要严格限制 */
@Bean("exportExecutor")
public ThreadPoolExecutor exportExecutor() {
return buildPool("export-", 4, 16, 60L, 100);
}
/** 下游 IO:等待型任务,线程数可以给大一些 */
@Bean("ioExecutor")
public ThreadPoolExecutor ioExecutor() {
return buildPool("io-", 32, 128, 60L, 500);
}
/** 通知****** */
******
private ThreadPoolExecutor buildPool(String namePrefix, int core, int max,
long keepAliveSeconds, int queueCapacity) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
core, max, keepAliveSeconds, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueCapacity),
new NamedThreadFactory(namePrefix),
new ThreadPoolExecutor.CallerRunsPolicy());
executor.allowCoreThreadTimeOut(true); // 每个池都默认开启
return executor;
}
}
线程数可以按照如下方式进行评估:
- CPU 密集型 (导出构建 Workbook 这类):
核数 + 1附近起步,再压测微调。给多了只会加剧上下文切换和 GC。 - IO 等待型 (RPC、DB、上传):
核数 × (1 + 平均等待时间 / 平均计算时间)。等待占比越高,可给的线程数越多。 - 两个数字都只是起点,最终要靠 4.4 的指标观测反推,不要一次拍定。
4.3 ✅ 完善监控:监控TCP连接数及线程数
我们项目的监控之前只有CPU和内存使用率的监控,之后借助运维部门已有的功能添加上了TCP连接数和线程数的监控,大家可以根据自身项目情况选择。
写在最后
这次故障真正的教训不是"线程池参数没配好",而是两件更普遍的事:
- 默认值往往不是安全值。
allowCoreThreadTimeOut默认false,意味着核心线程默认终身制。用了一个类,就要清楚它每个默认值在生产环境下的含义。 - "不报错"不等于"没问题"。 没有拒绝、没有异常、没有 OOM,服务还能响应,但资源已经在悄悄泄漏。线程静静挂在
workQueue.take()上等待永远不会到来的任务,这类泄漏不会触发任何告警,只有监控趋势曲线能捕捉到它。
如果你的服务里也有自建线程池,建议关注一下线程使用情况:高峰过去之后,poolSize 回落了吗?
说明
- 本文基于一次真实的线上排查经历整理,代码示例为简化示意,非生产源码原文。涉及的线程池参数、超时阈值等数值仅反映当时的业务场景,不构成通用最佳实践,实际使用请结合自身系统的流量特征与压测结果调整。
- 文中提到的监控方案(TCP 连接数、线程数监控)依赖具体基础设施能力,不同团队的运维体系和可观测性成熟度不同,落地方式需因地制宜。
- 个人技术水平有限,文中难免存在疏漏或不准确之处,欢迎指正与交流。