2519 字
约 8 分钟
17
S05-X09 · 线程池队列满了之后:拒绝策略与背压的真实选择

队列满是一个必然后果,不是异常。任何有界队列在流量超过处理能力时都会满,问题只在于"满了以后谁承担代价"。默认答案是拒绝策略,但四种内置策略里没有一种能同时满足"不丢任务、不拖垮 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. 自测

  1. core=8, max=64, queue=1000 时,什么条件下线程才会超过 8 个?
  2. DiscardPolicy 为什么最难排查?
  3. CallerRunsPolicy 在 Tomcat 里阻塞的到底是哪个线程,后果是什么?
  4. 自定义拒绝策略里,重试队列为什么也必须是有界的?
  5. 信号量的 release 放在提交处会怎样?
  6. 为什么"拒绝数 > 0 就告警",而线程数要用"持续超过 80%"?
S05-X09 · 线程池队列满了之后:拒绝策略与背压的真实选择
https://www.clxhxhhr.top/posts/5662/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。