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)。中间隔着的东西,比大多数调用方想象的要多。把它们列清楚,才能知道哪些失败该由谁负责。
| 阶段 | 谁在做 | 具体动作 | 失败会长什么样 |
|---|---|---|---|
| 1 | stub | 把语言对象转成 IDL 消息 | 类型不匹配、必填缺失、超长 |
| 2 | 序列化器 | 编码成字节,必要时压缩 | CPU 尖峰、超出 max message size |
| 3 | stub/channel | 附加 metadata:deadline、trace、auth、幂等键 | 键丢失、header 被代理剥掉 |
| 4 | 名字解析/负载均衡 | 选一个后端地址 | 解析失败、选到不健康实例 |
| 5 | 连接池 | 取一条已有连接或新建 | 池耗尽、握手超时、GOAWAY |
| 6 | 传输层 | 分帧、写 socket、等响应 | 半包、连接重置、流控阻塞 |
| 7 | 服务端 | 解帧、反序列化、分发到 handler | 解码失败、线程池满、找不到方法 |
| 8 | handler | 执行业务、访问数据库/下游 | 业务异常、锁等待、下游超时 |
| 9 | 回程 | 序列化响应、写回、客户端解析 | 响应丢失、超时、版本不兼容 |
这张表和第 1 章的十二个 hop 是同一件事的两种切法:第 1 章按「网络位置」切,这张表按「软件层次」切。切法不同,用途也不同——前者用来定位延迟,后者用来定位责任。
2.1.1 stub 不是魔法,它只是把五件事藏起来了
生成代码里的 PaymentStub 通常做五件事,且只做五件事:
- 类型检查:把
ChargeRequest编译成一个有字段、有默认值的类; - 序列化:调用 IDL 运行时把对象编码成字节;
- 元数据拼装:把 deadline、trace context、认证 token、幂等键塞进 header;
- 连接管理:从 channel 里取连接、做负载均衡、处理重连;
- 错误映射:把 wire 上的状态码映射成语言里的异常类型。
它明确不做的事同样重要:
- 不保证「最多执行一次」;它连「执行了几次」都不知道;
- 不保证顺序;多路复用下,响应到达顺序与请求发送顺序无关;
- 不保证取消能传到服务端;
cancel()通常只是本地停止等待; - 不替你决定业务幂等键;metadata 里的键是调用方自己放的;
- 不替你选超时预算;
timeout=1.0是你写的,不是框架推荐的。
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% 的事故:
- 新字段一律可选,服务端给默认值;不要新增
required; - 枚举保留一个
UNSPECIFIED = 0,客户端遇到未知值不要抛异常; - 删除字段先标
deprecated,观察一个发布周期,再用reserved删除。
2.1.4 连接复用:省下的是握手,付出的是耦合
短连接的成本可以算出来。假设同城 RTT 为 0.5 ms、跨地域 RTT 为 40 ms:
| 建连方式 | 额外往返 | 同城成本 | 跨地域成本 |
|---|---|---|---|
| TCP 三次握手 | 1 RTT | 0.5 ms | 40 ms |
| TCP + TLS 1.3 | 2 RTT | 1 ms | 80 ms |
| TCP + TLS 1.2 | 3 RTT | 1.5 ms | 120 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
两个坑:
- 先校验再分配。如果实现写成
buf[4:4+n]再判断长度,一个伪造的 4 GB 长度前缀就能让进程 OOM。校验必须发生在分配之前。 - 半包与粘包是常态。一次
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 个 RTT;
- 调用方的处理时间:反序列化、写日志、唤醒等待者;
- 抖动:p99 与中位数的差,而不是中位数本身。
一个可用的公式:
其中 。同城 RTT 0.5 ms 时 margin 约 6 ms;跨地域 RTT 40 ms 时 margin 约 85 ms。如果算出来小于 0,正确动作是立刻失败,而不是给一个最小值继续调用——预算已经不够完成一次有意义的往返,继续调用只会制造一个注定被放弃的副作用。
设逻辑请求的端到端 deadline 为 ,路径上有 跳,第 跳的排队、处理、传输时间分别为 ,第 跳留给下游的预算为 。若每一跳都满足
且 ,则最坏情况下整条链在 之前结束;若某一跳把 重置为固定值 ,则端到端最坏时间可能达到 ,与 无关。
对第一段,把不等式从 累加到 ,得到 。每跳实际耗时不超过 ,所以总耗时不超过 。
对第二段,构造每跳都耗时接近 的执行:第 1 跳在 时把请求交给第 2 跳,第 2 跳重新获得 的预算,于是总时间约为 。这与 无关,因此重置预算会破坏端到端上限。
∎这个定理看起来是废话,但它的推论经常被忽略: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 只剩 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 指数退避与三种抖动
设第 次重试( 从 0 开始)的基准等待为
无抖动时,所有失败客户端会在同一时刻重试,形成同步脉冲。三种常见抖动:
| 策略 | 等待时间 | 期望等待 | 特点 |
|---|---|---|---|
| 无抖动 | 同步脉冲,最差 | ||
| Full jitter | 打散最好,但可能立即重试 | ||
| Equal jitter | 保留最小等待,打散稍弱 | ||
| Decorrelated | 取决于历史 | 适应性强,难分析 |
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,前七次重试的等待范围:
| 重试序号 | 基准 | full jitter 范围 | 期望等待 |
|---|---|---|---|
| 1 | 50 ms | 0–50 ms | 25 ms |
| 2 | 100 ms | 0–100 ms | 50 ms |
| 3 | 200 ms | 0–200 ms | 100 ms |
| 4 | 400 ms | 0–400 ms | 200 ms |
| 5 | 800 ms | 0–800 ms | 400 ms |
| 6 | 1600 ms | 0–1600 ms | 800 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
参数怎么定:
| 参数 | 建议起点 | 依据 | 调错的后果 |
|---|---|---|---|
ratio | 5%–10% | 正常重试率通常 < 1% | 太高:放大;太低:偶发失败也失败 |
burst | 10–50 | 覆盖冷启动/短抖动 | 太小:开局就耗尽 |
max_attempts | 2–3 | 与 deadline 一起算 | 太大:尾部尝试注定超时 |
base | 20–100 ms | 约 1 个 RTT | 太小:同步脉冲 |
cap | 1–2 s | 小于 deadline | 太大:退避吃掉全部预算 |
Envoy 的 retry_budget 用 budget_percent(默认 20%)和 min_retry_concurrency(默认 3)实现同一思路:预算按「活跃请求数 × 百分比」计算,低于最小值时至少允许 3 个并发重试。这说明预算不是「每秒固定次数」,而是与当前流量成比例。
重试预算要和熔断配合。预算只限制「重试多少」,不阻止「原始请求继续打进来」;如果下游已经不可用,正确动作是熔断一段时间,让原始请求也快速失败,而不是让预算把重试压到 0 后继续用原始流量压垮它。
2.3.4 对冲请求:用额外负载换尾延迟
对冲(hedged request)不是「失败后重试」,而是「还没失败就再发一份」:请求发出后等 ,如果还没返回,就向另一个实例再发一份,谁先返回用谁,另一份取消。
| 维度 | 普通重试 | 对冲 |
|---|---|---|
| 触发条件 | 失败/超时 | 超过延迟阈值,尚未失败 |
| 额外负载 | 失败请求数 × 重试次数 | 约 比例的正常请求翻倍 |
| 改善的指标 | 错误率 | 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")
这段代码有三个约束,缺一不可:
- 重试共享同一个 deadline,
call每次拿到的是同一个对象,不能新建; - 睡之前检查剩余预算,否则会在 deadline 之后才发出注定失败的请求;
- 预算耗尽直接抛,不要「再试最后一次」。
RetryBudgetExhausted 应该映射成 503 + Retry-After,而不是 500。500 会让上游以为「服务端有 bug,可能值得重试」,503 才表达「现在别来」。
客户端 SDK 对每个请求最多重试 3 次(共 4 次尝试),API 网关对上游失败也重试 3 次。两层都独立重试时,最坏情况下游收到 个请求。如果下游每个请求发一张券,就产生 16 张券。
修复不是「把 3 改成 1」,而是:
- 只保留一层重试(通常放在最接近用户、最了解业务语义的一层);
- 另一层只做「快速失败 + 返回 Retry-After」;
- 两层都保留时,内层预算必须显著小于外层,且外层要知道内层已经重试过(通过
attempt或x-retry-count传递)。
2.4 幂等:键、指纹、去重表、状态机
重试要安全,必须让服务端能识别「这是同一个逻辑请求」。识别靠两样东西:键(identity)和指纹(content)。键回答「是不是同一个意图」,指纹回答「参数有没有变」。
2.4.1 键的粒度:一个用户意图,一个键
| 场景 | 键的粒度 | 生成时机 | 复用范围 |
|---|---|---|---|
| 用户点击「支付」 | 一次支付意图 | 创建支付单时 | 该支付单的所有重试/轮询 |
| 下单 | 一个订单 | 进入结算页时 | 提交订单的所有尝试 |
| 发券 | 一次发放任务 | 创建任务时 | 任务的所有重试 |
| 内部任务重放 | 一个任务 ID + 执行序号 | 任务入队时 | 同一次执行的所有重试 |
| 读请求 | 通常不需要 | — | — |
键必须满足四个条件:
- 客户端生成:服务端自增 ID 在第二次请求时会变,无法作为身份;
- 在首次发送前生成并持久化:不能放在 retry 循环里,也不能只放内存;
- 在所有重试中复用:包括自动重试、手动重试、跨进程恢复后的重试;
- 粒度等于业务意图:太粗会把两个合法请求误判为重复,太细挡不住重复。
常见的错误生成方式:
| 写法 | 问题 |
|---|---|
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=(",", ":") | 同上 |
| 浮点数 | 金额统一用整数分;禁止 float | 1 与 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 至少覆盖:
经验值:同步接口 24 小时,支付/退款 7 天,异步任务 30 天。回收任务必须跳过 running 且租约未过期的记录;删除 succeeded 记录前,要先确认没有客户端还在用这个键重试。
- 响应体要限长:把 10 MB 的响应存进去,去重表会比业务表还大;超过阈值只存摘要和查询指针。
- 不要把下游调用放在数据库事务里:长事务会持锁,连接池很快耗尽;先短事务 claim,提交后再执行。
- 状态更新要带条件:
UPDATE ... WHERE status='running' AND lease_until=?,否则会覆盖别人刚写的结果。 - 键要加 scope:不同租户可能生成相同的键;
(scope, key)才是唯一键。
到这里,重试的「安全底座」齐了:deadline 控制总预算,退避和预算控制放大,幂等键和去重表控制重复。接下来把这三者放进一条失败时间线,看它们如何一起失效。
2.5 失败模式:超时 + 重试风暴
重试风暴不是「流量太大」,而是「系统用重试把一次变慢变成了持续过载」。它的形状固定:某个依赖变慢 → 超时率上升 → 客户端重试 → 总请求量上升 → 队列变长 → 延迟进一步上升 → 超时率更高。每一轮都在给正反馈加柴。
2.5.1 一条具体的时间线
假设一个服务有 500 个工作线程,正常时每次请求占用线程 100 ms(其中 60 ms 等下游),那么它的吞吐上限是:
平时流量 2000 QPS,利用率 40%,很健康。某个下游开始抖动,平均处理时间从 100 ms 涨到 600 ms,p99 到 1.5 s。吞吐上限立刻变成:
注意这里 CPU 可能只用了 20%:线程都在等下游,不在算。容量不是 CPU 决定的,是「并发数 ÷ 延迟」决定的。
客户端超时设 1 s,重试 2 次,没有预算。下面三轮的数字是示意,但数量级符合真实系统:
| 轮次 | 原始 QPS | 超时率 | 重试 QPS | 总 QPS | 在途需求 | 结果 |
|---|---|---|---|---|---|---|
| 1 | 2000 | 20% | 400 | 2400 | 1440 | 排队 607,等待约 0.7 s |
| 2 | 2000 | 45% | 900 | 2900 | 1740 | 等待约 1.1 s,超时率再升 |
| 3 | 2000 | 65% | 1300 | 3300 | 1980 | 有效吞吐掉到 700 以下 |
关键在第三列和第五列:原始流量一直是 2000,没有涨;涨的是重试。系统并没有被更多用户访问,它是被自己对失败的补偿压垮的。
更糟的是,重试请求比原始请求更「贵」:它往往带着更短的剩余 deadline,服务端如果按 FIFO 排队,会在快到期时才执行,然后超时,再触发下一轮重试。一个注定超时的请求,越晚被处理,浪费越大。
2.5.2 正反馈的数学形状
把「总负载」记为 ,原始负载 ,重试比例 ,超时率是负载的函数 ,则下一轮的负载为:
不动点存在不等于稳定。在不动点附近线性化,稳定条件是:
是「负载每增加一点,超时率上升多少」。当系统接近饱和时, 会急剧变大,这个乘积很容易超过 1;此时任何小的扰动都会把系统推出不动点,进入发散。这就是为什么 70% 利用率时一次小抖动可能没事,90% 时同样的抖动会直接雪崩(第 1 章的排队算例给过这条曲线)。
重试把「错误率」变成了「流量」,而流量又变成「错误率」。只要这个环路的增益大于 1,系统就没有稳定点,只有「还活着」和「已经挂了」两种状态。
2.5.3 同步重试:连接在睡觉,线程也在睡觉
很多 SDK 的重试是同步的:sleep 期间仍握着连接和线程。假设连接池 50 条、每个请求重试 2 次、每次退避 1 s,单个客户端实例的吞吐上限约为 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 说:稳定系统里,平均在途请求数等于到达率乘以平均停留时间:
换一种写法,如果你把并发上限固定为 ,那么吞吐和延迟的关系是:
| 并发上限 C | 平均延迟 W | 吞吐上限 | 说明 |
|---|---|---|---|
| 50 | 20 ms | 2500 req/s | 健康 |
| 50 | 50 ms | 1000 req/s | 正常 |
| 50 | 200 ms | 250 req/s | 下游变慢,容量塌缩 |
| 50 | 1 s | 50 req/s | 已经过载,队列无限增长 |
这张表解释了一个常见困惑:「CPU 只有 30%,为什么 QPS 上不去?」因为线程都在等下游, 变大了, 就小了。并发上限是吞吐的天花板,延迟是天花板的高度。
反过来,用 Little’s law 定并发上限:
目标 1000 QPS、p99 延迟 250 ms,则 。注意用 p99 而不是 p50:p50 只覆盖一半请求,按它算出来的并发会在高峰期排队。
2.6.2 队列该有多长
队列的作用是吸收短时突发,不是吸收持续过载。持续过载时,队列只会把「快速失败」变成「慢速失败」,还额外占用内存和 deadline。
一个可用的估算:
峰值 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 立即失败——这是正确的:不要让一个注定超时的请求进入队列。
拒绝时返回什么,决定了上游会不会立刻再打回来:
| 拒绝原因 | 返回 | 带什么 | 上游应该做什么 |
|---|---|---|---|
| 自身并发满 | 503 | Retry-After: 1 + 抖动 | 退避,不要立即重试 |
| 租户配额耗尽 | 429 | Retry-After + 配额重置时间 | 降速或换租户 |
| 请求已过期 | 504 或 503 | 不建议重试 | 放弃,走 unknown 收敛 |
| 依赖熔断 | 503 | Retry-After | 降级/读缓存 |
把队列从 1000 加到 10000,不会增加吞吐,只会增加延迟和内存。真正的背压是「拒绝」,而且要在请求进入昂贵资源之前拒绝:在网关拒绝,比在数据库连接池前拒绝便宜一个数量级;在连接池前拒绝,比让请求超时后再重试便宜两个数量级。
2.7 限流与过载:令牌桶、429/503、Retry-After
限流回答「你能用多少」,背压回答「我现在还能处理多少」。两者经常一起出现,但语义不同:限流是配额(quota),背压是容量(capacity)。配额可以买,容量不能。
2.7.1 令牌桶:允许突发,限制均值
令牌桶有两个参数:速率 (每秒补充多少 token)和桶容量 (最多攒多少)。请求消耗 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 常用 |
| 令牌桶 | 允许 突发 | 高 | 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 万个客户端都收到
Retry-After: 3,它们会在第 3 秒同时回来。客户端应该在[0.8, 1.2] × Retry-After内随机化。 - 不要给太小:
Retry-After: 0等于「立刻重试」,会加剧过载;至少给一个估计的恢复时间。 - 不要给太大:超过客户端 deadline 的
Retry-After等于让客户端放弃;如果确实要这么久,应该返回 503 并让客户端走异步查询。
服务端如何区分「自己过载」和「依赖失败」:
| 信号 | 自身过载 | 依赖失败 |
|---|---|---|
| 本服务 CPU | 高(>80%) | 可能低(线程在等) |
| 队列等待 | 高且持续上升 | 可能正常 |
| 下游延迟/错误 | 正常 | 高 |
| 本服务 handler 耗时 | 高(含排队) | 高(含等待下游) |
| 线程池 | 满,任务排队 | 大量线程处于 waiting |
| 正确动作 | 限流/降载/扩容自己 | 熔断/降级/切换下游 |
这张表决定返回 429 还是 503:如果是自己过载,返回 503 并限流;如果是租户超额,返回 429;如果是依赖失败,返回 503,但要在响应体里说明是依赖不可用(可选,避免泄露内部拓扑),并考虑降级读缓存。
某 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_seconds | histogram | 每次尝试慢在哪 |
| 客户端 | rpc_client_retries_total{reason,attempt} | counter | 重试多少、为什么 |
| 客户端 | rpc_client_deadline_remaining_ms | histogram | 发出时还剩多少预算 |
| 客户端 | pool_wait_seconds / pool_in_use | histogram/gauge | 连接池是不是瓶颈 |
| 服务端 | rpc_server_requests_total{method,code} | counter | 收到多少、错误分布 |
| 服务端 | rpc_server_queue_wait_seconds | histogram | 排队多久 |
| 服务端 | 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 的重复扣款前兆。
pool_wait p99 < 5 ms。peer 且都超时。2.9 真实案例:S3 2017-02-28 的重启与重试
2017 年 2 月 28 日,AWS 位于北弗吉尼亚(us-east-1)的 S3 发生了一次持续约 4 小时的服务中断。AWS 官方的复盘写得克制,但事实链很清楚:
| 时间(当地时间) | 事件 |
|---|---|
| 09:37 | S3 计费团队的一名授权人员执行一条移除服务器的命令,输入参数写错 |
| 随后 | 命令移除了比预期更多的服务器,波及索引(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 | 扣款成功,响应慢 | 钱已扣 |
| t3 | 2 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 对账与修复
事故已经发生后的处理顺序:
- 按
order_id聚合:找出同一订单下status='succeeded'且金额相同的多条 intent; - 核对 PSP:用
psp_charge_id拉取收单方流水,确认是否真的多扣; - 退款:用独立的
refund_id作为退款幂等键,不要用原charge_id直接重试退款; - 修数据:保留两条 intent 作为审计记录,把其中一条标记为
duplicate,不要物理删除; - 补约束:上线
UNIQUE (merchant_id, order_id)前,先清理历史重复,否则建索引会失败。
修复后的回归测试要覆盖三个场景:同一请求键重试(应返回原结果)、同一订单换键重试(应 409 或返回原 intent)、PSP 超时后重试(PSP 侧应只扣一次)。第三个场景必须用 PSP 的沙箱验证,不能只测自己的服务。
某支付服务在重试循环内生成 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 本章陷阱清单
- 把 timeout 当 deadline:每跳重置成固定超时,端到端变成 ;必须传剩余预算并逐跳扣减。
- 重试时重新生成幂等键:键在 retry 循环里生成,等于每次都是新请求;键必须在首次发送前生成并持久化。
- 用请求体哈希当幂等键:两次合法的相同请求(如两次充值 100 元)会被误判为重复;哈希只能当指纹,不能当身份。
- 只做客户端幂等:服务端没有唯一约束,并发插入仍会产生两条记录;唯一约束必须在存储层。
- 同键不同参返回旧结果:用户以为新参数生效了,实际没有;必须 409。
- 重试没有预算:单客户端 3 次,乘以百万客户端就是三百万次;预算按比例限制,耗尽返回 503。
- 指数退避不加抖动:所有客户端在同一时刻重试,形成同步脉冲;至少用 full jitter。
- 重试不可重试的错误:400/401/403/404 重试一万次也不会成功,只会浪费连接和配额。
- 重试延长 deadline:每次尝试都拿新预算,最后一次尝试注定在客户端放弃后执行;所有尝试共享一个 deadline。
- 无限堆队列:队列只吸收短时突发;持续过载时加队列只会把快速失败变成慢速失败。
- 把连接池当并发上限:池 50 条、每条 1 个在途请求,等价于并发 50;下游变慢时吞吐按 塌缩。
- 忽略
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 |
| 设计幂等键 | 能说清粒度、生成方、持久化、TTL | 2.4 |
| 设计去重表 | 有唯一约束、条件更新、指纹冲突处理 | 2.4 |
| 用 Little’s law 算并发 | 给定 QPS 与 p99 能算出 C | 2.6 |
| 区分 429/503 | 能说出作用域与 Retry-After 语义 | 2.7 |
| 用 PromQL 看重试比例 | 能写出重试/原始请求的比值查询 | 2.8 |
| 复述 S3 教训 | 能说出重试放大恢复期负载 | 2.9 |
| 解释两个键 | 能区分请求键与效果键的保护范围 | 2.10 |
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 基准 ms,未到 cap;范围 0–1600 ms,期望 800 ms。若 cap=2 s,第 5 次基准 3200 ms 被截到 2000 ms。
A3 稳态预算约 次/秒,加上 burst 50 的初始额度;30% 失败意味着期望 1500 次重试,预算只能放行约 400 次,其余返回 503。正确动作是降低原始流量或修复依赖,而不是提高 ratio。
A4 。用 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-After | 2.2–2.4、2.7 | 引用 | 只引用语义与参数名,不复制文档长段 |
| 本书自编 + AWS 公开复盘 | S3 2017-02-28 | 2.9 | 完整 | 只引用公开事实,不复制原文 |
下一站:知道了「一次调用怎么超时、怎么重试、怎么去重」,下一步要回答「同一份数据放在多个副本上,谁来写、谁来读、延迟多久」。进入 3 复制:主从、多主、无主,看复制日志、故障切换与读己之写。