承接的问题
构造场景:libraryd-conc 的借阅入口在活动开始后被流量冲垮。入口线程池配的是 Executors.newFixedThreadPool(4)——底层是一个无界的 LinkedBlockingQueue。活动第一分钟 QPS 从 300 涨到 2000,线程池没有拒绝任何请求,队列一直涨,P99 从 30 ms 涨到 38 秒,最后 OOM。值班改成 new ThreadPoolExecutor(4, 4, ..., new ArrayBlockingQueue<>(64)) 并保留默认的 AbortPolicy,拒绝率立刻变成 37%。上游 SDK 看到异常就重试,重试把到达率又往上推了一截。
两次处置都只做了一半:第一次把过载变成了延迟,第二次把过载变成了异常风暴。这一节要回答三个问题:队列该多长、拒绝策略选哪个、拒绝之后调用方拿到的应该是什么。
有界队列把过载变成可观测的事件
无界队列的问题不在于「内存会爆」,而在于它让系统失去了一个信号。回看 5.1 的 L = λW:当到达率长期高于处理能力时,L 会一直增长,W 随之增长,而 λ 锁死在容量上。队列就是那个让 L 无限增长的容器——它不是缓冲,它是延迟的蓄水池。
有界队列的作用是给 L 设一个上限,从而给 W 设一个上限:
队列长度 ≈ 可接受排队时间 × 服务速率
用 5.3 场景的数字算:4 个线程、单任务 6.1 ms,服务速率约 656/s。如果 SLO 允许排队 100 ms,队列长度应该约为 656 × 0.1 = 66,取 64。这个数不是「越大越安全」:64 个槽位意味着最坏情况下第 64 个请求要等 64/656≈98 ms,恰好用完预算。
| 队列长度 | 可吸收的突发 | 最坏排队时间 | 过载时的表现 |
|---|---|---|---|
0(SynchronousQueue) | 无 | 0 | 立即拒绝,拒绝率最高 |
| 16 | 16 个请求 | ≈24 ms | 拒绝得早,尾延迟可控 |
| 64 | 64 个请求 | ≈98 ms | 常规选择 |
| 256 | 256 个请求 | ≈390 ms | 尾延迟逼近秒级,SLO 已破 |
| 无界 | 无限 | 无限 | 拒绝率为 0 的假象,直到 OOM |
表里的排队时间是按「服务速率 656/s」推导的理论值,不是实测;下一节的压测会看到实测值比它略高,因为真实容量比名义值低。
三种拒绝策略
ThreadPoolExecutor 提供四种,本书只推荐前三种,第四种单独说:
| 策略 | 队列满时的行为 | 调用方感受 | 适合场景 |
|---|---|---|---|
AbortPolicy(默认) | 抛 RejectedExecutionException | 立刻拿到异常,可自行降级 | 入口层,配合降级 |
CallerRunsPolicy | 在提交线程里直接跑这个任务 | 提交线程被拖住,直到任务跑完 | 后台批处理,不能丢也不能拒 |
DiscardPolicy | 静默丢弃 | 什么都不发生,永远不知道 | 可丢失的埋点、心跳 |
DiscardOldestPolicy | 丢掉队列头部最老的,再重试提交 | 老请求被无声牺牲 | 只关心最新值的场景(如行情) |
CallerRunsPolicy 经常被当成「优雅的背压」,但它有一个非常具体的代价:执行 execute() 的那个线程会被占用整整一个服务时间。如果那个线程是 Tomcat 的工作线程、是 Netty 的 event loop、是压测的发生器,那么背压会沿着调用链一路传导,直到把上游也拖慢。
同一次过载(到达 2000/s、池 8 线程、队列 64、任务 6 ms),三种策略的实测对照:
Abort rejected=3738 rejectRate=37.4% done=6262 p50=56.6ms p99=65.0ms 提交线程阻塞=0.10s
CallerRuns rejected=0 rejectRate= 0.0% done=10000 p50=56.4ms p99=70.3ms 提交线程阻塞=7.31s
Discard rejected=0 rejectRate= 0.0% done=6453 p50=54.7ms p99=63.1ms 提交线程阻塞=0.01s
这张表里最关键的一列不是拒绝率,是最后一列:
Abort:提交线程 5 秒里只花了 0.10 s 在execute()上,其余时间在按节奏投递。拒绝是即时的,调用方可以马上走降级分支。CallerRuns:提交线程有 7.31 s 花在execute()里——而整个实验窗口只有 5 s。压测发生器自己被拖成了消费者,实际到达率从 2000/s 掉到 1353/s,实验窗口被拉长到 7.39 s。这是背压生效的样子:不是系统变快了,是上游被按住了。Discard:拒绝率显示 0,但done=6453而提交了 10000 次——3547 个请求被静默丢弃,占 35.5%。如果没有「提交数 - 完成数」这个对账,这次丢弃在监控上完全不可见。
DiscardPolicy 的危险不在丢,而在「丢得没有痕迹」。要用它,就必须自己维护提交计数,否则线上会表现为「QPS 正常、成功率正常、业务量对不上」。
拒绝率曲线:同到达率、不同队列与线程数
实验仍然是开环的:一个定速线程按 2000 请求/秒投递,跑 5 秒,共 10000 次。任务体是 parkNanos(5ms),实测平均服务时间约 6.1 ms。池大小取 4 与 16,队列长度取 0/16/64/256,策略统一 AbortPolicy。
import java.util.Arrays;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.concurrent.locks.LockSupport;
/** 定速投递,统计拒绝率与被接受请求的分位数。 */
public class RejectCurveProbe {
static final long SERVICE_NANOS = 5_000_000L;
static final long RUN_NANOS = 5_000_000_000L;
static final int ARRIVAL_PER_SEC = 2000;
public static void main(String[] args) throws Exception {
for (int poolSize : new int[]{4, 16}) {
for (int queueSize : new int[]{0, 16, 64, 256}) {
run(poolSize, queueSize);
}
}
}
static void run(int poolSize, int queueSize) throws Exception {
BlockingQueue<Runnable> queue = queueSize == 0
? new SynchronousQueue<>()
: new ArrayBlockingQueue<>(queueSize);
ThreadPoolExecutor pool = new ThreadPoolExecutor(
poolSize, poolSize, 0, TimeUnit.MILLISECONDS, queue,
new ThreadPoolExecutor.AbortPolicy());
LongAdder accepted = new LongAdder();
LongAdder rejected = new LongAdder();
long[] latency = new long[1 << 19];
AtomicInteger idx = new AtomicInteger();
long start = System.nanoTime();
long interval = 1_000_000_000L / ARRIVAL_PER_SEC;
for (long k = 0; start + k * interval < start + RUN_NANOS; k++) {
long target = start + k * interval;
if (target - System.nanoTime() > 200_000L) {
LockSupport.parkNanos(target - System.nanoTime() - 100_000L);
}
while (System.nanoTime() < target) {
Thread.onSpinWait();
}
long submitAt = System.nanoTime();
try {
pool.execute(() -> {
LockSupport.parkNanos(SERVICE_NANOS);
int i = idx.getAndIncrement();
if (i < latency.length) {
latency[i] = System.nanoTime() - submitAt;
}
});
accepted.increment();
} catch (RejectedExecutionException e) {
rejected.increment();
}
}
// 排空剩余任务,最多等 3 秒
long deadline = System.nanoTime() + 3_000_000_000L;
while ((queue.size() > 0 || pool.getActiveCount() > 0)
&& System.nanoTime() < deadline) {
Thread.sleep(20);
}
pool.shutdownNow();
long sub = accepted.sum() + rejected.sum();
long[] s = Arrays.copyOf(latency, Math.min(idx.get(), latency.length));
Arrays.sort(s);
System.out.printf("pool=%2d queue=%3d rejection=%5.1f%% p50=%6.1fms p99=%7.1fms%n",
poolSize, queueSize, rejected.sum() * 100.0 / sub,
pct(s, 0.50), pct(s, 0.99));
}
static double pct(long[] sorted, double p) {
if (sorted.length == 0) {
return -1;
}
return sorted[(int) Math.min(sorted.length - 1L,
Math.round(p * (sorted.length - 1)))] / 1e6;
}
}
两次独立运行的实测(Apple M1 Pro 10 核,Temurin 25.0.5+8,load average 31–39,单次运行、随调度波动):
pool= 4 queue= 0 rejection= 71.1% p50= 6.1ms p99= 8.0ms
pool= 4 queue= 16 rejection= 68.4% p50= 30.8ms p99= 38.3ms
pool= 4 queue= 64 rejection= 68.3% p50=108.1ms p99= 125.0ms
pool= 4 queue=256 rejection= 67.3% p50=428.0ms p99= 457.4ms
pool=16 queue= 0 rejection= 2.7% p50= 6.1ms p99= 9.6ms
pool=16 queue= 16 rejection= 0.5% p50= 6.1ms p99= 11.8ms
pool=16 queue= 64 rejection= 0.0% p50= 6.1ms p99= 10.0ms
pool=16 queue=256 rejection= 0.0% p50= 6.3ms p99= 14.0ms
--- 第二次运行(拒绝率 / p99)---
pool= 4: 71.4%/8.5ms 68.9%/37.5ms 68.3%/121.0ms 66.8%/448.1ms
pool=16: 3.5%/10.9ms 0.2%/8.6ms 0.0%/15.2ms 0.0%/14.7ms
这张表里有两条结论,一条符合直觉,一条非常反直觉。
符合直觉的那条:P99 随队列长度线性上涨。 队列 16 → 64 → 256,P99 从 38 ms → 125 ms → 457 ms,几乎是四倍四倍地涨,因为队列变长 4 倍,排队时间也变 4 倍。小池(4 线程)时这个关系最清楚,因为系统一直处在饱和状态。
反直觉的那条:拒绝率几乎不随队列长度变化。 池 4 的四行是 71.1% / 68.4% / 68.3% / 67.3%,池 16 的四行是 2.7% / 0.5% / 0% / 0%。加大队列没有降低拒绝率,只推迟了第一次拒绝。
这个现象可以直接推导出来。系统长期过载时,队列迟早会满,满了之后每个新请求都会被拒绝;拒绝率收敛到:
拒绝率 = 1 - 容量 / 到达率
池 4 的容量是 4 / 0.0061 ≈ 656/s,到达 2000/s,理论拒绝率 1 - 656/2000 = 67.2%,实测 66.8%–71.4%。四个队列长度的差别(67.3% 到 71.4%)全部来自「队列填满需要多久」:256 个槽位能多吸收 256 个请求,摊到 10000 次投递上就是 2.6 个百分点。
池 16 的容量是 16 / 0.0061 ≈ 2623/s,高于到达率 2000/s,所以理论拒绝率为 0;实测在队列 0 和 16 时出现的 2.7% 与 0.5% 是瞬态:实验刚开始时线程还在创建、JIT 还在编译,前几百毫秒的处理能力低于稳态,同步队列没有缓冲就把这几个请求拒了。队列 64 之后这点瞬态也被吸收掉,拒绝率归零。
所以队列长度的真正作用是:
- 决定尾延迟(饱和时
P99 ≈ 队列长度 / 容量); - 决定突发吸收能力(能挡住多长时间的瞬时过载);
- 不决定稳态拒绝率(那是容量与到达率的比值)。
指望「把队列从 64 加到 1024 就能少拒绝」是一种常见误解:它只会把拒绝推迟 0.9 秒,同时把 P99 从 100 ms 抬到 1.5 秒。
降级策略
拒绝是把问题暴露给调用方,降级是决定暴露之后给什么。三类做法,按实现成本从低到高:
一、快速失败。 抛出一个区分得开的错误(ErrorCode.OUT_OF_STOCK 这种业务码,而不是 RuntimeException("系统繁忙")),并带上重试提示。关键是抑制重试放大:入口拒绝率达到阈值时,返回 Retry-After 或者直接告诉上游「本分钟别再试」,否则重试会把 2000/s 变成 4000/s。本节实验里那个「拒绝 37% 之后上游重试」的场景,就是缺这一步。
二、降级返回值。 借阅查询是读多写少、可以容忍短暂陈旧的场景:查书目详情失败时返回缓存里的上一次结果,或者返回一个只含标题与库存的极简对象。写路径(借出、归还)不能这么干,它们是必须成功的操作,只能快速失败并让客户端自己决定。
三、只保核心流量。 按业务重要性分池:借出/归还走一个独立的小池(比如 16 线程、队列 32),查询走另一个池。过载时查询池先拒绝,核心写路径不受影响。这比在一个大池里做优先级排队简单得多,也不会出现「低优先级任务已经进了队列、高优先级任务反而被拒」的倒挂。
| 做法 | 适用 | 不适用 | 需要的埋点 |
|---|---|---|---|
| 快速失败 + 抑制重试 | 所有入口 | 无 | 拒绝计数、拒绝率、重试预算 |
| 降级返回缓存 | 查询类、幂等读 | 写操作、强一致读 | 降级次数、降级命中率、缓存年龄 |
| 分池保核心 | 读写混合 | 只有单一业务 | 每池的队列深度与拒绝数 |
在 libraryd-conc 里落地
把「有界队列 + 快速失败 + 降级返回 + 计数」四件事收进一个入口类,拒绝不再是一个没人看的异常:
package com.plume.conc.ops;
import java.util.concurrent.*;
import java.util.concurrent.atomic.LongAdder;
import java.util.function.Supplier;
/** 借阅查询入口:有界队列、快速失败、降级返回值、拒绝与降级可观测。 */
public final class BorrowGateway {
private final ThreadPoolExecutor pool;
private final long timeoutMillis;
private final LongAdder rejected = new LongAdder();
private final LongAdder degraded = new LongAdder();
public BorrowGateway(int threads, int queueCapacity, long timeoutMillis) {
this.pool = new ThreadPoolExecutor(
threads, threads, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueCapacity),
r -> new Thread(r, "borrow-gw"),
new ThreadPoolExecutor.AbortPolicy());
this.timeoutMillis = timeoutMillis;
}
/** fallback 只在拒绝、超时、异常时调用,必须自己保证不抛异常。 */
public String query(String isbn, Supplier<String> fallback) {
Future<String> future;
try {
future = pool.submit(() -> load(isbn));
} catch (RejectedExecutionException e) {
rejected.increment();
degraded.increment();
return fallback.get();
}
try {
return future.get(timeoutMillis, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true); // 中断下游调用,避免线程继续被占用
degraded.increment();
return fallback.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
future.cancel(true);
return fallback.get();
} catch (ExecutionException e) {
degraded.increment();
return fallback.get();
}
}
public long rejectedCount() {
return rejected.sum();
}
public long degradedCount() {
return degraded.sum();
}
public int queued() {
return pool.getQueue().size();
}
private String load(String isbn) {
return "book:" + isbn;
}
}
三个设计点值得展开:
AbortPolicy而不是CallerRunsPolicy。入口层面对的是 Web 容器线程,CallerRuns会把容器线程拖住一个服务时间,等于把过载传导给整个容器。前面实测的 7.31 s 就是这么来的。- 超时后
cancel(true)。只get超时而不取消,任务会继续跑完并占着线程,队列的「容量」被悄悄侵蚀。cancel(true)会中断线程,但如果下游调用不响应中断,仍然无效——这是 11.1 会讲的连接超时与中断的边界。 - 拒绝与降级分成两个计数器。两者含义不同:拒绝是「没进去」,降级是「进去了但结果不完整」。合成一个指标,就分不清是该扩容还是该修下游。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 用无界队列「避免拒绝」 | 拒绝率 0 的假象,直到 OOM | 有界队列,长度按 SLO 排队预算算 |
| 加大队列以减少拒绝 | 拒绝率不变,P99 涨数倍 | 加容量(线程/下游),不是加队列 |
入口层用 CallerRunsPolicy | 容器线程被拖住,过载传导 | 快速失败 + 降级 |
用 DiscardPolicy 但不对账 | 静默丢单,监控全绿 | 提交数 - 完成数必须可查 |
| 拒绝后不做重试抑制 | 拒绝 37% 引来 100% 的额外流量 | 返回错误码 + 退避提示 |
| 只报拒绝率的绝对值 | 不知道分母,无法判断严重程度 | 同时报拒绝率与到达率 |
什么时候不该拒绝:幂等的读操作,且降级返回的旧数据仍在业务可接受范围内——这时候「返回 30 秒前的缓存」比「返回错误」对用户更好。反过来,涉及库存扣减、配额校验的写操作不能降级返回旧值,那会造成超卖,只能快速失败。
小结
队列不是缓冲,是延迟的蓄水池。本机实测在 2000/s 的固定到达率下:池 4(容量约 656/s)时,队列从 0 加到 256,拒绝率始终在 67%–71% 之间——加大队列没有减少一次拒绝,只是把 P99 从 8 ms 推到 457 ms;池 16(容量约 2623/s)时容量高于到达率,拒绝率归零,只在同步队列上留下 2.7%/3.5% 的启动瞬态。
拒绝策略的差别不在拒绝率,在调用方为此付出什么:AbortPolicy 让提交线程 5 秒内只阻塞 0.10 s,CallerRunsPolicy 让它阻塞 7.31 s 并把到达率从 2000/s 压到 1353/s,DiscardPolicy 则安静地丢掉了 35.5% 的请求且监控上看不出来。选哪个取决于你能接受哪种代价,而不是哪个「更优雅」。
下一章把线程数从几十推到十万、百万,看看虚拟线程在这个量级上到底省下了什么。
阅读导航:上一节 5.2 CPU 密集与 IO 密集的池大小 | 下一节 6.1 十万/百万虚拟线程实测
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。