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

1 从一次工具调用到分布式请求

本章回答

一次工具调用离开本机之后,究竟经过了哪些 hop?为什么本地函数调用只有「成功/失败」,远程调用却多出一个「未知」?超时、重试、重复、乱序分别在哪个环节发生,为什么会把一次操作变成两次副作用?

读完你会

能画出一次远程调用的状态机并标出 unknown;能用 curl -w 和 trace id 把一次请求的延迟切成若干段;能写出远程调用契约的 12 个必答问题;能解释为什么「超时后重试」是最常见的重复副作用来源。

一个内部工具平台提供「发券」能力。客户端调用 POST /tool/issue-coupon,服务端做三件事:在数据库里写一条发券记录、调用下游券系统、把券号返回给客户端。某天网络抖动,客户端在 3 秒时超时;SDK 的重试策略是「超时重试 3 次,指数退避」。三次重试都超时,用户看到「发放失败」,又点了一次。最后用户收到 4 张券。

这个事故里没有哪台机器真正宕机,也没有哪段代码写错。三方的行为单独看都合理:

  • 客户端:「超时就是失败,失败当然要重试。」
  • 服务端:「每个请求我都处理了,是网络没把响应带回去。」
  • 下游:「我们只保证至少一次,重复调用要上游去重。」

把三句话放在一起,重复副作用就出现了。分布式系统的很多故障不是「组件坏了」,而是「组件对同一个事实的理解不一致」:客户端认为失败,服务端认为成功,下游认为被调用了两次。本章要做的,就是把这三种理解拆开,落到一条请求路径、一个状态机和一个契约上。

这一章是全书的地基。后面的 RPC、复制、一致性、事务、队列、缓存、锁、观测、容量,都会反复回到本章的三个问题:这次调用成功了吗?读到的值是最新的吗?出事时看什么?先把这三个问题的语言统一,后面才不会各说各话。

1.1 一次工具调用的十二个 hop

先看一条具体的路径。假设客户端在浏览器里点「发放」,请求最终到达工具服务的 /v1/tool-calls:

一次工具调用的路径与延迟预算 客户端经过接入层到服务 A、服务 B,再访问存储;下方是各段延迟占总预算的比例。 一次工具调用的路径与延迟预算 客户端 重试 / 超时 / 幂等键 接入层 鉴权 / 限流 / 路由 服务 A RPC / 事务边界 服务 B 幂等 / 背压 存储 日志 / MVCC 1–3 ms 2–8 ms 5–50 ms 1–10 ms 每一跳都可能排队;超时预算按跳分配,不按「服务」平均分。 1 s 预算怎么花(示例) 240 380 160 每条都按 1 s 总预算切;任何一段超时,整条链路应尽快失败,而不是等满 1 s。
读图:一次调用不是一个「服务」,而是一串 hop。真正的 p99 往往由最慢的那一跳决定;把预算按 hop 切开,才能知道该给谁设更短的 deadline。

图上的五个方框只是粗粒度。把一次调用拆细,至少经过下面十二个 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 mstoken 过期、路由规则不命中
7服务 A 排队0–1 s工作线程不足、GC 停顿
8服务 A 调服务 B1–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 的一次丢包。只看客户端错误码,等于只看最后一环。

第三,重试会改变路径,而不是回到原点。 第二次尝试可能命中另一个实例、另一个可用区、另一个连接;它可能绕过第一次已经完成的副作用,也可能在同一张表上再写一次。重试不是「再试一次」,而是「再产生一次请求」。

定义 1.1.1 · 逻辑请求与物理尝试

逻辑请求(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、succeeded、failed 与 unknown 之间的迁移;超时进入 unknown,查询或同键重试才能离开。 一次逻辑请求的状态机:unknown 不是失败 pending 已接收,未执行 running 执行中(有租约) succeeded 副作用已提交 failed 确定未提交 unknown 超时 / 响应丢失 开始 提交 + 响应 明确失败 客户端超时 查询到成功 同键重试/查询 确定失败 关键不变量 unknown 不能当 failed 只能查询或同键重试 不能换新键重发
读图:很多系统把 unknown 直接折成 failed,于是客户端重试、服务端再执行一次。把 unknown 画出来,是为了在代码里给它一个明确的分支,而不是靠异常类型猜。

图中最重要的是 unknown。它不是「失败」的同义词,也不是「成功」的模糊态,而是一个需要额外动作才能离开的状态:查询服务端、用同一个键重试、或者等待对账。

定义 1.3.1 · 请求状态机

一次逻辑请求的状态取值:

  • 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 的三个误解
  1. 「unknown 就是失败,重试就行」:换新键重试会重复副作用;必须复用原键。
  2. 「查询状态一定可靠」:查询也可能超时/读到旧副本;查询本身要带一致性要求(第 4 章)。
  3. 「服务端返回 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 决定体验

假设一个接口的延迟分布如下:

分位延迟含义
p5080 ms一半请求比这快
p90220 ms10% 请求超过 220 ms
p991.2 s1% 请求超过 1.2 s
p9994.5 s千分之一超过 4.5 s

如果只看 p50,你会觉得「80 ms 很快」;但对一个每天 1 亿次调用的接口,p99 意味着每天有 100 万次请求超过 1.2 秒。用户感知的是他这一次的延迟,不是平均值。

更麻烦的是,p99 会被调用链放大。一个请求要串行调用 10 个服务,每个服务独立地有 1% 的概率超过 1 秒,那么至少一个服务超 1 秒的概率是:

P(slow)=1−(1−0.01)10≈9.6% P(\text{slow}) = 1 - (1 - 0.01)^{10} \approx 9.6%

也就是说,单服务 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 msp99 锁等待
响应回传 + 客户端解析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 并在网关做一次校准。两者都要在契约里写清楚「谁负责换算」。

为什么不用墙上时钟传 deadline

墙上时钟会被 NTP 向前/向后调整。如果发送方和接收方的时钟差 2 秒,接收方可能认为 deadline 已过(立刻失败),也可能认为还有很多时间(继续执行)。单调时钟只在同一台机器内有意义,不能直接跨机器传;所以协议里通常传相对预算,接收方用本地单调时钟换算。

1.4.3 一个排队算例:为什么 70% 就开始抖

假设一个服务有 10 个工作线程,每个请求平均处理 10 ms,服务率 μ = 100 req/s,到达率 λ = 90 req/s。利用率:

ρ=λμ=0.9 \rho = \frac{\lambda}{\mu} = 0.9

用最简单的 M/M/1 近似(单队列、单服务台;多线程时用 M/M/c,趋势相同),平均停留时间:

W=1μ−λ=1100−90=0.1 s=100 ms W = \frac{1}{\mu - \lambda} = \frac{1}{100 - 90} = 0.1\ \text{s} = 100\ \text{ms}

也就是说,处理时间只有 10 ms,但排队让平均停留时间变成 100 ms。把利用率再提高:

利用率 ρλ (req/s)平均停留 W相对处理时间
0.55020 ms2×
0.77033 ms3.3×
0.990100 ms10×
0.9595200 ms20×
0.99991 s100×

这张表说明:容量不是「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 失败的处理顺序

遇到一次失败,正确的处理顺序是:

  1. 分类:是确定没执行、确定失败、还是 unknown?
  2. 收敛:unknown 先查询/同键重试,不要换键;
  3. 隔离:把故障实例从负载均衡里摘掉,避免重试继续打到它;
  4. 降级:如果依赖不可用,返回缓存/默认值/排队,而不是无限重试;
  5. 观测:记录 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,服务端可能处于三种状态:

超时后的三种真相 客户端在 T 时刻返回 timeout;请求可能没到、执行了但响应丢失、或执行并提交但响应丢失。 超时后的三种真相 客户端 服务端 T = 1 s(客户端超时) ① 请求没到 报文丢失/连接未建立 server 无记录 ② 执行了,响应丢了 副作用已发生,客户端不知道 server 有记录,状态成功 已执行 ③ 执行并提交,响应丢了 数据库已提交,最危险 server 可查到结果 已执行 客户端看到的都是 timeout;服务端状态分别是「无」「成功」「成功」。
读图:同一条 timeout 日志背后可能是三种完全不同的服务端状态。只看客户端错误码,无法判断该不该重试;这就是为什么需要幂等键和服务端查询接口。

第一种:请求没到。 报文在网络上丢了,或者连接根本没建立。服务端没有任何记录,客户端重试是安全的(但仍应复用同一个键,避免「第一次其实到了、只是响应丢了」的边界情况)。

第二种:执行了,响应丢了。 服务端完成了副作用,但在返回路径上连接被重置、代理超时、客户端进程被 kill。服务端有记录,客户端不知道。此时换新键重试会重复执行;同键重试会被去重。

第三种:执行并提交,响应丢了。 服务端不仅执行了,还把结果写进了数据库/下游。这是最危险的一种,因为「补偿」也可能重复。此时唯一安全的做法是:用同一个键查询/重试,让服务端返回已提交的结果。

三种真相在客户端看来完全一样:都是 timeout。这就是为什么「超时」不能作为重试的充分条件。重试可以发生,但必须带上能让服务端识别「这是同一个逻辑请求」的键。

例 1.6.1 · 一次发券的三种结局

客户端发送 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 次。最坏情况下,下游收到:

(1+3)×(1+3)=16 (1 + 3) \times (1 + 3) = 16

次请求。如果下游每次都会发一张券,用户就可能收到 16 张。重试是有成本的,而且成本按层数指数放大。

重试预算:次数不是策略,放大倍数才是 单层 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 退避 + 抖动把重试摊开;预算限制总量。 没有预算,重试会把下游的慢变成雪崩。
读图:指数退避只能减少同一时刻的并发,不能消除总放大。真正要限制的是「重试请求 / 原始请求」的比值;预算耗尽后应快速失败,而不是继续排队。

更隐蔽的是,重试不一定发生在「失败」之后。很多 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—等待下游发券成功,响应慢副作用已发生
t33 s 超时,重试 K1收到重复请求—若按 K1 去重,返回处理中
t4再次超时,换 K2 重试收到新请求再发一张重复
t5用户看到失败,手动重试收到 K3再发一张第三次重复

事故的根因不是某一个时刻,而是「重试键在中途变了」:t3 还在用 K1,t4 却换成了 K2。只要键不变,服务端就有机会把 t4 识别为同一个逻辑请求;键一变,任何去重表都无能为力。

例 1.7.1 · 重试导致的重复副作用:发券

某工具平台的发券接口没有幂等键,客户端 SDK 在超时后自动重试 3 次,用户手动重试 1 次。服务端每次请求都插入一条 coupon_issue 记录并调用下游,下游按「请求 ID」去重,但每次重试的请求 ID 都是新生成的。结果:一次用户操作产生 4 条发券记录、4 张券。

修复分三层:

  1. 客户端:在用户点击时生成 idempotency_key,所有自动/手动重试复用它;
  2. 服务端:coupon_issue 表对 idempotency_key 建唯一索引;重复请求返回原记录;
  3. 下游:用 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_namelookupDNS 解析耗时解析慢/失败;本地 hosts 是否生效
time_connectTCP 建连耗时连接池未命中、SYN 重传、防火墙
time_appconnectTLS 握手耗时证书链、会话复用、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,会把异步执行的服务看成「很快」。

读什么先看 TTFB 与 trace 的最慢 span,不要先看平均值。
正常TTFB 与下游 span 之和接近 total;每个 span 有 trace id。
异常TTFB 高但下游 span 短 → 本服务排队;下游 span 长 → 依赖慢。

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

服务端收到请求后,应该:

  1. 优先复用 X-Trace-Id(调用方已经生成);
  2. 没有就生成一个;
  3. 在每个下游调用、每条日志、每个异步任务里带上;
  4. 在响应头里回传,方便客户端关联。

这不是完整的 OpenTelemetry,但足以把「客户端超时」和「服务端执行」串起来。第 10 章会讲完整的 trace/span/采样,以及为什么「有 trace id」不等于「有可观测性」。

trace id 的三个常见错误
  1. 只在入口打印,不传给下游:下游日志里没有 trace id,无法串起来。
  2. 每个重试生成新 trace id:同一逻辑请求的多次尝试应该共享 trace id,用 attempt 区分 span。
  3. 把 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观测粒度不足

教训不是「部署要小心」这种空话,而是三条可执行的设计:

  1. 每个有副作用的操作都要有幂等身份:订单 ID、发券键、支付 intent,至少在存储层有唯一约束。
  2. 部署要能发现「版本不一致」:Knight 的 7/8 是典型的灰度不一致;健康检查不能只看端口,要看版本/能力。
  3. 异常时要能自动停:按策略/账户的断路器,比人工发现快得多。
事实边界

本节只引用公开事实:时间、损失规模、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,服务端直接返回第一次的结果,用户会以为第二次也生效了。正确做法是:

  1. 对请求参数做规范化(排序 key、去掉空白、统一数字格式);
  2. 计算 SHA-256 指纹;
  3. 存 (idempotency_key, fingerprint);
  4. 同键不同指纹 → 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. 这个接口有没有副作用?有副作用就必须有幂等键。
  2. 超时后客户端会做什么?如果答案是「重试」,键是否复用?
  3. 服务端去重的唯一约束建在哪张表、哪个字段?
  4. 同键不同参返回什么?是否可能静默覆盖?
  5. 过载时返回什么?上游是否会因此更糟?

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: 2URL 稳定不易调试、缓存键要带 header
字段版本schema_version细粒度每个字段都要处理兼容

本书的项目一只用一个版本,不加版本号;这是教学简化,不是生产建议。生产环境至少要有一个「契约测试」:用旧版客户端请求新版服务端,断言不会 4xx/5xx;再用新版客户端请求旧版服务端,断言降级行为符合预期。

契约变更的检查单
  1. 新字段是否可选?默认值是什么?
  2. 旧客户端不传新字段时,服务端行为是否与之前一致?
  3. 新客户端遇到旧服务端的未知字段/错误码,是否会崩溃?
  4. 删除字段前,是否统计过还有多少流量在使用?
  5. 语义变更是否通过新增字段 + 双写完成?
  6. 是否有契约测试覆盖「旧客户端 × 新服务端」?

1.11 本书的边界与不重复清单

本书假设你已经具备以下基础;没有的话,先补 Linux 书对应篇章,再回来。

前置知识在哪本书为什么不在重复
TCP/UDP、可靠传输、拥塞控制Linux 4.4–4.6本书只讲应用层 deadline 与超时语义
TCP 建立/关闭、TIME_WAITLinux 4.5连接池/重试只引用,不重画状态机
socket、epoll、连接队列Linux 4.8–4.11项目用 Python 标准库,不实现事件循环
HTTP、DNS、TLSLinux 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 本章陷阱清单

12 条高频错误
  1. 把超时当失败:超时只说明客户端不再等待;服务端可能已执行。正确做法是进入 unknown,查询或同键重试。
  2. 重试时换新键:每次重试重新生成 UUID,服务端无法去重;这是重复副作用最常见的直接原因。
  3. 用服务端自增 ID 当幂等键:第二次请求会拿到新 ID;键必须由客户端在首次发送前生成。
  4. 只对「写接口」做幂等:读接口也可能触发副作用(如「读时刷新缓存/计数」);只要有副作用就要有身份。
  5. 把 Retry-After 当建议:它是服务端给出的最小等待时间;忽略它会加重过载。
  6. 重试所有 5xx:500 可能是「已提交但序列化失败」;要按错误码语义分类,不能按状态码段位重试。
  7. 不做抖动:所有客户端在同一时刻重试,形成同步脉冲;指数退避必须加 jitter。
  8. 没有重试预算:单客户端 3 次,乘以百万客户端就是三百万次额外请求;预算按比例限制。
  9. 只看平均延迟:p50 正常不代表没问题;要看 p99/p999 和慢请求 trace。
  10. 把 time_connect 与 time_starttransfer 相加:curl 的计时是包含关系,不是并列;相加会重复计算。
  11. 用 time.time() 算超时:墙上时钟会被 NTP 调整;超时用 time.monotonic()。
  12. 以为「内网调用不用鉴权/幂等」:内网也会横向移动、也会重试;信任边界不因网络位置而消失。

1.13 本章出口检查

检查项达标线没达标去哪
画出一次调用的路径至少 8 个 hop,标出每 hop 的失败模式1.1
解释逻辑请求与物理尝试能说出为什么服务端必须区分两者1.1、1.3
画出请求状态机包含 unknown,且能说出离开 unknown 的两种动作1.3
解释超时的三种真相能分别说出服务端状态与安全动作1.6
计算重试放大给定两层各 3 次重试,算出 161.7
设计幂等键能定义键的粒度、生成方、复用规则1.10
计算指纹知道要规范化、排序、SHA-2561.10
用 curl -w 切延迟能区分 DNS/连接/TTFB/总耗时1.8
用 trace 找慢 span能从日志定位最慢下游1.8
说出三个真实教训能复述 Knight 的 7/8 部署与重复订单1.9
写契约的 12 个问题至少能答出 10 个1.10
即时自测 · 1

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 20131.9完整只引用公开事实,不复制原文

下一站:知道了「一次调用可能未知」,下一步要把它变成可执行的协议。进入 2 RPC:超时、重试、幂等、背压,看 deadline 怎么传、重试怎么限、幂等怎么做、背压怎么建。