1 从一次工具调用到分布式请求
一次工具调用离开本机之后,究竟经过了哪些 hop?为什么本地函数调用只有「成功/失败」,远程调用却多出一个「未知」?超时、重试、重复、乱序分别在哪个环节发生,为什么会把一次操作变成两次副作用?
能画出一次远程调用的状态机并标出 unknown;能用 curl -w 和 trace id 把一次请求的延迟切成若干段;能写出远程调用契约的 12 个必答问题;能解释为什么「超时后重试」是最常见的重复副作用来源。
一个内部工具平台提供「发券」能力。客户端调用 POST /tool/issue-coupon,服务端做三件事:在数据库里写一条发券记录、调用下游券系统、把券号返回给客户端。某天网络抖动,客户端在 3 秒时超时;SDK 的重试策略是「超时重试 3 次,指数退避」。三次重试都超时,用户看到「发放失败」,又点了一次。最后用户收到 4 张券。
这个事故里没有哪台机器真正宕机,也没有哪段代码写错。三方的行为单独看都合理:
- 客户端:「超时就是失败,失败当然要重试。」
- 服务端:「每个请求我都处理了,是网络没把响应带回去。」
- 下游:「我们只保证至少一次,重复调用要上游去重。」
把三句话放在一起,重复副作用就出现了。分布式系统的很多故障不是「组件坏了」,而是「组件对同一个事实的理解不一致」:客户端认为失败,服务端认为成功,下游认为被调用了两次。本章要做的,就是把这三种理解拆开,落到一条请求路径、一个状态机和一个契约上。
这一章是全书的地基。后面的 RPC、复制、一致性、事务、队列、缓存、锁、观测、容量,都会反复回到本章的三个问题:这次调用成功了吗?读到的值是最新的吗?出事时看什么?先把这三个问题的语言统一,后面才不会各说各话。
1.1 一次工具调用的十二个 hop
先看一条具体的路径。假设客户端在浏览器里点「发放」,请求最终到达工具服务的 /v1/tool-calls:
图上的五个方框只是粗粒度。把一次调用拆细,至少经过下面十二个 hop。每一个 hop 都有自己的队列、超时和失败模式;把它们列全,是为了知道「延迟到底花在哪」以及「失败到底发生在哪」。
| # | hop | 典型耗时 | 这里的失败会长什么样 |
|---|---|---|---|
| 1 | 客户端序列化请求体 | 0.1–2 ms | 参数太大、编码错误、循环引用 |
| 2 | 连接池取连接 / 建连 | 0–200 ms | 池耗尽、握手超时、DNS 失败 |
| 3 | 请求写入内核发送缓冲 | 0.01–1 ms | 发送缓冲满、连接被 RST |
| 4 | 网络传输到接入层 | 0.5–50 ms | 丢包、抖动、跨地域 RTT |
| 5 | 接入层排队 | 0–500 ms | 限流队列满、线程池满 |
| 6 | 鉴权 / 路由 | 0.1–10 ms | token 过期、路由规则不命中 |
| 7 | 服务 A 排队 | 0–1 s | 工作线程不足、GC 停顿 |
| 8 | 服务 A 调服务 B | 1–500 ms | 下游慢、连接池耗尽、超时 |
| 9 | 服务 B 执行工具 | 1 ms–30 s | 下游超时、第三方限流、业务失败 |
| 10 | 数据库提交 | 0.5–50 ms | 锁等待、磁盘满、主从切换 |
| 11 | 响应回传 | 0.5–50 ms | 响应丢失、连接被中间设备断开 |
| 12 | 客户端解析响应 | 0.1–5 ms | 半包、协议不匹配、字段缺失 |
这张表至少说明三件事。
第一,延迟不是一个数,而是一条分布。 同一段代码在不同负载下,hop 5 和 hop 7 可以从 0 ms 变成 500 ms。你在监控上看到的 p99,往往是某个 hop 的排队时间,而不是「服务处理慢」。
第二,失败不是一个点,而是一条链。 客户端看到的是 hop 11 的「响应没回来」,但根因可能在 hop 7 的线程池、hop 10 的锁等待,甚至 hop 4 的一次丢包。只看客户端错误码,等于只看最后一环。
第三,重试会改变路径,而不是回到原点。 第二次尝试可能命中另一个实例、另一个可用区、另一个连接;它可能绕过第一次已经完成的副作用,也可能在同一张表上再写一次。重试不是「再试一次」,而是「再产生一次请求」。
逻辑请求(logical request)是用户意图的一次表达,由客户端生成的 (client_id, seq) 或幂等键唯一标识。物理尝试(attempt)是逻辑请求的一次实际发送;同一逻辑请求可以有多次物理尝试,每次尝试可能落在不同连接、不同实例、不同数据中心。
服务端必须能区分这两者:只看到物理尝试,就会把重试当成新请求;只有把它们归并到同一个逻辑请求,去重才有可能。
这个区分看起来只是命名,实际决定了接口设计。很多系统的请求体里没有 client_id/seq,服务端只能按「每次 HTTP 请求」计数;客户端一重试,服务端就多一条记录。没有逻辑请求 ID,就没有去重的锚点。
一个常见的反例是「用数据库自增 ID 当请求 ID」:自增 ID 是服务端生成的,第二次尝试会拿到新的 ID,服务端无法知道它和第一次是同一个意图。正确的做法是客户端在第一次发送前就生成键,并在所有重试中复用它。
{
"client_id": "tool-console-7f3a",
"seq": 1842,
"idempotency_key": "tool-console-7f3a:1842",
"tool": "issue_coupon",
"args": {"user_id": "u-1024", "amount": 100},
"deadline_ms": 3000
}
seq 是客户端本地的单调序号,用来防止「同一个键被不同请求复用」。服务端应当把 (client_id, seq) 和 idempotency_key 都存下来:前者用于审计,后者用于去重;两者不一致时,按冲突处理,而不是猜。
1.2 本地调用与远程调用的七个差异
本地函数调用和远程调用看起来都是「传参、执行、返回」,但至少有七个本质差异。理解这七个差异,比背会 gRPC 的四种调用方式更有用。
| # | 差异 | 本地调用 | 远程调用 | 后果 |
|---|---|---|---|---|
| 1 | 延迟 | 纳秒–微秒 | 毫秒–秒,且长尾 | 同步等待会放大排队 |
| 2 | 失败 | 抛异常或返回错误码 | 可能「不知道」 | 需要 unknown 状态 |
| 3 | 内存 | 共享地址空间 | 各自独立 | 不能靠指针/锁共享状态 |
| 4 | 数据 | 对象引用 | 必须序列化 | 版本、兼容、体积 |
| 5 | 并发 | 线程/协程可控 | 多实例、多副本 | 幂等与顺序问题 |
| 6 | 边界 | 进程内 | 信任边界/网络边界 | 鉴权、重放、注入 |
| 7 | 时钟 | 同一个时钟 | 不同机器、可能漂移 | 不能拿时间戳定序 |
1.2.1 延迟:长尾不是异常,是常态
本地调用的 p99 可能是 p50 的 2 倍;跨网络的 p99 可能是 p50 的 20 倍。原因不神秘:一次远程调用要经过多跳队列,任何一跳的尾部都会叠加到总延迟上。第 1.4 节会把延迟拆开算;这里先记住一个结论:同步调用链越长,p99 越接近「最慢那一跳的 p99 之和」。
1.2.2 失败:从二元到三元
本地调用要么返回,要么抛异常;即使崩溃,调用方也通常能通过进程状态知道「没执行完」。远程调用多出第三种状态:请求可能已经到达并执行,但响应在回程丢失。调用方既不能当成功,也不能当失败。
很多 SDK 把这种状态映射成 TimeoutException,然后在上层被当成失败重试。这是本书反复强调的头号错误:超时是「本地不再等待」,不是「远端没有执行」。
1.2.3 内存:不能共享,只能复制
本地调用可以把一个大对象传进去,函数内部修改它,调用方立刻看到。远程调用必须把对象序列化成字节,接收方反序列化出一份副本;两边修改互不可见。这带来三个问题:
- 大对象传输成本高,可能触发序列化/反序列化的 CPU 尖峰;
- 结构变更需要版本兼容,旧客户端可能不认识新字段;
- 不能把「锁」放在调用方,必须把并发控制放到服务端或存储层。
1.2.4 数据:序列化不是免费的
JSON 可读但体积大;Protobuf 紧凑但有 schema 演进规则;Thrift、Avro、MessagePack 各有取舍。序列化层最容易被忽略的失败是兼容性:新增必填字段、修改枚举值、删除字段后复用编号,都会让旧客户端在新服务端上失败。第 2 章会把「契约」单独列一节。
1.2.5 并发:多实例让「顺序」消失
本地调用通常只有一个进程,锁和队列可以保证顺序。远程调用可能落到多个实例、多个副本,甚至多个地域。没有全局队列,就没有全局顺序;要么把顺序收敛到单个分区/单个 leader,要么让操作可交换(幂等、CRDT)。
1.2.6 边界:信任模型变了
本地调用默认「调用方和被调用方在同一个信任域」。远程调用跨进程、跨主机、跨租户,必须考虑鉴权、重放、注入、限流。一个典型的错误是「内网调用不加鉴权」:内网也会被横向移动,攻击者可以伪造请求。
1.2.7 时钟:时间戳不是顺序
两台机器的墙上时钟可能差几毫秒到几秒,NTP 调整还可能让时钟倒退。用 time.time() 给请求排序,在跨机器时不可靠;用单调时钟算超时,用逻辑时钟/版本号定序。第 4 章会讲线性一致为什么不能靠「谁的时间戳大」。
远程调用不是「更慢的本地调用」,而是「在一个会丢包、会重复、没有全局时钟、没有共享内存的环境里,重新定义一次函数调用」。
1.2.8 一个对照实验:把本地调用改成远程调用
把下面的函数从本地调用改成 HTTP 调用,观察失败语义怎么变:
# 本地版本:调用方能看到异常,也能确定函数是否返回
def issue_coupon_local(user_id: str) -> str:
record = db.insert_coupon(user_id) # 副作用
return record.coupon_id
# 远程版本:调用方只能看到 HTTP 结果或超时
def issue_coupon_remote(user_id: str, key: str) -> str:
resp = http.post("/v1/coupons", json={"user_id": user_id},
headers={"Idempotency-Key": key}, timeout=1.0)
return resp.json()["coupon_id"]
本地版本里,如果 db.insert_coupon 成功但后续代码抛异常,调用方至少知道「异常发生在我可控的栈里」,可以查数据库。远程版本里,超时可能发生在服务端写库之前、之后、或者响应回程;调用方的栈里没有任何服务端状态。
| 场景 | 本地调用 | 远程调用 |
|---|---|---|
| 参数错误 | 立即抛 ValueError | 可能 400,也可能网关 502 |
| 执行中崩溃 | 进程退出,调用方通常也崩溃 | 连接 RST/超时,服务端可能已提交 |
| 执行成功但返回失败 | 几乎不会(同一进程) | 响应丢失、序列化失败、代理超时 |
| 重试 | 重新调用函数,副作用再来一次 | 重新发请求,副作用可能再来一次 |
| 幂等 | 调用方自己控制 | 必须跨进程传递键 |
这个对照实验说明:远程调用不是「加一个 HTTP 客户端」那么简单,它把错误处理从「异常捕获」变成了「状态收敛」。
1.3 请求状态机:unknown 为什么必须单独存在
把一次逻辑请求的状态画出来,会得到下面这张图:
图中最重要的是 unknown。它不是「失败」的同义词,也不是「成功」的模糊态,而是一个需要额外动作才能离开的状态:查询服务端、用同一个键重试、或者等待对账。
一次逻辑请求的状态取值:
pending:服务端已接收,尚未开始执行;running:正在执行,持有租约(lease);succeeded:副作用已提交,结果可查询;failed:确定未提交,可安全重试或放弃;unknown:客户端无法判断服务端是否执行;只能通过查询或同键重试收敛。
状态迁移必须满足:succeeded 和 failed 是终态(除非业务补偿);unknown 只能由 running 在超时/响应丢失时进入;unknown 不能直接当 failed 处理。
为什么不能把 unknown 折成 failed?因为两者的安全动作不同:
| 状态 | 客户端可以做什么 | 不可以做什么 |
|---|---|---|
failed | 换新键重试、返回用户失败、触发补偿 | 假设副作用已发生 |
unknown | 用同一个键重试、查询状态、等待对账 | 换新键重发、直接告诉用户失败 |
succeeded | 返回结果、复用结果 | 再次执行副作用 |
running | 轮询、等待、超时后转 unknown | 并发再发一次(除非有租约接管) |
这张表是第 12 章项目一的全部设计基础。项目里的 calls 表会有 status、lease_until、attempts、effect_id 四个字段,分别对应「状态机」「租约」「尝试次数」「副作用幂等 ID」。
一个可运行的 Python 状态迁移检查器长这样:
from enum import Enum
class State(str, Enum):
PENDING = "pending"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
UNKNOWN = "unknown"
ALLOWED = {
State.PENDING: {State.RUNNING, State.FAILED},
State.RUNNING: {State.SUCCEEDED, State.FAILED, State.UNKNOWN},
State.UNKNOWN: {State.SUCCEEDED, State.FAILED, State.RUNNING},
State.SUCCEEDED: set(),
State.FAILED: {State.RUNNING}, # 只允许同键重试
}
def transition(cur: State, nxt: State) -> State:
if nxt not in ALLOWED[cur]:
raise ValueError(f"illegal transition: {cur} -> {nxt}")
return nxt
这段代码的关键不在 ALLOWED 表,而在 FAILED -> RUNNING 只允许同键重试。如果调用方换了一个新键,服务端会创建一条新记录,状态机无法阻止重复副作用;状态机只能约束「同一次逻辑请求」,不能约束「用户又点了一次」。后者需要业务层的去重键(例如「用户 + 券模板 + 活动 + 时间窗」),第 12 章会展开。
- 「unknown 就是失败,重试就行」:换新键重试会重复副作用;必须复用原键。
- 「查询状态一定可靠」:查询也可能超时/读到旧副本;查询本身要带一致性要求(第 4 章)。
- 「服务端返回 500 就一定是 failed」:500 可能是提交后序列化失败、也可能是网关错误;要看错误码语义和幂等记录。
1.3.2 unknown 的三种收敛路径
unknown 不能一直悬着。工程上有三条收敛路径,各有代价:
客户端 服务端 存储
│ GET /calls/K1 │ │
├────────────────────▶│ 查 calls by key │
│ ├────────────────────▶│
│ 200 succeeded │ │
│◀────────────────────┤ │
│ │ │
│ POST /calls (K1) │ │
├────────────────────▶│ 命中幂等记录 │
│ 200 succeeded │ 直接返回原结果 │
│◀────────────────────┤ │
│ │ │
│ 等待/对账 │ 异步 worker 扫描 │
│ │ running 超租约 → 重试 │
| 路径 | 何时用 | 代价 | 风险 |
|---|---|---|---|
| 查询状态 | 有 call_id 或幂等键查询接口 | 一次额外 RTT | 查询可能读到旧副本,需要一致性读 |
| 同键重试 | 没有查询接口,但服务端支持幂等 | 一次额外执行(被去重) | 服务端没做去重时会重复 |
| 等待对账 | 异步任务、支付、跨日结算 | 延迟高 | 对账逻辑本身可能出错 |
三条路径可以组合:先查询,查不到再同键重试;异步任务则靠 worker 扫描租约过期的记录。关键是不要用「换新键重发」来收敛 unknown——那等于放弃身份。
如果查询走从库,可能读不到刚提交的记录;客户端会误以为「没执行」,然后同键重试。重试本身是安全的(服务端去重),但会增加延迟。更稳妥的做法是让查询接口支持「按幂等键读主库」或「读己之写」会话保证(第 4 章)。
1.4 延迟预算:p50 骗人,p99 决定体验
假设一个接口的延迟分布如下:
| 分位 | 延迟 | 含义 |
|---|---|---|
| p50 | 80 ms | 一半请求比这快 |
| p90 | 220 ms | 10% 请求超过 220 ms |
| p99 | 1.2 s | 1% 请求超过 1.2 s |
| p999 | 4.5 s | 千分之一超过 4.5 s |
如果只看 p50,你会觉得「80 ms 很快」;但对一个每天 1 亿次调用的接口,p99 意味着每天有 100 万次请求超过 1.2 秒。用户感知的是他这一次的延迟,不是平均值。
更麻烦的是,p99 会被调用链放大。一个请求要串行调用 10 个服务,每个服务独立地有 1% 的概率超过 1 秒,那么至少一个服务超 1 秒的概率是:
也就是说,单服务 p99 是 1 秒,端到端 p90 左右就可能超过 1 秒。这就是「长尾放大」(tail amplification)。第 2 章的对冲请求(hedged request)和第 11 章的容量规划都会回到这个公式。
1.4.1 把预算按 hop 切开
假设产品要求端到端 p99 ≤ 1 s。你不能给每个 hop 都设 1 s 超时——那样最坏情况会叠加成 12 s。正确做法是先留余量,再按 hop 分配:
| hop | 预算 | 依据 |
|---|---|---|
| 客户端排队/序列化 | 50 ms | 本地 CPU,可观测 |
| 建连/连接池 | 100 ms | 池命中时接近 0;未命中才花 |
| 网络到接入层 | 80 ms | 同城 RTT + 抖动 |
| 接入层排队 | 100 ms | 限流/线程池 |
| 服务 A 处理 | 250 ms | 含一次下游 RPC |
| 服务 B 处理 | 300 ms | 工具执行,允许慢 |
| 数据库提交 | 120 ms | p99 锁等待 |
| 响应回传 + 客户端解析 | 100 ms | 回程与反序列化 |
| 合计 | 1100 ms | 留 100 ms 余量给未知抖动 |
这张表是预算,不是 SLA。每个 hop 的实际超时应比预算略小(例如 80%),这样上游还有时间做降级;如果每个 hop 都等到预算上限,端到端一定超。
1.4.2 deadline 不是 timeout
timeout 是「我愿意等多久」,deadline 是「这个请求最晚必须在什么时候完成」。两者在单跳里看起来一样,在多跳里差别巨大:
- 服务 A 收到请求时已经过了 200 ms,它给服务 B 的剩余预算应该
deadline - now,而不是重新给 1 s; - 如果 A 给 B 重新 1 s,B 可能执行到第 1.1 s,A 早已超时返回,B 的副作用却发生了;
- 如果 A 把绝对 deadline 传给 B,B 会在剩余预算内尽快失败,减少无效执行。
Python 里的绝对 deadline:
import time
class Deadline:
def __init__(self, budget_ms: int):
self._end = time.monotonic() + budget_ms / 1000.0
def remaining_ms(self) -> int:
return max(0, int((self._end - time.monotonic()) * 1000))
def expired(self) -> bool:
return self.remaining_ms() == 0
跨进程传递时,不要传 remaining_ms,也不要用墙上时钟;传「剩余预算」会在每一跳被重新解释,传墙上时钟会受 NTP 影响。工程上的折中是:在 RPC header 里传 grpc-timeout(相对时间),由每一跳在接收时立刻换算成本地单调时钟的 deadline;或者传 x-request-deadline-ms 并在网关做一次校准。两者都要在契约里写清楚「谁负责换算」。
墙上时钟会被 NTP 向前/向后调整。如果发送方和接收方的时钟差 2 秒,接收方可能认为 deadline 已过(立刻失败),也可能认为还有很多时间(继续执行)。单调时钟只在同一台机器内有意义,不能直接跨机器传;所以协议里通常传相对预算,接收方用本地单调时钟换算。
1.4.3 一个排队算例:为什么 70% 就开始抖
假设一个服务有 10 个工作线程,每个请求平均处理 10 ms,服务率 μ = 100 req/s,到达率 λ = 90 req/s。利用率:
用最简单的 M/M/1 近似(单队列、单服务台;多线程时用 M/M/c,趋势相同),平均停留时间:
也就是说,处理时间只有 10 ms,但排队让平均停留时间变成 100 ms。把利用率再提高:
| 利用率 ρ | λ (req/s) | 平均停留 W | 相对处理时间 |
|---|---|---|---|
| 0.5 | 50 | 20 ms | 2× |
| 0.7 | 70 | 33 ms | 3.3× |
| 0.9 | 90 | 100 ms | 10× |
| 0.95 | 95 | 200 ms | 20× |
| 0.99 | 99 | 1 s | 100× |
这张表说明:容量不是「CPU 没满就能扛」,而是「排队时间是否还能接受」。当利用率到 90%,一次 10% 的流量突增就会把 ρ 推到 0.99,延迟放大 10 倍;如果重试再增加 10% 流量,系统直接进入正反馈。第 11 章会把这条曲线和重试风暴接起来。
1.5 部分失败:超时只是其中一种
分布式系统里的「失败」至少有七种。把它们混成一种,就会用错恢复手段。
| 失败 | 表现 | 检测信号 | 常见错误处理 |
|---|---|---|---|
| 崩溃(crash) | 进程退出,连接 RST | 连接错误、健康检查失败 | 立即重试到其他实例 |
| 超时(timeout) | 客户端不再等待 | deadline 到期 | 当失败重试(错) |
| 分区(partition) | 两组节点互相不可达 | 心跳失败、quorum 丢失 | 两边都继续写(错) |
| 重复(duplicate) | 同一请求被执行多次 | 幂等键冲突、日志重复 | 只靠重试次数限制(不够) |
| 乱序(reorder) | 消息/请求到达顺序与发送不同 | 版本号倒退、旧值覆盖新值 | 用时间戳排序(不可靠) |
| 消息丢失(loss) | 请求/响应消失 | 超时、序列号空洞 | 无限重试(放大) |
| 拜占庭(byzantine) | 节点发送错误信息 | 校验失败、签名不匹配 | 普通重试(无效) |
本书默认不处理拜占庭失败(除了第 5 章提一句),因为大多数数据面系统的信任模型是「节点不会伪造消息」,但会崩溃、会暂停、会丢包。
1.5.1 失败检测器不完美
「节点挂了」这件事本身就不是可判定的。你只能根据心跳超时推测它挂了;但对方可能只是 GC 停顿、网络抖动、或者 CPU 被抢占。任何基于超时的失败检测都会产生假阳性:把慢节点判死,触发主从切换,然后旧主恢复,出现双主。
这就是为什么第 3 章要讲 fencing、第 9 章要讲租约、第 5 章要讲 quorum:不要把「检测到失败」当成「失败已确认」。检测只是触发动作的信号,动作本身必须满足安全性(不会因为误判而产生两个写者)。
1.5.2 失败的处理顺序
遇到一次失败,正确的处理顺序是:
- 分类:是确定没执行、确定失败、还是 unknown?
- 收敛:unknown 先查询/同键重试,不要换键;
- 隔离:把故障实例从负载均衡里摘掉,避免重试继续打到它;
- 降级:如果依赖不可用,返回缓存/默认值/排队,而不是无限重试;
- 观测:记录 request_id、attempt、状态迁移,供事后对账。
这个顺序不能反。很多事故的根因不是「第一次失败」,而是「失败后立刻重试,重试把故障扩散到整个集群」。
请求失败
├─ 有明确错误码且语义为「未执行」 → failed → 可换实例重试(同键)
├─ 有明确错误码且语义为「已拒绝」 → failed → 不重试,返回用户
├─ 超时/连接断开/5xx 无体 → unknown → 查询状态;查不到则同键重试
└─ 重试预算耗尽 → 返回 503 + Retry-After,交给上游/用户
决策树的第一行不是「重试」,而是「分类」。分类错了,后面全错。
1.5.3 心跳检测:为什么「假阳性」不可避免
故障检测器通常用心跳实现:
import time
class HeartbeatDetector:
def __init__(self, timeout_s: float = 3.0):
self.timeout_s = timeout_s
self.last_seen = {} # node -> monotonic timestamp
def observe(self, node: str) -> None:
self.last_seen[node] = time.monotonic()
def alive(self, node: str) -> bool:
seen = self.last_seen.get(node)
return seen is not None and time.monotonic() - seen < self.timeout_s
这段代码能用,但它有三个无法消除的误差来源:
- 网络延迟:心跳在队列里排了 3.1 s,节点本身活着,却被判死;
- GC/STW:进程暂停 2 s,心跳线程没运行,发送方看到的是「没心跳」;
- 调度:容器被 CPU 限流,心跳线程抢不到时间片。
所以任何基于超时的心跳都会产生假阳性(把活节点判死)和假阴性(把死节点判活,直到超时)。工程上不是追求「检测准确」,而是让「误判的后果可控」:
| 误判 | 后果 | 防护 |
|---|---|---|
| 假阳性(活→死) | 触发主从切换,可能出现双主 | fencing、quorum、旧主自停 |
| 假阴性(死→活) | 请求继续打到死节点,超时 | 客户端重试、负载均衡摘除 |
第 3 章的分区、第 5 章的选举、第 9 章的租约,都是围绕「误判之后怎么不产生两个写者」设计的。心跳只能触发怀疑,不能证明死亡;这是分布式系统里最贵的一课。
1.6 失败模式:超时后的三种真相
现在把镜头拉近到一次超时。客户端在 T = 1 s 返回 timeout,服务端可能处于三种状态:
第一种:请求没到。 报文在网络上丢了,或者连接根本没建立。服务端没有任何记录,客户端重试是安全的(但仍应复用同一个键,避免「第一次其实到了、只是响应丢了」的边界情况)。
第二种:执行了,响应丢了。 服务端完成了副作用,但在返回路径上连接被重置、代理超时、客户端进程被 kill。服务端有记录,客户端不知道。此时换新键重试会重复执行;同键重试会被去重。
第三种:执行并提交,响应丢了。 服务端不仅执行了,还把结果写进了数据库/下游。这是最危险的一种,因为「补偿」也可能重复。此时唯一安全的做法是:用同一个键查询/重试,让服务端返回已提交的结果。
三种真相在客户端看来完全一样:都是 timeout。这就是为什么「超时」不能作为重试的充分条件。重试可以发生,但必须带上能让服务端识别「这是同一个逻辑请求」的键。
客户端发送 issue_coupon(user=u-1024, key=K1),1 s 超时。
- 结局 A:请求丢在客户端到网关之间。服务端无记录。客户端用 K1 重试,服务端创建记录并执行一次。
- 结局 B:服务端执行成功,写库成功,响应在网关处超时。客户端用 K1 重试,服务端查到 K1 已成功,直接返回原券号,不重复发券。
- 结局 C:服务端执行成功,但下游券系统也超时;服务端把记录标为
unknown并返回 202。客户端轮询GET /calls/K1,最终看到succeeded或failed。
如果客户端在 B/C 中改用 K2 重试,服务端会创建第二条记录,下游会再发一张券。事故不是「重试」本身,而是「重试没有携带身份」。
1.6.1 服务端应该返回什么
面对不确定的执行结果,服务端有三种返回策略:
| 策略 | 返回 | 优点 | 缺点 |
|---|---|---|---|
| 同步等待 | 200 + 结果 | 客户端简单 | 长事务占用连接,超时后仍未知 |
| 异步受理 | 202 + call_id | 快速释放连接,可查询 | 客户端要轮询,状态最终一致 |
| 快速失败 | 503 + Retry-After | 保护服务端 | 客户端可能误以为没执行 |
没有一种策略适合所有场景。发券、支付这类有副作用的操作,通常选「异步受理 + 可查询」;纯读操作可以同步等待;过载时快速失败并明确「未执行」。关键是返回体里要能区分「已执行」「未执行」「未知」,而不是把所有失败都塞进 500。
一个可用的响应契约:
{
"call_id": "c-9f2a",
"status": "unknown",
"attempt": 2,
"retry_after_ms": 500,
"query_url": "/v1/tool-calls/c-9f2a"
}
status 是服务端对逻辑请求状态的判断,不是 HTTP 状态码的复述。HTTP 200 + status=unknown 是合法的:请求被受理了,但执行结果还没确定。客户端不应看到 200 就认为成功,也不应看到 unknown 就换键重发。
1.7 重试如何把一次调用变成两次副作用
重试的数学很简单,后果却不简单。假设一个客户端对每次请求最多重试 3 次,上游网关又对失败请求重试 3 次。最坏情况下,下游收到:
次请求。如果下游每次都会发一张券,用户就可能收到 16 张。重试是有成本的,而且成本按层数指数放大。
更隐蔽的是,重试不一定发生在「失败」之后。很多 SDK 的超时重试逻辑是:
def call_with_retry(fn, retries=3):
for attempt in range(retries + 1):
try:
return fn() # 超时抛 TimeoutError
except TimeoutError:
if attempt == retries:
raise
time.sleep(2 ** attempt) # 1s, 2s, 4s
这段代码把 TimeoutError 当成「确定失败」,但超时只说明本地不再等待。第二次 fn() 会重新生成请求 ID、重新走一遍完整路径;服务端看到的是一个新请求。
一个最朴素的修复是「重试时复用同一个请求 ID」:
def call_with_idempotency(fn, key: str, retries=3):
for attempt in range(retries + 1):
try:
return fn(idempotency_key=key) # 服务端按 key 去重
except TimeoutError:
if attempt == retries:
raise
time.sleep((2 ** attempt) * 0.1 + random.random() * 0.05)
注意这里加了抖动(jitter)。没有抖动,所有客户端会在同一时刻重试,形成「重试风暴」;第 2.3 节会给出完整的退避公式。
1.7.1 一个重复副作用的完整链路
把发券事故拆成时间线:
| 时间 | 客户端 | 服务端 | 下游 | 结果 |
|---|---|---|---|---|
| t0 | 发送 K1 | 收到,开始执行 | — | — |
| t1 | — | 写库成功 | 调用发券 | — |
| t2 | — | 等待下游 | 发券成功,响应慢 | 副作用已发生 |
| t3 | 3 s 超时,重试 K1 | 收到重复请求 | — | 若按 K1 去重,返回处理中 |
| t4 | 再次超时,换 K2 重试 | 收到新请求 | 再发一张 | 重复 |
| t5 | 用户看到失败,手动重试 | 收到 K3 | 再发一张 | 第三次重复 |
事故的根因不是某一个时刻,而是「重试键在中途变了」:t3 还在用 K1,t4 却换成了 K2。只要键不变,服务端就有机会把 t4 识别为同一个逻辑请求;键一变,任何去重表都无能为力。
某工具平台的发券接口没有幂等键,客户端 SDK 在超时后自动重试 3 次,用户手动重试 1 次。服务端每次请求都插入一条 coupon_issue 记录并调用下游,下游按「请求 ID」去重,但每次重试的请求 ID 都是新生成的。结果:一次用户操作产生 4 条发券记录、4 张券。
修复分三层:
- 客户端:在用户点击时生成
idempotency_key,所有自动/手动重试复用它; - 服务端:
coupon_issue表对idempotency_key建唯一索引;重复请求返回原记录; - 下游:用
coupon_issue.id作为发券的幂等 ID,保证即使服务端重放,券也只发一次。
三层缺一不可。只改客户端,恶意/旧版客户端仍能绕过;只改服务端,下游仍可能重复;只改下游,服务端会积累重复记录。
1.7.2 为什么「最多重试 3 次」不是保护
「3 次」限制的是单个客户端的尝试次数,不是系统的总请求量。考虑 1 万个客户端同时超时:
- 每个客户端重试 3 次,瞬间多出 3 万个请求;
- 服务端本来就慢,多出的请求让它更慢;
- 更多客户端超时,触发更多重试;
- 最终形成正反馈,直到服务端完全不可用。
这就是重试风暴。防护手段不是「把 3 改成 1」,而是:
- 重试预算:重试请求量不得超过原始请求量的一定比例(例如 10%);
- 退避 + 抖动:把重试摊开,避免同步;
- 熔断:下游错误率超过阈值时,直接失败一段时间,不再重试;
- 幂等:即使重试到达,也不产生额外副作用。
一个最小的重试预算实现:
import random, time
class RetryBudget:
def __init__(self, ratio: float = 0.1, min_tokens: int = 10):
self.ratio = ratio
self.tokens = float(min_tokens)
self.last = time.monotonic()
def _refill(self, requests: int) -> None:
now = time.monotonic()
self.tokens = min(self.tokens + requests * self.ratio * (now - self.last), 1000)
self.last = now
def allow(self, requests: int = 1) -> bool:
self._refill(requests)
if self.tokens >= 1:
self.tokens -= 1
return True
return False
def backoff(self, attempt: int) -> float:
return min(2 ** attempt * 0.1, 2.0) * (0.5 + random.random())
这里的 tokens 以「原始请求量 × ratio」补充,重试消耗 token;预算耗尽直接失败。第 2 章会把这段扩展成带 Retry-After 和服务端反馈的版本。
第 2 章和第 11 章会分别从协议和容量角度展开。
1.8 观测:用 curl -w 与 trace id 切开延迟
在讨论更多机制之前,先学会看一次请求。curl -w 可以把一次 HTTP 调用拆成多个阶段:
curl -sS -o /dev/null \
-w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} \
ttfb=%{time_starttransfer} total=%{time_total} code=%{http_code}\n' \
http://127.0.0.1:8081/v1/tool-calls/c-9f2a
一次本机测试的输出:
dns=0.000021 connect=0.000312 tls=0.000000 ttfb=0.004812 total=0.004901 code=200
怎么读:
| 字段 | 含义 | 异常时先看什么 |
|---|---|---|
time_namelookup | DNS 解析耗时 | 解析慢/失败;本地 hosts 是否生效 |
time_connect | TCP 建连耗时 | 连接池未命中、SYN 重传、防火墙 |
time_appconnect | TLS 握手耗时 | 证书链、会话复用、CPU |
time_starttransfer | 首字节时间(TTFB) | 服务端排队/处理慢,或响应大 |
time_total | 总耗时 | 传输/客户端解析 |
注意:time_connect 包含 time_namelookup,time_starttransfer 包含前面所有阶段。不要把它们相加。这个细节和 TCP 握手本身无关,属于 curl 的计时定义;TCP 状态机、握手细节见 Linux 书 4.5,本书不重写。
如果 TTFB 高、time_connect 正常,问题通常在服务端排队或下游;如果 time_connect 高,先查连接池、DNS、网络,而不是业务代码。
1.8.1 trace id:把客户端和服务端串起来
curl 只能看到客户端视角。要知道服务端把时间花在哪,需要 trace id。最小实现是在请求头里带一个 ID,并在每一层日志里打印:
TRACE_ID=$(python3 -c 'import uuid; print(uuid.uuid4().hex)')
curl -sS -H "X-Trace-Id: $TRACE_ID" \
-H 'Idempotency-Key: demo-1' \
-H 'Content-Type: application/json' \
-d '{"tool":"charge","args":{"amount":100}}' \
http://127.0.0.1:8081/v1/tool-calls
服务端日志:
ts=2026-10-19T09:31:02.114Z trace=d7c1... span=root op=POST /v1/tool-calls status=202 dur=3ms
ts=2026-10-19T09:31:02.117Z trace=d7c1... span=db op=insert calls dur=1ms
ts=2026-10-19T09:31:02.121Z trace=d7c1... span=tool op=charge dur=180ms status=ok
这条日志说明:总耗时 3 ms 是假象,工具执行在异步线程里继续了 180 ms;202 返回的是「已受理」,不是「已完成」。只看访问日志的 dur,会把异步执行的服务看成「很快」。
1.8.2 一条命令判断「是不是重试」
如果你怀疑重复来自重试,先数请求,而不是猜:
# 按幂等键统计服务端收到的请求数(示例:从结构化日志里抽)
jq -r 'select(.event=="tool_call") | [.idempotency_key, .attempt] | @tsv' /tmp/tool.log \
| sort | uniq -c | sort -rn | head
期望:
3 K1 1
1 K1 2
1 K2 1
K1 有 3 次 attempt 1,说明客户端在并发重试(同一逻辑请求的多个物理尝试同时到达);K2 是新键,说明客户端在中途换了键。两种情况的修复不同:前者要靠服务端并发去重,后者要靠客户端契约。
1.8.3 把 trace context 传下去
trace id 不能只打印在入口日志里,要跟着请求走。Python 可以用 contextvars 做一个最小实现:
import contextvars, uuid
_trace_id = contextvars.ContextVar("trace_id", default=None)
def new_trace_id() -> str:
tid = uuid.uuid4().hex
_trace_id.set(tid)
return tid
def current_trace_id() -> str:
tid = _trace_id.get()
if tid is None:
tid = new_trace_id()
return tid
def headers_with_trace(extra: dict | None = None) -> dict:
h = {"X-Trace-Id": current_trace_id()}
if extra:
h.update(extra)
return h
服务端收到请求后,应该:
- 优先复用
X-Trace-Id(调用方已经生成); - 没有就生成一个;
- 在每个下游调用、每条日志、每个异步任务里带上;
- 在响应头里回传,方便客户端关联。
这不是完整的 OpenTelemetry,但足以把「客户端超时」和「服务端执行」串起来。第 10 章会讲完整的 trace/span/采样,以及为什么「有 trace id」不等于「有可观测性」。
- 只在入口打印,不传给下游:下游日志里没有 trace id,无法串起来。
- 每个重试生成新 trace id:同一逻辑请求的多次尝试应该共享 trace id,用
attempt区分 span。 - 把 trace id 当请求 ID:trace 是一次调用链的标识,不是幂等键;两者不能混用。
1.9 真实案例:Knight Capital 的重复订单
2012 年 8 月 1 日,美国做市商 Knight Capital 在纽交所开盘后约 45 分钟内发出大量错误订单,造成约 4.4 亿美元损失,最终被另一家公司收购。SEC 在 2013 年对其处以 1200 万美元罚款,理由是违反了市场访问规则(Rule 15c3-5)。
公开复盘的关键事实:
- 当天部署了一段新代码,但 8 台服务器中只有 7 台更新成功;剩下 1 台仍运行旧版本。
- 旧版本里有一个早已废弃的订单执行路径(内部代号 “Power Peg”),被一个复用的配置开关重新激活。
- 这台旧服务器开始向市场发送大量重复/错误订单;其他服务器正常,因此监控没有立刻发现。
- 系统没有「按账户/按策略的订单幂等闸门」,也没有在发现异常后自动停止该策略的能力。
它不是「网络超时导致重试」的案例,而是重复副作用的另一个典型形状:同一段逻辑被旧代码路径反复执行,而没有一道「这个意图是否已经执行过」的闸门。把它放在第 1 章,是因为它和发券事故共享同一个根因:
执行动作没有身份,重复就无法被识别。
| 维度 | Knight Capital | 发券事故 | 共同点 |
|---|---|---|---|
| 重复来源 | 旧代码路径 + 配置开关 | 超时重试 + 换键 | 同一意图被多次执行 |
| 识别手段 | 无订单幂等 ID | 无幂等键 | 缺少逻辑请求身份 |
| 止损手段 | 人工发现后关停 | 用户投诉后对账 | 没有自动闸门 |
| 观测 | 监控未覆盖该路径 | 只看 HTTP 200/500 | 观测粒度不足 |
教训不是「部署要小心」这种空话,而是三条可执行的设计:
- 每个有副作用的操作都要有幂等身份:订单 ID、发券键、支付 intent,至少在存储层有唯一约束。
- 部署要能发现「版本不一致」:Knight 的 7/8 是典型的灰度不一致;健康检查不能只看端口,要看版本/能力。
- 异常时要能自动停:按策略/账户的断路器,比人工发现快得多。
本节只引用公开事实:时间、损失规模、SEC 处罚与 Rule 15c3-5 的结论。具体的代码细节来自公开报道与监管文件,不复述原文。链接见 A.3 参考文献。
1.10 设计一张远程调用契约
把前面的讨论压缩成一张契约。每次新增一个远程调用,先回答下面 12 个问题;答不上来的,不要写代码。
| # | 问题 | 为什么必须回答 | 写在哪 |
|---|---|---|---|
| 1 | 这次调用的逻辑请求 ID 是什么? | 没有身份就无法去重 | 请求头/体 |
| 2 | 谁生成幂等键,粒度是什么? | 决定重复能否被识别 | 客户端 |
| 3 | 超时预算多少,怎么随 hop 递减? | 防止无效执行和级联超时 | 契约 |
| 4 | 超时后是 unknown 还是 failed? | 决定客户端下一步 | 错误码 |
| 5 | 哪些错误可重试,哪些不可? | 避免对不可恢复错误重试 | 错误码表 |
| 6 | 重试次数、退避、抖动、预算? | 防止重试风暴 | 客户端配置 |
| 7 | 服务端如何判定重复? | 去重表/唯一约束 | 存储设计 |
| 8 | 重复请求返回什么? | 原结果/处理中/冲突 | 响应契约 |
| 9 | 参数不同但键相同怎么办? | 客户端 bug,必须拒绝 | 409 |
| 10 | 服务端过载时返回什么? | 让上游降速,而不是堆积 | 429/503 |
| 11 | 结果怎么查询?查询一致性是什么? | unknown 的出口 | 查询接口 |
| 12 | 观测字段有哪些? | 事后对账/排障 | 日志/指标/trace |
1.10.1 一份可用的契约模板
{
"request": {
"idempotency_key": "string, client-generated, stable across retries",
"client_id": "string",
"seq": "int64, monotonically increasing per client",
"deadline_ms": "int, remaining budget at send time",
"tool": "string",
"args": "object, canonicalized before fingerprinting"
},
"response": {
"call_id": "string",
"status": "pending|running|succeeded|failed|unknown",
"attempt": "int",
"result": "object|null",
"error": {"code": "string", "retryable": "bool", "message": "string"},
"retry_after_ms": "int|null",
"query_url": "string"
},
"errors": {
"409": "same key, different fingerprint",
"429": "rate limited; honor Retry-After",
"503": "overloaded or dependency unavailable; may be retryable"
}
}
模板里有三个容易漏的字段:
deadline_ms是发送时的剩余预算,不是原始超时;接收方要立刻换算成本地 deadline。attempt是物理尝试序号,用于观测和去重,不参与业务语义。error.retryable是服务端对「这个错误能否重试」的判断;客户端不要自己猜。
1.10.2 指纹:同键不同参必须拒绝
幂等键只能保证「同一个键」被处理一次,不能保证「参数没变」。如果客户端用 K1 先发 amount=100,再发 amount=1000,服务端直接返回第一次的结果,用户会以为第二次也生效了。正确做法是:
- 对请求参数做规范化(排序 key、去掉空白、统一数字格式);
- 计算 SHA-256 指纹;
- 存
(idempotency_key, fingerprint); - 同键不同指纹 → 409,并返回第一次的指纹摘要,便于排障。
import hashlib, json
def fingerprint(tool: str, args: dict) -> str:
canonical = json.dumps(
{"tool": tool, "args": args},
sort_keys=True, separators=(",", ":"), ensure_ascii=False,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
规范化不是「可选优化」。{"a":1,"b":2} 和 {"b":2,"a":1} 在 JSON 语义上相同,但直接 json.dumps 会得到不同字符串;不排序就会把同一个请求误判为冲突。反过来,浮点数 1 和 1.0、Unicode 归一化、空值省略,也需要在契约里统一。
- 这个接口有没有副作用?有副作用就必须有幂等键。
- 超时后客户端会做什么?如果答案是「重试」,键是否复用?
- 服务端去重的唯一约束建在哪张表、哪个字段?
- 同键不同参返回什么?是否可能静默覆盖?
- 过载时返回什么?上游是否会因此更糟?
1.10.3 错误码分类:哪些能重试
错误码不是给人看的注释,是给客户端做决策的输入。至少要把下面几类分开:
| 状态码 | 语义 | 可重试 | 客户端动作 |
|---|---|---|---|
| 400 | 参数格式错误 | 否 | 修正参数,不要重试 |
| 401/403 | 未认证/无权限 | 否 | 刷新凭证/提权 |
| 404 | 资源不存在 | 否 | 检查 ID |
| 409 | 同键不同指纹 / 状态冲突 | 否 | 换键或查询原请求 |
| 422 | 语义校验失败 | 否 | 修正业务参数 |
| 429 | 限流 | 是(按 Retry-After) | 退避后重试 |
| 500 | 服务端内部错误 | 视语义 | 若可能已提交,按 unknown 处理 |
| 502/504 | 网关/上游超时 | 视语义 | 按 unknown 查询/同键重试 |
| 503 | 过载/依赖不可用 | 是(按 Retry-After) | 退避 + 降级 |
最容易出错的是 500 和 504。500 可能是「写库成功后序列化响应失败」,此时副作用已经发生;504 可能是网关等不到上游响应,上游可能已提交。这两类不能简单标成可重试,必须先走 unknown 收敛。
反过来,400/401/403/404/422 重试一万次也不会成功,只会浪费连接和配额。把「不可重试」的错误也重试,是重试风暴的另一个来源。
错误码分类的本质是回答「这个错误有没有可能通过再试一次改变」。如果答案是否定的,重试只是把确定性失败放大成资源浪费。
1.10.4 契约演进:新增字段、删除字段、改语义
远程调用的契约一旦发布,就不能假设「所有客户端同时升级」。你必须按「旧客户端 + 新服务端」「新客户端 + 旧服务端」两种组合设计。
| 变更 | 旧客户端 → 新服务端 | 新客户端 → 旧服务端 | 兼容策略 |
|---|---|---|---|
| 新增可选字段 | 兼容(旧客户端不传) | 兼容(旧服务端忽略) | 默认值 + 忽略未知字段 |
| 新增必填字段 | 破坏(旧客户端不传) | 破坏(旧服务端不认) | 不要做;改用可选 + 服务端默认 |
| 删除字段 | 兼容(旧客户端传了被忽略) | 破坏(新客户端依赖旧字段) | 先标记 deprecated,观察使用量再删 |
| 修改字段语义 | 破坏 | 破坏 | 新增字段,双写/双读一段时间 |
| 修改枚举值 | 可能破坏 | 可能破坏 | 保留未知值分支,不要 default: raise |
| 修改错误码 | 可能破坏 | 可能破坏 | 新增错误码,旧码保留映射 |
一个常见的坑是「新增必填字段」。服务端以为客户端会升级,但移动端可能有一年没更新的版本;这些请求全部 400,用户看到的是「服务不可用」。正确做法是:新增字段一律可选,服务端给默认值;确实需要强制时,用版本化接口(/v2/...),而不是在原接口上加必填。
1.10.5 契约的版本化
接口版本化有三种常见方式:
| 方式 | 例子 | 优点 | 缺点 |
|---|---|---|---|
| URL 版本 | /v1/tool-calls | 直观、易缓存 | URL 膨胀,网关路由复杂 |
| Header 版本 | Accept-Version: 2 | URL 稳定 | 不易调试、缓存键要带 header |
| 字段版本 | schema_version | 细粒度 | 每个字段都要处理兼容 |
本书的项目一只用一个版本,不加版本号;这是教学简化,不是生产建议。生产环境至少要有一个「契约测试」:用旧版客户端请求新版服务端,断言不会 4xx/5xx;再用新版客户端请求旧版服务端,断言降级行为符合预期。
- 新字段是否可选?默认值是什么?
- 旧客户端不传新字段时,服务端行为是否与之前一致?
- 新客户端遇到旧服务端的未知字段/错误码,是否会崩溃?
- 删除字段前,是否统计过还有多少流量在使用?
- 语义变更是否通过新增字段 + 双写完成?
- 是否有契约测试覆盖「旧客户端 × 新服务端」?
1.11 本书的边界与不重复清单
本书假设你已经具备以下基础;没有的话,先补 Linux 书对应篇章,再回来。
| 前置知识 | 在哪 | 本书为什么不在重复 |
|---|---|---|
| TCP/UDP、可靠传输、拥塞控制 | Linux 4.4–4.6 | 本书只讲应用层 deadline 与超时语义 |
| TCP 建立/关闭、TIME_WAIT | Linux 4.5 | 连接池/重试只引用,不重画状态机 |
| socket、epoll、连接队列 | Linux 4.8–4.11 | 项目用 Python 标准库,不实现事件循环 |
| HTTP、DNS、TLS | Linux 4.7 | 只讲 HTTP 语义对重试的影响 |
| 容器、K8s 基础 | Linux 第 5 篇 | 项目不依赖容器编排 |
| 操作系统进程/线程/锁 | Linux 第 2、3 篇 | 只借用概念,不重讲实现 |
反过来,本书覆盖而母本不覆盖的内容,会在每章末尾的「母本对应」里标成「本书自编 + 论文/规范」。这不是为了显得原创,而是为了诚实:谢希仁、张彦飞和《Python 网络编程》不讲 Raft、MVCC、幂等表;这些必须靠论文、规范和自己写代码。
1.11.1 本书的章节依赖
1 请求/失败 ──▶ 2 RPC ──▶ 12 项目一
│ │
▼ ▼
3 复制 ──▶ 4 一致性 ──▶ 5 共识 ──▶ 13 项目二
│ │
▼ ▼
6 事务 ◀──── 7 队列/流 ────▶ 8 缓存/CDN
│
▼
9 锁/租约 ──▶ 10 观测 ──▶ 11 容量/演练
这张图不是「必须先读左再读右」的硬约束,而是概念依赖:不知道 unknown,就很难理解为什么 Raft 的客户端要去重;不知道 quorum,就很难理解为什么缓存一致性不能只靠 TTL。
1.11.2 三条阅读路线
不同的读者可以从这一章走向不同的方向:
| 路线 | 下一章 | 适合谁 | 先跳过什么 |
|---|---|---|---|
| 工程实现 | 2 RPC → 12 项目一 | 后端/平台工程师 | 第 3–5 章的协议证明可后补 |
| 一致性 | 3 复制 → 4 一致性 → 5 共识 | 存储/基础设施工程师 | 第 6–8 章可后读 |
| 运行治理 | 10 观测 → 11 容量 → 9 锁 | SRE/on-call | 第 6–8 章按需 |
无论走哪条,第 1 章的三个概念都要带走:逻辑请求与物理尝试的区分、unknown 状态、超时不是失败。这三件事在后面每一章都会以不同形式出现。
1.12 本章陷阱清单
- 把超时当失败:超时只说明客户端不再等待;服务端可能已执行。正确做法是进入 unknown,查询或同键重试。
- 重试时换新键:每次重试重新生成 UUID,服务端无法去重;这是重复副作用最常见的直接原因。
- 用服务端自增 ID 当幂等键:第二次请求会拿到新 ID;键必须由客户端在首次发送前生成。
- 只对「写接口」做幂等:读接口也可能触发副作用(如「读时刷新缓存/计数」);只要有副作用就要有身份。
- 把
Retry-After当建议:它是服务端给出的最小等待时间;忽略它会加重过载。 - 重试所有 5xx:500 可能是「已提交但序列化失败」;要按错误码语义分类,不能按状态码段位重试。
- 不做抖动:所有客户端在同一时刻重试,形成同步脉冲;指数退避必须加 jitter。
- 没有重试预算:单客户端 3 次,乘以百万客户端就是三百万次额外请求;预算按比例限制。
- 只看平均延迟:p50 正常不代表没问题;要看 p99/p999 和慢请求 trace。
- 把
time_connect与time_starttransfer相加:curl 的计时是包含关系,不是并列;相加会重复计算。 - 用
time.time()算超时:墙上时钟会被 NTP 调整;超时用time.monotonic()。 - 以为「内网调用不用鉴权/幂等」:内网也会横向移动、也会重试;信任边界不因网络位置而消失。
1.13 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 画出一次调用的路径 | 至少 8 个 hop,标出每 hop 的失败模式 | 1.1 |
| 解释逻辑请求与物理尝试 | 能说出为什么服务端必须区分两者 | 1.1、1.3 |
| 画出请求状态机 | 包含 unknown,且能说出离开 unknown 的两种动作 | 1.3 |
| 解释超时的三种真相 | 能分别说出服务端状态与安全动作 | 1.6 |
| 计算重试放大 | 给定两层各 3 次重试,算出 16 | 1.7 |
| 设计幂等键 | 能定义键的粒度、生成方、复用规则 | 1.10 |
| 计算指纹 | 知道要规范化、排序、SHA-256 | 1.10 |
| 用 curl -w 切延迟 | 能区分 DNS/连接/TTFB/总耗时 | 1.8 |
| 用 trace 找慢 span | 能从日志定位最慢下游 | 1.8 |
| 说出三个真实教训 | 能复述 Knight 的 7/8 部署与重复订单 | 1.9 |
| 写契约的 12 个问题 | 至少能答出 10 个 | 1.10 |
Q1 客户端 1 s 超时,服务端 1.2 s 完成并提交。客户端收到 timeout 后直接用新键重试,会发生什么?应该怎么做?
Q2 为什么 curl 的 time_connect 和 time_starttransfer 不能相加?
Q3 一个接口的 p50 是 30 ms,p99 是 3 s。产品说「平均很快」,你怎么反驳?
Q4 幂等键用 user_id + timestamp 生成,有什么问题?
Q5 服务端收到同键但不同参数的请求,应该返回 200 + 第一次结果,还是 409?为什么?
Q6 重试预算设为「原始请求的 10%」,当原始 QPS 是 1000 时,最多允许多少次重试?如果重试仍然失败怎么办?
Q7 为什么不能用两台机器的 time.time() 判断哪个请求先发生?
Q8 一次调用经过 10 个服务,每个服务 1% 概率超过 1 s。端到端至少一个服务超 1 s 的概率是多少?这说明了什么?
答案
A1 服务端会创建第二条记录并再次执行,产生重复副作用。正确做法是用原键重试,或先查询原 call_id 的状态;只有确认原请求 failed 且副作用未发生,才考虑新键。
A2 time_connect 包含 DNS 解析,time_starttransfer 包含连接、TLS、服务端处理到首字节;它们是累计值,不是互斥的阶段。要算「服务端处理」应该用 time_starttransfer - time_appconnect(近似),而不是把几项相加。
A3 平均值会被大量快请求拉低;p99 是 3 s 意味着 1% 的用户体验很差。按每天 1 亿次调用算,每天有 100 万次超过 3 s;如果这些请求集中在高峰,会拖垮重试和队列。
A4 同一用户在同一秒内重试会生成相同的键(碰撞),不同逻辑请求可能被误判为重复;同一用户跨秒重试又会生成不同的键(无法去重)。键应由客户端为每个逻辑请求生成一次,并在所有重试中复用,例如 UUID/ULID 或 client_id:seq。
A5 返回 409。200 + 第一次结果会让客户端误以为第二次参数也生效了,可能造成「用户以为改了金额,实际没改」的静默错误。409 强制客户端修正键或参数。
A6 原始 1000 QPS 时,重试上限约 100 QPS。预算耗尽后不应继续重试,而应返回 503 + Retry-After,让上游/用户降速;同时触发告警,检查下游是否恢复。
A7 两台机器的墙上时钟可能相差几毫秒到几秒,NTP 还会调整甚至倒退;时间戳不能用来确定跨机器的先后。用逻辑时钟、版本号或共识协议定序。
A8 1 - 0.99^10 ≈ 9.6%。即使每个服务都很可靠,串行链路也会放大长尾;这要求减少串行 hop、设置合理 deadline、对慢请求做对冲/降级。
母本对应
| 母本 | 章节 | 本书落点 | 程度 | 说明 |
|---|---|---|---|---|
| 谢希仁《计算机网络》第 8 版 | 第 5 章 运输层、第 6 章 应用层 | 1.1、1.8 | 背景 | 只借用「请求经过网络分层」的视角;TCP 细节不重写 |
| 张彦飞《深入理解 Linux 网络》 | 第 4–7 章(TCP、socket、连接) | 1.1、1.2、1.8 | 背景 | 连接池/建连失败只引用,不展开内核路径 |
| 《Python 网络编程》 | socket/HTTP 客户端、并发服务器 | 1.8、1.10 | 精选 | 借用工程风格;代码为本书重写 |
| 《Linux 系统与内核》第 4 篇 | 4.1–4.11(网络与协议栈) | 1.1、1.8、1.11 | 交叉引用 | 明确不重复 TCP/IP、socket、epoll、HTTP/DNS/TLS |
| 《Linux 系统与内核》第 5 篇 | 5.1–5.6(容器与 K8s) | 1.11 | 交叉引用 | 本书不写容器编排 |
| 本书自编 + 公开复盘 | Knight Capital 2012、SEC 2013 | 1.9 | 完整 | 只引用公开事实,不复制原文 |
下一站:知道了「一次调用可能未知」,下一步要把它变成可执行的协议。进入 2 RPC:超时、重试、幂等、背压,看 deadline 怎么传、重试怎么限、幂等怎么做、背压怎么建。