我用一晚上排查“接口偶发超时”:Java 线程池与队列的那点事(含代码)
我用一晚上排查“接口偶发超时”:Java 线程池与队列的那点事(含代码)
那次故障很典型:白天一切正常,晚上某个时间段开始出现偶发超时。日志里看不出明显异常,CPU 不高,内存也没爆,数据库连接池指标“看起来”还行,但用户侧就是会遇到 2–5 秒不等的请求卡顿,偶尔直接超时。
我当时的第一反应是“下游慢了”。但抓了一轮链路数据之后发现:下游并没有明显变慢。真正慢的,是我们服务自己在排队。更准确地说,是线程池里的任务在排队,而且排队不稳定——导致“偶发”更难查。
这篇文章不是讲线程池 API,也不是做功能清单。它更像一份排障记录:我怎么定位问题、怎么验证猜想、最后怎么用一段代码把“偶发超时”变成可控行为。
1)从“现象”开始:偶发超时意味着什么
当 CPU 不满、DB 不慢,但接口偶发超时,常见原因之一是:
- 线程池饱和 → 队列堆积 → 延迟抖动
线程池饱和并不一定会把 CPU 打满。比如你的任务在等 IO、等锁、等连接,线程数多起来之后,更多的是“排队+等待”,而不是“疯狂计算”。所以你看到 CPU 仍然平稳,但响应时间已经开始漂。
我那次就是这种:平均 RT 正常,但 P95/P99 拉长,且在流量波峰后出现“尾巴”。
2)我做的第一个动作:把线程池状态打到日志里
很多人排查线程池问题会直接改参数,但那很像盲猜。我当时先做了一个很简单的“观察器”,每隔 2 秒把线程池关键指标打印一次:
- poolSize / activeCount
- queue size / remainingCapacity
- completedTaskCount
- taskCount
这段代码可以直接放进你的项目里(示例用 ScheduledExecutorService):
import java.util.concurrent.*;
public class ThreadPoolMonitorDemo {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8, // core
16, // max
30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200), // 有界队列,关键
new NamedThreadFactory("biz"),
new ThreadPoolExecutor.CallerRunsPolicy() // 后面会解释
);
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor(
new NamedThreadFactory("monitor")
);
monitor.scheduleAtFixedRate(() -> {
System.out.printf(
"[pool=%d active=%d queued=%d remaining=%d completed=%d task=%d]%n",
executor.getPoolSize(),
executor.getActiveCount(),
executor.getQueue().size(),
executor.getQueue().remainingCapacity(),
executor.getCompletedTaskCount(),
executor.getTaskCount()
);
}, 0, 2, TimeUnit.SECONDS);
// 压测模拟:提交大量“看似轻量但会阻塞”的任务
for (int i = 0; i < 2000; i++) {
int id = i;
executor.execute(() -> fakeWork(id));
}
executor.shutdown();
}
static void fakeWork(int id) {
// 模拟:偶发 IO 抖动/锁等待
try {
if (id % 20 == 0) {
Thread.sleep(200); // 慢任务
} else {
Thread.sleep(10); // 快任务
}
} catch (InterruptedException ignored) {}
}
static class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private int idx = 0;
NamedThreadFactory(String prefix) { this.prefix = prefix; }
@Override public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setName(prefix + "-" + (++idx));
t.setDaemon(true);
return t;
}
}
}
当我把类似的监控加到线上(当然是更温和地采样、并且不会刷爆日志),问题开始变得“可见”:
- activeCount 经常顶到 max
- queue size 在一段时间内持续上涨
- completedTaskCount 增速下降
- 然后请求开始“偶发超时”
这就解释了:并不是下游突然慢,而是我们在“排队”。
3)关键误区:无界队列会把问题隐藏得更深
很多代码直接用:
Executors.newFixedThreadPool(n)
它内部是 LinkedBlockingQueue(无界)。无界队列最大的坑不是“会 OOM”(虽然也可能),而是:
- 线程池不会扩容到 max(因为队列永远能塞)
- 任务只会越排越多
- 延迟会越来越长,但你很难第一时间从错误上看出来
这种情况的“偶发”更可怕:白天队列慢慢累积,晚上某个波峰就爆出超时。
所以我那次的第一个改动,不是改线程数,而是把队列变成有界。
4)我最终采用的策略:有界队列 + CallerRunsPolicy
当队列是有界的,你就必须回答一个问题:
队列满了怎么办?
很多人会用 AbortPolicy(直接抛 RejectedExecutionException),但对 Web 接口来说,这会让错误变得更尖锐。那次故障里,我更希望系统在高压时“自动降速”,而不是直接炸。
于是我选择了 CallerRunsPolicy:
- 队列满时,不丢任务
- 让提交任务的线程自己执行任务
- 等价于给入口施加背压(backpressure)
这在接口层的效果是:当系统忙不过来,入口线程会被迫变慢,从而自然降低吞吐,保护系统稳定。
简化理解:把排队从线程池内部挪到入口处,让延迟更可控。
5)把线程池封装成“可控组件”(避免散落到处)
排查之后我做了一件“工程化”的事:写一个工厂方法,强制团队不要随手 newFixedThreadPool,而是统一创建带有界队列与命名线程的线程池。
import java.util.concurrent.*;
public class PoolFactory {
public static ThreadPoolExecutor newBizPool(String name,
int core,
int max,
int queueSize) {
return new ThreadPoolExecutor(
core,
max,
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueSize),
r -> {
Thread t = new Thread(r);
t.setName(name + "-" + t.getId());
t.setDaemon(true);
return t;
},
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
public static void main(String[] args) {
ThreadPoolExecutor pool = newBizPool("order", 8, 16, 200);
pool.execute(() -> System.out.println("hello"));
pool.shutdown();
}
}
这段代码不高级,但它把“策略”变成了默认行为: 有界队列、命名线程、背压策略,这些不是每个人都愿意手动设置,但应该作为底线存在。
6)如何验证“偶发超时”是否真的被修复
我当时没有用“感觉”来验证,而是做了两类验证:
1)压力下的队列是否可控
- queue size 是否在波峰后回落
- activeCount 是否长期顶住
- completedTaskCount 是否能稳定增长
2)延迟分位数是否稳定
- P50 变化不大是正常的
- 关键是 P95/P99 是否还会突然拉长
- 如果还拉长,说明还有别的问题(锁、连接池、下游抖动)
改完后,最明显的变化是: 队列不再无限增长;在极端压力下,入口会变慢,但不会让延迟拖成“长尾灾难”。
系统开始表现得像一个“可控系统”,而不是“偶尔抽风”。
7)我后来总结的一个简单判断公式
遇到接口偶发超时,我会先问三句话:
- 线程池队列是不是无界?
- 队列增长时,是否有清晰的“拒绝/背压策略”?
- 你能不能在日志或监控里看见 activeCount 与 queueSize?
如果三条都答不上来,基本就能确定:你现在的线程池更像“延迟制造机”,只是还没到爆点。
结尾:把“偶发”变成“可解释”
那次排障让我意识到: “偶发超时”往往不是玄学,而是系统内部某个缓冲区(线程池队列、连接池等待队列、锁等待队列)在悄悄积累。
你不需要立刻把系统改得很复杂,但你必须做到两点:
- 可观察:关键指标能看到
- 可控制:满了之后系统怎么退让,是设计出来的,而不是随机发生
当这两点成立,超时就不再是“偶发”,而是你可以解释、可以调参、可以迭代的工程问题。
评论 0