十月十六号:一个线程池配置引发的连锁故障
那天下午三点,监控系统开始报"下单接口超时率 35%"。我打开看的时候,发现不只是下单——商品详情、购物车、用户信息,几乎所有接口都在超时。网关的活跃连接数从平时的 200 涨到了 7800。
整个服务集群像是被什么东西卡住了。
现象:线程全在 WAITING,但 CPU 很低
先抓线程栈:
$ jstack 12847 > /tmp/stack.txt
$ grep -c 'http-nio-8080-exec' /tmp/stack.txt
198
$ grep 'http-nio-8080-exec' -A 3 /tmp/stack.txt | grep 'java.lang.Thread.State' | sort | uniq -c
186 java.lang.Thread.State: WAITING (parking)
12 java.lang.Thread.State: RUNNABLE
198 个 Tomcat 工作线程(server.tomcat.max-threads 配的 200),186 个在 WAITING。看其中一个在等什么:
"http-nio-8080-exec-87" #231 daemon prio=5 os_prio=0 tid=0x00007f8c4c0d8000 nid=0x5a21 waiting on condition
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000006c2a14c08> (a java.util.concurrent.FutureTask)
at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:429)
at java.util.concurrent.FutureTask.get(FutureTask.java:191)
at com.xxx.service.OrderService.buildDetail(OrderService.java:142)
at com.xxx.controller.OrderController.detail(OrderController.java:56)
FutureTask.get() 阻塞。对应的代码长这样:
@Service
public class OrderService {
// 罪魁祸首
private final ExecutorService executor = Executors.newFixedThreadPool(20);
public OrderDetailVO buildDetail(String orderNo) {
Future<OrderPO> orderFuture = executor.submit(() -> orderMapper.selectByNo(orderNo));
Future<List<OrderItemPO>> itemsFuture = executor.submit(() -> itemMapper.listByOrderNo(orderNo));
Future<UserVO> userFuture = executor.submit(() -> userClient.getUser(orderNo));
Future<AddressVO> addrFuture = executor.submit(() -> addressClient.getAddress(orderNo));
OrderDetailVO vo = new OrderDetailVO();
try {
vo.setOrder(orderFuture.get()); // 逐个 get,阻塞等待
vo.setItems(itemsFuture.get());
vo.setUser(userFuture.get());
vo.setAddress(addrFuture.get());
} catch (Exception e) {
throw new BizException("查询订单详情失败", e);
}
return vo;
}
}
问题一眼看上去不明显,但有个致命配置:Executors.newFixedThreadPool(20) 用的是无界队列 LinkedBlockingQueue。
根因一:无界队列让线程池失去了拒绝能力
看 JDK 8 的源码,newFixedThreadPool 的实现:
// java.util.concurrent.Executors
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
LinkedBlockingQueue 无参构造的容量是 Integer.MAX_VALUE(21 亿)。这带来的后果:
maximumPoolSize参数完全失效。因为队列永远填不满,永远不会触发"创建超出 corePoolSize 的线程"这个逻辑,线程数永远是 20。- 拒绝策略永远不触发。
RejectedExecutionHandler只有在队列满且线程数达上限时才生效,队列无界意味着它永远不会执行。 - 任务无限堆积。上游来得比处理得快,队列就一直涨,每个
Runnable对象占内存,直到 OOM。
故障当时的队列长度(用 Arthas 看的):
$ ognl '#sp=@com.xxx.service.OrderService@executor, #sp.getQueue().size()'
@Integer[48213]
队列里堆了 48213 个任务。20 个线程在跑,每个任务平均 8ms,消化完这 4.8 万个要 19 秒。而每个进来的请求都要 submit 4 个任务并等它们全部完成——排在队尾的请求要等 19 秒才能轮到。
更要命的是请求不会超时退出。Future.get() 没有设超时,它会一直等到任务跑完或者被中断。Tomcat 的 200 个线程全部耗在 get() 上,新请求进来直接被拒绝连接。
根因二:上游拖垮下游,雪崩传遍整个链路
但队列为什么会堆到 4.8 万?线程池只有 20 个线程,平时 QPS 800 完全够用。触发点是用户服务变慢。
从监控上看,15:02 开始 userClient.getUser() 的耗时从 8ms 涨到 3.2 秒:
| 时间 | userClient P99 | 队列长度 | 下单 TP99 | Tomcat 活跃线程 |
|---|---|---|---|---|
| 15:00 | 8ms | 12 | 95ms | 34 |
| 15:02 | 3200ms | 1840 | 4100ms | 198 |
| 15:05 | 3400ms | 23400 | 超时 | 198(满) |
| 15:08 | 3300ms | 48213 | 超时 | 198(满) |
用户服务变慢的原因是它的 MySQL 有个大查询(运营跑了个全表统计)。而这个慢查询通过调用链一路传导:
- 用户服务慢 →
userClient.getUser()从 8ms 变 3.2 秒。 - 线程池只有 20 个线程,每个请求要占一个线程 3.2 秒 → 线程池的处理能力从 2500 QPS 掉到 6 QPS。
- 请求进不来就堆队列,队列无界,一直堆到 4.8 万。
- Tomcat 线程全部阻塞在
Future.get(),200 个全占满。 - Tomcat 没有可用线程 → 所有接口(包括不依赖用户服务的商品详情)全部超时。
- 网关连接数暴涨,重试又放大了流量。
第 5 步是这次事故最典型的教训:订单详情这个接口的线程池配置问题,最终让整个服务的全部接口不可用。因为 Tomcat 的线程池是所有接口共享的,一个接口把线程占光,其他接口陪葬。
解决方案一:线程池参数必须显式指定
先说结论:生产代码里禁止用 Executors 创建线程池。这是《阿里巴巴 Java 开发手册》里的强制条款,我以前觉得是教条,这次是真的被教育了。
改成显式构造 ThreadPoolExecutor:
@Configuration
public class ThreadPoolConfig {
@Bean("orderQueryPool")
public ThreadPoolExecutor orderQueryPool() {
// 8 核容器,这个池是 IO 密集型(查库 + 调远程)
int core = Runtime.getRuntime().availableProcessors() * 2; // 16
return new ThreadPoolExecutor(
core,
core * 2, // 32
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2000), // 有界队列,2000
new ThreadFactoryBuilder()
.setNameFormat("order-query-%d")
.setUncaughtExceptionHandler((t, e) -> log.error("线程异常", e))
.build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
}
}
四个参数怎么定,我现在的判断标准:
- corePoolSize:IO 密集型(要调数据库、调远程接口)设
CPU核数 × 2;CPU 密集型(纯计算)设CPU核数 + 1。这个池要查库调接口,8 核设 16。 - maximumPoolSize:给 core 的 1.5~2 倍作为弹性空间。注意只有在队列满了之后才会扩容到这个值。
- 队列容量:这是关键。有界队列的长度决定了"能容忍多长时间的突发"。我们的算法是
核心线程数 × 单任务耗时 × 可容忍的排队秒数。16 线程 × 8ms = 每秒能处理 2000 个,队列设 2000 意味着最多容忍 1 秒的排队。超过就拒绝。 - 拒绝策略:四种里面我选
CallerRunsPolicy。
| 策略 | 行为 | 适用 |
|---|---|---|
| AbortPolicy(默认) | 抛 RejectedExecutionException | 要快速失败并告警 |
| CallerRunsPolicy | 让提交任务的线程自己执行 | 需要反压,推荐 |
| DiscardPolicy | 静默丢弃 | 几乎不用 |
| DiscardOldestPolicy | 丢弃队首任务 | 允许丢旧的,比如日志 |
CallerRunsPolicy 的妙处在于反压:线程池满了之后,让 Tomcat 的工作线程自己去跑这个任务。Tomcat 线程被占用 → 它无法接收新请求 → 上游流量自然降下来。这比"默默堆队列直到 OOM"健康得多。缺点是如果提交方是主线程或者单线程,会阻塞流程,要评估。
解决方案二:Future.get() 必须设超时
原来的 get() 无参调用会无限等待。改成带超时的版本:
public OrderDetailVO buildDetail(String orderNo) {
Future<OrderPO> orderFuture = pool.submit(() -> orderMapper.selectByNo(orderNo));
Future<List<OrderItemPO>> itemsFuture = pool.submit(() -> itemMapper.listByOrderNo(orderNo));
Future<UserVO> userFuture = pool.submit(() -> userClient.getUser(orderNo));
Future<AddressVO> addrFuture = pool.submit(() -> addressClient.getAddress(orderNo));
OrderDetailVO vo = new OrderDetailVO();
try {
// 每个 get 单独设超时,总耗时可控在 1 秒内
vo.setOrder(orderFuture.get(500, TimeUnit.MILLISECONDS));
vo.setItems(itemsFuture.get(500, TimeUnit.MILLISECONDS));
vo.setUser(getOrDefault(userFuture)); // 非核心信息,超时用降级值
vo.setAddress(getOrDefault(addrFuture));
} catch (TimeoutException e) {
orderFuture.cancel(true);
itemsFuture.cancel(true);
throw new BizException("订单详情查询超时", e);
} catch (Exception e) {
throw new BizException("订单详情查询失败", e);
}
return vo;
}
/** 非核心信息:拿不到就降级,不能拖累主流程 */
private <T> T getOrDefault(Future<T> future) {
try {
return future.get(300, TimeUnit.MILLISECONDS);
} catch (Exception e) {
future.cancel(true);
log.warn("非核心信息获取失败,降级处理");
return null;
}
}
这里做的一个判断是区分核心和非核心依赖:订单主信息和商品明细是核心,拿不到就得报错;用户昵称、收货地址是非核心,拿不到就展示个"暂无",页面不至于整个挂掉。
解决方案三:隔离,别让一个接口拖垮全部
即使线程池参数对了,Tomcat 线程共享的问题依然存在。我们的做法有两层。
第一层:不同重要度的接口用不同的业务线程池
核心思路是舱壁隔离——每个依赖或者每组接口一个独立的池,互不影响。
@Configuration
public class ThreadPoolConfig {
/** 订单查询:高频,可降级 */
@Bean("orderQueryPool")
public ThreadPoolExecutor orderQueryPool() { ... 队列 2000 ... }
/** 订单创建:低频,不可降级,独立池 */
@Bean("orderCreatePool")
public ThreadPoolExecutor orderCreatePool() { ... 队列 200 ... }
/** 调外部服务:慢,独立池,不占用内部资源 */
@Bean("externalCallPool")
public ThreadPoolExecutor externalCallPool() { ... 队列 500 ... }
}
这样即使"查订单详情"的池被打满,"创建订单"依然有自己独立的 8 个线程可用。代价是线程总数变多(上下文切换开销),但相比整个服务挂掉,这笔账划算。
第二层:接入 Sentinel 做熔断降级
线程池隔离只能限制"自己别被拖死",但没法阻止上游持续打流量过来。Sentinel 1.8 的熔断规则补上这一环:
@PostConstruct
public void initRules() {
List<DegradeRule> rules = new ArrayList<>();
DegradeRule rule = new DegradeRule("userClient.getUser")
.setGrade(RuleConstant.DEGRADE_GRADE_RT) // 按响应时间熔断
.setCount(200) // 阈值 200ms
.setTimeWindow(10) // 熔断 10 秒
.setRtSlowRequestAmount(5) // 连续 5 个慢请求
.setMinRequestAmount(20); // 最少 20 个请求才统计
rules.add(rule);
DegradeRuleManager.loadRules(rules);
}
配好之后的效果:用户服务 P99 涨到 3.2 秒 → 5 个慢请求触发熔断 → 接下来 10 秒内所有对 userClient.getUser 的调用直接返回降级值,根本不发请求 → 线程池不被拖住 → 服务存活。
同时给核心接口加了限流:
FlowRule flowRule = new FlowRule("GET:/api/order/detail")
.setCount(2000) // QPS 上限
.setGrade(RuleConstant.FLOW_GRADE_QPS)
.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_WARM_UP)
.setWarmUpPeriodSec(10); // 预热 10 秒
FlowRuleManager.loadRules(Collections.singletonList(flowRule));
改造后的压测验证
用故障注入复现当时的场景:把用户服务的响应人为延迟到 3 秒,跑 10 分钟。
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 订单详情 TP99 | 超时(>30s) | 3200ms → 熔断后 45ms |
| 商品详情 TP99(无关接口) | 超时 | 52ms |
| 创建订单成功率 | 0% | 99.8% |
| 队列最大长度 | 48213 | 2000(触发拒绝) |
| Tomcat 线程占用 | 198/200 | 68/200 |
| 堆内存 | 5.8GB(接近 OOM) | 2.1GB |
最关键的一行是"商品详情 TP99"——无关接口不再受牵连。这就是隔离的意义。
顺带把其他几个 Executors 也清了
事故之后我全项目搜了一遍:
$ grep -rn "Executors\." --include=*.java src/main/ | grep -v "newScheduledThreadPool"
OrderService.java:47: private final ExecutorService executor = Executors.newFixedThreadPool(20);
ExportService.java:33: private final ExecutorService executor = Executors.newCachedThreadPool();
NotifyService.java:28: private final ExecutorService executor = Executors.newSingleThreadExecutor();
三个都有问题:
newFixedThreadPool:无界队列,就是这次的主角。newCachedThreadPool:maximumPoolSize 是 Integer.MAX_VALUE,高并发下会疯狂创建线程。我们测试环境就因为这个创建过 3000 多个线程,直接 OOM(每个线程默认 1MB 栈)。newSingleThreadExecutor:单线程 + 无界队列,一旦某个任务卡住,后面全堵死。
全部替换成显式构造,并且接入 Micrometer 做监控:
@Bean
public MeterBinder threadPoolMetrics(@Qualifier("orderQueryPool") ThreadPoolExecutor pool) {
return registry -> {
Gauge.builder("thread.pool.active", pool, ThreadPoolExecutor::getActiveCount)
.tag("name", "orderQuery").register(registry);
Gauge.builder("thread.pool.queue", pool, e -> e.getQueue().size())
.tag("name", "orderQuery").register(registry);
Gauge.builder("thread.pool.reject", pool, ThreadPoolExecutor::getRejectedExecutionHandler == null ? 0 : ...)
.tag("name", "orderQuery").register(registry);
};
}
队列长度超过 50% 就告警。这样下次再有堆积,能在队列打满之前发现,而不是等接口全挂。
写在后面
现在回头看,《一次线程池参数设置不当导致的雪崩》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。