Skip to content

每层都重试三次,最底下的服务收到二十七倍请求 ​

一条调用链上,前端、A、B 三层各自「失败了重试两次」。C 出故障时,100 个用户请求让 C 收到了 2,700 个请求;C 只是变慢时,用户早已放弃,C 还在把一个请求做 9 遍。

重试和超时通常各层各配各的:写 A 的人给调用 B 加了重试,写 B 的人给调用 C 也加了,谁也不知道上面和下面还有几层在重试。平时看不出问题,下游一出故障,这些重试会相乘,把一个局部故障放大成整条链路的过载。

本文用一条三级调用链复现两种放大:重试次数的相乘,以及超时设置倒挂造成的无用功,再对比几种控制办法。实验在同一个 JVM 里启动 A、B、C 三个 HTTP 服务(JDK 21.0.5 自带的 HttpServer),请求计数都是确定的,见文末配套实验。

一次调用内部各个超时管哪一段、失败后什么情况可以放心重试,是这篇的前提,见 一次 HTTP 调用超时了,对方到底执行了没有。

一、重试次数是相乘的 ​

链路是:驱动程序模拟用户,调用 A;A 调用 B;B 调用 C。C 持续返回 503,每一层都「最多尝试 3 次」:

用户请求A 收到B 收到C 收到× 3× 3× 313927只在入口重试:3 · 每层按 10% 预算重试:约 1.3
图 1 · C 持续失败,驱动、A、B 每层都尝试 3 次:一个用户请求在 A 变成 3 个,在 B 变成 9 个,在 C 变成 27 个(实测 100 个用户请求,C 收到 2,700 个)。只在入口重试时 C 收到 3 个,每层按 10% 预算重试时约 1.3 个

用户的一次请求,A 收到 3 次;A 的每一次都会让 B 被调用 3 次,B 的每一次又让 C 被调用 3 次。层数为 n、每层尝试 k 次时,最底层收到的请求是 kⁿ 倍。实测 100 个用户请求,C 收到 2,700 个。

放大发生的时机正好是最糟的时候:C 返回 503 往往是因为它已经过载,而重试让它承受的压力变成原来的 27 倍,想恢复也恢复不了。

二、只在一层重试,或者给重试设预算 ​

同样是 C 持续失败、100 个用户请求,换两种策略:

重试策略C 收到的请求每个用户请求
每层都尝试 3 次2,70027
只在入口尝试 3 次,A、B 失败直接返回3003
每层尝试 3 次,但每一跳的重试数不超过它请求数的 10%1331.3

只在一层重试最简单:选一层负责重试,其他层失败时直接把错误往上传。选哪一层,要看谁能判断重试是否安全。通常是入口层,或者离故障最近、知道接口是否幂等的那一层。其他层要在错误里带上「已经重试过」的标记,避免上游再重试一遍。

重试预算适合做不到「只在一层重试」的系统,比如公共的 HTTP 客户端封装被各个服务共用。每个客户端统计自己发出的请求里有多少是重试,超过比例就不再重试,直接失败。Google 的 SRE 书介绍过这种做法,比例取 10%。实验里每一跳的请求都只放大 1.1 倍,三跳乘起来是 1.33 倍:

java
/** 重试不超过请求数的 10%:下游大面积失败时,重试会很快用完预算,失败直接返回。 */
final class RetryBudget {
    private final AtomicLong requests = new AtomicLong();
    private final AtomicLong retries = new AtomicLong();

    void onRequest() {
        requests.incrementAndGet();
    }

    boolean tryAcquireRetry() {
        if (retries.get() + 1 > 0.1 * requests.get()) {
            return false;
        }
        retries.incrementAndGet();
        return true;
    }
}

实验里的预算从进程启动开始一直累计,只为了让计数确定。生产上要按时间窗口统计,比如最近 10 秒,否则一段平稳期积累的额度会在故障时被一次用完。重试之间还要加上带随机抖动的退避,避免所有客户端在同一时刻一起重试。

三、超时倒挂:用户已经走了,下游还在干活 ​

第二种放大和重试无关,来自超时的设置。常见的写法是每一层按自己的经验定超时,越往下越长:用户等 1 秒,A 调 B 等 2 秒,B 调 C 等 3 秒。C 这次处理要 2.5 秒:

超时倒挂,不传截止时间用户等 A1 秒后放弃A 等 B2 秒超时B 等 C3 秒超时C 处理用户走后又做了 1.5 秒传递截止时间用户等 A约 5ms 拿到失败C 处理剩余时间不到 1 秒,少于所需的 2.5 秒:立即拒绝01s2s3s4s
图 2 · 上:用户等 1 秒、A 等 B 2 秒、B 等 C 3 秒,C 处理要 2.5 秒;用户 1 秒时放弃,C 仍然做完了这次处理,后 1.5 秒全是无用功。下:把截止时间往下传,C 收到时发现剩余时间不到 2.5 秒,立即拒绝,用户约 5ms 就拿到失败(JDK HttpServer 实测)

用户 1 秒时就放弃了,A 却还在等 B,B 还在等 C,C 把 2.5 秒的活做完,结果没有人要。一个请求浪费 1.5 秒的 C 还不算严重;倒挂和重试叠加起来,情况就变了:每层都尝试 3 次时,一个用户请求让 C 一共收到 9 个请求并全部做完,最多 5 个同时在处理,而用户在第 3 秒就已经放弃了。

用户放弃之后常常会刷新页面或者再点一次,也就是再发一个新请求。C 慢下来的时候,上游的每一次放弃都会在 C 那里留下几个还在执行的请求,C 就越来越慢。

四、传递截止时间 ​

解决倒挂的办法,是让整条链路共用同一个截止时间。入口定下「这个请求最晚什么时候必须结束」,每一跳把它往下传;每一跳的超时取「自己配置的超时」和「离截止时间还剩多少」中较小的一个;被调用方在开始干活之前先检查剩余时间够不够,不够就直接拒绝。

java
long deadline = incomingDeadline(request);                  // 上游传来的截止时间,没有就是自己定的
long remaining = deadline - System.currentTimeMillis();
if (remaining < expectedCostMillis) {
    return reject(504, "deadline too close");               // 做不完就别开始
}
HttpRequest downstream = HttpRequest.newBuilder(uri)
        .timeout(Duration.ofMillis(Math.min(ownTimeoutMillis, remaining)))
        .header("X-Deadline", Long.toString(deadline))       // 继续往下传
        .build();

同样的慢下游,传递截止时间之后,C 收到请求时发现剩余时间不到 1 秒,少于这次处理需要的 2.5 秒,直接拒绝。用户约 5ms 就拿到了失败,而不是干等 1 秒;C 没有做任何无用的处理。

几点注意:

  • 传截止时间还是剩余时间。截止时间是绝对时间,依赖各台机器的时钟基本一致。跨机房或时钟不可靠时,传「剩余多少毫秒」,由接收方按收到请求时的本地时钟换算。gRPC 的截止时间就是按剩余时长编码在请求头里往下传的。
  • 超时要逐层递减。即使不传截止时间,也至少保证下游的超时比上游短,并给网络和本层处理留出余量。
  • 被调用方要用剩余时间。只把截止时间传下去而不检查,C 照样会把活做完;开始耗时的操作(查数据库、调下一跳)之前,都要先看一眼还剩多少时间。

五、上线前核对 ​

检查项做法
链路上有几层在重试画出调用链,标出每一跳的重试次数,把它们乘起来
重试是否只在一层其他层失败直接返回,并带上「已重试」标记
重试是否有预算和退避按时间窗口限制重试比例;退避带随机抖动
超时是否逐层递减下游超时小于上游剩余时间,并留出余量
截止时间是否往下传入口设定,各跳传递,被调用方开始干活前检查
重试是否安全请求已发出后的重试要带幂等键,见超时分层一文
监控每一跳的重试率、下游请求数与上游请求数之比、因截止时间拒绝的次数

最后一项里的「下游请求数与上游请求数之比」,是发现重试放大最直接的指标:平时接近 1,故障时如果飙到好几倍,说明某一层的重试正在把故障放大。

入口的限流和降级是另一道防线,见 容量评估与秒杀设计;限流组件本身的实现与失败语义,见 可复用的服务端组件。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权