本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的 batch-4/ 目录(examples/、evidence/ 等子目录);业务数据均为合成数据。

一个下游变慢 20 秒,上游在这 20 秒里失败,这是预期之内的损失。更难接受的是另一种结果:下游已经恢复,上游的成功率仍然是零,并且一直保持下去。本篇用同一份合成负载比较四种超时与重试策略,测量请求放大、拒绝、积压和恢复时间,并解释“慢下游结束之后系统起不来”是怎样被重试和无界队列维持住的。

实验是确定性的离散事件仿真。它能比较策略之间的相对行为,不能给出任何真实服务的吞吐或延迟。

1. 需求与规模假设

调用链为 客户端 → gateway → svc_a → svc_b。

项 假设值 说明
客户端到达 泊松,平均 400 次/秒,持续 60 秒 固定随机种子
客户端截止时间 1 秒 用户等待上限;超时后客户端最多再试 2 次,间隔 50 毫秒
svc_b 并发槽位 16 平均服务时间 20 毫秒,指数分布;理论容量约 800 次/秒
慢下游窗口 10–30 秒 平均服务时间变为 200 毫秒,容量约 80 次/秒
svc_a 并发槽位 128 调用 svc_b 期间占住槽位(每请求一个工作单元的等待模型)
gateway 并发槽位 512 同上

客户端重试代表 SDK 默认重试或用户刷新,四种策略都保留,因为服务端无法消除它。

需求写成可检查的形式:

  • R1 慢下游期间,系统可以失败,但每个用户请求在 svc_b 上触发的尝试次数要有可计算的上限。
  • R2 下游恢复后,成功率在有限时间内回到 99% 以上。恢复时间定义为:慢窗口结束后,第一个“随后 3 秒内到达的请求成功率 ≥ 99%”的整秒时刻与窗口结束时刻之差。
  • R3 无人等待的工作(调用方已超时,服务端仍在执行)占比可观测。
  • R4 过载时尽早失败:失败请求的耗时中位数应远小于截止时间。

2. 四种策略

策略 每层单次超时 重试 截止时间 队列
naive 网关 1 秒、svc_a 1 秒 网关 3 次、svc_a 3 次 不传播 无界
deadline 网关 1 秒、svc_a 0.3 秒,均不超过剩余时间 同上 传播“本次尝试”的截止时间;出队时丢弃已过期请求 无界
deadline_budget 同上 网关不重试;svc_a 最多 2 次,受令牌节流 同上 无界
protected 同上 同上 同上 svc_b 16、svc_a 64,满即 0.5 毫秒快速拒绝

naive 的最坏放大可以直接算出:客户端 3 次 × 网关 4 次 × svc_a 4 次 = 48 次 svc_b 尝试。AWS Builders’ Library 关于超时与重试的文章用同样的乘法说明了 5 层各尝试 3 次时数据库负载是 243 倍,并建议只在调用栈的一个位置重试。deadline_budget 的最坏放大是 3 × 1 × 3 = 9,而且令牌节流让持续失败期间的实际重试远低于上限。

重试节流采用 gRFC A6 定义的规则:令牌数初始为 maxTokens,失败减 1,成功加 tokenRatio,令牌数不大于 maxTokens/2 时停止重试,直到回升到阈值之上。实验取 maxTokens=10、tokenRatio=0.1,与 gRPC 重试指南的示例配置相同。A6 还规定只有可重试的状态码和服务端回推才计为失败,本模型把超时、过载拒绝和过期丢弃都计为失败。

---
title: 图 37-1 截止时间传播与确认位置(protected 策略下的一次请求)
---
sequenceDiagram
    participant C as 客户端(截止 1s)
    participant G as gateway
    participant A as svc_a
    participant B as svc_b
    C->>G: 请求 deadline=t0+1.0
    G->>A: 尝试 1,deadline=min(t0+1.0, now+1.0)
    A->>B: 尝试 1,deadline=now+0.3
    Note over B: 出队时 now ≥ deadline 则直接丢弃
    B--xA: 0.3s 内无响应 → A 超时,令牌 -1
    A->>B: 尝试 2(令牌 > 5 才允许)
    B-->>A: 成功,令牌 +0.1
    A-->>G: 成功
    G-->>C: 成功(必须在 t0+1.0 前)

3. 关键实现

截止时间以“本次尝试”的绝对时刻向下传播。下游看到的是调用方愿意等待的最后时刻,而不是整个用户请求的截止时间;否则 svc_b 会继续处理一个 svc_a 已经放弃的尝试。

1
2
3
4
5
6
timeout = self.p.attempt_timeout
if deadline is not None:
timeout = min(timeout, deadline - now)
...
# 向下游传播“本次尝试”的截止时间:调用方不会等待超过 now + timeout
down_deadline = now + timeout if self.p.propagate_deadline else None

gRPC 的截止时间文档描述了同一原则的另一面:截止时间到达后服务端自动取消调用,而服务端应用自己负责停止为该调用启动的工作。本模型的服务端只在出队时检查截止时间,已经开始执行的工作不会被中断,所以“浪费的工作”指标仍然大于零。

服务端的拒绝与丢弃:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def submit(self, r: Req) -> None:
if self.busy < self.slots:
self._start(r)
elif self.queue_cap is not None and len(self.queue) >= self.queue_cap:
self.st.rejected_full += 1
self.sim.at(self.sim.now + 0.0005, lambda: r.reply("reject")) # 快速拒绝
else:
self.queue.append(r)

def _next(self) -> None:
while self.queue and self.busy < self.slots:
r = self.queue.popleft()
if self.drop_expired and r.deadline is not None and self.sim.now >= r.deadline:
self.st.dropped_expired += 1
r.reply("error")
continue
self._start(r)

在 HTTP 接口上,快速拒绝对应 503 或 429。RFC 9110 把 503 定义为服务器因临时过载或维护暂时无法处理请求,可以附带 Retry-After,并特别说明 503 的存在不意味着服务器过载时必须使用它;RFC 6585 的 429 表示客户端在给定时间内发送了过多请求。两者都可以携带 Retry-After,让调用方的重试有一个服务端给出的下限。

4. 实验结果

运行入口:examples/ch37_load/。python3 -m unittest test_loadsim -v 共 9 个用例通过;python3 report.py ../../evidence/37 生成三个种子的结果表。下表取种子 7,其他两个种子的结论方向一致(完整数据见 evidence/37/results.md)。

指标 naive deadline deadline_budget protected
慢窗口前成功率 1.0 1.0 1.0 1.0
慢窗口内成功率 0.012 0.084 0.106 0.089
慢窗口后成功率 0.0 1.0 1.0 0.999
恢复时间(秒) 未恢复 0 0 1
网关收到的尝试 / 用户请求 2.66 1.67 1.65 1.64
慢窗口内 svc_b 到达 / 用户请求 0.32 2.76 1.05 2.90(其中多数被快速拒绝)
svc_b 最大队列 981 238 155 16
svc_a 最大队列 9850 411 14952 0
svc_b 完成中服务于成功请求的比例 0.11 0.89 0.91 0.95
失败请求耗时中位数(秒) 3.10 3.10 2.90 0.10
成功请求 p99 耗时(秒,含客户端重试) 0.54 2.71 2.67 0.33

4.1 正常负载

不注入慢窗口时,四种策略成功率都不低于 0.99,svc_b 到达与用户请求之比都小于 1.02(测试 test_quiet_load_all_configs_healthy)。重试与截止时间在健康状态下几乎没有成本,差异只在过载时出现。

4.2 失败:naive 的自持过载

naive 在慢窗口结束后成功率仍为 0,并在剩余的 30 秒里一直如此。把慢窗口缩短到 2 秒、5 秒、10 秒,结果相同(evidence/37/results.md 的敏感性表)。只要过载开始,它就会自己维持下去。

机制可以从表中的数字拼出来。svc_a 的 128 个槽位都被等待 svc_b 的调用占住,新请求进入 svc_a 的无界队列,最多积压到 9850 个。这些请求不带截止时间,客户端早已放弃,svc_a 仍按先进先出逐个处理。svc_b 恢复到 800 次/秒之后,每一次完成几乎都在服务一个无人等待的请求,所以 svc_b 的工作只有 11% 对应成功结果。与此同时,网关收到的尝试是用户请求的 2.66 倍:客户端每个失败请求重试两次,网关每次再重试三次,持续向 svc_a 灌入超过它能排空的工作。队列不会缩短,成功率也就回不来。

慢窗口内 naive 的 svc_b 到达只有用户请求的 0.32 倍,看起来比其他策略“更节制”。这是因为请求被困在 svc_a 的槽位和队列里,没有到达 svc_b。只看最底层的放大系数,会把这种状态误判为健康。

4.3 截止时间:恢复,但放大仍在

deadline 的唯一变化是截止时间与过期丢弃,窗口结束后立即恢复。过期丢弃把 svc_b 的 22070 次到达在出队时直接拒掉,没有消耗执行时间。代价是放大:慢窗口内 svc_b 到达是用户请求的 2.76 倍,网关与 svc_a 两层各自重试的乘积仍然存在,只是每次尝试都被截止时间截短了。

4.4 重试节流:把放大压回 1 附近

deadline_budget 去掉网关重试并给 svc_a 加上令牌节流后,慢窗口内 svc_b 到达降到 1.05 倍。持续失败时令牌很快降到 5 以下,整个运行中 svc_a 只完成了 5 次重试,8479 次重试被节流拒绝。慢窗口内成功率反而是四者中最高的 0.106,因为 svc_b 的容量没有被重复尝试占用。

它的 svc_a 队列峰值达到 14952。这些请求最终在出队时因过期被丢弃,所以不影响恢复,但它们占用内存,并让失败请求等到截止时间才失败(失败耗时中位数 2.9 秒,接近客户端 3 次尝试的总时长)。

4.5 有界队列:快速失败

protected 在 deadline_budget 之上给两层加了队列上限。svc_b 队列不超过 16,svc_a 队列始终为 0:svc_b 满了就在 0.5 毫秒内拒绝,svc_a 的槽位不再被长时间占住。失败请求的耗时中位数从 2.9 秒降到 0.10 秒,成功请求的 p99 从 2.67 秒降到 0.33 秒,svc_b 的完成中有 95% 服务于成功请求。

慢窗口内 svc_b 到达是用户请求的 2.90 倍,但其中大部分是被快速拒绝的,svc_b 实际接纳的只有 17735 次(deadline_budget 是 25405 次)。过载时“到达次数”与“接纳次数”要分开统计,否则无法区分便宜的拒绝与昂贵的执行。

4.6 慢窗口内的成功率为什么都很低

理论上 svc_b 在慢窗口内每秒能完成约 80 个请求,而用户请求是 400 次/秒,最好的结果也只有 20% 左右。实测最高的 0.106 离这个上限很远,主要原因是服务时间的分布:平均 200 毫秒的指数分布中,约 22%(e^-1.5)的请求本身就超过 300 毫秒的单次超时,这部分工作必然浪费,排队时间再叠加上去。把单次超时放宽能提高慢窗口内的成功率,同时会推迟失败、占住更多上游槽位。单次超时应当从下游延迟分位和可接受的误超时比例推导,AWS 的文章给出的做法是先定误超时率(例如 0.1%),再取下游对应的延迟分位(p99.9)作为超时。本实验故意用固定值,以便比较其他变量。

5. 预算怎样分配到各层

总截止时间是用户愿意等待的上限,各层的单次超时与重试次数要在这个上限内排得下。protected 策略的分配如下:

层 可用时间 本层策略 约束
客户端 1 秒 / 次,最多 3 次 由调用方决定,服务端无法控制 服务端只能通过 503/429 与 Retry-After 影响它
gateway 剩余时间 不重试 网关离故障最远,不知道哪个下游失败、请求是否幂等
svc_a → svc_b 每次 ≤ min(0.3 秒, 剩余时间) 最多 2 次重试,受令牌节流,退避加全抖动 3 × 0.3 秒加两次退避,不超过 1 秒
svc_b 收到的截止时间 出队时过期即丢弃;队列满即拒绝 最坏排队时间 < 单次超时

把重试放在 svc_a 而不是网关,有三个理由。svc_a 直接看到 svc_b 的错误类型,可以只对超时与过载重试,对参数错误不重试,这正是 gRFC A6 只把可重试状态码计入节流的原因。svc_a 知道调用是否幂等,网关通常不知道。在离故障最近的一层重试,放大只发生一次。

重试之间的退避加入随机抖动,避免同一时刻失败的大量请求在同一时刻重试。gRPC 的重试实现对退避加入正负 20% 的抖动,本模型用的是 [0, base×2^n] 内的全随机抖动。两者的目的相同,参数不同。

6. 需要记录的观测项

这些指标在实验中都直接从模型计数得到,部署时应当成为每一层的常规指标:

  • 拒绝数,按原因分开:队列满、截止时间已过、节流拒绝重试。三者的处理方式不同,混在一起无法判断是容量问题还是重试策略问题。
  • 队列长度与排队时间。只有长度没有时间,无法判断队列是否已经超出截止时间。
  • 每层的到达、接纳、完成次数,以及“完成时调用方已放弃”的次数。本文的 b_wasted_ratio 和 b_goodput_ratio 来自这一组计数。
  • 重试次数与被节流的重试次数。节流计数持续上升,说明下游处于持续失败而不是偶发失败。
  • 慢下游结束后的恢复时间。它需要事件起止时刻,最好由告警系统记录(见第 36 篇),而不是事后从图表上目测。

7. 方案比较与选择

机制 解决的问题 不能解决的问题
截止时间传播 + 过期丢弃 下游不再为已放弃的调用工作;过载结束后快速恢复 多层重试的放大
只在一层重试 放大从乘积变为单层上限 持续失败时该层仍按上限重试
重试节流(令牌桶,gRFC A6) 持续失败时自动把重试降到很低 首次尝试的流量
比例型重试预算(Envoy retry_budget) 并发重试数不超过活跃请求的一定比例(默认 20%,最少 3 个) 与令牌桶相同;本文未实现
有界队列 + 快速拒绝 失败提前到毫秒级,释放上游槽位 队列长度固定时,下游变慢会让队列等待时间超过截止时间
按排队时间丢弃(CoDel 思路) 直接约束等待时间而非长度 本文未实现;RFC 8289 的参数针对网络包队列(目标 5 毫秒、间隔 100 毫秒),不能直接搬到 RPC

选择上,protected 的组合在本负载下同时满足 R1–R4。四项要求中最容易被忽视的是 R2:只看慢窗口内的表现,naive 与 protected 都很差;区别在窗口结束之后。

调参时有一个直接的约束。队列上限乘以单个请求的服务时间就是最坏排队时间,它必须明显小于单次超时,否则队列里的请求排到时已经超时。本实验中 svc_b 正常时 16 × 20 毫秒 ÷ 16 槽位 = 20 毫秒,慢窗口内是 200 毫秒,仍小于 300 毫秒的单次超时。下游变慢 10 倍,按个数计的队列上限就等价于 10 倍的排队时间,这也是按时间丢弃更稳健的原因。

8. 限制

  • 仿真中的服务端在出队时检查截止时间,不会中断执行中的工作;真实框架的取消传播程度取决于语言与实现。
  • 客户端重试与用户刷新用固定的 2 次、50 毫秒表示,真实用户行为不在模型中。
  • 没有模拟连接池、线程池切换、GC 停顿、网络丢包与重传;“占住槽位”是对线程或协程等待的粗略建模。
  • 恢复时间的度量粒度是 1 秒,种子 7 的 protected 为 1 秒、其余种子为 0 秒,差异在粒度之内。
  • 自适应客户端限流(按近期接受率按比例拒绝)未实现。相关公式出自 Google SRE 书“Handling Overload”一章,本次无法访问原文核对,未在正文中采用。

9. 来源

核对日期 2026-10-18。

  • gRFC A6 Client Retries,“Throttling Retry Attempts and Hedged RPCs”:令牌初值、失败减 1、成功加 tokenRatio、阈值 maxTokens/2;只有可重试状态码计为失败。https://github.com/grpc/proposal/blob/master/A6-client-retries.md
  • gRPC 文档 Retry:示例 maxTokens: 10, tokenRatio: 0.1。https://grpc.io/docs/guides/retry/
  • gRPC 文档 Deadlines:默认不设截止时间;服务端在截止时间后取消调用;服务端应用负责停止派生工作;以剩余超时形式传播。https://grpc.io/docs/guides/deadlines/
  • AWS Builders’ Library,Timeouts, retries, and backoff with jitter:各层独立重试导致 243 倍负载;在单点重试;用令牌桶限制本地重试;从误超时率选择超时分位。现重定向到 builder.aws.com。
  • Envoy 1.41.0-dev,circuit_breaker.proto:retry_budget.budget_percent 默认 20%,min_retry_concurrency 默认 3。
  • RFC 8289 CoDel,§4.2–4.3:TARGET 5 ms、INTERVAL 100 ms。
  • RFC 9110 §15.6.4(503)、§10.2.3(Retry-After);RFC 6585 §4(429)。

本批导航