我用一晚上排查“接口偶发超时”: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)我后来总结的一个简单判断公式

遇到接口偶发超时,我会先问三句话:

  1. 线程池队列是不是无界?
  2. 队列增长时,是否有清晰的“拒绝/背压策略”?
  3. 你能不能在日志或监控里看见 activeCount 与 queueSize?

如果三条都答不上来,基本就能确定:你现在的线程池更像“延迟制造机”,只是还没到爆点。


结尾:把“偶发”变成“可解释”

那次排障让我意识到: “偶发超时”往往不是玄学,而是系统内部某个缓冲区(线程池队列、连接池等待队列、锁等待队列)在悄悄积累。

你不需要立刻把系统改得很复杂,但你必须做到两点:

  • 可观察:关键指标能看到
  • 可控制:满了之后系统怎么退让,是设计出来的,而不是随机发生

当这两点成立,超时就不再是“偶发”,而是你可以解释、可以调参、可以迭代的工程问题。

评论 0