每层都重试三次,最底下的服务收到二十七倍请求
一条调用链上,前端、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 收到 3 次;A 的每一次都会让 B 被调用 3 次,B 的每一次又让 C 被调用 3 次。层数为 n、每层尝试 k 次时,最底层收到的请求是 kⁿ 倍。实测 100 个用户请求,C 收到 2,700 个。
放大发生的时机正好是最糟的时候:C 返回 503 往往是因为它已经过载,而重试让它承受的压力变成原来的 27 倍,想恢复也恢复不了。
二、只在一层重试,或者给重试设预算
同样是 C 持续失败、100 个用户请求,换两种策略:
| 重试策略 | C 收到的请求 | 每个用户请求 |
|---|---|---|
| 每层都尝试 3 次 | 2,700 | 27 |
| 只在入口尝试 3 次,A、B 失败直接返回 | 300 | 3 |
| 每层尝试 3 次,但每一跳的重试数不超过它请求数的 10% | 133 | 1.3 |
只在一层重试最简单:选一层负责重试,其他层失败时直接把错误往上传。选哪一层,要看谁能判断重试是否安全。通常是入口层,或者离故障最近、知道接口是否幂等的那一层。其他层要在错误里带上「已经重试过」的标记,避免上游再重试一遍。
重试预算适合做不到「只在一层重试」的系统,比如公共的 HTTP 客户端封装被各个服务共用。每个客户端统计自己发出的请求里有多少是重试,超过比例就不再重试,直接失败。Google 的 SRE 书介绍过这种做法,比例取 10%。实验里每一跳的请求都只放大 1.1 倍,三跳乘起来是 1.33 倍:
/** 重试不超过请求数的 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 秒:
用户 1 秒时就放弃了,A 却还在等 B,B 还在等 C,C 把 2.5 秒的活做完,结果没有人要。一个请求浪费 1.5 秒的 C 还不算严重;倒挂和重试叠加起来,情况就变了:每层都尝试 3 次时,一个用户请求让 C 一共收到 9 个请求并全部做完,最多 5 个同时在处理,而用户在第 3 秒就已经放弃了。
用户放弃之后常常会刷新页面或者再点一次,也就是再发一个新请求。C 慢下来的时候,上游的每一次放弃都会在 C 那里留下几个还在执行的请求,C 就越来越慢。
四、传递截止时间
解决倒挂的办法,是让整条链路共用同一个截止时间。入口定下「这个请求最晚什么时候必须结束」,每一跳把它往下传;每一跳的超时取「自己配置的超时」和「离截止时间还剩多少」中较小的一个;被调用方在开始干活之前先检查剩余时间够不够,不够就直接拒绝。
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,故障时如果飙到好几倍,说明某一层的重试正在把故障放大。
入口的限流和降级是另一道防线,见 容量评估与秒杀设计;限流组件本身的实现与失败语义,见 可复用的服务端组件。
配套实验
- codesphere-labs/distributed/retry-amplification:三级调用链上三种重试策略下 C 收到的请求数;超时倒挂时是否传递截止时间;倒挂加每层重试时 C 的无用处理(验证记录)
参考资料