《Java 并发编程·性能诊断与事故》10.2 拒绝策略引发的雪崩

重试不会创造容量,只会把拒绝率换算成更高的到达率。本节在 8 线程、64 队列、2000/s 到达的构造场景里实测:3 次重试把尝试数放大 2.53 倍,却只多完成 0.9% 的请求;同时对比 CallerRunsPolicy 占用提交线程 9 ms、DiscardPolicy 静默丢掉 48% 的任务,并给出拒绝不重试的落地写法。

承接的问题

构造场景: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();
    }
}

三个设计点:

  1. 本地拒绝绝不重试。 RejectedExecutionException 是「我自己满了」的信号,重试等于把同一份压力再打一次。这正是本节实测的反面教材:3 次重试把尝试数放大 2.53 倍,完成量只多 0.9%。
  2. 重试预算必须是全局的,不是每请求的。 如果每个请求都允许「失败重试 3 次」,那么过载时所有请求会同时重试,预算形同虚设。令牌桶把重试速率钉在 permitsPerSecond,过载时绝大多数请求只能直接降级。
  3. 写路径的超时不重试。 reserve 会扣库存,超时意味着「不知道下游做没做」,重试可能重复扣减。要么带上幂等键(11.2 的租约与 fencing token 会讲),要么先查证再决定。这条边界比「重试几次」重要得多。

常见错误与边界

错误后果正确做法
把 RejectedExecutionException 当临时失败重试尝试数放大 2~4 倍,完成量不变本地拒绝直接降级
每个请求独立重试计数过载时全量重试,预算失效全局令牌桶 / 重试预算
入口层用 CallerRunsPolicyWeb 容器线程被拖住数秒入口层用 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 线程泄漏与上下文类加载器

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收