在生产环境中,线程池相关的问题往往有一个共同特点:
CPU 不高、内存正常、没有明显异常日志,但系统就是“卡死了”。
这类问题排查成本高、定位慢,很多时候直到请求完全堆死,问题才被真正发现。
一、事故背景
系统背景:
- Spring Boot 微服务
- Tomcat + 自定义业务线程池
- 接口为同步 HTTP 请求
- 下游存在 RPC / 数据库调用
事故现象:
- 接口大量超时(RT 飙升)
- 线程池监控显示 未达到最大线程数
- CPU 使用率低于 40%
- 服务未宕机,但几乎不可用
这类问题最容易被误判为:
“是不是下游慢了?”
“是不是数据库压力大?”
“要不要先扩容?”
二、第一层排查:线程池真的“没满”吗?
业务线程池配置如下(简化):
new ThreadPoolExecutor(
10, // corePoolSize
20, // maxPoolSize
1000, // queueCapacity
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>()
);
监控显示:
- activeCount ≈ 10
- poolSize ≈ 10
- queueSize ≈ 持续增长
从表面看:
- 线程池没有扩容
- 没有触发拒绝策略
- 看起来“一切正常”
但实际上,问题已经开始了。
三、问题根因一:队列过大,线程池“失去了扩容能力”
ThreadPoolExecutor 的核心行为规则:
先用核心线程 → 再入队 → 队列满了才扩容 → 最后拒绝
在上面的配置中:
- 核心线程:10
- 队列容量:1000(远大于核心线程数)
结果是:
- 所有请求优先进入队列
- 队列长期不满
- 最大线程数(20)永远不会生效
📌 线程池被“队列劫持”了
四、问题根因二:请求模型是“同步阻塞型”
业务逻辑中存在:
- RPC 调用
- 数据库查询
- 外部服务访问
典型执行路径:
HTTP 请求
↓
业务线程池
↓
同步调用下游服务(阻塞)
↓
等待返回
这意味着:
- 一个线程 = 一个请求
- 下游慢 → 线程被长期占用
- 新请求只能排队
当下游 RT 从 50ms 变成 500ms 时:
线程池吞吐能力直接下降 10 倍
五、问题根因三:拒绝策略“形同虚设”
由于队列容量过大:
- 队列长期未满
- 拒绝策略从未触发
- 系统没有任何“背压”信号
结果是:
- 上游继续疯狂发请求
- 本服务默默排队
- RT 越来越高
📌 这是最危险的状态:系统在“假装自己还能扛”
六、事故升级:Tomcat 线程被反向拖死
更严重的问题在于:
- Tomcat 接收请求
- 请求提交到业务线程池
- Tomcat 线程同步等待结果
当业务线程池排队时间过长:
- Tomcat 线程被大量阻塞
- 新连接无法被处理
- 整个服务对外“失联”
👉 最终表现为:
端口还在,但服务已不可用
七、为什么这种问题极难第一时间发现?
因为它具备几个“迷惑性特征”:
- ❌ CPU 不高
- ❌ 内存不满
- ❌ 没有 OOM
- ❌ 没有明显异常日志
- ❌ 线程池监控看起来“正常”
但实际上:
吞吐能力已经被队列慢慢吞噬
八、正确的线程池配置思路
1️⃣ 控制队列容量(比线程数更重要)
经验原则:
队列容量 ≈ 核心线程数 * 2 ~ 4
例如:
new ThreadPoolExecutor(
20,
40,
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(80),
new ThreadPoolExecutor.CallerRunsPolicy()
);
2️⃣ 让拒绝策略“早点生效”
推荐策略:
CallerRunsPolicy(反压上游)- 自定义拒绝策略 + 日志 + 监控
目的只有一个:
宁可失败,也不要无限堆积
3️⃣ 同步接口慎用大队列
如果业务是:
- 同步 HTTP
- 强依赖下游
那么:
大队列 = 放大 RT = 放大故障
九、事故总结
这次事故的本质不是线程数不够,而是:
- 队列设计不合理
- 对 ThreadPoolExecutor 行为模型理解不足
- 缺乏“系统背压”意识
最终导致:
系统没有崩,但已经失去服务能力
十、写在最后
线程池不是“调大就能解决问题”的工具,而是系统吞吐能力的边界控制器。
当线程池开始悄悄排队时,
事故往往已经在路上了。
「代码不背锅」系列 · 生产事故复盘
关注的不是 API,而是系统如何一步步失控