队列满是一个必然后果,不是异常。任何有界队列在流量超过处理能力时都会满,问题只在于"满了以后谁承担代价"。默认答案是拒绝策略,但四种内置策略里没有一种能同时满足"不丢任务、不拖垮 caller、可观测"——因为这三件事本来就互相冲突。
这篇按"扩容顺序 → 四种策略的后果 → CallerRuns 在 Web 容器里到底做了什么 → 自定义策略要补什么 → 背压的替代方案 → 监控怎么设阈值"拆。
1. 扩容顺序:队列满了才扩到 max
ThreadPoolExecutor.execute 的判定顺序是固定的,先看懂它,才谈拒绝:
public void execute(Runnable command) {
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) { // 1. 没到核心数,直接建线程
if (addWorker(command, true)) return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) { // 2. 入队(不管 max 还没到)
int recheck = ctl.get();
if (!isRunning(recheck) && remove(command)) reject(command);
else if (workerCountOf(recheck) == 0) addWorker(null, false);
}
else if (!addWorker(command, false)) // 3. 队列满才尝试扩到 max
reject(command); // 4. 扩不动就拒绝
}
| 阶段 | 条件 | 动作 |
|---|---|---|
| 1 | 线程数 < core | 新建核心线程执行 |
| 2 | 核心已满 | 任务入队,线程数不增长 |
| 3 | 队列满且线程数 < max | 新建非核心线程 |
| 4 | 队列满且已到 max | 触发拒绝策略 |
第 2 步是反直觉的根源:只要队列还能塞,池子就不会扩到 maximumPoolSize。设 core=8, max=64, queue=1000,实际表现是"线程永远 8 个,队列涨到 1000 才开始扩",而不是"流量一大就扩到 64"。很多"参数配了没用"的反馈都出自这里。
想让它更早扩容,只有三条路:把 core 调大、把队列调小、或者换成 SynchronousQueue(不排队,直接交给空闲线程或新建线程)。
2. 四种内置拒绝策略的后果
// JDK 17:AbortPolicy 是默认策略
public static class CallerRunsPolicy implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (!e.isShutdown()) r.run(); // 在调用者线程里直接跑
}
}
| 策略 | 行为 | 调用方感知 | 适合 |
|---|---|---|---|
AbortPolicy |
抛 RejectedExecutionException |
立刻拿到异常 | 有上层兜底、允许快速失败 |
CallerRunsPolicy |
在提交线程执行任务 | 提交变慢 | 能容忍 caller 被占用 |
DiscardPolicy |
静默丢弃 | 完全无感 | 可丢弃的采集/埋点 |
DiscardOldestPolicy |
丢队首最老的,再重试提交 | 老任务消失 | 只要最新值的场景(行情快照) |
四种里最危险的是 DiscardPolicy:它不抛异常、不打日志、不返回任何信号。任务消失了,业务侧只会看到"某些请求没有结果",排查时连线索都没有。DiscardOldestPolicy 稍好一点,但它丢的是最早排队的任务,对一个订单流程来说,丢的可能正是最早、最该被处理的那一个。
| 组合 | 实际效果 |
|---|---|
AbortPolicy + 上层 catch 后重试 |
重试风暴,队列更满 |
CallerRunsPolicy + 无界队列 |
永远不会触发(队列不会满) |
DiscardPolicy + 关键任务 |
静默数据丢失 |
DiscardOldestPolicy + 有状态流程 |
顺序被打乱 |
3. CallerRunsPolicy 在 Web 容器里的真实语义
"让调用者自己跑"听起来很温和,实际是把背压推到了 Tomcat 的请求线程上:
| 步骤 | 发生什么 |
|---|---|
| 请求线程提交任务 | 队列满,进入 CallerRunsPolicy |
| 请求线程执行任务 | 这个线程被业务逻辑占用数百毫秒 |
可用的 maxThreads 减少 |
其他连接等待空闲线程 |
| 连接堆积 | acceptCount 队列涨满,新连接被拒或超时 |
| 客户端看到 | 响应变慢、超时,而不是明确的"忙" |
所以它确实是"自限流"——上游变慢,压力自然回落。代价是整个 Web 层被拖住:一个下游慢接口能让所有请求线程都在替别人干活,最后表现为全站超时,而不是局部降级。
判断能不能用它,看一个条件:被阻塞的调用者是不是可以安全阻塞的。批处理的主线程可以;Tomcat 的工作线程、Netty 的 EventLoop 线程不可以。
4. 自定义策略要补齐的四件事
内置策略只做了"拒绝动作",生产上还需要另外三件:留痕、降级、可观测。
public class RecordingRejectPolicy implements RejectedExecutionHandler {
private final MeterRegistry registry;
private final BlockingQueue<Runnable> retryQueue; // 延迟重试队列
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
registry.counter("executor.rejected", "pool", e.toString()).increment();
if (r instanceof Traceable) { // 1. 留痕
log.warn("rejected taskId={} queueSize={}", ((Traceable) r).id(), e.getQueue().size());
}
if (!retryQueue.offer(r)) { // 2. 降级:重试队列也满了
fallback(r); // 走兜底(落盘/返回默认值)
}
}
}
| 要素 | 做法 | 不做的后果 |
|---|---|---|
| 计数 | 每次拒绝 +1,带池名标签 | 告警没有数据源 |
| 留痕 | 记录任务标识、队列长度 | 事后无法复盘丢了什么 |
| 降级 | 重试队列 / 落盘 / 返回默认值 | 业务直接失败 |
| 限速 | 拒绝风暴时降采样日志 | 日志打满磁盘 |
重试队列本身必须有界,否则只是把无界队列的问题搬了个位置。落盘也要限速:拒绝一旦成规模,每秒几千条写盘同样会拖垮磁盘。
5. 背压:拒绝之外的四种选择
拒绝是"最后一刻的反应",背压是"提前控制进入量"。两者的区别是后者不会走到拒绝那一步。
| 方案 | 机制 | 优点 | 代价 |
|---|---|---|---|
| 信号量 | 提交前 acquire,完成 release |
简单、精确控制并发 | 阻塞提交线程 |
| 入口限流 | 令牌桶/漏桶,超阈值直接 429 | 保护整条链路 | 需要网关或拦截器 |
| 有界队列 + 快速失败 | 队列小、AbortPolicy |
反馈最快 | 需要调用方处理异常 |
| 响应式背压 | request(n) 拉模型 |
端到端不丢 | 改造成本高 |
| TCP 层 | 停止读取 socket | 内核级限流 | 只对特定协议有效 |
// 信号量背压:并发上限 64,超出则等待(或 tryAcquire 失败即降级)
private final Semaphore permits = new Semaphore(64);
public void handle(Request req) throws InterruptedException {
permits.acquire();
try { pool.execute(() -> { try { process(req); } finally { permits.release(); } }); }
catch (RejectedExecutionException e) { permits.release(); throw e; }
}
信号量有个容易写错的地方:release 必须放在任务体内部,且在 finally 里。放在提交处就等于"提交成功即释放",并发上限形同虚设。
6. 监控:三个必看指标与阈值
| 指标 | 取法 | 告警阈值(起点) |
|---|---|---|
| 活跃线程数 | getActiveCount() |
持续 > 80% × max |
| 队列长度 | getQueue().size() |
持续 > 70% × 容量 |
| 拒绝计数 | 自定义 counter | > 0 立即告警 |
| 已完成任务数 | getCompletedTaskCount() |
增速骤降 |
| 最大线程数 | getLargestPoolSize() |
长期等于 max 说明容量不足 |
// Micrometer 一行接入,指标名 executor.*
ExecutorServiceMetrics.monitor(registry, pool, "order-pool");
// 拒绝数需要手动计数:内置策略不会自动上报
阈值只写"80%"是不够的,要加持续时间:瞬时打满很常见,持续 1 分钟以上才值得叫人。拒绝计数则相反——它不是比例指标,出现就该看,因为每一次拒绝都对应一个失败的任务。
监控还有一个盲区:getActiveCount() 是估算值,遍历 worker 得出,高并发下会抖动。用它做趋势可以,用它做精确计费不行。
7. 排错:五种典型误判
| 现象 | 误判 | 实际 |
|---|---|---|
| 线程数一直是 core | 以为 max 没生效 | 队列没满,不会扩容(见第 1 节) |
| 拒绝为 0 但业务有失败 | 以为没触发拒绝 | 可能是 DiscardPolicy,或上游先超时 |
| 队列一直满、CPU 不高 | 以为线程不够 | 任务都在等 IO,扩线程只会更糟 |
| 加了 CallerRuns 后整体变慢 | 以为策略写错 | 它在按设计阻塞请求线程 |
| 重启后指标归零 | 以为监控断了 | 计数器是进程内的,重启即清零 |
8. 边界:线程池之外该解决的问题
| 症状 | 线程池参数能否解决 | 该做什么 |
|---|---|---|
| 下游超时拖住线程 | 不能 | 超时 + 熔断 + 隔离舱 |
| 突发流量打满队列 | 部分 | 入口限流 |
| 任务本身很慢 | 不能 | 拆任务、优化下游 |
| 需要任务优先级 | 不能 | 分池或优先级队列 |
| 需要精确的任务追踪 | 不能 | 包装 Runnable,透传 traceId |
隔离舱(bulkhead)值得一提:把不同下游的调用放进不同线程池,一个慢下游只打满自己的池,不会牵连全局。这比调大 max 有效得多——调大只是把故障范围扩大。
9. 自测
core=8, max=64, queue=1000时,什么条件下线程才会超过 8 个?DiscardPolicy为什么最难排查?CallerRunsPolicy在 Tomcat 里阻塞的到底是哪个线程,后果是什么?- 自定义拒绝策略里,重试队列为什么也必须是有界的?
- 信号量的
release放在提交处会怎样? - 为什么"拒绝数 > 0 就告警",而线程数要用"持续超过 80%"?