做后端开发这么多年,线程池算是最基础、最常用的组件了。不管是业务异步处理、接口并行查询,还是定时任务拆解,基本都会用到ThreadPoolExecutor。
按理说,这东西入门简单,网上教程一抓一大把,参数、原理、源码解析到处都有。但真实线上场景里,绝大多数线程池问题,都不是不会用,而是自以为会用。
前段时间项目迭代上线后,线上频繁出现接口响应超时、服务CPU飙高、监控面板线程池任务堆积告警。排查了整整两天,最后发现全是线程池配置和使用习惯的老问题,没有任何技术难点,全是经验坑。
今天抽空复盘下,把这次线上事故、以及过往几年踩过的所有线程池坑,一次性整理出来,给正在搬砖的小伙伴避个雷。内容都是实战落地总结,不扯虚的理论,不讲教科书废话。
(本文基于Java原生线程池,SpringThreadPoolTaskExecutor同理,适用所有SpringBoot后端项目)
一、先说本次线上故障的核心问题
本次出问题的服务,核心业务是批量处理用户数据,每小时会触发一次大批量的数据统计、入库、推送逻辑。
开发阶段本地测试完全没问题,压测也没测出异常,结果上线跑了三天,凌晨低峰期还好,一到白天业务高峰,直接频繁爆出任务堆积,线程池队列满员,新任务直接被拒绝。
最初排查以为是代码逻辑卡顿、数据库慢查询导致的,抓了日志、看了链路追踪,发现单个任务执行耗时并不高,平均也就几十毫秒。
那问题到底出在哪?
翻看线程池配置代码,瞬间就无语了。
先贴一下当时出错的配置(线上错误代码):
// 错误示例!!线上踩坑代码 ThreadPoolExecutor threadPool = new ThreadPoolExecutor( 10, 50, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100) );
很多人看到这配置,估计一眼就能看出问题,但我当时写代码的时候,纯属随手填的参数,想当然认为:核心线程10,最大线程50,队列100,完全够用了。
真实线上打脸来的飞快。
我之前一直误解了线程池的执行逻辑,以为任务多了就会立刻扩容最大线程数。实际上!线程池的扩容机制,是先塞满队列,再创建非核心线程。
也就是说,上面这个配置,在线程数达到10个核心线程后,后续所有新任务,都会先丢进容量100的队列里。只有队列彻底塞满之后,才会继续创建线程,扩容到50个。
本次业务高峰期,瞬时并发任务量能达到200+。10个核心线程快速处理任务,但是任务进来的速度,远远快于执行结束的速度。队列瞬间打满100,这时候才开始扩容线程。
但扩容也是需要时间的,瞬时并发冲击下,扩容速度跟不上任务请求速度,直接触发拒绝策略,大量任务丢弃、重试,最终导致业务逻辑错乱、接口超时。
更坑的是,我们项目里没有自定义拒绝策略,用的是默认的AbortPolicy,直接抛异常,不做任何兜底。这也是导致故障放大的关键原因。
二、复盘那些年,所有人都容易踩的线程池低级坑
这次故障之后,我翻了下团队过往项目代码,发现几乎半数新人、甚至部分老开发,对线程池的使用都存在误区。很多知识点看似基础,实操起来百分百翻车。
1、盲目使用Executors快捷创建线程池
这是新手最容易犯的错,也是阿里开发手册明确禁止的写法。但还是有很多人为了省事,直接一行代码创建线程池。
比如:
Executors.newFixedThreadPool(10); Executors.newCachedThreadPool();
为什么禁止?
newFixedThreadPool 底层是无界队列,任务量大的时候,无限往队列里塞,直接堆内存溢出OOM。
newCachedThreadPool 最大线程数是Integer.MAX_VALUE,瞬时并发上来,会创建海量线程,直接打满操作系统线程资源,导致服务卡死、宕机。
很多人觉得自己业务量小,不会出问题。但线上业务是动态增长的,今天没流量,不代表下个月不会突发流量。一旦出问题,就是线上重大事故,完全没必要省这几行代码。
2、核心线程数配置凭感觉,不结合业务场景
很多人配置核心线程数,要么写10、要么写20,完全随缘,网上抄的配置直接照搬,根本不区分IO密集型、CPU密集型业务。
这里说下实战里的配置思路,不用记复杂公式,够用、稳定、好维护就行。
CPU密集型任务(数据计算、解析、加密):
核心线程数配置为CPU核心数+1即可。配置多了没用,CPU上下文切换会严重拖慢执行效率,反而降低吞吐量。
IO密集型任务(数据库查询、接口调用、文件读写):
这类任务线程大部分时间在等待IO返回,不占用CPU资源,核心线程数可以适当放大,一般配置CPU核心数*2 ~ 200之间,根据并发量微调。
我之前见过有人把IO密集型业务线程池核心数设为5,结果高峰期大量任务阻塞,服务吞吐量极低,还找不到原因,纯纯是参数配置不懂业务导致的。
3、队列容量设置不合理,要么无界、要么过小
队列是线程池最容易出问题的地方,没有之一。
第一种极端:使用无界队列LinkedBlockingQueue。
看似稳妥,不会触发拒绝策略,不会报错。但代价是,任务堆积全部积压在队列里,内存持续上涨,最终OOM崩服务。而且队列堆积太多任务,排查问题极其困难。
第二种极端:队列容量设置极小,甚至为0。
就像我本次线上故障,队列100的容量,面对瞬时高峰完全扛不住。队列太小,稍微有点并发波动,就直接触发拒绝策略,业务容错率极低。
实战建议:线上一律使用有界队列,根据业务峰值并发,预留20%-30%的冗余容量,不要过大、不要过小。
4、不自定义拒绝策略,线上问题直接爆炸
默认的拒绝策略AbortPolicy,直接抛出RejectedExecutionException,粗暴拒绝任务,没有任何重试、兜底、日志记录。
线上一旦触发,就是业务报错、数据丢失、流程中断。
很多开发自测的时候从来不会压测到极限,所以永远发现不了这个问题,等到线上出事才后悔。
实际项目中,一定要自定义拒绝策略:
1、拒绝任务时,打印详细日志(任务参数、时间、线程池状态),方便后续排查;
2、增加重试机制,非核心任务可以丢弃,核心任务必须兜底重试;
3、可以结合告警,任务频繁拒绝,直接推送钉钉、企业微信告警,提前发现风险。
5、线程池不允许使用默认线程工厂,线程无命名
这是一个小细节,但排查问题的时候真的救命。
默认线程工厂创建的线程,名字全是pool-xxx-thread-xxx。线上服务几十个线程池,出问题打堆栈日志,根本分不清是哪个业务的线程池、哪个线程阻塞。
规范写法是自定义线程工厂,给不同业务的线程池,设置专属线程名,比如user-task-thread、order-push-thread。后续排查堆栈、线程阻塞问题,一眼就能定位业务模块,节省大量时间。
6、任务内部异常不捕获,导致线程静默死亡
这个坑很多老开发都踩过。
线程池执行的任务,如果内部抛出未捕获的异常,会直接导致当前线程终止。线程池会新建线程补足数量,但任务本身执行失败,且没有任何日志提示。
久而久之,就会出现:线程数正常、服务无报错日志,但业务数据一直缺失、部分任务莫名不执行。
所以!所有扔进线程池的任务,必须手动try-catch捕获异常,打印错误日志,绝对不能偷懒。
三、线上稳定可用的线程池最终配置方案
结合本次故障复盘,和多年线上实战经验,贴一套目前我们团队统一使用的、稳定落地的线程池配置,适配绝大多数后端业务,直接复制改参数就能用。
/** * 业务异步线程池 统一配置 * 适配IO密集型业务:数据处理、消息推送、接口异步调用 */ @Bean("businessThreadPool") public ThreadPoolExecutor businessThreadPool() { int corePoolSize = 16; int maxPoolSize = 64; int queueCapacity = 200; long keepAliveTime = 30L; // 自定义线程工厂,指定线程名 ThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat("business-async-thread-%d") .setDaemon(false) .build(); // 自定义拒绝策略:打印日志 + 主线程重试执行 RejectedExecutionHandler handler = (r, executor) -> { log.error("业务线程池任务拒绝!当前队列容量:{},活跃线程数:{}", executor.getQueue().size(), executor.getActiveCount()); // 主线程兜底执行,避免任务丢失 r.run(); }; return new ThreadPoolExecutor( corePoolSize, maxPoolSize, keepAliveTime, TimeUnit.SECONDS, new ArrayBlockingQueue<>(queueCapacity), threadFactory, handler ); }
简单说下这套配置的优势:
1、线程命名清晰,排查问题高效;
2、有界队列,杜绝OOM风险;
3、自定义拒绝策略,日志完整、任务兜底不丢失;
4、空闲线程30秒回收,节省服务资源;
5、参数适配常规IO密集型后端业务,通用性极强。
四、线上线程池日常监控建议
很多项目的线程池,都是黑盒运行,平时不管,出问题乱查。想要服务稳定,必须做监控。
实战里建议监控这几个核心指标:
1、队列堆积数量:持续上涨说明线程处理能力跟不上任务速度,需要调优参数;
2、活跃线程数:长期打满最大线程数,说明参数配置过小;
3、任务拒绝次数:一旦出现拒绝,直接告警,提前规避故障;
4、任务平均执行耗时:耗时突增大概率是下游接口、数据库卡顿导致。
配合Prometheus+Grafana或者SpringBoot监控端点,就能实现线程池可视化监控,不用等故障爆发才发现问题。
五、最后碎碎念几句
写这篇文章不是为了讲线程池源码,也不是为了搬教科书理论。这些基础知识点,随便一搜到处都是。
但真正线上能稳住服务的,从来不是你会不会背原理,而是你能不能避开这些不起眼的小坑。
很多开发都觉得线程池简单,不屑于深究,结果线上出的80%的线程池问题,全都是基础使用不规范导致的。
技术开发从来不是比谁会的多,而是比谁踩的坑少,谁的代码更稳。
希望这篇复盘,能让看到的小伙伴,以后在线上开发中,避开这些低级错误,少熬夜排查bug。
后续遇到新的线程池线上问题,我也会持续更新这篇博客,持续复盘沉淀。