Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

2 RPC:超时、重试、幂等、背压

本章回答

一次 RPC 在 stub、IDL 和连接池之间到底发生了什么?为什么 deadline 必须逐跳传播,而不能每跳重置?重试该怎么退避、怎么设预算、什么时候该对冲?幂等键和指纹如何挡住重复副作用?背压和限流分别在保护谁?

读完你会

能写出一个带绝对 deadline 的调用链并解释 grpc-timeout 的换算;能实现 full jitter 退避、重试预算和令牌桶;能设计幂等键、指纹与去重表;能用 Little's law 算出并发上限和队列长度;能在一张契约表上答完 RPC 的十五个问题。

上一章留下三个词:unknown、逻辑请求、物理尝试。它们描述的是「一次调用可能失败得不明不白」。这一章要把这三个词变成可执行的协议:调用方在什么时刻放弃、放弃后做什么、服务端如何识别重复、系统在过载时如何拒绝。

很多团队以为用了 gRPC 或 Thrift 就自动获得了这些能力。事实相反:RPC 框架只负责「把参数变成字节、把字节变成响应」,它不负责「这次调用到底算不算成功」。超时、重试、幂等、背压这四件事,框架最多提供钩子,语义必须由使用方定义。定义错了,框架会把错误放大得更快——它让重试变得太容易,让连接复用得太深,让一次用户点击在 200 ms 内变成 16 个请求。

这一章的顺序按「一次调用的生命周期」排:先看 stub 做了什么(2.1),再看到达服务端之前的时间预算怎么传(2.2),然后看失败后怎么重试(2.3)以及重试凭什么安全(2.4)。2.5 把超时和重试接成一条正反馈时间线;2.6、2.7 讨论系统被压垮时,队列和限流如何把压力挡在门外。2.8 给出客户端与服务端各自的观测面,2.9 用 S3 2017-02-28 说明重试如何改变恢复曲线,2.10 用一笔支付说明「两个键」为什么会留下两条扣款记录。最后三节是契约检查表、陷阱清单和出口检查。

2.1 RPC 到底做了什么:stub、IDL、序列化、连接复用

先看一行代码:

resp = payment_stub.Charge(ChargeRequest(order_id="o-8812", amount_cents=1999),
                           timeout=1.0, metadata=(("idempotency-key", "pay-o-8812"),))

这行代码在服务端对应一个普通函数 def Charge(req, ctx)。中间隔着的东西,比大多数调用方想象的要多。把它们列清楚,才能知道哪些失败该由谁负责。

阶段谁在做具体动作失败会长什么样
1stub把语言对象转成 IDL 消息类型不匹配、必填缺失、超长
2序列化器编码成字节,必要时压缩CPU 尖峰、超出 max message size
3stub/channel附加 metadata:deadline、trace、auth、幂等键键丢失、header 被代理剥掉
4名字解析/负载均衡选一个后端地址解析失败、选到不健康实例
5连接池取一条已有连接或新建池耗尽、握手超时、GOAWAY
6传输层分帧、写 socket、等响应半包、连接重置、流控阻塞
7服务端解帧、反序列化、分发到 handler解码失败、线程池满、找不到方法
8handler执行业务、访问数据库/下游业务异常、锁等待、下游超时
9回程序列化响应、写回、客户端解析响应丢失、超时、版本不兼容

这张表和第 1 章的十二个 hop 是同一件事的两种切法:第 1 章按「网络位置」切,这张表按「软件层次」切。切法不同,用途也不同——前者用来定位延迟,后者用来定位责任。

2.1.1 stub 不是魔法,它只是把五件事藏起来了

生成代码里的 PaymentStub 通常做五件事,且只做五件事:

  1. 类型检查:把 ChargeRequest 编译成一个有字段、有默认值的类;
  2. 序列化:调用 IDL 运行时把对象编码成字节;
  3. 元数据拼装:把 deadline、trace context、认证 token、幂等键塞进 header;
  4. 连接管理:从 channel 里取连接、做负载均衡、处理重连;
  5. 错误映射:把 wire 上的状态码映射成语言里的异常类型。

它明确不做的事同样重要:

  • 不保证「最多执行一次」;它连「执行了几次」都不知道;
  • 不保证顺序;多路复用下,响应到达顺序与请求发送顺序无关;
  • 不保证取消能传到服务端;cancel() 通常只是本地停止等待;
  • 不替你决定业务幂等键;metadata 里的键是调用方自己放的;
  • 不替你选超时预算;timeout=1.0 是你写的,不是框架推荐的。
定义 2.1.1 · stub、channel 与逻辑调用

stub 是 IDL 编译器生成的客户端代理,负责「方法签名 ↔ 消息」的双向映射。channel 是 stub 背后的连接抽象,负责名字解析、负载均衡、连接复用与重连。逻辑调用是一次 stub 方法调用所表达的业务意图;一次逻辑调用可以产生多次物理尝试(2.3),也可能在 channel 内部被透明重试。

关键区别:stub 的生命周期通常与进程相同,channel 的生命周期与「目标服务」相同,逻辑调用的生命周期只到 deadline 为止。把三者混为一谈,就会出现「stub 重试了,业务又重试一次」的双层放大。

2.1.2 IDL 定义的是成功路径,不是失败路径

一个典型的 proto 定义:

syntax = "proto3";

message ChargeRequest {
  string order_id = 1;
  int64 amount_cents = 2;
  string currency = 3;
  reserved 4, 5;            // 曾经是 card_number / cvv,永远不要复用
  reserved "card_number";
}

message ChargeResponse {
  string charge_id = 1;
  string status = 2;        // succeeded | failed | unknown
  int64 amount_cents = 3;
}

service Payment {
  rpc Charge(ChargeRequest) returns (ChargeResponse);
}

这段 IDL 能表达「参数长什么样」,不能表达:

  • 这个方法是幂等的还是非幂等的;
  • 超时后调用方应该重试还是查询;
  • 哪些错误码可以重试;
  • 服务端过载时返回什么;
  • 同一个幂等键配不同参数该怎么处理。

这些语义要么写在注释里(然后被忽略),要么写进契约文档和错误码表(2.11),要么由中间件强制(例如服务端校验 idempotency-key 必须存在)。IDL 是类型契约,不是行为契约;把行为契约寄托在 .proto 注释上,是团队协作里最常见的隐形债务。

字段编号还有一条硬规则:删除字段时用 reserved 占住编号和名字。否则下一个同事把编号 4 复用成 user_id,旧客户端发来的 card_number 会被解码成 user_id,产生极难排查的串字段。这条规则与 RPC 的失败语义无关,却经常在「重试 + 旧客户端」的场景里引爆。

2.1.3 序列化的成本:体积、CPU、兼容性

同一份 ChargeRequest(order_id、金额、币种、两个嵌套对象)在不同编码下的量级:

编码体积(示例)编码 CPU可读性兼容性策略
JSON约 210 B中好字段名、忽略未知字段
Protobuf约 75 B低差字段编号 + 默认值
MessagePack约 95 B低差位置/键名约定
压缩后的 JSON约 120 B高(小包不划算)好同 JSON

体积差 2–3 倍,在 1 KB 以下的小请求上通常不是瓶颈;真正贵的是大对象和深嵌套。一个 2 MB 的响应在客户端反序列化时可能占用 10 MB 内存和几十毫秒 CPU,而这发生在「 deadline 只剩 50 ms」的时候。压缩也有阈值:小于 1 KB 的包压缩后可能更大,还多一次 CPU。

兼容性比体积更常出事。三条规则足够挡住 90% 的事故:

  1. 新字段一律可选,服务端给默认值;不要新增 required;
  2. 枚举保留一个 UNSPECIFIED = 0,客户端遇到未知值不要抛异常;
  3. 删除字段先标 deprecated,观察一个发布周期,再用 reserved 删除。

2.1.4 连接复用:省下的是握手,付出的是耦合

短连接的成本可以算出来。假设同城 RTT 为 0.5 ms、跨地域 RTT 为 40 ms:

建连方式额外往返同城成本跨地域成本
TCP 三次握手1 RTT0.5 ms40 ms
TCP + TLS 1.32 RTT1 ms80 ms
TCP + TLS 1.23 RTT1.5 ms120 ms
连接池命中0约 0约 0

TCP 握手与 TLS 握手的细节见 Linux 书 4.5,本书不重画状态机。这里只需要一个结论:在跨地域调用上,短连接会把每次调用的固定成本抬高两个数量级。1000 QPS 的服务如果每次新建连接,就是每秒 1000 次握手;连接池把这块成本摊薄到接近零。

但复用不是免费的,它把三个问题引入了调用路径:

  • 队头阻塞:HTTP/1.1 一条连接同时只能有一个在途请求;HTTP/2 用 stream 解决了应用层队头,但 TCP 层的丢包仍会阻塞同连接的所有 stream。QUIC 把 stream 独立化,代价是 UDP 上的拥塞控制与中间设备兼容性。
  • 流控与并发上限:HTTP/2 有 SETTINGS_MAX_CONCURRENT_STREAMS,常见默认值是 100。超过上限的请求会在客户端排队,表现为「服务端 CPU 不高,但客户端 p99 很高」。这个队列不在你的业务指标里,却真实存在。
  • 生命周期耦合:服务端重启、缩容、证书轮换都会发 GOAWAY。GOAWAY 里带 last_stream_id:编号大于它的 stream 没被处理,可以安全重试;小于等于它的可能已经执行,不能盲目重试。很多「透明重试」的实现只对未处理的 stream 生效,原因就在这里。

连接池大小本身也是一道并发限制:池 50 条、每条 1 个在途请求,等价于并发上限 50;下游延迟从 20 ms 涨到 200 ms,池子的吞吐上限就从 2500 QPS 掉到 250 QPS。

连接池指标比连接数更有用

只看 pool_open 没意义。要看 pool_wait_seconds(取连接等了多久)、pool_in_use / pool_max(饱和度)、pool_timeouts_total(等不到连接)。如果 pool_wait 的 p99 超过 5 ms,说明池子已经成为排队点;此时调大池子可能只是把队列从客户端挪到服务端。

2.1.5 分帧:长度前缀与它的两个坑

RPC 在字节流上要解决「一条消息到哪里结束」。最常见的是长度前缀帧:

import struct

MAX_FRAME = 4 * 1024 * 1024

def encode_frame(payload: bytes) -> bytes:
    if len(payload) > MAX_FRAME:      # 先校验长度,再分配内存
        raise ValueError(f"frame too large: {len(payload)}")
    return struct.pack(">I", len(payload)) + payload

# decode 端按 4 字节长度前缀循环切分;不足一帧时保留残余,等下一次 recv

两个坑:

  1. 先校验再分配。如果实现写成 buf[4:4+n] 再判断长度,一个伪造的 4 GB 长度前缀就能让进程 OOM。校验必须发生在分配之前。
  2. 半包与粘包是常态。一次 recv 可能只拿到半个长度前缀,也可能拿到两条完整消息加半条;解码端必须能处理任意切分。这不是 TCP 的缺陷,而是字节流的定义;细节见 Linux 书 4.4。

帧格式还决定了「错误如何表达」。gRPC 把状态码放在 HTTP/2 的 trailers 里;如果连接在 trailers 之前断开,客户端只知道「流断了」,不知道服务端是否执行。这正是第 1 章 unknown 的物理来源。

2.2 超时不是重试的触发器:deadline 传播

下面这条时间线在很多系统里都出现过:

t(ms)  actor     event
   0   client    send, budget=1000
  30   gateway   recv, remaining=970
  30   gateway   forward, timeout=1000   // BUG: resets the budget
  80   svc-A     recv, assumes 1000ms
 120   svc-A     query
1000   client    timeout -> unknown
3200   svc-A     commit done, side effect already happened

客户端在 1 s 时已经放弃,服务端在 3.2 s 才提交。客户端如果重试,就会产生第二次副作用;如果客户端不重试,用户看到失败但钱已经扣了。两种结果都不可接受。根因不是「超时设得太短」,而是网关把剩余预算重置成了一个新的 1 s。

2.2.1 绝对 deadline 与剩余预算

timeout 是「我愿意等多久」,deadline 是「这个请求最晚必须在什么时刻完成」。单跳里两者等价,多跳里差别巨大:

概念表示谁维护跨进程怎么传
单跳 timeout时长(如 1 s)每个调用方各自决定不传
绝对 deadline单调时钟的终点整条调用链共享不能直接传单调时钟
剩余预算deadline - now每一跳到达时计算传相对值,接收方立刻换算
每跳余量留给回程与排队的 buffer发送方扣减不传,体现在剩余预算里

单调时钟只在同一台机器内有意义,所以跨进程不能传「绝对时刻」。工程上的标准做法是传剩余预算,接收方在收到的那一刻用本地单调时钟换算成自己的绝对 deadline:

接收方 deadline = 本地 monotonic_now + 收到的剩余预算

这个换算必须发生在「收到请求的第一时间」,不能等到业务代码里再算。中间经历的反序列化、排队、鉴权都要从预算里扣掉。

gRPC 的 grpc-timeout 就是这种相对预算,格式是「正整数 + 单位」,单位取 H/M/S/m/u/n,数值最多 8 位。发送方取「当前剩余预算」,接收方解码后立刻换算。它不传绝对时间戳,因此不受两端墙上时钟偏差影响。

2.2.2 每跳该留多少余量

预算不能平均分,也不能每跳都留固定 100 ms。余量至少覆盖三件事:

  1. 回程网络时间:响应从服务端回到调用方的时间,通常约 1 个 RTT;
  2. 调用方的处理时间:反序列化、写日志、唤醒等待者;
  3. 抖动:p99 与中位数的差,而不是中位数本身。

一个可用的公式:

dchild=dparent−tqueue−tdecode−margin d_{\text{child}} = d_{\text{parent}} - t_{\text{queue}} - t_{\text{decode}} - \text{margin}

其中 margin=2×RTTp99+5msmargin = 2 \times RTT_{p99} + 5\text{ms}。同城 RTT 0.5 ms 时 margin 约 6 ms;跨地域 RTT 40 ms 时 margin 约 85 ms。如果算出来小于 0,正确动作是立刻失败,而不是给一个最小值继续调用——预算已经不够完成一次有意义的往返,继续调用只会制造一个注定被放弃的副作用。

定理 2.2.1 · 预算不等式

设逻辑请求的端到端 deadline 为 DD,路径上有 nn 跳,第 ii 跳的排队、处理、传输时间分别为 qi,si,tiq_i, s_i, t_i,第 ii 跳留给下游的预算为 bib_i。若每一跳都满足

bi≤bi−1−qi−si−ti−mi b_i \le b_{i-1} - q_i - s_i - t_i - m_i

且 bn≥0b_n \ge 0,则最坏情况下整条链在 DD 之前结束;若某一跳把 bib_i 重置为固定值 TT,则端到端最坏时间可能达到 nTnT,与 DD 无关。

证明

对第一段,把不等式从 i=1i=1 累加到 nn,得到 bn≤D−∑(qi+si+ti+mi)b_n \le D - \sum(q_i+s_i+t_i+m_i)。每跳实际耗时不超过 qi+si+tiq_i+s_i+t_i,所以总耗时不超过 D−bn−∑mi≤DD - b_n - \sum m_i \le D。

对第二段,构造每跳都耗时接近 TT 的执行:第 1 跳在 T−ϵT-\epsilon 时把请求交给第 2 跳,第 2 跳重新获得 TT 的预算,于是总时间约为 nTnT。这与 DD 无关,因此重置预算会破坏端到端上限。

∎

这个定理看起来是废话,但它的推论经常被忽略:deadline 不是「每次尝试的超时」,而是「所有尝试共享的总预算」。第一次尝试花了 800 ms,第二次尝试就只剩 200 ms;如果重试策略还按「每次 1 s」设置,第二次尝试必然在服务端继续执行,而客户端早已放弃。

2.2.3 一个可用的 Deadline 实现

import math
import time

_UNIT_MS = {"H": 3_600_000, "M": 60_000, "S": 1_000,
            "m": 1, "u": 0.001, "n": 0.000001}


class Deadline:
    """绝对截止时间。内部只用单调时钟,不碰墙上时钟。"""

    __slots__ = ("_end",)

    def __init__(self, end_mono: float):
        self._end = end_mono

    @classmethod
    def budget(cls, ms: int) -> "Deadline":
        return cls(time.monotonic() + ms / 1000.0)

    def remaining_ms(self) -> int:
        return max(0, math.ceil((self._end - time.monotonic()) * 1000))

    def child(self, margin_ms: int) -> "Deadline":
        # margin_ms 应包含排队、解码与回程;子 deadline 绝不能晚于父 deadline
        return Deadline(self._end - margin_ms / 1000.0)

    def expired(self) -> bool:
        return self.remaining_ms() <= 0


def encode_grpc_timeout(ms: int) -> str:
    ms = max(1, ms)
    for unit in ("m", "S", "M", "H"):        # 选能放进 8 位数字的最细单位
        value = math.ceil(ms / _UNIT_MS[unit])
        if value <= 99_999_999:
            return f"{value}{unit}"
    return "99999999H"


def decode_grpc_timeout(value: str) -> Deadline:
    if len(value) < 2 or value[-1] not in _UNIT_MS:
        raise ValueError(f"bad grpc-timeout: {value!r}")
    return Deadline.budget(math.ceil(int(value[:-1]) * _UNIT_MS[value[-1]]))

三个细节值得单独说:

  • 用 math.ceil 而不是 int:剩余 0.4 ms 时 int 会得到 0,编码成 0m 是非法的;ceil 得到 1 ms,虽然仍然会立刻超时,但至少是一个合法值。
  • child() 只减不加。如果 margin_ms 大于剩余预算,子 deadline 落在过去,expired() 立刻为真,调用方快速失败——这是期望行为。
  • remaining_ms() 返回 0 表示「已过期」,不要用它区分「刚好 0」和「负数」;两者都该失败。

2.2.4 超时之后:取消是提示,不是回滚

客户端超时后通常会发 RST_STREAM 或 Cancel。服务端收到取消,应该怎么做?

服务端状态收到取消后的正确动作错误动作
还没开始执行丢弃请求,不写任何记录继续执行
正在执行,未提交尽快停止,回滚事务忽略取消,跑完再说
已提交,未返回记录结果,供查询;返回 CANCELLED 也无妨尝试「撤销」已提交的写
正在调用下游把取消传给下游;下游可能已经执行假设下游一定没执行

关键点:取消只能减少无效工作,不能撤销已经发生的副作用。服务端在提交前检查一次 deadline 是好习惯,但检查与提交之间仍有窗口;数据库提交后进程崩溃,取消也来不及。所以「超时后是否重复」最终仍要靠幂等键(2.4)解决,而不是靠取消。

为什么服务端不应该在 deadline 过期后继续重试下游

如果服务端发现自己的 deadline 只剩 10 ms,它调下游至少需要 20 ms,那么这次下游调用注定在客户端放弃之后才完成。正确做法是直接返回 DEADLINE_EXCEEDED,并把「未执行」写清楚;继续调用只会产生一个无人认领的副作用。这个判断要放在调用下游之前,而不是之后。

2.3 重试策略:指数退避、抖动、预算、对冲

重试的前提不是「失败了」,而是「失败了,且再试一次有可能成功,且再试一次的副作用可控」。这三个条件缺一不可。把不满足条件的错误也重试,是重试风暴的另一个源头。

2.3.1 哪些错误可以重试

错误语义可重试条件
UNAVAILABLE连接失败/实例不可达是有剩余预算;最好换实例
DEADLINE_EXCEEDED本地或上游超时视情况只在还有预算且请求幂等时
RESOURCE_EXHAUSTED限流/配额耗尽是遵守 Retry-After
ABORTED并发冲突/事务中止是业务可重放,且退避后重试
INTERNAL服务端内部错误否可能已提交,按 unknown 处理
INVALID_ARGUMENT参数错误否重试一万次也一样
PERMISSION_DENIED无权限否刷新凭证或提权
NOT_FOUND资源不存在否检查 ID
ALREADY_EXISTS唯一约束冲突否查已有记录,不要换键
CANCELLED调用方取消否除非是新逻辑请求

HTTP 状态码的映射在 1.10.3 已经给过,这里只强调一条:DEADLINE_EXCEEDED 是最容易被误判为可重试的错误。它的语义是「我没等到结果」,不是「服务端没执行」。只有在请求带幂等键、且剩余预算足够时才重试;否则应该进入 unknown 收敛。

2.3.2 指数退避与三种抖动

设第 ii 次重试(ii 从 0 开始)的基准等待为

ti=min⁡(cap, base×2i) t_i = \min(\text{cap},\ \text{base} \times 2^i)

无抖动时,所有失败客户端会在同一时刻重试,形成同步脉冲。三种常见抖动:

策略等待时间期望等待特点
无抖动tit_itit_i同步脉冲,最差
Full jitterU(0,ti)U(0, t_i)ti/2t_i/2打散最好,但可能立即重试
Equal jitterti/2+U(0,ti/2)t_i/2 + U(0, t_i/2)3ti/43t_i/4保留最小等待,打散稍弱
Decorrelatedmin⁡(cap,U(base,3×prev))\min(\text{cap}, U(\text{base}, 3 \times \text{prev}))取决于历史适应性强,难分析

Full jitter 的公式就是一行:

import random


def full_jitter(attempt: int, base: float = 0.05, cap: float = 2.0) -> float:
    """attempt 从 0 开始;返回值单位是秒。"""
    ceiling = min(cap, base * (2 ** attempt))
    return random.uniform(0, ceiling)

取 base=50ms、cap=2s,前七次重试的等待范围:

重试序号基准 tit_ifull jitter 范围期望等待
150 ms0–50 ms25 ms
2100 ms0–100 ms50 ms
3200 ms0–200 ms100 ms
4400 ms0–400 ms200 ms
5800 ms0–800 ms400 ms
61600 ms0–1600 ms800 ms

Full jitter 有一个副作用:它有概率抽到接近 0 的等待,等于「立即重试」。在服务端已经过载时,这会增加瞬时压力。缓解办法是给一个最小等待(例如 max(10ms, ...)),或者用 equal jitter。没有一种抖动对所有场景最优;关键是不要用无抖动。

2.3.3 重试预算:限制放大倍数,而不是次数

「最多重试 3 次」限制的是单个客户端,不是系统总量。1 万个客户端各重试 3 次,就是 3 万个额外请求。重试预算(retry budget)限制的是重试请求占原始请求的比例:

class RetryBudget:
    """每个原始请求存入 ratio 个 token,每次重试消耗 1 个。

    稳态下重试比例不超过 ratio;burst 允许冷启动时先借一些 token。
    """

    def __init__(self, ratio: float = 0.1, burst: int = 20):
        self.ratio = ratio
        self.burst = float(burst)
        self.tokens = float(burst)

    def on_request(self) -> None:
        self.tokens = min(self.burst, self.tokens + self.ratio)

    def allow_retry(self) -> bool:
        if self.tokens >= 1.0:
            self.tokens -= 1.0
            return True
        return False

参数怎么定:

参数建议起点依据调错的后果
ratio5%–10%正常重试率通常 < 1%太高:放大;太低:偶发失败也失败
burst10–50覆盖冷启动/短抖动太小:开局就耗尽
max_attempts2–3与 deadline 一起算太大:尾部尝试注定超时
base20–100 ms约 1 个 RTT太小:同步脉冲
cap1–2 s小于 deadline太大:退避吃掉全部预算
重试预算:次数不是策略,放大倍数才是 单层 3 次重试乘以两层就是 16 次尝试;带预算与抖动的重试把放大控制在可接受范围。 重试预算:次数不是策略,放大倍数才是 0s 1s 3s 7s 15s 时间轴(指数退避 + 抖动,单位 s) 1 首次 2 重试1 3 重试2 4 重试3 最坏放大 4 次尝试/层 × 2 层 = 16 若每层都重试 3 次 原始 1 个请求 → 最多 16 个 带预算 重试 ≤ 原始请求的 10% 预算耗尽后返回 503 + Retry-After 退避 + 抖动把重试摊开;预算限制总量。 没有预算,重试会把下游的慢变成雪崩。
读图:指数退避只能减少同一时刻的并发,不能消除总放大。真正要限制的是「重试请求 / 原始请求」的比值;预算耗尽后应快速失败,而不是继续排队。

Envoy 的 retry_budget 用 budget_percent(默认 20%)和 min_retry_concurrency(默认 3)实现同一思路:预算按「活跃请求数 × 百分比」计算,低于最小值时至少允许 3 个并发重试。这说明预算不是「每秒固定次数」,而是与当前流量成比例。

重试预算要和熔断配合。预算只限制「重试多少」,不阻止「原始请求继续打进来」;如果下游已经不可用,正确动作是熔断一段时间,让原始请求也快速失败,而不是让预算把重试压到 0 后继续用原始流量压垮它。

2.3.4 对冲请求:用额外负载换尾延迟

对冲(hedged request)不是「失败后重试」,而是「还没失败就再发一份」:请求发出后等 p95p95,如果还没返回,就向另一个实例再发一份,谁先返回用谁,另一份取消。

维度普通重试对冲
触发条件失败/超时超过延迟阈值,尚未失败
额外负载失败请求数 × 重试次数约 pp 比例的正常请求翻倍
改善的指标错误率p99/p999 尾延迟
适用请求幂等写/读只读或幂等写;非幂等写禁用
主要风险重试风暴阈值估低导致大面积双发

对冲的代价可以算:如果阈值取 p95,约 5% 的请求会被对冲,总请求量增加 5%;如果阈值取 p50,一半请求翻倍,成本立刻不可接受。更隐蔽的是,p95 会随负载漂移:下游变慢时,p95 阈值也应该变,否则对冲比例会从 5% 涨到 50%。

对冲只适合没有副作用或副作用可去重的请求。对支付、下单这类写操作,第二份请求即使被取消,也可能在服务端已经执行;取消不是回滚。要用对冲,必须先有 2.4 的幂等键。

2.3.5 把重试、deadline、预算接在一起

import time


class RetryPolicy:
    def __init__(self, max_attempts=3, base=0.05, cap=2.0, budget=None):
        self.max_attempts = max_attempts
        self.base = base
        self.cap = cap
        self.budget = budget or RetryBudget()

    def execute(self, call, deadline):
        """call(attempt, deadline) 抛出的异常需带 retryable 属性。"""
        self.budget.on_request()          # 每个逻辑请求只充值一次
        for attempt in range(self.max_attempts):
            try:
                return call(attempt, deadline)
            except RpcError as exc:
                if not exc.retryable or attempt + 1 == self.max_attempts:
                    raise
                if not self.budget.allow_retry():
                    raise RetryBudgetExhausted() from exc
                delay = full_jitter(attempt, self.base, self.cap)
                # 重试不能延长 deadline:睡完再加一次最小调用时间仍不够,就放弃
                if deadline.remaining_ms() <= delay * 1000 + 20:
                    raise
                time.sleep(delay)
        raise AssertionError("unreachable")

这段代码有三个约束,缺一不可:

  1. 重试共享同一个 deadline,call 每次拿到的是同一个对象,不能新建;
  2. 睡之前检查剩余预算,否则会在 deadline 之后才发出注定失败的请求;
  3. 预算耗尽直接抛,不要「再试最后一次」。

RetryBudgetExhausted 应该映射成 503 + Retry-After,而不是 500。500 会让上游以为「服务端有 bug,可能值得重试」,503 才表达「现在别来」。

例 2.3.1 · 一次重试放大 16 倍的计算

客户端 SDK 对每个请求最多重试 3 次(共 4 次尝试),API 网关对上游失败也重试 3 次。两层都独立重试时,最坏情况下游收到 4×4=164 \times 4 = 16 个请求。如果下游每个请求发一张券,就产生 16 张券。

修复不是「把 3 改成 1」,而是:

  • 只保留一层重试(通常放在最接近用户、最了解业务语义的一层);
  • 另一层只做「快速失败 + 返回 Retry-After」;
  • 两层都保留时,内层预算必须显著小于外层,且外层要知道内层已经重试过(通过 attempt 或 x-retry-count 传递)。

2.4 幂等:键、指纹、去重表、状态机

重试要安全,必须让服务端能识别「这是同一个逻辑请求」。识别靠两样东西:键(identity)和指纹(content)。键回答「是不是同一个意图」,指纹回答「参数有没有变」。

2.4.1 键的粒度:一个用户意图,一个键

场景键的粒度生成时机复用范围
用户点击「支付」一次支付意图创建支付单时该支付单的所有重试/轮询
下单一个订单进入结算页时提交订单的所有尝试
发券一次发放任务创建任务时任务的所有重试
内部任务重放一个任务 ID + 执行序号任务入队时同一次执行的所有重试
读请求通常不需要——

键必须满足四个条件:

  1. 客户端生成:服务端自增 ID 在第二次请求时会变,无法作为身份;
  2. 在首次发送前生成并持久化:不能放在 retry 循环里,也不能只放内存;
  3. 在所有重试中复用:包括自动重试、手动重试、跨进程恢复后的重试;
  4. 粒度等于业务意图:太粗会把两个合法请求误判为重复,太细挡不住重复。

常见的错误生成方式:

写法问题
uuid4() 写在 retry 循环内每次尝试都是新键,等于没有键
user_id + timestamp同秒内碰撞、跨秒无法去重
hash(请求体)两个合法的相同请求(如两次充值 100 元)被误判为重复
服务端自增 ID第二次请求拿到新 ID
数据库主键写库前还没有主键,第一次超时时无法带上

推荐 client_id:seq(客户端本地单调序号)或 UUIDv7/ULID。前者便于审计和排序,后者不需要中心协调。无论哪种,都要把键写进业务记录,而不是只放在日志里。

2.4.2 指纹:同键不同参必须拒绝

键只保证「同一个键只处理一次」。如果客户端用 K1 先发 amount=100,再发 amount=1000,服务端直接返回第一次的结果,用户会以为第二次也生效了。正确做法是对参数做规范化后计算指纹,存 (key, fingerprint):

import hashlib
import json
from decimal import Decimal


def _default(obj):
    if isinstance(obj, Decimal):
        return format(obj, "f")        # 1.0 -> "1.0",但金额应统一为分
    raise TypeError(f"non-canonical type: {type(obj)!r}")


def canonical_json(args: dict) -> str:
    return json.dumps(args, sort_keys=True, separators=(",", ":"),
                      ensure_ascii=False, default=_default)


def fingerprint(method: str, args: dict) -> str:
    payload = method + "\n" + canonical_json(args)
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()

规范化要处理的边界:

边界处理方式不处理的后果
对象 key 顺序sort_keys=True同一请求不同指纹,误报 409
空白/缩进separators=(",", ":")同上
浮点数金额统一用整数分;禁止 float1 与 1.0 不同指纹
Unicode统一 NFC 归一化同形字符不同指纹
空值/缺省契约规定「省略 = 默认值」null 与缺省被当成不同请求
易变字段从指纹中排除(trace id、客户端时间)每次重试指纹都变,永远冲突

同键不同指纹时返回 409,并在响应里带上第一次指纹的前 8 位,方便排障。不要「以新参数覆盖旧结果」,也不要「返回旧结果但假装成功」——前者破坏幂等,后者制造静默错误。

2.4.3 去重表:唯一约束是最后一道闸门

应用层的「先查再插」在并发下一定会漏:两个请求同时查到「不存在」,然后都插入。唯一约束必须由存储层保证:

CREATE TABLE idempotency_key (
  scope         TEXT    NOT NULL,          -- 租户/接口,避免不同业务撞键
  key           TEXT    NOT NULL,
  fingerprint   TEXT    NOT NULL,
  status        TEXT    NOT NULL CHECK (status IN ('running','succeeded','failed')),
  lease_until   INTEGER NOT NULL,          -- 毫秒时间戳,防执行者崩溃后永久 running
  http_status   INTEGER,
  response_body TEXT,
  created_at    INTEGER NOT NULL,
  expires_at    INTEGER NOT NULL,
  PRIMARY KEY (scope, key)
);

CREATE INDEX idx_idem_expires ON idempotency_key (expires_at);

状态机只有三个状态,比第 1 章的请求状态机更窄,因为去重表只关心「结果是否可复用」:

        claim (INSERT)
              │
              ▼
        ┌──────────┐   handler ok   ┌───────────┐
        │ running  │ ─────────────▶ │ succeeded │
        └──────────┘                └───────────┘
              │ handler error
              ▼
        ┌──────────┐
        │ failed   │
        └──────────┘
              │ same key + retryable, lease expired
              └──────────────▶ running

succeeded 是终态:同键重试直接返回原响应,不再进入 running;只有 failed 或租约过期的 running 可以被重新 claim。

一个可用的 claim 流程(SQLite,isolation_level=None 即自动提交):

def claim(db, scope: str, key: str, fp: str, ttl_ms: int, lease_ms: int = 5000):
    """返回 (state, payload)。state ∈ claimed/replay/conflict/in_progress。"""
    now = int(time.time() * 1000)
    cur = db.execute(
        "INSERT INTO idempotency_key"
        "(scope,key,fingerprint,status,lease_until,created_at,expires_at) "
        "VALUES (?,?,?,'running',?,?,?) "
        "ON CONFLICT(scope,key) DO NOTHING",
        (scope, key, fp, now + lease_ms, now, now + ttl_ms))
    if cur.rowcount == 1:
        return "claimed", None

    row = db.execute(
        "SELECT fingerprint,status,lease_until,http_status,response_body "
        "FROM idempotency_key WHERE scope=? AND key=?", (scope, key)).fetchone()
    fp0, status, lease, code, body = row
    if fp0 != fp:
        return "conflict", fp0[:8]
    if status == "succeeded":
        return "replay", (code, body)
    if status == "running" and lease > now:
        return "in_progress", max(1, lease - now)
    # 租约过期或上次失败:条件更新,只有一个请求能接管
    cur = db.execute(
        "UPDATE idempotency_key SET status='running', lease_until=? "
        "WHERE scope=? AND key=? "
        "AND (status='failed' OR (status='running' AND lease_until<=?))",
        (now + lease_ms, scope, key, now))
    return ("claimed", None) if cur.rowcount == 1 else ("in_progress", lease_ms)

这段代码的关键是最后那条条件 UPDATE:两个请求同时发现租约过期时,只有一个能把 lease_until 改到未来,另一个的 rowcount 是 0。如果没有这个条件,两个执行者会同时跑 handler,去重表形同虚设。

lease_until 还解决「执行者崩溃后记录永远 running」的问题。租约过期后允许同键接管,但接管者必须假设「前任可能已经执行了下游副作用」;所以下游调用也必须带幂等键,不能只靠这张表。

2.4.4 TTL 与回收

去重记录不能永久保留(存储成本),也不能太早删除(重试还没结束)。TTL 至少覆盖:

Tttl≥Tclient retry window+Tasync callback+margin T_{\text{ttl}} \ge T_{\text{client retry window}} + T_{\text{async callback}} + \text{margin}

经验值:同步接口 24 小时,支付/退款 7 天,异步任务 30 天。回收任务必须跳过 running 且租约未过期的记录;删除 succeeded 记录前,要先确认没有客户端还在用这个键重试。

设计去重表时的四个细节
  1. 响应体要限长:把 10 MB 的响应存进去,去重表会比业务表还大;超过阈值只存摘要和查询指针。
  2. 不要把下游调用放在数据库事务里:长事务会持锁,连接池很快耗尽;先短事务 claim,提交后再执行。
  3. 状态更新要带条件:UPDATE ... WHERE status='running' AND lease_until=?,否则会覆盖别人刚写的结果。
  4. 键要加 scope:不同租户可能生成相同的键;(scope, key) 才是唯一键。

到这里,重试的「安全底座」齐了:deadline 控制总预算,退避和预算控制放大,幂等键和去重表控制重复。接下来把这三者放进一条失败时间线,看它们如何一起失效。

2.5 失败模式:超时 + 重试风暴

重试风暴不是「流量太大」,而是「系统用重试把一次变慢变成了持续过载」。它的形状固定:某个依赖变慢 → 超时率上升 → 客户端重试 → 总请求量上升 → 队列变长 → 延迟进一步上升 → 超时率更高。每一轮都在给正反馈加柴。

2.5.1 一条具体的时间线

假设一个服务有 500 个工作线程,正常时每次请求占用线程 100 ms(其中 60 ms 等下游),那么它的吞吐上限是:

μ=5000.1 s=5000 req/s \mu = \frac{500}{0.1\ \text{s}} = 5000\ \text{req/s}

平时流量 2000 QPS,利用率 40%,很健康。某个下游开始抖动,平均处理时间从 100 ms 涨到 600 ms,p99 到 1.5 s。吞吐上限立刻变成:

μ′=5000.6 s≈833 req/s \mu&#x27; = \frac{500}{0.6\ \text{s}} \approx 833\ \text{req/s}

注意这里 CPU 可能只用了 20%:线程都在等下游,不在算。容量不是 CPU 决定的,是「并发数 ÷ 延迟」决定的。

客户端超时设 1 s,重试 2 次,没有预算。下面三轮的数字是示意,但数量级符合真实系统:

轮次原始 QPS超时率重试 QPS总 QPS在途需求 L=λWL=\lambda W结果
1200020%40024001440排队 607,等待约 0.7 s
2200045%90029001740等待约 1.1 s,超时率再升
3200065%130033001980有效吞吐掉到 700 以下

关键在第三列和第五列:原始流量一直是 2000,没有涨;涨的是重试。系统并没有被更多用户访问,它是被自己对失败的补偿压垮的。

更糟的是,重试请求比原始请求更「贵」:它往往带着更短的剩余 deadline,服务端如果按 FIFO 排队,会在快到期时才执行,然后超时,再触发下一轮重试。一个注定超时的请求,越晚被处理,浪费越大。

2.5.2 正反馈的数学形状

把「总负载」记为 AA,原始负载 A0A_0,重试比例 rr,超时率是负载的函数 p(A)p(A),则下一轮的负载为:

An+1=A0(1+r⋅p(An)) A_{n+1} = A_0 \left(1 + r \cdot p(A_n)\right)

不动点存在不等于稳定。在不动点附近线性化,稳定条件是:

∣r⋅A0⋅p′(A∗)∣<1 \left| r \cdot A_0 \cdot p&#x27;(A^*) \right| &lt; 1

p′(A)p&#x27;(A) 是「负载每增加一点,超时率上升多少」。当系统接近饱和时,p′p&#x27; 会急剧变大,这个乘积很容易超过 1;此时任何小的扰动都会把系统推出不动点,进入发散。这就是为什么 70% 利用率时一次小抖动可能没事,90% 时同样的抖动会直接雪崩(第 1 章的排队算例给过这条曲线)。

一句话

重试把「错误率」变成了「流量」,而流量又变成「错误率」。只要这个环路的增益大于 1,系统就没有稳定点,只有「还活着」和「已经挂了」两种状态。

2.5.3 同步重试:连接在睡觉,线程也在睡觉

很多 SDK 的重试是同步的:sleep 期间仍握着连接和线程。假设连接池 50 条、每个请求重试 2 次、每次退避 1 s,单个客户端实例的吞吐上限约为 50/(1+2)≈1650 / (1 + 2) \approx 16 req/s。不是因为服务端只能处理 16 个,而是因为客户端把连接的时间花在「握着连接睡觉」。异步重试(把请求放回队列、释放连接、到点再取)能把这段时间还给你,但会增加实现复杂度和乱序风险。

一个折中是「重试不占连接,但占一个轻量 timer」:连接归还池子,退避结束后重新取连接、重新解析后端。这要求请求对象可重放(幂等键已持久化),也要求重试时不要复用可能已被污染的连接。

2.5.4 止损顺序:先关放大器,再谈扩容

事故现场的常见错误是「先扩容」。扩容慢(分钟级),而且如果瓶颈是下游或队列,扩容只会把更多请求送进同一个瓶颈。正确的顺序:

顺序动作目的观测信号
1关闭客户端自动重试,或把预算降到 1%切断正反馈重试 QPS 立刻下降
2入口限流/降载到当前容量让队列不再增长队列长度、排队时间下降
3返回 503 + Retry-After + 抖动让上游也降速429/503 比例上升,goodput 稳定
4熔断最慢的依赖,走降级释放线程线程池等待下降
5扩容/恢复下游恢复容量延迟与错误率同步下降
6逐步恢复重试避免二次冲击重试比例 < 5%

第 1 步经常被跳过,因为「重试是客户端的行为,服务端改不了」。但大多数公司同时拥有客户端和服务端;即使只有服务端,也可以通过 Retry-After、GOAWAY、关闭 keep-alive 来迫使客户端退避。重试策略是系统级配置,不是某个 SDK 的私有选项。

第 11 章会把这条时间线扩展成容量规划与故障演练;本节只需要记住一个判据:当重试 QPS 与原始 QPS 的比值超过 20%,且错误率还在上升时,系统已经进入风暴,不要再加任何重试。

2.6 背压:并发上限、队列与 Little’s law

背压(backpressure)是「让上游知道下游满了,并主动减速」。它和限流的区别是:限流按配额拒绝,背压按当前处理能力拒绝;限流保护的是「别人」,背压保护的是「自己」。

2.6.1 Little’s law:并发、吞吐、延迟只能三选二

Little’s law 说:稳定系统里,平均在途请求数等于到达率乘以平均停留时间:

L=λW L = \lambda W

换一种写法,如果你把并发上限固定为 CC,那么吞吐和延迟的关系是:

λmax⁡=CW \lambda_{\max} = \frac{C}{W}

并发上限 C平均延迟 W吞吐上限说明
5020 ms2500 req/s健康
5050 ms1000 req/s正常
50200 ms250 req/s下游变慢,容量塌缩
501 s50 req/s已经过载,队列无限增长

这张表解释了一个常见困惑:「CPU 只有 30%,为什么 QPS 上不去?」因为线程都在等下游,WW 变大了,C/WC/W 就小了。并发上限是吞吐的天花板,延迟是天花板的高度。

反过来,用 Little’s law 定并发上限:

C=λtarget×Wp99×1.2 C = \lambda_{\text{target}} \times W_{p99} \times 1.2

目标 1000 QPS、p99 延迟 250 ms,则 C≈300C \approx 300。注意用 p99 而不是 p50:p50 只覆盖一半请求,按它算出来的并发会在高峰期排队。

2.6.2 队列该有多长

队列的作用是吸收短时突发,不是吸收持续过载。持续过载时,队列只会把「快速失败」变成「慢速失败」,还额外占用内存和 deadline。

一个可用的估算:

Q≥(λpeak−μ)×Tburst Q \ge (\lambda_{\text{peak}} - \mu) \times T_{\text{burst}}

峰值 1200 QPS、服务能力 1000 QPS、突发持续 2 s,则至少需要 400 个槽位。超过这个长度的队列,在突发结束后仍然需要 2 s 才能排空;如果突发持续 10 s,队列需要 2000 个槽位,此时更合理的动作是限流,而不是加队列。

队列还有三个隐性成本:

  • 内存:每个排队请求持有请求体、上下文、deadline 对象;10 万个排队请求可能吃掉几 GB。
  • 过期工作:排队时 deadline 已经过了的请求,出队后应该直接丢弃,而不是继续执行。
  • 公平性:FIFO 会让一个慢客户端的大请求堵住后面所有小请求;按优先级/租户分队列更稳。

2.6.3 一个带 deadline 的并发闸门

import threading


class Overloaded(Exception):
    pass


class Gate:
    """有界并发 + 有界等待。等待时间不得超过调用方剩余预算。"""

    def __init__(self, limit: int):
        self._sem = threading.BoundedSemaphore(limit)
        self._lock = threading.Lock()
        self.inflight = 0
        self.rejected = 0

    def enter(self, max_wait_s: float) -> None:
        if not self._sem.acquire(timeout=max(0.0, max_wait_s)):
            with self._lock:
                self.rejected += 1
            raise Overloaded("concurrency limit")
        with self._lock:
            self.inflight += 1

    def leave(self) -> None:
        with self._lock:
            self.inflight -= 1
        self._sem.release()

调用方传入的 max_wait_s 不是固定值,而是「剩余预算减去一次最小处理时间」。如果剩余预算只有 30 ms,而处理至少需要 50 ms,max_wait_s 为负,enter 立即失败——这是正确的:不要让一个注定超时的请求进入队列。

拒绝时返回什么,决定了上游会不会立刻再打回来:

拒绝原因返回带什么上游应该做什么
自身并发满503Retry-After: 1 + 抖动退避,不要立即重试
租户配额耗尽429Retry-After + 配额重置时间降速或换租户
请求已过期504 或 503不建议重试放弃,走 unknown 收敛
依赖熔断503Retry-After降级/读缓存
队列不是背压

把队列从 1000 加到 10000,不会增加吞吐,只会增加延迟和内存。真正的背压是「拒绝」,而且要在请求进入昂贵资源之前拒绝:在网关拒绝,比在数据库连接池前拒绝便宜一个数量级;在连接池前拒绝,比让请求超时后再重试便宜两个数量级。

2.7 限流与过载:令牌桶、429/503、Retry-After

限流回答「你能用多少」,背压回答「我现在还能处理多少」。两者经常一起出现,但语义不同:限流是配额(quota),背压是容量(capacity)。配额可以买,容量不能。

2.7.1 令牌桶:允许突发,限制均值

令牌桶有两个参数:速率 rr(每秒补充多少 token)和桶容量 BB(最多攒多少)。请求消耗 token,token 不够就拒绝或排队。

import time


class TokenBucket:
    def __init__(self, rate_per_s: float, burst: int):
        self.rate = float(rate_per_s)
        self.burst = float(burst)
        self.tokens = float(burst)
        self.updated = time.monotonic()

    def _refill(self) -> None:
        now = time.monotonic()
        self.tokens = min(self.burst, self.tokens + (now - self.updated) * self.rate)
        self.updated = now

    def allow(self, cost: float = 1.0) -> bool:
        self._refill()
        if self.tokens >= cost:
            self.tokens -= cost
            return True
        return False

    def retry_after_ms(self, cost: float = 1.0) -> int:
        self._refill()
        missing = max(0.0, cost - self.tokens)
        return int(missing / self.rate * 1000) + 1

参数选择:

  • rate 等于长期允许的均值,例如每租户 100 req/s;
  • burst 决定能吸收多长的突发。burst=200 意味着空闲 2 s 后可以一次性打 200 个;
  • burst 不是越大越好:它把「瞬时并发」放大,下游必须能接住这个瞬时值。

常见限流算法对比:

算法突发处理精度内存分布式实现
固定窗口边界处可双倍低O(1)简单,但边界不公平
滑动窗口日志精确高O(请求数)贵,适合低频
滑动窗口计数近似中O(窗口数)Redis 常用
令牌桶允许 BB 突发高O(1)Redis Lua 原子化
漏桶不允许突发高O(1)适合整形输出

固定窗口有一个经典边界问题:限制「每分钟 100 次」,客户端在 12:00:59 打 100 次、12:01:00 再打 100 次,两秒内实际通过了 200 次。对下游来说,这和没有限流差不多。

分布式令牌桶要把「读-算-写」做成原子操作,通常用 Redis Lua:脚本里读 tokens 和 ts,按 now-ts 补充,判断并扣减,最后一次写回。分两次 GET/SET 会在并发下超发。

2.7.2 429 与 503:谁该退避,退多久

状态码语义作用域Retry-After客户端动作
429配额/速率超限租户、API key、IP建议给退避到指定时间,不要立即重试
503服务过载/依赖不可用整个服务或实例建议给退避 + 切换实例/降级
504网关等上游超时单次请求不给按 unknown 处理,不要盲目重试

Retry-After 的值可以是秒数(Retry-After: 3)或 HTTP 日期(Retry-After: Wed, 21 Oct 2026 07:28:00 GMT)。服务端给秒数更简单,但要注意:

  1. 加抖动:如果 1 万个客户端都收到 Retry-After: 3,它们会在第 3 秒同时回来。客户端应该在 [0.8, 1.2] × Retry-After 内随机化。
  2. 不要给太小:Retry-After: 0 等于「立刻重试」,会加剧过载;至少给一个估计的恢复时间。
  3. 不要给太大:超过客户端 deadline 的 Retry-After 等于让客户端放弃;如果确实要这么久,应该返回 503 并让客户端走异步查询。

服务端如何区分「自己过载」和「依赖失败」:

信号自身过载依赖失败
本服务 CPU高(>80%)可能低(线程在等)
队列等待高且持续上升可能正常
下游延迟/错误正常高
本服务 handler 耗时高(含排队)高(含等待下游)
线程池满,任务排队大量线程处于 waiting
正确动作限流/降载/扩容自己熔断/降级/切换下游

这张表决定返回 429 还是 503:如果是自己过载,返回 503 并限流;如果是租户超额,返回 429;如果是依赖失败,返回 503,但要在响应体里说明是依赖不可用(可选,避免泄露内部拓扑),并考虑降级读缓存。

例 2.7.1 · 一个租户打满全局令牌桶

某 API 只有一个全局令牌桶,速率 1000 req/s。一个大租户用脚本重试,瞬间打到 900 req/s,其他租户共享剩下的 100 req/s,全部超时。

修复分两层:

  • 每个租户一个桶(速率按套餐分配),再加一个全局桶(保护总容量);
  • 请求先过租户桶,再过全局桶;任一失败都返回 429,并在响应头带 X-RateLimit-Scope: tenant|global。

只加全局桶会让「一个租户拖垮所有人」;只加租户桶会让「所有租户同时打满」拖垮服务端。两层缺一不可。

限流还会和重试相互作用:客户端收到 429 后如果立即重试,等于把限流器当成了随机数生成器。429 的语义是「现在别来」,不是「换个连接再来」;重试必须遵守 Retry-After,并且消耗重试预算。

2.8 观测:客户端和服务端各看什么

RPC 的观测难点在于:同一个逻辑请求在客户端和服务端会产生不同数量的记录。客户端看到 1 次逻辑调用、3 次物理尝试;服务端看到 3 条请求日志,其中 2 条命中去重表。如果不把 attempt 和 idempotency_key 带进指标,两边永远对不上账。

2.8.1 两边各自的黄金指标

侧指标类型回答什么问题
客户端rpc_client_attempt_duration_secondshistogram每次尝试慢在哪
客户端rpc_client_retries_total{reason,attempt}counter重试多少、为什么
客户端rpc_client_deadline_remaining_mshistogram发出时还剩多少预算
客户端pool_wait_seconds / pool_in_usehistogram/gauge连接池是不是瓶颈
服务端rpc_server_requests_total{method,code}counter收到多少、错误分布
服务端rpc_server_queue_wait_secondshistogram排队多久
服务端idempotency_hits_total{result}counter去重命中/冲突
服务端rpc_server_rejected_total{reason}counter限流/过载拒绝

客户端和服务端都要打 method、attempt、outcome 三个标签。标签基数是成本:method 有限,attempt 最多 5 个值,outcome 建议收敛到 5 类(ok / retryable / fatal / timeout / rejected)。不要把 idempotency_key 或 user_id 放进指标标签,基数会爆炸;它们属于日志和 trace。

2.8.2 一条命令:看连接复用与总耗时

for i in 1 2 3 4 5; do
  curl -sS -o /dev/null \
    -w "i=$i code=%{http_code} total=%{time_total} ttfb=%{time_starttransfer} connects=%{num_connects}\n" \
    -H "Idempotency-Key: obs-pay-$i" \
    -H 'Content-Type: application/json' \
    -d '{"order_id":"o-8812","amount_cents":1999}' \
    http://127.0.0.1:8081/v1/payments
done

一次本机测试的输出:

i=1 code=201 total=0.018213 ttfb=0.017904 connects=1
i=2 code=201 total=0.006911 ttfb=0.006102 connects=0
i=3 code=201 total=0.005442 ttfb=0.004910 connects=0

怎么读:

现象含义下一步
第 1 次 connects=1,之后 connects=0连接池生效,复用了连接正常
每次都 connects=1连接池没生效/被禁用/服务端发 Connection: close查客户端配置与服务端 keep-alive
connects=0 但 total 逐次上升连接复用但排队(流控/池竞争)看 pool_wait、服务端 inflight
ttfb 接近 total时间花在服务端处理/排队看服务端 queue wait 与下游 span
total 远大于 ttfb时间花在响应传输/客户端读取看响应体大小、带宽、客户端解析

num_connects 是 curl 的字段,表示本次传输新建了几条连接;0 表示完全复用。它和 time_connect 不同:time_connect 只在新建连接时非零,复用连接时接近 0,容易让人误以为「网络很快」。

2.8.3 PromQL:重试比例与按 attempt 的 p99

重试比例必须用「重试次数 / 原始请求数」,不是「重试次数 / 总尝试次数」——后者在重试风暴时反而下降,因为分母被重试撑大了:

sum(rate(rpc_client_retries_total{service="payment"}[5m]))
/
sum(rate(rpc_client_requests_total{service="payment"}[5m]))

输出:

0.184

18.4% 已经超过 10% 的警戒线。再看按 attempt 分组的 p99:

histogram_quantile(0.99,
  sum by (le, attempt) (
    rate(rpc_client_attempt_duration_seconds_bucket{service="payment"}[5m])
  )
)

输出:

{attempt="1"} 0.212
{attempt="2"} 1.024
{attempt="3"} 1.481

读法:第 1 次尝试 p99 是 212 ms,第 2 次是 1.02 s,第 3 次是 1.48 s。重试没有变快,反而一次比一次慢,说明重试打到了已经过载的实例或更深的队列;此时继续重试只会延长故障。正常系统里,第 2、3 次尝试的 p99 应该接近第 1 次(甚至更低,因为换到了健康实例)。

2.8.4 日志:把同一个逻辑请求串起来

一条可用的尝试日志:

{"ts":"2026-10-19T09:41:02.114Z","event":"rpc_attempt","method":"Payment/Charge",
 "idempotency_key":"pay-o-8812","attempt":2,"outcome":"DEADLINE_EXCEEDED",
 "deadline_remaining_ms":18,"peer":"10.2.7.11:8443","trace_id":"d7c1e2..."}

四个字段不能省:idempotency_key(把多次尝试归并)、attempt(区分第几次)、deadline_remaining_ms(判断是不是「发出去就注定超时」)、peer(判断是不是总打到同一个坏实例)。

用 jq 按逻辑请求聚合:

jq -r 'select(.event=="rpc_attempt") | [.idempotency_key,.attempt,.outcome] | @tsv' /tmp/payment.log \
  | sort | uniq -c | sort -rn | head

输出:

  12  pay-o-8812  1  DEADLINE_EXCEEDED
   3  pay-o-8812  2  DEADLINE_EXCEEDED
   1  pay-o-8812  3  OK

这个键一共留下 16 条尝试记录,其中 12 条是 attempt=1:说明 12 个并发请求共用了同一个键,客户端在并发重试或去重逻辑失效。如果同一个 order_id 出现两个不同的 idempotency_key,那就是 2.10 的重复扣款前兆。

读什么先看「重试比例」和「按 attempt 的 p99」,再看单条慢 trace;不要先看总平均延迟。
正常重试比例 < 5%,attempt≥2 的 p99 不高于 attempt=1 的 1.5 倍,pool_wait p99 < 5 ms。
异常重试比例 > 20%,或 attempt 越大 p99 越高,或同一个键出现多个不同 peer 且都超时。

2.9 真实案例:S3 2017-02-28 的重启与重试

2017 年 2 月 28 日,AWS 位于北弗吉尼亚(us-east-1)的 S3 发生了一次持续约 4 小时的服务中断。AWS 官方的复盘写得克制,但事实链很清楚:

时间(当地时间)事件
09:37S3 计费团队的一名授权人员执行一条移除服务器的命令,输入参数写错
随后命令移除了比预期更多的服务器,波及索引(index)与放置(placement)两个核心子系统
随后两个子系统必须整体重启;数据量大、校验耗时长,重启比预期慢
恢复期缓存需要重建,大量请求失败并被客户端重试,进一步加重负载
约 4 小时后服务恢复

公开复盘的关键句是:一条输入错误的命令导致大量 S3 子系统被移除;重启后缓存重建、请求重试放大;影响约 4 小时。官方链接:Summary of the Amazon S3 Service Disruption。

2.9.1 为什么把它放进 RPC 一章

这场事故的根因是运维工具的参数校验与爆炸半径,不是 RPC。但它对本章有两个直接教训:

第一,重试决定了恢复的斜率。 故障期间,客户端看到的是超时和 5xx,SDK 按默认策略重试。重试没有造成最初的故障,却让恢复期的请求量高于正常水平;而恢复期的服务端恰恰最脆弱——缓存是冷的,索引在重建,能处理的并发远低于稳态。故障前的容量规划不能直接用于故障恢复期。

第二,恢复期需要主动的背压,而不是被动等待。 如果服务端在恢复期继续按稳态接收请求,队列会被填满,冷缓存下的每次未命中都要回源,回源又加重索引负担。正确动作是:入口限流到「当前能处理的量」,返回 503 + Retry-After,并临时关闭客户端的自动重试。AWS 在恢复期间采取了一系列限制性动作,本质都是「把流量降到系统能恢复的水平」。

把这两点翻译成本章的机制:

机制在事故中的角色如果缺失
deadline 传播让客户端尽早放弃,减少无效等待连接被长时间占用,恢复更慢
重试预算限制恢复期的额外流量重试量随错误率线性上升
背压/限流把入口流量压到冷缓存能承受的水平队列堆积,缓存无法预热
幂等重试到达时不产生额外副作用恢复后出现重复对象/重复计费
事实边界

本节只引用 AWS 公开复盘中的事实:时间、命令参数错误、索引与放置子系统、整体重启、缓存重建、重试放大、约 4 小时影响。具体命令、内部拓扑和恢复步骤不复述;也不把「重试」写成事故根因——根因是破坏性工具缺少边界,重试是放大器。

2.10 重试导致重复副作用:支付请求的两个键

现在看一个比发券更贵的例子。用户在一次结算里只点了一次「支付」,最后却被扣了两笔。系统里没有崩溃,数据库也没有丢数据;问题出在两个键上:请求键(request key)与效果键(effect key)。

2.10.1 时间线

时间客户端支付服务PSP(收单方)结果
t0创建支付意图 pi_1,生成键 K1——用户只点了一次
t1发送 K1插入 payment_intent(pi_1),调用 PSP——
t2等待响应等 PSP扣款成功,响应慢钱已扣
t32 s 超时,重试;重试代码重新生成了 K2收到 K2,按请求键去重,未命中—第二条 intent
t4等待再次调用 PSP用 K2 扣款成功第二次扣款
t5收到 t3 的响应,显示成功两条 intent 都是 succeeded两笔 charge用户被扣两次

第二天对账时,财务发现同一个 order_id 下有两笔金额相同的扣款,psp_charge_id 不同,intent_id 也不同。

2.10.2 根因不是「重试」,是键的生命周期错了

这段重试代码是常见的写法:

def pay(order_id: str, amount_cents: int) -> dict:
    for attempt in range(3):
        key = str(uuid.uuid4())          # BUG:每次尝试都生成新键
        try:
            return client.charge(order_id, amount_cents,
                                 metadata={"idempotency-key": key})
        except TimeoutError:
            continue
    raise PaymentFailed(order_id)

键在循环内部生成,意味着每次物理尝试都是一个新的逻辑请求。服务端的去重表按 (scope, key) 建唯一约束,K1 和 K2 是两个不同的键,两条记录都合法。去重表没有失效,它只是从未收到同一个键。

修复的第一步是把键的生命周期提到「支付意图」上,并持久化:

def pay(intent: PaymentIntent) -> dict:
    # intent.request_key 在创建支付意图时生成并写库,重试只读不写
    for attempt in range(3):
        try:
            return client.charge(
                intent.order_id, intent.amount_cents,
                metadata={"idempotency-key": intent.request_key,
                          "attempt": str(attempt)})
        except TimeoutError:
            continue
    raise PaymentFailed(intent.intent_id)

但这还不够。因为「请求键」只保护支付服务这一层;如果支付服务自己重试、或者消息队列重投,PSP 仍可能收到两次扣款。所以还需要效果键(effect key):把业务意图的 ID 作为下游的幂等键。

键生成方作用域保护的是存储位置
请求键 request_key客户端支付服务的一次调用传输层重试支付意图行
效果键 effect_key支付服务PSP 的一次扣款副作用本身支付意图行 + PSP metadata
业务键 (merchant_id, order_id)业务一个订单用户重复点击/多入口唯一约束

三个键的粒度不同,缺一层就有一类重复能穿过去:

  • 只有请求键:支付服务重试下游时会重复扣款;
  • 只有效果键:客户端换键重试会创建两条 intent,虽然 PSP 只扣一次,但账目上多一条;
  • 只有业务键:同一订单的合法多次支付(例如补款)无法表达。

2.10.3 数据库约束:最后一道闸门

CREATE TABLE payment_intent (
  intent_id     TEXT PRIMARY KEY,
  merchant_id   TEXT    NOT NULL,
  order_id      TEXT    NOT NULL,
  amount_cents  INTEGER NOT NULL,
  request_key   TEXT    NOT NULL,
  fingerprint   TEXT    NOT NULL,
  effect_key    TEXT    NOT NULL,
  status        TEXT    NOT NULL CHECK (status IN ('running','succeeded','failed')),
  psp_charge_id TEXT,
  created_at    INTEGER NOT NULL,
  UNIQUE (merchant_id, order_id),
  UNIQUE (merchant_id, request_key)
);

CREATE UNIQUE INDEX idx_effect_key ON payment_intent (merchant_id, effect_key);

UNIQUE (merchant_id, order_id) 挡住「同一订单创建两个 intent」;UNIQUE (merchant_id, request_key) 挡住「同键并发插入」;effect_key 的唯一索引保证一个意图只能对应一个效果。三层约束里,业务键最容易被忽略,因为它看起来「不属于 RPC」;但重复扣款的最终表现,往往就是业务表里多了一条合法记录。

2.10.4 对账与修复

事故已经发生后的处理顺序:

  1. 按 order_id 聚合:找出同一订单下 status='succeeded' 且金额相同的多条 intent;
  2. 核对 PSP:用 psp_charge_id 拉取收单方流水,确认是否真的多扣;
  3. 退款:用独立的 refund_id 作为退款幂等键,不要用原 charge_id 直接重试退款;
  4. 修数据:保留两条 intent 作为审计记录,把其中一条标记为 duplicate,不要物理删除;
  5. 补约束:上线 UNIQUE (merchant_id, order_id) 前,先清理历史重复,否则建索引会失败。

修复后的回归测试要覆盖三个场景:同一请求键重试(应返回原结果)、同一订单换键重试(应 409 或返回原 intent)、PSP 超时后重试(PSP 侧应只扣一次)。第三个场景必须用 PSP 的沙箱验证,不能只测自己的服务。

例 2.10.1 · 重试导致的重复副作用:两个键的两条扣款

某支付服务在重试循环内生成 Idempotency-Key,服务端按该键去重。一次网络抖动导致客户端重试一次,服务端收到两个不同的键,创建了两条 payment_intent,PSP 扣了两次。对账发现时已过 26 小时,退款还产生了额外的手续费损失。

修复:请求键在创建 intent 时生成并写库;服务端对 (merchant_id, order_id) 加唯一约束;调用 PSP 时用 intent_id 作为效果键。三层都上线后,同样的故障注入只产生一条 intent、一笔扣款。

这两个键的区分,是本章最重要的工程结论之一:请求键解决「同一次调用被重发」,效果键解决「同一个副作用被执行两次」。前者属于 RPC 层,后者属于业务/存储层;只在 RPC 层做幂等,挡不住业务层的重放。

2.11 一份 RPC 契约检查表

新增一个 RPC 接口时,把下面 15 个问题答完再写代码。答不上来的,不是「以后再补」,而是「现在就没想清楚」。

#问题达标线写在哪
1这个接口有副作用吗?有副作用必须有幂等键接口文档
2逻辑请求的身份是什么?客户端生成、全局唯一、可持久化请求头/体
3幂等键的粒度是什么?等于一次业务意图,而不是一次 HTTP 请求契约
4键在什么时候生成、存在哪?首次发送前,写入业务行客户端/存储
5同键不同参怎么办?409,并返回首次指纹摘要错误码表
6端到端 deadline 是多少?有数字,且按 hop 递减契约
7每跳留多少余量?≥ 2×RTT(p99) + 处理时间客户端配置
8哪些错误可重试?有明确列表,默认不重试错误码表
9退避、抖动、上限?full jitter,base/cap 有依据客户端配置
10重试预算多少?5%–10%,耗尽返回 503客户端/网关
11超时后是 unknown 还是 failed?明确区分,不混用 500错误码表
12服务端如何去重?唯一约束 + 条件更新,不靠先查后插存储设计
13重复请求返回什么?原结果 / 202+查询 / 409,三选一响应契约
14过载时返回什么?429/503 + Retry-After + 抖动响应契约
15观测字段有哪些?key、attempt、deadline、peer、outcome日志/指标

配套的默认参数(可按业务调整,但不能没有):

四件套默认值上调条件下调条件
超时端到端 1–3 s;单跳 ≤ 剩余预算业务确实长耗时,且有异步查询用户交互路径
重试最多 2 次,full jitter,base 50 ms依赖偶发瞬时失败依赖已过载
幂等键 + 指纹 + 唯一约束,TTL 24 h支付/退款 7 天纯读接口可不用
背压并发上限 = 目标 QPS × p99 × 1.2有突发流量且下游能吸收下游已饱和

这张表不能替代设计评审,但它能挡住最常见的五类事故:键在循环里生成、deadline 每跳重置、重试无预算、同键不同参静默覆盖、过载时返回 500。

2.12 本章陷阱清单

12 条高频错误
  1. 把 timeout 当 deadline:每跳重置成固定超时,端到端变成 nTnT;必须传剩余预算并逐跳扣减。
  2. 重试时重新生成幂等键:键在 retry 循环里生成,等于每次都是新请求;键必须在首次发送前生成并持久化。
  3. 用请求体哈希当幂等键:两次合法的相同请求(如两次充值 100 元)会被误判为重复;哈希只能当指纹,不能当身份。
  4. 只做客户端幂等:服务端没有唯一约束,并发插入仍会产生两条记录;唯一约束必须在存储层。
  5. 同键不同参返回旧结果:用户以为新参数生效了,实际没有;必须 409。
  6. 重试没有预算:单客户端 3 次,乘以百万客户端就是三百万次;预算按比例限制,耗尽返回 503。
  7. 指数退避不加抖动:所有客户端在同一时刻重试,形成同步脉冲;至少用 full jitter。
  8. 重试不可重试的错误:400/401/403/404 重试一万次也不会成功,只会浪费连接和配额。
  9. 重试延长 deadline:每次尝试都拿新预算,最后一次尝试注定在客户端放弃后执行;所有尝试共享一个 deadline。
  10. 无限堆队列:队列只吸收短时突发;持续过载时加队列只会把快速失败变成慢速失败。
  11. 把连接池当并发上限:池 50 条、每条 1 个在途请求,等价于并发 50;下游变慢时吞吐按 C/WC/W 塌缩。
  12. 忽略 Retry-After:它是服务端给出的最小等待时间;忽略它会让过载持续,重试变成攻击。

2.13 本章出口检查

检查项达标线没达标去哪
说出 stub 做的五件事能区分类型、序列化、metadata、连接、错误映射2.1
解释 IDL 的边界能说出它不定义幂等/超时/重试2.1
写 deadline 传播能写出 child = parent - queue - margin,并解释 grpc-timeout 的相对预算与 8 位限制2.2
实现 full jitter能写出 U(0, min(cap, base*2^i))2.3
设重试预算能给出 ratio/burst/max_attempts 的依据2.3
设计幂等键能说清粒度、生成方、持久化、TTL2.4
设计去重表有唯一约束、条件更新、指纹冲突处理2.4
用 Little’s law 算并发给定 QPS 与 p99 能算出 C2.6
区分 429/503能说出作用域与 Retry-After 语义2.7
用 PromQL 看重试比例能写出重试/原始请求的比值查询2.8
复述 S3 教训能说出重试放大恢复期负载2.9
解释两个键能区分请求键与效果键的保护范围2.10
即时自测 · 2

Q1 客户端总预算 1200 ms。网关排队 30 ms,留给回程的余量 20 ms;服务 A 排队 80 ms,余量 20 ms。服务 A 转发给服务 B 时,grpc-timeout 应填多少?

Q2 attempt=4(第 5 次重试),base=100 ms,cap=2 s。full jitter 的等待范围与期望值是多少?

Q3 某服务原始 5000 QPS,重试预算 ratio=8%、burst=50。若 30% 请求失败且都可重试,预算能放行多少重试?会发生什么?

Q4 目标 800 QPS,p99 延迟 300 ms,安全系数 1.2。按 Little's law,并发上限应设为多少?

Q5 支付请求的请求键和效果键分别保护什么?只做其中一层会漏掉哪类重复?

答案

A1 1200 − 30 − 20 = 1150 ms 到达 A;A 再扣 80 + 20,剩 1050 ms,填 1050m(若只能填整秒,填 1S,并接受精度损失)。

A2 基准 100×24=1600100 \times 2^4 = 1600 ms,未到 cap;范围 0–1600 ms,期望 800 ms。若 cap=2 s,第 5 次基准 3200 ms 被截到 2000 ms。

A3 稳态预算约 5000×8%=4005000 \times 8\% = 400 次/秒,加上 burst 50 的初始额度;30% 失败意味着期望 1500 次重试,预算只能放行约 400 次,其余返回 503。正确动作是降低原始流量或修复依赖,而不是提高 ratio。

A4 C=800×0.3×1.2=288C = 800 \times 0.3 \times 1.2 = 288。用 p99 而不是 p50;若延迟涨到 1 s,同样并发只能支撑约 288 QPS。

A5 请求键保护「同一次逻辑调用被重发」,挡住传输层重试;效果键保护「同一个副作用被执行两次」,挡住服务端/队列/PSP 重放。只做请求键,支付服务重试下游会重复扣款;只做效果键,换键重试会多一条 intent 记录;还需要业务键挡住用户重复点击。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 5 章 运输层、第 6 章 应用层2.1、2.2背景只借用「请求经过网络分层」的视角;TCP 细节不重写
张彦飞《深入理解 Linux 网络》第 4–7 章(TCP、socket、连接)2.1、2.2、2.6背景连接复用与池耗尽只引用,不展开内核路径
《Python 网络编程》socket/HTTP 客户端、并发服务器2.1、2.3、2.6精选借用工程风格;deadline、退避、令牌桶代码为本书重写
《Linux 系统与内核》第 4 篇4.4–4.7(TCP/HTTP/DNS/TLS)2.1、2.2、2.8交叉引用明确不重复 TCP 握手、HTTP/2 帧、epoll、DNS/TLS
gRPC 文档与 Envoy 文档grpc-timeout、retry budget、Retry-After2.2–2.4、2.7引用只引用语义与参数名,不复制文档长段
本书自编 + AWS 公开复盘S3 2017-02-282.9完整只引用公开事实,不复制原文

下一站:知道了「一次调用怎么超时、怎么重试、怎么去重」,下一步要回答「同一份数据放在多个副本上,谁来写、谁来读、延迟多久」。进入 3 复制:主从、多主、无主,看复制日志、故障切换与读己之写。