上午十点,接口全部超时
5 月 8 号上午十点整,监控告警连着响了:订单服务的接口 P99 从 80ms 涨到 30 秒(超时阈值),成功率掉到 12%。登录机器看,进程还在,CPU 只有 6%,内存正常,但所有请求都在超时。
第一感觉是数据库挂了。查了 MySQL 监控,QPS 只有平时的三分之一,慢查询为零——不是数据库的问题,是请求压根没到数据库。
jstack 抓了一把,看到了这个:
"order-pool-3" #71 prio=5 os_prio=0 tid=0x00007f9c nid=0x7a31 waiting on condition
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)
一堆线程 park 在队列上等任务,说明线程池是空的。那任务去哪了?往前翻 jstack,找到了真正的卡点——有 200 多个线程堵在同一个地方:
"http-nio-8080-exec-187" #238 daemon prio=5 tid=0x00007f9c nid=0x7b12 waiting on condition
java.lang.Thread.State: WAITING (parking)
at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:429)
at java.util.concurrent.FutureTask.get(FutureTask.java:191)
at com.xxx.order.service.OrderQueryService.batchQuery(OrderQueryService.java:88)
全都卡在 FutureTask.get() 上,等一个永远不会被执行的任务。
问题代码
@Service
public class OrderQueryService {
// 用了 Executors 的便捷工厂方法
private static final ExecutorService POOL = Executors.newFixedThreadPool(4);
public List<OrderDetail> batchQuery(List<Long> orderIds) {
List<Future<OrderDetail>> futures = new ArrayList<>();
for (Long id : orderIds) {
futures.add(POOL.submit(() -> orderMapper.selectDetail(id)));
}
List<OrderDetail> result = new ArrayList<>();
for (Future<OrderDetail> f : futures) {
result.add(f.get()); // 第 88 行,卡死在这里
}
return result;
}
}
看起来人畜无害。4 个线程的池子,一个请求提交 N 个任务并发查,然后等结果。平时 orderIds 只有十几条,跑得很正常。
那天上午运营搞了个活动,有个商家一次性查了 3000 个订单。3000 个任务塞进池子,池子只有 4 个线程,一个任务 60ms,全部执行完需要 3000 × 60 / 4 = 45 秒。而 Tomcat 的 200 个工作线程全部堵在 f.get() 上等这 3000 个任务——Tomcat 线程池瞬间被打满,新请求进不来,整个服务不可用。
更要命的是这个请求自己也在无限等待,永远等不到那 4 个线程来服务它。典型的线程池饥饿导致的死锁。
Executors 的三个坑
出事之后我把 Executors 的三个工厂方法翻出来看了一遍源码,发现它们都有隐患。
newFixedThreadPool:队列无界
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
注意 new LinkedBlockingQueue<Runnable>() 没传容量,默认容量是 Integer.MAX_VALUE(约 21 亿)。任务堆积时队列会一直涨,直到 OOM。而且因为队列永远不满,maximumPoolSize 这个参数形同虚设,永远不会创建超出 corePoolSize 的线程。
newCachedThreadPool:最大线程数无界
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
maximumPoolSize 是 Integer.MAX_VALUE,来多少任务建多少线程。我见过一个用 CachedThreadPool 处理 HTTP 回调的服务,下游抖动时一瞬间创建了 6000 多个线程,直接把机器的内存和上下文切换开销拖垮。
newSingleThreadExecutor:同上,队列无界
《阿里巴巴 Java 开发手册》里直接把这三个列为不推荐,要求用 ThreadPoolExecutor 的构造方法手动创建。我以前觉得这规定教条,现在服了。
把 ThreadPoolExecutor 的参数掰开看
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
任务进来的处理流程
这个流程我之前一直理解错,以为"先开到 corePoolSize,满了就开到 maximumPoolSize,再满了进队列"。实际顺序不是这样:
- 当前线程数 < corePoolSize → 直接新建线程执行(哪怕有空闲线程也新建);
- 当前线程数 ≥ corePoolSize → 先尝试入队;
- 队列满了 → 才新建线程,直到达到 maximumPoolSize;
- 线程数已达 maximumPoolSize 且队列满 → 执行拒绝策略。
关键在第 2 步:队列先于最大线程数。这意味着如果你用了无界队列,maximumPoolSize 就永远不会生效;反过来,如果你用 SynchronousQueue(容量为 0 的队列),队列永远"满",那么线程会一直建到 maximumPoolSize——这就是 CachedThreadPool 的行为。
拒绝策略
JDK 8 内置了四种:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy(默认) | 抛 RejectedExecutionException | 需要快速失败,通知调用方 |
| CallerRunsPolicy | 让提交任务的线程自己执行 | 异步转同步,天然限流 |
| DiscardPolicy | 静默丢弃,无任何提示 | 基本不用,丢数据了都不知道 |
| DiscardOldestPolicy | 丢弃队列头那个,重试提交 | 允许丢旧任务的场景 |
我最后给订单查询池配的是 CallerRunsPolicy。它的好处很实在:任务太多时,提交任务的 Tomcat 线程会自己去执行任务,这样一来它就没法继续提交新任务了,形成一个天然的反馈式限流,不会把线程池打死,也不会丢数据。
参数到底怎么设
这个问题我查了不少资料,也问了组里做过压测的同事,结论是先区分任务类型。
CPU 密集型
任务主要在算,比如大量数据的排序、加密解密、复杂报表计算。线程数太多只会带来上下文切换开销。
线程数 ≈ CPU 核数 + 1
那个 +1 是为了应对偶尔的页缺失或暂停,多了反而有害。我们 8 核的机器,一个做对账计算(纯内存运算,无 IO)的池子配了 9 个线程,压测下来比配 16 个快 12% 左右。
int cpuCount = Runtime.getRuntime().availableProcessors();
IO 密集型
任务是查数据库、调 HTTP 接口、读写文件,线程大部分时间在等 IO。这时可以让线程数远大于核数。
线程数 ≈ CPU 核数 × (1 + 等待时间 / 计算时间)
这个公式里的比例可以估算。比如我们查一次订单明细:CPU 计算约 2ms,等 MySQL 返回约 58ms,那线程数 ≈ 8 × (1 + 58/2) = 240。当然这是理论值,实际上还要受数据库连接池大小的限制——池子有 300 个线程,但连接池只有 50 个连接,那多出来的 250 个线程全堵在等连接上,纯属浪费。
所以实际做法是:先定数据库连接池大小,再倒推线程数,让线程数不超过连接池大小(如果这个池子里的任务都要查库的话)。
我改完之后的配置
@Configuration
public class ThreadPoolConfig {
@Bean("orderQueryPool")
public ThreadPoolExecutor orderQueryPool() {
int core = 20;
int max = 60;
return new ThreadPoolExecutor(
core,
max,
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2000), // 有界队列
new ThreadFactoryBuilder()
.setNameFormat("order-query-%d")
.setUncaughtExceptionHandler((t, e) -> log.error("线程异常", e))
.build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
}
几个说明:
- 用 ArrayBlockingQueue(2000) 而不是 LinkedBlockingQueue 无界。 队列满了就触发拒绝策略,而不是无限堆积到 OOM。2000 这个数是按"最多容忍堆积 3 秒的量"估的。
- 给线程起名字。
ThreadFactoryBuilder来自 Guava。这一点太重要了,之前 jstack 出来全是 "pool-3-thread-1" 这种名字,根本不知道是哪个业务。现在看到 "order-query-17" 就知道问题出在哪。 - corePoolSize 和 maximumPoolSize 拉开差距。 20 到 60,平时维持 20 个线程,突发流量时扩到 60 扛一扛。
另外两个我加上的改进:
限制批量查询的大小。 接口层直接挡掉不合理的请求:
if (orderIds.size() > 200) {
throw new BizException("单次查询订单数不能超过 200");
}
给 Future.get() 加超时。 这是防御性编程,避免任何一个下游抖动把线程池拖死:
for (Future<OrderDetail> f : futures) {
try {
result.add(f.get(3, TimeUnit.SECONDS));
} catch (TimeoutException e) {
f.cancel(true);
log.warn("查询订单超时,已取消任务");
}
}
加监控
光配好参数不够,还得能看见。我们加了个定时任务,每 10 秒打一次线程池状态到日志,再由 ELK 收集:
@Scheduled(fixedDelay = 10000)
public void logPoolStatus() {
log.info("pool=orderQuery, active={}, poolSize={}, core={}, max={}, " +
"queue={}/{}, completed={}, rejected={}",
pool.getActiveCount(),
pool.getPoolSize(),
pool.getCorePoolSize(),
pool.getMaximumPoolSize(),
pool.getQueue().size(),
pool.getQueue().size() + pool.getQueue().remainingCapacity(),
pool.getCompletedTaskCount(),
rejectCounter.get());
}
rejected 那个计数是自定义 RejectedExecutionHandler 里累加的,JDK 没提供这个统计。配了告警规则:queue 使用率 > 80% 持续 1 分钟 就发钉钉。
改完上线之后又跑了一次 3000 订单的查询,会被参数校验直接挡掉;200 个订单的查询耗时从优化前的 12 秒(实际上那次是超时失败)降到 340ms,服务整体 P99 回到 85ms。
下篇预告
这篇先把《线程池参数怎么设?一次线程池打满导致的服务不可用》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。