承接的问题
构造场景:10.1 里那个无界队列被换成了 ArrayBlockingQueue(64),池还是 8 个线程,活动峰值仍然是 2000/s。拒绝率立刻从 0 变成 37%——过载终于可见了。但值班同学发现错误率并没有停在 37%:上游的借阅 SDK 把 RejectedExecutionException 当成「临时失败」,自动重试 3 次;重试又打到同一个已经饱和的池上,拒绝率反而涨到 52%,SDK 的重试次数从 3 次涨到 4 次,日志里的错误量比第一分钟多了三倍。同一天,另一条链路改用了 CallerRunsPolicy,Web 容器线程被拉去跑 6 ms 的业务任务,接口 P99 从 80 ms 涨到 900 ms,最后触发上游网关超时,形成了第二轮重试。
这两次故障的共同点不是「线程池不够大」,而是拒绝之后的后续动作把局部过载放大成了全局过载。10.1 讲了「堆积而不拒绝」,5.3 讲了「有界队列 + 拒绝率曲线」,这一节只讲一件事:拒绝发生之后,调用方、提交线程和队列里的任务分别会把压力传到哪里,以及放大系数怎么算。
拒绝之后,调用方只有三条路
ThreadPoolExecutor 的四种拒绝策略,本质上是在决定「拒绝这件事由谁承担」:
| 策略 | 拒绝的承担者 | 压力去向 | 典型误用 |
|---|---|---|---|
AbortPolicy | 调用方,立刻拿到异常 | 调用方决定重试或降级 | 调用方无脑重试 → 放大 |
CallerRunsPolicy | 提交线程,自己跑这个任务 | 压力回流到 Web 容器 / event loop | 在入口层使用 → 拖垮容器 |
DiscardPolicy | 没人,任务消失 | 压力凭空消失,数据也消失 | 不对账 → 静默丢单 |
DiscardOldestPolicy | 队列里最老的那个任务 | 老请求被牺牲 | 用在有顺序要求的写路径 |
「拒绝」本身不是故障,它是一次背压信号。信号发出后,系统是收敛还是雪崩,取决于接收方怎么响应。下面三节分别看三种响应方式在本机上的实测结果。
放大系数:拒绝率乘上重试次数
先建立一个可以手算的模型。设:
C为线程池的稳态容量(任务/秒),由「线程数 ÷ 单任务耗时」决定;λ为业务请求的到达率(请求/秒);p为单次提交被拒绝的概率;r为每个请求的最大重试次数(不含首次);A为放大系数,即「实际尝试次数 ÷ 业务请求数」。
稳态下拒绝率等于「超出容量的比例」:
p = max(0, 1 − C / λ_att) (λ_att 是实际尝试到达率)
λ_att = λ × A
A = 1 + p + p² + … + p^r = (1 − p^(r+1)) / (1 − p)
第一次估算时先把 p 当成不随重试变化的常量,代入 p₀ = 1 − C/λ。本机实测的容量约 1045/s(8 线程、6 ms 任务体,实测单任务约 7.7 ms),到达 2000/s,于是 p₀ = 1 − 1045/2000 = 0.478,r = 3:
A = (1 − 0.478^4) / (1 − 0.478) = (1 − 0.052) / 0.522 = 1.81
也就是说,光算一遍,重试就会把 2000/s 变成 3620/s。但这不是终点,因为 λ_att 涨了,p 也会跟着涨。把 p 代回去迭代:
第 0 轮 p=0.478 A=1.814 λ_att=3628/s
第 1 轮 p=0.712 A=2.580 λ_att=5159/s
第 2 轮 p=0.797 A=2.940 λ_att=5881/s
第 3 轮 p=0.822 A=3.054 λ_att=6109/s
第 4 轮 p=0.829 A=3.086 λ_att=6171/s
第 5 轮 p=0.831 A=3.094 λ_att=6187/s
收敛 p≈0.831 A≈3.096 λ_att≈6193/s
这条迭代就是雪崩的形状:容量 C 一动不动,实际到达率却被重试推高了 3.1 倍。如果重试次数不设上限(r → ∞),方程 p = 1 − C(1−p)/λ 在 C < λ 时只有 p = 1 一个解,放大系数发散——也就是「越重试越拒绝,越拒绝越重试」的正反馈。设了 r 只是给它加了一个 A ≤ r+1 的天花板,天花板之下仍然可以把系统推到 3 倍负载。
这里有一个必须说清楚的反直觉结论:重试不产生任何容量。C 只由线程数和单任务耗时决定,重试改变的只有 λ_att。所以重试能「救回」的请求,本质上是把原本会在别的时间点被拒绝的请求,挤到了另一个时间点——总量不变,只是把拒绝换了个位置,并且额外消耗了 3 倍的提交开销。
实测:3 次重试换来 0.9% 的完成量
把上面的模型做成一个可运行的对照实验。四个模式共用同一个池(8 线程、队列 64、任务体 parkNanos(6ms))、同一个开环到达率 2000/s、同一个 5 秒窗口,只有拒绝策略和重试次数不同。
import java.util.Arrays;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;
import java.util.concurrent.locks.LockSupport;
/** 过载下四种提交方式:重试放大、CallerRuns 占用提交线程、Discard 静默丢单。 */
public final class RejectAmplify {
static final long SERVICE_NANOS = TimeUnit.MILLISECONDS.toNanos(6);
static final int ARRIVAL_PER_SEC = 2000;
static final int REQUESTS = ARRIVAL_PER_SEC * 5;
static final int POOL = 8;
static final int QUEUE = 64;
public static void main(String[] args) throws Exception {
run("abort-r0", new ThreadPoolExecutor.AbortPolicy(), 0);
run("abort-r3", new ThreadPoolExecutor.AbortPolicy(), 3);
run("caller ", new ThreadPoolExecutor.CallerRunsPolicy(), 0);
run("discard ", new ThreadPoolExecutor.DiscardPolicy(), 0);
}
static void run(String label, RejectedExecutionHandler handler, int maxRetry)
throws Exception {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
POOL, POOL, 0, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(QUEUE),
r -> new Thread(r, "gw"), handler);
LongAdder attempts = new LongAdder();
LongAdder accepted = new LongAdder();
LongAdder rejected = new LongAdder();
LongAdder completed = new LongAdder();
long[] submitNanos = new long[REQUESTS];
AtomicInteger done = new AtomicInteger();
long start = System.nanoTime();
long interval = 1_000_000_000L / ARRIVAL_PER_SEC;
for (int k = 0; k < REQUESTS; k++) {
long wait = start + k * interval - System.nanoTime();
if (wait > 0) {
LockSupport.parkNanos(wait);
}
for (int tryNo = 0; tryNo <= maxRetry; tryNo++) {
long before = System.nanoTime();
try {
attempts.increment();
pool.execute(() -> {
LockSupport.parkNanos(SERVICE_NANOS);
done.incrementAndGet();
completed.increment();
});
accepted.increment();
submitNanos[k] = System.nanoTime() - before;
break; // 接住了就不再重试
} catch (RejectedExecutionException e) {
rejected.increment();
submitNanos[k] = System.nanoTime() - before;
}
}
}
long deadline = System.nanoTime() + 10_000_000_000L;
while ((pool.getQueue().size() > 0 || pool.getActiveCount() > 0)
&& System.nanoTime() < deadline) {
Thread.sleep(10);
}
long end = System.nanoTime();
pool.shutdownNow();
Arrays.sort(submitNanos);
System.out.printf("%s requests=%d attempts=%d accepted=%d rejected=%d done=%d "
+ "attemptsPerReq=%.2f window=%.2fs submitP99=%.2fms submitMax=%.2fms%n",
label, REQUESTS, attempts.sum(), accepted.sum(), rejected.sum(),
completed.sum(), attempts.sum() / (double) REQUESTS,
(end - start) / 1e9, submitNanos[(int) Math.round(0.99 * (REQUESTS - 1))] / 1e6,
submitNanos[REQUESTS - 1] / 1e6);
if (completed.sum() < accepted.sum()) {
System.out.printf(" -> accepted %d but completed %d, silently dropped %d (%.1f%%)%n",
accepted.sum(), completed.sum(), accepted.sum() - completed.sum(),
(accepted.sum() - completed.sum()) * 100.0 / accepted.sum());
}
}
}
运行与两次独立运行的结果(-Xmx256m,Apple M1 Pro 10 核,Temurin 25.0.5+8,load average 44–52,单次运行、随调度波动、不作基准):
java -Xmx256m -cp /tmp/conc10/classes RejectAmplify
abort-r0 requests=10000 attempts= 10000 accepted= 5226 rejected= 4774 done= 5226 attemptsPerReq=1.00 window=5.08s submitP99= 0.02ms submitMax= 2.40ms
abort-r3 requests=10000 attempts= 25320 accepted= 5274 rejected=20046 done= 5274 attemptsPerReq=2.53 window=5.08s submitP99= 0.01ms submitMax= 0.11ms
caller requests=10000 attempts= 10000 accepted=10000 rejected= 0 done=10000 attemptsPerReq=1.00 window=9.41s submitP99= 9.02ms submitMax= 13.80ms
discard requests=10000 attempts= 10000 accepted=10000 rejected= 0 done= 5197 attemptsPerReq=1.00 window=5.08s submitP99= 0.00ms submitMax= 0.07ms
-> accepted 10000 but completed 5197, silently dropped 4803 (48.0%)
原文每行末尾还有 latP50/latP99 两列,这里为排版省略;四个模式的端到端 P50/P99 分别是 69.0/74.0、68.5/73.0、72.0/81.1、69.0/75.0 ms。同机第二次运行的结果一致:abort-r3 的 attemptsPerReq=2.52、accepted=5231、rejected=19977;caller 的投递窗口 9.29 s;discard 的 done=5237、静默丢弃 47.6%。
先对账容量:5226 / 5.08 ≈ 1029/s,用投递窗口算是 5226 / 5.00 ≈ 1045/s。名义容量是 8 / 0.006 = 1333/s,实测只有 1045/s,因为 6 ms 的 parkNanos 在 8 个线程争抢下实际要 7.7 ms 才走完一个。写容量公式时用实测服务时间,不要用代码里的 sleep 参数,这是 5.2 留下的教训。
再看重试:
- 尝试数从 10 000 涨到 25 320,放大 2.53 倍;
- 每尝试被拒率
20046 / 25320 = 79.2%; - 用实际尝试到达率反算:
1 − 1045 / (25320/5.00) = 1 − 1045/5064 = 79.4%。
79.2% 与 79.4% 几乎相等,这不是巧合,而是模型的自洽性验证:系统拒绝的永远只是「超出容量的那一部分」,重试把分母抬高了,拒绝率就按比例上升。放大系数实测 2.53,落在「单轮估算 1.81」和「不动点 3.10」之间——因为 5 秒窗口还没走到平衡,而且同一个请求的 4 次尝试挤在几毫秒内、彼此相关(一次拒绝之后紧接着的尝试大概率还是拒绝),所以没有每个请求都跑满 4 次。
最重要的一个数字是完成量:5226 → 5274,多了 48 个请求,相对提升 0.9%,代价是 15 320 次多余的提交和 15 272 次多余的拒绝。这就是「重试不产生容量」的实测版本。如果上游把重试次数从 3 提到 10,A 的上界从 4 提到 11,但 done 仍然被 C × 窗口 锁死——多出来的尝试全部变成拒绝日志和 CPU 开销。
CallerRuns:把过载灌回调用线程
CallerRunsPolicy 的语义是:队列满时,提交任务的那个线程自己执行这个任务。它看起来像一种优雅的背压——不丢任务、不抛异常、自然限速——但它有一个非常具体的代价:execute() 的调用方会被占用整整一个服务时间。
本机实测(同一过载条件):
caller req=10000 done=10000 window=9.41s submitP99=9.02ms submitMax=13.80ms
- 投递窗口从 5.00 s 拉长到 9.33 s(
dispatch window),多出来的 4.33 s 就是提交线程被拉去干活的时间; - 单次
execute()的 P99 是 9.02 ms,最大 13.80 ms——比 6 ms 的任务体还长,因为线程被唤醒、执行、再回来继续投递都有开销; - 实际到达率从 2000/s 掉到
10000 / 9.33 ≈ 1072/s。
如果这个提交线程是 Tomcat 的工作线程,那么这 4.33 秒里它没有在处理 HTTP 请求;容器能并发的请求数被悄悄砍掉了一截,上游看到的是「接口变慢」,于是触发了自己的超时重试。压力就这样沿着 入口池 → Web 容器 → 网关 一层层往回传,最后在整个调用链上同时超时。这就是「拒绝策略引发的雪崩」最典型的一条路径:不是线程池炸了,是线程池把调用方变成了自己的消费者。
CallerRunsPolicy 不是不能用,它适合「后台批处理」这种「提交线程本身就是生产者、慢一点没关系」的场景。它不适合任何「提交线程是共享资源」的场景:Web 容器线程、事件循环线程、调度线程、另一个线程池的 worker。
Discard:0 拒绝率掩盖 48% 丢单
DiscardPolicy 的 rejectedExecution 方法体是空的:任务被静默丢弃,execute() 正常返回。于是监控上看到的是——拒绝数 0、异常数 0、提交数 10000、QPS 正常,唯独业务量对不上。
本机实测:
discard accepted=10000 rejected=0 done=5197
-> dropped 4803 (48.0%)
accepted 记的是「execute() 没有抛异常」,done 记的是「任务真的跑完了」。两者相差 4803,正好等于 10000 × (1 − 1045/2000) ≈ 4775 的量级。也就是说,48% 的借阅请求在监控全绿的情况下消失了。
如果一定要用 DiscardPolicy(例如埋点、心跳这类可丢失的数据),必须自己维护「提交数 − 完成数」的对账指标,并且给它设告警。否则线上会表现为一种非常难查的故障:用户说没借到书,日志里没有错误,监控里没有拒绝,数据库里没有记录。
| 策略 | 拒绝可见性 | 数据丢失 | 压力去向 | 适合 |
|---|---|---|---|---|
AbortPolicy | 高(异常) | 无(由调用方决定) | 调用方 | 入口层 + 降级 |
CallerRunsPolicy | 低(只是变慢) | 无 | 提交线程 | 后台批处理 |
DiscardPolicy | 无 | 有 | 消失 | 可丢失的遥测 |
DiscardOldestPolicy | 无 | 有(最老的) | 消失 | 只关心最新值 |
在 libraryd-conc 里落地
修复的核心不是「换一个更好的拒绝策略」,而是把「本地拒绝」和「下游可重试错误」分开对待:本地拒绝说明自己已经过载,重试只会加重过载;只有下游返回的可重试错误才值得消耗重试预算,而且预算必须是全局的。
package com.plume.conc.ops;
import com.plume.conc.core.LoanService;
import com.plume.conc.core.Reservation;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.locks.LockSupport;
import java.util.function.Predicate;
/** 入口调用:本地拒绝不重试;下游可重试错误才消耗全局预算。 */
public final class BorrowCall {
public static final class BusyException extends RuntimeException {
public BusyException(String message) { super(message); }
}
private final ExecutorService pool;
private final LoanService downstream;
private final RetryBudget budget;
private final Predicate<Throwable> retryable;
private final int maxAttempts;
private final long timeoutMillis;
public BorrowCall(ExecutorService pool, LoanService downstream, RetryBudget budget,
Predicate<Throwable> retryable, int maxAttempts, long timeoutMillis) {
this.pool = pool;
this.downstream = downstream;
this.budget = budget;
this.retryable = retryable;
this.maxAttempts = maxAttempts;
this.timeoutMillis = timeoutMillis;
}
public Reservation reserve(String isbn, String memberId) {
RuntimeException last = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return pool.submit(() -> downstream.reserve(isbn, memberId))
.get(timeoutMillis, TimeUnit.MILLISECONDS);
} catch (RejectedExecutionException e) {
// 本地过载:重试只会让队列更满,直接让调用方降级
throw new BusyException("local overload");
} catch (TimeoutException e) {
// 写路径超时不能盲目重试,否则可能重复扣减库存
throw new BusyException("downstream timeout, verify before retry");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusyException("interrupted");
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (!retryable.test(cause)) {
throw new BusyException("non-retryable: " + cause);
}
last = new BusyException("retryable: " + cause.getMessage());
}
if (attempt == maxAttempts || !budget.tryAcquire()) {
throw last;
}
LockSupport.parkNanos((long) Math.pow(2, attempt) * 1_000_000L);
}
throw last;
}
}
配套的令牌桶只做一件事:把「重试」变成一种有限资源。
package com.plume.conc.ops;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/** 全局重试预算:每秒最多放行 permitsPerSecond 次重试,用完就降级。 */
public final class RetryBudget {
private final Semaphore permits;
public RetryBudget(int permitsPerSecond) {
this.permits = new Semaphore(permitsPerSecond, true);
ScheduledExecutorService refill = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "retry-budget-refill");
t.setDaemon(true);
return t;
});
refill.scheduleAtFixedRate(() -> {
int add = permitsPerSecond - permits.availablePermits();
if (add > 0) {
permits.release(add);
}
}, 1, 1, TimeUnit.SECONDS);
}
public boolean tryAcquire() {
return permits.tryAcquire();
}
}
三个设计点:
- 本地拒绝绝不重试。
RejectedExecutionException是「我自己满了」的信号,重试等于把同一份压力再打一次。这正是本节实测的反面教材:3 次重试把尝试数放大 2.53 倍,完成量只多 0.9%。 - 重试预算必须是全局的,不是每请求的。 如果每个请求都允许「失败重试 3 次」,那么过载时所有请求会同时重试,预算形同虚设。令牌桶把重试速率钉在
permitsPerSecond,过载时绝大多数请求只能直接降级。 - 写路径的超时不重试。
reserve会扣库存,超时意味着「不知道下游做没做」,重试可能重复扣减。要么带上幂等键(11.2 的租约与 fencing token 会讲),要么先查证再决定。这条边界比「重试几次」重要得多。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
把 RejectedExecutionException 当临时失败重试 | 尝试数放大 2~4 倍,完成量不变 | 本地拒绝直接降级 |
| 每个请求独立重试计数 | 过载时全量重试,预算失效 | 全局令牌桶 / 重试预算 |
入口层用 CallerRunsPolicy | Web 容器线程被拖住数秒 | 入口层用 AbortPolicy + 降级 |
用 DiscardPolicy 但不对账 | 静默丢单,监控全绿 | 提交数 − 完成数必须有指标 |
| 写路径超时后重试 | 重复扣库存 / 重复下单 | 幂等键,或查证后再重试 |
| 只看拒绝率不看尝试数 | 不知道放大系数,容量规划失真 | 同时上报 attempts / requests |
什么时候重试是对的:下游返回明确的、幂等的、短暂的可重试错误(连接重置、503、限流响应),并且重试预算充足、请求本身幂等。什么时候重试是错的:本地拒绝、参数错误、业务失败(库存不足、配额超限)、以及任何非幂等的写操作在超时之后。
小结
重试不产生容量。本机在 8 线程、队列 64、到达 2000/s 的构造场景里实测:不做重试时接受 5226/10000;做 3 次重试后尝试数变成 25 320(放大 2.53 倍),接受 5274,只多了 48 个,相对提升 0.9%。每尝试被拒率 79.2%,与 1 − C/λ_att = 79.4% 吻合,说明拒绝永远只是「超出容量的比例」,重试抬高的只是分母。把 p 代回方程迭代,稳态会收敛到 p≈0.83、A≈3.10、实际到达约 6193/s——这就是雪崩的不动点。
另外两条路径的代价是:CallerRunsPolicy 把投递窗口从 5.00 s 拉长到 9.33 s,单次提交 P99 9.02 ms,等于把过载灌回 Web 容器;DiscardPolicy 用 0 的拒绝率掩盖了 48.0% 的丢单。下一节换一个方向:不看压力,看那些悄悄留在 JVM 里、永远不会退出的线程和类加载器。
阅读导航:上一节 10.1 队列堆积与 OOM | 下一节 10.3 线程泄漏与上下文类加载器
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。