又一起线程池事故——“系统没挂但服务不可用”

9 阅读4分钟

在生产环境中,线程池相关的问题往往有一个共同特点:
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,而是系统如何一步步失控