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

封面与导览

分布式系统与数据面 · 定制教程

分布式系统与数据面

从一次工具调用出发,沿请求、复制、事务、消息、缓存与观测一路走到可验证的一致性。
5 篇 · 13 章 2 个可运行项目 只用 Python 标准库 章末陷阱清单与出口检查
查看范围、前置与边界
前置:TCP 三次握手、socket、epoll、HTTP、DNS、TLS 读《Linux 系统与内核》第 4 篇;容器与 Kubernetes 基础读第 5 篇。本书只写数据面,不重写 TCP/HTTP/K8s 基础。
两个项目都只用 Python 标准库,不依赖 Docker 或云账号;协议事实以 2026 年仍有效的规范与实现为准,依赖版本的结论都标了版本。
CONTENTS / 目录5 篇 · 13 章 · 2 个项目

左侧目录是完整导航;页首搜索支持全文检索。第一次来,先读 三条阅读路线,再从 第 1 章 开始。

ROUTE 01 · 后端工程师 把一次调用写对

第 1、2 章精读远程调用的状态与重试预算;第 6 章精读 MVCC,第 7、8 章选读;第 12 章做项目一,注入丢响应故障,证明副作用只发生一次。约 12–16 小时。

展开路线 →
ROUTE 02 · SRE / 平台 把故障看清楚

先快读第 1 章的失败分类,再精读第 3、9、10 章的分区、租约与观测;第 11 章写一份爆炸半径受控的演练剧本。约 10–14 小时。

展开路线 →
ROUTE 03 · 存储 / 基础设施 把一致性讲清楚

精读第 3、4、5 章的复制拓扑、线性一致判定与 Raft,用第 6 章的 MVCC 解释可见性;第 13 章做项目二。约 16–20 小时。

展开路线 →
这本书回答

一次工具调用离开本机之后,为什么会出现「超时了但已经成功」「重试一次扣了两次钱」「主库切换后刚写的记录不见了」「扩容之后 p99 反而更高」?这些问题不是名词不够多,而是失败模型、一致性契约和容量边界没有写清楚。

读完之后

你能为一个远程调用写出超时、重试、幂等与背压的完整契约;能判断该选主从、多主还是无主;能读懂 Raft 的选举与提交;能用 MVCC 解释隔离级别;能把队列、缓存、CDN、锁、观测和容量串成一条可验证的数据面。

分布式系统最容易写成两本错的书。

第一本错在只讲名词:CAP、BASE、最终一致、服务网格、云原生、可观测性,每一页都有新词,合上书却画不出一条请求的路径,也解释不了一次重复扣款。第二本错在只讲协议:Paxos 的两个阶段、Raft 的五个状态、2PC 的投票与提交都写对了,但读者不知道这些协议在业务代码里对应哪一行,也不知道超时该设 200 ms 还是 2 s。

这本书想写第三本:从一次具体的工具调用出发,沿着请求、复制、事务、消息、缓存和观测一路走,每到一个概念都问三件事——它解决什么失败、代价是什么、怎么观测。 协议是骨架,失败是血肉,观测是证据。

这本书的位置

它不是《Linux 系统与内核》的续集,也不是微服务名词表。它假设你已经知道 TCP 三次握手、socket、epoll、HTTP、DNS、TLS 的基本工作方式;这些内容在 Linux 书第 4 篇 已经讲过,本书只引用、不重写。容器与 Kubernetes 的基础同理,见 Linux 书第 5 篇。

本书只写数据面:请求怎么发、失败怎么判、状态怎么复制、事务怎么提交、消息怎么投递、缓存怎么失效、锁怎么过期、指标怎么解释、容量怎么估。控制面(调度、编排、服务发现、配置下发)只在必要时出现,而且从不展开成产品手册。

                  ┌──────────────────────────────────────────────┐
                  │  一次工具调用的数据面(本书主线)              │
                  └──────────────────────────────────────────────┘
   客户端            接入层              服务层                 状态层
  ┌────────┐      ┌────────┐        ┌────────────┐        ┌──────────────┐
  │ 重试   │─────▶│ 限流   │───────▶│ RPC/事务   │───────▶│ 复制日志     │
  │ 超时   │      │ 鉴权   │        │ 幂等键     │        │ 共识/选主    │
  │ 幂等键 │      │ 路由   │        │ 背压       │        │ MVCC/队列    │
  └────────┘      └────────┘        └────────────┘        └──────────────┘
       │                │                  │                     │
       └────────────────┴──────────────────┴─────────────────────┘
                    观测:trace / metrics / logs / replay

读这张图要抓住一个事实:同一份「一次调用」会在六个地方留下状态——客户端的内存、接入层的连接、服务端的请求上下文、数据库的事务、复制日志的位置、观测系统的 span。一次重复副作用,往往是其中两个状态对不上:客户端认为失败了,服务端已经提交了;服务端认为只执行了一次,复制日志里却有两条。

全书地图

篇章回答的问题关键产物
第 1 篇 请求与失败1–2一次远程调用为什么会「未知」;超时、重试、幂等、背压怎么组合远程调用契约、重试预算、幂等状态机
第 2 篇 复制与一致3–5数据放在几台机器上;读到旧值算不算错;共识到底保证什么复制拓扑、一致性契约、Raft 检查表
第 3 篇 数据与消息6–8事务边界在哪;消息为什么重复;缓存为什么越缓存越旧MVCC 读法、投递语义、缓存失效策略
第 4 篇 运行与治理9–11锁过期了怎么办;出事时看什么;容量和演练怎么做fencing token、观测面板、故障演练剧本
第 5 篇 项目12–13把前四篇压成两个能跑的服务幂等工具调用后端、三节点复制 KV

每一章都按同一个骨架写:先给一条真实请求或一次真实故障,再拆机制,再给边界与观测命令,最后落到「如果你来实现,必须回答哪些问题」。章末有 8–15 条陷阱清单和出口检查;陷阱不是「注意安全」这种空话,而是「把 Retry-After 当建议、把超时当失败、把 RPO=0 当默认」这类会直接导致事故的判断。

两个项目

纸上的协议必须落到进程上。第 12、13 章是两个可运行的项目,代码在 projects/:

  • 项目一 · 幂等工具调用后端:HTTP + SQLite,带幂等键、请求指纹、租约、崩溃恢复、丢响应故障注入。你会亲手制造「服务端成功了,客户端超时了」,再用同一个键重试,证明副作用只发生一次。
  • 项目二 · 小型复制 KV:三个 Python 进程,简化 Raft 选举与日志复制,支持线性一致读、租约读和陈旧读;用分区脚本把主节点隔离,观察旧主如何被 fencing 挡住。

两个项目都只用 Python 标准库,python3 -m unittest 可跑,故障注入脚本 bash -n 通过,且不依赖 Docker 或云账号。它们不是玩具:每个不变量都有对应的测试,每个测试都对应正文的一节。

不做什么

  • 不重写 TCP/IP、socket、epoll、HTTP/DNS/TLS;这些在 Linux 书第 4 篇。
  • 不重写容器、Kubernetes 基础与云原生编排;这些在 Linux 书第 5 篇。
  • 不把 CAP 当口号。第 4 章会给出可判定的定义、反例和检查方法;只喊「三者选二」的段落不会出现。
  • 不把分布式写成微服务名词表。每个名词必须能落到一条请求路径、一个状态字段或一条观测命令。
  • 不复制母本原文、代码或图。所有图由本书自绘,所有代码为本书编写,公开案例只引用事实与链接。
版本与时间

本书的协议事实以 2026 年仍有效的规范与实现为准:Raft 以 Ongaro & Ousterhout 的论文与 etcd 3.5 的行为为参照;MVCC 以 PostgreSQL 16 与 InnoDB 8.0 为参照;对象存储以 S3 的强一致模型为参照。产品行为会变,书中凡是依赖版本的结论都标了版本;没有标版本的,按「截至 2026 年公开文档」理解。

下一站:如果你想知道这本书为什么值得单独写,读 为什么写这本书;如果你要先确认自己是否准备好了,做 入学诊断。

COLOPHON / 题记 分布式系统与数据面 · 定制教程;协议与产品行为以公开规范和实现为准,依赖版本的结论都标了版本。 MDBOOK · 2026-10

为什么写这本书

1 一个真实的小事故

一个内部工具平台提供「发券」能力:客户端调用 POST /tool/issue-coupon,服务端写数据库、调下游发券、返回券号。某天网络抖动,客户端在 3 秒时超时;客户端 SDK 的重试策略是「超时重试 3 次,指数退避」。3 次重试都超时,用户看到失败,点了第二次「发放」。最后用户收到 4 张券,财务对账时才发现。

复盘时三个团队各说各话:

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

三方都没有说错,但三方合起来造出了一个重复副作用。这本书的第一章就从这件事开始,因为它是分布式系统最典型的形状:不是「机器坏了」,而是「成功与失败的边界在网络上变得不可判定」。

2 为什么现有的书不够用

市面上的分布式教材大致分三类。

第一类是协议教材:Paxos、Raft、2PC、线性一致性,证明写得很漂亮,但读者学完不知道 etcdctl get 的 --consistency=s 和 --consistency=l 差别在哪,也不知道为什么 Kubernetes 的 apiserver 会在 etcd 选举时抖动。

第二类是系统教材:讲 HDFS、Spark、Kafka、Dynamo 的架构,图很多,但代码离业务太远;读者能画出三层架构,却写不出一个带幂等键的接口。

第三类是工程手册:重试、熔断、限流、可观测性,条目很全,但缺少「为什么」——为什么指数退避要加抖动?为什么重试预算比重试次数更重要?为什么线性一致读在 Raft 里要走一次心跳?

这本书试图把三者接起来:协议给出保证,系统给出实现,工程给出边界。 每讲一个协议,都要回答「它在哪个失败模型下成立」;每讲一个工程手段,都要回答「它把哪一类失败转换成了哪一类失败」。

3 与四本母本的关系

本书的母本定位是四本书的组合,而不是任何一本的替代:

母本它给了什么本书怎么用不重复什么
谢希仁《计算机网络》第 8 版分层、IP、TCP、应用层协议的系统视角作为「请求离开本机之后」的底层事实来源;只在需要解释超时、连接复用、DNS 轮询时引用不重写 TCP 状态机、拥塞控制、HTTP 报文
张彦飞《深入理解 Linux 网络》Linux 协议栈的实现路径与性能视角解释「为什么客户端超时了服务端却收到了」「为什么连接池会放大故障」不重写收包/发包路径、epoll、连接队列
《Python 网络编程》socket、并发服务器、协议实现的工程视角两个项目的 HTTP 服务、超时、连接处理沿用其工程风格不复制代码,不把 API 手册抄成章节
《Linux 系统与内核》网络/云原生篇内核、容器、K8s 的完整基础只在边界处交叉引用,作为前置知识不重写 TCP/IP、socket、epoll、HTTP/DNS/TLS、容器与 K8s 基础

这张表说明一件事:本书的“新”不在底层协议,而在底层协议之上的失败处理与状态管理。 读者如果对 TCP 还不熟,应该先去补 Linux 书第 4 篇;如果已经熟,本书可以从第 1 章直接开始。

4 三个贯穿全书的问题

4.1 这次调用到底成功了吗

在单机上,函数返回就是成功,抛异常就是失败。跨进程之后,出现第三种状态:调用方不知道。请求可能没到、可能到了没执行、可能执行了没提交、可能提交了响应丢了。本书把「未知」当作一等状态,而不是把它硬塞进成功或失败。

第 1 章定义请求状态机,第 2 章把未知拆成超时与重试,第 12 章用一个可运行的服务证明:未知状态下,唯一安全的做法是让重试携带同一个幂等键,而不是让客户端「再发一次新请求」。

4.2 读到的值是最新的吗

复制带来延迟,延迟带来旧值。旧值不一定是错误——取决于你向读者承诺了什么。第 3 章讲复制拓扑,第 4 章把「线性一致、顺序一致、因果一致、最终一致」变成可判定的历史条件,第 13 章用三节点 KV 让你亲手制造陈旧读。

这里最容易被口号带偏的是 CAP。本书不会写「CAP 三者选二」就结束,而是给出:分区发生时,究竟是放弃线性一致还是放弃可用;PACELC 在无分区时又要求你在延迟与一致之间选什么;以及一个系统在什么条件下才算「满足线性一致」。

4.3 出故障时看什么

没有观测的分布式系统只能靠猜。第 10 章把 trace、指标、日志、回放拆成四种证据,并说明每种证据的盲区:trace 会漏采样,指标会丢标签,日志会重复,回放会放大副作用。第 11 章把观测接到容量与演练上:不是「看板好看」,而是「用一条命令判断是过载、是重试风暴,还是下游变慢」。

5 这本书的写法

  • 先失败,后机制:每一节尽量从一个可复现的失败或一条真实请求开始,再解释机制。没有失败场景的协议段落,会被压缩或删除。
  • 给边界,不给口号:不说「最终一致就够了」,而说「在什么业务下、允许多长时间的旧值、用什么读修复兜底」。
  • 可执行:命令能复制运行,代码能编译/运行,脚本 bash -n 通过;不能运行的片段明确标为伪代码。
  • 不复制:所有图自绘,所有代码自写;公开案例只引用事实、时间线和链接,不搬运原文。
  • 可检查:每章有出口检查,全书有覆盖矩阵和 check_book.py。写得好不好可以争论,覆盖没覆盖、锚点有没有断,是机械可查的。

6 写给谁

  • 写过 HTTP 服务、用过数据库,但一遇到「超时重试」「主从切换」「缓存不一致」就靠经验的后端工程师;
  • 正在把单体拆成服务、需要给团队定 RPC 与数据一致性规范的架构师;
  • 要做容量规划、故障演练、on-call 的 SRE;
  • 想从「会调 API」走到「能解释实现」的学生。

不要求你会写 Raft,但要求你能读 Python;不要求你部署过 Kubernetes,但要求你知道 Pod 是什么。如果这些都不熟,先读 Linux 书的对应篇章,再回来。

一句话

分布式系统的难点不是「把机器连起来」,而是「在部分失败、没有全局时钟、消息可能重复的条件下,仍然让上层看到一个可解释的状态」。

下一站:范围、取舍与不重复清单——先把本书不写什么划清楚,后面的每一章才不会变成其他书的复述。

范围、取舍与不重复清单

1 本书的问题边界

本书讨论数据面(data plane):一次请求从离开客户端到落盘/返回之间,数据怎么走、状态怎么变、失败怎么处理。控制面(control plane)只在必要处出现,因为控制面的错误最终也会以数据面的形式暴露——例如 etcd 选举抖动会变成 apiserver 的 5xx,Kubernetes 的滚动发布会被误读成「服务变慢」。

属于本书不属于本书
RPC 的超时、重试、幂等、背压HTTP/2 帧格式、TLS 握手、DNS 解析细节
主从/多主/无主复制、quorum、读修复TCP 拥塞控制、epoll 事件循环实现
线性/顺序/因果/最终一致性的定义与判定CAP 口号与「三者选二」的营销叙事
Paxos、Raft、选举、日志提交、线性一致读分布式系统的形式化证明全集
ACID、隔离级别、MVCC、2PC/TCC/Saga某个数据库的全部 SQL 语法
队列/日志、投递语义、消费者组、CDCKafka 的每个配置项逐条解释
对象存储语义、缓存模式、CDN 回源S3 全部 API、CDN 厂商控制台操作
分布式锁、租约、fencing token、选主具体产品的锁 API 大全
trace/指标/日志/回放、SLO某家 APM 产品的部署手册
容量、限流、降级、故障演练Kubernetes 调度器与容器运行时实现

2 不重复清单(硬约束)

以下内容已经在《Linux 系统与内核》中完整讲过。本书只交叉引用,不重写;如果某章需要用到,最多用一两句话提醒,并给出链接。

已覆盖内容Linux 书位置本书的处理
网络分层、IP、路由、分片4.1–4.3只在解释「请求走哪条路」时引用
UDP/TCP、可靠传输、拥塞控制4.4–4.6超时章节只讲应用层 deadline,不重讲 RTO
TCP 建立/关闭、TIME_WAIT4.5连接池与重试章引用,不重画状态机
socket、epoll、连接队列4.8–4.11项目章只用 Python 标准库的 HTTP server
HTTP、DNS、TLS4.7不解释报文;只讲 HTTP 语义对重试的影响
容器、namespace、cgroups5.1–5.5项目不依赖容器,最多用进程隔离
Kubernetes 基础5.6只在「发布与容量」处引用
三个常见越界
  1. 把「TCP 超时」当成本书的「请求超时」。TCP 的 RTO 是传输层重传计时器;本书的 deadline 是应用层「我最多等到什么时候」,两者可以差一个数量级。
  2. 把「HTTP 503」当成一种失败。503 是语义,不是原因;本书要求同时看 Retry-After、连接是否被复用、服务端是否已经执行。
  3. 把「Kubernetes 重启了 Pod」当成根因。Pod 重启是控制面的动作,根因通常在数据面:探针超时、依赖阻塞、内存泄漏、重试风暴。

3 深度取舍

3.1 协议:讲可判定的部分

Paxos 与 Raft 都讲,但重点不同:

  • Paxos 讲安全性从哪来:为什么 prepare 的多数派与 accept 的多数派相交,就能保证只有一个值被选中;为什么活锁不是安全性问题。
  • Raft 讲工程实现:term、选举超时、日志匹配、提交规则、ReadIndex、成员变更。因为项目二会实现一个简化版,读者需要能对着代码检查不变量。

不展开:Byzantine 共识、区块链共识、Paxos Made Live 的全部工程细节、Raft 的每一条形式化证明。

3.2 一致性:给判定方法,不给口号

本书用「历史」(history)来定义一致性:一组操作、它们的调用/返回时间、读到的值。线性一致要求存在一个全序,使得每个操作在其调用与返回之间生效,且读到的值符合这个全序。第 4 章会给正例、反例和检查方法(Jepsen 风格的 :ok / :fail 历史)。

不展开:形式化验证工具(TLA+、Coq)的教程;只给一个 TLA+ 风格的伪代码例子,说明「不变量」怎么写。

3.3 事务:单机原理 + 分布式协议

单机部分讲 MVCC 的版本可见性与隔离级别,因为这是理解「快照读」「写偏斜」的基础;分布式部分讲 2PC、TCC、Saga、Outbox,重点是失败路径:协调者挂了怎么办、参与者超时怎么办、重试怎么不重复。

不展开:某个数据库的完整锁系统、buffer pool、B+ 树实现;这些属于存储引擎教材。

3.4 工程:以可观测和可演练为终点

第 9–11 章不是「运维常识合集」,而是把前面所有机制接到三个问题上:

  • 锁过期后,怎么保证资源不被两个持有者同时修改?——fencing token。
  • 出事时,怎么在 5 分钟内判断是过载、依赖慢、还是重试风暴?——RED/USE + trace + 队列指标。
  • 容量怎么估、怎么压、怎么演练?——Little’s law、利用率曲线、故障注入剧本。

4 版本与事实政策

  • 协议事实以公开规范/论文为准,例如 Raft 论文、RFC、S3 一致性文档;产品行为标版本。
  • 公开事故只写可核对的时间、影响和官方复盘结论;不写「据说」「网传」。
  • 代码以 Python 3.11+ 标准库为准;不依赖第三方包,保证 python3 -m unittest 在干净环境可跑。
  • 图表全部自绘;SVG 的 id 全书唯一,状态机与时序图必须能对应到正文的某个不变量。

5 淘汰与迁移

以下内容曾经常见于分布式教材,但本书不展开,原因如下:

内容处理原因
3PC只在 2PC 对比表出现一行不能解决分区,工程上很少单独使用
两阶段锁的完整实现用 MVCC 替代讲解现代 OLTP 主流是 MVCC + 短锁
租约锁的「绝对安全」叙事第 9 章明确反驳租约依赖时钟,必须配 fencing
CAP 的「三选二」第 4 章给精确版本口号会掩盖「分区时选什么」
微服务拆分原则不写属于架构方法论,不是数据面
Service Mesh 数据面实现只在可观测章节提一句属于云原生控制面/代理实现

下一站:符号、单位与代码约定——统一记号,避免后面把「延迟」「超时」「deadline」混着用。

符号、单位与代码约定

1 时间与延迟

分布式系统里最常见的错误是把不同层次的「时间」混为一谈。本书统一用下面的记号:

记号含义单位/例子
t_call客户端发起调用的本地时间单调时钟读数
t_return客户端收到响应的本地时间单调时钟读数
D端到端延迟,D = t_return - t_callms
T客户端设置的超时/deadlinems
RTT一次网络往返时间ms
s服务时间(不含排队)ms
λ到达率req/s
μ单副本服务率req/s
ρ利用率,ρ = λ / (c·μ)0–1
L系统中平均请求数个
W平均停留时间,W = L/λs

规则一:D > T 只说明客户端不再等待,不说明服务端没执行。本书把前者叫「超时」,把后者叫「失败」;两者不是同义词。

规则二:所有 deadline 都用绝对时间在进程间传递,不用「剩余多少毫秒」。原因很简单:剩余时间在每一跳都会因为排队而变短,接收方无法判断发送方已经等了多久。

规则三:不要把墙上时钟(wall clock)用于计算超时或顺序。墙上时钟会被 NTP 调整,可能倒退;超时用单调时钟(time.monotonic() / CLOCK_MONOTONIC)。

2 失败模型

本书只用三种失败模型,出现更复杂的模型会明确标注:

记号名称允许的失败典型系统
Ccrash-stop进程崩溃后不再恢复理论分析、简单选主
C-Rcrash-recovery崩溃后重启,持久化状态保留、内存状态丢失大多数数据库、Raft 节点
N网络分区/异步消息可丢、可重复、可乱序、可延迟任意长;节点不一定崩溃跨机房系统
BByzantine节点可能发送错误消息区块链;本书只提一句

默认模型是 C-R + N:节点会崩溃重启,网络会分区、会重复、会乱序,但节点不会伪造消息。凡是依赖「消息一定到达」「时钟一定同步」「节点不会同时暂停」的结论,都会在正文里标出假设。

3 请求与状态

记号含义
req一次逻辑请求,由 (client_id, seq) 唯一标识
key幂等键,客户端生成,同一逻辑请求重试时必须复用
fp请求指纹,对方法、路径、关键参数做规范化哈希
attempt一次物理尝试;同一 req 可以有多次 attempt
state ∈ {pending, running, succeeded, failed, unknown}请求/幂等记录的状态
lease_until租约到期时间(绝对时间)
fencefencing token,单调递增,用来挡住过期持有者
commit_index日志/复制组已提交位置
applied_index状态机已应用位置
read_index线性一致读需要等待达到的提交位置

unknown 是本书特别强调的状态:它表示「客户端不知道服务端是否执行」。在 UI 上它通常被折叠成「失败」,在协议上它必须被单独处理。

4 一致性与事务

记号含义
W / R写 quorum / 读 quorum
N副本数
R + W > N读写 quorum 相交,读至少能看到一个最新写
lin线性一致(linearizable)
seq顺序一致(sequentially consistent)
causal因果一致
eventual最终一致
SI快照隔离(snapshot isolation)
SSI可串行化快照隔离(serializable snapshot isolation)
2PC两阶段提交

注意:W + R > N 只保证「读到的集合与写入的集合相交」,不自动保证线性一致。第 4 章会说明还需要哪些条件(写必须是最新已提交、读必须等待修复、不能有脏读等)。本书不会把 quorum 相交当成线性一致的充分条件。

5 代码约定

  • 语言:Python 3.11+,只用标准库(http.server、sqlite3、threading、urllib、unittest、json、hashlib、time)。
  • 服务端口默认 127.0.0.1,避免暴露到公网;测试用临时端口。
  • 每个代码块标注语言:python、bash、sql、json、text、http。
  • 伪代码用 text,不要伪装成可运行代码。
  • 错误处理不能省:捕获具体异常,记录 request_id,返回结构化错误。
  • 脚本开头 set -euo pipefail;需要 root 的命令前标 sudo 并给清理命令。
  • 不把长代码墙贴进正文:核心函数 20–80 行,完整文件放 projects/,正文给路径和行号。

6 锚点与交叉引用

本书的锚点采用统一命名:

sec-distributed.<part>.<chapter>.<section>     小节
def-distributed.<part>.<chapter>.<n>           定义
thm-distributed.<part>.<chapter>.<n>           定理/保证
ex-distributed.<part>.<chapter>.<n>            案例/练习
method-distributed.<part>.<chapter>.<n>        方法/清单
errata-distributed.<part>.<chapter>.<n>        误解/陷阱
note-distributed.<part>.<chapter>.<n>          边注

<part> 是篇号 1–5,<chapter> 是全书章号 1–13。例如第 5 章(第 2 篇)第 3 节的锚点是 sec-distributed.2.5.3。这样即使章节在篇内移动,锚点仍然可读;所有锚点由 tools/check_book.py 检查唯一性。

7 代码清单的读法

书中出现下面这种清单时,表示「这段代码在仓库里的位置」:

projects/idempotent-tool/server.py
  ├─ class CallStore        持久化与状态迁移
  ├─ class ToolRunner       工具执行与副作用
  └─ class Handler          HTTP 契约

路径相对于本书根目录 distributed/。运行命令默认也在该目录下执行。

关于「行号」

正文引用的行号以交付时的文件为准;如果你在本地改过代码,行号会漂。优先按函数名搜索,不要死记行号。

下一站:实验环境——把端口、依赖和清理命令一次配好。

实验环境与复现方式

1 最低配置

项要求检查命令
操作系统macOS 13+ / Linux 5.15+ / Windows 11 + WSL2uname -a
Python3.11+(3.13 亦可)python3 -V
SQLite3.35+(BEGIN IMMEDIATE、WAL、RETURNING 可用)python3 -c "import sqlite3;print(sqlite3.sqlite_version)"
构建mdBook 0.5+、mdbook-katex 0.10+mdbook --version
观测工具curl、jq、sqlite3、ss/lsofcurl --version
可选wrk/hey(压测)、tc(需要 root)which wrk hey tc

两个项目不需要 Docker、Kubernetes、Redis、Kafka 或云账号。原因不是这些系统不重要,而是本书想让你先看清「重复、超时、分区」的最小机制;真实系统只是在同样的机制上加了更多优化。

2 目录

distributed/
├── book.toml
├── src/                     正文与图
├── projects/
│   ├── idempotent-tool/     项目一:幂等工具调用后端
│   └── replicated-kv/       项目二:三节点复制 KV
├── tools/                   mathbook / check_book / check_coverage / check_projects
├── coverage.md              覆盖矩阵(与 src/appendix/coverage.md 同源)
├── CHECK-distributed.md     交付自检报告
└── README.md

3 构建本书

cd distributed
mdbook build .
# 产物在 book/;打开 book/index.html

本地预览:

mdbook serve --open
# 默认 http://127.0.0.1:3000/

构建依赖:

pip3 install -r requirements.txt     # markdown-it-py,供 mathbook 预处理器使用
cargo install mdbook-katex --version 0.10.0

质检:

python3 tools/check_book.py          # 结构、锚点、链接、盒子、行数
python3 tools/check_coverage.py      # 覆盖矩阵 0 缺口
python3 tools/check_projects.py      # py_compile + bash -n

4 运行项目一

cd projects/idempotent-tool
python3 server.py --port 8081 --db /tmp/idem.db
# 另开一个终端
python3 client.py --base http://127.0.0.1:8081 --key demo-1 --tool charge --amount 100

预期:

{"call_id":"...","status":"succeeded","effect_id":"...","attempts":1}

同一把钥匙再发一次,返回同一个 call_id,attempts 不增加;换一把钥匙、同参数,得到新的 call_id——这两句话是项目一的核心验收。

完整测试:

python3 -m unittest -v test_server.py
bash fault_inject.sh --dry-run
bash fault_inject.sh --drop-response

5 运行项目二

cd projects/replicated-kv
./run_cluster.sh start          # 启动 3 个节点:7101/7102/7103
python3 kv_client.py status
python3 kv_client.py --key k --value v put
python3 kv_client.py --key k --consistency linearizable get

故障注入:

bash fault_inject.sh --dry-run
bash fault_inject.sh --partition 1          # 隔离节点 1,观察重新选主
bash fault_inject.sh --heal
bash fault_inject.sh --kill-leader

完整测试:

python3 -m unittest -v test_kv.py

6 端口与清理

项目端口清理
项目一8081pkill -f idempotent-tool/server.py
项目二7101–7103./run_cluster.sh stop
本书预览3000Ctrl-C

端口冲突时,两个服务都支持 --port/--base-port;测试使用 --port 0 让内核分配临时端口。

7 非 root 与 root 的边界

  • 项目一、项目二、python3 -m unittest 都不需要 root。
  • fault_inject.sh 的 --partition 通过服务内置的 /admin/partition 接口实现,不需要 iptables;这样在 macOS 和 CI 里都能跑。
  • 只有 tc netem 的丢包/延迟实验需要 root;脚本会先检查 id -u,不是 root 时打印替代命令并退出 0(--dry-run 永远 0)。
  • 不要在生产机器上跑 tc/iptables 实验;用容器或虚拟机。

8 版本与可复现性

书中所有「实测输出」都来自以下环境之一:

macOS 15.6 / Apple Silicon / Python 3.13.13 / SQLite 3.50.x
Ubuntu 24.04 / x86-64 / Python 3.12.x / SQLite 3.45.x

端口、PID、时间戳、随机数会不同;结构化的字段名、状态迁移、错误码应当一致。如果你的输出在字段层面不同,先检查版本,再检查是否用了 --db 指向旧库。

第一次运行的最小检查单
  1. python3 -V ≥ 3.11;
  2. python3 -c "import sqlite3; print(sqlite3.sqlite_version)" ≥ 3.35;
  3. mdbook build . 退出码 0;
  4. python3 tools/check_book.py 退出码 0;
  5. python3 -m unittest discover -s projects/idempotent-tool -p 'test_*.py' 通过;
  6. python3 -m unittest discover -s projects/replicated-kv -p 'test_*.py' 通过。

下一站:三条阅读路线——按你的目标选择顺序,不必从第一页读到最后一页。

三条阅读路线

这本书有 13 章,但不需要按 1 到 13 顺序读完。下面三条路线对应三类目标;每条路线都标了「必须做」的练习,不做练习等于没读。

1 后端工程师:把一次调用写对

目标:能给团队定一份 RPC 契约,遇到超时、重试、重复、过载时知道先看什么。

顺序章节读法必须做
1第 1 章精读,画出请求状态机用 curl -w 测一次本地调用的各段延迟
2第 2 章精读,抄下重试预算与幂等键设计改项目一:把重试次数从 3 改成 10,观察放大倍数
3第 6 章精读 MVCC 与隔离级别在 SQLite/PostgreSQL 里复现写偏斜
4第 7 章选读投递语义与 outbox用项目一的表实现一个 outbox
5第 8 章选读缓存一致性给项目一加一层 TTL 缓存,制造一次陈旧读
6第 12 章做项目一跑 --drop-response,证明只扣一次

时间:约 12–16 小时,其中项目一 4–6 小时。

2 SRE / 平台工程师:把故障看清楚

目标:能在 5 分钟内判断「过载、依赖慢、重试风暴、分区」中的哪一种,并能设计一次低风险演练。

顺序章节读法必须做
1第 1 章快读失败分类列出你负责系统的最常见三种失败
2第 3 章精读复制与分区画出你系统的复制拓扑与故障切换路径
3第 9 章精读租约与 fencing检查现有锁是否带单调 token
4第 10 章精读 trace/指标/日志给一个接口补齐 RED 指标与 trace
5第 11 章精读容量与演练写一份爆炸半径受控的演练剧本
6第 13 章选做项目二用 --partition 制造一次旧主读

时间:约 10–14 小时,其中观测改造 3–5 小时。

3 存储 / 基础设施工程师:把一致性讲清楚

目标:能判断一个系统声称的一致性是否成立,能读懂 Raft 实现的关键不变量。

顺序章节读法必须做
1第 3 章精读三种复制拓扑用 3 个进程模拟 quorum 读写
2第 4 章精读历史与线性一致判定给出一段违反线性一致的历史
3第 5 章精读 Raft 选举/提交/读手推一次分区后的选举
4第 6 章精读 MVCC 可见性画出两个事务的版本链
5第 13 章做项目二跑 --partition 并解释旧主为何不能提交
6第 11 章选读容量估算三副本写入的可用性与延迟

时间:约 16–20 小时,其中项目二 6–8 小时。

4 章节依赖图

        1 请求/失败
         │
    ┌────┴────┐
    ▼         ▼
 2 RPC      3 复制 ──▶ 4 一致性 ──▶ 5 共识
    │         │                         │
    │         └──────────┬──────────────┘
    ▼                    ▼
 6 事务 ◀──────────── 7 队列/流
    │                    │
    └────────┬───────────┘
             ▼
        8 缓存/CDN
             │
    ┌────────┴────────┐
    ▼                 ▼
 9 锁/租约         10 观测
    │                 │
    └────────┬────────┘
             ▼
        11 容量/演练
             │
    ┌────────┴────────┐
    ▼                 ▼
 12 项目一         13 项目二

实线箭头表示「读后者之前最好先读前者」;虚线不存在——本书没有必须按顺序读的硬依赖,只有概念上的前后关系。第 10 章可以在读完第 2 章后直接读;第 12 章可以在读完第 2、6 章后直接做。

5 三条路线的共同底线

无论选哪条路线,下面五件事必须做,否则这本书对你只是「读过」:

  1. 亲手制造一次超时但成功,并用幂等键重试;
  2. 亲手制造一次重复投递,观察副作用是否翻倍;
  3. 亲手制造一次分区/旧主,观察读到的值;
  4. 用一条命令判断一次过载(不是看 CPU,而是看队列与延迟);
  5. 写一份故障演练剧本,包含假设、爆炸半径、回滚和观测点。
不建议的读法
  • 只读第 4、5 章,跳过第 1、2 章:你会把一致性当成数学,却不知道业务里的「未知」长什么样。
  • 只做项目,跳过第 3、4 章:项目二能跑,但你解释不了为什么旧主必须被 fencing。
  • 只看观测章节:没有前面的状态机,trace 上的一条红色 span 你仍然不知道该改哪里。

下一站:能力矩阵——把「读完」变成可自评的 24 项能力。

能力矩阵

1 怎么用这张表

24 项能力分四组:判断(能不能识别问题)、设计(能不能写出契约)、实现(能不能落代码)、验证(能不能证明它有效)。每项给一个可观察的达标线;达不到就回到对应章节,不要用「大概懂了」糊过去。

自评等级:

  • 0 没听说过;
  • 1 能复述定义;
  • 2 能在给定场景下选对方案;
  • 3 能写出实现并解释失败路径;
  • 4 能设计验证实验并读出证据。

本书的目标不是让所有读者都到 4;第 2 篇到 3、第 5 篇到 3 是合理目标,负责 on-call 的读者应在第 9–11 章到 4。

2 判断

#能力达标线章节
J1区分超时、失败、未知给定一次 context deadline exceeded,能说出服务端可能处于哪三种状态1、2
J2识别重试放大给定重试次数与并发,能估算最坏请求放大倍数2、11
J3识别分区与分裂脑看到两个节点都认为自己是主,能列出至少三条证据3、9
J4判断一致性承诺给定一段读写历史,能判断是否线性一致,并指出反例4
J5区分一致性与隔离级别能解释「线性一致读」和「可串行化事务」不是同一件事4、6
J6判断投递语义看到「至少一次」能说出重复发生在哪一步、怎么去重7
J7判断缓存陈旧能列出 TTL、双写、失效失败三种导致旧值的路径8
J8判断锁是否安全能指出只靠 SET NX PX 的锁在 GC 停顿时为什么失效9
J9判断观测盲区能说出 trace 采样、指标聚合、日志重复各自的盲区10
J10判断过载还是依赖慢能根据队列长度、延迟分布、下游错误率区分两者11

3 设计

#能力达标线章节
D1写远程调用契约一份契约包含 deadline、重试条件、幂等键、错误码、背压信号2
D2设计幂等键能定义键的粒度、指纹、冲突策略、过期与回收2、12
D3选复制拓扑能根据 RPO/RTO、写延迟、冲突容忍度选主从/多主/无主3
D4写一致性契约明确读己之写、单调读、线性读分别用在哪些接口4
D5设计事务边界能判断哪些操作必须在一个事务里,哪些可以用 Saga/Outbox6
D6设计事件流能定义分区键、顺序保证、消费者幂等、重放策略7
D7设计缓存策略能给出 TTL、失效、击穿保护、热点 key 方案8
D8设计锁与选主锁带租约与 fencing;选主带 quorum 与降级路径9
D9设计观测每个关键路径有 RED 指标、trace、结构化日志与 SLO10
D10设计容量与演练有基线、梯度、headroom、爆炸半径与回滚11

4 实现

#能力达标线章节
I1实现带 deadline 的客户端超时后不把请求当成功;能取消在途请求2、12
I2实现幂等写入同一键重试只产生一次副作用;不同参数返回冲突12
I3实现去重表唯一约束 + 状态机 + 租约,崩溃后可恢复12
I4实现 quorum 读写3 副本、R+W>N,读修复可运行3、13
I5实现 Raft 选举term、投票、随机超时、日志新旧比较5、13
I6实现日志复制prevLogIndex/term 检查、冲突截断、提交推进5、13
I7实现线性一致读ReadIndex 或 lease read,能解释为什么需要心跳13
I8实现 MVCC 可见性能写出快照读的可见性判断6
I9实现消费者去重offset 提交与业务事务的原子性,或 outbox7、12
I10实现 fencing资源端校验单调 token,拒绝过期持有者9、13

5 验证

#能力达标线章节
V1故障注入能注入丢响应、重复、分区、进程暂停,并观察不变量11、12、13
V2一致性检查能写一个检查器,判断历史是否满足线性一致4、13
V3压测有基线、并发梯度、P50/P99、错误率、资源曲线11
V4观测排障能从 trace 找到最慢 span,从指标确认是否过载10、11

6 三条路线的达标组合

路线必须到 3 的能力必须到 2 的能力
后端D1、D2、I1、I2、I3J1、J2、D5、D7、V1
SREJ1、J3、J8、J9、J10、D8、D9、D10、V1、V4D3、D4、I7
存储J3、J4、J5、D3、D4、I4、I5、I6、I7、V2D5、D6、D10
自评怎么做

不要凭印象打分。每一项找一个你做过的事故/接口/实验作为证据;找不到证据的,最高只能打 2。把证据写在表格第三列(例如「2025-11 支付超时事故复盘」),半年后回来看是否还成立。

下一站:入学诊断——12 道题,测一测你现在处于哪一档。

入学诊断

12 道题,覆盖本书的六个核心概念。不要查资料,15 分钟内做完;答案在最后,先记下你的选择。

1 题目

Q1 客户端设置 1 s 超时,1 s 后收到 timeout。服务端最可能处于什么状态?

A. 一定没收到请求 B. 一定执行成功 C. 可能没收到、可能执行中、可能已成功 D. 一定执行失败

Q2 一个客户端对每个请求重试 3 次,失败后上游再重试 3 次。最坏情况下游收到多少次请求?

A. 3 B. 4 C. 9 D. 16

Q3 三副本,W=2, R=2,是否一定满足线性一致?

A. 一定 B. 只在没有并发写时 C. 不一定,还要看读修复、写确认与时钟/排序规则 D. 只要 R+W>N 就一定

Q4 下面哪种历史违反线性一致?设写 x=1 在 t1 返回,读在 t2 开始且 t2 > t1。

A. 读返回 1 B. 读返回 0 C. 读超时 D. 读返回 1 但来自从库

Q5 Raft 中,leader 能把 commitIndex 推进到某个日志项的条件是:

A. 本地写入磁盘 B. 多数派复制了该项,且该项属于当前 term(或通过当前 term 的项间接提交) C. 所有 follower 都复制了 D. 客户端收到 ACK

Q6 PostgreSQL 的 READ COMMITTED 能防止下列哪个异常?

A. 脏读 B. 不可重复读 C. 幻读 D. 写偏斜

Q7 Kafka 的「至少一次」语义下,重复最可能来自:

A. 生产者发送前 B. 消费者处理成功但 offset 提交失败/再平衡 C. broker 落盘 D. 分区数太多

Q8 缓存旁路(cache-aside)下,最危险的旧值路径是:

A. 缓存命中 B. 先更新数据库、后删除缓存,但删除失败 C. 缓存 TTL 太长 D. 缓存容量太小

Q9 SET lock:key value NX PX 30000 的锁,在下列哪种情况下可能被两个客户端同时持有?

A. Redis 重启 B. 客户端 GC 停顿超过 30 s,锁过期后被另一个客户端获取 C. 网络延迟 1 ms D. 使用 Lua 解锁

Q10 一个服务的 p50 是 20 ms,p99 是 2 s。最应该先看什么?

A. 平均延迟 B. 慢请求的 trace 与下游分位 C. CPU 使用率 D. 日志条数

Q11 系统利用率从 60% 升到 85%,延迟通常会:

A. 线性上升 1.4 倍 B. 基本不变 C. 非线性上升,排队时间显著变长 D. 下降

Q12 故障演练中,「爆炸半径」指的是:

A. 故障注入脚本的行数 B. 一次演练可能影响的最大用户/数据/服务范围 C. 告警条数 D. 恢复时间

2 答案与解释

答案

A1 C。超时只说明客户端不再等待。请求可能还在网络上、可能正在执行、可能已经提交但响应丢了。第 1 章把「未知」作为一等状态。

A2 D。每一层最多 4 次尝试(首次 + 3 次重试),两层相乘是 16 次。注意这是最坏情况,实际受重试预算与退避限制;第 2 章会讲怎么把 16 压到可控范围。

A3 C。R+W>N 只保证读写集合相交,不保证读到的就是最新已提交值:还需要写确认规则、读修复、避免脏读、以及读操作在返回前等待必要的提交。第 4 章给反例。

A4 B。x=1 已经返回,之后开始的读必须看到 1(或更新的值);返回 0 说明读发生在写生效之前,违反实时序。C 是未知,不构成反例;D 只要值正确就不违反。

A5 B。Raft 的提交规则:leader 只能直接提交当前 term 的日志项;旧 term 的项要通过当前 term 的项间接提交。只写本地磁盘或等所有 follower 都不够。

A6 A。PostgreSQL 的 READ COMMITTED 防止脏读,但允许不可重复读、幻读和写偏斜。第 6 章会用 MVCC 解释为什么。

A7 B。消费者处理完消息但 offset 没提交(崩溃或再平衡),消息会被重新投递。生产者的重试也可能造成重复,但 Kafka 幂等生产者/事务能消掉一部分;消费者侧仍是主因。

A8 B。先写库后删缓存,删除失败会留下旧缓存,直到 TTL 到期。TTL 太长会放大窗口;容量小只会导致更多回源。第 8 章会讲延迟双删、版本号与 binlog 失效。

A9 B。GC/STW 或进程暂停超过租约,锁自动过期,另一个客户端获取;原客户端恢复后仍以为自己持有锁。解法不是加长 TTL,而是 fencing token。

A10 B。p50 正常、p99 很差,说明长尾;先看慢请求 trace 与下游分位,不要被平均值骗。CPU 可能不高,因为瓶颈在排队或下游。

A11 C。排队系统在利用率接近 1 时延迟非线性上升;M/M/1 的 W = 1/(μ-λ) 在 λ→μ 时发散。第 11 章给曲线与容量余量。

A12 B。爆炸半径是一次演练可能影响的最大范围;控制它比「注入更多故障」重要。第 11 章给剧本模板。

3 评分

答对建议起点
10–12直接从第 2 章开始;第 1 章快速扫一遍状态机与失败分类
6–9按顺序读第 1–5 章,项目选一个做
3–5先读第 1、2 章,补 Linux 书第 4 篇的 TCP/HTTP 基础,再回来
0–2先从 Linux 书第 4 篇开始;本书第 1 章会假设你知道「请求要经过网络」
这不是考试

诊断的目的是选起点,不是打分。答错不丢人;真正危险的是把「超时=失败」「至少一次=不会重复」「R+W>N=线性一致」当成常识带进生产。

下一站:准备好了就从 第 1 章 开始;想先看全书结构,回到 封面与导览。

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 怎么传、重试怎么限、幂等怎么做、背压怎么建。

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

本章回答

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

读完你会

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

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

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

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

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

先看一行代码:

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

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

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

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

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

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

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

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

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

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

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

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

一个典型的 proto 定义:

syntax = "proto3";

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

import struct

MAX_FRAME = 4 * 1024 * 1024

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

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

两个坑:

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

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

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

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

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

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

2.2.1 绝对 deadline 与剩余预算

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

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

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

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

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

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

2.2.2 每跳该留多少余量

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

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

一个可用的公式:

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

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

定理 2.2.1 · 预算不等式

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

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

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

证明

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

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

∎

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

2.2.3 一个可用的 Deadline 实现

import math
import time

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


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

    __slots__ = ("_end",)

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

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

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

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

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


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


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

三个细节值得单独说:

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

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

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

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

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

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

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

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

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

2.3.1 哪些错误可以重试

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

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

2.3.2 指数退避与三种抖动

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

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

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

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

Full jitter 的公式就是一行:

import random


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

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

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

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

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

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

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

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

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

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

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

参数怎么定:

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

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

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

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

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

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

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

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

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

import time


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

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

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

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

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

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

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

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

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

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

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

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

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

键必须满足四个条件:

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

常见的错误生成方式:

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

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

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

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

import hashlib
import json
from decimal import Decimal


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


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


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

规范化要处理的边界:

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

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

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

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

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

CREATE INDEX idx_idem_expires ON idempotency_key (expires_at);

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

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

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

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

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

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

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

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

2.4.4 TTL 与回收

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

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

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

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

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

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

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

2.5.1 一条具体的时间线

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

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

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

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

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

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

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

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

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

2.5.2 正反馈的数学形状

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

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

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

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

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

一句话

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

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

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

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

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

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

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

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

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

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

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

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

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

L=λW L = \lambda W

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

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

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

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

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

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

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

2.6.2 队列该有多长

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

一个可用的估算:

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

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

队列还有三个隐性成本:

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

2.6.3 一个带 deadline 的并发闸门

import threading


class Overloaded(Exception):
    pass


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

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

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

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

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

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

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

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

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

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

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

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

import time


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

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

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

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

参数选择:

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

常见限流算法对比:

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

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

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

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

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

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

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

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

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

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

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

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

修复分两层:

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

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

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

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

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

2.8.1 两边各自的黄金指标

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

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

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

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

一次本机测试的输出:

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

怎么读:

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

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

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

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

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

输出:

0.184

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

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

输出:

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

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

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

一条可用的尝试日志:

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

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

用 jq 按逻辑请求聚合:

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

输出:

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

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

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

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

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

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

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

2.9.1 为什么把它放进 RPC 一章

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

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

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

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

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

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

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

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

2.10.1 时间线

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2.10.4 对账与修复

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

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

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

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

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

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

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

2.11 一份 RPC 契约检查表

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

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

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

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

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

2.12 本章陷阱清单

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

2.13 本章出口检查

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

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

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

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

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

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

答案

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

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

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

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

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

母本对应

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

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

3 复制:主从、多主、无主

本章回答

复制到底复制了什么?同步、异步、半同步分别在等谁?为什么「三副本」不等于「三个故障域」?R + W > N 只保证集合相交,为什么仍然可能读到旧值,甚至读到从未完成的写?分区期间两个 leader 都接受写,数据会怎样分叉,怎么止损?复制延迟到底来自哪一段,应该看哪个指标?

读完你会

能为一个业务选出拓扑、RPO/RTO 和 quorum 参数;能画出一次 failover 的时间线与 RTO 分解;能解释 LWW、版本向量、CRDT 各自的边界;能用 pg_stat_replication、SHOW REPLICA STATUS 和 Kafka ISR 把 lag 拆成四段;能写出「新主重放旧命令」的防重方案。

凌晨 02:13,一张 orders 表所在机架断电。主库和一台副本在同一个机架,另一台副本在 30 公里外的机房。监控上复制一直正常,只是远端副本的 replay lag 常年有 8 秒。恢复后财务发现:8 秒内大约 1.6 万笔已经返回「下单成功」的订单不见了。

复制因子是 3,但没有一份副本在正确的故障域里。这个事故里也没有哪台机器「写错」:主库按异步复制返回了成功,副本按自己的节奏重放,监控按「复制线程活着」判定健康。三个正确的局部行为,拼出了一次数据丢失。

上一章把一次远程调用拆成了逻辑请求、物理尝试和 unknown 状态。那时我们默认「服务端的数据只有一份」。这一章要把这一份数据变成多份,并回答由此产生的三个新问题:

  1. 多份副本之间,谁先写、谁后写、谁说了算?
  2. 主库挂了,谁接替?接替的一瞬间,哪些已经告诉用户「成功」的写可能消失?
  3. 用户写完立刻去读,读到的是自己的写,还是另一个副本上的旧值?

复制是后面所有章节的地基。一致性模型(第 4 章)讨论「读到的值算不算新」,共识(第 5 章)讨论「多个副本如何对同一个顺序达成一致」,事务(第 6 章)讨论「多个 key 的复制如何保持原子」,队列(第 7 章)讨论「日志保留与重放」,锁(第 9 章)讨论「租约与 fencing」。这些话题的源头,都是本章的三件事:日志、quorum、故障切换。

本章路线:先讲复制的三个目标和 RPO/RTO(3.1),再讲单主复制的三种同步模式(3.2)与复制日志/状态机(3.3),然后进入故障切换和分裂脑(3.4);接着换拓扑,讲多主复制的冲突来源(3.5)与三种冲突解决手段(3.6),再讲无主复制的 quorum 与修复机制(3.7);有了这些,才能讨论复制延迟和读己之写(3.8),以及分区下双主的完整失败链(3.9)。最后用三条真实命令看 lag(3.10)、复盘 GitHub 2018-10-21(3.11)、拆解「新主重放旧命令」的重复副作用(3.12)。

3.1 复制的三个目标:耐久、读扩展、地理就近

「复制」最容易被理解成「多存一份」。但多存一份本身没有价值:如果三份副本在同一个机架、同一个交换机、同一个电源域,一次机架掉电就能全带走。复制真正要解决的是三个不同的问题,而它们对拓扑的要求并不一样。

目标要回答的问题典型手段主要代价
耐久(durability)已确认的写,在 f 个节点/故障域故障后还在吗?同步或半同步复制、跨故障域放置、备份写延迟、写可用性
读扩展(read scaling)读流量能不能分摊到多个副本?只读副本、从库、缓存复制 lag、陈旧读
地理就近(geo proximity)用户能不能就近读写,少走跨洲 RTT?多地域副本、多主、边缘缓存冲突解决、跨域带宽

这三个目标经常互相拉扯。为了耐久,你让主库等远端副本的 fsync,写延迟从 1 ms 变成 60 ms;为了读扩展,你把读流量放到 lag 5 秒的从库,用户开始看到旧数据;为了地理就近,你在两个洲都开主库,冲突解决立刻变成业务问题。拓扑不是「先进程度」的排序,而是这三种取舍的不同组合。

三种复制拓扑:主从、多主、无主 左:单主写、多个从读;中:两个主都能写;右:任意副本读写,靠 quorum 相交。 三种复制拓扑:主从、多主、无主 单主(single-leader) Leader 读写 Follower 只读 Follower 只读 复制 复制 写只有一条路;切换时选主 多主(multi-leader) Leader A 本地写 Leader B 本地写 Follower / 合并 冲突解决 复制 复制 两个主都能写;冲突必须可解 无主(leaderless) N1 N2 N3 写/读 R + W > N;读修复/反熵 选择依据:写延迟、RPO/RTO、冲突容忍度、运维复杂度。
读图:拓扑不是「先进程度」排序,而是三种不同的取舍:单主简单但有切换窗口;多主写延迟低但冲突要解决;无主可用性高但读需要 quorum 与修复。

3.1.1 RPO 与 RTO:把「高可用」拆成两个可测的数

「高可用」是一个太粗的词。运维和业务真正能对齐的是两个数:

  • RPO(Recovery Point Objective):故障发生时,最多可以丢多少数据。用时间表示就是「最多回退到多久之前」,用写量表示就是「最多丢多少条已确认写」。
  • RTO(Recovery Time Objective):从故障发生到服务恢复,最多允许多久。
场景RPORTO常见复制方式
支付账务0(对已确认写)小于 1 分钟同步 quorum + 受控自动切换
订单主库小于 5 秒小于 5 分钟半同步 + 人工确认切换
商品目录小于 5 分钟小于 30 分钟异步复制 + 多副本读
行为日志/分析小于 1 小时小于 4 小时异步批复制,允许重放

这张表里最容易误读的是「RPO = 0」。它的准确含义是:对那些已经向客户端返回成功的写,在承诺的故障模型内不丢。它不覆盖三种情况:

  • 写还在主库内存/WAL 缓冲里,没 fsync,也没复制;
  • 主库返回成功,但 ack 条件只包含「副本收到」,不包含「副本落盘」;
  • 故障模型之外的相关性故障,例如三副本同机架、同交换机、同一次固件升级。

把这些边界写进契约,比在事故后争论「不是三副本吗」有用得多。

定义 3.1.1 · 复制、RPO 与 RTO

复制(replication)是把同一份逻辑数据的多个副本,按某种顺序和协议持续同步的过程。副本不是一个备份文件,而是一个可以独立接受读(有时也包括写)的服务实例。

RPO 是故障后可接受的最大数据丢失量,通常用时间或写条数表示;RTO 是故障后可接受的最大恢复时间。两者都必须绑定故障模型:是单节点崩溃、单机架掉电、单地域隔离,还是区域性灾难。脱离故障模型谈 RPO/RTO 没有意义。

3.1.2 复制不是备份

这是运维里最常见、也最贵的一句话混淆。复制和备份都会「多一份数据」,但它们防的是不同的故障。

维度复制备份
目的故障切换、读扩展、就近访问恢复逻辑错误、误删、勒索加密
时效持续、秒级同步周期快照、时间点副本
删除是否传播会,主库删副本也删不会,历史时间点保留
典型位置同城/跨城机房,在线对象存储、离线磁带、异地
验证方式lag、校验和、切换演练恢复演练、恢复时间、完整性校验
主要风险相关性故障、误操作同步扩散备份本身不可恢复、从没演练过

一个 DELETE FROM orders 的误操作,会在几秒内被复制到所有副本;备份则保留误操作之前的那个时间点。反过来,备份的 RTO 通常是小时级,不能拿它当高可用。复制保证「机器坏了还有一份」,备份保证「数据错了还能回到过去」;两者不能互相替代。

三副本不等于三个故障域

副本数只说明「有几份」,不说明「分布在哪」。三份副本在同一个可用区,和一份副本没有本质区别;一次交换机升级、一次电源维护、一次存储固件 bug,都可能同时带走它们。设计 RPO 时,要先画故障域(机架、可用区、地域、账号、控制面),再看副本是否真的跨域。跨域放置的代价是 RTT,这正是下一节要算的账。

3.1.3 同步与异步的延迟代价

同步复制和异步复制的区别,不是「可靠/不可靠」,而是「主库在什么时候向客户端返回成功」。

同步复制的写延迟可以近似写成:

Tsync≈Tlocal commit+max⁡i∈A(RTTi)+Tremote fsync T_{\text{sync}} \approx T_{\text{local commit}} + \max_{i \in A}(RTT_i) + T_{\text{remote fsync}}

其中 AA 是必须确认的副本集合。跨地域 RTT 的物理下限由光速和路由决定:同城两个可用区可能 1–3 ms,跨 1000 公里可能 20–40 ms,跨洲可能 150–250 ms。同步复制不是加一点开销,而是把最远那个副本的 RTT 加到每一次写的关键路径上。

异步复制则是:

Tasync≈Tlocal commit,RPO≈lag T_{\text{async}} \approx T_{\text{local commit}}, \qquad RPO \approx lag

主库本地提交后就返回,副本在后台追。写延迟接近本地盘,但故障时最多丢一个 lag 窗口的数据。按 2000 写/秒、lag 8 秒计算,风险窗口里大约有 1.6 万条写;如果每条订单平均 300 元,就是约 480 万元的暴露面。这个乘法应该在设计阶段算,而不是在事故后算。

模式主库何时返回成功已确认写的 RPO写延迟副本故障时
全同步所有副本持久化0(副本跨故障域时)最慢副本 RTT任一副本慢/挂都可能阻塞
半同步(quorum k)k 个副本持久化0(k 个副本跨故障域时)第 k 慢的 RTT可容忍 N−k 个副本故障
半同步(固定 1 个)指定副本确认取决于提升策略1 个 RTT指定副本挂则阻塞或降级
异步主库本地持久化约等于 lag本地提交不阻塞,但可能丢数据

这张表埋下了本章后面的冲突:RPO = 0 不是「打开同步」就自动得到的,它还需要「提升的副本拥有所有已提交写」。提升一个落后的异步副本,前面同步过的写照样会丢;3.4 和 3.11 会反复回到这一点。

3.2 单主复制:同步、异步、半同步

单主复制(single-leader,也叫主从复制)是最常见、也最容易讲清楚的拓扑:所有写先到 leader,leader 把操作写成日志,按顺序发给 follower;follower 重放日志,得到与 leader 相同的状态。读可以走 leader,也可以走 follower(要接受 lag)。

一次写从客户端到「可见」,至少经过下面几步:

  1. 客户端把写请求发给 leader;
  2. leader 在本地 WAL/binlog 里追加一条记录,拿到 LSN/GTID/offset;
  3. leader 把日志流式发给 follower;
  4. follower 写入自己的 WAL/relay log,按配置选择是否 fsync;
  5. follower 回 ack;
  6. leader 根据同步策略判断「提交点」是否到达,推进 commit index;
  7. leader 把日志 apply 到状态机,向客户端返回成功;
  8. follower 继续 apply;apply 完成后,这条写才在 follower 的读路径上可见。

第 6 步和第 8 步是两件事。很多「读不到刚写的值」不是复制没到,而是提交了但还没 apply,或者读的那台副本 apply 得慢。

leader   WAL:      [1][2][3][4][5]
commit index:            ^ 3
replica1 applied:  [1][2][3]
replica2 applied:  [1][2]

客户端写第 4 条:leader 已 append,但 commit index 仍停在 3;
如果此时读 replica2,只能看到 1、2;读 replica1,也只能看到 1、2、3。

3.2.1 三种同步模式:等谁、等到什么程度

「同步」和「异步」之间不是二选一,工程上有许多中间形态。真正需要写清楚的是两个维度:等几个副本、等到副本做到哪一步。

模式等的副本数等到哪一步已确认写的 RPO典型实现
全同步全部 N落盘 + 回复0(跨故障域)传统主从全同步
quorum 同步任意 k(通常多数派)落盘 + 回复0(k 跨故障域)PostgreSQL ANY k (...)、组复制
半同步至少 1 个指定副本收到/落盘/apply 视配置取决于 ack 点与提升策略MySQL semi-sync
异步0不等待约等于 lag大多数默认配置

「等到哪一步」比「等几个」更容易被忽略。以 PostgreSQL 的 synchronous_commit 为例:

  • remote_write:备库把 WAL 写进操作系统缓冲就 ack,没有 fsync;备库掉电可能丢;
  • on:主库本地 fsync 后返回,备库只写不 fsync;
  • remote_apply:备库 apply 完成才 ack,读备库能立刻看到(代价最大)。

MySQL 的半同步也有类似的区别:after_sync 在 binlog 同步到备库后才提交,after_commit 先在主库提交、再等备库 ack;后者的提交点更早,若 ack 不代表落盘,或切换提升了另一个副本,已返回的写仍可能丢。同一个词「半同步」,在两种实现里可能对应不同的 RPO。

定义 3.2.1 · 提交点与可见性

提交点(commit point)是一条日志记录已经持久化在同步策略要求的副本集合上、不会因为一次受控 failover 而消失的位置。可见性(visibility)是一条记录已经被 apply 到某个副本的读路径上、能被读请求看到的状态。

提交不等于可见:leader 可以在 commit index 推进后立刻返回客户端,而 follower 的 apply 还在后面追。判断「读到的值新不新」时,要区分「已提交但未 apply」和「根本没复制到」。

3.2.2 半同步的 quorum 变体

「半同步」常被实现成「至少一个副本确认」。这个策略简单,但有两个隐患:

  1. 固定副本依赖:如果指定的那个副本挂了,主库要么阻塞,要么降级为异步;降级的那一刻,RPO 从 0 变成了 lag。
  2. 不保证提升安全:如果自动切换提升了一个没有被同步确认过的副本,之前「已确认」的写仍然可能丢。

更稳妥的写法是 quorum:等 k 个副本确认,且规定新 leader 必须来自「拥有全部已提交日志」的副本集合。k 的选择决定了写延迟和可用性:

Nk容忍副本故障写延迟备注
321第 2 慢 RTT最常见;跨 3 个 AZ
330最慢 RTT任何副本慢都影响写
532第 3 慢 RTT跨 5 个故障域,成本高
523第 2 慢 RTT写快,但提升约束更复杂

等待多个副本会把长尾放大。假设每个副本有 1% 的概率让一次写超过 50 ms,且彼此独立,那么全同步等 3 个副本时,至少一个慢的概率是:

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

也就是说,单副本 p99 是 50 ms,三副本全同步的 p99 可能已经超过 50 ms 一个数量级。同步复制的尾延迟不是平均值,而是「最慢那个副本」的分布。这解释了为什么很多系统宁愿选 k=2 而不是 k=3:多等一个副本,换来的故障容忍度可能不值那一段尾延迟。

3.2.3 提交与可见性:两个常见事故

第一个事故是「读从库读不到刚写的值」:写已提交,但从库 apply 还没到,不是丢数据而是可见性滞后(3.8 用会话保证解决)。第二个事故是「主库返回成功,切换后数据没了」:ack 点在 after_commit,自动切换又提升了另一个落后副本,客户端看到的成功在新主上并不存在。RPO = 0 需要三个条件同时成立:ack 覆盖持久化、提升只选拥有全部提交日志的副本、旧主被 fence 住;缺一个,成功就可能是假的。

例 3.2.1 · 一次「半同步」切换的三种结局

主库 M,副本 R1(同城,半同步确认)、R2(异地,异步)。写 W 在 M 提交,R1 ack,客户端收到成功。

  • 结局 A:M 崩溃,切换提升 R1。R1 有 W,RPO = 0。
  • 结局 B:M 崩溃,R1 同时不可达,运维提升 R2。R2 还没有 W,RPO = lag 窗口;客户端看到的成功消失了。
  • 结局 C:M 只是网络隔离,没有崩溃;运维提升 R2,M 恢复后仍接受写。此时 R1 和 R2 各有一条历史,进入 3.9 的双主场景。

同一个「半同步」配置,在三种故障路径下给出三种 RPO。同步模式只定义正常路径,故障路径由提升策略和 fencing 定义。

3.3 复制日志与状态机

复制系统真正复制的通常不是「表」,而是一串有序的操作。leader 把操作追加到日志,follower 按同样的顺序重放,得到同样的状态。这个模型叫状态机复制(state machine replication,SMR)。

它成立的前提是:相同的初始状态 + 相同的命令序列 + 确定性的执行 = 相同的最终状态。三个条件缺一不可。初始状态不一致,重放会分叉;顺序不一致,结果不同;命令不确定,各副本算出的值不同。

3.3.1 日志里有什么

字段作用典型形式
index / offset日志位置,单调递增Raft index、Kafka offset、binlog position
term / epoch哪一届 leader 产生的记录Raft term、fencing epoch
command要执行的确定性操作行变更、KV 操作、SQL
checksum损坏检测CRC32C、SHA-256
commit index已提交到的最大位置quorum ack 后推进
last applied状态机已经 apply 到的位置重启后从这里继续

index 和 term 的组合很重要。只看 index 无法判断「这个位置的记录是不是当前 leader 写的」;在 Raft 里,follower 会用 prevLogIndex/prevLogTerm 检查前缀是否匹配,不匹配就截断重来。MySQL 的 GTID 用另一套标识:每个事务有一个全局唯一 ID,切换后通过 Executed_Gtid_Set 判断哪些事务已经执行过。

日志位置不能跨系统直接比较。PostgreSQL 的 LSN 是字节偏移,MySQL 的 binlog position 是文件内偏移,Kafka 的 offset 是分区内条数;它们都叫「位置」,但单位和语义不同。观测时可以用 pg_wal_lsn_diff() 把字节差换算成可读的字节数,但不要拿 LSN 和 offset 比大小。

3.3.2 确定性:复制日志最容易被打破的假设

语句级复制(statement-based replication)直接把 SQL 文本发给副本重放。只要 SQL 里出现下面这些,副本就可能算出不同的结果:

语句/特性为什么不确定修复方式
UPDATE t SET ts = NOW()各副本执行时刻不同leader 生成时间戳,写进日志
INSERT ... VALUES (UUID())各副本生成的 UUID 不同leader 生成 UUID
UPDATE ... LIMIT 1 无 ORDER BY各副本选中的行可能不同明确排序或按主键定位
触发器/存储过程读外部状态副本环境、缓存不同行级日志,固化结果
AUTO_INCREMENT各副本分配的值可能不同leader 分配,或使用步长/号段
浮点/时区/字符集依赖环境配置不同固定配置,显式转换

行级复制(row-based)把「哪一行从什么改成什么」写进日志,避开了大部分不确定性,但仍有非确定性:触发器、外键级联、ON UPDATE CURRENT_TIMESTAMP 都可能在副本上产生不同结果。确定性不是「大多数时候一样」,而是「所有副本必须一样」;一次不一样,校验和就对不上。

3.3.3 日志分叉:未提交的后缀

考虑三个副本,leader A 的日志比 follower C 多两条未提交记录:

index:        1   2   3   4   5   6   7
leader A:     a   b   c   d   e
follower B:   a   b   c   d
follower C:   a   b   c   x   y      ← 上一任 leader 留下的未提交后缀
commit:                ^ 3

如果 C 在此时被提升,x、y 要不要保留?答案取决于协议:

  • 在 Raft 里,只有日志至少和多数派一样新的候选人才可能当选;C 的日志虽然长,但 term 旧,通常不会赢。新 leader 会用新记录覆盖 C 的 x、y。
  • 在异步主从里,如果运维强行提升 C,x、y 可能被当成「最新数据」保留下来,而 A 的 d、e 可能丢失;两边从此分叉。

关键不是「谁的日志更长」,而是「哪些记录已经提交」。未提交的后缀可以被截断,已提交的前缀不能丢。状态机如果在提交前就 apply 了 x、y,截断时还必须回滚状态;很多系统干脆规定「apply 只能发生在 commit 之后」,用延迟换安全。

3.3.4 快照:日志不能无限长

日志会无限增长,重放也越来越慢。快照(snapshot)把「某个 index 处的完整状态」物化下来,日志就可以截断到该 index 之前。新副本加入时,先安装快照,再从 base_index + 1 开始追日志。

快照有三个工程细节:一致性(必须对应确定的 index/term,不能拍到一半)、校验(带 checksum,避免损坏状态扩散)、截断边界(安装后 last_applied = base_index,更早的未提交记录必须丢弃)。

3.3.5 一个确定性的 apply 骨架

下面这段代码不是完整的复制协议,而是状态机 apply 的最小约束:按 index 顺序、每个 index 只 apply 一次、按 op_id 去重、发现空洞就报错。

class StateMachine:
    def __init__(self):
        self.last_applied = 0
        self.state = {}
        self.seen_ops = {}          # op_id -> fingerprint

    def apply(self, index, term, op):
        if index <= self.last_applied:
            return "already-applied"
        if index != self.last_applied + 1:
            raise ValueError(f"gap: want {self.last_applied + 1}, got {index}")

        op_id = op["op_id"]
        fp = op["fingerprint"]
        if op_id in self.seen_ops:
            if self.seen_ops[op_id] != fp:
                raise ValueError(f"same op_id, different payload: {op_id}")
            self.last_applied = index   # 同一条命令换了位置,仍然只执行一次
            return "duplicate-op"

        self.state[op["key"]] = op["value"]
        self.seen_ops[op_id] = fp
        self.last_applied = index
        return "applied"

这段代码里有两个容易忽略的点:

  • op_id 去重和 index 去重是两件事。同一个 op_id 可能因为重试或日志重放出现在不同 index;只按 index 去重挡不住它。
  • 发现 gap 时不能「先跳过,等补上再说」。状态机一旦在空洞上继续 apply,后续所有结果都可能错;正确做法是停下、拉快照、或从 leader 重新同步。
日志不是业务表

apply 不是「把日志抄进表」,而是「重新执行一次业务操作」。如果业务操作是 UPDATE balance = balance - 100,同一条日志执行两次就扣两次;如果操作是 UPDATE balance = 900,执行两次结果一样,但可能覆盖掉后来更新的值。日志只能保证「顺序一致」,不能保证「副作用只发生一次」;后者要靠 op_id 去重或业务幂等,3.12 会展开。

3.4 故障切换:选主、分裂脑、fencing

复制解决了「多一份」,但没有解决「主库挂了怎么办」。故障切换(failover)是把写权限从旧 leader 转移到新 leader 的过程。它看起来只是「选一个副本提升」,实际包含六个阶段,每个阶段都有自己的失败模式。

阶段动作失败后果典型耗时
检测心跳/lease 超时,判定 leader 不可用假阳性,误切3–10 秒
决策quorum 投票或人工确认两个 leader,或无人敢切1–5 秒
提升选日志最全的副本,应用剩余日志丢已提交写,RPO 变大1–10 秒
引流VIP/DNS/Proxy/客户端配置切换请求仍打到旧主0–60 秒
清场fencing、旧主自停、撤权双写,数据分叉取决于机制
对账校验和、日志比对、修复长期隐性不一致分钟–小时

RTO 不是「选举耗时」,而是这六段之和。很多团队演练时只测了「新主多久能选出来」,真实故障时却花了几小时在对账上。3.11 的 GitHub 案例就是典型:选举本身很快,恢复服务却用了 24 小时 11 分钟。

3.4.1 一次 failover 的时间线

t0   A 是 leader(term 7),B、C 是 follower;客户端写都走 A
     A ──append/ack──▶ B, C

t1   网络分区:A 与 B、C 互相不可达(但都活着)
     [A]  ┊ 分区 ┊  [B, C]

t2   A 的租约还没到期,仍认为自己可写;B、C 选举超时,开始投票
     A: "我还是 leader"          B、C: "A 可能死了"

t3   B 拿到多数票,成为 leader(term 8),开始接受写
     A(term 7)仍在服务一部分客户端

t4   两个客户端分别写同一个 key:
     client X → A: stock = 9
     client Y → B: stock = 8
     两边都可能返回成功

t5   分区恢复。A 的 term 7 写入被 B 拒绝;A 必须自停或被 fence
     若没有 fencing,A 会继续接受写,形成双主

t6   对账:找出只在 A 上存在的写、只在 B 上存在的写
     选择保留哪一侧,或人工合并;旧主重建为 follower

这张图里有两个「都活着」:A 活着,B、C 也活着。分区不是「一边死了」,而是「两边都认为自己是对的」。故障检测器只能根据超时猜,猜错就会制造两个 leader。

3.4.2 分裂脑:两个 leader 都接受写

分裂脑(split brain)是指系统同时存在两个(或更多)自认为有写权限的 leader。它的直接后果不是「服务不可用」,而是「服务太可用」:两边都接受写,都返回成功,直到恢复时才发现两份历史对不上。

双主期间发生的事恢复时的表现业务后果
两边插入相同主键复制冲突/主键冲突,复制线程停新数据无法同步,lag 持续增长
两边更新同一行后应用的覆盖先应用的丢失更新,余额/库存错
两边各自递增计数计数被覆盖或重复累加计数偏大/偏小,对账困难
两边发唯一券码唯一索引冲突一侧写入失败,客户端重试
两边都扣库存超卖业务不变量被破坏,无法靠合并修复

最危险的不是「值冲突」,而是「业务不变量冲突」。两个 leader 都把库存从 1 改成 0,值上看起来收敛了,但两个订单都成立,实际卖了 2 件。LWW 能合并值,不能合并「已经发生的副作用」。

3.4.3 fencing:把旧 leader 挡在资源外

解决分裂脑不能只靠「旧主自觉」。旧主可能 GC 停顿、网络隔离、时钟错乱,它「不知道自己已经过期」。可靠的做法是在资源端加一道闸:每次提升 leader 时分配一个单调递增的 epoch/term,资源端记录见过的最大 epoch,拒绝更小的 epoch。

提升 B 时:epoch = 8
B 写存储:  if epoch >= stored_epoch: accept; stored_epoch = 8
A 恢复后:  epoch = 7
A 写存储:  7 < 8 -> reject

这就是 fencing token 的核心:不是阻止旧 leader 发请求,而是让它的请求在资源端无效。token 必须由单调计数器分配,资源端记录见过的最大值并拒绝更小的 token;相等只应发生在同一个 leader 的重试上。如果两个 leader 拿到同一个 token,说明分配器已经失效,fencing 无法区分它们。

常见的 fencing 手段:

手段作用点优点局限
epoch/term 校验存储/数据库精确、可审计需要资源端配合
STONITH/断电物理机/虚拟机彻底慢,可能误杀
网络隔离/撤权网络/凭证快速旧主仍可能在本地写
租约(lease)时间无需资源端改造依赖时钟,GC 停顿会失效
只读模式数据库简单应用可能绕过

租约和 fencing 经常一起用:租约让旧主「大多数时候自觉停」,fencing 让它在「不自觉」时也写不进去。只靠租约是不够的,因为租约依赖本地时钟;进程停顿超过租约时间,醒来后可能继续写。第 9 章会把租约、GC 停顿和 fencing 展开成完整的锁协议。

3.4.4 节点角色状态机

FOLLOWER ──election timeout──▶ CANDIDATE ──majority──▶ LEADER
    ▲                              │                     │
    └──── 更高 term / 发现 leader ──┘                     │
    └──────────── 更高 term / 租约到期 ──────────────────┘

最容易被忽略的是 LEADER → STOPPED 这条边。很多实现只让 leader 退回 follower,然后期待它「不再接受写」;但如果客户端还连着它,或者它还有本地缓存,就可能继续服务。「退回 follower」是状态变更,「停止服务」是行为变更,两者之间需要显式的 fence 或撤权。

例 3.4.1 · 一次 GC 停顿制造的双主

leader A 的租约是 10 秒。某次 Full GC 停顿了 12 秒;B、C 在 10 秒后判定 A 失联,选出 B(term 8)。A 在 12 秒后恢复,仍持有 term 7 的写权限,客户端 C1 正好在这时向 A 写入。

  • 如果存储端有 epoch 校验:A 的写被拒绝,C1 收到错误,重试到 B,安全。
  • 如果只有租约、没有 fencing:A 的写成功,B 也接受写,进入双主;恢复时两条历史冲突。

租约只能降低「旧主继续写」的概率,不能把它降到零;fencing 才是最后一道闸。

3.5 多主复制:冲突从哪来

单主复制的写路径只有一条,所以「顺序」天然存在。多主复制(multi-leader)让多个节点都能接受写,写延迟可以做到本地,但代价是:两个写可能在没有因果关系的情况下同时发生。这就是冲突。

多主不是为了「更高级」,它有明确的使用场景:

场景为什么需要多主冲突从哪里来
跨地域双活让每个地域的写都走本地,降低 RTT两个地域改同一行
离线优先客户端手机/桌面在断网时继续写,恢复后同步本地草稿与服务端版本
collaborative 编辑多个用户同时编辑同一文档同一段文字/同一字段
多写入端不同服务各自写同一张表缺少统一写入口
迁移/双跑新旧系统同时写,逐步切流两边都认为自己是最新

「应用双写」经常被误认为多主复制,其实不是。多主复制至少有一条复制日志,写操作会被传播、排序、检测冲突;应用双写是两个独立的写请求,没有共享日志,也没有原子性。

维度多主复制应用双写
传输复制日志,有顺序两个独立客户端调用
失败一边写成功、一边失败,日志可补部分成功,通常没有补偿日志
冲突检测版本/因果元数据多数没有
修复自动合并或半自动对账脚本,常靠人工
典型问题冲突解决双写不一致、重复写

3.5.1 冲突的六个来源

来源例子冲突形态能否自动检测
双活同 key上海和法兰克福同时改同一库存写写需要版本向量/时间戳
离线重放手机离线改了草稿,回线后上传写写需要 base version
双写写数据库成功,写搜索索引失败读到的两份数据不一致需要 outbox/对账
切换重叠旧主未停,新主已接写写需要 epoch/fencing
唯一键两边都插入同一个 email约束冲突数据库唯一索引
读路径两个 leader 返回不同值读冲突/siblings需要版本比较

最后一类「读冲突」值得单独说。写冲突是「两个写并发」,读冲突是「一次读看到了多个并发版本,或者从不同 leader 读到了不同值」。有些系统(如 Dynamo 系)会把多个版本作为 siblings 一起返回,让客户端合并;有些系统在服务端用 LWW 选一个,读冲突被隐藏,但可能选错。读冲突通常是写冲突没有被解决的症状,而不是另一类独立问题。

3.5.2 一个库存例子:LWW 解决不了不变量

假设两个地域各有一个 leader,库存初始为 1:

时间上海 leader法兰克福 leader结果
t0读到 stock = 1读到 stock = 1都认为有货
t1卖出 1 件,写 stock = 0卖出 1 件,写 stock = 0两笔订单都成功
t2复制 stock = 0复制 stock = 0值收敛为 0
t3对账对账卖了 2 件,库存 0,超卖 1 件

从「值」的角度,两个写都是 0,LWW 合并后还是 0,看起来完美收敛;从「业务」的角度,两笔订单都成立,不变量已经破了。冲突解决只能选一个值,不能撤销一个已经发生的副作用。

要避免这种问题,要么把库存变成「预留/扣减」的协调操作(quorum 或单主),要么用 escrow:把库存拆成多个地域的额度,跨额度调拨需要协调。CRDT 的 PN-Counter 能正确累加,但同样不能阻止「减到负数」;不变量需要额外的协调或约束。

定义 3.5.1 · 写冲突与读冲突

写冲突(write conflict)是两个写操作之间没有因果关系(谁也不在对方的因果历史里),却修改了同一个对象。读冲突(read conflict)是一次读操作看到了多个并发版本,或从不同副本读到了不一致的值,需要选择或合并。

写冲突是根因,读冲突是表现。检测写冲突需要因果元数据(版本向量、base version、epoch);解决写冲突需要业务语义(LWW、合并、拒绝、补偿)。没有因果元数据,系统只能看到「两个值」,无法判断它们是「先后写」还是「并发写」。

3.5.3 冲突检测:早检测还是晚检测

策略做法优点缺点
写时检测写请求带 base version,版本不匹配就拒绝冲突早暴露,客户端可重试/合并需要客户端配合,可能降低可用性
读时检测服务端保存多版本,读时返回 siblings写不阻塞客户端要合并,体验复杂
事后检测复制后对账,发现不一致再修实现简单发现晚,修复可能已影响业务
约束检测唯一索引/外键/检查约束数据库保证只能覆盖部分不变量,冲突时复制可能停

没有一种策略适合所有场景。支付/库存这类强不变量,应该在写时检测(条件更新、唯一约束、quorum);协作编辑/购物车这类可合并状态,可以读时检测 + CRDT;分析/日志这类可重放数据,事后对账即可。

3.6 冲突解决:LWW、版本向量、CRDT

检测到冲突之后,必须选一个解决方案。工程上常见三种:LWW(最后写入者获胜)、版本向量(检测并发,交给业务合并)、CRDT(用可交换的数据结构自动合并)。它们不是「谁更先进」,而是「谁愿意付出多少元数据与协调成本」。

3.6.1 LWW:最简单,也最容易静默丢数据

LWW(last-write-wins)给每个写附加一个时间戳,合并时选时间戳最大的;时间戳相同则用节点 ID 等确定性规则打破平局。

它的问题不是「实现难」,而是「时间戳不是因果」:

事件实际发生时间A 的时钟B 的时钟记录的时间戳
A 写 x = 110:00:00.000快 1 秒—10:00:01.000
B 写 x = 210:00:00.500—慢 1 秒09:59:59.500

B 的写发生在 A 之后,但它的时间戳更小;LWW 选 x = 1,B 的写被静默丢弃。没有报错,没有冲突提示,只有对账时才发现「用户明明改了,怎么还是旧值」。

LWW 还有几个变体问题:

  • 时间戳由谁生成:应用服务器、数据库、客户端?客户端时钟最不可信;应用服务器也可能 NTP 回拨。
  • 删除与更新:删除通常用 tombstone(带时间戳的删除标记)。如果 tombstone 被过早回收,旧更新可能「复活」已删除的数据。
  • 相同时间戳:必须有一个确定性的 tie-break,否则不同副本可能选不同值,收敛失败。
  • 时钟回拨:NTP 把时钟往回调,后来的写可能带更小的时间戳;单调时钟只能保证同一台机器内不倒退,不能跨机器比较。
一句话

LWW 保证「所有副本最终选同一个值」,不保证「选的是业务上正确的值」。它用「丢掉一个写」换「实现简单」,适合那些「丢一个旧值没关系」的场景,比如用户偏好、最后一帧位置;不适合余额、库存、权限。

3.6.2 版本向量:先判断「是不是并发」

版本向量(version vector)给每个副本一个计数器。副本本地写时把自己的计数加一;收到别人的版本时逐分量取最大。两个版本向量之间只有三种关系:

  • a 支配 b(a 的所有分量 ≥ b,且至少一个 >):b 是旧版本;
  • b 支配 a:a 是旧版本;
  • 互不支配:两个写并发,需要业务合并。
from collections import Counter

def happens_before(a, b):
    """a 的因果历史是否严格早于 b。"""
    keys = set(a) | set(b)
    return a != b and all(a.get(k, 0) <= b.get(k, 0) for k in keys)

def concurrent(a, b):
    return not happens_before(a, b) and not happens_before(b, a)

def merge(a, b):
    out = Counter(a)
    for node, n in b.items():
        out[node] = max(out[node], n)
    return out

a = Counter({"A": 2, "B": 1})
b = Counter({"A": 1, "B": 2})
print(concurrent(a, b))      # True:两个写并发
print(dict(merge(a, b)))     # {'A': 2, 'B': 2}:合并后的因果历史

c = Counter({"A": 2, "B": 2})
print(happens_before(b, c))  # True:c 见过 b 的写

这段代码只解决「检测」,不解决「合并」。当 concurrent(a, b) 为真时,系统必须做一个业务决定:保留两个值(siblings)、按 LWW 选一个、按字段合并、还是拒绝并要求客户端重试。

版本向量也有成本:

问题说明缓解
元数据大小每个 key 存 N 个计数器,N 是副本/客户端数只保留活跃副本,定期裁剪
动态成员增删副本会改变向量维度版本向量 + 成员 epoch
客户端 ID 膨胀每个离线客户端一个 ID,向量越来越大dotted version vector、按会话合并
不提供全序并发关系是偏序,无法直接排序业务合并或额外协调

版本向量不是用来排序的,而是用来判断「能不能排序」。如果两个版本并发,任何「谁大谁赢」的规则都是在替业务做决定;LWW 只是其中一种决定方式。

3.6.3 CRDT:用可交换的结构消灭冲突

CRDT(conflict-free replicated data type)把「合并」设计成数学运算,使副本无论以什么顺序、收到多少次更新,最终都收敛到同一个值。它依赖三个性质:

  • 交换律:a ⊔ b = b ⊔ a;
  • 结合律:(a ⊔ b) ⊔ c = a ⊔ (b ⊔ c);
  • 幂等律:a ⊔ a = a。

满足这三条,合并就是一个 join-semilattice;副本不需要协调,只要最终把所有更新都传播到,就会收敛。常见的 CRDT:

类型表示合并方式能保证什么不能保证什么
G-Counter每个副本一个只增计数逐副本取 max所有增量都不丢不能减
PN-Counter两个 G-Counter(P/N)分别合并,值 = sum(P) − sum(N)可增可减不能阻止减到负数
OR-Set元素 + 唯一 tag并集;remove 只删见过的 tag并发 add 不丢tombstone 占空间
LWW-Register值 + 时间戳取时间戳最大收敛时钟偏斜会丢更新
RGA/序列 CRDT带位置的字符/节点按因果顺序插入协同编辑收敛元数据与 GC 成本

以 G-Counter 为例:A、B 两个副本,初始都是 0;分区期间 A 加 3 次、B 加 1 次;合并时逐副本取 max,得到 A=3, B=1,总值 4,两个增量都没丢。PN-Counter 则在每个副本上维护 P(加)和 N(减)两个向量,合并后 sum(P) − sum(N),减法也不会丢。

但 CRDT 只保证「收敛到某个值」,不保证「这个值满足业务不变量」。PN-Counter 可以减到负数;OR-Set 可以让购物车里同时出现「已删除」和「并发添加」的商品。CRDT 解决的是「副本最终一致」,不是「业务规则永远成立」。库存不能为负、余额不能透支这类不变量,仍然需要协调(quorum、单主、escrow)或约束。

定义 3.6.1 · 因果序与并发

若事件 a 在 b 的因果历史中(同进程先后发生,或 a 的消息被 b 收到),记作 a → b。若 a → b 和 b → a 都不成立,则 a 与 b 并发(concurrent)。版本向量是判断这种偏序的一种编码:a → b 当且仅当 a 的每个分量都 ≤ b 且至少一个 <。

并发不是「同时发生」的物理时间概念,而是「互相不知道对方」的逻辑关系。两个相隔一小时的写,只要没有消息传递,仍然是并发的。

3.6.4 三种方案怎么选

选择顺序通常是:先问「冲突能不能在写路径上避免」(单主、quorum、条件更新);不能避免,再问「业务能不能合并」(版本向量 + 合并函数);最后才问「能不能用 CRDT 自动合并」。LWW 元数据最小但会静默丢写,版本向量把冲突暴露给业务,CRDT 自动收敛但元数据和语义成本最高。不要因为 CRDT 听起来先进,就把需要强不变量的业务塞进去。

例 3.6.1 · 购物车的并发添加与删除

用户的购物车在两个设备上同时操作:设备 A 添加商品 X,设备 B 删除商品 X(B 删除的是它看到的旧版本)。用 OR-Set 表示,A 的 add 带唯一 tag t1,B 的 remove 只能删除它观察到的 tag 集合;如果 B 没见过 t1,删除不会生效,合并后 X 仍在购物车。

这符合「并发添加优先于删除」的语义,但不一定符合用户预期:用户明明在 B 上删了。要满足「删除优先」,需要更强的协调或业务规则;要满足「添加优先」,OR-Set 就够了。CRDT 的选择也是产品语义的选择。

3.7 无主复制:quorum、读修复、hinted handoff、Merkle

单主和多主都有「谁是主」的问题。无主复制(leaderless)干脆取消主:客户端(或协调者)把写发给 N 个副本中的若干个,只要收到 W 个确认就算成功;读的时候联系 R 个副本,取版本最新的值。Dynamo 系系统(Cassandra、Riak、Voldemort)是代表。

Quorum 相交:R + W > N 只保证集合相交 N=3、W=2、R=2 时,读集合与写集合至少共享一个副本;但共享副本不一定有最新值。 Quorum 相交:R + W > N 只保证集合相交 W = {A, B} R = {B, C} B A C 交集 B 是读能看到最新写的必要条件 R + W > N ⇒ 读集合 ∩ 写集合 ≠ ∅ 常见误读 × 相交 = 线性一致 × W=all 才安全
读图:相交只解决「至少一个副本知道这次写」,不解决「读是否等到修复完成」。要线性一致,还需要写确认、读修复/提交等待与顺序规则。

3.7.1 quorum 的数学:相交是必要条件

设每个 key 有 N 个副本,写需要 W 个确认,读需要 R 个响应。如果

R+W>N R + W &gt; N

那么任意一个写集合与任意一个读集合必然相交。证明用鸽巢原理:两个集合都是 N 的子集,大小之和超过 N,不可能不相交。

NWRR + W > N含义
322是容忍 1 个副本故障;读写都联系多数派
311否写快读快,但读可能完全错过写
331是写必须全部成功,读一个即可;写可用性差
533是容忍 2 个副本故障;跨 5 个故障域

相交只说明「至少一个副本见过这次写」,不说明「读一定能拿到它」。这是本章最容易被跳过的一步,也是面试和事故里最常见的误解。

定理 3.7.1 · quorum 相交

若一次写已经收到 W 个副本的确认,一次读联系了 R 个副本,且 R + W > N,则读集合与写集合至少有一个公共副本。

这个定理只保证「公共副本存在」,不保证:该副本的写已经持久化、该副本在本次读的响应里、读路径会比较版本、以及该写与并发写之间有全序。四个条件任意一个不成立,读仍可能返回旧值或未完成的值。

3.7.2 R + W > N 的八个边界

下面八条,每一条都能单独让 R + W > N 失效。把它们当成检查表,而不是理论注脚。

#边界具体怎么失效修复
1读返回第一个响应读集合里有最新值,但客户端取了先到的旧值读完 R 个,按版本取最大
2ack 早于 fsync副本回 ack 后崩溃,写丢失ack 前 fsync;或用 remote_apply
3sloppy quorum写落到 hint 节点,不在 home set;读 home set 不相交读也查 hint,或等 hint 回放
4成员变更读和写用了不同的 N/副本列表用配置 epoch,切换期间禁止读写混用
5并发写两个写都满足 W,但没有全序版本向量 + 合并,或引入共识
6读到未完成的写写只到达一个副本、coordinator 崩溃;读却看到了它读只接受已提交版本,或写用两阶段
7读修复是异步的本次读返回旧值,修复只对后续读生效阻塞读修复,或读主/等修复
8多 key 无原子性每个 key 各自 quorum,跨 key 仍可能不一致单 key 事务/共识,或接受最终一致

第 6 条尤其反直觉:一个写只到达 A,还没凑够 W,coordinator 就崩溃了;但 A 上的值已经能被读到。读集合 {A, C} 取最大版本,会把这条「从未完成」的写返回给客户端;之后 A 被修复回旧值,客户端之前读到的值又消失了。读到未提交的写,比读到旧值更糟:它破坏了「读到的值不会倒退」的直觉。

3.7.3 一个 3 副本 quorum 模拟器

下面的模拟器只用 Python 标准库,故意把「读路径」「持久化」「sloppy quorum」拆成可切换的开关,用来复现上面三条边界。它不是生产实现,而是一个可以跑的思维实验。

"""3 副本 quorum 模拟器(Python 标准库)。

结论:R + W > N 只保证读集合与写集合相交;
相交的副本是否被读到、是否落盘、是否在 home set 内,都还要单独保证。
"""
from dataclasses import dataclass, field


@dataclass
class Node:
    name: str
    up: bool = True
    fsync: bool = True
    store: dict = field(default_factory=dict)    # 已落盘:key -> (version, value)
    pending: dict = field(default_factory=dict)  # 仅内存:key -> (version, value)

    def put(self, key: str, version: int, value: str) -> None:
        if self.fsync:
            self.store[key] = (version, value)
        else:
            self.pending[key] = (version, value)

    def get(self, key: str):
        return self.pending.get(key) or self.store.get(key)

    def crash(self) -> None:
        self.up = False
        self.pending.clear()        # 未落盘的写在崩溃后消失

    def restart(self) -> None:
        self.up = True


class Cluster:
    def __init__(self, names=("A", "B", "C")) -> None:
        self.names = names
        self.nodes = {n: Node(n) for n in names}
        self.version = 0

    def write(self, key, value, w, down=(), sloppy=False,
              ack_before_fsync=False):
        self.version += 1
        ver = self.version
        targets = [n for n in self.names
                   if self.nodes[n].up and n not in down]
        picked = targets[:w]
        if sloppy and len(picked) < w:       # 用 hint 节点凑够 W
            for i in range(w - len(picked)):
                extra = f"H{i + 1}"
                self.nodes.setdefault(extra, Node(extra))
                picked.append(extra)
        acks = []
        for n in picked:
            node = self.nodes[n]
            node.put(key, ver, value)
            if node.fsync or ack_before_fsync:
                acks.append(n)
        return ver, acks

    def read(self, key, r, nodes=None, pick="max"):
        names = nodes or [n for n in self.names if self.nodes[n].up][:r]
        got = []
        for n in names:
            node = self.nodes[n]
            if node.up and node.get(key) is not None:
                got.append((n, node.get(key)))
        if not got:
            return None, got
        if pick == "max":
            return max(got, key=lambda x: x[1][0])[1], got
        return got[0][1], got


def seed(cluster):
    cluster.version = 1
    for n in cluster.names:
        cluster.nodes[n].put("balance", 1, "v1")


def show(label, val, got):
    ver = val[0] if val else None
    value = val[1] if val else None
    print(f"{label:24s} version={ver} value={value} from={[n for n, _ in got]}")


print("== A. 相交不等于读到最新:读路径必须比较版本 ==")
c = Cluster(); seed(c)
_, acks = c.write("balance", "v2", w=2)
print("write acked:", acks)
show("read max (A,C)", *c.read("balance", 2, nodes=("A", "C"), pick="max"))
show("read first (C,A)", *c.read("balance", 2, nodes=("C", "A"), pick="first"))

print("\n== B. ack 早于 fsync:写已确认,崩溃后仍丢 ==")
c = Cluster(); seed(c)
c.nodes["B"].fsync = False
_, acks = c.write("balance", "v2", w=2, ack_before_fsync=True)
print("write acked:", acks)
c.nodes["B"].crash(); c.nodes["B"].restart()
show("read after crash", *c.read("balance", 2, nodes=("B", "C")))

print("\n== C. sloppy quorum:hint 节点不在 home set,读集合不相交 ==")
c = Cluster(); seed(c)
c.nodes["B"].up = False; c.nodes["C"].up = False
_, acks = c.write("balance", "v2", w=2, sloppy=True)
print("write acked:", acks)
c.nodes["B"].restart(); c.nodes["C"].restart()
show("read home (B,C)", *c.read("balance", 2, nodes=("B", "C")))
show("read with hint", *c.read("balance", 2, nodes=("A", "H1")))

运行结果:

== A. 相交不等于读到最新:读路径必须比较版本 ==
write acked: ['A', 'B']
read max (A,C)           version=2 value=v2 from=['A', 'C']
read first (C,A)         version=1 value=v1 from=['C', 'A']

== B. ack 早于 fsync:写已确认,崩溃后仍丢 ==
write acked: ['A', 'B']
read after crash         version=1 value=v1 from=['B', 'C']

== C. sloppy quorum:hint 节点不在 home set,读集合不相交 ==
write acked: ['A', 'H1']
read home (B,C)          version=1 value=v1 from=['B', 'C']
read with hint           version=2 value=v2 from=['A', 'H1']

把三段输出对应回边界表:

场景表面现象真正失效的假设生产修复
A写 W=2、读 R=2,仍读到 v1读路径取了第一个响应,没比较版本读满 R 个,按版本/向量取最新
B写已 ack,崩溃后读不到ack 早于持久化ack 前 fsync;提升时只选持久化副本
C写已 ack,home set 读不到hint 节点不在读集合,sloppy quorum 不相交读也查 hint,或等 hint 回放后再读

这三段都没有违反 R + W > N。失效的是「读会等满 R 个并比较版本」「ack 代表持久化」「读写使用同一个 home set」这三个隐含假设。quorum 的数学只覆盖集合关系,覆盖不了实现细节。

3.7.4 为什么 quorum 不是线性一致

线性一致要求:存在一个全序,读能看到在它开始之前已经完成的写,并且读到的值不会倒退。R + W > N 最多保证「至少一个副本知道这次写」,它缺三样东西:

  1. 全序:无主复制没有 leader/term,两个并发写没有天然顺序;用 LWW 时间戳排序,会引入 3.6.1 的时钟问题。
  2. 实时序:读可能看到未完成的写,之后该写消失;也可能在写完成前读到它,之后又读不到。线性一致不允许这种「读到的值倒退」。
  3. 提交语义:W 个 ack 不等于 W 个已提交的版本;如果 ack 在 fsync 之前,或写只到达一个副本就被读出来,都不满足「已完成」的定义。

在单写者、版本单调、读满 R 个并按版本取最大、ack 前 fsync、无 sloppy quorum、无成员变更这些假设同时成立时,quorum 读写可以接近「regular register」的语义:读不会返回比最近完成写更旧的值(除非并发)。但它仍然不是线性一致,因为并发写没有全序,多 key 操作也没有原子性。想把 quorum 变成线性一致,最终还是要引入共识(第 5 章)。

「强一致 quorum」的说法

有些文档把 R + W > N 直接叫「强一致」。这个说法省略了太多前提,容易误导。更准确的表述是:「在单写者、持久化 ack、读满 R 并比较版本、无 sloppy quorum 的前提下,quorum 读写提供比最终一致更强的保证,但仍不是线性一致。」如果业务需要线性一致,应该看第 4 章的定义和第 5 章的共识协议,而不是调 W/R 参数。

3.7.5 读修复与反熵

读修复(read repair)是 quorum 读的配套动作:读的时候联系 R 个副本,发现某个副本版本落后,就把最新版本写回给它。同步修复在返回前写回,读延迟高但修复确定;异步修复返回后写回,读快但进程崩溃会丢,下一次读仍旧。读修复只覆盖「被读到」的 key;冷 key 的差异要靠反熵(anti-entropy)后台比对。读修复是热点的快速通道,反熵是冷数据的兜底。

读修复还有一个陷阱:它可能把「未完成的写」当成最新版本写回所有副本,把一个本来会失败的写「扶正」。如果系统不允许脏读,读路径必须先判断版本是否已提交(例如带 commit 标记),而不是只看版本号大小。

3.7.6 hinted handoff:用别人的盘暂时接住写

当 home set 里的副本不可达时,coordinator 把写临时放到一个 hint 节点,记录「这条写原本属于哪个副本」;等目标副本恢复再回放。hint 记录至少包含 target、key、version、value、expires_at,回放时按 version 幂等写入,目标已有更高版本就跳过。

它解决的是写可用性:副本挂了,写仍能凑够 W。代价是读一致性:hint 节点不在 home set,普通 R 个读可能碰不到它;回放前读到的还是旧值。hint 过期后只能靠反熵修复;如果 hint 节点也挂了,这条写可能彻底丢失。它适合「写不能失败、读可以旧」,不适合「读必须最新」。

3.7.7 Merkle 树:用哈希找到「哪一段不一样」

反熵逐 key 比对成本太高。Merkle 树把 key 范围切成桶,每个桶算哈希,再逐层向上算到根;先比根哈希,相同就认为整个范围一致,不同就递归比较子节点,只修复不同的桶。

root ──▶ h(0-7), h(8-15) ──▶ h(0-3), h(4-7), h(8-11), h(12-15)

比较成本从 O(全部 key) 降到 O(不同的桶 × log N),网络只传哈希。修复时再拉取具体 key 的值。Merkle 树只能发现「哈希不同」,不能判断「哪个版本更新」;树的重建有 CPU/IO 成本;哈希要用 SHA-256/BLAKE2;tombstone 被提前回收时,两边可能「看起来一致」但语义上已经丢了删除。

3.7.8 无主复制的适用边界

无主复制把「选主」的成本换成了「冲突解决 + 修复」的成本。写可用性优先、冲突可合并、读可旧、能接受后台修复的场景适合它;余额、库存、权限、跨 key 原子事务不适合。如果你的业务不能定义合并函数,无主复制只会把冲突推迟到对账时爆发。

本节的三个概念不要混
  • quorum:读写集合的相交条件,解决「至少一个副本知道写」。
  • read repair:读路径发现旧副本并写回,解决「热点 key 的副本追赶」。
  • anti-entropy:后台比对全部数据并修复,解决「冷 key 和丢失的 hint」。

三者经常一起出现,但职责不同。把 read repair 当成 anti-entropy,会漏掉冷数据;把 anti-entropy 当成实时修复,会让读延迟失控。

3.8 复制延迟与读己之写

用户改完头像,点击保存,页面跳转。请求被负载均衡打到一台只读副本,副本的 apply 还停在两秒前,用户看到旧头像,又点了一次保存。第二次请求打到主库,成功;用户刷新,终于看到新头像,但不知道第一次到底有没有生效。

这不是数据丢失,而是可见性滞后。写已经在主库提交,只是读的那台副本还没 apply 到那个位置。复制延迟(replication lag)描述的正是「主库的提交位置」与「副本的可见位置」之间的差距。

3.8.1 lag 是一个分布,不是常数

监控上常写「复制延迟 2 秒」,但真实 lag 是一条随时间波动的曲线:平时 50 ms,批量导入时 30 秒,DDL 时几分钟,网络抖动时甚至瞬间归零(因为主库也没写)。用平均值会掩盖长尾,用瞬时值会误报。

更准确的度量是位置差,而不是秒数:

  • PostgreSQL:pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn),单位是字节;
  • MySQL:Read_Source_Log_Pos - Exec_Source_Log_Pos,以及 Retrieved_Gtid_Set 与 Executed_Gtid_Set 的差集;
  • Kafka:ISR 是否包含全部副本,以及 follower 的 MaxLag。

秒数 lag 有一个天然缺陷:它通常用「主库当前时间 − 副本最后应用事务的时间」估算。主库没有新写时,副本看起来永远追平;主库写入突然停止,lag 会瞬间归零,但副本可能还差几 MB 的 WAL。用位置差,不要只用秒数。

3.8.2 读己之写:四种会话保证

分布式系统里,「读到自己刚写的内容」不是天然成立的。它属于会话保证(session guarantees)的一种,需要显式实现。

保证含义常见破坏场景实现方式
读己之写(RYW)同一会话能读到自己的写写主库、读从库粘主库、写后 token、会话跟踪
单调读(monotonic reads)同一会话不会读到更旧的版本多次读打到不同 lag 的副本粘副本、按版本比较
单调写(monotonic writes)同一会话的写按发出顺序生效多主、重试乱序序列号、单连接、因果 token
写后读(writes-follow-reads)写基于读到的版本读到旧值后写覆盖新值CAS/base version、因果元数据

最常用的实现是「写返回一个位置 token,读副本等到自己的 replay 位置超过 token 再返回」:

import time

def read_after_write(replica, primary, key, write_lsn, timeout_s=0.5):
    """write_lsn 来自主库提交后的 pg_current_wal_lsn()。"""
    deadline = time.monotonic() + timeout_s
    while time.monotonic() < deadline:
        if replica.replay_lsn() >= write_lsn:
            return replica.get(key)
        time.sleep(0.005)
    return primary.get(key)      # 超时回主库,保底但不放大延迟

这段代码有三个工程细节:

  1. write_lsn 必须在主库提交之后取,不能取事务开始时的位置;
  2. 副本比较的是 pg_last_wal_replay_lsn(),不是 pg_last_wal_receive_lsn();收到 WAL 不等于 apply;
  3. 超时后回主库,而不是一直等;否则一台慢副本会把整个会话拖死。

真实客户端不要把 LSN 当字符串比较;用 pg_wal_lsn_diff() 或驱动提供的 pg_lsn 类型,否则格式变化会让比较结果失真。

另一种做法是「会话粘性」:同一用户的读请求在一段时间内都路由到主库,或者都路由到同一台副本。它实现简单,但代价是读扩展失效(粘主库)或负载不均(粘副本)。RYW 不是免费的;它把一致性成本转嫁到了路由层。

3.8.3 从库读的风险:按业务分级

业务能否从库读原因建议
余额、额度、权限否旧值会导致错误决策(超扣/越权)主库或 quorum 读
库存扣减否读到旧库存会超卖条件更新 + 主库
用户刚改的资料可以,但要 RYW用户感知明显写后 token/粘主
商品详情、文章可以旧几秒通常可接受从库 + 缓存
Feed、推荐可以延迟不敏感从库,接受 lag
审计、对账谨慎需要完整时间点快照/主库导出

这张表不是「从库不安全」,而是「从库读要匹配业务对陈旧度的容忍度」。把余额读放到从库,等于把 RPO 的风险转成了读错误;把商品详情锁在主库,则是浪费读扩展能力。

单调读与负载均衡

轮询负载均衡会把同一会话的连续读打到不同副本,可能先读到新值、再读到旧值,违反单调读。解决方式:按用户/会话哈希到固定副本,或让客户端携带上一次读到的版本,副本版本不够就等待/转主。「读多台副本取最新」是另一种思路,但它把 RYW 的成本加到了每次读上。

3.8.4 lag 与 failover 的交叉点

复制延迟本身不是事故,延迟 + 故障切换才是。主库在 lag 5 秒时崩溃,提升从库就丢这 5 秒内已确认的写;提升更落后的副本,丢得更多。RPO 不是配置里的一个数,而是「故障发生时 lag 的分布」。所以 lag 监控不只用于「看副本健康」,还要用于「判断能不能切换」:自动切换前检查候选副本的 replay 位置是否覆盖所有已提交事务;不覆盖就等待追平或人工确认并明确 RPO。

3.9 失败模式:分区下的双主

网络分区(network partition)是最容易被误判的故障:两边机器都活着,CPU、磁盘、进程都正常,只是彼此收不到心跳。监控看到的是「A 说 B 失联」「B 说 A 失联」,而不是「某一台机器死了」。

如果系统在失联时仍然允许两边写,就会出现两个 leader。下面是一条典型的双主时间线。

t0   正常:A 是 leader(term 7),B、C 是 follower
     client ──▶ A ──replicate──▶ B, C

t1   分区:A 与 B、C 之间的链路中断(约 43 秒)
     [A]  ┊ 分区 ┊  [B, C]
     两边都活着,都认为对方失联

t2   A 的租约未到期,继续接受写;B、C 选举超时
     A: "我还能撑 10 秒"        B、C: "A 可能死了,准备选主"

t3   B 获得多数票,成为 leader(term 8)
     A 仍以 term 7 接受写;客户端连接池里还有指向 A 的连接

t4   两个写并发:
     client X ──▶ A: UPDATE stock SET qty = 9 WHERE id = 1
     client Y ──▶ B: UPDATE stock SET qty = 8 WHERE id = 1
     两个请求都可能返回成功

t5   分区恢复:A 的 term 7 写被 B 拒绝;A 必须自停或被 fence
     若没有 fencing,A 继续写,双主持续

t6   对账:找出只在 A 上存在的写、只在 B 上存在的写
     选择权威一侧;旧主重建为 follower;校验和比对

3.9.1 两个 leader 都接受写,会发生什么

双主期间的操作恢复时的表现业务后果能否自动修复
两边插入相同主键主键冲突,复制线程停新数据无法同步,lag 增长需要人工改键/丢弃一侧
两边更新同一行后应用的覆盖先应用的丢失更新LWW 可收敛,但会丢一个写
两边递增同一计数计数被覆盖或重复累加计数偏差需要重算/对账
两边发唯一券码唯一索引冲突一侧写入失败,客户端重试需要发号器/去重
两边都扣库存值收敛,订单都成立超卖无法靠合并修复
两边都写审计日志日志重复/缺失审计不可信需要 op_id 去重

最危险的是最后两行:值可以合并,副作用不能合并。库存从 1 变成 0,两个 leader 都写 0,LWW 合并后还是 0,看起来「收敛」了;但两个订单都已经创建,超卖不会因为值收敛而消失。

3.9.2 如何止损:先停写,再选权威,最后对账

双主止损的顺序不能反。很多二次事故是因为「先对账,发现两边都有理,继续写,越对越乱」。

  1. 停止两边写入:把数据库设为只读,或撤销应用的写权限;不要只靠应用层开关,应用可能有多个写入路径。
  2. fence 旧 leader:撤销凭证、隔离网络、STONITH,或让存储端拒绝低 epoch 的写;确保它即使恢复也不能写。
  3. 确定权威一侧:按 term/epoch、已提交日志、quorum 选出的 leader 为准;不要按「哪边数据多」「哪边时间戳新」选。
  4. 保留分叉证据:把两边的 binlog/WAL、差异行导出,供审计和修复;不要急着删除旧主的日志。
  5. 按操作 ID 对账:用业务操作号、幂等键、唯一约束比对,而不是逐行 diff;逐行 diff 无法区分「丢失的写」和「合法的旧值」。
  6. 重建旧主:以权威侧为源,重新做一份副本;不要直接把旧主加回集群继续复制,否则会把分叉带回。
  7. 复盘触发条件:为什么旧主在失去 quorum 后还能写?租约、fencing、只读开关哪一个失效了?

3.9.3 分裂脑与「设计上的多主」

维度分裂脑(事故)多主复制(设计)
写权限两个 leader 都以为自己唯一合法多个 leader 明确共享写权限
冲突预期没有预期,通常没有解决机制预期冲突,有检测/合并机制
数据分叉意外,恢复时才发现常态,按版本/CRDT 合并
主要风险不变量被破坏、复制线程停合并语义不符合业务
处理方式停写、fence、选权威、对账检测、合并、补偿

分裂脑不是「多主复制的一种」。多主复制至少承认冲突存在,并准备了检测和合并;分裂脑是单主系统在故障切换时意外多出一个写者,通常没有任何冲突处理。把两者混为一谈,会得出「多主本来就会冲突,所以双主没关系」的错误结论。

定义 3.9.1 · 分裂脑

分裂脑(split brain)是指系统在同一个逻辑分片(shard)上同时存在两个或更多自认为拥有写权限的 leader,且它们都接受并确认了写。分裂脑的必要条件不是「有两个进程」,而是「两个进程都能让写生效」。

判断是否进入分裂脑,看三个信号:同一分片出现两个不同 term/epoch 的写;两边都在推进 commit index;恢复后出现无法用一条日志解释的差异。

只读模式要落在存储层

应用层的「维护模式」开关只能挡住一部分流量。定时任务、迁移脚本、旧版本客户端、直连数据库的运营工具都可能绕过它。止损时要把只读落在存储层(default_transaction_read_only、super_read_only、撤销写权限),并确认没有绕过路径。「我已经把开关关了」不是证据,「存储拒绝了写」才是证据。

3.10 观测:复制 lag 的四个来源

看到「复制延迟 30 秒」,第一反应不应该是「加副本」或「重启复制线程」。lag 是一个结果,它至少可能来自四段不同的路径;每一段的修复方式完全不同。

#来源发生在哪PostgreSQL 字段MySQL 字段典型原因
1生成/发送 backlog主库 WAL 生成、sender 发送pg_current_wal_lsn() - sent_lsn主库 binlog 位置与已发送位置主库写入突增、sender 被阻塞、带宽不足
2网络传输主库到备库的链路sent_lsn - write_lsnIO 线程读取位置RTT、丢包、跨域带宽、MTU
3备库落盘备库 WAL/relay log fsyncwrite_lsn - flush_lsnRelay_Log_Space、relay log fsync磁盘慢、IO 争用、fsync 排队
4备库重放apply/startup 进程flush_lsn - replay_lsnRead_Source_Log_Pos - Exec_Source_Log_PosCPU、锁、长事务、DDL、单线程 apply

Kafka 的 ISR 不直接对应这四段,它是「副本是否追得上 leader」的结果信号:follower 落后超过 replica.lag.time.max.ms 就会被踢出 ISR。看到 ISR 缩小,仍然要回到 broker 的磁盘、网络、fetch 线程去分段定位。

3.10.1 PostgreSQL:把 WAL 位置拆成四段

psql -c "
SELECT application_name, state, sync_state,
       pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn)      AS send_backlog_bytes,
       pg_wal_lsn_diff(sent_lsn, write_lsn)                 AS network_bytes,
       pg_wal_lsn_diff(write_lsn, flush_lsn)                AS fsync_bytes,
       pg_wal_lsn_diff(flush_lsn, replay_lsn)               AS apply_bytes,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)    AS total_bytes,
       replay_lag
FROM pg_stat_replication
ORDER BY total_bytes DESC;"

示例输出:

 application_name |   state   | sync_state | send_backlog_bytes | network_bytes | fsync_bytes | apply_bytes | total_bytes | replay_lag
------------------+-----------+------------+--------------------+---------------+-------------+-------------+-------------+------------
 dc2-sync         | streaming | sync       |                  0 |             0 |           0 |       16384 |       16384 | 00:00:00.004
 dc3-async        | streaming | async      |               8192 |          4096 |           0 |     4194304 |     4202496 | 00:00:02.731

怎么读:

  • send_backlog_bytes 大:主库还没把 WAL 发出去;查 WAL sender、写入突增、带宽。
  • network_bytes 大:已发送但备库没收到/写入;查 RTT、丢包、备库接收进程。
  • fsync_bytes 大:备库收到但没落盘;查磁盘 IO、fsync 延迟、文件系统。
  • apply_bytes 大:已落盘但没重放;查 CPU、锁、长事务、DDL、恢复冲突。
  • total_bytes 是客户端可见 lag 的上界;先定位占比最大的那一段。

replay_lag 为 NULL 不一定是异常:主库没有新 WAL 时,PostgreSQL 无法估算时间差。判断健康要看 total_bytes 是否持续增长,以及副本的 pg_last_wal_replay_lsn() 是否在推进。

3.10.2 MySQL:IO 线程、SQL 线程与 GTID 缺口

mysql -e "SHOW REPLICA STATUS\G" | egrep \
 'Replica_IO_Running|Replica_SQL_Running|Seconds_Behind_Source|Read_Source_Log_Pos|Exec_Source_Log_Pos|Relay_Log_Space|Last_SQL_Error|Retrieved_Gtid_Set|Executed_Gtid_Set'

示例输出:

             Replica_IO_Running: Yes
            Replica_SQL_Running: Yes
          Seconds_Behind_Source: 6
             Read_Source_Log_Pos: 194321117
                Exec_Source_Log_Pos: 190968541
                  Relay_Log_Space: 335544320
                  Last_SQL_Error:
              Retrieved_Gtid_Set: 3f1a9c2e-8b71-11ee-9d0a-0242ac120002:1-918223
               Executed_Gtid_Set: 3f1a9c2e-8b71-11ee-9d0a-0242ac120002:1-918198

怎么读:

  • Replica_IO_Running: No:连不上主库或认证失败;查网络、账号、主库 binlog 保留。
  • Replica_SQL_Running: No:重放报错,复制停了;看 Last_SQL_Error,常见是主键冲突/行不存在。
  • Read_Source_Log_Pos - Exec_Source_Log_Pos 大:已收到但没执行;查 SQL 线程、锁、长事务、并行复制。
  • Relay_Log_Space 增长:relay log 堆积;重放跟不上,优先查 apply。
  • GTID 有缺口但 Seconds_Behind_Source 很小:秒数在骗人;用 Retrieved_Gtid_Set - Executed_Gtid_Set 判断。

Seconds_Behind_Source 的语义是「SQL 线程当前执行的事务时间戳与主库当前时间之差」,不是严格的 WAL 位置差;主库空闲、长事务、时钟偏斜都会让它失真。GTID 缺口比秒数可靠。

3.10.3 Kafka:ISR 是结果,不是分段

kafka-topics.sh --bootstrap-server localhost:9092 --describe --under-replicated-partitions

示例输出:

Topic: orders  Partition: 3  Leader: 2  Replicas: 2,1,3  Isr: 2,1

怎么读:命令有输出,说明存在副本不在 ISR;Isr 少一个,说明 follower 落后超过 replica.lag.time.max.ms;ISR 反复收缩/扩张,说明副本在阈值附近抖动,查 GC、网络、磁盘延迟。UnderReplicatedPartitions 持续大于 0 应告警。

kafka-consumer-groups.sh --describe 里的 LAG 是消费者 lag,不是副本复制 lag。消费者 lag 大说明业务消费慢;副本 ISR 小说明 broker 复制慢。两者修复方向完全不同,不要拿 consumer lag 去查副本。

3.10.4 正常与异常的分界

指标正常需要关注异常/告警
PG total_bytes小于 16 MB,且不持续增长16–256 MB,或 5 分钟内翻倍大于 256 MB,或 replay_lag 持续大于 30 秒
MySQL 位置差小于 16 MB16–128 MB大于 128 MB,或 SQL 线程停止
MySQL GTID 缺口空1–2 个事务且快速收敛持续扩大,或 Last_SQL_Error 非空
Kafka ISR全部副本偶发收缩后恢复持续缺少副本,或 under-replicated 大于 0 达 5 分钟

阈值只是起点,真正要告警的是「趋势」:lag 持续增长、副本位置不再推进、ISR 反复抖动。单次尖刺通常可以忽略,持续 5 分钟的偏移才值得叫人。

空闲数据库的 lag 会骗人

pg_last_xact_replay_timestamp() 和 Seconds_Behind_Source 都相对「最后一次事务」计算。主库没有写时,副本看起来永远追平;一旦恢复写入,积压的 WAL 才暴露出来。生产上通常建一张 heartbeat 表,每秒更新一行,用它的时间戳估算真实 lag;或者直接看 WAL 位置差。不要用「秒数为 0」证明复制健康。

3.11 真实案例:GitHub 2018-10-21 分区与 MySQL 切换

2018 年 10 月 21 日,GitHub 经历了一次持续约 24 小时 11 分钟的服务降级。公开复盘给出的关键事实是:一次约 43 秒的网络分区触发了数据库集群的故障切换;为避免数据不一致,GitHub 暂停了部分写入并降级服务;恢复过程远长于分区本身。

项公开事实
时间2018-10-21
系统GitHub.com 的 MySQL 数据库集群
触发网络分区,持续约 43 秒
切换Orchestrator 执行数据库 failover
影响约 24 小时 11 分钟的服务降级
处置暂停写入/降级,优先保证数据一致性
复盘Oct21 post-incident analysis

3.11.1 失败链

t0   网络分区(约 43 秒)
     │
     ├─▶ 主库与副本失去连接;副本之间仍可能互通
     │
     ├─▶ 健康检查/Orchestrator 判定主库不可用
     │
     ├─▶ 提升一个副本为新主
     │      └─ 若提升点落后于原主的已提交位置,两份历史开始分叉
     │
     ├─▶ 旧主恢复:它可能还有未被复制的写
     │      └─ 若未 fence,旧主继续接受写 -> 双主风险
     │
     ├─▶ 暂停写入/只读降级,阻止分叉扩大
     │
     └─▶ 对账、修复、恢复复制,24h11m 后完全恢复

这条链里,43 秒的分区只是触发器;真正决定影响长度的是后面几步:提升哪个副本、旧主是否被 fence、分叉如何对账、写入暂停多久。RTO 的主体不是选举,而是恢复一致性。

3.11.2 五条可执行教训

#教训落地动作
1自动切换必须检查复制位置候选副本的 replay 位置覆盖所有已提交事务才允许提升
2先 fence 再提升撤销旧主写权限/隔离网络;存储端校验 epoch
3把对账纳入 RTO演练不只测选举,还要测差异发现与修复耗时
4暂停写是合法降级预设只读模式、写入冻结开关、业务降级预案
5观测要能区分「线程活着」和「数据追平」用 LSN/GTID/ISR,不只看 Seconds_Behind_Source
事实边界

本节只引用公开复盘中的时间、系统、持续时长、切换工具和处置原则;具体的命令、日志和内部决策不在公开复盘里,本文不做推测。上面的失败链是机制层分析,用来说明「43 秒分区为什么会变成 24 小时降级」,不是对 GitHub 内部操作的复述。24 小时 11 分钟是服务降级时长,不是数据库完全不可用的时长;这两个说法不能混。

3.12 重试导致重复副作用:新主重放旧命令

复制系统里的重复副作用,通常有两个形状:

  • 客户端重试跨过了 failover:旧主执行了写,客户端超时重试;新主没有见过这条写(或没有见过去重记录),又执行一次。
  • 新主重放旧主的日志:分区恢复后,为了合并历史,把旧主上的一段 binlog/WAL 应用到新主;如果命令是非幂等的(balance = balance - 100),就会再扣一次。

两者都指向同一个问题:副作用的身份没有跟着数据一起复制。前者是客户端重试造成的重复执行,后者是日志重复:同一条逻辑命令在旧主和新主各执行了一次。

3.12.1 一条重复扣款的时间线

时间客户端旧主 A新主 B结果
t0发送 charge(K1, 100)收到,写入 binlog——
t1—执行扣款,准备返回—A 上副作用已发生
t2响应丢失,超时——客户端进入 unknown
t3用 K1 重试A 已被 fenceB 成为新主,收到 K1B 没有 K1 记录
t4——执行扣款,返回成功第二次扣款
t5—A 恢复,binlog 被回放到 Bbalance = balance - 100 再执行第三次扣款

第 t5 步是「新主重放旧命令」的典型:A 的 binlog 里是一条增量更新,回放到 B 时又减了一次。即使客户端没有重试,单靠日志回放也能制造重复副作用。

3.12.2 为什么新主会「不知道」

原因机制后果
去重记录没复制去重表写在另一个库/缓存,failover 只切了主库新主查不到 K1
去重与业务不同事务先扣款、后写去重记录,中间崩溃扣款成功,记录丢失
未提交后缀被截断提升时丢弃旧主未提交的日志旧主已执行但未提交的副作用失去记录
客户端换了键每次重试生成新 UUID服务端无法识别为同一意图
回放不看 op_id只按 binlog 位置重放,不查业务去重表同一操作执行多次

前三条是「身份没复制」,后两条是「身份没使用」。修复要同时覆盖两边。

3.12.3 去重表:和业务写在同一个事务里

CREATE TABLE applied_ops (
  op_id       TEXT PRIMARY KEY,
  fingerprint TEXT NOT NULL,
  applied_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

WITH claim AS (
  INSERT INTO applied_ops(op_id, fingerprint)
  VALUES ('charge:K1', 'sha256:9f2a...')
  ON CONFLICT (op_id) DO NOTHING
  RETURNING op_id
)
UPDATE accounts SET balance = balance - 100
WHERE id = 'a-1' AND EXISTS (SELECT 1 FROM claim);

claim 为空(冲突)时,UPDATE 不执行;一条语句同时完成去重与业务写。若业务更新本身可能影响 0 行(例如账户不存在),仍要在应用层检查并在同一事务回滚。同 op_id 不同参数必须校验指纹并返回冲突,不能把 claim 为空一律当成重复成功。下面是一个最小实现:

def apply_once(conn, op_id, fingerprint, effect):
    with conn:                      # 同一事务:去重记录与业务写一起提交
        cur = conn.execute(
            "INSERT INTO applied_ops(op_id, fingerprint) VALUES (?, ?) "
            "ON CONFLICT(op_id) DO NOTHING",
            (op_id, fingerprint),
        )
        if cur.rowcount == 0:
            row = conn.execute(
                "SELECT fingerprint FROM applied_ops WHERE op_id = ?", (op_id,)
            ).fetchone()
            if row[0] != fingerprint:
                raise ValueError("same op_id, different payload")
            return "duplicate"
        effect(conn)                # 业务写也在同一个事务里
    return "applied"

关键点:

  1. 同一事务:去重记录和业务写要么都提交,要么都回滚;分开写会留下「扣了钱但没记录」的窗口。
  2. 同一复制流:去重表必须和业务表在同一个数据库/同一个日志里;如果它在缓存或另一个集群,failover 可能只带走一半。
  3. op_id 由客户端生成:服务端生成的话,重试会拿到新 ID;客户端生成并在所有重试中复用。
  4. 指纹校验:同 op_id 不同参数要拒绝,不能静默返回旧结果。

3.12.4 回放旧日志:增量与绝对值的两难

旧日志里的命令回放两次回放到更新的状态安全性
balance = balance - 100扣两次覆盖/叠加错误不安全
balance = 900结果相同(幂等)可能覆盖后来更大的余额可能丢更新
INSERT ... VALUES (...)主键冲突可能重复业务记录需要唯一键
UPDATE ... WHERE version = 7第二次不匹配,无操作条件更新,较安全推荐

「增量」不幂等,「绝对值」幂等但会覆盖更新;没有一个纯日志层面的写法能同时解决两者。工程上的做法是:

  • 每条业务命令带 op_id,apply 时先查 applied_ops;
  • 对账时按 op_id 集合比较,而不是按行值比较;
  • 回放旧日志前先 fence 旧主,标记回放会话,禁止正常流量同时写;
  • 对无法安全合并的分叉,选择「以一侧为准 + 全量重建另一侧」,不要强行双向合并;
  • 外部副作用(支付网关、券系统)用同一个 op_id 做幂等键,不能只在数据库里去重。
数据库去重不等于端到端 exactly-once

applied_ops 只能保证「数据库里的这条命令执行一次」。如果 effect 里调用了外部支付 API,而 API 调用成功、数据库事务回滚,外部副作用仍然发生了。端到端的「有效一次」需要:数据库事务 + outbox/消息 + 外部幂等键 + 对账。第 6 章和第 7 章会分别从本地事务和消息投递的角度继续这条链。

3.13 本章陷阱清单

12 条高频错误
  1. 三副本放在同一个机架:副本数不等于故障域数;一次机架掉电就能全带走。先画故障域,再谈 RPO。
  2. 把复制当备份:DELETE 会同步到所有副本;复制防机器故障,备份防逻辑错误,两者不能互相替代。
  3. 以为异步复制 RPO = 0:异步只保证主库本地提交,lag 窗口内的已确认写会在切换时丢。
  4. 以为配了半同步就一定不丢:ack 点在 after_commit、提升落后副本、旧主未 fence,任意一个都能让「已确认」变成「已丢失」。
  5. 只看 lag 秒数:Seconds_Behind_Source 和 replay_lag 在空闲/长事务/时钟偏斜下会失真;用 LSN/GTID/ISR 位置差。
  6. 以为 R + W > N 就是线性一致:它只保证集合相交,不保证读比较版本、写已持久化、并发写有全序,也不保证不读到未完成的写。
  7. 读只取第一个响应:读集合里哪怕有最新值,先到的旧值也会被返回;必须读满 R 个并按版本取最大。
  8. 把 sloppy quorum 当普通 quorum:hint 节点不在 home set,读集合可能完全不相交;读要查 hint 或等回放。
  9. ack 早于 fsync:副本回了 ack 但没落盘,崩溃后写消失;「写成功」变成了「写曾经在内存里」。
  10. 先提升后 fence:旧主还持有写权限,新主已接受写,双主窗口被打开;正确顺序是先 fence 再提升(或至少同时)。
  11. LWW 用应用时钟/updated_at:时钟偏斜会让后发生的写输给先发生的写,静默丢更新;LWW 只适合可丢旧值的场景。
  12. 重试换幂等键,或去重表与业务写分开提交:服务端无法识别同一意图;崩溃窗口会留下「副作用已发生、记录不存在」。

3.14 本章出口检查

检查项达标线没达标去哪
说出复制的三个目标能分别举一个业务例子与代价3.1
计算 RPO/RTO能按故障模型写出数据丢失窗口与恢复阶段3.1、3.4
区分三种同步模式能说清等几个副本、等到哪一步、ack 点在哪3.2
画出日志/状态机能标出 commit index、last applied、未提交后缀3.3
设计一次 failover能列出检测、决策、提升、引流、清场、对账3.4
识别分裂脑能说出双主期间至少三种数据分叉与止损顺序3.9
选冲突解决能比较 LWW、版本向量、CRDT 的代价与边界3.5、3.6
解释 quorum 边界能说出 R+W>N 不保证线性一致的三条理由3.7
观测复制 lag能用一条命令把 lag 拆成四段并读出异常3.10
防重复副作用能写出 op_id + 同事务去重 + 日志回放防护3.12
复述真实案例能说出 43 秒分区、切换、24h11m 降级与教训3.11
即时自测 · 3

Q1 三副本全同步复制,单副本 p99 是 20 ms。为什么端到端写 p99 可能远大于 20 ms?给出一个估算式。

Q2 N=3、W=2、R=2,满足 R+W>N。请给出两个不同的原因,说明为什么仍可能读到旧值。

Q3 用 updated_at 做 LWW,两台机器的时钟相差 2 秒。举一个「后发生的写被丢掉」的例子,并说明它为什么不会报错。

Q4 主库异步复制 lag 5 秒,主库故障后提升从库。RPO 大约是多少?要把 RPO 降到 0,需要改哪三件事?

Q5 新主重放旧主的 UPDATE balance = balance - 100,为什么会重复扣款?给出两个不同层面的修复。

答案

A1 全同步要等最慢副本,写延迟是多个副本延迟的最大值,不是单副本分布。若每个副本独立地有 1% 概率超过 20 ms,三个副本至少一个超时的概率约 1 - 0.99^3 ≈ 2.97%,尾延迟被放大;跨地域时还要加上最远副本的 RTT。

A2 例如:读路径只取第一个响应,没有比较版本(交集里的最新值被忽略);或者写 ack 早于 fsync,副本回 ack 后崩溃丢写(交集里的副本没有持久化)。另外,sloppy quorum 会让写落到 hint 节点、读 home set 不相交;读到未完成的写也会让值后来消失。

A3 A 的时钟快 2 秒,实际 10:00:00 写 x=1,记录 10:00:02;B 的时钟慢,实际 10:00:01 写 x=2,记录 09:59:59。LWW 选 x=1,B 的更新被丢弃。系统不会报错,因为两边都认为自己在做合法的「取最大时间戳」,冲突在值层面被静默解决。

A4 RPO 约等于 5 秒的写入量(例如 2000 写/秒就是约 1 万条)。要把 RPO 降到 0:ack 必须覆盖副本持久化(半同步/quorum + fsync);提升只能选拥有全部已提交日志的副本;旧主必须被 fence,且同步副本集合跨故障域。

A5 增量更新不是幂等的,回放一次就再减一次;如果新主没有 op_id 去重记录,客户端重试也会再扣一次。修复:业务命令携带 op_id,去重表与业务写同事务、同复制流;回放旧日志前 fence 旧主,按 op_id 集合对账,必要时以一侧为准全量重建,而不是盲目双向回放。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 5 章 运输层、第 6 章 应用层3.1、3.2、3.10背景只借用「跨节点传输有 RTT/排队」的视角;TCP 细节不重写
张彦飞《深入理解 Linux 网络》第 6–8 章(连接、排队、协议栈)3.1、3.2、3.10背景跨域延迟与连接行为只引用,不展开内核路径
《Linux 系统与内核》第 4 篇4.4–4.7(TCP/IP、socket、HTTP)3.1、3.10交叉引用明确不重复 TCP 状态机、拥塞控制、HTTP 语义
Dynamo 论文(2007)quorum、hinted handoff、版本向量3.6、3.7引用只引用机制事实,不复制论文原文;不把 Dynamo 说成线性一致
Raft 论文(2014)term、日志复制、选举限制3.3、3.4、3.9引用只借用 term/日志/提交概念;共识协议细节留给第 5 章
GitHub 2018-10-21 公开复盘分区、MySQL 切换、降级3.11完整只引用公开时间线与事实,机制分析为本书自编

下一站:知道了「副本之间如何同步、冲突如何解决、lag 从哪里来」,下一步要回答一个更精确的问题:一个读到底算不算「看到了最新写」?进入 4 一致性模型:线性/顺序/最终,把「旧值」「新值」「并发」写成可判定的历史,再用线性一致、顺序一致和最终一致去约束复制系统的读路径。

4 一致性模型:线性/顺序/最终

本章回答

一次读到底允许返回哪个版本?「强一致」能不能写成一个可判定的条件?线性一致、顺序一致、因果一致、最终一致分别在实时序、全序、收敛速度上做了什么让步?分区发生时 CAP 到底限制了哪一件事,为什么说「三选二」是误读?

读完你会

能用 invocation/response 记录一段 history,并手工判断它是否线性一致;能写出一个可运行的小型历史检查器;能区分副本一致性、事务隔离和 CAP 的三个维度;能用 etcdctl get --consistency=l/s 看到陈旧读;能解释 Jepsen 为什么在 majority 写之下仍然让 MongoDB 丢数据;能为一组业务选出可辩护的一致性方案。

第 3 章把同一份数据放到了多个副本上。副本解决了耐久性和读扩展,却留下一个新问题:当一次读到达某个副本时,它允许看到哪个版本? 第 3 章只回答了「写去哪里、怎么复制」,没有回答「读返回什么才算对」。这一章补上这个判定标准。

先看一个真实形状的事故。账户 A 的余额是 100,用户发起一笔 100 元的转账。主库在 09:41:02.140 提交,A 变成 0,响应在回程丢失。客户端在 800 ms 后超时,自动重试。重试请求落到一个尚未应用这笔写的只读副本,读到 A 还是 100,余额检查通过,于是又扣了一次。09:41:05,A 的余额是 -100,收款方收到 200。这里没有哪台机器宕机,也没有哪段代码「写错」:主库提交了,副本只是还没追上,重试只是「再试一次」。事故的根源是系统对「读到的 100 意味着什么」没有定义。

「强一致」「最终一致」这些词在日常讨论里几乎无法证伪:每个人都能找到自己满意的解释。本章换一种写法:先记录一段 history,再给出一个判定条件,最后用检查器跑一遍。一个模型只有能说清「哪段 history 违反它」,才值得写进设计文档。

路线是这样:4.1 建立 history、实时序和三种操作的词汇;4.2–4.5 依次给出线性一致、顺序一致、因果/会话保证、最终一致的定义、反例和代价;4.6 把 CAP 还原成 Gilbert–Lynch 的精确版本,并补上 PACELC;4.7 把副本一致性和事务隔离级别拆成两个维度;4.8–4.11 用分区陈旧读、etcd 观测、Jepsen 案例和二次转账把理论落到故障上;4.12 给出业务选型表,4.13–4.14 收束成陷阱清单与出口检查。读完这一章,你应该能指着一张时序图说:「这里违反的是线性一致,不是隔离级别。」

4.1 历史、实时序与三种操作

一致性模型不是对「数据库性格」的描述,而是对一段可观测历史的约束。先把历史写成表。下面是一段单寄存器 x 上的六个操作,时间单位是毫秒,inv 是客户端发出调用的时刻,resp 是客户端收到响应的时刻:

#进程操作invresp返回
1P1write(x, 1)010ok
2P2read(x)481
3P1write(x, 2)1216ok
4P2read(x)18222
5P3cas(x, 2, 3)2025true
6P3cas(x, 2, 4)2630false

这张表里已经藏着几个容易读错的点:

  • 操作 1 和操作 2 的时间区间重叠(4 < 10),它们是并发的。并发不等于「同时执行」,只表示一个操作返回之前另一个已经发起。
  • 操作 3 在操作 2 返回之后才发起(8 < 12),所以操作 2 在操作 3 之前。这是实时序,不是「谁的时间戳小」。
  • 操作 5 和操作 6 都来自 P3,且不重叠;操作 6 的 CAS 期望值是 2,但操作 5 已把 x 改成 3,所以返回 false 是合法的。
  • 操作 4 读到了 2,发生在操作 3 返回之后,符合直觉;如果它读到 1,这段历史就有问题了。

把同样的信息写成 JSON Lines,方便程序读取:

{"proc":"P1","op":"write","key":"x","value":1,"inv":0,"resp":10,"ret":"ok"}
{"proc":"P2","op":"read","key":"x","inv":4,"resp":8,"ret":1}
{"proc":"P1","op":"write","key":"x","value":2,"inv":12,"resp":16,"ret":"ok"}
{"proc":"P2","op":"read","key":"x","inv":18,"resp":22,"ret":2}
{"proc":"P3","op":"cas","key":"x","expected":2,"value":3,"inv":20,"resp":25,"ret":true}
{"proc":"P3","op":"cas","key":"x","expected":2,"value":4,"inv":26,"resp":30,"ret":false}

这六条记录对应三种最小操作。很多教材只讲读和写,但检查真实系统时 CAS 不可少:只用读/写建模,「读-改-写」会被拆成两个独立操作,丢失更新就看不见了;CAS 把「比较并替换」建模成一个原子操作,才能表达「只有版本仍是 7 才扣款」这类语义。

操作调用返回单寄存器顺序规范
read(k)键 k值 v 或 not_found返回此前最后一次写(或 CAS 成功)写入的值
write(k, v)键 k、新值 vok把 k 设为 v,总是成功
cas(k, e, n)键 k、期望值 e、新值 ntrue / false当前值等于 e 时设为 n 并返回 true;否则不改并返回 false
定义 4.1.1 · 历史与实时序

操作(operation)是一个五元组 (proc, kind, key, arg, ret),外加两个时刻:inv(invocation,客户端发起调用的时间)与 resp(response,客户端收到响应的时间),且 inv < resp。历史(history)是一组已完成操作及其 inv/resp 的集合。

实时序(real-time order)<rt 定义为:a <rt b 当且仅当 a.resp < b.inv。若两个操作的时间区间重叠(a.inv < b.resp 且 b.inv < a.resp),称它们并发(concurrent),实时序不规定二者的先后。

没有 resp 的操作称为待定操作(pending)。线性一致性是非阻塞的:待定操作可以被丢弃,也可以被补全为任意结果;它不能反过来「卡住」其他操作。

为什么一定要用客户端观测到的 inv/resp,而不是服务端日志里的提交时间?因为一致性模型约束的是客户端能观察到什么。服务端在 t=5 提交,客户端在 t=9 才收到响应;另一个客户端的读在 t=7 发起。若用服务端提交时间排序,会错误地认为写已经在读之前完成;实际上读的调用早于写的响应,二者并发,读返回旧值完全合法。反过来,如果服务端在 t=9 提交,客户端 t=10 收到响应,另一个读在 t=11 发起却返回旧值,这才是真的违反线性一致。提交时间在操作区间内部,响应时间才是区间右端。

记录历史时有四个坑:

  1. 不要用墙上时钟跨机器比较。time.time() 会被 NTP 调整,两台机器的偏差可能是毫秒,也可能是秒。用 time.monotonic_ns() 在单机记录;跨客户端时先测偏差,只有偏差远小于操作间隔才敢比较。
  2. 不要只记录服务端的开始/结束。服务端处理时间只是区间的一部分,无法替代客户端的 resp。
  3. 不要丢掉失败的 CAS。false 也是一次合法的返回;把它当「没发生」会掩盖顺序错误。
  4. 不要把 pending 操作当成功。超时后服务端可能已提交;检查器应允许把它补全或删除,而不是强行赋一个结果。

真实系统通常还会在响应里带一个版本号(revision、applied_index、LSN)。记录历史时一并保存,后面判断「读到的是不是旧版本」会容易得多:

09:41:02.140  write A=80   rev=58  resp=ok
09:41:02.221  read  A=100  rev=57  applied=57   <- 比写返回的 rev 旧

这两行不需要复杂的理论,已经能说明一次陈旧读。4.2 要做的,是把「rev 旧」升级成「整段历史是否可能存在一个合法全序」。

4.1.1 从单对象到多对象:先确定「被检查的对象」

4.2 节的检查器只处理一个 key。真实系统里还有多 key 事务、跨行约束和「先读 A 再读 B」的业务逻辑。把它们塞进单对象模型,会得到两种相反的错误。

第一种错误是把一个事务拆成若干个单 key 操作,分别检查线性一致,然后宣布「每个 key 都线性一致,所以系统一致」。4.7 节会给出反例:每个 key 的读都合法,拼起来却不是任何时刻的快照。事务的原子性不是「每个 key 各自原子」,而是「整组操作在同一时刻可见」。

第二种错误是把事务内部读到的中间值当成客户端可见的操作。事务在提交前读到的值,外部客户端看不到;把它放进 history 会增加不必要的实时序约束,甚至把正确实现误判为错误。

层次被检查的对象合适的模型例子
单 key一个寄存器线性一致/顺序一致/最终一致余额、配置项
多 key 原子操作一个事务/批量 CAS串行化、严格串行化转账、库存扣减
会话一个客户端的多次操作读己之写、单调读个人资料、购物车

所以动手判定前先问:这段 history 的操作粒度是什么? 如果业务不变量跨多个 key,单 key 检查器只能证明「每个 key 没退步」,不能证明「业务不变量没被破坏」。本章后面的检查器明确只做单 key;跨 key 的检查需要把事务当成一个复合操作,或者使用快照/串行化检查器(第 6 章)。

4.2 线性一致性:定义与判定

线性一致(linearizability,也叫原子一致、atomic consistency)的直觉是:每个操作看起来都在它的调用与响应之间某个瞬间原子地生效,整个系统像只有一个副本。 直觉很好用,但判断事故时不够精确。精确版本需要存在一个全序。

定义 4.2.1 · 线性一致

历史 H 是线性一致的,当且仅当存在一个对所有已完成操作的全序 S,满足:

  1. 扩展实时序:若 a <rt b,则 a 在 S 中排在 b 之前;
  2. 合法:从初始状态出发,按 S 依次执行每个操作,每个操作的返回值都符合该对象的顺序规范(对寄存器而言:读返回此前最后一次写的值;CAS 的返回值与实际状态一致)。

等价说法:每个操作都存在一个线性化点(linearization point),落在它的 [inv, resp] 区间内;所有操作按线性化点排序后,结果与顺序规范一致。

线性一致:读必须看到已返回的写 写 x=1 在 t1 返回;t2 之后开始的读若返回 0,则违反线性一致。 线性一致:读必须看到已返回的写 客户端 A 客户端 B write x=1 t1 写返回 read x ? t2 读开始 t2 > t1 ⇒ 读必须返回 1(或更新的值) 返回 0 = stale read = 违反线性一致 返回 timeout 只是 unknown,不构成反例
读图:线性一致的判定不是看「有没有复制」,而是看能否找到一个全序,既尊重每个操作的实时区间,又让读返回该全序中最近一次写的值。图中下半部分给出反例。

图里只画了一个写和一个读,但已经包含判定所需的全部信息:写 x=1 在 t1 返回,读在 t2 > t1 才开始,读必须返回 1(或更新的值)。读返回 0 就是陈旧读,违反线性一致;读超时只是 unknown,不构成反例,因为待定操作可以被丢弃。

并发允许两种结果。 如果读在写返回之前就发起了(区间重叠),读返回 0 或 1 都可能是线性一致的:把读的线性化点放在写之前,它看到 0;放在写之后,它看到 1。很多人把「线性一致」误读成「读必须立刻看到正在进行的写」,这是错的。线性一致只要求存在某一个合法的全序,不要求读偏向新值。

反例的最小结构是「写已返回,读才开始,却读到旧值」:

历史写区间读区间读返回判定
Awrite(x,1) [0,10]read(x) [12,20]1线性一致
Bwrite(x,1) [0,10]read(x) [11,20]0违反:读开始时写已返回
Cwrite(x,1) [0,10]read(x) [5,15]0线性一致:可把读排在写前
Dread(x) [0,5] 返回 2;read(x) [6,10] 返回 1,期间无写——违反:读出现回退

历史 D 是另一个常见形状:没有写,读却从 2 退到 1。线性一致要求所有操作共享一个全序,读只能沿着这个全序前进,不可能倒退。「单次读都合法」不等于「整段历史线性一致」,这是 4.8 节陈旧读的核心。

4.2.1 判定:枚举所有尊重实时序的全序

有限历史可以暴力判定。思路是深度优先搜索所有「线性扩展」:每一步从尚未放置、且其所有实时序前驱都已放置的操作中选一个,应用到一个寄存器状态上;如果操作是读,读到的值必须等于当前状态;如果是 CAS,返回值必须与当前状态一致。只要找到一条走完全部操作的路径,历史就是线性一致的。

下面是一个可运行的单寄存器检查器。它接受 Op 列表,返回 (是否线性一致, 见证全序或 None):

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class Op:
    proc: str
    kind: str          # "write" | "read" | "cas"
    key: str
    arg: object        # write: 新值;read: None;cas: (期望值, 新值)
    ret: object        # write: "ok";read: 读到的值;cas: True/False
    inv: int           # invocation 时间
    resp: int          # response 时间


def is_linearizable(history, initial=None):
    """单寄存器检查器:返回 (是否线性一致, 见证全序或 None)。"""
    ops = list(history)
    n = len(ops)
    # preds[j] 中的操作必须在 j 之前:a.resp < b.inv 即 a <rt b
    preds = [
        {i for i, a in enumerate(ops) if i != j and a.resp < b.inv}
        for j, b in enumerate(ops)
    ]
    placed = [False] * n
    order = []

    def apply(state, op):
        s = dict(state)
        if op.kind == "write":
            s[op.key] = op.arg
            return s, True
        if op.kind == "read":
            return s, s.get(op.key) == op.ret
        if op.kind == "cas":
            expected, new = op.arg
            if s.get(op.key) == expected:
                s[op.key] = new
                return s, op.ret is True
            return s, op.ret is False
        raise ValueError(op.kind)

    def dfs(state, done):
        if done == n:
            return True
        for i, op in enumerate(ops):
            if placed[i] or any(not placed[p] for p in preds[i]):
                continue
            nxt, ok = apply(state, op)
            if not ok:
                continue
            placed[i] = True
            order.append(i)
            if dfs(nxt, done + 1):
                return True
            order.pop()
            placed[i] = False
        return False

    ok = dfs(dict(initial or {}), 0)
    return ok, ([ops[i] for i in order] if ok else None)


def show(name, history):
    ok, order = is_linearizable(history, initial={"x": 0})
    print(f"{name:8s} linearizable={ok}")
    if ok:
        print("         witness: " + " -> ".join(
            f"{o.proc}.{o.kind}({o.ret})" for o in order))


H_OK = [
    Op("A", "write", "x", 1, "ok", 0, 10),
    Op("B", "read", "x", None, 1, 12, 20),
]
H_STALE = [
    Op("A", "write", "x", 1, "ok", 0, 10),
    Op("B", "read", "x", None, 0, 11, 20),
]
H_SC = [  # 顺序一致,但不线性一致;4.3 节解释
    Op("A", "write", "x", 1, "ok", 0, 5),
    Op("B", "write", "x", 2, "ok", 6, 10),
    Op("C", "read", "x", None, 1, 11, 15),
    Op("C", "read", "x", None, 2, 16, 20),
]
H_CAS = [
    Op("A", "write", "x", 1, "ok", 0, 5),
    Op("A", "cas", "x", (1, 2), True, 6, 10),
    Op("B", "read", "x", None, 2, 11, 15),
]

for name, h in [("H_OK", H_OK), ("H_STALE", H_STALE), ("H_SC", H_SC), ("H_CAS", H_CAS)]:
    show(name, h)

把它保存为 linearizability.py 并运行,输出是:

H_OK     linearizable=True
         witness: A.write(ok) -> B.read(1)
H_STALE  linearizable=False
H_SC     linearizable=False
H_CAS    linearizable=True
         witness: A.write(ok) -> A.cas(True) -> B.read(2)

四点读法:

  1. H_OK 的读在写返回之后开始,见证全序只有一种合法排列。
  2. H_STALE 的读返回 0,但写已完成;任何全序都必须把写放在读前,读应返回 1,所以搜索失败。
  3. H_SC 在 4.3 节会看到它顺序一致;检查器返回 False 只说明「不线性一致」,不说明「不一致」。判定器回答的是某个模型,不是笼统的『一致/不一致』。
  4. H_CAS 说明 CAS 成功会改变状态,后续读必须看到 2;CAS 不是「读一下再写」,而是一个原子操作。

4.2.2 复杂度与工程判定

暴力搜索最坏是 n!,而判定线性一致性在一般情况下是 NP 完全的(Wing 与 Gong, 1993)。真实历史动辄百万操作,不能直接套上面的代码。工程上有四类优化:

优化做法代价
按对象分片每个 key 独立检查;线性一致是局部性质跨对象事务另算
约束传播读的值只能来自某个写;先固定候选写,再查实时序实现复杂
记忆化对 (已放置集合, 状态) 去重;同一状态不重复搜索状态爆炸时仍慢
分窗把长历史切成小窗口,分别在每个窗口内检查可能漏掉跨窗反例

Jepsen 的 Knossos 用的是这类搜索加剪枝;它报告的是一个具体反例(哪几个操作无法排列),而不是一个概率。工程上更实用的做法是:先用 session 保证、版本号和业务不变量做粗筛,再把可疑窗口喂给检查器。检查器不是证明系统正确,它只能证明「这段有限历史存在/不存在合法全序」。没有反例不等于正确,只等于这段历史没暴露问题。

定理 4.2.1 · 线性一致的可组合性

若系统中每个对象的历史都线性一致,且对象之间没有跨对象的原子约束,则整个系统的历史线性一致。证明思路:把各对象的全序与实时序取并集,做拓扑排序;不同对象的操作可交换,冲突只可能在同一对象内,而同一对象已有合法全序(完整证明见 Herlihy 与 Wing, 1990)。

顺序一致没有这个性质:两个各自顺序一致的对象,合起来可能不满足顺序一致。这也是分片系统敢说「每分片线性一致」的理论依据,但它不保证跨分片快照一致(4.7 节)。

4.2.3 边界:线性一致不保证什么

线性一致只约束单对象的读/写/CAS 历史,不自动提供下面这些东西:

  • 跨对象的原子读。分别线性一致地读 A 和 B,合起来可能不是某个时刻的快照。
  • 事务的隔离。两个线性一致的写仍可能丢失更新,如果它们是「读-改-写」而不是 CAS。
  • 低延迟。线性一致读通常要一次 leader/quorum 往返;跨地域时这往返就是几十毫秒。
  • 分区期间的可用性。4.6 节会证明,分区时线性一致和可用性不能同时成立。
  • 会话保证的跨副本版本。线性一致对单对象已经蕴含读己之写和单调读(同一客户端的操作在实时序上不重叠);但如果你用的是「每个副本各自线性一致」,把多个副本的读拼起来,会话仍可能回退(4.8 节)。

4.2.4 一次搜索的回溯记录

把 H_STALE 的搜索过程写出来,比看代码更容易理解「为什么不存在全序」:

步骤可选操作当前状态说明
1write(x,1)x=1读的实时序前驱只有写,必须先放写
2read(x)=0x=1读要求当前值为 0,失败;无其他候选
———回溯到步骤 1,仍无其他候选,返回 False

H_SC 的回溯更能说明「实时序」和「程序序」的差别:

步骤可选操作当前状态说明
1write(x,1)x=1B 的写有实时序前驱 A 的写
2write(x,2)x=2C 的第一次读在 B 写返回之后,B 必须先放
3read(x)=1x=2读要求 1,失败;没有别的候选
———回溯,穷尽后返回 False

如果只保留程序序、去掉实时序,H_SC 就能找到 4.3 节给出的见证全序。检查器的差别不在搜索算法,而在 preds 里放了哪些边。 把 a.resp < b.inv 换成「同一进程的前一个操作」,得到的就是顺序一致检查器;这正是 4.3 节的形式差别。

成功时返回的 witness 是一个证书:它给出每个操作在线性化顺序中的位置。失败时返回 False 只对这段有限历史成立;换一段更长的历史、加上没记录到的 pending 操作,结论可能改变。判定器的输出要连同 history 一起存档,否则无法复核。

4.3 顺序一致性:差在哪

顺序一致(sequential consistency,SC)是 Lamport 1979 年提出的模型。它保留了「所有操作存在一个全序」,但删掉了「全序必须尊重实时序」这一条。

定义 4.3.1 · 顺序一致

历史 H 是顺序一致的,当且仅当存在一个对所有已完成操作的全序 S,满足:

  1. 尊重程序序:若在同一进程中 a 先于 b 被调用,则 a 在 S 中排在 b 之前;
  2. 合法:按 S 执行时,每个操作的返回值符合顺序规范。

注意:S 不需要尊重跨进程的实时序。一个在实时上早已返回的写,可以被排在一个后来才发起的读之后。

最小的「顺序一致但不线性一致」历史就是上面的 H_SC:

进程操作区间返回
Awrite(x,1)[0,5]ok
Bwrite(x,2)[6,10]ok
Cread(x)[11,15]1
Cread(x)[16,20]2

C 的两次读有明确的程序序:先读到 1,再读到 2。顺序一致的见证全序是:

write(x,1) -> read(x)=1 -> write(x,2) -> read(x)=2

这个全序尊重每个进程的程序序(A 只有写 1,B 只有写 2,C 的两次读顺序不变),也满足寄存器语义:第一次读之前最后写的是 1,第二次读之前最后写的是 2。它唯一的问题是:B 的写 write(x,2) 在 [6,10] 已经返回,而 C 的第一次读在 11 才开始,实时序要求写 2 排在读 1 之前;一旦如此,读 1 就不可能返回 1。 所以这段历史顺序一致,但不线性一致。

实时轴 0     5  6    10 11   15 16   20
A      [W=1]
B              [W=2]
C                        [R=1]  [R=2]
                          ↑
                  W=2 已返回,R=1 才开始;
                  SC 可以把 W=2 排到 R=1 之后,
                  线性一致不允许。

两者的差别可以压成一张表:

性质线性一致顺序一致
全序需要需要
尊重实时序需要不需要
尊重进程程序序单线程客户端下由实时序蕴含明确要求
可组合性是(按对象)否
直观承诺读能看到「已返回」的写所有客户端同意的写顺序,但可能滞后于现实
典型实现leader/quorum 读、ReadIndex、共识单点序列化、全序广播、部分主从读
分区时少数派不能提供线性一致读/写可能返回陈旧读;单个副本的局部历史可以是 SC,但全局 SC 仍需额外协调

两个常见误解需要当场拆掉。

误解一:顺序一致比线性一致「便宜很多」。 顺序一致仍然要求一个全局全序,通常仍要单点序列化或共识;它省掉的是「等待实时确认」,不是「协调所有操作」。一个系统如果连全局日志都没有,往往连顺序一致也给不了,只能给因果或最终一致。

误解二:顺序一致就是「串行化」。 串行化(serializability)是事务层面的性质:并发事务的结果等价于某个串行执行顺序;顺序一致是单对象读写的可见性顺序。两者正交,4.7 节会展开。把「sequential consistency」翻成「顺序一致性」时,尤其容易和数据库的「串行隔离」混在一起。

4.3.1 顺序一致也不是免费的

一个常见的实现是「单点序列器 + 全序广播」:所有写提交给序列器,序列器分配一个递增序号并广播;每个副本按序号应用。读可以在本地按已应用的前缀回答,不必等序列器确认。这样得到的全局顺序尊重每个客户端的程序序(客户端按序号提交),但读可能被排在一个已经返回的写之前,所以是 SC,不一定是线性一致。为了让同一客户端的读不退步,读还要固定副本或带上「已见前缀」令牌;否则第二个读打到更落后的副本,程序序就被破坏了。

实现全序来源读路径得到什么代价
单点序列器 + 全序广播序列器分配序号本地前缀顺序一致序列器是瓶颈/单点
主库串行日志主库日志顺序本地已应用顺序一致(若读不确认)主库故障切换复杂
每写都等确认主库/quorumleader 确认线性一致每次读一次往返
无全序,仅反熵无任意副本最终一致冲突合并

注意第三行:「顺序一致」和「线性一致」的差别往往就是读要不要等确认。 等确认就把读放进了实时序,不等就只保证「存在某个全序」。这也解释了为什么很多系统把「强读」做成一个显式选项:默认本地读是 SC/陈旧读,关键读才付一次往返。

顺序一致的另一个代价是全序本身。没有全局日志、没有序列器、没有共识,就没有 SC;多个副本各自按本地顺序应用,得到的是因果或最终一致。SC 不是「不协调的强一致」,它只是「不要求实时确认的强顺序」。

4.4 因果一致性与会话保证

线性一致和顺序一致都要求全序,代价是协调。很多系统退一步:只要求有因果关系的操作被所有副本以相同顺序看到,并发操作可以各排各的。 这就是因果一致(causal consistency)。

先看它要防的那个经典反例。论坛里,Alice 发帖 P,Bob 读到 P 后回复 R,Carol 读到了 R 却读不到 P:

步骤进程操作观察到
1Alicewrite(post, P)ok
2Bobread(post)P
3Bobwrite(reply, R)ok
4Carolread(reply)R
5Carolread(post)not_found

Carol 看到了回复却看不到被回复的帖子,这是「因果倒置」。因果一致禁止的正是这种形状;它不禁止 Carol 在 Bob 回复之前看不到 P(那时 P 和 R 并发),也不要求 Carol 看到 P 的那一刻 Bob 的回复已经存在。

定义 4.4.1 · happens-before 与因果一致

happens-before(→)是满足以下规则的最小关系:

  1. 程序序:同一进程中先发起的操作 → 后发起的操作;
  2. 读到即因果:若读 r 读到了写 w 的值,则 w → r;
  3. 传递:若 a → b 且 b → c,则 a → c。

两个操作若既不 a → b 也不 b → a,称并发。

历史是因果一致的,当且仅当每个进程观察到的写集合是因果闭的(看到 w2 就包含所有 w1 → w2 的 w1),且其观察顺序是因果序(→ 的传递闭包)的一个线性扩展;不同进程可以对并发写选择不同的扩展。等价地:若 w1 → w2,任何进程看到 w2 时,w1 已经在它的视图中排在 w2 之前。

因果一致比线性一致弱在哪?它不要求实时序,也不要求全局唯一顺序。Alice 发帖后,Carol 可以过一会儿才看到;Bob 和 Dave 对两条并发评论的先后可以不同。它比最终一致强在哪?它禁止「回复先于原帖」这种不可解释的顺序,而最终一致允许副本在收敛前任意乱序。

因果一致在工程上常和「会话保证」一起出现。因果一致是跨客户端的性质,实现成本不低(要传播依赖关系,例如向量时钟);会话保证是单客户端的性质,实现便宜得多,但只保护这个客户端自己。复制 lag 的成因和读己之写的工程做法在 第 3 章 已经出现过;本节换一个角度,把会话保证当成一组可判定的承诺来检查。

会话保证承诺不满足时的典型事故常见实现
读己之写(read-your-writes)本客户端写返回后,后续读能看到该写或更新值用户改完资料,刷新还是旧头像写后读主库;会话 token 带版本;粘性会话
单调读(monotonic reads)同一客户端连续读不会回退到更旧版本余额先显示 100,再显示 80,再显示 100会话固定副本;记录最大 revision,只接受 ≥ 它的副本
单调写(monotonic writes)同一客户端的写按程序序生效先改名后改头像,副本先应用头像再应用改名单写者队列;按客户端序号去重排序
写后读(writes-follow-reads)读到 v 之后的写,排在生产 v 的写之后回复排到了原帖之前读时带因果依赖;写时携带依赖版本

一个最小实现是给会话发一个 token,里面放「本会话最后一次写产生的 applied index」;读副本只有追到这个 index 才允许返回:

import time


def read_your_writes(replicas, key, token, timeout_ms=200):
    """token 是写返回时拿到的 applied_index。"""
    deadline = time.monotonic() + timeout_ms / 1000
    while time.monotonic() < deadline:
        for replica in replicas:
            value, applied = replica.get_with_version(key)
            if applied >= token:          # 该副本已经包含我的写
                return value
        time.sleep(0.005)
    raise TimeoutError("no replica caught up; fall back to leader")

这段代码的关键不是循环,而是 applied >= token 这个条件。没有它,replicas 列表里的任何一个副本都可能返回旧值;有了它,最坏情况是等待或回退到主库,而不是悄悄破坏读己之写。get_with_version 需要副本暴露「已应用的日志位置」,这也是为什么第 3 章强调复制日志索引:没有版本,就没有会话保证的锚点。

4.4.1 向量时钟:把因果依赖带在请求上

会话 token 是一个标量,只能表达「我这个客户端见过哪些写」。跨客户端的因果需要更完整的依赖:Bob 的回复依赖 Alice 的帖子,Carol 的副本必须先应用 Alice 的写,才能应用 Bob 的回复。向量时钟(vector clock)就是做这件事的。

每个副本维护一个 {node: counter};本地事件递增自己的计数器;发送消息时带上整个向量;接收方逐项取最大值。比较规则:

关系条件含义
V1 <= V2每个分量都 ≤V1 不晚于 V2;相等表示同一因果状态
V1 < V2V1 <= V2 且至少一个分量更小V1 严格早于 V2
并发两个方向都不 ≤需要冲突解决

一个最小判断函数:

def happens_before(a, b):
    keys = set(a) | set(b)
    return all(a.get(k, 0) <= b.get(k, 0) for k in keys) and a != b

Bob 读完 Alice 的帖子后,写回复时携带 {A:1, B:1};Carol 的副本如果只有 {B:1},就不能应用这条回复,要先等 A:1 到达。这就是「因果交付」(causal delivery)。代价是向量随写入者数量增长;工程上用依赖集合、版本向量压缩或混合逻辑时钟来控制大小。

向量时钟只解决因果顺序,不解决实时序:Alice 的帖子可能很久以后才到 Carol,只要顺序对,因果一致就满足。要实时,仍然需要线性一致读/写。

因果一致和会话保证都不能免费升级成线性一致:

  • 读己之写只保护「自己写的」,别人写的最新值仍可能看不到;
  • 单调读只保证不回退,不保证看到最新;
  • 因果一致不保证实时,Alice 的帖子可能几秒后才对 Carol 可见;
  • 所有这些都不提供跨对象的原子快照。

它们适合什么?社交动态、评论、购物车、用户资料这类「顺序错了很怪,但晚一点没关系」的场景。余额、库存、配置开关这类「错了要赔钱」的场景,仍然要更强的模型或原子写。

4.5 最终一致性:收敛的条件

最终一致(eventual consistency)是最容易被口号化的模型。它的正式承诺其实很短:如果不再有新的写,所有副本最终会返回同一个值。 注意三个词:最终(没有时间上界)、所有(每个副本)、同一个(收敛,不是「都最新」)。

定义 4.5.1 · 最终一致

对每个对象 k,若在某个时刻之后再没有对 k 的写,则存在一个时刻 T,使得对所有副本 r 和所有 t > T,r 上对 k 的读都返回同一个值。这个定义只要求活性(最终发生),不要求顺序、实时性,也不给出 T 的上界。

这个定义单独看几乎不约束实现:一个永远不复制、但也不接受读的系统也「最终一致」。工程上说的最终一致,通常隐含了下面六个条件;缺一个,收敛就可能不成立,或者只在 PPT 上成立。冲突解决的具体机制(LWW、版本向量、CRDT)在 第 3 章 已经展开;这里把它们当成「收敛条件」逐条检查。

#收敛条件不满足时会怎样工程手段
1写会停止(quiescence)持续写入时没有「最终」可言;每个对象只能按最后一次写算按对象界定收敛窗;不要用全局「系统已收敛」
2每个更新最终到达每个副本副本永久落后;节点下线后数据没有重新复制反熵(Merkle 树)、hinted handoff、读修复、全量同步
3冲突解决是确定性的,且不依赖到达顺序两个副本对同一组写得出不同结果,永久分叉LWW + 可信时钟;版本向量 + 应用合并;CRDT
4删除不会被「复活」tombstone 先被 GC,分区恢复后旧值被读修复带回来tombstone 保留时间 > 最大分区/修复时间
5读路径不会永久指向陈旧副本副本已收敛,但缓存/连接池/死副本仍在返回旧值TTL 上界;健康检查;版本校验
6更新不会因保留期/磁盘故障被静默丢弃hinted handoff 过期、队列截断、副本重建丢数据保留期 > 最大故障窗口;校验和;定期修复

把这六条压成一句话:最终一致 = 停止写 + 送到 + 确定合并 + 不复活删除 + 读路径可达 + 不丢更新。 其中任何一条都需要工程实现,不是选一个数据库就自动获得的。

「最终」到底有多最终?没有上界。一个每小时跑一次反熵的系统,收敛时间可能接近一小时;hinted handoff 保留三天,节点宕机三天内回来还能补,超过三天就可能丢。收敛时间不是 SLA,除非你用机制给它加上界,例如「读修复在 200 ms 内完成」「反熵每 30 秒一轮」「副本 lag 超过 5 秒就摘除读流量」。把这些机制写进 SLO,才谈得上「最终一致的延迟」。

一个具体的时钟陷阱:LWW(last-write-wins)用时间戳决定胜者,但时间戳来自各节点的墙上时钟。

写节点时钟偏差真实发生时间记录的时间戳LWW 胜者
W1 x=1+5 s10:00:0010:00:05W1
W2 x=2010:00:0210:00:02—

W2 在真实时间上更晚,却因为 W1 的节点时钟快而输掉。最终两个副本都收敛到 1,收敛了,但收敛到了错误的值。这就是为什么条件 3 要求「确定性」还不够,还要说明时间戳的语义:LWW 的「最后」是「时间戳最大」,不是「真实最后」。如果业务不能接受这种错序,就需要版本向量/因果序,或者把冲突交给应用合并。

CRDT 走的是另一条路:把状态设计成可交换、可结合、可幂等的合并结构,这样到达顺序不影响结果。一个 Grow-only Counter 的最小实现:

class GCounter:
    def __init__(self, node):
        self.node = node
        self.counts = {}

    def inc(self, n=1):
        self.counts[self.node] = self.counts.get(self.node, 0) + n

    def merge(self, other):
        for node, value in other.counts.items():
            self.counts[node] = max(self.counts.get(node, 0), value)

    def value(self):
        return sum(self.counts.values())


a, b = GCounter("a"), GCounter("b")
a.inc(2)
b.inc(3)
a.merge(b)
b.merge(a)
print(a.value(), b.value())   # 5 5

merge 对每个节点取最大值,满足交换律、结合律、幂等律;无论 a.merge(b) 和 b.merge(a) 谁先执行,结果都是 5。这就是「强最终一致」(strong eventual consistency):所有看到同一组更新的副本,不需要协调就处于相同状态。代价是数据模型受限:计数器可以这样合并,但「把余额设为 100」这种覆盖写不能;它需要 LWW 或应用层冲突解决,也就重新引入了时钟/顺序问题。

4.5.1 反熵、读修复与删除:收敛的实际时间线

「最终」不是一个瞬间,而是一条由多种机制拼起来的时间线。假设 key x 在副本 A 上从 0 改为 1:

时间事件AB读到的值
t0客户端写 A,A 返回成功10—
t1A 在复制给 B 之前宕机10从 B 读 → 0(陈旧)
t2客户端读 B,触发读修复10读修复无法从 B 修 A(B 没有新值)
t3A 恢复,反熵交换 Merkle 树11从 B 读 → 1(收敛)

读修复只在「读到的副本比另一个副本新」时有用;如果读打到落后副本,它没有新值可修。真正兜底的是反熵或 hinted handoff:

机制触发典型延迟失败模式
读修复读时发现版本差异一次读的延迟读不到就不修
hinted handoff写时目标副本不可达目标恢复即投递TTL 过期后丢
反熵(Merkle)周期任务分钟–小时带宽/周期决定上界
全量同步节点重建/落后太多小时–天期间读仍陈旧

删除比写入更危险。删除通常写一个 tombstone;如果 tombstone 在分区恢复前被 GC,落后副本会把旧值当成「缺失的新值」读修复回来,出现「删除复活」。所以 tombstone 的保留时间必须大于最大可能分区时长 + 反熵周期,而不是「几天应该够了」。

收敛时间的粗糙上界可以写成:

T_converge <= max(反熵周期, hinted handoff 投递时间, 读修复触发间隔) + 传播/应用时间

这个式子里的每一项都必须有监控;没有这些项,「最终一致」只是一个没有上界的承诺。4.12 节会把「有界陈旧」作为选型的一档,而不是把「最终」当成一个可承诺的延迟。

4.6 CAP 的精确版本与 PACELC

CAP 被引用得最多,也被误读得最多。先把三个字母还原成定义。2000 年 Brewer 提出猜想,2002 年 Gilbert 与 Lynch 给出证明;他们证明的「C」不是「所有副本一样」,而是线性一致(原子对象)。

定义 4.6.1 · CAP 的三个字母
  • C(Consistency):对象满足线性一致,即 4.2 节的定义。
  • A(Availability):每个到达未故障节点的请求,都必须在有限时间内返回一个非错误响应。
  • P(Partition tolerance):网络允许在节点之间丢弃任意多的消息,把它们分成互相不可达的组。

CAP 定理:一个分布式数据对象不可能同时满足 C、A、P。

形式证明里的 A 只要求请求终止(返回某种响应);工程上通常收紧为「返回非错误响应」,因为 503 等于拒绝服务。无论采用哪个版本,返回 503 都不是「可用」。

证明只需要一个两节点反例。节点 N1、N2 组成系统,初始 x=0,网络把它们分区。客户端向 N1 写 x=1,N1 返回成功(否则不满足 A)。接着客户端向 N2 读 x:

  • 若 N2 返回 1,它必须在分区期间知道 N1 的写,但消息被丢弃,矛盾;
  • 若 N2 返回 0,读不是线性一致的(写在读之前已返回),违反 C;
  • 若 N2 等待到超时,违反 A;若 N2 返回错误,工程语义下也算不可用。

三者不可能同时成立。注意这里没有假设「N2 一定知道 N1 写过」;恰恰因为分区,它不可能知道。

这个证明经常被转述成「CAP 三选二」,但精确的含义要窄得多:

常见说法精确说法
「CAP 三选二,我们选 CA」P 是网络属性,不是产品开关。所谓 CA 只是在「假设永不分区的部署」里成立;一旦分区,仍要选 C 或 A。
「AP 系统不保证一致性」AP 只表示分区时优先可用;无分区时仍可提供线性一致,或至少提供读己之写、单调读。
「CP 系统不可用」CP 只在少数派/失去 quorum 时拒绝写或线性一致读;多数派一侧仍可服务。
「A 就是 200 OK」A 要求非错误响应;返回 503 不是 available。等待到超时也不是。
「CAP 说的是延迟」CAP 只谈分区时的 C/A;延迟是 PACELC 的「Else」分支。
「这个数据库是 CP/AP」一致性是按操作选的:同一系统可以写用 quorum、读走本地副本、关键读走 leader。

所以更有用的问法不是「这个数据库是 CP 还是 AP」,而是:在这次请求、这个对象、这个故障场景下,我放弃的是线性一致还是可用性?放弃的边界在哪里?

PACELC 补上了 CAP 没说的另一半。Abadi 在 2012 年把它写成:If Partition, choose A or C; Else, choose Latency or Consistency. 没有分区时,强一致仍然要付协调/等待的延迟;这个代价天天都在,不是故障时才出现。

系统/配置分区时(P)无分区时(E)说明
Spanner(外部一致性)CC(付 TrueTime 等待)跨地域提交要等不确定性窗口
etcd(默认线性一致读/写)CC(一次 ReadIndex/leader 往返)少数派拒绝服务
etcd(--consistency=s)A(可能陈旧)L(本地读,不协调)同一集群,按请求切换
DynamoDB(最终一致读)AL强一致读可另行指定,按请求付延迟
Cassandra(QUORUM 读/写)A/C 可调(多数派才可用)Lquorum 提高交集,不自动等于线性一致
MongoDB(v1 + majority + linearizable)CC(主库读)3.4.0 之前 v1 有 bug,见 4.10

「Else, Latency or Consistency」不是空话,可以用数字感受:同可用区一次 RTT 约 0.3–1 ms,跨地域 30–150 ms;线性一致读通常要多一次 leader/quorum 确认,quorum 写要多一次多数派落盘。这些延迟在系统完全健康时也会发生,而 CAP 只在分区时说话。选型时如果只问 CAP,就会漏掉 99.9% 时间里的性能代价。

4.6.1 分区不是一个二值事件

CAP 证明里的 P 是「任意消息丢失」,现实中的分区更复杂:可能单向、可能只影响部分链路、可能持续几百毫秒,也可能时断时续。系统通常用超时/心跳/quorum 来猜测分区,猜测本身会出错:

现象系统以为实际后果
心跳超时但网络正常分区/节点故障对端 GC 停顿/过载误切主,增加抖动
单向丢包对端还活着我发得出去,收不到回复半双工状态,选举/复制异常
短时抖动分区网络瞬断频繁重配置,放大故障
分区但客户端仍能访问两侧只有一个 leader两个 leader 各自服务双写/数据分叉(第 5 章)

所以「分区时选 C 还是 A」在工程上还要加一问:我能不能及时、准确地检测到分区? 检测太敏感会把正常抖动当分区,检测太迟钝会让旧 leader 继续服务。lease(租约)用时间换检测速度,fencing(第 9 章)用单调 token 把旧持有者挡在资源外;两者都不是 CAP 定理本身,而是把 CAP 的选择落到实现上。

最后一个常见错误:把 CAP 的 C 当成「所有副本数据相同」。副本相同是收敛(4.5 节),线性一致是读的可见性顺序。一个系统可以所有副本最终完全相同,却在收敛前返回陈旧读;也可以每次读都线性一致,却在分区时拒绝服务。两者不是同一个坐标轴。

4.7 一致性不是隔离级别

「一致性」这个词在分布式系统和数据库里指两件不同的事,混用会导致非常具体的错误。本节把两个维度拆开:

  • 副本一致性(replica consistency):同一个对象有多个副本时,一次读允许看到哪个版本?关键词是线性一致、顺序一致、因果、最终。
  • 事务隔离(transaction isolation):多个事务并发访问多个对象时,允许出现哪些交错?关键词是读未提交、读已提交、可重复读、快照隔离、串行化。

它们回答的问题不同:

维度副本一致性事务隔离
对象单个对象的多个副本多个对象、多个事务
核心问题读能否看到已返回的写并发事务能否看到彼此的中间状态
典型保证线性一致、顺序一致、因果、最终RU、RC、RR、SI、Serializable
典型反例读到旧余额脏读、丢失更新、写偏斜
实现手段quorum、ReadIndex、leader 读、会话 token锁、MVCC、SSI、串行化
跨地域代价每次强读一次往返冲突检测/锁等待/回滚

线性一致不等于串行化。 一个每键都线性一致的 KV 存储,不提供跨键事务:转账时先读 A、再读 B,两个读各自线性一致,合起来仍可能不是同一时刻的快照。反过来,一个串行化的数据库如果允许从只读副本读,返回的仍可能是旧版本;串行化约束的是事务之间的等价性,不是副本的实时可见性。

一个具体的「不一致快照」:初始 A=100,B=100。事务 T1 把 50 从 A 转到 B,提交后 A=50、B=150。客户端在 T1 提交前线性一致地读 A 得到 100,在 T1 提交后线性一致地读 B 得到 150。两次读都合法,但把它们拼起来得到 A+B=250,而系统在任何时刻的总和都是 200。每一把钥匙都线性一致,不等于跨钥匙的读是一个快照。 要解决它,需要快照读/多对象事务(第 6 章),而不是把单键一致性调得更强。

事务隔离级别的常见异常可以用一张表对照(细节在第 6 章):

隔离级别脏读不可重复读幻读丢失更新写偏斜
读未提交(RU)可能可能可能可能可能
读已提交(RC)阻止可能可能可能可能
可重复读(RR)阻止阻止视实现视实现可能
快照隔离(SI)阻止阻止阻止阻止可能
串行化(Serializable)阻止阻止阻止阻止阻止

这张表里最容易被忽略的是快照隔离下的写偏斜:两个事务各读一份「对方没动」的快照,各自写不同的行,合起来破坏不变量。它是隔离级别问题,不是副本问题;把读切到主库、把一致性调成线性,都不会修复它。反过来,4.8 节的陈旧读是副本问题,把隔离级别从 RC 调到 Serializable 也不会修复它,因为旧值本身是已提交的合法数据。

4.7.1 两个维度同时出错:一个组合反例

把两个维度放在同一个事故里,能看清它们各自负责什么。初始 A=100、B=100:

步骤操作副本一致性事务隔离结果
1T1 在 primary 上把 50 从 A 转到 B(Serializable)强强提交后 A=50、B=150
2客户端从 lag 副本读 A弱(陈旧)不涉及读到 100
3客户端从 primary 读 B强不涉及读到 150
4客户端用 A+B=250 做预算弱不涉及基于不存在过的快照决策

第 1 步的事务是串行化的,第 3 步读 B 也可能是线性一致的;但第 2 步读 A 已经是陈旧读,组合起来更不是任何时刻的快照。要修第 4 步,需要一致的快照读(同一 revision 读多个 key),而不是把事务隔离级别再调高。

反过来也成立:一个系统每 key 都线性一致,但两个并发事务各自读 A、读 B,然后写对方读过的行,仍可能产生写偏斜。副本一致性管「读到的版本」,事务隔离管「并发事务的交错」;一个维度的加强不能替代另一个。

两个维度可以组合,现实系统通常只各取一档:

系统/用法副本一致性事务隔离说明
etcd 单次 txn线性一致单次原子事务(非交互)多键比较/写入是一个日志条目,但不是交互式事务
PostgreSQL 主库读主库单副本RC/RR/Serializable 可选强隔离不等于跨副本强一致
PostgreSQL 异步只读副本最终一致(异步复制)只读查询可能读到旧事务的结果
Spanner线性一致(外部一致)严格串行化TrueTime + 2PC;代价是提交等待
DynamoDB 最终一致读最终一致单条条件写条件写提供单条原子性;跨项事务要显式使用事务 API

最后一个术语:严格串行化(strict serializability)= 串行化 + 实时序。它要求事务的串行顺序尊重实时序,Spanner 的「外部一致性」就是它。线性一致是单对象的严格串行化;串行化是多对象的等价性。把这三个词排成一条线:

单对象:最终一致 < 因果一致 < 顺序一致 < 线性一致
多对象:串行化 < 严格串行化
严格串行化 = 线性一致(实时序)+ 串行化(多对象原子性)

这两条线不是同一个维度(因果和顺序的关系、会话保证的位置都有细节),但足以防止最常见的偷换概念:「我们用了 Serializable,所以读一定是最新的」是错的;「我们用了线性一致,所以转账不会丢更新」也是错的。

4.8 失败模式:分区期间的陈旧读

4.7 节把副本一致性和隔离级别拆开了。现在把镜头对准最典型的副本一致性故障:网络分区期间,客户端从落后副本读到了已提交但过期的值,并把这个值当成决策依据。 它和超时、重试、重复副作用是同一条链上的环节,只是这次「错误的数据」而不是「重复的动作」先出场。

把开头那个账户事故展开成时间线。集群三副本:leader L、follower F1、follower F2。F2 仍与 L 连通,F1 与 L 之间的复制链路在 09:41:02.130 被切断:

t(ms)  客户端                 leader L                 follower F1
  0    TX t-1  ----------->  A=100 B=0    rev=57      A=100  rev=57
 10                          ── F1 与 L 的复制链路断开(分区) ──
 20                          A=0   B=100  rev=58      A=100  rev=57
 21                          ack 在回程丢失            (收不到 rev=58)
800    timeout(800ms 预算)
804    重试 t-2 ----------->  (读请求被负载均衡到 F1)
805                          A=100  rev=57  <-------- 陈旧读:guard 通过
820    TX t-2  ----------->  A=-100 B=200 rev=59

这里有三份「真相」,每一份在自己的视角里都合理:

角色它知道的它不知道的它做了什么
客户端t-1 在 800 ms 超时t-1 已在 20 ms 提交重试,并重新执行余额检查
leader Lt-1 已提交,rev=58响应没有到达客户端继续接受新请求
follower F1本地状态停在 rev=57rev=58 已经存在用旧值回答读请求

这就是第 1 章说的「部分失败」在数据面的版本:每个节点都只掌握了事实的一部分,但每个节点都在基于自己那部分事实做决定。 分区不会「制造旧数据」,它只是让旧数据的存在时间从毫秒级变成无界;真正造成事故的是客户端把「读到的值」当成了「当前的值」。

定义 4.8.1 · 陈旧读

陈旧读(stale read)是一次读返回了某个已经提交、但已经被更新的写覆盖的值。它与脏读不同:脏读读到的是未提交的数据;陈旧读读到的数据在某个时刻是合法的,只是不是最新版本。

形式化地说,若写 w 在 t_w 返回,读 r 在 t_r > t_w 发起(t_r 晚于 w 的响应),而 r 返回的版本早于 w 写入的版本,则 r 是陈旧读。这个定义默认 w 与 r 访问同一对象;跨对象的「不一致快照」是 4.7 节的问题,不是单对象陈旧读。

分区期间陈旧读的放大路径有五条,按出现频率排序:

#路径机制为什么分区时更严重
1异步复制 lagfollower 的 apply 落后于 leader 的 commit分区让 lag 从毫秒变成秒/分钟,且没有上界
2读流量打到 follower负载均衡/只读副本/就近接入客户端不知道哪个副本落后
3读重试换副本第一次读超时,重试落到另一个 lag 更大的副本连续读可能回退,破坏单调读
4缓存/连接固定缓存 TTL 未到、连接池粘在旧副本即使副本已追上,读路径仍返回旧值
5旧 leader 仍服务分区后旧 leader 未及时自停,继续接受读需要 lease/fencing,第 5、9 章展开

第 3 条值得单独说,因为它经常被当成「读重试无害」。假设客户端连续两次读同一个 key:

read #1 -> F1:A=100  rev=57
read #2 -> F2:A=80   rev=55   (F2 比 F1 更落后)
read #3 -> F1:A=100  rev=57

每一次读都可能是「该副本自己的日志上线性一致」的读,但三次读合起来不是任何一致模型:三次读之间没有新的写,值却从 100 退到 80 再回到 100。「每个副本各自线性一致」不等于「整个系统线性一致」。 线性一致性是对整段 history 的判定;把多个独立副本的局部 history 拼起来,实时序可能根本无法满足(4.2 节的定义里,读 #2 在 read #1 返回之后发起,必须看到 100 或更新值)。

业务后果不是抽象的「不一致」,而是可以计价的:

  • 余额检查通过,实际余额不足,账户透支 100 元;
  • 库存显示 1 件,三个分区各自卖出 1 件,超卖 2 件;
  • 优惠券在旧副本上显示「未使用」,同一张券被核销两次;
  • 权限撤销写到 leader,读副本仍返回旧 ACL,被撤销的用户多访问了几分钟;
  • 调度器基于旧状态做决策,把同一个任务派给两个 worker。

这些场景的共同点是:读不是终点,而是下一个写/副作用的输入。 如果读只用于展示,陈旧最多让人困惑;如果读用于 guard(余额够不够、库存有没有、锁是否空闲),陈旧读就会变成重复副作用。4.11 节会把这条链接到二次转账。

修复要分层,不能只喊「读主库」:

手段做法代价适用
线性一致读leader 读、ReadIndex、quorum 确认多一次往返;分区少数派不可用余额、库存、权限等决策读
有界陈旧读副本必须满足 applied_index >= 写回执的 index可能等待;需要暴露 index能接受几十毫秒延迟的读
会话 token写返回 token,读副本追到 token 才返回每会话状态;跨设备要同步读己之写、个人资料
单调读记录本会话见过的最大 revision,只接受 ≥ 它的副本固定副本;故障切换要挑副本连续刷新的页面
quorum 读R + W > N,读多个副本取最新读放大;仍不自动等于线性一致无主复制、Dynamo 风格
失败关闭无法确认新鲜度时返回错误,而不是旧值可用性下降支付、风控、权限
原子条件写不依赖读做 guard,把条件放进写需要 CAS/事务扣款、扣库存

4.8.1 读路由策略与代价

同一个「读余额」请求,路由策略不同,得到的新鲜度和可用性完全不同:

策略新鲜度延迟分区时实现复杂度
只读 leader线性一致(正确实现的 leader 读)一次到 leader 的 RTT少数派不可用低
follower + lag 上界有界陈旧(如 ≤ 50 ms)本地读落后超界则摘除中
quorum 读(R+W>N)可能读到最新,但不保证线性读多个副本需多数派中
会话固定副本单调读本地读副本故障要迁移中
本地优先 + 失败回退 leader取决于回退通常本地,偶尔 RTT回退可能超时中
任意副本最终一致最低高可用低

选择不是「哪个最强」,而是这个读能不能接受旧值、能接受多旧、分区时能不能失败。余额 guard 选第一行;商品详情选第二/第六行;购物车选第四行;配置读选第一行 + watch。把策略写进接口(例如 read(key, mode=...)),而不是藏在负载均衡器里,否则调用方无法判断自己拿到的是什么。

前面那张修复手段表的最后一行——原子条件写——是陈旧读的兜底,不是替代品。即使读到了旧值,只要写是「余额 ≥ 100 才扣」的原子操作,第二次扣款会被拒绝;但用户仍会看到错误的余额,体验和客服成本还在。读的一致性和写的原子性要一起设计。

观测上,至少要能回答「这次读来自哪个副本、它的 applied index 是多少、和写回执差多少」。如果响应里只有值,没有版本,事故复盘时只能猜。4.9 节用 etcd 演示如何把这两个数字读出来。

4.9 观测:etcd 的 linearizable vs serializable read

etcd 把「读的一致性」做成了每个请求的显式选项,非常适合观察。下面以 etcd 3.5 的 etcdctl 为例;etcdctl get 的 --consistency 有两个取值:

  • l(linearizable,默认):读必须经过 leader 确认(ReadIndex/心跳),保证返回的是集群已提交的最新值;
  • s(serializable):读由本地成员直接服务,可能落后于 leader,但延迟低、分区少数派也能读。

先准备一个三节点集群(本机示例端口 2379/22379/32379),写入一个余额,然后看三个成员的状态:

etcdctl --endpoints=http://127.0.0.1:2379 put /ledger/alice 100

etcdctl --endpoints=http://127.0.0.1:2379,http://127.0.0.1:22379,http://127.0.0.1:32379 \
  endpoint status -w json \
  | jq -r '.[] | [.Endpoint, .Status.header.revision, .Status.raftIndex, .Status.raftAppliedIndex, .Status.leader] | @tsv'

输出示例(字段顺序:endpoint、当前 revision、raftIndex、raftAppliedIndex、leader id):

http://127.0.0.1:2379    51    51    51    10276657743932975437
http://127.0.0.1:22379   51    51    51    10276657743932975437
http://127.0.0.1:32379   51    51    51    10276657743932975437

现在向 leader 写 80,制造一次版本推进:

etcdctl --endpoints=http://127.0.0.1:2379 put /ledger/alice 80
OK

从 leader 做一次线性一致读,确认写已经可见:

etcdctl --endpoints=http://127.0.0.1:2379 get /ledger/alice --consistency=l -w json
{"header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":52,"raft_term":4},"kvs":[{"key":"L2xlZGdlci9hbGljZQ==","create_revision":51,"mod_revision":52,"version":2,"value":"ODA="}],"count":1}

value 是 base64 的 80,mod_revision=52 是这次写的版本。接下来把 follower 22379 与集群的 peer 流量切断。可以用防火墙/网络策略,或直接用第 13 章项目的 /admin/partition:

# 隔离 22379 的 peer 流量(任选其一):
#   1) 在 22379 所在节点用防火墙/网络策略切断它与 2379、32379 的 peer 连接;
#   2) 用第 13 章项目的 /admin/partition 接口(macOS/CI 可复现)。
# 本机三进程部署时,peer 端口通常是 2380/22380/32380。

再查一次状态,会看到 22379 停在旧 revision:

http://127.0.0.1:2379    52    52    52    10276657743932975437
http://127.0.0.1:22379   51    51    51    10276657743932975437
http://127.0.0.1:32379   52    52    52    10276657743932975437

此时从 22379 做 serializable 读,它会用本地状态回答:

etcdctl --endpoints=http://127.0.0.1:22379 get /ledger/alice --consistency=s -w json
{"header":{"cluster_id":14841639068965178418,"member_id":11276657743932975437,"revision":51,"raft_term":4},"kvs":[{"key":"L2xlZGdlci9hbGljZQ==","create_revision":51,"mod_revision":51,"version":1,"value":"MTAw"}],"count":1}

revision=51、mod_revision=51、value=100:这是 4.8 节说的陈旧读。再从同一个 22379 做 linearizable 读:

etcdctl --endpoints=http://127.0.0.1:22379 --command-timeout=2s \
  get /ledger/alice --consistency=l -w json
{"level":"warn","ts":"2026-10-19T09:41:07.812Z","logger":"etcd-client","caller":"v3/retry_interceptor.go:63","msg":"retrying of unary invoker failed","target":"etcd-endpoints://0xc0000e8000/127.0.0.1:22379","attempt":0,"error":"rpc error: code = DeadlineExceeded desc = context deadline exceeded"}
Error: context deadline exceeded

具体错误文本随 etcdctl 版本和超时设置略有差异;关键是它没有返回旧值,而是失败。这正是 CAP 的现场演示:serializable 读在分区少数派上仍然可用,但返回旧值;linearizable 读拒绝返回旧值,代价是不可用。没有哪个选项「更好」,只有这次读能不能接受旧值。

批量对比三个成员的读,可以一眼看出谁落后:

for ep in 2379 22379 32379; do
  etcdctl --endpoints=http://127.0.0.1:$ep get /ledger/alice --consistency=s -w json \
    | jq -r --arg ep "$ep" '[$ep, .header.revision, (.kvs[0].mod_revision // -1), (.kvs[0].value // "")] | @tsv'
done
2379   52  52  ODA=
22379  51  51  MTAw
32379  52  52  ODA=

怎么读这些数字:

字段含义正常异常
header.revision这次读执行的 MVCC revision同一时刻不同副本可能不同;单副本单调不减同一客户端连续读出现回退
mod_revisionkey 最后一次修改的 revision应等于最近一次写回执的 revision小于写回执,说明读到了旧版本
raftIndex / raftAppliedIndex已提交日志索引 / 已应用索引两者接近;follower 落后 leader 有界applied 长期落后,或与 leader 差距持续扩大
Status.errors成员报告的异常空非空,优先排查成员健康
延迟本地读 vs leader 确认s 通常 0.1–1 ms;l 多一次往返l 在无 quorum 时超时,是可用性代价

三条读法:

  1. 线性一致读成功时,结果不能比已经返回的写旧。 如果写回执是 rev=52,读却返回 rev=51,这不是「最终一致」,而是线性一致读的实现 bug(或者你以为发的是 l,实际发的是 s)。
  2. serializable 读落后多少,要有一个上界。 例如「读流量只允许打到 applied lag ≤ 5 s 的副本」;超过就摘除或降级。没有上界的 s 读,在分区时可能落后几分钟。
  3. linearizable 读超时不是数据丢失。 它说明当前成员无法确认最新值,因此拒绝回答;这比返回旧值安全。复盘时要把它计入可用性,而不是一致性错误。

两个容易忽略的边界:

  • --consistency 只作用于 get(以及基于 get 的命令),不作用于 watch。watch 从指定 revision 之后按序推送已提交事件,但一个从旧 revision 开始的 watch 会先收到旧事件;它不是线性一致读。
  • endpoint status 的 revision 是该成员本地看到的 revision,不是全局最新值;比较时要以 leader 的 header.revision 或写回执为准,不能拿两个 follower 互相比较。

4.9.1 把读模式写进客户端契约

etcd 的 --consistency 是一个好示范:一致性是请求的属性,不是集群的属性。客户端应该能显式表达:

{"key":"/ledger/alice","consistency":"bounded_staleness","max_lag_ms":50}

服务端根据模式选择读路径,并在响应里带回证据:

模式服务端行为响应证据失败时客户端
linearizableleader/ReadIndex 确认revision、served_by返回错误,不降级
bounded_staleness本地读,检查 lag ≤ 上界revision、applied_index、lag_ms超界则换副本或升级为线性读
session追到会话 tokenapplied_index等待/回退 leader
eventual任意副本revision(可能旧)接受旧值

关键在最后一列:降级必须是显式的。如果客户端请求 linearizable,服务端因为分区偷偷返回本地旧值,调用方会把陈旧读当成最新值,4.11 节的事故就会重演。反过来,bounded_staleness 超界时返回错误或升级,也比静默返回旧值好。

如果手边没有 etcd,第 13 章的项目二提供了同构接口:kv_client.py get k --consistency linearizable 与 --consistency serializable,底层分别是 ReadIndex 读和本地读。命令不同,读法相同:看返回的 revision/applied index 与写回执的差。

4.10 真实案例:Jepsen 对 MongoDB 3.4 的分析

2017 年 2 月 7 日,Jepsen 发布了针对 MongoDB 3.4.0-rc3 的分析。这份分析的重要性不在于「MongoDB 有 bug」,而在于它用一个可复现的 history 证明了:在 majority write concern 和 linearizable read concern 都打开的情况下,系统仍然可能丢失已经被确认的写。 这两个选项的名字听起来已经是最强保证,但一致性是端到端的,选举协议的一个假设就能把它击穿。

分析的测试环境是五节点副本集,工作负载包含读、写和 CAS;故障注入不是简单的「杀节点」,而是专门制造有时钟偏斜的隔离主库:

阶段动作目的
1找出所有自认为是 primary 的节点,切断它们与集群其余部分的连接制造少数派主库
2把这些节点的时钟向前拨 2 分钟让它们的 optime 看起来更新
3让隔离主库继续接受写(多数派写关注)产生「无法复制但已确认」的写
4杀死所有 primary,恢复网络、重置时钟强制重新选举
5让隔离主库重新加入并参与选举观察谁赢、哪些写被回滚
6静默后做最终读,检查哪些已确认的插入不见了统计丢失

MongoDB v0 复制协议的选举用 optime 比较「谁更新」。optime 是单调递增的,但它被锚定在节点的本地墙上时钟上。于是出现了分析里的经典场景:

  1. 节点 A 是 primary,与集群隔离 10 秒,期间接受 w1、w2,optime 记为 10、11,但无法复制给多数派。
  2. 另一侧 B 当选 primary,接受 w3,optime 记为 10,并以多数派写确认;w3 复制到了 C。
  3. 隔离解除,A 与 C 选举。A 的 optime 11 大于 C 的 10,A 当选。
  4. A 的日志里没有 w3;为了让日志一致,系统回滚 w3。w3 已经被多数派确认,却从系统中消失了。

时钟偏斜不是「小概率的脏数据」,它直接扩大了「隔离主库的 optime 超过多数派」的窗口。分析指出,v0 的这个缺陷是设计性的,不是某个版本的实现 bug;包括 3.4.1 在内的所有 v0 部署都存在,修复方式是切换到 v1 协议。

Jepsen 的第一组数字:v0 协议下,4525 次插入尝试中,322 条最终保留,93 条被确认后丢失。这不是「写失败了」,而是「客户端收到了成功,数据后来没了」。

v1 协议借鉴了 Raft 的 term 概念,把 optime 换成 (term, timestamp),理论上应该修复 v0 的选举缺陷。但 v1 在 3.4.0-rc3 与 rc4 上仍然暴露了多个 bug:

  • primary 在确认写时只检查「我是否还是 primary」,没有检查 term 是否已经变化,于是可能确认上一个 term 的写(SERVER-27053);
  • 心跳消息可能让一个过期 primary 推进 commit point,确认尚未真正持久的写;
  • 3.4.0-rc4 中,secondary 复制时没有检查对方是否是更新的节点,仍然用时间戳而不是 term 比较(SERVER-27149)。

结果:v1 下 6078 次插入尝试,932 条被确认,417 条最终丢失——接近已确认写的一半。这两个 bug 的根因都可以追溯到 v0 时代的假设:代码里仍然相信「时间戳更大 = 日志更新」,而 v1 的 term 才是逻辑时钟。

修复与验证:

版本/协议结果
v0,所有版本(含 3.4.1)设计性不安全;隔离主库 + 时钟偏斜下可丢 majority 确认写
v1,≤ 3.2.11 / 3.4.0-rc4实现 bug;丢已确认写
v1,3.2.12 / 3.4.0 / 3.5.1修复 term/commit 检查;通过扩展后的 Jepsen 测试
v1 + majority 写 + linearizable 读,3.4.0 及以后测试中保持已确认写,单文档读/写/CAS 线性一致

这份分析给出的教训可以直接迁移到任何系统:

  1. 「多数派确认」是一个条件承诺,不是无条件事实。 它依赖选举协议不会让一个缺少该写的节点当选;如果选举依赖墙上时钟,时钟偏斜就是数据丢失的故障注入项。
  2. 读一致性不能修复写丢失。 如果已确认的写被回滚,linearizable 读再强也只能返回「当前存在的值」;它无法让不存在的写复活。
  3. 协议版本是配置的一部分。 同一个 MongoDB 版本,v0 与 v1 的一致性行为不同;升级/切换协议要和升级代码一样做验证。分析特别指出 v1 不是 Raft,保留了 rollback 等额外复杂度。
  4. 测试要注入时钟偏斜和分区,而不只是杀进程。 只测「节点崩溃后重启」会漏掉「隔离主库 + 快时钟赢得选举」这条路径。Jepsen 的 nemesis 之所以有效,是因为它针对协议的假设设计故障,而不是随机搞破坏。
事实边界

本节只引用 Jepsen 公开分析中的时间、测试方法、数字和结论:2017-02-07 发布,五节点副本集,v0 的 4525/322/93,v1 的 6078/932/417,以及 3.2.12/3.4.0 的修复。具体实现细节以原文和 MongoDB 官方文档为准,本节不复述源码。链接:Jepsen: MongoDB 3.4.0-rc3。

4.10.1 把案例变成自己的测试

不必复现 Jepsen 的五节点集群,也可以在自己的项目里验证同一类假设。第 13 章的项目二提供了分区注入接口,按下面的表设计用例:

故障注入方式观察期望
隔离 followerfault_inject.sh --partition 2从该 follower 读serializable 读返回旧值;linearizable 读报错/等待
隔离 leader--partition 1新 leader 选举、旧 leader 读旧 leader 不再服务线性读;新 leader 可写
时钟偏斜用逻辑时间/延迟模拟,不改系统时钟选举/租约旧主不会凭更大的时间戳获胜
响应丢失代理丢弃响应(或用项目一 --drop-response)客户端超时、重试同键重试不产生第二条副作用
恢复--heal副本追平、读修复revision 收敛,读不再回退

每条用例都要记录 history:操作的 inv/resp、返回值、revision。测试的断言不是「最终一致了」,而是「在注入期间,线性一致读要么返回最新值,要么失败;serializable 读允许旧值,但不能在同一会话中回退」。这比「跑一遍没报错」强得多。

对本书的读者来说,这个案例还有一个方法论意义:第 4.2 节的检查器不是玩具。 把上面的插入/读/CAS 操作记录成 history,检查器(或 Jepsen 的 Knossos)就能给出「不存在合法全序」的反例。没有 history 和判定器,你只能看到「监控显示写入成功」,看不到「成功之后又消失」。

4.11 重试导致重复副作用:陈旧读后的二次转账

现在把 4.8 节的陈旧读和重试接起来。这是全书最值得背下来的事故形状之一:读到的旧值让 guard 通过,重试让同一个意图执行两次。 它同时违反了两条设计原则:读没有版本约束,写也没有幂等身份。

场景:账户 A 初始 100 元,向 B 转账 100 元。服务端的处理函数是典型的「先查再写」:

1. balance = read(A)            # 读请求走只读副本
2. if balance < amount: 拒绝
3. debit(A, amount); credit(B, amount)
4. 返回 transfer_id

第一次请求 t-1 在 leader 上提交:A=0、B=100,rev=58。响应在回程丢失,客户端 800 ms 超时。客户端重试,但重试逻辑生成了新的 transfer_id=t-2;同时读请求被负载均衡到落后的副本,读到 A=100(rev=57)。于是:

时间事件AB读/写版本
t0初始1000rev=57
t1t-1 提交,响应丢失0100rev=58
t2客户端超时,生成 t-2 重试0100—
t3t-2 从副本读 A0100读到 100,rev=57
t4guard 通过,t-2 扣款-100200rev=59
t5对账发现流水有两笔 100 出账,但用户只授权了一笔-100200客户 A 多损失 100

注意两个错误是独立的,任何一个被修复都能阻止事故:

  • 如果 t-2 复用 t-1 的幂等键,服务端的唯一约束会发现「这个意图已经执行」,直接返回原结果;
  • 如果 t-2 的读是线性一致的(或读己之写),它会看到 A=0,guard 拒绝;
  • 如果扣款是原子条件写(balance >= 100),即使 guard 通过,第二次扣款也会失败;
  • 如果对账把流水与用户授权关联起来,能在 t4 之后立即发现并冻结,或者扣款有「单日单账户最多转 100」的业务约束,也能兜住。

四个「如果」对应四层防御,不能只做一层。幂等键解决「同一个意图被执行两次」,条件写解决「基于旧值的写覆盖新值」,线性一致读解决「看到旧值」,对账解决「前三个都漏了」。 它们防的不是同一件事。

例 4.11.1 · 重试导致重复副作用:陈旧读后的二次转账

客户端发起 transfer(t-1, A, B, 100)。服务端提交成功,响应丢失;客户端超时后用新键 t-2 重试。重试的读落到 lag 副本,看到 A=100,guard 通过;服务端没有把流水关联到用户授权(或业务去重键),也没有条件扣款,于是再次扣款。结果 A=-100、B=200。

修复分四层:

  1. 身份:客户端为每个逻辑转账生成 transfer_id,所有自动/手动重试复用它;服务端 transfers.id 主键唯一。
  2. 原子写:扣款用 UPDATE ... WHERE balance >= amount 或 CAS(版本号),不要先读后写。
  3. 读:guard 读走 leader/线性一致读,或至少带 applied_index >= 写回执 的会话 token。
  4. 对账:把流水与用户授权/外部账单定期核对;发现同一授权对应多笔出账或账实不符时告警并冻结,而不是等用户投诉。

只做第 1 层,能防「同一次重试」;只做第 2 层,能防「并发双花」;只做第 3 层,能防「读落后于已提交写」,但防不住两个并发请求都在写之前读到 100;只做第 4 层,只能事后发现。四层一起做,才是「有效一次」的语义。

一个可运行的 SQLite 版本(Python 标准库,无需外部依赖)把前两层放在同一个事务里:

import sqlite3


def transfer(conn, tid, src, dst, amount):
    with conn:  # 事务:异常自动回滚
        cur = conn.execute(
            "INSERT INTO transfers(id, src, dst, amount, status) "
            "VALUES (?, ?, ?, ?, 'pending') ON CONFLICT(id) DO NOTHING",
            (tid, src, dst, amount))
        if cur.rowcount == 0:                 # 幂等键已存在
            return "duplicate"
        cur = conn.execute(
            "UPDATE accounts SET balance = balance - ?, version = version + 1 "
            "WHERE id = ? AND balance >= ?",   # 原子 guard
            (amount, src, amount))
        if cur.rowcount == 0:
            raise ValueError("insufficient funds")
        conn.execute(
            "UPDATE accounts SET balance = balance + ? WHERE id = ?",
            (amount, dst))
        conn.execute("UPDATE transfers SET status = 'done' WHERE id = ?", (tid,))
    return "ok"

配套的建表语句:

CREATE TABLE accounts (
  id      TEXT PRIMARY KEY,
  balance INTEGER NOT NULL,
  version INTEGER NOT NULL
);
CREATE TABLE transfers (
  id     TEXT PRIMARY KEY,
  src    TEXT NOT NULL,
  dst    TEXT NOT NULL,
  amount INTEGER NOT NULL,
  status TEXT NOT NULL
);

两次调用 transfer(conn, "t-1", "A", "B", 100) 的结果:第一次返回 "ok",第二次返回 "duplicate",A 只扣一次。示例为了简短返回 duplicate;生产实现应在命中幂等键时读出原 transfers 行,返回第一次的结果。再调用 transfer(conn, "t-2", ...) 会因 balance >= 100 不成立抛 insufficient funds,事务回滚,不会留下半条记录。注意这是单机 SQLite 的串行写模型;换成 PostgreSQL/MySQL 时,唯一约束和行锁的语义类似,但隔离级别、锁等待和死锁重试要按第 6 章处理。

还有一个更隐蔽的变体:客户端没有超时,但用户看到「处理中」后手动点了第二次。 这次是新的用户意图,客户端会生成新键,幂等键救不了;只有业务层的去重规则(同一用户、同一收款方、同一金额、时间窗)或条件写能挡住。幂等键识别「同一个意图的重试」,业务规则识别「看起来像新意图的重复点击」。 两者也不能互相替代。

最后,不要用「读主库」替代条件写。线性一致读只能保证「读的那一刻是最新」,读和写之间仍有时间窗;两个并发请求可以都线性一致地读到 100,然后都扣款。读一致性解决可见性,条件写/事务解决原子性。 这就是 4.7 节两个维度的分界线。

4.12 如何为业务选一致性

一致性选型不是「选一个最强的数据库」,而是按操作回答五个问题:读错了会怎样?写冲突能否合并?能接受多旧?分区时要不要可用?这个错误能否被对账修复?把答案落到机制上,而不是口号上。

业务读错的后果写冲突推荐组合失败时兜底
账户余额/支付资损、透支同一账户并发扣款线性一致读 + 条件扣款 + 幂等键账本对账、冻结、人工复核
库存/秒杀超卖、履约失败同一 SKU 并发扣减quorum 写或原子 CAS + 预留/回补超卖告警、补偿、限购
优惠券/积分重复发放、薅羊毛同券并发核销幂等键 + 唯一约束 + 条件更新对账、回收、风控
社交 Feed看到旧内容,可接受并发发帖/删除最终一致 + 单调读下拉刷新、版本号
评论/回复回复先于原帖,体验怪并发写不同对象因果一致 + 读己之写客户端重排、延迟渲染
购物车丢商品、数量错多端并发修改读己之写 + 合并策略(CRDT/LWW)合并提示、版本冲突
配置/开关灰度错、功能异常覆盖写线性一致读 + CAS + watch版本回滚、双人审批
权限/ACL越权、撤权延迟并发授权/撤销线性一致读 + 版本号 + 短 TTL 缓存失败关闭、审计
日志/分析统计偏差,可接受重复/乱序最终一致 + 幂等消费重放、去重、离线校正
跨地域用户资料旧头像/旧昵称多地域双写最终一致 + LWW/版本向量冲突提示、手动合并

这张表不是标准答案,而是一个论证模板:每一行都要能回答「为什么这一档够用」。如果答不上来,就往上加一档;如果加一档的延迟/可用性代价无法接受,就把业务改成能接受旧值(例如展示层加「可能延迟」提示),或者把关键判断移到写路径上。

成本可以粗略量化:

机制额外延迟(同地域)分区时可用性实现复杂度主要风险
线性一致读(leader/ReadIndex)+1 次 RTT,约 0.5–2 ms少数派不可用中跨地域时延放大
quorum 写(R+W>N)+1 次多数派落盘需多数派中仍不自动线性一致
会话 token / 读己之写0–1 次 RTT,可能等待高低只保护本会话
有界陈旧读0–lag高中需要 lag 上界和摘除
最终一致 + 冲突合并0高高(冲突逻辑)收敛慢、语义受限
幂等键 + 条件写0–1 次 RTT高中键设计/唯一约束

一个可执行的五问决策树:

  1. 读错会不会直接造成资损、超卖、越权或不可逆的外部副作用? 会 → 决策读走线性一致/leader,写走条件更新或事务。
  2. 能不能用幂等 + 对账把错误变成可修复的? 能 → 可以降一档,但必须真的实现对账和补偿,而不是写在文档里。
  3. 能接受多旧? 给出毫秒/版本上界;不能给出上界 → 不要选「最终一致」作为决策读。
  4. 并发写是否可交换/可合并? 可合并 → CRDT/版本向量;不可合并 → 单写者、CAS 或锁(第 9 章)。
  5. 分区时业务要可用还是要正确? 两者都要 → 把操作拆开:展示读降级、决策写失败关闭;不要指望一个开关同时满足。

4.12.1 一个走完流程的例子:库存扣减

假设 SKU 只有 1 件库存,两个用户同时下单。业务要求:不能超卖;下单接口在分区时宁可失败也不能卖重;商品详情页可以显示旧库存。

操作一致性选择机制失败时
详情页读库存有界陈旧/最终一致缓存 + lag_ms 上界显示「库存紧张」,下单时再校验
下单扣库存线性一致 + 原子条件写UPDATE stock SET qty=qty-1 WHERE sku=? AND qty>=1,leader/quorum返回 503/409,不降级
订单创建幂等order_id 唯一约束重试返回原订单
超时补偿最终一致预留 + TTL + 回补定时对账,释放未支付库存

两个常见错误:

  • 先读库存再扣:两个请求都读到 1,都通过 guard;除非扣减是原子条件写,否则超卖。读的一致性再强,也挡不住读写之间的窗口。
  • 用 quorum 写就以为线性一致:W=2, R=2 只保证读写集合相交;如果读只是取第一个响应、没有版本比较/读修复,仍可能拿到旧值。即使读到了最新,也不自动等于线性一致(并发写、sloppy quorum、跨 key 都不在 R+W>N 的覆盖范围)。库存扣减要靠条件写或共识,不是靠「多数派」三个字。

这个例子也说明为什么本节开头的表要按操作拆:同一个 SKU,详情读和下单写的选择可以完全不同。

三个常见的过度设计也要避免:

  • 所有读都走线性一致:跨地域延迟翻倍,读扩展消失,leader 成为热点;展示型读完全没必要。
  • 所有写都用全局事务:跨地域 2PC 的延迟和阻塞代价很高;很多写可以用「单分区事务 + outbox + 幂等消费」解决(第 6、7 章)。
  • 把「最终一致」当成「不用设计」:冲突解决、tombstone、反熵、对账都是实打实的代码;不写这些,最终一致只是「最终不一致」。

4.13 本章陷阱清单

12 条高频错误
  1. 把 CAP 当「三选二」:P 是网络属性,不是产品开关;分区时是在 C 和 A 之间选,无分区时三者可以同时成立,但强一致仍要付延迟(PACELC)。
  2. 把「最终一致」当成「很快一致」:定义没有时间上界;没有反熵、读修复、tombstone 保留和对账,收敛可能永远不到来。
  3. 用只读副本读余额再做扣款判断:读是陈旧的,guard 通过不代表余额够;这是 4.11 节二次转账的直接原因。
  4. 以为开了 majority 写就等于线性一致:MongoDB v0 的案例证明,选举协议依赖墙上时钟时,多数派确认的写仍可能被回滚。
  5. 把陈旧读当成脏读:陈旧读读到的是已提交的旧值,调高事务隔离级别不会修复;要改读路径(leader/ReadIndex/会话 token)。
  6. 把线性一致当成串行化:单键线性一致不提供跨键快照,也不阻止丢失更新;多对象原子性要靠事务/条件写。
  7. 用墙上时钟给跨机器操作排序:时钟偏斜会让「后发生的写」输给「时间戳更大的写」;用逻辑时钟、版本号或单观察者记录。
  8. 「先读后写」不加 CAS/条件更新:读和写之间的时间窗足够两个请求都通过检查;原子性要落在写路径上。
  9. 重试时换新的幂等键:服务端无法把第二次请求归并到第一次;键必须由客户端为逻辑请求生成一次,并在所有重试中复用。
  10. 把会话保证当成全局保证:读己之写/单调读只保护单个客户端;其他用户仍可能看到旧值,跨设备也不自动生效。
  11. 只在无分区、时钟同步的环境测一致性:Jepsen 的故障注入专门制造隔离主库和时钟偏斜;不注入这两个,测不到选举/回滚类问题。
  12. 检查器把 pending 操作当成功:超时操作可能已提交也可能没执行;线性一致性允许丢弃或补全 pending,强行赋值会制造假反例/假通过。

4.14 本章出口检查

检查项达标线没达标去哪
记录一段 history每个操作有 proc/kind/key/inv/resp/ret,能区分并发与实时序4.1
判断并运行检查器能手工解释 H_OK、H_STALE,并运行 python3 linearizability.py 得到与 4.2 节一致的输出4.2
解释顺序一致能构造「SC 但不线性一致」的历史,并给出 SC 见证全序4.3
解释因果一致能画出 post/reply 的 happens-before,指出 Carol 的违反点4.4
说出会话保证能区分读己之写、单调读、单调写、写后读,并各举一个事故4.4
说出收敛条件至少列出停止写、送达、确定性合并、tombstone、读路径、保留期4.5
精确复述 CAP能写出 C/A/P 定义,指出「三选二」错在哪,并给出 PACELC 的例子4.6
区分一致性与隔离能解释「每键线性一致但不一致快照」和「Serializable 但读到旧副本」4.7
观察 etcd 两种读能跑 --consistency=l/s,用 revision 判断陈旧,解释超时是可用性代价4.9
复述 Jepsen 案例能说出 v0 的时钟/选举链、v1 的 term bug、93/417 两个数字4.10
修复二次转账能给出幂等键、条件写、线性一致读、对账四层,并说明各防什么4.11
为业务选型能按「读错后果/写冲突/陈旧上界/分区取舍」给出组合与兜底4.12
即时自测 · 4

Q1 历史:write(x,1) 区间 [0,10],read(x) 区间 [5,15] 返回 0。它线性一致吗?如果读返回 1 呢?如果读区间是 [11,20] 返回 0 呢?

Q2 历史:A write(x,1) [0,5],B write(x,2) [6,10],C read(x)=1 [11,15],C read(x)=2 [16,20]。它是顺序一致吗?是线性一致吗?给出见证或反例。

Q3 三节点集群发生分区,2 个节点在多数派一侧,1 个在少数派。多数派一侧继续接受写并返回成功,少数派一侧对线性一致读返回 503。这个系统是 CP 还是 AP?「少数派返回 503」是否说明它不满足 CAP 的 A?

Q4 etcd 写返回 revision=58,随后从 follower 用 --consistency=s 读到 revision=57,用 --consistency=l 超时。这两个结果分别说明什么?哪个是 bug?

Q5 转账服务读到 A=100(实际已是 0),guard 通过后扣款 100;客户端超时后用新键重试,再次扣款。请按「身份、原子写、读、对账」四层给出修复,并说明只做「读主库」为什么不够。

答案

A1 返回 0 时线性一致:读与写并发,可以把读的线性化点放在写之前,读到初始值 0。返回 1 也线性一致:把读放在写之后即可。区间 [11,20] 返回 0 则违反:写已在 10 返回,读在 11 才开始,任何合法全序都必须让读看到 1。

A2 顺序一致,见证全序 write(x,1) -> read=1 -> write(x,2) -> read=2,它尊重 C 的程序序。但不线性一致:B 的写在 10 已返回,C 的第一次读在 11 才开始,实时序要求写 2 排在读 1 之前;此时寄存器值是 2,读不可能返回 1。所以不存在合法全序。

A3 它是 CP(更准确地说,在这个配置下分区时选择 C):多数派继续提供线性一致的服务,少数派拒绝线性一致读。CAP 的 A 要求每个未故障节点都返回非错误响应;少数派节点没有故障,却返回 503,因此在这个请求上不满足 A。CAP 并不要求系统在所有分区模式下都可用;它说的是不能同时满足 C 和 A。

A4 s 读返回 rev=57 是合法的 serializable(本地)读:follower 还没应用 rev=58,返回旧值符合 serializable 的定义,但它是陈旧读。l 读超时说明该 follower 无法联系 leader/quorum,无法确认最新值,因此拒绝回答;这是分区少数派的可用性代价,不是数据损坏。若 l 读成功却返回 rev=57,那才是线性一致实现的 bug。两者都不是「脏读」,旧值本身是已提交的。

A5 身份:客户端为逻辑转账生成并复用 transfer_id,服务端主键/唯一约束去重,重复请求返回原结果。原子写:扣款用 UPDATE ... WHERE balance >= 100 或 CAS,避免读-改-写窗口。读:guard 读走 leader/线性一致读,或带 applied_index 的会话 token。对账:账本余额与流水定期核对,异常冻结。只做「读主库」不够,因为读和写之间仍有时间窗,两个并发请求都能读到 100 再各扣一次;线性一致只保证读的可见性,不保证写的原子性。反过来,只做条件写也不够:客户端换新键重试仍会产生第二条业务记录,只是余额不会被扣成负数;对账和幂等仍然必要。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 5 章 运输层、第 6 章 应用层4.6、4.9背景只借用「请求跨网络、可能丢失/延迟」的视角;不重写 TCP/HTTP
张彦飞《深入理解 Linux 网络》第 4–7 章(TCP、socket、连接)4.8、4.9背景连接超时/重传只引用,不展开内核路径
《Python 网络编程》socket/HTTP 客户端、并发4.2、4.4、4.11精选借用工程风格;检查器、会话 token、转账事务均为本书重写
《Linux 系统与内核》第 4/5 篇4.1–4.11 / 5.1–5.64.9交叉引用明确不重复 TCP/IP、epoll、HTTP/DNS/TLS、Kubernetes
本书自编 + Gilbert & Lynch、Abadi、JepsenCAP 证明、PACELC、MongoDB 3.4 分析4.6、4.10完整只引用定义、定理与公开测试数据,不复制论文/分析原文

下一站:知道了「读允许返回什么」,下一步要问「多个副本如何就同一个顺序达成一致」。进入 5 共识:Paxos 与 Raft,看 quorum 如何相交、term 如何阻止旧主、ReadIndex 如何把线性一致读落到协议上。

5 共识:Paxos 与 Raft

本章回答

共识到底要保证哪三条性质?Paxos 的 prepare/promise/accept/accepted 怎样防止两个值都被选中?Raft 的 term、选举限制、日志匹配和提交规则分别堵住了哪个洞?分区之后旧 leader 还能读、还能写吗?ReadIndex、lease read、follower read 各自押了什么假设?提案重试为什么会在状态机里留下两条命令?

读完你会

能画出 Paxos 两阶段和 Raft 选举/复制的时序;能用「当前 term 提交」规则判断一段 Raft 实现是否安全;能设计线性一致读路径并说清它依赖不依赖时钟;能从 etcd 指标里区分 leader 抖动、apply 滞后和磁盘瓶颈;能在状态机里做请求去重,避免提案重试的重复副作用。

一个 5 节点的配置中心,leader 和一个 follower 在机房 A,另外三个节点在机房 B。A 与 B 之间的链路抖动了 8 秒。A 上的客户端仍在写配置,请求超时;B 上的三个节点选出新 leader,提交了新配置。链路恢复后,客户端把超时的请求重发到新 leader,配置版本又增加了一次。没有节点崩溃,没有磁盘损坏,也没有人改错配置,但「谁有权决定下一个值」这个问题没有被正确回答。

第 3 章讲了复制:把日志从一台机器搬到另一台。第 4 章讲了一致性:读到的值能不能满足线性一致。这两章留下同一个空洞:当多个副本都能接受写、而网络又随时可能分区时,凭什么只有一个顺序? 复制负责「多存几份」,一致性负责「读到的算不算数」,共识负责中间那件事——让一组进程对「下一个值是什么、排在第几位」达成一个不会反悔的决定。

共识是分布式系统里最像「地基」的部分。etcd、Consul、ZooKeeper、TiKV、CockroachDB、Kafka 的 KRaft 控制器,乃至 Kubernetes 的元数据,最终都要落到一个共识日志上。但共识只保证日志的顺序和持久性,不保证业务只执行一次,也不保证读一定线性一致;这两件事分别要靠第 5.12 节的去重和第 5.8 节的读路径。把边界划清楚,比背下 Raft 的几个 RPC 更重要。

本章路线:5.1 定义问题和状态机复制;5.2 讲 FLP、故障模型与 quorum;5.3–5.4 用 Paxos 建立「提案号 + 多数派」的安全直觉,并看它为什么会活锁;5.5–5.8 讲 Raft 的选举、日志、成员变更、快照和三种读路径;5.9–5.13 进入工程:旧主分区、etcd 观测、Jepsen 案例、提案重试去重、自己实现 Raft 的五个坑;5.14–5.15 收束成陷阱清单和出口检查。

5.1 共识问题:协议要保证什么

先把「共识」和「复制」分开。假设三个副本 S1、S2、S3,客户端先后发了两条命令:x=1、x=2。如果 S1 先收到 x=1 再收到 x=2,S2 先收到 x=2 再收到 x=1,两个副本的最终状态可能都是 x=2,但中间状态和读到的历史不同;如果命令是 append("a")、append("b"),最终列表的顺序就会分叉。复制只保证字节到了,共识要保证所有副本按同一个顺序执行。

一个共识实例(consensus instance)通常这样描述:有 N 个进程,每个进程可以提议(propose)一个值;协议结束后,每个正确的进程要选择一个值(decide),并满足三条性质。

性质含义被违反时会发生什么
一致性(agreement)没有两个正确的进程选择不同的值两个客户端看到不同的「已提交」结果,无法合并
有效性(validity)被选择的值必须是某个进程提议过的值协议可以永远输出一个谁都没提过的默认值,毫无意义
终止性(termination)每个正确的进程最终都能选择一个值系统永远不返回,客户端只能超时

有些教材还会加一条完整性(integrity):每个进程最多选择一次,且选择后不能改。它通常由实现保证,不单独列为共识的核心性质。工程上还要区分「安全性质」和「活性性质」:agreement、validity、integrity 是安全性质,违反一次就不可挽回;termination 是活性性质,可以暂时不满足,等网络恢复后再满足。这个区分是理解 FLP 和所有超时机制的关键。

定义 5.1.1 · 共识与状态机复制

共识:一组进程对某个值达成一致,满足 agreement、validity、termination。状态机复制(state machine replication,SMR):把客户端命令写进一个有序日志,每个副本按相同顺序把命令应用到确定性状态机;只要日志顺序一致、命令确定,所有副本的状态就一致。

SMR 把「一次共识」扩展成「一串共识」:日志的第 1 槽、第 2 槽、第 3 槽各是一次共识实例。Paxos 用槽位(slot)组织,Raft 用连续的日志索引组织。共识决定顺序,状态机决定语义;两者之间还隔着去重和读路径。

为什么需要有效性?如果没有它,协议可以规定「所有进程直接决定 0」,agreement 和 termination 都满足,但客户端写的 x=1 永远不会生效。有效性把共识和「随便选一个默认值」区分开。为什么需要终止性?如果协议永远不决定,客户端只能超时,然后重试;重试又会引入新的提案,系统可能永远在「正在达成共识」的路上。

状态机复制的最小例子:

客户端         日志(共识决定顺序)              状态机
  set x=1  →  [1: set x=1] [2: set x=2]  →  S1: x=2
  set x=2  →  同一顺序                      →  S2: x=2
                                             S3: x=2

这张图里有三个容易忽略的前提:

  • 命令必须是确定性的:如果命令里有 now()、随机数、自增序列,不同副本执行同一条命令可能得到不同结果;顺序一致也会状态分叉。把非确定性结果在提案前固化(客户端给时间戳/ID),或者让 leader 把结果写进日志。
  • 日志必须是连续的:状态机只能按 1、2、3 的顺序 apply;中间缺一个槽位,后面的命令即使已经选中也不能 apply。Multi-Paxos 用 no-op 填洞,Raft 用连续索引 + commitIndex 避免洞。
  • 共识的对象是命令,不是最终状态:不要试图让副本对「当前余额」达成一致,而要让它们对「扣款命令」达成一致;状态由确定性 apply 推导出来。

这也是为什么「共识」和「复制」不能互相替代:复制可以把命令搬到所有副本,但只有共识能保证它们按同一个顺序 apply。

如果日志顺序变成 [set x=2, set x=1],三个副本仍然一致,但客户端看到的最终值是 x=1,与它以为的「后写的赢」相反。共识不保证「后写的赢」,只保证「大家按同一个顺序赢」;哪个顺序算「后」,由协议决定的日志位置说了算,不由墙钟说了算。

这里有一个常见的概念混淆:把「多数派都有这条日志」当成「这条日志已经提交」。多数派有,只说明它有机会被提交;如果 leader 在提交前崩溃,后续 leader 仍可能覆盖它(第 5.6 节会讲 Raft 的 Figure 8 反例)。提交是一个协议动作,不是一次磁盘计数的观察。

另一个混淆是把共识当成「所有节点都同意」。共识只要求正确的、参与决策的多数派同意;一个暂时失联的 follower 可以落后,之后通过日志复制追上来。要求所有节点都同意,会让任何一台机器故障都阻塞整个系统,这不是共识,是「全体一致」的陷阱。

维度复制(第 3 章)共识(本章)
解决的问题数据放在几个副本上哪个值/哪条命令排在哪个位置
典型机制主从、多主、无主、quorumPaxos、Raft、Zab
安全边界复制 lag、冲突、双主两个 leader、两个值、丢已提交日志
失败后果读到旧值、写冲突已确认的写丢失、状态机分叉
与读的关系决定副本是否新决定「已提交」的边界

最后强调本章的故障模型:崩溃-恢复(crash-recovery)+ 网络分区 + 部分同步(partial synchrony)。我们不考虑拜占庭节点(会撒谎、会伪造消息),也不考虑磁盘静默损坏。拜占庭共识需要 3f+1 个节点和签名等额外机制,是另一个话题;把崩溃模型下的结论套到拜占庭环境,会低估所需副本数。

5.2 FLP、故障模型与 quorum

初学者常问:「既然异步网络里消息可能丢、可能慢,那共识是不是不可能?」答案是:在完全异步、确定性算法、允许一个进程崩溃的模型下,要求所有正确进程都终止,确实不可能。 这就是 FLP 不可能性定理(Fischer、Lynch、Paterson,1985)。

定理 5.2.1 · FLP 不可能性

在一个完全异步的分布式系统中,即使消息可靠、网络最终会送达,只要允许一个进程崩溃,且算法是确定性的,就不存在一个共识协议能同时保证 agreement、validity 和 termination。

注意限定词:完全异步、确定性、一个崩溃、必须终止。少一个限定词,结论就可能不同;多一个「部分同步」假设,工程上就有活路。

FLP 的直觉不复杂:在异步模型里,你无法区分「进程已经崩溃」和「进程只是极慢」。假设协议在某一步必须等某个进程的消息才能决定;调度器可以一直延迟这个消息,让协议停在「还差一步」的状态。如果协议为了终止而选择不等,它又可能在一个「其实还活着、只是消息晚到」的场景里做出错误决定。调度器可以把系统在两种几乎相同的全局状态之间来回摆,让任何确定性协议都无法安全地打破平局。

这不是说「共识不能做」。它说的是:安全性和活性不能同时无条件保证。工程系统的选择是:安全性质永远不妥协(绝不出现两个值、绝不丢已提交日志),活性性质只在「网络最终稳定、时钟漂移有界」的假设下保证。超时、随机化、leader 选举,都是在这个边界上做工程。

故障模型进程会怎样网络会怎样本章是否覆盖
崩溃-停止(crash-stop)崩溃后不再恢复可靠或可能丢是(简化)
崩溃-恢复(crash-recovery)崩溃后重启,内存丢失、磁盘保留可能丢、可能分区是(主模型)
遗漏(omission)可能漏发/漏收消息可能丢是(分区的一种)
网络分区进程都活着,但互相不可达双向/单向/非传递是
时钟故障时钟跳变、漂移正常是(lease read 的重点)
拜占庭任意撒谎、伪造任意否

超时在共识里扮演什么角色?它不是「证明对方死了」,而是一个不完美的故障检测器:在部分同步假设下,超时最终能发现真正崩溃的进程,但也会误判「只是慢」的进程。误判的代价是多余的选举,不是安全性的丧失——只要投票规则和 quorum 规则不被绕过。把超时当证据,是很多实现错误的起点;把超时当怀疑,才是正确用法。

quorum 是共识的另一个支柱。大多数协议用多数派(majority):N 个节点中需要 floor(N/2)+1 个。为什么是多数派?因为任意两个多数派必然相交。

N多数派大小能容忍的崩溃数 f备注
110没有容错,退化成本地日志
220任何一台挂掉都无法形成多数派
321最常见的入门配置
431成本比 3 节点高,容错没变
532生产常见;可跨三个可用区
642仍然只容忍 2,成本更高
743跨五个可用区时才会考虑

把相交性写成计数:N=5、quorum=3,两个 quorum 的最小并集是 3+3-5=1,所以至少共享 1 个节点;N=4、如果误用 quorum=2,两个 2 的集合可以是 {A,B} 和 {C,D},交集为 0。quorum 的大小不是「看起来过半数」,而是由「任意两个集合必须相交」反推出来的。

加权 quorum 也一样:假设 A、B、C 各 2 票,D、E 各 1 票,总权重 10,quorum 设为 6;任意两个 6 票的集合在 10 票里必然相交。加权 quorum 常用于跨机房:把更多权重放在核心机房,牺牲一点容错换更低的跨机房 RTT。但权重配置本身也要通过共识变更,不能两边各算一套。

两个多数派相交,意味着「上一次决定的证据」至少会被一个节点带到下一次投票里。Paxos 的 promise 里带着已接受的值,Raft 的投票者会比较候选人的日志,都是同一件事:让相交的那个节点成为信息的传递者。如果 quorum 不相交,两个 leader 可以各自拿到「多数」,却没有任何节点知道对方已经决定过什么,安全性就无从谈起。

一句话

quorum 不是「超过一半的人举手」,而是「任意两个决策集合一定有一个共同的见证者」。多数派只是最容易实现的一种 quorum;加权 quorum、分层 quorum 也可以,只要满足相交性。

两个常见误区:

  • 「N=4 时 quorum=2」:2 不是多数派,两个 2 的集合可以不相交({A,B} 和 {C,D});N=4 的多数派是 3。
  • 「quorum 只看节点数,不看配置」:成员变更时,旧配置的多数派和新配置的多数派可能不相交;必须用联合共识(5.7)或一次只改一个节点。

还有一个容易被忽略的细节:quorum 是相对于当前配置的。如果集群从 3 节点扩到 5 节点,而某个节点还用旧配置计算多数派,它可能和一个新配置的多数派做出不同的决定。配置本身也必须通过共识来变更,不能靠人工改配置文件然后重启。

5.3 Paxos:prepare/promise/accept/accepted

Paxos 是共识协议里最经典的一个。它不要求稳定的 leader,也不要求全局时钟;它只要求提案号(ballot)全序和多数派相交。理解 Paxos 的两阶段,是理解 Raft 为什么这样设计的最好铺垫。

Paxos 里有三种角色,一个节点可以同时扮演多种:

角色职责关键状态
Proposer提出提案号 n 和值 v,推动两阶段当前 ballot、待提议的值
Acceptor对提案做出 promise/accept 的承诺promised、accepted_ballot、accepted_value
Learner学习已经被选中的值已学习的值、日志槽位

一次单值 Paxos 的消息流:

Proposer                    Acceptor A1        A2   A3   A4   A5
   │  prepare(n=10)            │                │    │    │    │
   ├──────────────────────────▶│────────────────▶│───▶│───▶│───▶│
   │                           │                │    │    │    │
   │◀── promise(10, (8,x=0)) ──┤                │    │    │    │
   │◀── promise(10, none) ─────┼────────────────┤    │    │    │
   │◀── promise(10, none) ─────┼────────────────┼────┤    │    │
   │                           │                │    │    │    │
   │  选 x=0(已接受值中 ballot 最大)             │    │    │    │
   │  accept(10, x=0)          │                │    │    │    │
   ├──────────────────────────▶│────────────────▶│───▶│───▶│───▶│
   │◀── accepted(10) ──────────┤                │    │    │    │
   │◀── accepted(10) ──────────┼────────────────┤    │    │    │
   │◀── accepted(10) ──────────┼────────────────┼────┤    │    │
   │  多数派接受 → x=0 被选中(chosen)            │    │    │    │

四个消息的语义:

  1. prepare(n):proposer 请求 acceptor 用一个比 n 小的提案号做承诺。
  2. promise(n, accepted):acceptor 承诺「不再接受小于 n 的提案」,并返回自己已经接受过的最大 ballot 及其值(如果有)。
  3. accept(n, v):proposer 请求 acceptor 接受「ballot 为 n、值为 v」的提案。
  4. accepted(n, v):acceptor 接受了提案;当多数派接受时,这个值被选中。

acceptor 的两条硬规则:

  • 收到 prepare(n) 时,如果 n > promised,就更新 promised = n,返回已接受的最大 ballot;否则拒绝。
  • 收到 accept(n, v) 时,如果 n >= promised,就接受,更新 promised = n、accepted_ballot = n、accepted_value = v;否则拒绝。

proposer 的一条硬规则:在收到的 promise 中,如果有任何 acceptor 已经接受过值,必须选择其中 ballot 最大的那个值;只有所有 promise 都没有已接受值时,才能提自己的值。 这条规则是 Paxos 安全性的核心,也是最容易被实现者省略的一步。

ballot 还有两个工程约束:

  • 全局唯一且全序:常用 (epoch, node_id) 或 (round, node_id);如果两个 proposer 用同一个 ballot 提不同的值,acceptor 会把它们当成同一个提案的不同版本,可能选出两个值。
  • 只能单调递增:proposer 重试时复用原 ballot,或者换一个更大的 ballot;不要在失败后换一个更小的 ballot,那会被 acceptor 直接拒绝。

这两个约束看起来是「编号规范」,实际上是安全性的组成部分:ballot 是 Paxos 里唯一的全序坐标,坐标乱了,quorum 相交也救不了。

为什么必须这样做?看一个反例:

例 5.3.1 · 忽略已接受值会选出两个值

假设 acceptor A1、A2、A3 在 ballot 8 已经接受了 x=0,所以 x=0 已经被选中。现在 proposer P2 用 ballot 10 发起 prepare,收到 A1、A4、A5 的 promise;A1 返回「我在 ballot 8 接受过 x=0」。如果 P2 忽略这个信息,直接 accept(10, x=1):

  • A1 的 promised 已经是 10,10 >= 10,它会接受 x=1;
  • A4、A5 也会接受;
  • 多数派 {A1, A4, A5} 接受了 x=1,于是 x=1 也被选中。

结果是 x=0 和 x=1 都被选中,agreement 被破坏。P2 本来可以从 A1 的 promise 里知道 x=0 已经存在,但它选择忽略;安全性不是被 acceptor 的规则破坏的,而是被 proposer 的疏忽破坏的。

这个反例也解释了「为什么 promise 要返回已接受值」:phase 1 的 quorum 和 phase 2 的 quorum 必然相交,交集里的 acceptor 一定见过之前被选中的值。proposer 只要把「见过的最大 ballot 值」带进 phase 2,就能保证新提案不会覆盖旧决定。Paxos 的安全性来自 quorum 相交 + 提案号单调 + 携带已接受值,三者缺一不可。

一个常见的误解是「acceptor 只能接受一个值」。不是的:acceptor 可以接受多个不同 ballot 的值,但它不能接受比 promised 小的 ballot。它最终只会为「最高的那个被选中的 ballot」作证;更低 ballot 的值即使被接受过,也会被后续提案覆盖或被学习者忽略。

下面是单值 Paxos 的简化 Python 模拟器。它只模拟 acceptor 的承诺/接受规则和 proposer 的选择规则,不模拟消息丢失、崩溃和持久化;目的是让你看到「忽略已接受值」会怎样破坏安全性。

from dataclasses import dataclass
from typing import Optional

@dataclass
class Acceptor:
    name: str
    promised: int = -1
    accepted_ballot: int = -1
    accepted_value: Optional[str] = None

    def prepare(self, ballot: int):
        if ballot <= self.promised:
            return ("reject", self.promised, None)
        self.promised = ballot
        prior = None
        if self.accepted_ballot >= 0:
            prior = (self.accepted_ballot, self.accepted_value)
        return ("promise", self.promised, prior)

    def accept(self, ballot: int, value: str):
        if ballot < self.promised:
            return ("reject", self.promised)
        self.promised = ballot
        self.accepted_ballot = ballot
        self.accepted_value = value
        return ("accepted", ballot)

def run_paxos(acceptors, ballot: int, value: str):
    replies = [a.prepare(ballot) for a in acceptors]
    promises = [r for r in replies if r[0] == "promise"]
    if len(promises) <= len(acceptors) // 2:
        return None
    prior = [r[2] for r in promises if r[2] is not None]
    if prior:
        value = max(prior, key=lambda item: item[0])[1]
    accepts = [a.accept(ballot, value) for a in acceptors]
    if sum(1 for r in accepts if r[0] == "accepted") > len(acceptors) // 2:
        return value
    return None

nodes = [Acceptor(f"A{i}") for i in range(1, 6)]
nodes[0].accepted_ballot, nodes[0].accepted_value = 8, "x=0"
nodes[0].promised = 8
print("chosen:", run_paxos(nodes, 10, "x=1"))

输出:

chosen: x=0

注意输出不是 x=1。proposer 本来想提 x=1,但它在 A1 的 promise 里看到了 ballot 8 的 x=0,于是改为 x=0。如果把 if prior: 那两行删掉,输出会变成 x=1,同时 A1 的 accepted_value 会被覆盖——这就是例 5.3.1 的 bug。

这个模拟器还有三个简化,生产实现不能照抄:

  • 没有持久化:真实 acceptor 必须在回复 promise/accepted 之前把 promised、accepted_ballot、accepted_value 落盘(fsync)。崩溃后丢失 promise,可能接受一个更小的 ballot,直接破坏安全性。
  • 没有消息丢失/重复:真实网络里 prepare 的响应可能丢,proposer 会重试;重试必须复用同一个 ballot,不能每次生成新 ballot,否则会制造活锁(5.4)。
  • 没有 learner:真实系统里「被选中」不等于「所有节点都知道」;learner 需要从 acceptor 或 leader 学习,并处理日志空洞。

Paxos 只保证一个值的安全。要复制一串命令,需要为每个日志槽位跑一次 Paxos,这就是 Multi-Paxos 的起点。

Paxos 常被拿来和两阶段提交(2PC)比较,但两者解决的不是同一个问题:

维度Paxos2PC
目标让一组进程对值达成一致让多个资源要么全提交、要么全回滚
容错少数派故障仍可继续(有 quorum)协调者故障可能阻塞,参与者要等决议
提交条件多数派接受所有参与者 vote yes
失败后的状态可以重新选 leader、继续提案可能处于 in-doubt,需要恢复/人工介入
典型位置复制日志、选主、元数据跨库事务、XA、分布式事务协调

2PC 不是「崩溃容错的共识协议」:它要求所有参与者都同意,协调者一挂就可能阻塞;Paxos/Raft 只要求多数派,少数派可以暂时失联。反过来说,Paxos 也不能直接替代 2PC 的事务语义:共识能决定「提交这条事务日志」,但事务的回滚、隔离、约束检查仍要数据库自己实现。第 6 章会把两者组合起来讲。

5.4 Paxos 的活锁与 Multi-Paxos

Paxos 保证安全性,但不保证活性。最典型的活性故障是提案者互锁(dueling proposers):

P1: prepare(10) ──────────────▶ 多数派 promise(10)
P2:        prepare(11) ────────▶ 多数派 promise(11)
P1:                    accept(10, v1) ──▶ 被拒绝(promised=11)
P2:                           accept(11, v2) ──▶ 被拒绝(P1 又 prepare(12))
P1:                                  prepare(12) ──▶ ...
P2:                                         prepare(13) ──▶ ...

两个 proposer 都没有违反规则:P1 的 accept 被拒是因为 acceptor 已经 promise 了更大的 11;P2 的 accept 被拒是因为 P1 又提高了 ballot。每一轮都「差一点」成功,但没有任何值被多数派接受。这不是安全性故障,而是活性故障;客户端看到的是无限超时。

活锁在工程上不罕见,常见的触发方式:

  • 两个客户端同时检测到 leader 失效,各自发起提案, ballot 一路抬高;
  • 两个机房都认为自己应该接管,各自有一个 proposer;
  • 重试逻辑没有随机退避,失败后立刻用更大的 ballot 重试;
  • leader 租约到期时间设置得太短,正常抖动也会触发切换。

解决办法不是「让 Paxos 更聪明」,而是减少 proposer 的数量和冲突概率:

  1. 稳定 leader(distinguished proposer):同一时刻只允许一个节点发起提案;其他节点把请求转发给它。这也是 Raft 直接内置 leader 的原因。
  2. 随机退避:冲突后等待一个随机区间再重试,打破同步脉冲。
  3. ballot 空间分区:每个 proposer 使用 (epoch, node_id) 形式的 ballot,避免两个节点生成相同的 ballot;ballot 相同但值不同会直接破坏安全性。
  4. 抢占与租约:leader 用租约告诉其他人「这段时间别抢」,租约到期后再开放选举;代价是时钟假设(5.8)。

Multi-Paxos 是把「一个值」扩展成「一串值」的工程方案。核心思想:选出一个稳定的 leader,让它只做一次 phase 1,然后对每个日志槽位直接做 phase 2。 这样每个命令省掉一轮 prepare/promise,吞吐量大幅提高。

Multi-Paxos 的几个关键设计:

  • 日志槽位(slot):每个槽位对应一个共识实例;leader 为槽位分配命令,acceptor 接受 (slot, ballot, command)。
  • phase 1 摊销:leader 在任期开始时对「所有未来槽位」做一次 prepare,或者对一段槽位区间做 prepare;之后 phase 2 只需要多数派接受。
  • 提交索引(commit index):槽位可以乱序被选中,但状态机只能按连续前缀应用;中间的洞用 no-op 填,或者等之前的槽位补齐。
  • leader 完整性:新 leader 必须先学习所有已选中的槽位,不能从自己的日志末尾直接开始,否则会覆盖已提交命令。
  • 成员变更与快照:日志不能无限增长;配置变更也要走共识,不能靠改配置文件。

一次 Multi-Paxos 的日志写入可以简化为:

slot:       1         2         3         4         5
命令:      set x=1   set y=2   no-op     set z=3   set w=4
状态:      chosen    chosen    chosen    pending   chosen?
commitIndex = 3     ← 只能提交连续前缀;slot 4 未选中,slot 5 即使已选中也不能先 apply

slot 4 的洞可能来自 leader 崩溃、消息丢失或并发提案;新 leader 必须先补齐/确认 slot 4,才能让 slot 5 生效。「已经选中」是每个槽位各自的事,「已经提交」是连续前缀的事;混用这两个概念,就会在恢复时应用一个中间有洞的日志。

Paxos 和 Raft 的关系,常被简化成「Raft 是 Multi-Paxos 的一种」。这句话方向没错,但容易掩盖差异:

维度Paxos / Multi-PaxosRaft
leader 选举协议之外,靠外部机制或额外 Paxos协议内置,term + 随机超时 + 投票
日志槽位,允许空洞连续索引,强日志匹配
提交规则每个槽位多数派接受当前 term 规则 + 多数派 matchIndex
成员变更通常靠外部配置管理联合共识/单步变更,写进日志
读路径需要额外设计(ReadIndex/lease)同样需要额外设计,协议不免费提供
可理解性论文较抽象,实现变体多为可理解性设计,状态机明确

这张表不是说 Paxos 更差。Paxos 更通用,适合需要灵活槽位、批量、跨地域优化的系统;Raft 把 leader 选举、日志、成员变更打包成一套更约束的协议,牺牲一些灵活性换取可实现性和可测试性。选型时不要问「哪个更强」,要问「团队能不能把它实现对、测出来」。

5.5 Raft:term、角色、选举

Raft 把共识拆成三个相对独立的部分:leader 选举、日志复制、安全性。它用 term(任期)把所有决策串起来,用随机化选举超时打破平票,用日志匹配性质保证「相同索引 + 相同 term ⇒ 前缀相同」。

Raft 的三个角色与迁移条件 Follower 在选举超时后成为 Candidate;获得多数票成为 Leader;发现更高 term 退回 Follower。 Raft 的三个角色与迁移条件 Follower 被动接收 AppendEntries Candidate term+1,拉票 Leader 心跳 + 复制 选举超时 获得多数票 发现更高 term 选举超时/平票 term 增大 收到更高 term 安全规则 · 每个 term 最多投一票 · 只给日志不旧于自己的候选人投票 · 只提交当前 term 的日志项(旧 term 间接提交)
读图:Raft 把「什么时候可以投票」「什么时候可以提交」绑定到 term 和日志新旧。图中每条边都对应一个必须检查的条件,少一个就会出现双主或丢日志。

Raft 的每个节点在任意时刻处于三种角色之一:

角色行为关键状态
Follower被动接收 AppendEntries/RequestVote,重置选举计时器currentTerm、votedFor、日志
Candidate超时后自增 term,给自己投票,向其他节点拉票获得票数、选举超时
Leader向所有 follower 发心跳和日志,处理客户端请求nextIndex[]、matchIndex[]

term 是一个单调递增的逻辑时钟。每个 term 最多有一个 leader;节点收到更大的 term 时,立即更新 currentTerm 并退回 follower;收到更小的 term 时拒绝。term 不是墙钟,不能用来判断「谁先发生」;它只用来给选举和日志条目划分纪元。

一次选举的步骤:

  1. follower 在选举超时(例如 150–300 ms 的随机值)内没有收到 leader 的心跳,进入 candidate。
  2. currentTerm++,给自己投一票,重置选举计时器。
  3. 向所有节点发 RequestVote(term, candidateId, lastLogIndex, lastLogTerm)。
  4. 接收方在以下条件都满足时投票:term >= currentTerm;本 term 还没投给别人(或已经投给该候选人);候选人的日志不旧于自己。
  5. 候选人拿到多数票,成为 leader;否则等待新的随机超时,term++ 后重来。
一次 Raft 选举:term 1 平票,term 2 选出 leader 三个节点在 term 1 各自拉票导致平票;随机超时后节点 B 在 term 2 先发起并赢得多数。 一次 Raft 选举:term 1 平票,term 2 选出 leader 节点 A 节点 B 节点 C term 1:三方同时超时 RequestVote RequestVote RequestVote 都没有拿到多数票 → 超时,term+1 term 2:B 先超时(随机 150–300 ms) RequestVote(term=2) RequestVote(term=2) 投票 投票 B 获得 2/3 票 → Leader AppendEntries(term=2) AppendEntries(term=2) 关键:每个节点在 term 2 只投一票;B 的日志必须不旧于投票者。
读图:随机化选举超时是打破平票的关键;没有它,三个节点可能在同一时刻反复拉票,形成活锁。图中 term 2 的 B 先超时,所以它拿到了 A 和 C 的票。

图里 term 1 三个节点同时超时,各自拉票,谁都没拿到多数;term 2 节点 B 的随机超时先到,先发起选举,拿到 A 和 C 的票。随机化不是为了「公平」,而是为了打破对称性:如果所有节点的超时时间相同,它们会永远同时超时、同时拉票,形成活锁。

「日志不旧于自己」的判定规则是 Raft 安全性的关键:

候选人的 lastLogTerm 更大                 → 候选人的日志更新
lastLogTerm 相同,lastLogIndex 更大       → 候选人的日志更新
lastLogTerm 和 lastLogIndex 都相同        → 一样新,可以投票

注意比较顺序:先比 term,再比 index。不能只看 index,也不能只看 term。一个候选人可能在 index 100 处有 term 5 的条目,另一个在 index 90 处有 term 6 的条目;虽然后者更短,但它的最后 term 更大,按 Raft 的规则它「更新」。这看起来反直觉,但正是它保证了「已提交的条目一定在候选人日志里」。

选举限制(election restriction)的直觉:一条日志被提交,意味着它已经复制到多数派;任何新 leader 要当选,必须拿到多数票;两个多数派相交,至少有一个投票者拥有这条已提交日志;这个投票者只会把票投给「日志不旧于自己」的候选人,所以新 leader 一定拥有这条日志。这条推理是 Raft 安全性证明的主干,也是你实现时不能偷懒的地方。

看三个具体的投票判断(候选人最后日志 vs 投票者最后日志):

候选人投票者投吗原因
(term=4, index=10)(term=3, index=12)是候选人最后 term 更大,日志更短也算更新
(term=3, index=12)(term=3, index=12)是最后 term 和 index 都相同,一样新
(term=3, index=9)(term=3, index=12)否最后 term 相同,但候选人更短

注意第一行:如果只看 index,投票者会拒绝一个最后 term 更高的候选人,可能让一个缺少已提交条目的节点当选。先比 term 的规则,是为了让「更新的 term」压过「更长的旧日志」。

需要持久化的状态:

状态持久化?为什么
currentTerm必须重启后若 term 回退,可能在同一 term 投两次票
votedFor必须重启后忘记投过谁,会重复投票,可能选出两个 leader
日志条目必须已提交的条目不能丢;未提交的条目也要参与日志匹配
commitIndex可不持久化可从 leader/快照恢复;但不能应用未提交条目
lastApplied可不持久化重启后从快照/日志重放
nextIndex/matchIndex不需要leader 上任后重新初始化

持久化必须在回复 RPC 之前完成,尤其是 votedFor。一个常见 bug 是「先回 granted,再异步落盘」:节点在落盘前崩溃,重启后忘记已经投过票,又投给另一个候选人,同一 term 出现两个 leader。投票是一种承诺,承诺必须先写进稳定存储。

Raft 还有两个工程上常用的扩展,虽然不属于原始论文的核心:

  • PreVote:候选人在真正增加 term 之前,先问一圈「如果我竞选,你会投我吗」。如果收不到多数 positive,就不增加 term。它避免一个分区里的落后节点反复自增 term,恢复后把健康 leader 逼退。
  • CheckQuorum:leader 定期检查自己是否还能联系到多数派;如果联系不到,主动退位。它避免旧 leader 在分区期间继续以 leader 自居(5.9 会展开)。

这两个扩展不改变安全性,只改善活性/稳定性;但它们的实现同样需要小心,尤其是 PreVote 的 term 处理和 CheckQuorum 的超时选择。

选举参数没有「标准值」,但有几条硬约束:

参数常见起点约束
heartbeat interval50–100 ms必须小于 election timeout 的 1/3;建议留出 5–10 倍余量
election timeout150–300 ms大于 heartbeat 的 3 倍;必须大于 p99 网络 RTT + 调度抖动
随机化区间[timeout, 2×timeout] 内随机区间太窄,平票概率高;太宽,故障恢复慢
CheckQuorum 窗口接近 election timeout太短会把正常抖动当成分区,导致 leader 频繁退位

这些值要按部署环境调:跨可用区的 RTT 可能是同机房的 10 倍,容器 CPU 限流会让调度抖动变大。不要直接抄 etcd 的默认值;etcd 的默认值是为它的典型部署调的,不是为你的集群调的。调参之后要用分区/暂停测试验证,而不是只看「跑起来没报错」。

5.6 Raft:日志复制与提交规则

选举只决定「谁来写日志」;日志复制决定「写进去的条目什么时候算数」。Raft 的日志是一个从 1 开始连续编号的数组,每个条目包含 (index, term, command)。

Leader 通过 AppendEntries 复制日志:

字段含义作用
termleader 的任期follower 用来更新自己的 term
leaderIdleader 标识方便 follower 重定向客户端
prevLogIndex新条目前一条的索引一致性检查的锚点
prevLogTerm新条目前一条的 term一致性检查的锚点
entries[]要追加的条目可以为空(心跳)
leaderCommitleader 已知的提交索引follower 据此推进自己的 commitIndex

follower 的处理逻辑:

  1. 如果 term < currentTerm,拒绝。
  2. 如果 prevLogIndex 超过自己的日志末尾,拒绝。
  3. 如果 prevLogIndex 处的 term 不等于 prevLogTerm,拒绝。
  4. 删除自己日志中与 entries 冲突的后缀(相同 index 但不同 term 的条目及其之后的所有条目)。
  5. 追加新条目。
  6. 如果 leaderCommit > commitIndex,把 commitIndex 推进到 min(leaderCommit, 新日志末尾)。
  7. 按顺序应用索引不大于 commitIndex 的条目到状态机。

Leader 为每个 follower 维护 nextIndex 和 matchIndex。初始时 nextIndex = lastLogIndex + 1,matchIndex = 0。如果 follower 拒绝,leader 把 nextIndex 减一重试;生产实现通常用 follower 返回的冲突 term/索引做跳跃式回退,避免逐条回退。

回退示例:follower 的日志到 index 7,leader 的 nextIndex=8;follower 发现 prevLogIndex=7 的 term 不匹配,拒绝;leader 把 nextIndex 降到 7,再降到 6,直到找到共同前缀。逐条回退最坏要 O(日志长度) 次 RPC;优化是让 follower 返回「冲突 term 的第一个 index」,leader 一次跳过整个 term,把回退降到 O(term 数)。

日志匹配性质(Log Matching Property):如果两个日志在某个 index 和 term 上相同,那么它们从开头到该 index 的所有条目都相同。AppendEntries 的 prevLogIndex/prevLogTerm 检查就是归纳法的那一步:每次追加都要求前缀匹配,所以整个日志前缀匹配。

提交规则是 Raft 最容易写错的地方:

Leader 只能把 commitIndex 推进到 N,当且仅当:log[N].term == currentTerm,且多数派的 matchIndex >= N。

为什么必须加「当前 term」这个条件?因为一个旧 term 的条目即使已经出现在多数派上,也可能被后续 leader 覆盖。Raft 论文的 Figure 8 给出了反例,直觉如下:

  • 条目 E 在 term 2 被复制到 S1、S2、S3(多数派),但 leader 在提交前崩溃,E 没有被提交。
  • S4 在 term 3 当选。S4 的日志可能更短,但它的最后一条 term 是 3,比 S1/S2/S3 的最后 term 2 更大;根据选举限制,S1/S2/S3 会把票投给 S4。
  • S4 成为 leader 后,可以在相同 index 写入自己的条目,覆盖 E。

如果旧 leader 在 term 2 时看到「E 在多数派上」就宣布提交,客户端会认为 E 已经成功;但 S4 上任后 E 被覆盖,已确认的写丢失。「多数派有」不是提交的充分条件,必须有一个当前 term 的条目作为「屏障」。

当前 term 的条目一旦被多数派复制并提交,它之前的所有条目也会被间接提交:新 leader 拥有当前 term 的条目,而日志匹配性质保证它拥有该条目之前的全部前缀;选举限制保证未来 leader 也拥有这个前缀。所以规则不是「只提交当前 term 的条目,旧条目永远不提交」,而是「旧条目要通过当前 term 的提交来间接提交」。

下面是一段可运行的 Raft 日志/选举检查伪代码,覆盖两个最关键的判断:

def up_to_date(cand_term, cand_index, voter_term, voter_index):
    """候选人日志是否不旧于投票者:先比最后 term,再比最后 index。"""
    if cand_term != voter_term:
        return cand_term > voter_term
    return cand_index >= voter_index

def can_commit(log, match_index, n, current_term, cluster_size):
    """log 用 1-based 列表,log[i] = (term, command)。"""
    if log[n][0] != current_term:
        return False
    quorum = cluster_size // 2 + 1
    return sum(1 for m in match_index if m >= n) >= quorum

log = [None, (1, "set x=1"), (2, "set x=2"), (4, "set x=3")]
match = [3, 3, 3, 1, 1]          # 5 个节点,前三个已复制到 index 3
assert can_commit(log, match, 3, 4, 5) is True
assert can_commit(log, match, 2, 4, 5) is False   # 旧 term,不能凭多数派提交
assert up_to_date(3, 2, 2, 5) is True             # term 更大,日志更短也算更新
assert up_to_date(2, 6, 3, 2) is False            # term 更小,index 更长也不算更新
print("raft checks passed")

输出:

raft checks passed

第 2 条断言是重点:log[2] 的 term 是 2,current_term 是 4,即使多数派 match_index 已经覆盖 index 2,也不能把它标成已提交。实现里如果写成 if majority(match) >= n: commitIndex = n,就会在 Figure 8 的场景里丢数据。

日志复制还有一个容易被忽略的点:commit 不等于 apply。commitIndex 表示「这条日志已经被多数派确认,不会丢」;lastApplied 表示「状态机已经执行到哪」。读请求如果要读到自己刚写的值,必须等到 lastApplied >= commitIndex;否则会出现「写已提交、读却看不到」的假象。etcd 的 raftAppliedIndex 和 raftIndex 的差值,就是这类滞后的观测入口(5.10)。

除了「旧 term 提交」,还有两个常见的提交错误:

错误后果正确做法
跳过空洞提交:commitIndex 从 3 直接推到 5,但 index 4 还没选中状态机 apply 时缺少中间命令,状态与日志不一致只提交连续前缀;洞用 no-op 或等补齐
follower 应用未提交条目:收到 AppendEntries 就 apply,不等 leaderCommit未提交命令可能被后续 leader 覆盖,状态机却已经执行只 apply commitIndex 之前的条目,commitIndex 由 leaderCommit 推进

这两个错误在 happy path 上很难发现:日志看起来都在,重启后也能跑;只有在 leader 切换、分区恢复、快照恢复时,才会暴露成「状态和日志对不上」。

最后是日志截断。follower 在冲突时会删除未提交的后缀,这是安全的:未提交意味着它还没有被多数派确认,也没有向客户端承诺。但绝不能删除已提交的条目;如果实现把 commitIndex 之前的日志也截掉,重启后就无法恢复状态机。快照是唯一可以安全丢弃已提交前缀的机制(5.7)。

5.7 成员变更与快照

Raft 的 quorum 是相对于「当前配置」的。成员变更如果处理不好,会让两个不相交的多数派同时存在。

假设集群从 3 节点 {A, B, C} 直接切到 3 节点 {D, E, F}。旧配置的多数派可以是 {A, B},新配置的多数派可以是 {D, E};两个集合完全不相交。如果 A、B 还用旧配置选出了 leader,D、E 用新配置也选出了 leader,两个 leader 都可以拿到「多数票」,但它们互相不知道对方,日志会分叉。

即使新旧配置有重叠,直接切换也未必安全。考虑 {A, B, C} 切到 {B, C, D}:旧多数派 {A, B} 和新多数派 {C, D} 不相交。只要存在两个不相交的多数派,就可能出现两个 leader。成员变更的本质,是让「旧配置的多数派」和「新配置的多数派」在过渡期间发生重叠。

工程上有两种常见做法:

做法规则优点缺点
单步变更(single-server change)一次只增加或删除一个节点实现简单;旧/新多数派必然相交扩容慢;连续变更要串行
联合共识(joint consensus)先进入 C_old,new,任何决定需要同时满足旧、新两个多数派;再切到 C_new可以一次改多个节点;理论清晰实现复杂;过渡期可用性下降

单步变更为什么安全?当配置只差一个节点时,旧配置的任意多数派和新配置的任意多数派必然相交(对于 N≥3 的多数派系统)。例如 {A,B,C} → {A,B,C,D}:旧多数派至少 2 个,新多数派至少 3 个,在 4 个节点里不可能不相交。一次只改一个,就把「两个不相交多数派」的可能性排除了。

联合共识的流程:

  1. leader 收到配置变更请求,先把 C_old,new 写入日志并提交;从这一刻起,任何决定都需要 C_old 的多数派 和 C_new 的多数派同时同意。
  2. 在 C_old,new 提交后,再写入 C_new 并提交;之后只用 C_new 的多数派。
  3. 如果 leader 不在 C_new 中,它在提交 C_new 后主动退位;新 leader 从 C_new 中选出。

联合共识的关键是两个多数派同时满足,而不是「先旧后新」的两次独立决定。过渡期间,任何一方单独都无法做出决定,从而避免两个不相交的多数派各自选出 leader。

生产实现还要处理这些细节:

  • Learner:新节点先以非投票成员加入,追日志,追到接近 leader 后再提升为投票成员;否则新节点一加入就拖慢 quorum。
  • Leader 移除:如果 leader 把自己从配置里删掉,它要在 C_new 提交后停止发送心跳并退位,不能继续以 leader 自居。
  • 一次一个变更:即使支持联合共识,也不要在过渡未完成时再发起下一次变更;配置变更本身要串行。
  • 配置也在日志里:成员变更是一条日志条目,必须走共识、持久化、快照;不能只改本地配置文件。

etcd 把成员变更暴露成命令,理解这些命令的语义比背参数有用:

# 以 learner 身份加入,先追日志,不参与投票
etcdctl member add etcd-4 --peer-urls=http://10.0.0.14:2380 --learner

# MEMBER_ID 从 member list 取;追到接近 leader 后再提升为投票成员
etcdctl member promote "$MEMBER_ID"

# 删除成员;被删除的成员最终会收到自己的移除通知
etcdctl member remove "$MEMBER_ID"

这些命令只是入口;真正的安全性来自「配置变更是一条经过共识的日志」和「过渡期间 quorum 同时覆盖旧、新配置」。

快照解决另一个问题:日志不能无限增长。每条命令都保留,磁盘会满,重启重放会越来越慢。快照把「某一时刻的状态机状态」写成一个文件,同时记录 lastIncludedIndex 和 lastIncludedTerm,然后丢弃这个索引之前的日志。

快照字段含义注意
lastIncludedIndex快照覆盖的最后一条日志索引必须是已提交、已应用的索引
lastIncludedTerm该索引对应条目的 term用于 InstallSnapshot 的一致性检查
状态机数据应用后的完整状态必须与索引一致;不能包含未提交命令
配置快照时的成员配置恢复后不能丢配置
校验和数据完整性传输/落盘损坏时能发现

Follower 落后太多、leader 已经丢弃了它需要的日志时,leader 发送 InstallSnapshot。Follower 的处理:

  1. 如果快照的 lastIncludedIndex 不大于自己的 commitIndex,忽略(自己已经更新)。
  2. 如果自己的日志在 lastIncludedIndex 处有匹配的 term,保留该索引之后的日志后缀。
  3. 否则清空整个日志,把快照作为新的起点。
  4. 把状态机重置为快照状态,更新 commitIndex、lastApplied、lastIncludedIndex/Term。
  5. 继续接收后续日志。

快照最容易出的三个错:

  • 快照了未提交的状态:状态机里可能包含还没被多数派确认的命令;恢复后这些命令可能被覆盖,状态就错了。快照必须基于 lastApplied,而 lastApplied 只能追到 commitIndex。
  • 截断太多:leader 把 follower 还需要的前缀日志删了,导致 follower 只能全量传快照;网络和磁盘压力骤增。通常要保留一段日志尾部,或者根据最慢 follower 的进度决定。
  • 把快照当备份:快照是日志压缩,不是灾难恢复。它和日志在同一块磁盘上,磁盘损坏时一起丢;真正的备份要独立存储、定期校验、可恢复演练(第 3 章)。

崩溃恢复的顺序同样重要:

  1. 读取最新快照,校验校验和与 lastIncludedIndex/Term;
  2. 把状态机恢复到快照状态,设置 commitIndex = lastApplied = lastIncludedIndex;
  3. 从 lastIncludedIndex + 1 开始重放日志,直到日志末尾;
  4. 只有 lastApplied 追上 commitIndex 后,才对外服务读;
  5. 向 leader 确认自己的日志/任期,接受后续 AppendEntries。

如果第 2 步把 commitIndex 设得比 lastApplied 大,又在第 4 步之前服务读,就会读到「提交了但没 apply」的旧状态;如果第 3 步重放时又 apply 了快照里已有的命令,就会重复执行副作用。快照恢复和日志重放必须严格按索引拼接,不能靠「大概对齐」。

5.8 线性一致读:ReadIndex、lease read、follower read

Raft 只保证「日志的顺序」,不自动保证「读是线性一致的」。一个 leader 可能在分区后仍然自认为 leader;一个 follower 可能落后很多;一个本地读可能绕过共识,直接返回旧值。

先把三种读路径摆出来:

路径做法线性一致?额外 RTT关键假设
本地读直接读本地状态机否0无;可能读到任意旧值
ReadIndexleader 确认多数派仍承认自己,再读已应用状态是1 次心跳往返多数派可达;不需要时钟
Lease readleader 在租约内直接读是(在租约假设下)0时钟漂移有界、进程不会被长暂停
Follower readfollower 向 leader 要 readIndex,等 apply 后读是(正确实现)1–2 次 RTTfollower 能联系 leader;不能本地猜

5.8.1 ReadIndex:用一次心跳确认「我还是 leader」

ReadIndex 的步骤:

  1. leader 必须已经提交过一条当前 term 的日志(没有的话,先提交一条 no-op)。这是为了确保 commitIndex 包含当前 term 的屏障,避免读到旧 term 的提交边界。
  2. 向多数派发送心跳(或带空 entries 的 AppendEntries),确认自己仍然是 leader。
  3. 记录 readIndex = commitIndex。
  4. 等待 lastApplied >= readIndex。
  5. 从状态机读取,返回结果和对应的 revision/index。
客户端            Leader                     Follower 多数派
  │  read           │                              │
  ├────────────────▶│                              │
  │                 │  heartbeat / AppendEntries   │
  │                 ├─────────────────────────────▶│
  │                 │◀───────── ack ───────────────┤
  │                 │  多数派确认:仍是 leader       │
  │                 │  readIndex = commitIndex      │
  │                 │  等待 lastApplied >= readIndex│
  │◀── value@rev ───┤                              │

为什么不能省略第 2 步?因为 leader 可能在分区后仍然保留 leader 状态。如果它直接用本地 commitIndex 返回,客户端可能读到一个旧值,而新 leader 已经提交了更新的值。心跳确认的是「多数派还认我」,不是「我还活着」。

为什么不能省略第 4 步?因为 commitIndex 可能大于 lastApplied:日志已经多数派确认,但状态机还没执行到那里。直接读状态机会漏掉刚提交的写,违反「读己之写」。

把数字代进去:commitIndex=100、lastApplied=98,读请求到达时 readIndex=100。如果直接读状态机,读到的是 index 98 的状态,漏掉了 index 99、100 两条已经提交的写;客户端可能刚写完其中一条,于是「读己之写」失败。等待 lastApplied >= 100 之后,读到的才是包含这两条写的状态。

ReadIndex 不依赖时钟。它只依赖多数派可达和日志/状态机的顺序,因此在时钟不可信的环境里更安全。代价是每次线性一致读都要一次心跳往返;高并发读会成为 leader 的 CPU 和网络瓶颈,工程上会用批量 ReadIndex、读请求合并、lease read 来优化。

5.8.2 Lease read:用时间换 RTT

Lease read 的思路:leader 在选举时获得一个租约,租约期内它相信自己还是 leader,不需要每次读都问多数派。只要租约没有过期,且没有发生长时间 GC/进程暂停/时钟跳变,读就是线性一致的。

安全性条件可以写成:

lease_duration + max_clock_drift < election_timeout
且(检查租约 → 返回结果)之间的进程暂停 < 剩余租约

第一个条件保证:旧 leader 的租约过期之前,新 leader 不可能被选出(因为新 leader 需要等待 election_timeout)。第二个条件保证:旧 leader 不会在「已经检查完租约、但还没返回结果」的窗口里被暂停,醒来后继续用已经过期的身份服务读。

Lease read 的风险全在时钟和暂停上:

  • GC 停顿:leader 停顿 2 秒,租约 1 秒;醒来后它可能还不知道自己已经过期,继续读旧值。
  • 时钟跳变:NTP 把时钟往回拨,租约看起来还没到期;或者往前跳,租约提前失效。
  • 虚拟机/容器冻结:进程没有执行,但墙钟在走;恢复后租约状态不可信。
  • 网络分区 + 租约配置错误:如果租约长度接近甚至超过 election timeout,新 leader 可能在旧租约到期前被选出;这段时间内旧 leader 仍可能服务读。

因此,lease read 必须配合 CheckQuorum(联系不到多数派就退位)、单调时钟(用 CLOCK_MONOTONIC 而不是墙钟)、保守的租约长度(远小于 election timeout),并且要接受「极端暂停下可能违反线性一致」的边界。ReadIndex 是安全优先,lease read 是延迟优先;不要把 lease read 当成「免费的线性一致」。

5.8.3 Follower read:把读压力分散,但不能本地猜

Follower read 的目标是让 follower 分担读流量。正确做法不是「follower 直接读本地」,而是:

  1. follower 向 leader 请求一个 readIndex(leader 走 ReadIndex 流程,或返回当前 commitIndex 并附上租约/任期证明)。
  2. follower 等待自己的 lastApplied >= readIndex。
  3. follower 从本地状态机读取。

这样 follower 不需要成为 leader,也不需要参与投票,但它读到的状态至少覆盖了 leader 确认时的提交边界。代价是 follower 要能联系到 leader;如果 follower 被分区,它不能提供线性一致读,只能提供串行化/最终一致的本地读。

etcd 把这两种语义暴露得很直接:

# 默认:线性一致读,走 ReadIndex/leader 确认
etcdctl get /config/feature --consistency=l

# 显式降级:串行化读,可以读本地副本,可能陈旧
etcdctl get /config/feature --consistency=s

--consistency=s 不是「更快的一致性读」,而是「放弃线性一致,换取本地读」。它适合读一些对陈旧不敏感的配置;如果用它读「刚写完的开关」,就可能读到旧值。把 s 当成默认,是把线性一致悄悄降级成最终一致。

三种路径的选择建议:

场景推荐路径理由
选主、锁、配置开关、余额ReadIndex不能容忍旧值;时钟不可信
高频只读、可容忍秒级陈旧follower read + 串行化分摊 leader 压力;接受陈旧
低延迟、时钟可控、有 CheckQuorumlease read省 RTT;必须做时钟/暂停防护
调试、离线分析本地读/串行化明确知道自己在读旧数据

最后记住一条:共识协议不自动提供线性一致读。Raft 论文专门用一节讲客户端交互与读路径,etcd 也把 --consistency 做成显式参数。把「写走 Raft」当成「读也自动线性一致」,是本章最常见的误解之一。

5.9 失败模式:分区后的旧主

Raft 能防止「两个 leader 同时提交不同的日志」,但这不等于「旧 leader 一被分区就立刻安静」。旧 leader 可能仍然监听端口、仍然返回读结果、仍然接受客户端的写请求——只是它无法让写提交。「接受」和「提交」是两件事;很多事故就发生在这两个词的缝隙里。

假设 5 节点集群 A、B、C、D、E,A 是 term 7 的 leader,已经提交到 index 100。某一刻网络分成 {A} 和 {B, C, D, E} 两边。B、C、D、E 在 election timeout 后选举 C 为 term 8 的 leader,继续提交新日志。A 这边没有多数派,既不能提交新日志,也不会立刻知道自己已经过时。

时间   A(旧 leader,term 7)                    B/C/D/E(term 8)                  客户端
──────────────────────────────────────────────────────────────────────────────────────────────
t0     leader,commitIndex=100                  follower                            写 W → A
t1     ── 网络分区:{A} | {B,C,D,E} ──           election timeout 倒计时              等待 W
t2     append W@101,拿不到多数派,不能提交       C 拿到 3 票,term=8,成为 leader      W 超时 → unknown
t3     lease 到期,ReadIndex 失败;               C 提交自己的 X@101                   重试 W → 路由到 C
       若实现直接本地读,可能返回旧值
t4     收到 term=8 的 AppendEntries              复制 X 给 B/D/E                      重试命中 C,提交 W'
t5     退位为 follower,截断未提交的 101         commitIndex 前进到 102               成功;但旧 W 可能已执行

这张图里有几条不同的时间线,必须分开看:

时间线旧 leader A 的状态客户端看到什么风险
写路径接受请求、追加日志,但拿不到多数派超时/unknown,不是成功客户端重试到新 leader,可能重复提案
ReadIndex 读心跳拿不到多数派,ReadIndex 失败读失败或超时不会返回陈旧值(正确实现)
Lease read(正确实现)租约到期后拒绝读,等退位/重新确认读失败或超时不会返回陈旧值;代价是可用性
Lease read(假设被破坏)时钟跳变/暂停导致租约判断错误旧值违反线性一致;需要 CheckQuorum/单调时钟
串行化本地读直接读本地状态机旧值(设计如此)被误当成线性一致读
外部副作用旧 leader 可能仍在执行定时任务/写下游下游收到旧 epoch 的写需要 fencing,不能只靠「我是 leader」

同一条写,在分区前后有三个不同的结局:

  1. 分区前已经提交:它已经复制到多数派,新 leader 一定包含它(选举限制);客户端收到成功,数据不会丢。
  2. 分区后打到旧 leader:旧 leader 只能追加、不能提交,客户端收到超时/unknown;这条写可能被新 leader 覆盖,也可能在新 leader 上以新的提案重新提交。
  3. 分区后打到新 leader:新 leader 正常提交;如果客户端同时重试到旧 leader,旧 leader 的那条未提交提案最终会被截断。

危险的不是第 1 种,也不是第 3 种,而是第 2 种里的「unknown」被客户端当成失败,换一个新请求 ID 重发。共识层把「未提交」处理得很干净,客户端层却可能把它变成两次副作用。

注意第一行:正确的 Raft 旧 leader 不会确认一条没有多数派的写。它会把请求挂起、超时,或者返回「不是 leader」;客户端得到的是 unknown,而不是成功。真正危险的是读路径和外部副作用:

  • 旧 leader 的 lease read 在租约到期后必须拒绝;如果实现用墙钟、不检查暂停,或把租约设得比 election timeout 还长,就可能在租约实际失效后仍返回旧值。
  • 旧 leader 可能还在跑「只有 leader 才做」的后台任务:清理、对账、向下游发指令。这些动作不经过 Raft 日志,新 leader 也在做同样的事,于是同一件事被执行两次。
  • 客户端/SDK 可能缓存了旧 leader 地址,重试时仍然打到 A;A 接受连接、挂起请求,放大超时。
  • 监控上 A 可能仍然 has_leader=1(它自己认为有 leader),直到它收到更高 term 或 CheckQuorum 触发退位。

所以「分区后的旧主」要分三层处理:

  1. 提交层:写必须拿到多数派确认才回复成功;旧 leader 不能凭本地日志回复。Raft 的提交规则已经覆盖这一层,前提是实现正确(5.6)。
  2. 读层:线性一致读必须走 ReadIndex,或者使用有严格时钟/暂停假设的 lease read;串行化本地读要显式标注「可能陈旧」。
  3. 副作用层:旧 leader 对下游资源的操作必须带 fencing token(term/epoch/lease ID),资源端拒绝更小的 token;否则「旧主还在写」无法靠共识协议本身阻止。
旧主的三种「还以为自己是主」
  1. 真 leader,暂时失联:它还能联系多数派,只是某个 follower 不可达;它不是旧主,读路径仍然有效。
  2. 少数派 leader,尚未退位:它拿不到多数派,写不能提交;ReadIndex 会失败,但 lease read 可能还在窗口内。
  3. 已退位但客户端还在打它:它自己知道不是 leader,但客户端路由没更新;它会返回重定向/错误,客户端必须重新发现 leader。

工程上的止损手段:

  • CheckQuorum:leader 在 election timeout 内联系不到多数派就主动退位,缩短「自以为主」的窗口。
  • PreVote:分区中的节点不会因为反复超时把 term 抬高,恢复后不会逼退健康 leader。
  • ReadIndex:读之前确认多数派仍承认自己;不依赖时钟。
  • Lease 保守化:租约长度远小于 election timeout,使用单调时钟,限制 GC 暂停;接受「极端暂停可能不安全」的边界。
  • Fencing:把 term/epoch 传给下游,资源端记录最大 token;旧 token 的写一律拒绝(第 9 章)。
  • 客户端幂等:重试复用 request id,状态机去重(5.12);否则旧主/新主各执行一次。

客户端路由也值得单独说一句。很多 SDK 会缓存 leader 地址,或者在配置里写死一个 endpoint;分区后它可能一直打到旧 leader,看到超时,然后重试同一个地址。正确的客户端行为是:

  • 收到「不是 leader」/重定向时,刷新 leader 地址,而不是盲目重试;
  • 收到超时/unknown 时,复用请求 ID,换一个 endpoint 重试;
  • 不要在客户端用「上次成功的那台机器」做粘性路由,除非它能接受陈旧读;
  • 把 leader 发现和业务重试分开:发现 leader 可以频繁,业务重试要有预算和退避。

服务端把旧主处理得再好,客户端一直打旧主也会把可用性问题放大。 共识协议不负责客户端路由,这部分要 SDK 和运维一起设计。

一个常见的错误结论是「Raft 已经避免了双主,所以分区后旧主无所谓」。Raft 避免的是两个 leader 同时提交不同日志;它没有避免旧 leader 返回陈旧读、没有避免旧 leader 的外部副作用、也没有避免客户端重试造成的重复提案。共识协议的安全性边界止于状态机;状态机之外的副作用要另外设计。

5.10 观测:etcd/raft 指标

排查共识问题,第一步不是看日志,而是确认「现在有没有 leader、term 是否一致、日志有没有追上」。etcd 把这些信息暴露在 endpoint status 和 /metrics 里。

先看每个 endpoint 的 raft 状态:

export ETCDCTL_API=3
etcdctl --endpoints=https://10.0.0.11:2379,https://10.0.0.12:2379,https://10.0.0.13:2379 \
  --cacert=/etc/etcd/ca.crt \
  --cert=/etc/etcd/client.crt \
  --key=/etc/etcd/client.key \
  endpoint status --cluster -w table

示意输出(版本、数值不是生产数据):

+----------------+----------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
|    ENDPOINT    |    ID    | VERSION | DB SIZE | IS LEADER | IS LEARNER | RAFT TERM | RAFT INDEX | RAFT APPLIED INDEX | ERRORS |
+----------------+----------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
| 10.0.0.11:2379 | 8e9e05c5 | 3.5.15  |   25 MB |      true |      false |        17 |      98231 |              98231 |        |
| 10.0.0.12:2379 | 9f8c1a2b | 3.5.15  |   24 MB |     false |      false |        17 |      98231 |              98229 |        |
| 10.0.0.13:2379 | a1b2c3d4 | 3.5.15  |   24 MB |     false |      false |        17 |      98231 |              98190 |        |
+----------------+----------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+

怎么读这张表:

列正常异常信号先查什么
IS LEADER恰好一个 true全 false:没有 leader;同一 term 多个 true:实现/观测异常网络分区、election timeout、进程停顿
RAFT TERM所有成员相同或短暂差 1某个成员 term 明显更高/更低该成员是否分区、是否刚重启、是否被隔离后 term 膨胀
RAFT INDEXleader 最高,follower 逐步追上follower 长期落后网络、磁盘、apply 速度、快照传输
RAFT APPLIED INDEX与 RAFT INDEX 差距小且不增长差距持续扩大状态机 apply 慢、磁盘慢、CPU 饱和
ERRORS空有错误文本该 endpoint 的日志/磁盘/网络
DB SIZE低于配额,增长平稳接近配额或突增compaction、写入量、告警阈值

再看指标。etcd_server_* 把「有没有 leader、leader 换了多少次、提案卡在哪」变成时间序列:

curl -s http://10.0.0.11:2379/metrics \
  | grep -E '^etcd_server_(has_leader|leader_changes_seen_total|proposals_(committed|applied|pending|failed)_total) '

示意输出:

etcd_server_has_leader 1
etcd_server_leader_changes_seen_total 2
etcd_server_proposals_committed_total 19342
etcd_server_proposals_applied_total 19340
etcd_server_proposals_pending 0
etcd_server_proposals_failed_total 0
指标含义正常/异常边界读法
etcd_server_has_leader本节点是否认为集群有 leader正常为 1;持续 0 超过一个 election timeout 为异常0 时写一定失败,先看网络和选举日志
etcd_server_leader_changes_seen_total见过的 leader 变更次数启动/维护时增长正常;10 分钟内多次增长异常突增说明选举抖动、租约过短、网络不稳
etcd_server_proposals_pending已提交但未完成的提案数接近 0;持续 > 0 异常卡在 quorum 或磁盘 fsync
committed - applied已提交但未应用的提案差小且不增长;持续扩大异常apply 慢、状态机阻塞、磁盘瓶颈
etcd_server_proposals_failed_total失败提案累计平稳;阶跃增长异常leader 切换、quorum 丢失、请求被取消
etcd_disk_wal_fsync_duration_secondsWAL fsync 延迟直方图与基线比;p99 持续升高异常磁盘 IO、fsync 争用、云盘抖动
etcd_network_peer_round_trip_time_secondspeer RTT与基线比;持续升高异常跨机房网络、拥塞、丢包

如果集群接了 Prometheus,下面几条查询可以直接做成告警:

# 1. 当前节点认为没有 leader
etcd_server_has_leader == 0

# 2. leader 变化过快(10 分钟内至少一次)
rate(etcd_server_leader_changes_seen_total[10m]) > 0

# 3. 有提案卡住
etcd_server_proposals_pending > 0

# 4. 提案失败在增长
rate(etcd_server_proposals_failed_total[5m]) > 0

告警要带上前提:单节点 has_leader=0 可能只是它自己与 leader 失联,其他节点仍然健康;leader_changes 的阈值要结合发布/维护窗口;proposals_pending 的绝对值受写入速率影响,最好看「持续时间 + 是否增长」。不要用一个固定阈值覆盖所有集群。

单看一个瞬时值不够,至少要看「趋势 + 对比」:

  • has_leader=1 但 leader_changes 在 10 分钟内涨了 5 次,说明 leader 不稳定,不是健康。
  • RAFT INDEX 一致但 APPLIED INDEX 差 5000,说明共识没问题,状态机 apply 有问题;此时读可能超时,写可能堆积。
  • proposals_pending 持续大于 0,先看 WAL fsync 和 peer RTT:如果是磁盘,pending 会随时间累积;如果是网络,通常伴随 term/leader 变化。
  • proposals_failed_total 阶跃增长,通常对应一次 leader 切换;把它和 leader_changes 对齐看,能区分「客户端取消」和「quorum 丢失」。

把几个指标组合起来,能快速缩小范围:

组合更可能的原因下一步
has_leader=0 + 所有节点 term 相同网络分区/选举超时,没人拿到多数派查 peer 连通性、election timeout、CPU 停顿
has_leader=0 + 某节点 term 明显更高该节点反复竞选,term 膨胀查 PreVote、网络抖动、日志落后
committed - applied 增长 + pending 正常状态机 apply 慢(磁盘/CPU/锁)看 apply 日志、slow_apply、磁盘 util
pending 增长 + WAL fsync p99 高磁盘 fsync 成瓶颈,提案卡在落盘看云盘 IOPS/吞吐、fsync 争用
leader_changes 增长 + peer RTT 高网络不稳导致心跳超时、频繁选举查跨机房链路、丢包、MTU
proposals_failed 阶跃 + leader 变化leader 切换导致在途提案失败对齐发布时间、节点重启、分区事件

这张表不是诊断的终点,而是「先看哪一列」的起点。真正的根因往往要回到日志和 trace:谁在什么时候失去了 quorum、谁先自增了 term、哪条提案被重复提交。

需要更细的 term/index 时,用 JSON 输出配合 jq:

etcdctl endpoint status -w json \
  | jq '{term: .raftTerm, index: .raftIndex, applied: .raftAppliedIndex}'
{"term": 17, "index": 98231, "applied": 98231}
阈值不要照抄

上面的「10 分钟」「5000」只是示意边界。生产阈值要来自自己的基线:集群规模、磁盘类型、写入速率、election timeout 都不同。能解释趋势,比记住一个数字更重要。

观测的最终目的是回答三个问题:现在有没有唯一的 leader?日志有没有追上?读路径走的是哪一条? 如果这三个问题答不上来,再看更多指标也只是堆信息。

5.11 真实案例:Jepsen 对 etcd 3.4.3 的分析

共识系统的结论对版本、配置、故障注入方式极其敏感。本节引用可核验的 Jepsen etcd 3.4.3 报告(2020-01-30),并把「KV 正确」与「上层锁不安全」分开说。

可核验的公开事实(Jepsen: etcd 3.4.3,2020-01-30):

项目公开内容
系统etcd 3.4.3,基于 Raft 的 KV 存储
集群5 节点 Debian Stretch
故障注入网络分区(隔离单节点、多数/少数分区、非传递分区)、崩溃与进程暂停、定向杀 leader、数百秒级时钟偏移、动态成员变更
工作负载register(读/写/CAS)、set、append 事务、lock、watch
KV 结论读、写、多键事务表现为严格可串行化(strict serializable);serializable 读允许陈旧,符合文档
watch 结论单 key 的 watch 按顺序交付每一次变更(在 compaction 不破坏历史的前提下)
lock 结论etcd 锁不保证互斥;等待锁的路径存在 lease 有效性未复查的问题,已在后续修复

这张表里最值得共识章关心的是「故障注入」和「KV 结论」两行:在分区、进程暂停、时钟偏移、成员变更下,KV 操作仍然严格可串行化。这说明 Raft 的提交规则、ReadIndex 读路径和成员变更在 etcd 的实现里经受住了故障注入;它不是「论文里的协议」,而是可被工程验证的。

但同一份报告也给出了一个反例:共识核心正确,不代表上层 API 正确。锁的语义建立在 lease 上:客户端持有 lease,lease 过期后锁应释放;如果客户端在持有锁时被暂停,lease 过期,另一个客户端拿到锁,原客户端恢复后仍以为自己持有锁,就可能出现两个持有者。报告指出等待锁的路径没有在唤醒后复查 lease 有效性,放大了这个问题;即使修掉这个 bug,分布式锁在异步系统里仍无法保证互斥,需要 fencing token(第 9 章)。

把公开结论映射成一条工程失败链(示意,不是报告原文的逐字复述):

1. 客户端 A 获取锁,lease TTL=2s,开始处理
2. A 进程被 GC/虚拟机暂停 3s;lease 过期,锁被释放
3. 客户端 B 获取同一把锁,开始处理同一资源
4. A 恢复,仍以为自己持有锁,继续写
5. 资源端没有 fencing 校验,两次写都成功 → 丢失更新/交错

这条链和 5.9 的旧主问题是同构的:「我以为我还有权」和「我实际还有权」之间,隔着租约、时钟和一次 quorum 确认。 Raft 能保证日志的顺序,但不能保证一个基于 lease 的锁在进程暂停后仍然有效;后者需要资源端的 fencing,或者干脆把操作放进同一个共识状态机里做条件写。

四条教训:

  1. 线性一致读要作为故障注入的必测项,不能只测「集群健康时 get/put 对不对」。分区、暂停、时钟偏移下读到的值,才是用户会遇到的。
  2. 每个 API 单独证明。KV 的线性一致不等于 lock 的互斥,不等于 watch 的 exactly-once,也不等于 lease 的活性;共识是底座,不是万能证明。
  3. 租约/锁必须有 fencing 或资源端校验。不要把「持有锁」当成「下游一定会接受我的写」;下游要能拒绝旧 token。
  4. 用可复现的历史检查器。Jepsen 的价值不只是「找到了 bug」,而是把并发历史记录下来,用模型检查器判定它是否满足一致性;这套方法可以搬到自己系统的测试里。

为什么把这个案例放在共识章,而不是锁章?因为它同时展示了「共识做对了什么」和「共识不能保证什么」:

  • 做对了:在分区、暂停、时钟偏移、成员变更下,KV 的读写仍然是严格可串行化;这说明 Raft 的提交规则和 ReadIndex 读路径在真实实现里是有效的。
  • 不能保证:锁是建立在 lease 之上的另一个协议;lease 过期、进程暂停、缺少 fencing,都会让「持有锁」变成一句空话。共识日志只能保证锁的状态变更按顺序发生,不能保证锁的持有者在暂停后仍然有资格操作外部资源。

这也是本章反复强调的边界:共识是底座,不是全部。 读路径、租约、去重、fencing,每一层都要单独设计和测试;把它们都寄希望于「Raft 会处理」,就是把这些 bug 留到生产。

链接:

事实边界

本节只引用公开页面上的结论、测试维度和版本信息;不把 3.4.3 的逐项结果倒推成 3.3 的结论,也不复述报告中的具体失败率数字。若需要版本级结论,以原文和对应版本的 release notes 为准。

5.12 重试导致重复副作用:提案重试与状态机去重

共识解决的是「顺序」,不是「身份」。同一个客户端意图,如果被提交两次,Raft 会老老实实地把它排进日志两次,并按顺序执行两次。日志没有重复的概念,只有命令;去重必须在命令里带上身份,并在状态机里完成。

场景:库存服务把 reserve(order-42, sku-7, 1) 包装成一条 Raft 提案。leader 提交了 index 101,但在返回响应前网络断开。客户端超时,重试到新 leader;新 leader 不认识这个请求,把它作为一条新提案写到 index 145。两条日志都提交、都 apply:

index 101: reserve(order-42, sku-7, 1)   → stock 10 → 9
index 145: reserve(order-42, sku-7, 1)   → stock 9  → 8

共识完全正确:所有副本都按 101、145 的顺序执行,状态一致。但业务错了:一次预留扣了两次库存。这正是「共识不保证 exactly-once」的含义。

为什么会这样?

  • 客户端重试是一个新的提案;Raft 只看到「两条命令」,不知道它们来自同一个意图。
  • 如果重试打到旧 leader,旧 leader 可能把同一条命令追加到本地日志(未提交),恢复后被覆盖或再次提交;行为取决于实现。
  • 如果命令本身是 set stock=9(幂等赋值),重复执行无害;如果是 decrement、append、transfer,重复执行就有副作用。
  • 在网关层做去重不够:网关重启、leader 切换、请求绕过网关,都会丢失去重状态。

正确的做法是把请求身份写进命令,并在状态机 apply 时原子地去重:

class Inventory:
    def __init__(self):
        self.stock = {"sku-7": 10}
        self.seen = {}  # (client_id, seq) -> (fingerprint, result, applied_index)

    def apply(self, index, cmd):
        key = (cmd["client_id"], cmd["seq"])
        if key in self.seen:
            fp, result, _ = self.seen[key]
            if fp != cmd["fingerprint"]:
                return "conflict"            # 同键不同参:拒绝,不返回旧结果
            return result                    # 同一个意图的重复提案:返回原结果
        if cmd["op"] == "reserve":
            if self.stock[cmd["sku"]] < cmd["qty"]:
                result = "rejected"
            else:
                self.stock[cmd["sku"]] -= cmd["qty"]
                result = "reserved"
        else:
            raise ValueError(f"unknown op: {cmd['op']}")
        self.seen[key] = (cmd["fingerprint"], result, index)  # 与副作用同一次 apply
        return result

inv = Inventory()
cmd = {"client_id": "c1", "seq": 7, "fingerprint": "fp-1",
       "op": "reserve", "sku": "sku-7", "qty": 1}
print(inv.apply(101, cmd), inv.stock["sku-7"])
print(inv.apply(145, cmd), inv.stock["sku-7"])
print(inv.apply(146, {**cmd, "qty": 2, "fingerprint": "fp-2"}), inv.stock["sku-7"])

输出:

reserved 9
reserved 9
conflict 9

第二次 apply 命中 seen,直接返回第一次的结果,库存仍然是 9。第三次 apply 用了同一个 (client_id, seq) 但 qty 不同,返回 conflict,库存保持 9;如果直接返回旧结果,客户端会以为 qty=2 也生效了。注意三个细节:

  1. 去重表和副作用必须在同一次 apply 中更新。如果先扣库存、再异步写 seen,中间崩溃会留下「扣了但没记住」的状态;恢复后重放又会扣一次。状态机 apply 应该是单线程、确定性的,去重表和业务状态一起进入快照。
  2. client_id/seq 必须由客户端生成,并在所有重试中复用。服务端生成的自增 ID 每次都会变,无法去重(第 1 章的逻辑请求/物理尝试)。
  3. seq 要持久化在客户端。客户端进程重启后如果从 0 重新计数,可能和旧请求撞车;用持久化的会话 epoch + 单调序号,或者在重启时换一个新的 client_id。

去重状态放在哪一层,决定了它能活多久:

位置leader 切换后还在?进程重启后还在?与副作用原子?结论
网关内存否否否只适合挡瞬时重试,不能作为正确性保证
leader 内存否否否leader 一换就丢,最危险的「假去重」
状态机内存 + 快照是是(从快照恢复)是(同一次 apply)正确方向
外部去重表(Redis/DB)视实现视实现否(跨系统双写)需要额外事务/对账,容易不一致

去重表会无限增长,必须回收:

  • 按 client_id 保留最近的 seq 窗口(例如每个客户端保留最后 N 个),旧请求直接拒绝;
  • 或者按 TTL 回收,但 TTL 必须大于客户端最大重试窗口 + 时钟偏移;否则客户端在 TTL 之后重试,去重表已经忘了,副作用会再来一次;
  • 快照必须包含去重表,否则恢复后去重能力退化;
  • 对「同 client_id + 同 seq 但参数不同」的请求,返回冲突,而不是返回旧结果(和第 1 章的指纹规则一致)。

去重 TTL 可以这样估:

客户端最大重试窗口 = 首次发送到最后一次重试的墙钟时间(例如 10 分钟)
安全余量           = 最大时钟偏移 + 网络延迟上界(例如 30 秒)
去重 TTL           >= 10 分钟 + 30 秒

如果客户端会在离线后恢复并重放(移动端、边缘设备),重试窗口可能是几小时甚至几天;这时 TTL 要按「最长离线时间」算,或者改用「每个客户端保留最后 N 个 seq」的窗口,而不是纯 TTL。TTL 太短,去重表先忘,副作用后到;TTL 太长,内存/快照膨胀。 这是一个明确的权衡,不要拍一个「1 小时」了事。

提案 ID 不是请求 ID

有些 Raft 实现会给提案分配一个 leader 本地的 proposal ID,用于匹配响应;这个 ID 在 leader 切换后通常就失效了。它不能替代客户端请求 ID。请求 ID 必须由客户端生成、跨 leader 稳定、写进日志命令,才能被状态机识别为同一个意图。

最后记住:exactly-once 的副作用 = at-least-once 的投递 + 状态机去重 + 幂等的应用语义。共识提供的是「所有副本看到同一个顺序」,不是「同一个意图只出现一次」。把这两件事混为一谈,就会写出「日志很一致,库存却多扣了」的系统。

5.13 自己实现 Raft 的五个坑

实现 Raft 的常见体验是:happy path 很快就能跑通,选主、复制、重启恢复看起来都正常;然后在分区、崩溃、快照、成员变更的组合下,出现一个难以复现的丢日志或双 leader。下面五个坑覆盖了大部分「最后 10%」的安全问题。

#坑典型症状最小测试
1提交旧 term 的日志已确认的写在新 leader 上消失构造 Figure 8 的 term/日志组合
2投票/term 未持久化同一 term 两个 leader在回复 RPC 前 kill -9
3快照与日志截断重启后状态回退/日志空洞慢 follower + 大快照 + 崩溃
4成员变更变更期间双 leader/不可用变更中分区、连续变更
5读路径分区后读到旧值/读己之写失败旧 leader + lease/ReadIndex 读

5.13.1 提交旧 term 的日志

错误写法:leader 看到多数派的 matchIndex >= N,就把 commitIndex 推到 N,不检查 log[N].term。这在正常路径下没问题,但在 Figure 8 的场景里会把一个「多数派有、但可能被覆盖」的旧 term 条目宣布为已提交。

正确写法:只有 log[N].term == currentTerm 时才推进 commitIndex;旧 term 条目通过当前 term 的提交间接提交(5.6)。测试时不要只测「leader 正常复制」,要构造「旧 term 条目 + 新 leader + 覆盖」的日志序列,断言已提交的条目不会被截断。

5.13.2 投票和 term 没落盘

错误写法:收到 RequestVote 后先更新内存中的 currentTerm/votedFor,回复 granted,然后异步刷盘。节点在刷盘前崩溃,重启后忘记已经投过票,又投给另一个候选人;同一 term 出现两个 leader。

正确写法:在回复任何 RPC 之前,把 currentTerm、votedFor、新增日志条目 fsync 到稳定存储。批量提交可以优化吞吐,但不能改变「先持久化、后回复」的顺序。测试时在持久化前后注入 kill -9,检查重启后的投票行为。

5.13.3 快照与日志截断

常见错误有三个:快照了未提交的状态;截断了 follower 还需要的前缀;InstallSnapshot 之后没有正确重置 lastApplied/commitIndex,导致重复 apply 或状态机回退。

正确做法:快照只基于 lastApplied;保留一段日志尾部(或按最慢 follower 的进度决定);InstallSnapshot 时用 lastIncludedIndex/Term 做一致性检查,保留匹配的后缀,否则清空日志;快照必须包含成员配置和去重表。测试时用一个慢 follower 触发快照传输,再在传输过程中崩溃 leader/follower。

5.13.4 成员变更

错误写法:直接把配置从 {A,B,C} 改成 {A,B,C,D,E},不做联合共识/单步变更;或者在变更未完成时再发起下一次变更;或者新节点还没追平日志就参与投票。

正确做法:单步变更一次一个,或联合共识同时满足旧/新多数派;新节点先做 learner 追日志;leader 被移出配置时在 C_new 提交后退位;配置变更写进日志并进入快照。测试时在变更过程中分区,检查是否可能出现两个不相交的多数派。

5.13.5 读路径

错误写法:leader 直接读本地状态机;lease read 不检查时钟/暂停;ReadIndex 在确认多数派之前就取 commitIndex;或者不等待 lastApplied >= readIndex 就返回。

正确做法:线性一致读走 ReadIndex(确认多数派 + 等待 apply),或使用有严格时钟/暂停假设的 lease read;串行化读显式标注可能陈旧;follower read 必须向 leader 要 readIndex,不能本地猜。测试时把旧 leader 隔离,分别用三种读路径读同一个 key,断言只有 ReadIndex 路径不会返回旧值。

把五个坑落成一张最小测试矩阵:

故障注入方式断言
崩溃时机在持久化前/后 kill -9重启后不重复投票、不丢已提交日志
网络分区隔离 leader / 隔离少数派少数派不能提交;多数派选出新 leader;旧 leader 退位
慢 follower暂停一个 follower 数分钟leader 不被拖慢;follower 通过日志/快照追上
快照传输让 follower 落后于 leader 的最旧日志InstallSnapshot 成功后状态一致,不重复 apply
成员变更变更中分区、连续变更不出现两个不相交多数派;leader 移除后正常退位
读路径隔离旧 leader 后读ReadIndex 失败;lease read 不返回旧值;串行化读标注陈旧

测试的目标不是「跑通 happy path」,而是在每一个可能崩溃/分区/暂停的点,检查安全性质是否仍然成立。这也是 Jepsen 那类工具的价值:它不证明你对,但它能在你出错时把历史留下来。

五个坑之外

还有几个常见问题:nextIndex 回退逐条减一导致恢复慢;日志匹配检查漏掉 prevLogTerm;把 commitIndex 和 lastApplied 混用;PreVote 的 term 处理错误;CheckQuorum 超时太短导致正常抖动也退位。它们大多会表现为「性能差」或「选举抖动」,而不是直接丢数据,但同样值得测试。

5.14 本章陷阱清单

12 条高频错误
  1. 把共识当成「所有节点都同意」:共识只要求多数派参与决策;要求全体一致会让单机故障阻塞整个系统。
  2. 把「多数派有日志」当成「已提交」:提交是协议动作;旧 term 条目即使多数派有,也可能被后续 leader 覆盖(5.6)。
  3. 用 term 大小判断日志新旧:先比最后 term,再比最后 index;只看 term 或只看 index 都会选错 leader。
  4. 投票后不落盘就回复:重启后重复投票,同一 term 可能出现两个 leader(5.13)。
  5. 选举超时太小或没有随机化:频繁选举、平票、活锁;超时必须远大于 RTT,并加随机抖动。
  6. 以为写走 Raft 读就自动线性一致:本地读/串行化读可能陈旧;线性一致读要走 ReadIndex 或受约束的 lease read(5.8)。
  7. lease read 不看时钟和暂停:GC 停顿、时钟跳变会让旧 leader 在租约过期后继续服务读。
  8. 成员变更直接切换:旧/新多数派可能不相交,出现双 leader;要用单步变更或联合共识(5.7)。
  9. 快照未提交状态/截断太多:恢复后状态回退,或慢 follower 只能全量传快照;快照必须基于 lastApplied,并保留必要日志尾部。
  10. 重试时换 request id:每次重试生成新 ID,状态机无法去重;请求 ID 必须由客户端生成并在所有重试中复用(5.12)。
  11. 去重只放在网关/leader 内存:leader 切换或进程重启后去重状态丢失,重复提案照样执行。
  12. 只看 has_leader 不看 term/apply lag:有 leader 不等于健康;leader 抖动、apply 滞后、磁盘 fsync 慢都会表现为「有 leader 但不可用」。

这 12 条里,第 2、4、10 条是「会直接丢数据/重复副作用」的硬错误;第 6、7、9 条是「边界条件」错误,平时不出现,故障时一起出现;第 12 条是观测错误,它不会直接造成事故,但会让你在事故里找错方向。

5.15 本章出口检查

检查项达标线没达标去哪
复述共识三性质能说出 agreement/validity/termination,并区分安全/活性5.1
解释 FLP能说出「完全异步 + 一个崩溃 + 确定性 + 必须终止」四个限定词5.2
计算 quorum给定 N 能算多数派和容忍数;能解释相交性5.2
画出 Paxos 两阶段四个消息、acceptor 两条规则、proposer 的「选最大已接受 ballot」5.3
解释 Paxos 活锁能画出双 proposer 互锁,并说出稳定 leader/随机退避5.4
解释 Raft 选举term、角色、随机超时、投票条件、日志新旧比较5.5
判断提交规则能说出「当前 term + 多数派 matchIndex」,并解释 Figure 85.6
设计成员变更能说出单步变更/联合共识的适用场景和风险5.7
设计读路径能对比 ReadIndex/lease read/follower read 的假设和代价5.8
处理旧主分区能区分「接受/提交/读/外部副作用」四层,并给出止损5.9
读 etcd 指标能解释 has_leader、leader_changes、RAFT TERM、apply gap5.10
做状态机去重能写出 (client_id, seq) 去重表,并说明快照/TTL5.12

如果你要动手做第 13 章的项目二(小型复制 KV),本章的检查顺序是:先实现选举和日志复制,再用「当前 term 提交」规则做提交,然后加 ReadIndex 读,最后加状态机去重和快照。不要先做 lease read:它看起来省了一次 RTT,实际上把时钟/暂停的假设引入了系统,调试成本远高于 ReadIndex。

即时自测 · 5

Q1 5 节点集群,quorum 是多少?如果分区成 {A,B} 和 {C,D,E},哪边能选出 leader?{A,B} 这边的旧 leader 还能提交写吗?能服务 lease read 吗?

Q2 Paxos acceptor 已经 promise 了 ballot 10,此时收到 prepare(9) 和 accept(10, v),分别怎么处理?如果它在回复 promise 后崩溃且丢失了 promised,会有什么后果?

Q3 为什么 Raft leader 不能凭「多数派 matchIndex 已经覆盖 index N」就提交旧 term 的条目?请用 Figure 8 的直觉解释。

Q4 ReadIndex 读的五个步骤是什么?为什么第 2 步(确认多数派)和第 4 步(等待 apply)都不能省?

Q5 集群从 {A,B,C} 直接切到 {D,E,F},可能发生什么?单步变更和联合共识分别怎么避免?

Q6 客户端提交 decrement 提案,leader 提交后响应丢失,客户端重试到新 leader。共识层和状态机层分别应该做什么,才能保证只扣一次?

答案

A1 quorum 是 3。{C,D,E} 有 3 个节点,能选出 leader;{A,B} 只有 2 个,不能形成多数派,也不能提交新写。旧 leader 如果还在 {A,B} 这边,它的写只能追加、不能提交;lease read 在租约未到期时可能返回旧值,租约到期后 ReadIndex 会失败。正确做法是让旧 leader 退位,客户端重试到新 leader,并用请求 ID 去重。

A2 prepare(9) 被拒绝,因为 9 不大于 promised=10;accept(10, v) 可以接受(10 >= 10),acceptor 会更新 accepted_ballot=10、accepted_value=v。如果它在回复 promise 后崩溃且丢失 promised,重启后可能接受一个更小的 ballot,覆盖已经 promise 的决定,破坏安全性;所以 promise 必须先持久化再回复。

A3 旧 term 条目即使出现在多数派上,也可能被后续 leader 覆盖:新 leader 的最后 term 更高,即使日志更短,也能凭选举限制赢得选举,然后在相同 index 写入自己的条目。若旧 leader 凭多数派计数就提交,客户端会认为已成功,但新 leader 会覆盖它,造成已确认写丢失。当前 term 的条目提交后,它之前的条目才被间接提交。

A4 步骤:提交一条当前 term 的日志(或 no-op)→ 向多数派发心跳确认自己仍是 leader → 记录 readIndex=commitIndex → 等待 lastApplied >= readIndex → 从状态机读。第 2 步防止旧 leader 在分区后返回旧值;第 4 步防止「已提交但未 apply」的写读不到,保证读己之写。

A5 直接切换会让旧配置的多数派(如 {A,B})和新配置的多数派(如 {D,E})不相交,可能各自选出 leader,日志分叉。单步变更一次只加/删一个节点,使旧/新多数派必然相交;联合共识在过渡期要求同时满足旧、新两个多数派,完成后再切到新配置。

A6 共识层:保证两条提案按同一顺序提交,不丢已提交的日志;如果旧提案未提交,新 leader 可以覆盖它。状态机层:命令里带 (client_id, seq),apply 时先查去重表;命中就返回原结果,不重复扣减;去重表和库存变更在同一次 apply 中写入,并进入快照。两层合起来才是「有效一次」的副作用。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 5 章 运输层、第 6 章 应用层5.1、5.2、5.10背景只借用分层与端到端视角;不重写 TCP、不重复运输层细节
张彦飞《深入理解 Linux 网络》第 4–7 章(TCP、socket、连接)5.10背景网络 RTT/丢包只作为观测背景,不展开内核路径
《Linux 系统与内核》第 4 篇4.1–4.11(网络与协议栈)5.10、5.13交叉引用磁盘 fsync、网络栈只引用,不重复实现细节
本书自编 + Paxos/Raft 论文Paxos Made Simple、Raft 论文5.3–5.9完整只引用定义、结论和反例;代码、图、时序均为本书重画,不复制论文或 etcd 源码
本书自编 + Jepsen/etcd 文档etcd 3.4.3 分析、etcd 文档5.10–5.12完整只引用公开结论和链接;版本固定为 3.4.3

共识是第 2 篇的收束:第 3 章把日志复制到多个副本,第 4 章定义读到的值算不算数,本章让多个副本对「日志的下一位是什么」达成不会反悔的决定。但共识只解决「顺序」,不解决「业务事务」——一条日志里能不能原子地改多行、失败后怎么回滚、读写怎么隔离,是下一篇的主题。

下一站:日志已经能按同一个顺序落地,下一步要问「一条日志里的多个操作能不能一起成功或一起失败」。进入 6 事务:ACID、隔离级别、MVCC,看单机事务、分布式事务和共识日志如何组合,以及为什么「重试一次事务」会把一次转账做成两次。

6 事务:ACID、隔离级别、MVCC

本章回答

一条 BEGIN ... COMMIT 到底承诺了什么?为什么同一个账户在两次读之间会变?为什么「事务回滚了」不等于「副作用没发生」?跨库转账用 2PC、TCC 还是 Saga,各自把什么风险留给了你?

读完你会

能用异常表判断一个隔离级别够不够;能读懂 MVCC 的版本链、read view 与可见性;能在 PostgreSQL 与 InnoDB 里定位长事务、锁等待和死锁;能写出带唯一约束和幂等键的重试;能画出 2PC 与 Saga 的失败窗口,并说清它们各自不保证什么。

第 5 章的共识解决的是「多个副本对同一条日志的顺序达成一致」。但用户不关心日志,用户关心的是「我这次转账,要么扣款和入账都生效,要么都不生效」。这是另一个问题:一组读写如何组成一个不可分割的边界。共识保证副本之间不打架,事务保证一组操作对外表现为一个整体;两者经常一起出现,但不是同一件事。

先看一个每天都在发生的场景。账户 A 向账户 B 转 100 元,服务端执行两条 UPDATE 和一条 INSERT,然后提交。数据库说提交成功,客户端却因为网关超时没收到响应。客户端重试,服务端又执行了一遍。数据库的每个事务都是原子的:第一次扣了 100,第二次又扣了 100,每次都不违反任何约束。用户看到一次成功,账上少了 200。

这个事故里没有哪个事务「写错」。错的是事务边界画在了数据库里,而逻辑请求的边界在数据库外。数据库只能保证「这一个事务全做或全不做」,它不知道「这是同一个用户意图的第二次尝试」。本章要做的,就是把「事务」这个词拆成三层:单机数据库的 ACID、并发事务之间的隔离、跨服务/跨库的提交协议。三层各有各的保证,也各有各的裂缝。

路线是这样:6.1 先把 ACID 四个字母在单机和分布式里的真实含义拆开;6.2 用五类异常说明隔离级别到底在防什么;6.3 讲 MVCC 的版本链、快照和可见性;6.4 对比 InnoDB 与 PostgreSQL 的实现差异;6.5 讲锁、死锁和「什么错误才值得重试」;6.6 讲 2PC、TCC、Saga 的失败窗口;6.7 讲 Outbox 与幂等消费。后半程是四件必须单独成节的事:重试如何把一次转账做成两次(6.8)、用什么命令观测长事务和锁(6.9)、Spanner 如何用 TrueTime 把 2PC 做成全球外部一致(6.10),以及非幂等 INSERT 被重放时会发生什么(6.11)。

一句话先放在这里:事务不是数据库送的免费午餐,而是一份边界声明。边界画错,ACID 也救不了你。

6.1 ACID 在单机与分布式中的含义

先看一条具体的转账语句。它看起来平平无奇,但四个字母都能在这几行里找到落点,也能找到裂缝。

BEGIN;
UPDATE accounts
   SET balance = balance - 100
 WHERE id = 'A' AND balance >= 100;
UPDATE accounts
   SET balance = balance + 100
 WHERE id = 'B';
INSERT INTO transfers (id, from_acct, to_acct, amount)
VALUES ('t-20261019-001', 'A', 'B', 100);
COMMIT;

A(原子性) 说的是:这四条语句要么全部生效,要么全部不生效。第二条 UPDATE 如果因为 B 账户不存在而报错,数据库会把第一条的扣款回滚掉。实现上靠 undo/rollback 段和 WAL:先记「要改成什么」,再改内存页,提交时把日志刷盘;崩溃后用日志重放或回滚。

但原子性有一个容易被忽略的前提:应用程序得让数据库知道「这个事务失败了」。如果第一条 UPDATE 因为 balance >= 100 不满足而影响了 0 行,数据库不会报错,事务会继续执行并提交。余额不足时,扣款语句「什么都没做」,入账语句却加了 100。原子性没有失效,它是被「0 行更新不是错误」这个 SQL 语义绕过去了。应用必须检查受影响行数,或者把条件写成会抛错的约束。

C(一致性) 是最容易被误解的一个字母。教科书说「事务把数据库从一个一致状态带到另一个一致状态」,但「一致」不是数据库自己能定义的。数据库能检查的是它认识的约束:主键、外键、唯一索引、CHECK、NOT NULL。至于「所有账户余额之和不变」「库存不能超卖」「一个用户只能有一个生效的订阅」,数据库不知道,也不会替你检查。

上面那条 SQL 里,balance >= 100 是一个应用层不变量;它只保护了 A 账户不透支,没有保护「转账前后总额不变」。如果第二条 UPDATE 写错了账户,或者金额单位从元变成分,数据库都会欣然提交。C 是应用的不变量,不是数据库的赠品。在分布式系统里,这条不变量往往跨多个库、多个服务,更不可能由某一个数据库的约束来保证。

I(隔离) 管的是并发事务之间「谁能看到谁」。两个人同时转账,会不会读到对方的中间状态?会不会两个人都基于同一个旧余额做决定?这是 6.2 和 6.3 的主题。

D(持久性) 说的是:COMMIT 返回之后,即使进程崩溃、机器断电,已提交的数据也不应该丢。单机上靠 WAL 刷盘(PostgreSQL 的 fsync、MySQL 的 redo log);分布式里还要靠复制。这里有一个非常常见的错觉:提交成功不等于数据已经到了多数派。

字母单机谁保证分布式里变成什么最常见的破法
Aundo/rollback + WAL2PC 的原子提交,或 Saga 的补偿协调者崩溃、补偿失败、部分提交
C约束、触发器 + 应用检查跨服务不变量、对账、补偿只在一个库检查,另一个库不知道
I锁 / MVCC没有全局隔离,中间状态会外泄把「本地事务」当成「全局事务」
DWAL fsync + 副本quorum 复制或异步复制主库宕机,异步备库还没收到

6.1.1 分布式里的 A:不是一个布尔值

单机事务的原子性是二值的:提交了就是全做,回滚了就是全不做。跨服务之后,「全做」需要所有参与者一起提交,「全不做」需要所有参与者一起回滚;而参与者可能在不同机器、不同数据库、不同可用区,任何一方都可能在关键时刻失联。

工程上有三种做法,它们对 A 的保证强度依次递减:

  1. 2PC(两阶段提交):协调者先问所有参与者「能不能提交」,全部同意后再统一提交。它提供原子提交,但参与者在投票后要一直持有锁,直到收到最终决定;协调者崩溃时,参与者会卡在 in-doubt 状态。2PC 的细节和失败窗口在 6.6。
  2. TCC(Try-Confirm-Cancel):每个服务提供「预留 / 确认 / 取消」三个动作,Try 先冻结资源,Confirm 真正扣减,Cancel 释放。它把原子性换成了「业务层的预留 + 补偿」,需要处理空回滚、悬挂和幂等。
  3. Saga:每个服务做本地事务,失败时按相反顺序执行补偿。它没有原子性,只有「最终补偿」;中间状态对外可见,补偿也可能失败。

所以「分布式事务」不是一个东西,而是一个谱:左边是强原子、长锁、低可用;右边是最终一致、短锁、要补偿和对账。选型的第一问不是「哪个最强」,而是「这个业务能不能接受中间状态被看到,能不能接受补偿失败后人工介入」。

6.1.2 分布式里的 D:提交、刷盘、复制是三件事

很多人把「事务提交成功」理解为「数据已经安全」。在单机上,这大致成立(前提是 fsync 真的落了盘);在分布式里,至少要区分三个位置:

  • 本机页缓存:数据在内存里,崩溃可能丢;
  • 本机 WAL/redo 落盘:单机崩溃可恢复,但机器整机损毁可能丢;
  • 多数派副本落盘:才能扛住单机/单 AZ 故障。

PostgreSQL 的 synchronous_commit 控制提交时等什么:

取值提交返回前保证崩溃后可能丢什么
off只写本地 WAL 缓冲,不等待刷盘最近若干事务(通常几百毫秒)
local本地 WAL 刷盘本机不丢,但备库可能没有
on(默认)本地 WAL 刷盘;有同步备库时等其刷盘无同步备库/异步复制时可能丢
remote_write备库收到并写入其 WAL 缓冲备库崩溃可能丢
remote_apply备库已应用,读可见延迟最高,但不丢已提交

MySQL 对应的是 innodb_flush_log_at_trx_commit 和 sync_binlog:前者控制 redo log 刷盘,后者控制 binlog 刷盘。两个都设为 1 才是「提交即落盘」;只设一个,崩溃恢复后可能出现「数据在、binlog 不在」或反过来,影响复制和 PITR。

这张表说明:D 不是数据库的一个开关,而是一条链。链上任何一段是异步的,链的末端就可能丢数据。异步复制的主库宕机、备库提升,是生产上「已提交数据丢失」最常见的原因之一。

定义 6.1.1 · 事务边界与逻辑请求边界

事务边界(transaction boundary)是数据库原子性生效的范围:从 BEGIN 到 COMMIT/ROLLBACK 之间的一组语句。逻辑请求边界(request boundary)是用户意图生效的范围:一次点击、一个支付 intent、一条转账指令。

单机时代两者经常重合,所以人们容易混用。跨服务之后,一个逻辑请求可能包含多个本地事务、多次 RPC、多条消息;数据库只看得见事务边界,看不见逻辑请求边界。重试、幂等、去重、对账,全都建立在「逻辑请求有身份」这个前提上;没有身份,数据库的原子性只能保证「每一次尝试内部不撕裂」,保证不了「同一意图只生效一次」。

提交成功之后,数据可能还在路上

COMMIT 返回只说明数据库接受了这个事务。它可能还在备库的复制队列里,可能还在消息队列的缓冲区里,可能还没被下游服务消费。「已提交」是本地事实,「已传播」是分布式事实;两者之间有一段窗口,所有「提交后立刻发消息」的双写问题都长在这段窗口里(6.7)。

6.1.3 C 在分布式里:从约束变成对账

单机的不变量可以写成约束,让数据库替你在每次写入时检查。跨服务之后,不变量往往长这样:

  • 「订单表里的每一笔订单,都必须有一条对应的库存扣减记录」;
  • 「账户余额 = 所有流水之和」;
  • 「一个支付 intent 最多对应一条成功的扣款」。

这些不变量没有一个能由单个数据库的 CHECK 表达。工程上的做法是把它们拆成三类机制:

机制保证代价例子
本地约束强、即时只能管一个库唯一索引防重复扣款
分布式协议强、有阻塞复杂度、延迟、可用性2PC 跨库转账
对账/补偿最终、可人工有时间窗口,可能错账日终流水对账、Saga 补偿

成熟系统通常三者都用:本地唯一约束挡住大部分重复,分布式协议处理必须原子的跨库操作,对账兜住协议失败和人为错误。

6.2 隔离级别与异常:从脏读到写偏斜

隔离级别不是「数据库的一个档位」,而是「一组异常,你允许哪些发生」。要理解它,先得认识五类异常。它们不是教科书里的名词,每一种都能对应到一条真实的业务 bug。

6.2.1 五类异常

异常一句话定义最小 schedule业务后果
脏读(dirty read)读到了另一个事务未提交的写T1 写 x=1;T2 读 x=1;T1 回滚基于「从未存在过」的数据做决定
不可重复读(non-repeatable read)同一事务内两次读同一行,值不同T1 读 x=0;T2 写 x=1 并提交;T1 再读 x=1同一份报表前后矛盾
幻读(phantom)同一事务内两次按同一条件查,行集不同T1 查 WHERE city='SH' 得 3 行;T2 插入一行并提交;T1 再查得 4 行统计/限额判断失效
丢失更新(lost update)两个事务读同一值、各自写回,后写覆盖先写T1 读 100,T2 读 100;T1 写 110;T2 写 120一次更新凭空消失
写偏斜(write skew)两个事务读重叠的集合、写不相交的行,各自都合法,合起来违反不变量两个医生都看到「2 人在岗」,各自把自己改成下班约束被绕过,且没有写冲突

前三个是「读」的异常,后两个是「写」的异常。丢失更新和写偏斜的区别值得单独记:丢失更新是两个事务写同一行,写偏斜是两个事务写不同的行、但读取的集合有重叠。前者可以被行锁或「第一次更新者获胜」挡住,后者不会产生任何行级写冲突,所以快照隔离也挡不住。

6.2.2 写偏斜:没有写冲突,也能破坏约束

假设医院有一条规则:任何时候至少有一名医生在岗。当前有两名医生 Alice 和 Bob 在岗。两人同时想下班,各自执行:

-- 会话 1(Alice)
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call;          -- 2
-- 会话 2(Bob),在会话 1 的 SELECT 之后开始
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call;          -- 2
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT;
-- 回到会话 1
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;

(上面是 PostgreSQL 语法;MySQL 要在 BEGIN 之前执行 SET TRANSACTION ISOLATION LEVEL REPEATABLE READ。)

两个事务读到的都是 2,都认为「还有一个人在」,于是都把自己改成下班。它们写的是不同的行,没有写写冲突;在快照隔离(PostgreSQL 的 REPEATABLE READ、InnoDB 的 REPEATABLE READ 一致读)下,两个都能提交。提交之后 count(*) = 0,规则被破坏。

这不是数据库的 bug,而是快照隔离的语义:它保证「你看到的是一个一致的快照」,不保证「你的写和别人的写合起来像一个接一个执行」。要阻止写偏斜,需要可串行化,或者手工加锁(例如先 SELECT ... FOR UPDATE 锁住所有在岗医生),或者把不变量变成数据库能检查的约束。

例 6.2.1 · 写偏斜的两个变体

变体 A:值班规则。 两个事务各读 count(on_call)=2,各写自己 on_call=false,提交后 0 人在岗。修复:SELECT ... FOR UPDATE 锁住相关行,或使用 SERIALIZABLE 并重试 40001。

变体 B:优惠券预算。 活动预算 100 元,两个请求各读到「已用 60,剩 40」,各发一张 30 元券,各自插入自己的发券记录。两条记录不冲突,提交后已用 120,超预算。修复:把预算扣减收敛到一行(UPDATE budget SET used = used + 30 WHERE used + 30 <= total),或对预算行加锁。

两个变体的共同点:不变量约束的是「集合」,而写操作落在「单行」。只要检查和使用不是原子的,中间就有窗口。

6.2.3 隔离级别:标准说的和数据库做的

SQL 标准定义了四个级别,用三个「读异常」来区分。但标准只规定「不允许什么」,没规定「怎么实现」;同一个级别在不同数据库上的行为可能不同。

级别脏读不可重复读幻读PostgreSQL 实际InnoDB 实际
Read Uncommitted可能可能可能等同 Read Committed名义上允许;很少使用
Read Committed否可能可能默认级别;每条语句一个新快照默认级别;每条语句一个新 read view
Repeatable Read否否标准说可能快照隔离:无幻读,但允许写偏斜首次读建 read view、事务内复用;锁定读用 next-key lock 防幻读
Serializable否否否SSI:冲突时返回 40001隐式加共享锁;可能死锁,不是 SSI

这张表有三个容易踩的坑:

  1. PostgreSQL 的 READ UNCOMMITTED 不是「真的允许脏读」,它被静默提升为 READ COMMITTED。想复现脏读,在 PG 上做不到。
  2. PostgreSQL 的 REPEATABLE READ 是快照隔离(SI),不是标准意义上的锁式 RR。它没有幻读,但允许写偏斜;很多团队以为「RR 就安全了」,其实只是换了一类异常。
  3. InnoDB 的 REPEATABLE READ 对「一致读」和「锁定读」是两套语义。普通 SELECT 读快照,SELECT ... FOR UPDATE 读最新版本并加 next-key lock。同一个事务里混用两者,容易出现「读到的和锁到的不是同一版」。
一句话

隔离级别管的是「你能看到什么」,不是「你的业务规则对不对」。写偏斜就是业务规则在「看到」和「写入」之间被别人钻了空子。

6.2.4 丢失更新:绝对值写入与增量写入

丢失更新经常被和写偏斜混为一谈。看一个最小例子:

T1: SELECT balance FROM accounts WHERE id='A';   -- 100
T2: SELECT balance FROM accounts WHERE id='A';   -- 100
T1: UPDATE accounts SET balance = 110 WHERE id='A';  -- 应用算好的绝对值
T2: UPDATE accounts SET balance = 120 WHERE id='A';  -- 覆盖,T1 的 +10 消失

在 PostgreSQL 的 REPEATABLE READ 下,T2 的 UPDATE 会发现这行已经被 T1 改过,返回 40001 could not serialize access due to concurrent update,应用必须重试。在 InnoDB 的 REPEATABLE READ 下,T2 的 UPDATE 会等待 T1 提交,然后基于最新版本执行;如果 SQL 写的是 SET balance = 120(绝对值),T1 的更新仍然会被覆盖;如果写的是 SET balance = balance + 20(增量),则不会丢。

所以「数据库的隔离级别能防丢失更新」这句话只在特定写法下成立。最稳的写法是让数据库做增量,或者用版本号/CAS:

UPDATE accounts
   SET balance = balance - 100, version = version + 1
 WHERE id = 'A' AND version = 7 AND balance >= 100;
-- 受影响行数为 0:要么版本变了,要么余额不足;应用据此决定重试还是拒绝

这条语句把「检查」和「写入」合并成一个原子操作,不依赖隔离级别,也不依赖读到的旧值。它是第 4 章 CAS 思想在事务里的落地。

6.3 MVCC:版本、快照、可见性

如果没有 MVCC,读和写会互相阻塞:读要加共享锁,写要加排他锁,一个长查询就能把整张表的写入卡住。MVCC 的思路是:写不覆盖旧值,而是产生一个新版本;读按自己的快照选择看哪个版本。这样读不阻塞写、写不阻塞读,代价是旧版本要保留、要回收。

MVCC:读快照,写新版本 x 的版本链 0→1→2;事务 T1 的快照看到 1,T2 的快照看到 2,旧版本由 vacuum 回收。 MVCC:读快照,写新版本 v0 x=0 T0 v1 x=1 T1 写 v2 x=2 T2 写 T1 快照 可见 v1,不可见 v2 T2 快照 可见 v2 旧版本保留到没有快照需要它;长事务 = vacuum 无法回收。 写偏斜:两个事务各读一部分、各写另一部分,快照隔离下仍可能破坏不变量。
读图:快照隔离下,读不阻塞写、写不阻塞读;代价是旧版本要保留,长事务会拖住回收。图中 T1 在 T2 提交后仍然读到 1,这不是 bug,而是快照的语义。

图里有一条 x 的版本链:v0(x=0) → v1(x=1) → v2(x=2)。T1 在 T2 提交之后仍然读到 x=1,因为 T1 的快照是在 T2 提交之前拿的。这不是 bug,是快照隔离的承诺:一个事务看到的世界,在它开始(或第一次读)的那一刻被冻结了。

6.3.1 三个词:版本、快照、可见性

  • 版本(version):某一行的某一个历史状态,带有「谁创建的」「谁删除/替换了它」的元数据。
  • 快照(snapshot / read view):一个事务在某一时刻看到的「事务 ID 边界」,通常包括「哪些事务还活跃」「下一个未分配的事务 ID」。
  • 可见性(visibility):给定一个版本和一个快照,判断这个版本对当前事务是否可见。可见性规则是 MVCC 的核心,也是不同数据库实现差异最大的地方。

三者合起来回答一个问题:「我现在读这一行,应该读哪个版本?」 答案不是「最新的」,而是「我的快照允许我看到的最新版本」。

6.3.2 PostgreSQL:元组头的 xmin/xmax

PostgreSQL 把版本信息放在每个堆元组的头部:

  • xmin:创建这个元组的事务 ID;
  • xmax:删除(或锁定)这个元组的事务 ID;0 表示没被删除;
  • cid:命令 ID,用于同一事务内的语句顺序。

快照由三部分组成:

xmin  = 快照时刻仍活跃的最小事务 ID(更早的事务一定已结束)
xmax  = 快照时刻下一个尚未分配的事务 ID(更大的事务一定还没开始)
xip   = 快照时刻仍在进行中的事务 ID 列表

在 psql 里可以直接看:

SELECT pg_current_snapshot();
-- 100:105:101,102

100:105:101,102 的意思是:小于 100 的事务都已结束,大于等于 105 的事务都还没开始,101 和 102 两个事务在快照时刻仍然活跃。一个元组的 xmin 如果落在 xip 里,或者 >= xmax,对当前快照就不可见。

可见性判断可以写成下面这段伪代码(省略了命令 ID、子事务、冻结、行锁的 LOCK_ONLY 等细节):

def visible(xmin, xmax, snap, self_xid, committed):
    if xmax == self_xid:  # 自己删掉的版本,对自己不可见
        return False
    # 自己写的东西,自己一定看得见
    if xmin == self_xid:
        return True
    # 插入者没提交,或者快照建立时它还没开始/还在活跃
    if not committed(xmin) or xmin >= snap.xmax or xmin in snap.xip:
        return False
    # 没有被删除
    if xmax == 0:
        return True
    # 删除者已提交,且对快照可见,才算真的被删了
    deleted = committed(xmax) and xmax < snap.xmax and xmax not in snap.xip
    return not deleted

注意最后一行:xmax 有值不代表元组已经不可见。如果删除它的事务在快照之后才提交,或者当时还在活跃,那么这个删除对当前快照「还没发生」,元组仍然可见。「已删除」是一个相对于快照的事实,不是一个全局事实。

6.3.3 InnoDB:undo log 里的旧版本

InnoDB 的行里没有把每个版本都摊在表上,而是只保留最新版本,旧版本放在 undo log 里,通过行头的 DB_ROLL_PTR 串成一条版本链:

聚簇索引行(最新)
  DB_TRX_ID = 900
  DB_ROLL_PTR ──▶ undo record(x=2, trx=900, roll_ptr=...)
                     └──▶ undo record(x=1, trx=800, roll_ptr=...)
                              └──▶ undo record(x=0, trx=700)

一致读(普通 SELECT)拿一个 read view,从最新版本沿 ROLL_PTR 往回走,直到找到第一个「对 read view 可见」的版本。read view 的四个字段是:

字段含义
m_ids创建 read view 时活跃的读写事务 ID 集合
min_trx_idm_ids 中的最小值
max_trx_id创建 read view 时下一个要分配的事务 ID
creator_trx_id创建这个 read view 的事务 ID

判断规则比 PostgreSQL 更直接:

trx_id == creator_trx_id        → 可见(自己的修改)
trx_id <  min_trx_id            → 可见(在 read view 之前已提交)
trx_id >= max_trx_id            → 不可见(read view 之后才开始)
trx_id in m_ids                 → 不可见(read view 时还活跃)
否则                             → 可见

再叠加「删除标记」:版本带 delete_flag 时,还要看删除它的事务对 read view 是否可见;可见才继续往回找,不可见则这个版本仍然有效。

定义 6.3.1 · 快照隔离

快照隔离(snapshot isolation, SI)保证:一个事务的所有读都来自同一个一致快照;事务提交时,如果它写过的行被其他已提交事务改过,则产生写写冲突(first-committer-wins),后提交者回滚。

SI 阻止了脏读、不可重复读、幻读和同一行的丢失更新,但不阻止写偏斜。PostgreSQL 的 REPEATABLE READ 和 InnoDB 的 REPEATABLE READ 一致读都提供 SI 级别的语义;要排除写偏斜,需要可串行化(PostgreSQL 的 SSI)或显式锁。

6.3.4 长事务为什么危险

MVCC 的旧版本不是免费的。只要还有一个快照需要某个旧版本,数据库就不能回收它。于是一个「忘了提交」的事务会带来连锁反应:

  • PostgreSQL:VACUUM 无法清理比最老快照更旧的死元组,表会膨胀;索引也会膨胀;查询变慢;极端情况下事务 ID 回卷(wraparound)风险上升。
  • InnoDB:purge 线程无法清理 undo,History list length 持续增长;回滚段变大;磁盘占用上升;崩溃恢复变慢。

这两种症状在 6.9 都有对应的观测命令。这里先记住一个可执行的规则:OLTP 事务应该以毫秒到秒计,不要以分钟计。批量任务要分批提交,报表要用只读事务/快照导出,不要让一个 BEGIN 跨越用户思考时间。

6.3.5 一个可复现的快照实验

开两个 psql 会话,按下面的顺序执行,可以直接看到「T1 在 T2 提交后仍然读到旧值」:

-- 会话 1
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 'A';   -- 100

-- 会话 2
BEGIN;
UPDATE accounts SET balance = 50 WHERE id = 'A';
COMMIT;

-- 会话 1,再次查询
SELECT balance FROM accounts WHERE id = 'A';   -- 仍然是 100
COMMIT;
快照不是备份

MVCC 的快照只保证「这个事务看到一致的时间点」,不保证「数据不会丢」。备份是另一套机制(物理备份、PITR、逻辑导出)。把只读事务当成备份、或者在长事务里做导出,既拖住回收,又不能提供可恢复性。

6.4 InnoDB 与 PostgreSQL 的实现差异

两个数据库都支持 MVCC,都用「快照 + 版本」实现读不阻塞写,但存放版本的位置、回收方式、锁的粒度完全不同。理解差异不是为了站队,而是为了在出问题时知道该看什么指标。

维度InnoDB(MySQL)PostgreSQL
版本存放undo log,行内 DB_ROLL_PTR 串链堆表里保留新旧元组,索引指向元组
行头字段DB_TRX_ID、DB_ROLL_PTR、DB_ROW_IDxmin、xmax、cid
快照结构read view:m_ids/min/max/creatorsnapshot:xmin/xmax/xip
旧版本回收purge 线程清理 undoVACUUM/autovacuum 清理死元组
回收受阻的症状History list length 增长、undo 表空间膨胀表/索引膨胀、n_dead_tup 高、vacuum 滞后
更新方式原地更新聚簇索引,旧值进 undo写新元组,旧元组留堆,索引可能新增条目
二级索引存主键,可见性可能要回表存 TID,指向具体元组;HOT 可减少索引更新
锁行锁、间隙锁、next-key lock(RR)行锁;无间隙锁;SSI 用谓词锁
序列化冲突靠锁/死锁检测;无 SSISSI,返回 40001
长事务的主要代价undo 膨胀、purge 滞后表膨胀、vacuum 滞后、xid 年龄
DDL8.0 原子 DDL,但仍有在线 DDL 代价事务性 DDL,可回滚

6.4.1 更新为什么在两边长得不一样

InnoDB 的聚簇索引就是表:主键决定行的物理位置。更新一行时,它尽量在原地改,把旧值写进 undo,回滚指针指向旧版本。这样主键查询很快,但二级索引里存的是主键值,二级索引本身不携带事务 ID;当二级索引扫描命中一行时,可能需要回到聚簇索引做可见性判断。覆盖索引能减少回表,但不一定完全消除这种检查。

PostgreSQL 的堆表不按主键排序,更新一行会写一个新元组,旧元组留在原地,索引条目指向具体的元组位置(TID)。这带来两个后果:

  • 更新频繁的表会积累死元组,必须靠 VACUUM 回收;回收跟不上就膨胀。
  • 如果更新没有改索引列,并且新元组能放在同一页的空闲空间里,PG 可以做 HOT 更新,不新增索引条目;否则每个索引都要新增一条指向新元组的记录。

这解释了一个常见现象:同样的更新量,MySQL 的表大小可能很稳定,PostgreSQL 的表却在涨。不是 PG「更差」,而是它的版本存放策略把回收的责任明确交给了 vacuum;只要 autovacuum 跟得上,空间会复用,表大小会趋于稳定。跟不上的时候,VACUUM FULL 能立刻收缩,但会持锁重写整表,生产上要谨慎。

6.4.2 回收:purge 与 vacuum 的对照

问题InnoDB 看什么PostgreSQL 看什么
旧版本积压SHOW ENGINE INNODB STATUS 的 History list lengthpg_stat_user_tables.n_dead_tup、last_autovacuum
谁挡住了回收最老的 read view(长事务/长查询)pg_stat_activity.backend_xmin、pg_stat_activity.xact_start
回收进度purge 线程、undo 表空间大小pg_stat_progress_vacuum、autovacuum 日志
极端风险undo 表空间占满磁盘事务 ID 回卷、表膨胀到磁盘告警

一个可执行的检查顺序是:先找最老的事务/查询,再看回收指标,最后才考虑调参数。大多数「回收不动」不是参数问题,而是一个忘了提交的连接或一个跑了几小时的查询。

backend_xmin 越小,说明这个连接持有的快照越老,阻挡的回收范围越大。一个 idle in transaction 状态、xact_age 几小时的连接,通常就是膨胀的元凶。

6.5 锁、死锁与重试

MVCC 让读不阻塞写,但写和写之间仍然要排队。两个事务改同一行,数据库必须用锁保证「后一个不会基于过期的版本做决定」。锁用得好是并发控制,用不好就是死锁和超时;而死锁之后怎么重试,决定了系统是「短暂抖动」还是「雪崩」。

6.5.1 锁的层次

锁作用范围什么时候用典型症状
行锁(record lock)单行UPDATE、DELETE、SELECT ... FOR UPDATE热点行排队
间隙锁(gap lock)索引区间之间的空隙InnoDB RR 的锁定读,防幻读插入被阻塞,但看不到谁锁了行
next-key lock行 + 前面的间隙InnoDB RR 默认的锁定读范围更新/插入互相阻塞
表级意向锁(IS/IX)表配合行锁,标记「表里有行锁」不直接阻塞,但影响表锁
表锁/元数据锁整表DDL、LOCK TABLEDDL 等待长事务,后续查询排队
谓词锁(predicate lock)查询条件PostgreSQL SSI读写依赖冲突,返回 40001

PostgreSQL 的行锁有一个容易忽略的细节:普通 SELECT 不加锁,所以它不会阻塞 UPDATE;SELECT ... FOR UPDATE 才会。InnoDB 的普通 SELECT 也不加锁(一致读),但 SELECT ... FOR UPDATE 在 RR 下会加 next-key lock,锁住的范围可能比你想象的大。

「锁了一行」不等于「只影响一行」

InnoDB 的 next-key lock 锁的是索引记录及其前面的间隙。一个 UPDATE ... WHERE id BETWEEN 10 AND 20 可能锁住范围内不存在的键位,导致插入 15 被阻塞。PostgreSQL 没有间隙锁,但 SSI 会用谓词锁跟踪读写依赖,冲突时让事务回滚。看不到锁行,不代表没有锁;6.9 的 pg_locks 和 data_locks 是查这个的。

6.5.2 死锁:两个事务的加锁顺序相反

死锁的最小例子是两条转账语句以相反顺序更新两个账户:

-- 会话 1
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 'A';
UPDATE accounts SET balance = balance + 10 WHERE id = 'B';
COMMIT;

-- 会话 2(几乎同时)
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 'B';
UPDATE accounts SET balance = balance + 10 WHERE id = 'A';
COMMIT;

会话 1 持有 A 的行锁,等 B;会话 2 持有 B 的行锁,等 A。两边都不肯放手,形成环。数据库的处理方式不一样:

  • InnoDB 默认开启死锁检测(innodb_deadlock_detect=ON),发现环后立刻回滚「代价较小」的那个事务,客户端收到 ERROR 1213 (40001): Deadlock found when trying to get lock。
  • PostgreSQL 在 deadlock_timeout(默认 1 s)后检测等待图,发现环就中止其中一个事务,返回 40P01 deadlock_detected。

两者都会牺牲一个事务来打破环。死锁不是异常,而是并发系统的正常现象;应用必须能识别并重试,而不是把它当成 bug 报给用户。

6.5.3 哪些错误值得重试

不是所有数据库错误都能重试。把「唯一键冲突」重试一百次,只会得到一百次冲突;把「语法错误」重试,只会浪费连接。大致分三类:可重试、不可重试、结果未知。

错误PostgreSQLMySQL能重试吗动作
序列化失败40001—(无 SSI)能退避后重试整个事务
死锁40P011213能退避后重试整个事务
锁等待超时55P03(lock_timeout)1205能,但要先查原因缩短事务/调整顺序,再重试
唯一键冲突235051062不能(除非是幂等插入)当作「已存在」处理
语法/类型错误42601/22P021064不能修代码
连接断开080062013能,但结果未知重连 + 幂等键,按 unknown 处理
事务已中止25P02—不能在同事务内重试先 ROLLBACK,再重开事务

最容易犯的错是「在同一个事务里重试一条语句」。PostgreSQL 里,一条语句失败后整个事务进入 aborted 状态,后续语句全部返回 25P02,必须先 ROLLBACK。重试的单位是整个事务,不是单条 SQL。

6.5.4 一个可用的重试封装

下面这段代码只重试「可重试」的 SQLSTATE,并且重试的是整个事务:

import random
import time

import psycopg

RETRYABLE = {"40001", "40P01", "55P03"}


def run_tx(dsn: str, work, attempts: int = 5):
    """work(conn) 在一个事务里执行;失败时整体回滚并重试。"""
    with psycopg.connect(dsn) as conn:
        for i in range(attempts):
            try:
                with conn.transaction():
                    return work(conn)
            except psycopg.Error as exc:
                if exc.sqlstate not in RETRYABLE or i == attempts - 1:
                    raise
                # 指数退避 + 抖动:避免所有客户端同时重试
                delay = min(0.05 * (2 ** i), 1.0)
                time.sleep(delay * (0.5 + random.random()))

这段代码有四个刻意的选择:

  1. 只捕获 psycopg.Error 并检查 sqlstate,不靠错误消息文本匹配;消息会随版本/语言变化。
  2. 重试整个 conn.transaction() 块,而不是块里的某条语句;这样每次重试都是一个干净的事务。
  3. 退避带抖动,否则一批同时死锁的请求会在同一时刻再次相撞。
  4. 最多 5 次,并且没有无限循环;重试耗尽后把错误抛给上层,由上层决定降级还是返回用户。

如果连接本身断了(08006),上面的 with psycopg.connect(...) 不会自动重连;生产代码需要在外层重建连接,并且用幂等键保证「断线前的提交可能已经生效」不会变成重复副作用。这一点在 6.8 和 6.11 会展开。

6.5.5 减少死锁的四条规则

  1. 统一加锁顺序:所有事务按主键/账户 ID 排序后依次加锁;不要一个按 A→B、一个按 B→A。
  2. 缩短事务:把网络调用、文件 IO、用户交互挪到事务外;事务里只留必要的读写。
  3. 一次锁够:能一条 UPDATE ... WHERE 完成的,不要先 SELECT 再 UPDATE;能批量更新的,不要逐行循环。
  4. 设置超时:SET lock_timeout = '2s'、SET statement_timeout = '5s',让慢事务快速失败,而不是无限等待拖垮连接池。

6.6 分布式事务:2PC、TCC、Saga

单库事务的边界是 BEGIN ... COMMIT,跨库之后没有这样一个语句能包住两个数据库。你要么引入一个协调者,让所有参与者按同一个决定提交或回滚(2PC);要么把「原子性」降级成「预留 + 确认/取消」(TCC);要么承认中间状态存在,用补偿把系统拉回一致(Saga)。三条路都有失败窗口,区别在于窗口里谁被阻塞、谁能自救。

6.6.1 2PC:两个阶段,一个决定

协调者 C                    参与者 A                参与者 B
   │ ① prepare ─────────────▶│                        │
   │ ◀───────────── vote yes │                        │
   │ ① prepare ──────────────────────────────────────▶│
   │ ◀────────────────────────────────────── vote yes │
   │
   │ ② 把 commit 决定写入日志(必须先落盘)
   │ ② commit ──────────────▶│                        │
   │ ② commit ───────────────────────────────────────▶│
   │ ◀─────────────── ack    │                        │
   │ ◀─────────────────────────────────────── ack    │

第一阶段(prepare/vote):协调者问每个参与者「这个事务能不能提交」。参与者在本地执行事务的所有写,把 undo/redo 和 prepared 状态写入日志,锁住相关资源,然后投票 yes 或 no。投 yes 意味着「我能提交,但我现在不能单独决定」。

第二阶段(commit/abort):如果所有参与者都投 yes,协调者把「commit」决定写入自己的日志(必须先落盘),再给所有参与者发 commit;只要有一个投 no,就发 abort。参与者按决定执行,释放锁,回 ack。

6.6.2 2PC 的失败窗口

失败时刻谁崩了参与者状态能否自救处理
prepare 发出前协调者未收到 prepare能,安全回滚超时后 abort
参与者投 yes 前参与者未写 prepared能,本地回滚协调者收到 no 或超时
投 yes 后、收到决定前协调者in-doubt,持锁不能等协调者恢复;协调者要高可用
commit 只送达一部分网络/协调者一部分提交,一部分 in-doubt不能协调者重试;参与者不能自行决定
参与者投 yes 后崩溃参与者恢复后读 prepared 日志不能单独决定恢复后向协调者询问
网络分区双方各自等待不能超时策略 + 人工介入

最危险的是第四行:一部分参与者已经提交,另一部分还在等。如果等待的参与者「等不及了」,自己决定回滚(启发式回滚),系统就出现「A 库扣了钱、B 库没入账」的分裂。XA 规范允许这种启发式决定,但工程上应该把它当成事故:宁可阻塞,也不要让参与者单方面推翻协调者的决定。

2PC 的代价也很具体:

  • 延迟:至少两轮网络往返,加上协调者和参与者的日志 fsync;跨可用区时,每次 fsync 都是毫秒级。
  • 锁持有时间长:从 prepare 到 commit,参与者一直持有行锁;并发一高,锁等待和死锁都会上升。
  • 协调者是单点:协调者崩溃时,所有 in-doubt 事务都卡住;生产上协调者本身要用 Paxos/Raft 复制。
  • 吞吐有上限:每个事务都要两次持久化和多轮 RPC,不适合高并发短事务。
2PC 不是银弹

2PC 保证的是「原子提交」,不是「隔离」,也不是「高可用」。它不能让跨库的读看到一致快照,不能在协调者失联时让参与者继续前进,也不能消除网络分区。把 2PC 加到微服务上,只是把「部分提交」换成了「阻塞 + 部分提交」,复杂度还上升了。能用一个库解决的事,不要用 2PC;能用本地事务 + Outbox 解决的事,也不要上 2PC。

6.6.3 TCC:把原子性换成预留

TCC 把一次业务操作拆成三个动作:

阶段做什么失败后必须满足
Try检查并预留资源(冻结金额、锁座位)释放预留幂等;预留可查询
Confirm确认使用预留(真正扣减)重试直到成功幂等;不会失败(业务上已确保)
Cancel释放预留(解冻、退座)重试直到成功幂等;能处理「没 Try 过」

TCC 有两个经典问题,名字听起来玄,本质都是「消息/请求乱序 + 重试」:

  • 空回滚(empty rollback):Cancel 先于 Try 到达。参与者没有对应的 Try 记录,不能报错(否则协调者会一直重试),而要记录「这个分支已取消」,返回成功。等 Try 后来到达时,看到「已取消」就拒绝执行,避免资源被预留后永远不释放。
  • 悬挂(hanging):Try 在 Cancel 之后到达。如果没有上面的「已取消」记录,Try 会成功预留资源,但 Confirm 永远不会来,资源就挂住了。解决方法是 Try 先查事务控制表:状态是 cancelled 就拒绝。

TCC 还需要一张事务控制表(tx_id、branch_id、status),记录全局事务和每个分支的状态。Try 插入 try,Confirm 更新为 confirm,Cancel 更新为 cancel;所有更新都用「状态机 + 条件更新」保证幂等和顺序。TCC 的隔离性来自「预留」:冻结的金额还在账户里,但可用余额已经减少,其他事务看到的是「可用余额」,不会把同一笔钱花两次。

6.6.4 Saga:没有原子性,只有补偿

Saga 把长事务拆成一串本地事务 T1, T2, ..., Tn,每个 Ti 都有一个补偿动作 Ci。如果 Tk 失败,就按相反顺序执行 Ck-1, ..., C1:

正向:  T1 ──▶ T2 ──▶ T3 ──▶ T4
                              │
                              └─ T4 失败
                                     │
补偿:  C1 ◀── C2 ◀── C3 ◀──────────┘
        (执行顺序:C3 → C2 → C1)

Saga 的保证是「最终一致」,不是「原子」。它有四个必须承认的事实:

  1. 中间状态对外可见:T1 创建了订单、T2 还没扣款时,别的请求能看到这个订单。要用业务状态(PENDING/RESERVED)把它标出来,让其他操作知道「还没完成」。
  2. 补偿不是回滚:退款不是「撤销扣款」,而是一笔新的正向交易;它可能失败、可能被风控拦截、可能产生手续费。补偿动作必须幂等,并且要能重试。
  3. 没有隔离:另一个事务可能读到中间状态并据此做决定。工程上用「语义锁」(semantic lock)来处理:在业务层标记「这个实体正在事务中」,其他事务要么等待,要么拒绝。
  4. 补偿也可能失败:C3 失败时,需要有重试、死信、人工介入。Saga 的尽头往往是一张对账工单,这不是耻辱,而是设计的一部分。

6.6.5 怎么选

方案原子性隔离性延迟复杂度适用
2PC/XA强原子提交参与者本地隔离,无全局快照高(2 RTT + fsync)中跨库短事务、同构数据库、能接受阻塞
TCC业务级原子靠预留实现中高(三个接口 + 控制表)支付、库存、座位等资源预留
Saga最终一致无,需要语义锁低中高(补偿链 + 对账)长流程、跨多个服务、能接受中间状态
Outbox本地原子 + 最终一致无低低事件通知、异步解耦(6.7)

选型时先回答三个问题:

  1. 这个操作能不能接受「中间状态被看到」? 不能,就别用 Saga;考虑 2PC 或把数据放到一个库里。
  2. 参与方能不能长时间持锁? 不能,就别用 2PC;考虑 TCC 或 Saga。
  3. 补偿失败时,业务能不能人工介入? 不能,就别把 Saga 当成「自动回滚」;要么把不变量收敛到单库,要么设计更强的预留。

6.7 Outbox 与幂等消费

很多团队第一次遇到「双写」问题时,会写这样的代码:

BEGIN;
INSERT INTO orders (...) VALUES (...);
COMMIT;
publish("order.created", order_id);   -- 进程在这里崩溃,消息永远发不出去

数据库提交了,消息没发,下游永远不知道有这个订单。把顺序反过来,又会出现另一种错误:

publish("order.created", order_id);   -- 消息先发出去
BEGIN;
INSERT INTO orders (...) VALUES (...);
ROLLBACK;                              -- 插入失败,下游看到了一个不存在的订单

问题的本质是:数据库事务和消息发送是两个系统,没有一个本地事务能同时包住它们。2PC 可以包,但代价高;更常用的做法是 Outbox:把「要发的消息」写进业务库的同一张表、同一个事务,然后由另一个进程把 outbox 里的记录搬到消息队列。

6.7.1 Outbox 表

CREATE TABLE outbox (
  id           BIGSERIAL   PRIMARY KEY,
  aggregate_id TEXT        NOT NULL,
  event_type   TEXT        NOT NULL,
  payload      JSONB       NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at TIMESTAMPTZ
);

CREATE INDEX outbox_pending ON outbox (id) WHERE published_at IS NULL;

业务事务变成:

BEGIN;
INSERT INTO orders (id, user_id, amount) VALUES ('o-1', 'u-1', 100);
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES ('o-1', 'order.created', '{"order_id":"o-1","amount":100}');
COMMIT;

两条插入在同一个本地事务里:要么都在,要么都不在。不会出现「订单在、消息不在」;也不会出现「消息在、订单不在」。Outbox 把分布式双写问题降级成了本地事务问题,这是它唯一但足够重要的价值。

6.7.2 Relay:至少一次地搬

一个最小的 relay 循环:

def relay_once(conn, publish, batch: int = 100) -> int:
    with conn.transaction():
        rows = conn.execute(
            "SELECT id, aggregate_id, event_type, payload "
            "FROM outbox WHERE published_at IS NULL "
            "ORDER BY id LIMIT %s FOR UPDATE SKIP LOCKED",
            (batch,),
        ).fetchall()
        for row in rows:
            publish(row)  # 可能成功、可能超时;下游必须能去重
            conn.execute(
                "UPDATE outbox SET published_at = now() WHERE id = %s",
                (row["id"],),
            )
        return len(rows)

这段代码有几个取舍:

  • FOR UPDATE SKIP LOCKED 让多个 relay 实例可以并行处理,而不会互相阻塞;每个实例拿到不同的行。
  • ORDER BY id 保证同一批内的顺序;跨实例的全局顺序不保证,所以消息的消费端要能处理乱序,或者按 aggregate_id 分区。
  • publish 在事务里,如果 publish 成功但事务提交失败,消息会重发;如果 publish 超时但实际成功,也会重发。这就是至少一次,下游必须按事件 ID 去重。
  • 生产上通常会把「claim 一批、提交、再 publish、再标记」拆开,避免网络调用把数据库事务拉长;代价是状态机多一个 locked_until 字段。

6.7.3 消费端:去重表与业务写入在同一个事务里

至少一次投递意味着消费端一定会遇到重复。去重的关键不是「记住所有见过的 ID」,而是「把去重记录和业务效果放在同一个事务里」:

CREATE TABLE processed_events (
  consumer     TEXT        NOT NULL,
  event_id     UUID        NOT NULL,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer, event_id)
);
def handle_event(conn, event) -> None:
    with conn.transaction():
        inserted = conn.execute(
            "INSERT INTO processed_events (consumer, event_id) "
            "VALUES (%s, %s) ON CONFLICT DO NOTHING RETURNING event_id",
            ("billing", event["id"]),
        ).fetchone()
        if inserted is None:
            return  # 已经处理过,直接跳过
        conn.execute(
            "UPDATE accounts SET balance = balance + %s WHERE id = %s",
            (event["amount"], event["account_id"]),
        )

如果「插入去重记录」成功、「更新余额」失败,整个事务回滚,事件会被重新投递;如果两者都成功,事务提交,重复投递会被 ON CONFLICT 挡住。这里没有「先查再插」的竞态,因为唯一约束和业务写入在同一个事务里。

去重表会一直增长,需要按时间分区或定期清理。清理的保留窗口要大于「消息的最长重投递时间」,否则一个延迟很久的重投会被当成新事件。

6.7.4 Outbox、CDC 与本地消息表

方案原子性延迟侵入性适用
双写(先库后 MQ)无低低能容忍丢消息的场景
Outbox 轮询本地事务原子轮询间隔中(多一张表)大多数业务系统
CDC(Debezium 等)本地事务原子低(读日志)低(不改业务代码)已有 binlog/WAL,追求低延迟
本地消息表 + 事务消息本地事务原子低高(依赖 MQ 特性)特定 MQ 生态

CDC 不是「没有重复」:它读的是数据库日志,日志里的每一次变更都会被投递一次;如果消费端提交 offset 失败,或者 relay 重启,仍然会重放。CDC 只是把「写 outbox 表」换成了「读数据库日志」,至少一次的语义没有变。

Outbox 的三个常见误解
  1. 「有了 outbox 就不会重复」:outbox 只保证「业务数据和消息一起提交」,不保证「消息只被消费一次」;重复仍然会发生,去重必须在消费端做。
  2. 「outbox 可以随便删」:删除已发布记录前要确认所有消费者都已处理;保留窗口太短,重放/补数就没有依据。
  3. 「CDC 不需要业务改造,所以没有顺序问题」:CDC 按日志顺序投递,但消费者多实例、分区重平衡都会打乱处理顺序;顺序保证要落到分区键上。

6.8 失败模式:重试把一次转账做成两次

把前面几节拼起来,就得到本章最重要的一个失败模式。服务端代码如下,看起来没有任何问题:

def transfer(conn, from_acct: str, to_acct: str, amount: int) -> None:
    with conn.transaction():
        conn.execute(
            "UPDATE accounts SET balance = balance - %s WHERE id = %s",
            (amount, from_acct),
        )
        conn.execute(
            "UPDATE accounts SET balance = balance + %s WHERE id = %s",
            (amount, to_acct),
        )
        conn.execute(
            "INSERT INTO transfers (from_acct, to_acct, amount) VALUES (%s, %s, %s)",
            (from_acct, to_acct, amount),
        )

事务是原子的,两条 UPDATE 和一条 INSERT 要么全做、要么全不做。数据库层面没有任何约束被违反。但客户端调用的是 POST /transfer,请求里没有幂等键;SDK 在 1 秒超时后自动重试一次。

时间客户端服务端数据库结果
t0发送 POST /transfer,无幂等键收到请求——
t1—BEGIN;扣 A;加 B;插 transfers行锁、写 WAL—
t2—COMMIT 成功已提交扣款已生效
t31 s 超时,重试同一请求网关/连接已断,响应没回去—客户端认为失败
t4—收到重试,视为新请求BEGIN;再扣 A;再加 B;再插一条—
t5—COMMIT 成功两条 transfersA 少了 200,B 多了 200
t6收到 200,显示成功——用户只看到一次成功

这个事故的根因不在数据库,也不在重试策略本身,而在两个边界的错位:

  • 数据库的事务边界是 BEGIN ... COMMIT,它保证「这一次尝试」内部不撕裂;
  • 用户意图的边界是「A 向 B 转 100 元」,它应该只生效一次;
  • 重试产生了一次新的尝试,服务端为它开了一个新事务;数据库没有任何信息能判断「这和刚才那笔是同一个意图」。

事务是幂等的敌人吗?不是。事务根本不知道「意图」存在。 它只看见语句。没有幂等键,服务端就无法把第二次尝试归并到第一次;两次尝试各自都是合法事务,合起来就是重复副作用。

6.8.1 两种更隐蔽的变体

上面是最直观的「响应丢失 + 客户端重试」。还有两种变体,往往更难发现:

变体一:服务端内部重试死锁事务。 6.5 的重试封装会把整个事务再执行一遍。如果事务里只有数据库操作,重试是安全的;但如果事务里还调用了外部服务(扣款网关、发券、发短信),回滚不会撤销外部副作用:

with conn.transaction():
    conn.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 'A'")
    gateway.charge(customer_id, 100)   # 外部副作用:事务回滚不会退款
    conn.execute("INSERT INTO transfers ...")

第一次执行到 gateway.charge 后发生死锁,事务回滚,重试再次 charge。数据库里可能只有一条 transfer,网关却扣了两次。事务边界只能包住数据库,包不住网络调用。

变体二:提交成功后抛异常。 服务端 COMMIT 成功,然后在序列化响应、写日志、发消息时抛异常;上层捕获异常后重试。数据库已经提交,重试会再执行一次。这类 bug 在「提交后还有一段代码」的实现里很常见。

6.8.2 修复:让「意图」在数据库里留下痕迹

修复的核心不是「别重试」,而是「让重试可以被识别」。最小方案是客户端生成请求 ID,服务端在同一个事务里先插入这个 ID,用唯一约束做闸门:

CREATE TABLE transfers (
  id          BIGSERIAL   PRIMARY KEY,
  request_id  UUID        NOT NULL UNIQUE,
  from_acct   TEXT        NOT NULL,
  to_acct     TEXT        NOT NULL,
  amount      NUMERIC     NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

BEGIN;
INSERT INTO transfers (request_id, from_acct, to_acct, amount)
VALUES ('6f1c2a9e-8d1b-4f3a-9c2e-2b7d5a0c1e44', 'A', 'B', 100)
ON CONFLICT (request_id) DO NOTHING
RETURNING id;
-- 如果没有返回行,说明这个 request_id 已经处理过:
-- 查出原记录,直接返回原结果,不再扣款
-- 只有上面返回了行(首次请求)才执行下面两条:
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';
COMMIT;

注意这里的顺序:先插入幂等记录,再做副作用。如果反过来(先扣款再插入),第二次重试会在扣款之后才撞上唯一约束,扣款已经发生,事务虽然会回滚,但如果中间还有外部调用就来不及了。

如果 INSERT ... ON CONFLICT DO NOTHING 没有返回行,应用必须查原记录并返回原结果,而不是继续执行后面的 UPDATE。这一步经常被漏掉:约束挡住了重复插入,代码却继续扣款,等于白做。

6.8.3 唯一约束挡不住的部分

唯一约束能挡住「同一张表插两条」,但挡不住下面这些:

  • 外部副作用:扣款网关、发券、发短信不在数据库里,唯一约束管不到;
  • 跨表/跨服务的效果:A 库的 transfers 唯一了,B 库的 ledger 可能还有一条;
  • 异步链路:事务提交后通过 outbox 发消息,消息重发会让下游再执行一次。

所以完整的修复通常是三层:

层机制挡住什么挡不住什么
接入层客户端幂等键 + 服务端去重表同一意图的重复请求客户端换键、用户重复点击
存储层唯一约束 + 同事务写业务/去重记录同一键的重复写入外部调用、跨库效果
业务层状态机 + 对账 + 补偿中间状态、遗漏、错账需要人工介入的极端情况
例 6.8.1 · 一次转账,两次扣款

某支付服务在高峰期出现「用户投诉扣了两次」。排查发现:客户端超时 1 s,SDK 自动重试一次;服务端 transfers 表没有 request_id,每次请求都插入新行;两次请求相隔 1.2 s,都成功提交。数据库日志显示两条 transfers,金额相同,时间相差 1.2 s。

修复分三步:

  1. 客户端在用户点击时生成 request_id,所有自动/手动重试复用它;
  2. 服务端建唯一索引 transfers(request_id),先插入、再扣款,冲突时返回原记录;
  3. 对历史数据按 (from_acct, to_acct, amount, 时间窗) 做一次对账,把确认重复的流水冲正。

第三步不能省。只改代码不修历史,用户看到的账单仍然是错的;而且对账过程本身会暴露「哪些重复是重试、哪些是真实的两笔转账」,不能一律删。

幂等键的粒度

request_id 应该代表「一次用户意图」,而不是「一次 HTTP 请求」。如果用户在页面上点两次「转账」,这是两个意图,应该有两个 key;如果 SDK 自动重试,应该是同一个 key。把 key 生成在「用户点击」而不是「HTTP 客户端发送」这一层,才能同时覆盖自动重试和手动重试。

6.9 观测:pg_stat_activity、pg_locks 与 InnoDB status

事务出问题时,第一现场通常不是错误日志,而是「现在谁在等谁」。PostgreSQL 的 pg_stat_activity 和 pg_locks、MySQL 的 information_schema.innodb_trx 和 SHOW ENGINE INNODB STATUS,能回答三个问题:有没有长事务?谁在等锁?回滚和死锁是不是在变多?

6.9.1 一条命令看长事务与锁等待

psql "$PGDSN" -x -c "
SELECT pid, usename, state,
       now() - xact_start AS xact_age,
       now() - query_start AS query_age,
       wait_event_type, wait_event,
       pg_blocking_pids(pid) AS blockers,
       left(query, 60) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_age DESC NULLS LAST
LIMIT 8;"

一次本机测试的输出(节选):

-[ RECORD 1 ]--+--------------------------------------------
pid            | 48213
usename        | app
state          | idle in transaction
xact_age       | 00:12:41.238
query_age      | 00:12:41.236
wait_event_type| Client
wait_event     | ClientRead
blockers       | {}
query          | UPDATE accounts SET balance = balance - 100 ...

-[ RECORD 2 ]--+--------------------------------------------
pid            | 48290
usename        | app
state          | active
xact_age       | 00:01:07.512
query_age      | 00:01:07.510
wait_event_type| Lock
wait_event     | transactionid
blockers       | {48213}
query          | UPDATE accounts SET balance = balance + 100 ...

怎么读:

字段/组合正常异常先做什么
state = idle in transaction短暂出现(毫秒级)持续数秒到数分钟应用忘了 COMMIT/ROLLBACK,或连接池归还逻辑有 bug
xact_ageOLTP < 1 s> 5 s 就值得看,> 1 min 基本是事故找这个事务在等什么、做了什么
wait_event_type = Lock偶尔短暂持续 > 1 s看 blockers,找链的根
blockers 非空空有 PID被阻塞者不是根因,根因在 blocker
query_age 接近 xact_age正常长查询持锁考虑 statement_timeout 或拆批

最关键的一条:不要从被阻塞的 PID 开始优化,要顺着 blockers 找到根。一条 UPDATE 等了 60 秒,可能只是因为另一个连接 12 分钟前开了事务、再也没提交。

6.9.2 锁等待与回滚率

找到根之后,用 pg_locks 确认锁的类型和对象:

psql "$PGDSN" -c "
SELECT l.pid, l.locktype, l.mode, l.granted, c.relname
FROM pg_locks l
LEFT JOIN pg_class c ON c.oid = l.relation
WHERE NOT l.granted
   OR l.pid IN (SELECT pid FROM pg_stat_activity
                WHERE cardinality(pg_blocking_pids(pid)) > 0)
ORDER BY l.pid;"

再看回滚率和死锁。这两个指标是累计值,单次采样没有意义,要看趋势或增量:

psql "$PGDSN" -c "
SELECT xact_commit, xact_rollback, deadlocks,
       round(100.0 * xact_rollback
             / nullif(xact_commit + xact_rollback, 0), 2) AS rollback_pct
FROM pg_stat_database
WHERE datname = current_database();"
 xact_commit | xact_rollback | deadlocks | rollback_pct
-------------+---------------+-----------+--------------
     1842301 |         41207 |        17 |         2.19

读法:

  • rollback_pct 在 OLTP 系统里通常应低于 1%。2.19% 说明每 50 个事务就有一个回滚,可能是业务校验失败、唯一键冲突,也可能是序列化失败重试。
  • deadlocks 是累计值。17 次本身不可怕,可怕的是单位时间内持续增长;把采样时间点记下来,算增量。
  • 回滚率高不一定是坏事:如果都是「重复请求撞唯一键」,说明幂等闸门在工作;如果都是 40001/40P01,说明并发冲突或加锁顺序有问题。

6.9.3 InnoDB:History list length 与活跃事务

MySQL 侧看两张东西:information_schema.innodb_trx 里的活跃事务,和 SHOW ENGINE INNODB STATUS 里的 History list length。

mysql -N -e "SELECT trx_id, trx_state,
  TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_s,
  trx_rows_locked, trx_rows_modified,
  LEFT(trx_query, 60) AS query
FROM information_schema.innodb_trx
ORDER BY trx_started LIMIT 5;"

mysql -e "SHOW ENGINE INNODB STATUS\G" | sed -n '/TRANSACTIONS/,/FILE I\/O/p'
4218842  RUNNING  128  3  1  UPDATE accounts SET balance = balance - 100 ...

------------
TRANSACTIONS
------------
Trx id counter 4218850
Purge done for trx's n:o < 4218831 undo n:o < 0 state: running
History list length 1823411
---TRANSACTION 4218842, ACTIVE 128 sec
3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1

读法:

指标正常异常含义
age_s(事务年龄)秒级> 60 s长事务,可能持锁、拖住 purge
trx_rows_locked个位数成百上千范围更新/缺索引,锁范围过大
History list length随写入量波动,稳定或缓慢增长持续快速增长,或 > 100 万purge 跟不上,undo 膨胀
ACTIVE n sec短长与 innodb_trx 对照,找同一个事务
LATEST DETECTED DEADLOCK偶尔有频繁出现加锁顺序/热点行问题
事务观测的三步
  1. 先找最老的事务:xact_age / trx_started 排序,看它在等什么、持有什么。
  2. 再找锁等待的根:pg_blocking_pids / data_locks,顺着链找到 blocker;被阻塞者只是受害者。
  3. 最后看趋势:回滚率、死锁数、History list length 都要看增量;单点采样会被业务周期骗。

6.10 真实案例:Spanner 的 TrueTime 与 2PC

2012 年,Google 在 OSDI 发表了 Spanner 的论文,描述一个全球分布、外部一致(externally consistent)的数据库。它经常被引用成「全球强一致可以做到」,但论文真正有意思的地方不是「强一致」,而是它如何把「时间不确定」变成「等一会儿」,以及 2PC 在这个架构里到底承担了什么。

6.10.1 TrueTime:把现在表示成一个区间

普通机器上的 now() 返回一个时间点,但这个点可能有几毫秒到几秒的误差。Spanner 的 TrueTime API 不返回点,而返回一个区间:

TT.now() = [earliest, latest]
ε = latest - earliest

TrueTime 由 GPS 接收器和原子钟支撑,Google 的数据中心里 ε 通常在 7 ms 以内(论文给出的典型值)。TT.after(t) 和 TT.before(t) 不是「大概」,而是「在 ε 的假设下,确定 t 已经过去/还没到」。

拿到区间之后,Spanner 给读写事务分配提交时间戳 s = TT.now().latest,然后等到 TT.after(s) 为真才真正提交。这段等待叫 commit-wait,长度大约是 2ε:

TT.now() = [e, l],  ε = l - e
             e          l
真实时间 ────┼──────────┼────────▶
             │          │
             │    选 s = l
             │          │
             │◀─────────┼────▶ 等待 TT.after(s),约 2ε
                        │
                     commit @ s

为什么这样能保证外部一致性?因为任何在 s 之后开始的事务,拿到的时间戳一定大于 s;任何在 s 之前提交的事务,时间戳一定小于 s。commit-wait 把「时钟可能不准」换成了「等足够久,久到不确定性已经过去」。代价是每次写多等约 2ε;ε 变大,写延迟就变大。

6.10.2 2PC 在 Spanner 里的位置

Spanner 的数据按 tablet 分片,每个分片是一个 Paxos 复制组。跨分片的事务用 2PC:

层机制作用代价
副本一致每个分片内 Paxos单分片内多数派提交、选主一轮 quorum RTT
跨分片原子2PC多个 Paxos 组一起提交/回滚两阶段、协调者、阻塞窗口
时间TrueTime + commit-wait给事务一个全局可比较的时间戳约 2ε 的等待
只读事务快照时间戳不加锁,从足够新的副本读读到的是某个过去的时间点

这里有两级协议:组内 Paxos,组间 2PC。2PC 的协调者本身也要高可用,否则它一崩,所有 in-doubt 的跨组事务都卡住;Spanner 把协调者状态也复制起来。只读事务不参与 2PC,它们在一个选定的时间戳上读快照,因此不需要锁,也不会阻塞写。

这个组合说明:2PC 不是「分布式事务的银弹」,它只是原子提交的一层。没有 Paxos,协调者和参与者都可能丢状态;没有 TrueTime,跨地域的事务无法给出外部一致的时间顺序;有了这两者,还要付出 commit-wait 和跨地域 RTT 的代价。

6.10.3 失败链与教训

Spanner 不是「不会坏」,它的设计把失败从「数据不一致」转移到了「延迟上升/不可用」:

  • ε 变大:GPS 信号受干扰、时钟漂移,TrueTime 的不确定性上升;commit-wait 变长,写延迟上升,上层超时和重试增多。
  • 协调者故障:跨组 2PC 的 in-doubt 事务要等协调者恢复;协调者用 Paxos 复制来缩短这个窗口,但不能消除。
  • 跨地域网络抖动:组内 Paxos 和组间 2PC 都要 RTT;跨大洋的写延迟是物理距离决定的,不是软件能优化的。
  • 只读副本落后:快照读不阻塞,但读到的可能是旧时间点;需要「读最新」时,仍要选足够新的时间戳。

五条可以带走的教训:

  1. 原子提交和一致性是两件事:2PC 解决「一起提交」,TrueTime 解决「时间顺序」,缺一不可。
  2. 不确定性可以被等待消除:commit-wait 的本质是用延迟换确定性;这个交易在很多系统里都成立。
  3. 协调者必须高可用:2PC 的单点不是理论问题,是值班时真会遇到的阻塞。
  4. 只读路径可以绕过 2PC:快照读是 Spanner 性能的关键;写路径的代价不能套到读上。
  5. 不是所有系统都能复制这套方案:它依赖 GPS/原子钟、受控的数据中心网络、专门的运维;普通云上多租户应用很难假设 ε 有界。
事实边界

本节只引用 Spanner 2012 年 OSDI 论文的公开内容:TrueTime 的区间语义、ε 的典型量级、commit-wait、Paxos 分片与跨组 2PC。论文里的数字是当时 Google 数据中心的测量值,不是对所有环境的承诺;具体的当前实现细节以 Google 的公开论文/文档为准。链接见 A.3 参考文献 与 Spanner 论文。

6.11 重试导致重复副作用:非幂等 INSERT 的重放

6.8 讲的是「转账服务没有幂等键」。这一节把镜头再拉近一点:即使事务本身没问题,一条非幂等的 INSERT 被重放,也会产生两条记录。它的可怕之处在于「没有任何错误」:两次插入都成功,数据库不报错,只有对账时才发现多了。

6.11.1 一条没有唯一键的流水表

CREATE TABLE ledger (
  id         BIGSERIAL   PRIMARY KEY,
  account_id TEXT        NOT NULL,
  amount     NUMERIC     NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 每次重试都执行一次;id 是新生成的,数据库没有任何理由拒绝
INSERT INTO ledger (account_id, amount) VALUES ('A', -100);

重放可能来自四个地方:

重放来源怎么发生表现闸门
客户端重试超时后重发同一请求两条金额相同的流水客户端 request_id + 唯一约束
服务端重试未回滚干净部分写入已提交、或外部写入已发生两条流水,时间接近重试前确保整个事务回滚
Outbox/CDC 重投relay 重启、offset 提交失败消费端重复执行processed_events 去重表
人工补数运维重跑脚本批量重复脚本幂等 + dry-run

它们的共同点是:重放的是「动作」,不是「结果」。如果动作是 INSERT,重放就多一行;如果动作是 UPDATE balance = 100(绝对值),重放可能无害;如果动作是 UPDATE balance = balance - 100(增量),重放就多扣一次。

6.11.2 修复:唯一键 + 原子 upsert

给流水表加一个客户端生成的 request_id,并让它唯一:

CREATE TABLE ledger (
  id          BIGSERIAL   PRIMARY KEY,
  request_id  UUID        NOT NULL UNIQUE,
  account_id  TEXT        NOT NULL,
  amount      NUMERIC     NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

写入用「原子 upsert」,保证无论重放多少次,都返回同一行的 id:

INSERT INTO ledger (request_id, account_id, amount)
VALUES ('6f1c2a9e-8d1b-4f3a-9c2e-2b7d5a0c1e44', 'A', -100)
ON CONFLICT (request_id)
DO UPDATE SET request_id = EXCLUDED.request_id
RETURNING id;

为什么用 DO UPDATE 而不是 DO NOTHING?因为 DO NOTHING 在冲突时不返回行,应用还得再查一次;而「插入」和「查询」之间可能有并发窗口。DO UPDATE SET request_id = EXCLUDED.request_id 是一个空更新,但它会锁住冲突行并 RETURNING 原 id,让「插入或取回」变成一次原子操作。代价是会多产生一个行版本(PostgreSQL)或一次 undo(InnoDB),对流水表通常可以接受;如果写入量极大,可以用 DO NOTHING + 重查,但要处理好「查不到」的竞态。

对应的 Python:

def append_ledger(conn, request_id: str, account_id: str, amount: int) -> int:
    row = conn.execute(
        "INSERT INTO ledger (request_id, account_id, amount) "
        "VALUES (%s, %s, %s) "
        "ON CONFLICT (request_id) DO UPDATE "
        "SET request_id = EXCLUDED.request_id "
        "RETURNING id",
        (request_id, account_id, amount),
    ).fetchone()
    return row["id"]

这段代码只解决「同一 request_id 不重复」。它不解决:

  • 客户端换了 request_id(用户点了两次,或 SDK 每次重试都生成新键);
  • 副作用发生在另一张表/另一个服务(6.8 的三层修复);
  • 消费端重复处理同一条消息(需要 6.7 的 processed_events)。

6.11.3 对账:发现已经发生的重复

唯一约束只能防未来,不能修历史。上线修复之后,通常要跑一次对账,找出已经重复的流水:

SELECT account_id, amount,
       date_trunc('second', created_at) AS second,
       count(*) AS n
FROM ledger
GROUP BY 1, 2, 3
HAVING count(*) > 1
ORDER BY n DESC
LIMIT 50;

这个结果是候选集,不是判决书:同一秒内的同额流水可能真是两笔。确认重复后用冲正流水抵消,不要物理删除,否则审计链和「余额 = 流水之和」都会断。

例 6.11.1 · 同一笔退款,两条流水

退款服务收到一条「退款 100 元」的消息,执行 INSERT INTO ledger (account_id, amount) VALUES ('A', 100),提交成功,然后给消息队列回 ack。ack 在网络上丢了,消息队列重投;服务再次插入,又成功。结果 A 账户多了 200 元。

修复:

  1. 消息带 event_id,消费端在同一个事务里写 processed_events 和 ledger;
  2. ledger.request_id 唯一,重放时 ON CONFLICT 返回原记录;
  3. 对账任务按 event_id 比对消息和流水,发现「有流水、无消息」或「有消息、无流水」的缺口。

注意第 1 步和第 2 步不是二选一:processed_events 挡住消费端重复,ledger.request_id 挡住写入端重复;两层都做,才能覆盖「消费端重启」和「上游重发」两种不同的重放。

6.12 事务边界设计清单

把前面所有讨论压缩成一张表。每设计一个写路径,从上到下过一遍;答不上来的那一条,就是下一次事故的入口。

#检查项为什么合格的样子
1事务里有没有 RPC/HTTP/文件 IO?网络调用会拉长事务、持锁,且回滚撤不回副作用事务里只有数据库读写
2每个写操作有幂等键吗?谁生成?没有身份就无法识别重试客户端在「用户意图」层生成,重试复用
3唯一约束建在哪张表、哪些字段?约束是最后一道闸门与业务写入同库同事务,先插入再副作用
4键冲突时返回什么?返回错会让客户端误判原结果 + 200,或 409(同键不同参)
5隔离级别够不够?有没有写偏斜?跨行不变量不会被 RR 自动保护用 Serializable + 重试,或把不变量收敛到一行
6加锁顺序统一吗?相反顺序是死锁的头号来源按主键/账户 ID 排序后加锁
7重试只针对可重试错误吗?重试约束冲突/语法错误没有意义40001、40P01、1213、1205,其余不重试
8重试前重新读状态了吗?带着旧读结果重试会重复冲突新事务 + 重新查询 + 重新判断
9外部副作用幂等/可补偿吗?数据库回滚不会退款幂等键、TCC 预留、或 Saga 补偿
10是否用 outbox 替代双写?双写没有原子性业务行 + outbox 行同事务
11消费端去重与业务写入同事务吗?分开写会有「处理了但没记」的窗口processed_events + 业务更新同一事务
12长事务/锁等待有超时和告警吗?长事务拖住 vacuum/purge,锁等待拖垮连接池statement_timeout、lock_timeout、面板告警
13对账覆盖哪些不变量?协议和对账是最后一道防线余额=流水之和、订单=库存扣减、支付=记账
14补偿失败后的人工路径是什么?Saga/TCC 都会有补偿失败工单、告警、可重放的修复脚本

这张表里最容易被跳过的是第 1 项和第 9 项。前者决定事务会不会变成「长事务 + 锁等待」,后者决定重试会不会变成「重复扣款」。先画边界,再写代码;边界画错,后面每一条都在给错误打补丁。

6.13 本章陷阱清单

12 条高频错误
  1. 把超时当失败直接重试:超时只说明本地不再等待,服务端可能已提交;必须带幂等键进入 unknown,而不是换新键重发。
  2. 在数据库事务里调用外部服务:回滚不会撤销已发出的扣款/发券/短信;外部调用要移到事务外,或用 outbox/TCC。
  3. 以为 RR 就没有写偏斜:快照隔离只保证读一致,不保证跨行不变量;值班、预算、库存都可能被两个「各自合法」的事务破坏。
  4. 在同一个事务里重试单条 SQL:PostgreSQL 语句失败后事务已 aborted(25P02),必须先回滚再重开。
  5. 重试所有数据库错误:唯一键冲突、语法错误、类型错误重试一万次也不会成功,只会浪费连接和放大过载。
  6. 用自增 ID 当幂等键:自增 ID 是服务端生成的,每次重试都是新值;键必须由客户端在首次发送前生成。
  7. 唯一约束建了,但代码在冲突后继续扣款:ON CONFLICT DO NOTHING 不返回行时,应用必须返回原结果,而不是继续执行副作用。
  8. 把 2PC 当银弹:2PC 只保证原子提交,不保证隔离、不保证高可用;协调者崩溃时参与者会阻塞在 in-doubt。
  9. Saga 补偿不幂等:补偿动作会被重试,退款/解冻重复执行就是二次事故;补偿必须带幂等键。
  10. TCC 不处理空回滚和悬挂:Cancel 先到、Try 后到都会留下永远不释放的预留资源;事务控制表要记录 cancel 并拒绝迟到的 Try。
  11. outbox 发完就删:保留窗口短于消费端的最大重投递时间,重放/补数就没有依据;删除前确认消费位点。
  12. 只看 COMMIT 返回就认为数据安全:异步备库、synchronous_commit=off、innodb_flush_log_at_trx_commit!=1 都可能让已提交事务在崩溃或切换后丢失/无法恢复。

6.14 本章出口检查

检查项达标线没达标去哪
解释 ACID 的 C能说出「C 是应用不变量,不是数据库赠品」并举一个跨服务例子6.1
说出五类异常能区分丢失更新与写偏斜,并各举一例6.2
画出 MVCC 版本链能标出 xmin/xmax 或 DB_TRX_ID/ROLL_PTR,并说出可见性规则6.3
对比 InnoDB 与 PostgreSQL能说出旧版本存放位置、回收机制、长事务症状的差异6.4
处理死锁能识别 40001/40P01/1213,只重试整个事务,带退避与抖动6.5
画出 2PC 失败窗口能指出协调者投 yes 后崩溃时参与者为什么不能自救6.6
解释 TCC 的空回滚与悬挂能说出事务控制表如何同时解决两者6.6
设计 Saga 补偿能说出补偿不幂等、补偿失败、中间状态可见三个问题6.6
用 outbox 替代双写能写出业务行 + outbox 同事务,并说明消费端如何去重6.7
用命令定位长事务能从 pg_stat_activity 的 xact_age/blockers 找到根6.9
修复非幂等 INSERT能给出唯一键 + 原子 upsert + 对账三步6.11
过一遍事务边界清单14 项里至少能答出 12 项6.12
即时自测 · 6

Q1 客户端 1 s 超时后重试了一次,服务端两次都提交成功,账上扣了两次。修复的第一步应该是什么?

Q2 两个事务都读到「有 2 名医生在岗」,各自把一名医生改成下班,都提交成功。这是什么异常?哪个隔离级别能自动阻止?

Q3 2PC 中协调者在所有参与者投 yes 之后、发出 commit 之前崩溃。参与者此时能自己决定回滚吗?为什么?

Q4 COMMIT 已经返回,为什么备库提升后仍可能丢数据?列出两个具体原因。

Q5 Outbox 保证了「业务数据和消息一起提交」,为什么消费端仍然要去重?

Q6 pg_stat_activity 里看到一个连接 state=idle in transaction、xact_age=12 分钟、backend_xmin 很老。你会先做什么?它可能造成哪两个连锁问题?

答案

A1 先让「意图」有身份:客户端在用户点击时生成 request_id,所有重试复用它;服务端在 transfers(request_id) 上建唯一约束,先插入幂等记录再扣款,冲突时返回原结果。然后才是对历史重复流水做对账冲正。

A2 写偏斜。两个事务写的是不同的行,没有写写冲突,所以快照隔离(PostgreSQL/InnoDB 的 RR)允许它们都提交;只有可串行化(PostgreSQL 的 SSI)或显式加锁能自动阻止。

A3 不能。参与者投 yes 后已经把 prepared 状态落盘并持有锁,它不知道其他参与者是否也投了 yes;单方面回滚可能和协调者的 commit 决定冲突,造成部分提交。正确做法是等协调者恢复后按日志重发决定;这也是 2PC 需要高可用协调者的原因。

A4 原因一:synchronous_commit=off,提交返回时 WAL 还没刷盘,主机断电可能丢失最近事务(local 会等本地刷盘,但不等备库);原因二:异步复制,主库提交后还没传到备库就宕机,备库提升后缺少最后一段 WAL。innodb_flush_log_at_trx_commit!=1 和 sync_binlog!=1 也会造成类似窗口。

A5 Outbox 只把「写业务数据」和「写消息记录」变成一个本地事务,relay 仍然是至少一次投递:发布成功但标记失败、relay 重启、消费端 offset 提交失败都会重发。去重必须放在消费端,用 (consumer, event_id) 唯一约束和业务写入同事务完成。

A6 先看它在等什么、持有什么:wait_event、pg_blocking_pids、以及它持有的 backend_xmin 挡住了哪些回收;确认是应用忘了提交还是连接池归还 bug,必要时 pg_terminate_backend 止损。两个连锁问题:VACUUM 无法回收旧版本导致表/索引膨胀;它持有的行锁让后续写排队,连接池被耗尽。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 5/6 章6.1、6.6背景只借用「分层与端到端」的视角;事务协议不在母本范围
张彦飞《深入理解 Linux 网络》第 4–7 章6.9背景连接/超时只引用,不展开内核路径
《Python 网络编程》并发与客户端工程6.5、6.7精选借用工程风格;重试、outbox 代码为本书重写
《Linux 系统与内核》第 4 篇4.1–4.116.9交叉引用明确不重复 TCP/IP、socket、epoll
本书自编 + 数据库教材/论文Gray 2PC、Garcia-Molina Saga、Spanner OSDI 20126.3、6.6、6.10完整只引用定义与公开事实,不复制论文/文档长段
PostgreSQL / MySQL 官方文档MVCC、锁、pg_stat_*、InnoDB 锁6.3、6.4、6.9参考命令与字段以文档为准,本章只讲读法

这一章没有对应的母本章节:谢希仁和张彦飞都不讲事务隔离、MVCC 和 2PC。本书自编的部分集中在「机制 → 失败窗口 → 观测命令」这条链上;定义和协议事实来自数据库教材与论文,代码和事故场景是按工程实践重写的,不复制原文。

下一站:事务解决的是「一次操作要么全做、要么全不做」,但仍有很多事需要「先记下来、稍后处理」。进入 7 队列、流与事件驱动,看至少一次投递、offset 提交与消费者组如何接住本章留下的 outbox 和重复消息。

7 队列、流与事件驱动

本章回答

一条消息「发出去了」到底承诺了什么?为什么消费端明明处理成功,重启后同一条消息又来一次?分区、消费者组、offset 提交和再平衡之间是什么关系?Kafka 的 acks、ISR、幂等生产者和事务各自保证到哪一步,又在哪一步必须交给业务去重?

读完你会

能区分队列与日志的语义并选出正确模型;能画出三种投递语义的失败窗口;能根据分区键说出顺序保证的范围;能读懂 consumer lag 和 rebalance 的状态;能写出 outbox + 消费者去重的可运行代码;能设计一条可重放、可演进、不会二次扣款的事件。

第 6 章的最后停在 outbox:业务行和消息行在同一个本地事务里提交,消费端用去重表挡住重复。那还是在「一次请求、一个数据库」的范围内。本章把镜头拉远:一次状态变化被写成事件之后,它会被多少个消费者、以什么顺序、重复多少次地读到?

一个支付成功事件 payment.charged 通常有四个消费者:记账、通知、风控、数据仓库。记账 consumer 停机两小时,恢复后要从两小时前的位置继续;通知 consumer 发版后从旧 offset 重放,可能给用户再发一条短信;风控要求同一张卡的事件严格有序;数仓只关心最终结果,重复和乱序都能靠聚合消化。同一条事件,四种「次数」和「顺序」要求。

队列和日志是两种不同的答案。队列把消息当任务,ack 之后删除;日志把消息当事实,追加之后保留,消费者用 offset 表示自己读到哪。选错模型,后面所有补偿都是在给语义打补丁。本章先讲清这两种模型,再讲投递语义、分区与消费者组、offset 提交、Kafka 的复制与事务,最后落到 outbox、CDC、事件溯源,以及一条 offset 提交失败导致的二次扣款。

路线是:7.1 队列与日志;7.2 三种投递语义;7.3 分区、顺序与消费者组;7.4 offset 提交与再平衡;7.5 ISR、acks、幂等生产者与事务;7.6 outbox、CDC 与事件溯源。后半程是四个必须单独成节的事:重复投递与乱序(7.7)、consumer lag 与 rebalance 的观测(7.8)、LinkedIn 的 Kafka 规模与运维(7.9),以及 offset 提交失败后的二次扣款(7.10)。最后是事件设计清单、陷阱和出口检查。

一句话先放在这里:消息系统的语义不是配置出来的,是生产者、broker、消费者三方行为组合出来的。

7.1 队列与日志:删除 vs 保留

先看两个方向相反的工单。

工单 A:一个发邮件的任务队列,消息的可见性超时是 30 秒,处理函数偶尔要跑 45 秒。超时后 broker 把同一条消息投给另一个 worker,两个 worker 都发了邮件。用户收到两封。

工单 B:一个 Kafka 消费者组停机了五天,topic 的保留期是三天。恢复时 consumer 的 offset 已经落在日志起点之前,客户端报 OffsetOutOfRange,按策略重置到 latest,五天的事件被跳过。下游报表少了一周的数据。

一个多了,一个少了,但问的是同一个问题:消息被消费之后,还应该存在多久? 队列的回答是「ack 就删」,日志的回答是「到期才删」。这个差异决定了消费进度归谁管、能不能重放、慢消费者会看到什么。

维度队列(SQS、RabbitMQ)日志(Kafka、Pulsar、Kinesis)
基本单位一条消息一个分区内的一条记录
生命周期ack/delete 后删除追加后按保留策略删除或压实
消费进度broker 记录每条消息的投递/确认状态消费者组自己提交 offset
顺序单队列近似 FIFO;多消费者竞争单分区严格按 offset
重放只能重投未 ack/DLQ 的消息重置 offset 即可重放(保留期内)
扩展方式增加竞争消费者增加分区
慢消费者的后果积压;可见性超时后重复投递lag 增长;超过保留期则数据缺口
典型失败重复消费、死信堆积重复消费、offset 越界、分区倾斜

这张表里最重要的是第三行:队列的进度在 broker,日志的进度在消费者。队列可以在超时后把消息再给你一次,因为它知道你没 ack;日志不会主动找你,它只在你回来时告诉你「你上次说到 4,但日志现在从 100 开始了」。

定义 7.1.1 · 队列与日志

队列(queue)把每条消息当作一个待办任务:broker 持有消息状态,消费者 ack 后删除;未 ack 的消息在可见性超时后重新投递。日志(log)把每条记录当作一个不可变的事实:只能追加,按分区内的 offset 排序,broker 按保留策略删除整段,消费者组通过提交 offset 声明自己的进度。

两者的核心差异不是「有没有分区」,而是消费进度由谁拥有:队列由 broker 拥有,日志由消费者拥有。进度归谁,谁就负责重复与丢失的语义。

日志里的 offset 是一个 64 位整数,表示记录在分区内的位置。记录一旦写入,offset 就固定;新记录追加在末尾。消费者提交的 committed offset 是「下一条要读的位置」,不是「最后一条读过的位置」。如果处理完 offset 5 之后提交,提交的值是 6。

日志、消费者 offset 与重复投递 消息 0–7 按顺序写入;消费者处理到 5,但 offset 只提交到 4,崩溃后 5 被重投。 日志、消费者 offset 与重复投递 分区日志(append-only) msg 0 offset 0 msg 1 offset 1 msg 2 offset 2 msg 3 offset 3 msg 4 offset 4 msg 5 offset 5 msg 6 offset 6 msg 7 offset 7 已提交 offset = 4 已处理到 5,但提交前崩溃 重启后从 5 重投 msg 5 再来一次 副作用可能翻倍 至少一次 = 允许重复;有效一次 = 业务去重 + 原子提交。
读图:「处理成功」和「offset 提交成功」是两个动作;两者之间崩溃,就会重复。把业务写与 offset 放在同一个事务里,或者让消费幂等,才能把重复变成无害。

图中消费者处理到 msg 5,但只提交到 4。按图里的「最后处理位点」口径,4 及之前已确认,重启从 5 开始读,msg 5 被再处理一次。Kafka API 的 committed offset 是「下一条要读」,同一个状态提交的值是 5;两种口径差一位,读监控、日志和文档时必须先确认用的是哪一种,否则会多算或少算一条。「处理成功」和「提交成功」是两个动作,两者之间有一个无法消除的窗口;这个窗口就是本章后面所有去重逻辑的起点。

还有三个位置需要区分,它们经常被混为一谈:

位置含义谁维护
LEO(log end offset)分区末尾下一条记录的 offsetbroker
HW(high watermark)已复制到 ISR 的记录位置;消费者只能读到 HW 之前broker
committed offset消费者组下一条要读的位置consumer group
LSO(last stable offset)事务隔离下,read_committed 消费者能读到的最远位置broker

LEO - committed offset 就是常说的 lag。它不是「有多少条没处理」,而是「有多少条还没提交」;处理了但没提交的记录也算在 lag 里。这个区别在 7.8 会用到。

保留策略不是一个运维参数,而是日志对外契约的一部分。Kafka 的 retention.ms/retention.bytes 按段(segment)删除,段文件名就是该段的 base offset,例如 00000000000000000123.log 表示这个段从 offset 123 开始。删除以段为单位,所以实际保留时间可能比配置多出一个段。消费者停机时间一旦超过保留期,恢复时就会遇到 OffsetOutOfRange;此时 auto.offset.reset 决定它是跳到最早还是最新,两种都会留下数据缺口。

日志还有另一种保留方式:压实(compaction)。它不按时间删除,而是对每个 key 只保留最后一条记录,更早的版本在后台被清理;删除某个 key 则写一条 value 为空的墓碑(tombstone),过一段时间连墓碑也被清理。压实适合「用日志保存一张表的当前状态」,例如 Kafka 里保存消费者偏移的 __consumer_offsets,或者事件溯源里的快照流。压实之后 offset 会出现空洞,所以 offset 不能当消息 ID 用。

保留方式保留什么适合风险
按时间删除最近 N 天/小时的所有记录事件流、日志采集停机超过 N 天就丢数据
按大小删除最近 N GB吞吐大、价值密度低流量突增会提前删掉旧数据
压实每个 key 的最后一条状态表、changelog、事件溯源快照历史版本不可重放;offset 有洞
分层存储热数据在 broker,冷数据在对象存储长保留、低成本重放重放延迟高;依赖版本

队列也有「保留」,只是表达方式不同:可见性超时、消息 TTL、死信队列。它们的共同点是由 broker 决定消息还能不能被看到;日志则把「还能看多久」写进了保留策略,消费者必须自己保证在窗口内读完。

ack 不等于处理成功

队列的 ack 只表示「这条消息可以删了」,不表示业务处理成功。很多实现把 ack 放在业务事务提交之前,进程崩溃后消息已删、业务没做,形成丢失。日志的 commit 同理:它只表示「这个消费者组读到这里了」,不表示副作用已经落库。ack/commit 是投递层的确认,不是业务层的确认;两者之间必须有一个幂等或事务边界来兜底。

7.1.1 什么时候该选哪一个

两种模型没有优劣,只有匹配。把业务问题压缩成五个问题,答案会指向其中一边:

问题偏向队列偏向日志
一条消息只该被一个 worker 处理?是否(多个消费者组各自读全量)
处理完还需要回看/重放?很少经常
需要严格的全序还是按 key 有序?单队列 FIFO分区内有序
消费者出错后,重投递由谁负责?broker(可见性超时/DLQ)消费者(offset + 幂等)
峰值吞吐靠什么扩?增加竞争消费者增加分区

典型的错配是把「任务」放进日志:任务要求「一个任务只执行一次」,日志却允许多个消费者组各读一遍;要做对,必须给任务加唯一 ID、去重表和完成状态,等于在日志上重建队列。反过来,把「事件流」放进队列:事件需要回放给新消费者、需要保留一周做审计,队列 ack 就删,做不到;只能靠 DLQ 和额外的归档表拼出来。

一个折中的判断方法是问「这条消息的价值在消费之后是否还存在」。任务完成后价值就结束了,队列合适;事件是事实,消费之后仍然对审计、重放、新消费者有价值,日志合适。如果一条消息既需要「只做一次」又需要「永久可回放」,通常要用日志 + 幂等消费,而不是队列 + 归档。

7.2 投递语义:最多一次、至少一次、有效一次

讨论「消息会不会丢」之前,先接受一个事实:发送方无法区分「消息没到」和「消息到了但确认丢了」。这是第 1 章 unknown 状态在消息系统里的翻版。面对这个不确定性,只有两种选择:

  • 不重试:最多一次。确认丢了就丢了,至少不会重复。
  • 重试:至少一次。确认丢了就再发,重复由接收方处理。

「恰好一次投递」在不可靠网络上做不到;工程上说的 exactly-once 指的是恰好一次效果(effectively once):消息可能被投递多次,但副作用只发生一次。这个区别不是文字游戏,它决定了你在哪一层写去重代码。

定义 7.2.1 · 三种投递语义

最多一次(at-most-once):每条消息最多被处理一次,可能丢失,不会因重投而重复。至少一次(at-least-once):每条消息至少被处理一次,不丢,但可能重复。有效一次(effectively-once):消息仍可能被投递多次,但业务效果只发生一次;靠消费者幂等、去重表或「业务写 + offset 提交」的原子事务实现。

「有效一次」的作用域是 (消费者, 目标存储) 这一对,不是整个系统。同一个事件被记账、通知、风控三个消费者各读一次,是设计如此;每个消费者各自保证自己的效果一次。

语义生产者做什么消费者做什么崩溃时典型场景
最多一次发完不等确认,或不重试先提交 offset 再处理丢消息指标采集、可丢的日志
至少一次失败重试,直到确认先处理再提交 offset重复消息订单、支付、审计
有效一次重试 + 幂等生产者幂等处理,或业务写与 offset 同事务不丢不重(效果)记账、库存、扣款

表中「先提交再处理」和「先处理再提交」只差一行代码,语义却相反。很多团队说「我们用的是至少一次」,代码却是自动提交 + 异步处理,实际是最多一次:offset 已经提交,异步任务还在队列里,进程一挂就丢了。

重复不只来自消费者。把一条消息从产生到消费拆开,至少有七个位置会产生重复:

阶段重复机制谁来兜底
生产者发送发送超时后重试,broker 其实已写入幂等生产者,或事件 ID 去重
broker 确认写入成功,ack 在回程丢失同上
outbox relay发布成功,标记 published_at 失败消费端按 event_id 去重
CDC 快照全量快照与增量日志重叠消费端按主键 + 版本去重
消费者处理处理成功,offset 提交失败消费端去重表
再平衡分区转给新消费者,从旧 offset 重来消费端去重表
人工重放为了修数据重置 offset重放隔离 + 幂等

丢失的位置同样多:acks=0/1 时 leader 崩溃、自动提交早于处理、捕获异常后继续提交、保留期短于停机时间、生产者缓冲区满后丢弃、进程退出前没有 flush。投递语义是三方行为的组合,不是 broker 的一个开关。

一个常见的误解是「Kafka 是至少一次,所以我的应用也是至少一次」。Kafka 只保证「在保留期内,已提交 offset 之后的记录还能读到」;consumer 完全可以用自动提交把它变成最多一次,也可以用「处理成功但提交失败」把它变成重复。broker 提供的是原材料,语义是应用组装出来的。

# 最多一次:先提交,再处理
for msg in consumer:
    consumer.commit(msg.offset + 1)
    handle(msg)              # 这里崩溃 -> 消息永远不会再来

# 至少一次:先处理,再提交
for msg in consumer:
    handle(msg)              # 这里崩溃 -> 消息会从旧 offset 重来
    consumer.commit(msg.offset + 1)

两段代码的差别只有一行,但第二段要求 handle 是幂等的,否则「至少一次」就变成「至少两次副作用」。第 1 章的幂等键、第 6 章的去重表,在这里重新出现;消息系统只是把这个问题从「一次 RPC」放大成「一条流」。

有效一次不是「只投递一次」

即使 broker 只把消息放进分区一次,消费者仍可能在「处理成功、提交失败」的窗口里重读它。有效一次从来不是投递层的属性,而是「投递层允许重复 + 业务层拒绝重复效果」的组合。任何声称「我们保证 exactly-once 投递」的系统,要么在偷换「投递」和「效果」的定义,要么把作用域限制在了它自己管理的存储里。

7.2.1 重复不是小概率

「重复率很低」是很多团队不做幂等的理由。把绝对值算出来,这个理由通常站不住。假设一个 topic 每天 1 亿条事件,处理成功率 99.9%,每天有 10 万条失败;失败重试和 rebalance 重放加起来,只有 1% 的处理会产生重复效果,也是每天 1000 次。对支付来说,1000 次重复扣款不是「小概率」,是事故。

rebalance 会把重复集中放大。一个每秒 1 万条的分区,滚动重启触发 5 分钟的重放窗口,涉及约 300 万条记录;如果其中 0.1% 的处理器不幂等,就是 3000 次重复。重复的规模取决于「一次重放涉及多少条」和「处理器有多不幂等」,而不是取决于「重复率」。

场景重放记录数不幂等比例重复效果
单条失败重试11%0.01 次
一批 500 条重放5001%5 次
5 分钟分区重放(1 万/s)3,000,0000.1%3,000 次
全量重放一天(1 亿条)100,000,0000.01%10,000 次

这张表解释了两件事。第一,重放的批次越大,幂等越重要;单条重试时可以靠人工发现,百万级重放只能靠代码。第二,去重表的容量要按「最大重放窗口」估算,而不是按「日常重复率」估算;如果去重记录只保留 7 天,而一次补数重放跨越 30 天,前面的重复就挡不住。

7.3 分区、顺序与消费者组

「Kafka 只保证分区内有序」这句话几乎每篇文档都有,但真正要回答的是两个工程问题:你的分区键是什么?这个键会不会变?

分区是 Kafka 里所有事情的单位:顺序、并行度、复制、保留、压实、offset 都以分区为边界。一个 topic 有 N 个分区,生产者按 key 把记录映射到其中一个:

partition = hash(key) % num_partitions

同一个 key 在分区数不变时总是落到同一个分区,因此同一 key 的记录按写入顺序排列。跨 key、跨分区没有全局顺序;消费者组里不同消费者各自处理不同分区,也无法知道另一个分区读到哪。

维度由分区决定直接后果
顺序同一分区内按 offset跨分区无顺序;换 key 就换顺序
并行度一个分区同时只给组内一个消费者消费者数超过分区数会空闲
复制每个分区独立选 leader/ISR副本故障以分区为单位
保留每个分区独立按时间/大小删除冷分区和热分区的保留不同步
压实每个分区独立按 key 压实同一 key 必须落在同一分区才有意义
offset每个分区独立编号offset 不能跨分区比较

分区键的选择直接决定「顺序保证的范围」。如果支付事件用 user_id 做 key,同一用户的事件有序,不同用户之间无序;如果风控需要「同一张卡」有序,就必须用 card_id,而不能指望 user_id。如果两个事件需要相互比较先后(比如 order.created 和 order.cancelled),它们必须用同一个 key,通常是 order_id。

一个经常被忽略的事实是:增加分区会改变 key 到分区的映射。hash(key) % 4 和 hash(key) % 5 对同一个 key 通常给出不同结果,原来在同一分区里的两个事件会被拆到两个分区,顺序保证当场断裂。Kafka 不能减少分区,增加分区也不迁移已有数据;已经写入的记录留在旧分区,新记录按新映射走。于是同一 key 的历史和未来可能落在不同分区,消费者看到的是两段各自有序、彼此无序的流。

这件事的工程含义是:分区数要在建 topic 时按未来一到两年的峰值定,不能等不够了再加。如果确实要扩,正确做法是接受「迁移窗口内该 key 无序」,在消费端用版本号/序号拒绝旧事件,或者双写新旧 topic 做一次受控迁移。直接把分区数从 4 改成 8,然后假设顺序还在,是很多「偶发乱序」工单的根因。

topic payments.charged(4 个分区)

P0: [offset 100][101][102] ...   ──▶  consumer C1(ledger-1)
P1: [offset 200][201] ...        ──▶  consumer C1
P2: [offset 300][301][302] ...   ──▶  consumer C2(ledger-2)
P3: [offset 400] ...             ──▶  consumer C2

group ledger:C1 拥有 P0/P1,C2 拥有 P2/P3
group notify:C3 拥有 P0–P3(不同组,独立 offset)

消费者组(consumer group)是分区分配的容器。同一个组内,一个分区同时只能被一个消费者拥有;不同组之间互不影响,各自读到全量消息、各自维护 offset。这就是为什么「加一个消费者」有时能扩容、有时只是多一个空闲进程:并行度的上限是分区数。

现象原因处理
加了消费者,lag 不降消费者数 ≥ 分区数,多出的空闲加分区(接受重映射)或减少消费者
一个分区 lag 很高,其他接近 0分区键倾斜,热 key 落在一个分区加盐/拆 key/独立 topic;加消费者无效
所有分区 lag 同时涨下游变慢或消费者处理能力不足先看下游,再谈扩容
频繁 rebalance消费者进出、max.poll.interval 超时、分区变化静态成员、合作式分配、调批大小
同一 key 的事件乱序分区数变了,或用了 null key恢复原分区数/固定 key;消费端版本号

热点分区值得单独说。假设一个 topic 有 12 个分区,key 是 tenant_id,其中一个租户占了 60% 的流量,那么这个分区会一直 lag,其他 11 个分区闲着。加消费者没用,因为瓶颈是一个分区;加分区也没用,因为同一个租户仍然落在一个分区。可选的做法是:给热 key 加后缀拆成多个子分区(但会破坏该租户的全局顺序)、给大租户单独建 topic、或者在业务层做预聚合。没有一种做法能同时保住「单 key 全局有序」和「无限并行」,必须选一个。

rebalance 的触发条件比想象中多:消费者加入/离开、消费者崩溃、max.poll.interval.ms 超时(处理一批太久)、订阅的 topic 分区数变化、订阅关系变化、coordinator 切换。每次 rebalance 都意味着分区所有权转移,未提交的进度可能被重放。7.4 会把这条时间线拆开。

7.3.1 分区数怎么估

分区数不是拍脑袋定的,它同时受三个下界约束:

partitions >= ceil(峰值写入 MB/s / 单分区安全写入 MB/s)
partitions >= 期望的消费者并行度
partitions >= 峰值 key 基数 / 单分区可接受的 key 数   # 防止热分区

第三个下界经常被忽略。如果 key 的基数只有 100,分区开到 64,看起来并行度很高,实际上大部分 key 还是会挤在少数分区;反过来,key 基数很大但流量集中在前 1% 的租户,也要按「最热 key 的流量」而不是「平均流量」来估。

一个算例:峰值写入 60 MB/s,单分区在 acks=all、3 副本、压缩后能稳定吃 8 MB/s,消费者希望 12 个并行,那么:

max(ceil(60 / 8), 12) = max(8, 12) = 12
加 30% 余量:ceil(12 * 1.3) = 16

最终定 16 个分区。这个数字要写进容量文档,和「为什么不是 8、不是 64」一起评审。单分区 8 MB/s 不是常数,它取决于消息大小、压缩率、副本同步、磁盘和网络;用自己的流量压测一遍,不要抄别人的数字。

分区太少分区太多
并行度不够,消费者空闲元数据膨胀,controller 压力大
热 key 集中,单分区 lagrebalance 变慢,恢复时间变长
单分区文件大,保留/压实变慢每分区批次变小,吞吐下降
扩容时要重映射 key打开文件句柄、内存和监控成本上升

分区数的调整只有「增加」一个方向,而且增加的那一刻改变 key 映射。所以它更像「建表时选主键」,而不是「运行时调线程池」;宁可一次多留 30% 余量,也不要在业务高峰做重映射。

7.4 Offset 提交与再平衡

offset 提交是消费者唯一能在崩溃后留下的东西。处理结果在数据库里,进度在 coordinator 里;两者不在一个事务里,就一定有窗口。

先看自动提交。enable.auto.commit=true 时,客户端每隔 auto.commit.interval.ms(默认 5 秒)提交一次,提交的是最近一次 poll 返回的记录的末尾,而不是「已经处理完的记录」。如果处理是异步的、或者一批记录处理到一半失败,自动提交可能把没处理完的记录也提交掉:

poll -> [100, 101, 102, 103]
处理 100 成功,101 失败
自动提交 offset=104        # 102、103 永远不会再被这个组读到

这就是「自动提交 + 异步处理」等于最多一次的原因。自动提交不是错,错在把它和处理进度当成同一件事。

手动提交把控制权拿回来,但有三种写法,语义不同:

写法提交时机崩溃时语义代价
每条同步提交handle 返回后 commitSync重放当前条至少一次每条一次 RPC,吞吐低
每批同步提交整批 handle 后 commitSync重放整批至少一次批内重复,吞吐好
异步周期提交handle 后 commitAsync可能提交了更大的 offset可能丢/重复快,但失败不重试
事务提交业务输出与 offset 同事务原子回滚或提交Kafka 内有效一次延迟高,仅限 Kafka

异步提交有一个隐蔽的坑:commitAsync(offset=104) 失败后,下一次 commitAsync(offset=110) 成功,104–109 就被跳过了。异步提交不会自动重试失败的那一次;后一次成功的大 offset 会覆盖前一次,中间段就永远不会被重放。所以失败的那一批只能靠业务幂等或重新处理来兜底;在 rebalance 的回调里必须用同步提交,否则分区已经转走,异步提交会直接失败。

定义 7.4.1 · 已提交 offset 与处理位点

已提交 offset(committed offset)是消费者组向 coordinator 声明的「下一条要读的位置」,持久化在 __consumer_offsets 里,跨进程、跨重启有效。处理位点(processed position)是应用实际完成副作用的最后一条记录,通常只存在于应用内存或业务表里。

两者一致时,重启不会重复;两者不一致时,重复或丢失就发生了。所有「至少一次」的重复,本质上都是「处理位点领先于已提交 offset」。

再平衡把这个问题放大。考虑下面这条时间线:

t0  C1 拥有 P0,已提交 offset=100,poll 到 100..109
t1  C1 处理 100(扣款成功),101..109 还在本地队列
t2  发版/GC/下游慢,C1 超过 max.poll.interval.ms 没有 poll
t3  coordinator 认为 C1 失联,generation 42 -> 43,P0 分配给 C2
t4  C1 处理完 109,调用 commitSync(110)
t5  coordinator 拒绝:generation 已变(CommitFailedException)
t6  C2 从已提交 offset=100 开始,100..109 全部重放
t7  如果 handle 不幂等,100 被扣第二次

注意 t4 的提交被拒绝,不是因为网络,而是因为 C1 已经是一个「僵尸消费者」:它失去了分区所有权,却还在处理旧数据。Kafka 用 generation 来 fencing 这种提交,这是对的;但fencing 只能挡住提交,挡不住已经发生的副作用。扣款在 t1 已经完成,t5 的拒绝只是让这个事实暴露得更晚。

max.poll.interval.ms 是这条时间线上最容易被忽略的参数。它约束的是「两次 poll 之间的最长时间」,不是「一条记录的处理时间」。如果一批 500 条记录、每条 100 ms,一批就要 50 秒;默认 5 分钟的间隔看似够,但下游一抖动就会超。超时后 consumer 被踢出组,分区转走,处理到一半的批次变成重复。调小 max.poll.records 比调大 max.poll.interval.ms 更安全:前者让每批更短,后者只是把炸弹推迟。

再平衡本身也在进化。早期的 eager 协议会让所有消费者先放弃全部分区,再重新分配,整个组停摆几秒到几分钟;cooperative sticky 只迁移需要移动的分区,其他分区继续消费。静态成员(group.instance.id)让滚动重启的消费者在 session.timeout.ms 内保留成员身份,避免每次重启都触发一轮 rebalance。这些机制减少的是停摆时间,不是重复本身;重复仍然要靠幂等处理。

正确的消费循环(示意)

while running:
    records = consumer.poll(500ms)
    for r in records:
        handle(r)                    # 必须幂等
    consumer.commitSync()            # 处理完才提交

on_partitions_revoked(parts):
    consumer.commitSync()            # 尽力而为;失败就交给下游去重
    drain_local_queue()              # 停止接收新活,处理完手上的

这段伪代码有两个细节:提交在处理之后,以及 revoke 时先提交再清空本地队列。第二点经常被漏掉:分区被收走时,本地队列里还有没处理完的记录,如果直接退出,这些记录会在新消费者那里重放;如果先处理完再提交,重放的是「已提交 offset 之后」的部分,重复更少。

如果输入和输出都在 Kafka,可以用事务把「写输出」和「提交 offset」合成一个原子操作:sendOffsetsToTransaction 把 consumer offset 写进 producer 的事务,commitTransaction 同时生效。这是 7.5 要讲的内容。但即使这样,外部数据库或 HTTP 副作用仍然不在事务里,还是需要业务幂等。

提交失败不是异常,是设计输入

commitSync 抛 CommitFailedException、超时、或者进程在提交前被 kill,都是正常运行的一部分。设计时应该假设「每次处理都可能被重放」,而不是假设「提交一定成功」。把提交失败当异常日志打出来,然后继续跑,是很多重复扣款的直接原因。

7.4.1 提交失败的四种形态

「提交失败」不是一个单一事件,它对数据的影响取决于失败发生在哪一步:

形态表现offset 可能已提交?重放范围后果
generation 冲突CommitFailedException否上次已提交 offset 之后重复,需幂等
提交超时/响应丢失客户端不知道成功与否可能上次已知 offset 之后重复,需幂等
进程在提交前被 kill无返回否当前批次重复,需幂等
revoke 时先提交后清队列提交成功,队列未处理完是不重放丢失,最危险
异步提交乱序大 offset 覆盖小 offset是中间段不重放丢失,需单调提交

前三行是「至少一次」的重复,后两行是「最多一次」的丢失。很多人只盯着 CommitFailedException,却忽略了 revoke 回调里的顺序:如果在 onPartitionsRevoked 里先 commitSync()、再清空本地队列,那些还没处理的记录已经被 offset 越过了,新消费者不会再来一遍。提交的时机比提交的 API 更重要。

异步提交的乱序是另一个隐蔽的丢失源:commitAsync(104) 还在路上,commitAsync(110) 已经成功,broker 只保留较大的 110,104–109 永远不会被重放。修法是维护一个单调递增的「已提交位点」,只在成功回调里推进,失败时不要提交更大的 offset;或者在 rebalance 时用同步提交兜底。

7.5 Kafka 的 ISR、acks、幂等生产者、事务

先回答一个常见疑问:acks=all 为什么还会丢数据?

因为 acks=all 等的是当前 ISR 里的所有副本,不是「配置的 replication factor 个副本」。ISR(in-sync replicas)是「跟得上 leader 的副本集合」,判断标准是副本最后一次成功 fetch 的时间与 leader 的差距(replica.lag.time.max.ms)。如果一个 follower 因为磁盘慢、网络抖动或 GC 落后太多,它会被踢出 ISR;此时 ISR 可能只剩 leader 自己,acks=all 就退化成 acks=1。

acks等什么leader 崩溃时吞吐适用
0不等待,发出去就算成功可能丢最高可丢的指标/日志
1leader 本地写入未复制的部分丢失高可容忍少量丢失
all(-1)ISR 全部写入ISR 内的数据不丢较低订单、支付、审计

min.insync.replicas 是把 acks=all 变成有意义承诺的配套参数。它规定「至少要有多少个 ISR 才接受写入」;当 ISR 小于这个值时,producer 收到 NotEnoughReplicas,写入失败。这是用可用性换持久性:副本不足时宁可拒绝写,也不接受可能丢的写。典型配置是 replication factor=3、min.insync.replicas=2、acks=all,容忍一个 broker 故障;如果三个副本在同一个机架或同一个可用区,一次机架掉电仍然可能带走多数派。

ISR 状态acks=all 的实际含义结果
3 副本都在 ISR等 3 个副本写入容忍 1 个故障
2 副本在 ISR等 2 个副本写入容忍 1 个故障,但余量变小
只剩 leader等 leader 本地写入与 acks=1 相同
ISR < min.insync.replicas拒绝写入可用性下降,但不丢已确认写

还有一个更隐蔽的丢数据入口:unclean leader election。当 leader 挂掉、ISR 里没有可用的副本时,如果允许「非 ISR 副本」当 leader,集群能恢复可用,但会丢掉未同步的记录。unclean.leader.election.enable=false 是大多数业务 topic 的默认选择;把它设成 true,等于接受「可用性优先于已确认写」。

HW(high watermark)是消费者能读到的位置。leader 把记录写入本地日志后,要等 ISR 副本都 fetch 到,才推进 HW;consumer 只能读到 HW 之前的记录。这保证了「消费者读到的数据不会因为 leader 切换而消失」。事务场景下还有 LSO(last stable offset):read_committed 消费者只能读到 LSO 之前、且事务已提交的记录,未提交事务之后的记录一律不可见。

定义 7.5.1 · ISR 与 high watermark

ISR 是与 leader 保持同步的副本集合,按「最后一次成功 fetch 的时间」判定,不是按配置的副本数。HW 是 ISR 都已复制的日志位置,消费者只能读到 HW 之前。LSO 是事务隔离下 read_committed 消费者可见的上界。

acks=all 的承诺是「写入当前 ISR」,不是「写入所有配置副本」;min.insync.replicas 才是防止 ISR 缩水后承诺失效的闸门。

幂等生产者解决的是另一类重复:producer 发送超时后重试,broker 其实已经写入。开启 enable.idempotence=true 后,producer 启动时从 broker 拿到一个 PID(producer id),并为每个分区维护单调递增的 sequence;broker 按 (PID, partition, sequence) 去重,重复的批次直接丢弃,同时保证分区内的顺序。

它有三个边界:

  1. 只在同一个 producer 会话内有效。producer 重启会拿到新 PID,之前的 sequence 记录不再匹配;跨会话的重复仍然要靠业务去重。
  2. 只覆盖 Kafka 内部的写入。它不知道你的数据库、HTTP 调用有没有重复。
  3. 有配置前提:需要 acks=all、retries>0、max.in.flight.requests.per.connection<=5。在不开启幂等时,max.in.flight>1 加上重试会导致批次乱序;开启幂等后,broker 会拒绝乱序的 sequence,producer 也会保序。

事务把范围再扩大一步。给 producer 配置 transactional.id 后,initTransactions() 会向 broker 注册并 fence 掉同 ID 的旧实例(epoch 递增),然后可以:

producer.initTransactions()
producer.beginTransaction()
producer.send(topicA, record1)
producer.send(topicB, record2)
producer.sendOffsetsToTransaction(offsets, groupMetadata)
producer.commitTransaction()        # 或 abortTransaction()

sendOffsetsToTransaction 把消费者 offset 和输出记录放进同一个事务:要么输出可见且 offset 前进,要么都不可见且 offset 不动。消费者设置 isolation.level=read_committed 后,只能读到已提交事务的记录,未提交的记录被 LSO 挡住。这就是 Kafka 里「读-处理-写」的有效一次。

保证机制作用域不覆盖什么
重试不重复写入幂等生产者(PID + sequence)单个 producer 会话、单个分区跨会话、跨系统副作用
多分区原子写事务(transactional.id)Kafka 的多个分区 + offset数据库、HTTP、邮件
消费不重复read_committed + LSOKafka 内部外部存储的重复
端到端有效一次上述机制 + 业务幂等整条链路需要业务自己实现

这张表的最后一行是关键:Kafka 的事务不是分布式事务。它不能把「写数据库」和「发消息」包在一起,也不能让外部支付接口参与提交。它能做的是「Kafka 内部的输出和 offset 原子」,把跨系统的部分留给 outbox 和幂等(7.6、7.10)。

长事务还有一个运行时代价:未提交事务会挡住 LSO,read_committed 消费者读不到它之后的记录,lag 看起来正常但消费停住。事务超时(transaction.timeout.ms)和 transaction.max.timeout.ms 要匹配,否则 broker 会主动 abort,producer 收到 InvalidTxnTimeout。这类问题在 7.8 的 lag 读数里表现为「lag 不涨也不降」,只看 lag 很容易误判。

副本数不是可用性

replication factor=3 只说明「有 3 份」,不说明「3 份在不同的故障域」。如果 3 个副本在同一台物理机、同一个机架或同一个可用区,一次掉电就能带走全部。Kafka 的 rack awareness(broker.rack)让副本分散到不同机架;云上要把「机架」映射到不同的可用区。副本的故障域比副本数更重要。

7.5.1 ISR 为什么会缩水

ISR 的判定是「follower 最近一次成功 fetch 距现在多久」,超过 replica.lag.time.max.ms 就被踢出。能让 follower 停止 fetch 的原因有四类:

原因现象排查入口
follower 磁盘慢fetch 到了但写不进日志,fetch 线程阻塞磁盘 await、kafka-log-dirs.sh --describe
网络带宽/丢包跨 AZ 复制跟不上,fetch 超时网卡吞吐、重传、跨 AZ 延迟
follower GC/CPU 饥饿fetch 线程拿不到 CPU,心跳还在但数据不前进GC 日志、CPU throttling
leader 写入过慢follower 没有新数据可 fetch,时间戳也会老化leader 磁盘、ISR 抖动

注意第三行:心跳正常不代表 ISR 正常。broker 之间有心跳,但 ISR 看的是数据 fetch 的进度;一个 GC 停顿的 follower 可能还在回心跳,却已经几秒没有 fetch,照样会被踢出。所以「broker 活着」和「副本同步」是两个监控项。

Kafka 用时间而不是字节数判断落后,是有原因的。按字节判断时,一条大消息就能让副本「看起来落后很多」,产生误报;按时间判断更接近「这个副本还能不能赶上」。LinkedIn 的公开材料也提到,他们把副本落后的判断从字节改成了时间,避免大消息导致的假告警。监控 ISR 要看时间维度的持续性,不要看某一个瞬间的字节差。

ISR 缩水时,acks=all 的承诺跟着缩水:3 副本变 2 副本还能写,变 1 副本就只等 leader;如果 min.insync.replicas=2,1 副本时 producer 会收到 NotEnoughReplicas,写入失败。这不是 bug,是设计:宁可让写入失败,也不让客户端以为写成功了、实际上只在一个随时会挂的 leader 上。

7.6 Outbox、CDC 与事件溯源

第 6.7 节已经讲过 outbox 解决双写:业务行和消息行在同一个本地事务里提交,relay 负责把消息搬出去。这一节换一个视角:outbox 是一张日志表,relay 是一个生产者,consumer offset 是另一张日志的位置。一条事件从数据库到消费者,至少经过三段日志:数据库的 WAL/binlog、outbox 表、Kafka 的分区日志。每一段都有自己的顺序、保留和重复窗口。

先看表结构。下面是 PostgreSQL 版本,重点是约束:

CREATE TABLE orders (
  order_id      TEXT PRIMARY KEY,
  account_id    TEXT NOT NULL,
  amount_cents  BIGINT NOT NULL,
  status        TEXT NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE outbox (
  id            BIGSERIAL PRIMARY KEY,
  event_id      UUID NOT NULL UNIQUE,
  aggregate_id  TEXT NOT NULL,
  aggregate_seq BIGINT NOT NULL,
  event_type    TEXT NOT NULL,
  payload       JSONB NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at  TIMESTAMPTZ,
  UNIQUE (aggregate_id, aggregate_seq)
);

CREATE INDEX outbox_pending ON outbox (id) WHERE published_at IS NULL;

CREATE TABLE processed_events (
  consumer     TEXT NOT NULL,
  event_id     UUID NOT NULL,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer, event_id)
);

四个约束各有作用:event_id 唯一,保证同一条事件不会被业务重复写入;(aggregate_id, aggregate_seq) 唯一,保证同一个聚合的事件序号不重复,消费端可以用它拒绝旧事件;outbox_pending 是部分索引,只索引未发布的行,relay 扫描不会随着历史增长而变慢;processed_events 的主键是 (consumer, event_id),不同消费者各自去重,互不干扰。

下面是一个可运行的 SQLite 版本,演示三件事:outbox 与业务同事务、relay 在「发布成功、标记失败」时重发、消费者在「处理成功、提交 offset 失败」后重放但不重复扣款。代码只用标准库,直接 python3 outbox_demo.py 即可运行。

import json, sqlite3, uuid

SCHEMA = """
CREATE TABLE orders (
  order_id TEXT PRIMARY KEY, amount_cents INTEGER NOT NULL, status TEXT NOT NULL
);
CREATE TABLE outbox (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  event_id TEXT NOT NULL UNIQUE,
  aggregate_id TEXT NOT NULL,
  aggregate_seq INTEGER NOT NULL,
  event_type TEXT NOT NULL,
  payload TEXT NOT NULL,
  published_at TEXT,
  UNIQUE (aggregate_id, aggregate_seq)
);
CREATE TABLE processed_events (
  consumer TEXT NOT NULL, event_id TEXT NOT NULL,
  PRIMARY KEY (consumer, event_id)
);
CREATE TABLE balances (
  account_id TEXT PRIMARY KEY, balance_cents INTEGER NOT NULL
);
CREATE TABLE offsets (
  consumer TEXT NOT NULL, topic TEXT NOT NULL, next_offset INTEGER NOT NULL,
  PRIMARY KEY (consumer, topic)
);
"""

def connect():
    conn = sqlite3.connect(":memory:")
    conn.executescript(SCHEMA)
    conn.execute("INSERT INTO balances VALUES ('acct-1', 0)")
    conn.commit()
    return conn

def place_order(conn, order_id, amount_cents):
    event_id = str(uuid.uuid4())
    with conn:                              # 业务行 + outbox 行,同一个事务
        conn.execute("INSERT INTO orders VALUES (?, ?, 'created')",
                     (order_id, amount_cents))
        conn.execute(
            "INSERT INTO outbox (event_id, aggregate_id, aggregate_seq, event_type, payload) "
            "VALUES (?, ?, 1, 'order.created', ?)",
            (event_id, order_id, json.dumps({"order_id": order_id,
                                             "amount_cents": amount_cents})),
        )
    return event_id

def relay(conn, topic, crash_after_publish=False):
    rows = conn.execute(
        "SELECT id, event_id, event_type, payload FROM outbox "
        "WHERE published_at IS NULL ORDER BY id"
    ).fetchall()
    for row in rows:
        topic.append({"event_id": row[1], "type": row[2],
                      "payload": json.loads(row[3])})
        if crash_after_publish:             # 发布成功,标记失败
            raise RuntimeError("relay crashed after publish")
        with conn:
            conn.execute("UPDATE outbox SET published_at = datetime('now') "
                         "WHERE id = ?", (row[0],))
    return len(rows)

def consume(conn, topic, consumer, commit_offset=True):
    row = conn.execute(
        "SELECT next_offset FROM offsets WHERE consumer = ? AND topic = 'orders'",
        (consumer,),
    ).fetchone()
    pos = row[0] if row else 0
    handled = 0
    for event in topic[pos:]:
        with conn:                          # 去重记录 + 副作用,同一个事务
            cur = conn.execute(
                "INSERT INTO processed_events (consumer, event_id) VALUES (?, ?) "
                "ON CONFLICT DO NOTHING", (consumer, event["event_id"]))
            if cur.rowcount == 1:
                conn.execute(
                    "UPDATE balances SET balance_cents = balance_cents + ? "
                    "WHERE account_id = 'acct-1'",
                    (event["payload"]["amount_cents"],))
                handled += 1
            if commit_offset:                # 模拟提交失败时置 False
                conn.execute(
                    "INSERT INTO offsets (consumer, topic, next_offset) "
                    "VALUES (?, 'orders', ?) ON CONFLICT (consumer, topic) "
                    "DO UPDATE SET next_offset = excluded.next_offset",
                    (consumer, pos + 1))
        pos += 1
    return handled

conn = connect()
place_order(conn, "order-1", 100)
topic = []
try:
    relay(conn, topic, crash_after_publish=True)
except RuntimeError as exc:
    print("relay:", exc)
relay(conn, topic)                          # 重试:同一条事件再发一次
print("topic copies:", len(topic))
print("handled, commit failed:", consume(conn, topic, "ledger", commit_offset=False))
print("handled, replay:", consume(conn, topic, "ledger"))
print("balance:", conn.execute("SELECT balance_cents FROM balances").fetchone()[0])

一次运行的输出:

relay: relay crashed after publish
topic copies: 2
handled, commit failed: 1
handled, replay: 0
balance: 100

怎么读:

  • topic copies: 2:relay 在发布后崩溃,重试又发了一次。重复发生在生产端,这是 outbox 的已知代价。
  • handled, commit failed: 1:第一遍处理成功,扣了 100;但 commit_offset=False 模拟提交失败,offset 没有前进。
  • handled, replay: 0:第二遍从 offset 0 重放,processed_events 里已经有这个 event_id,副作用被跳过。重复被消费端挡掉。
  • balance: 100:一次订单只扣一次。

这个 demo 没有模拟「relay 标记成功但消息没发出去」——那需要 broker 端配合。真实系统里 relay 的 publish 是至少一次:先发后标记,发成功标记失败会重发;先标记后发,发失败就丢。Outbox 只能保证「业务数据和消息记录一起提交」,不能保证「消息只发一次」。

CDC(change data capture)把「写 outbox 表」换成「读数据库日志」。Debezium 之类的连接器订阅 MySQL binlog / PostgreSQL logical replication,把每一行变更转成事件;outbox event router 可以把 outbox 表的插入直接路由成 topic 消息。它的好处是业务代码不用改,坏处是表结构变成了事件契约:加一列、改类型、删字段,所有下游都要跟着变。

CDC 也不是「没有重复」。全量快照和增量日志之间有一个重叠窗口:快照读到某行时,binlog 可能已经包含这行的更新,连接器需要按位点/GTID 去重;切换、重启、schema 变更都会重放一段。CDC 的语义仍然是至少一次,消费端仍然要去重。

方案事件来源业务侵入延迟重放主要风险
Outbox 轮询业务库的 outbox 表中(多一张表)轮询间隔从 outbox/topic 重放表膨胀、relay 重复
CDC数据库 WAL/binlog低(不改代码)低从日志位点重放schema 耦合、快照重叠
事件溯源事件日志本身高(改写模型)低全量重放 + 快照版本演进、快照一致性

事件溯源(event sourcing)走得最远:它不把事件当成「数据库变更的通知」,而把事件当成唯一的事实来源。状态不直接写,而是由事件折叠(fold)出来:state = fold(events)。订单的当前状态是 created、paid、shipped 这些事件依次应用的结果;要查当前状态,可以重放,也可以定期做快照。

这带来两个好处:完整的审计历史,以及「回到任意时刻」的能力。也带来两个硬问题:

  1. schema 演进。事件一旦写入就不能改,字段改名、类型变化、语义修正都要靠版本化和 upcaster(读旧版本、转新版本)。不像数据库可以 ALTER TABLE 回填,事件日志里的旧事件永远在那里。
  2. 重放副作用。重放 order.created 时不能再发一次货、再扣一次款。纯投影(读模型)可以重放,副作用必须隔离到单独的、幂等的处理器里。

事件溯源常和 CQRS 一起出现:写侧只追加事件,读侧维护投影。投影的更新必须是幂等的,因为它会被重放;投影的位点也要持久化,否则每次重启都从头折叠。Kafka 的压实 topic 常被用作「状态 changelog」:每个 key 保留最新状态,重启时先读 changelog 再继续消费。压实和事件溯源是互补的,不是同一件事:前者保留每个 key 的最新值,后者保留全部历史。

重放一条历史 topic,最常用的命令是重置消费者组的 offset:

# 先 dry-run,确认要重放的范围
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --group ledger-replay-20261019 --topic payments.charged \
  --reset-offsets --to-earliest --dry-run

# 确认后执行;只影响这个组,不影响在线组
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --group ledger-replay-20261019 --topic payments.charged \
  --reset-offsets --to-earliest --execute

重放的前提是:目标组是新组或已停用的组,在线组不能被重置;消费者是幂等的;重放产生的副作用要么被关闭,要么写到隔离的环境。重放不是「再读一遍」,而是「再执行一遍」;如果把 handle 里的扣款、发货、发短信原样跑一遍,就是一次人为的重复副作用。

CDC 不是 exactly-once

CDC 连接器读的是数据库日志,日志里的每一次变更都会被投递一次。连接器重启、位点提交失败、快照与增量重叠,都会导致重复。CDC 只是把「业务代码写 outbox」换成了「数据库日志自动产出」,至少一次的语义没有变。

Outbox 与 CDC 的三个误解
  1. 「有了 outbox 就不会重复」:outbox 只保证业务行和消息行同事务,relay 仍然是至少一次;重复必须在消费端去掉。
  2. 「CDC 不需要业务改造,所以没有顺序问题」:CDC 按日志顺序读,但消费端多实例、分区重平衡都会打乱处理顺序;顺序要落到分区键上。
  3. 「事件溯源就是把所有表改成 topic」:事件溯源改的是写模型和一致性边界;没有快照、版本化和幂等投影,只会得到一个又大又慢的日志。

7.6.1 outbox 表的增长与清理

outbox 是一张只增不减的表:每次业务写都多一行,relay 发布后只是把 published_at 填上,不会删行。一个月后,relay 的 WHERE published_at IS NULL 还能走部分索引,但表的体积、备份和 vacuum 成本会一直涨。

清理要满足两个约束:已发布的行至少保留到「不会再被重放」的窗口,未发布的行一行都不能删。如果 outbox 同时承担审计职责,已发布的行要归档到冷存储,而不是直接删。

-- 分批删除,避免长事务和锁表
DELETE FROM outbox
WHERE id IN (
  SELECT id FROM outbox
  WHERE published_at IS NOT NULL
    AND published_at < now() - interval '7 days'
  ORDER BY id
  LIMIT 10000
);

这条语句要循环执行到影响行数为 0。配套的索引是 published_at,不是 id:

CREATE INDEX outbox_published_at ON outbox (published_at);

比清理更重要的是监控「未发布的积压」。它和 consumer lag 是两回事:outbox 积压说明 relay 没把消息发出去,下游可能一条都没收到。

SELECT count(*) FILTER (WHERE published_at IS NULL)          AS pending,
       max(now() - created_at) FILTER (WHERE published_at IS NULL) AS oldest_pending
FROM outbox;

oldest_pending 超过「业务可接受的最大发布延迟」就该告警,而不是等到下游发现数据少了。relay 的 lag 是生产端的 lag,consumer 的 lag 是消费端的 lag;两个都要看,只看一个会漏掉一半的链路。

7.7 失败模式:重复投递与乱序

重复和乱序经常被当成两个独立的问题:重复靠去重表,乱序靠分区键。但在真实事故里,它们会叠加成第三种错误:一条旧事件被重投,落在一条更新的事件之后,把状态改回去。去重表只能挡住「同一个 event_id」,挡不住「不同 event_id、但语义更旧」的事件。

看一条订单取消的时间线:

时间事件/动作订单状态
t0写入 e1 order.created(ord-1, seq=1)created
t1用户取消,写入 e2 order.cancelled(ord-1, seq=2)created
t2消费者处理 e1,下游创建订单成功,但响应超时created
t3消费者把 e1 放进重试队列,继续处理 e2cancelled
t4重试 worker 再处理 e1,调用创建订单created(复活)
t5用户看到一个已取消的订单又出现了created

t2 的重复是「处理成功、响应超时」,t4 的重放本身没有错——至少一次语义下它必然发生。错的是 t4 的处理逻辑没有检查「这个订单是否已经取消」,也没有比较 seq。重复是触发器,乱序是放大器:如果 t4 的 e1 带着 seq=1,而消费者已经见过 seq=2,一个简单的 seq <= last_seq 判断就能把它丢掉。

重复对「增量写」和「绝对值写」的破坏方式不同,乱序则相反:

写入方式重复投递乱序
增量 balance -= 100错:扣两次通常可交换,但依赖顺序的业务状态不行
绝对值 balance = 500通常无害:写同一个值错:旧值覆盖新值
绝对值 + 版本号/CAS无害:版本不匹配就拒绝安全:旧版本被拒绝
去重表 + 增量安全:同一 event_id 只应用一次仍可能错:不同 id 的旧事件会通过

这张表说明:去重和顺序是两个不同的问题,不能互相替代。去重表解决「同一条事件被应用两次」;版本号/序号解决「旧事件覆盖新状态」。两者都有的消费者,才能同时挡住重复和乱序。

消费者遇到失败时,重试策略会直接改变顺序:

策略顺序代价适用
原地阻塞重试保持分区内顺序队头阻塞,lag 增长;可能触发 max.poll.interval少量瞬时错误
重试 topic打乱顺序需要按 key 分区 + 版本号/状态机长延迟、大量重试
死信队列移出主流程需要人工/自动回放;回放要幂等毒消息、schema 错误
暂停分区保持顺序整个分区停摆依赖恢复后一次性重放

「原地阻塞重试」看起来最安全,但它会把一条毒消息变成整个分区的停摆:后面的消息都等着,lag 一直涨,最后触发 rebalance,未提交的批次又被重放。重试 topic 把主流程解放出来,代价是顺序;如果重试 topic 不按原 key 分区,同一订单的 created 和 cancelled 会落到不同分区,乱序更严重。重试 topic 也必须用同一个分区键,否则你只是把乱序从主 topic 搬到了重试 topic。

例 7.7.1 · 同一订单的两个 key

订单服务发布两个事件,但用了不同的 key:

  • order.created 用 order_id=ord-1 做 key,落到 P2;
  • order.cancelled 用 user_id=u-9 做 key,落到 P0。

消费者组有两个消费者,C1 拥有 P2,C2 拥有 P0。用户取消得很快,C2 先处理 cancelled:它在本地状态里找不到 ord-1,于是把取消记录成一个「待定取消」或者直接丢弃;随后 C1 处理 created,订单变成 active。取消先到、创建后到,订单复活。

修复不是「让消费者等一等」,而是让两个事件用同一个 key(order_id),保证它们进同一个分区、按写入顺序处理。如果历史数据已经用了两个 key,消费端必须能处理「取消先到」:把取消写成墓碑,等 created 到达时立即应用并标记为 cancelled,而不是假设事件一定按业务顺序到达。

顺序修复通常有三种手段,按成本从低到高:

  1. 版本号/序号:每个聚合维护单调 seq,消费者保存 last_seq;seq <= last_seq 直接跳过,seq == last_seq + 1 应用,出现空洞则缓存或回退重放。缓存要有上限,否则一个永远不来的事件会让内存无限增长。
  2. 状态机:cancelled 是终态,迟到的 created 只能记录为异常事件,不能把状态改回 active。状态机比序号更懂业务,但需要为每个聚合类型单独设计。
  3. 数据库 CAS:UPDATE orders SET status='cancelled', version=version+1 WHERE id=? AND version=?,版本不匹配就拒绝。这把顺序检查下沉到存储层,适合跨消费者、跨服务的场景。

三种手段都不能替代去重:版本号挡住旧事件,去重表挡住同一事件;一个事件可能既旧又重复。

顺序保证的范围就是分区键的范围

不要问「Kafka 有没有顺序」,要问「哪些事件用了同一个 key,这个 key 的分区数会不会变」。如果两个事件需要比较先后,它们必须在同一个分区;如果它们用了不同的 key,任何消费者端的等待都无法恢复它们原本的因果顺序。

7.7.1 生产者重试也会乱序

乱序不只发生在消费端。producer 开启重试、但没开幂等时,max.in.flight.requests.per.connection > 1 允许第二个批次先成功,第一个批次的重试后到,日志里的顺序就反了:

producer                          broker 日志
batch1(seq=1) --超时,进入重试-->   []
batch2(seq=2) --成功------------>   [batch2]
batch1(seq=1) --重试成功-------->   [batch2, batch1]   # 顺序反了

同一个 key 的两条记录,如果分别落在 batch1 和 batch2,消费者看到的顺序就是 batch2 → batch1。这不是消费端能修的:日志本身已经反了,所有从头读的消费者都会看到反序。修法只有两个:把 max.in.flight 降到 1(吞吐下降),或者开启幂等生产者(broker 按 (PID, partition, sequence) 拒绝乱序/重复的批次)。

这也解释了为什么 7.5 说幂等生产者同时保证「不重复」和「分区内有序」:它不只是去重,还给每个批次一个单调序号,让 broker 能判断「这个批次来晚了」。如果业务依赖同一 key 的严格顺序,幂等生产者不是可选项,是前提。

7.8 观测:consumer lag 与 rebalance

lag 是流系统里最容易被误读的指标。它是「位置差」,不是速率,也不是延迟。100 万的 lag 在一个每秒 10 万条的 topic 上是 10 秒,在一个每秒 100 条的 topic 上是近 3 小时;只看绝对值,等于没看。

最常用的一条命令是 kafka-consumer-groups.sh --describe:

kafka-consumer-groups.sh --bootstrap-server broker-1:9092 \
  --describe --group billing-ledger
GROUP            TOPIC              PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG    CONSUMER-ID                                       HOST         CLIENT-ID
billing-ledger   payments.charged   0          184220          184220          0      consumer-billing-ledger-1-6f3a...                 /10.0.3.17   consumer-billing-ledger-1
billing-ledger   payments.charged   1          90104           90574           470    consumer-billing-ledger-1-6f3a...                 /10.0.3.17   consumer-billing-ledger-1
billing-ledger   payments.charged   2          120071          120071          0      consumer-billing-ledger-2-9c11...                 /10.0.3.18   consumer-billing-ledger-2
billing-ledger   payments.charged   3          -               152200          -      -                                                 -            -

怎么读:

列含义异常信号
CURRENT-OFFSET该分区已提交的 offset- 表示这个组从未提交过,或 offset 已过期
LOG-END-OFFSET分区末尾下一条记录的 offset(LEO)持续增长是正常的;突然下降说明日志被截断/重建
LAGLEO - CURRENT-OFFSET- 不是 0,是「不知道」
CONSUMER-ID当前拥有该分区的消费者- 表示没有活跃成员,分区无人消费
HOST / CLIENT-ID消费者实例的位置与逻辑名多行指向同一实例,说明分区分配不均

逐行看这份输出:

  • P0 的 lag 是 0,消费者是 ledger-1,健康。
  • P1 的 lag 是 470,且 ledger-1 同时拥有 P0/P1。如果 470 在几分钟内不降,说明这个消费者处理不过来,或者卡在某个下游。
  • P2 的 lag 是 0,消费者是 ledger-2,健康。
  • P3 的 CURRENT-OFFSET 和 LAG 都是 -,CONSUMER-ID 也是 -:这个分区没有提交过 offset,也没有活跃消费者。它可能是新加的分区,也可能是组已经空了。- 不是 0;如果把它当 0,会误判为「没有积压」。

再看组状态:

kafka-consumer-groups.sh --bootstrap-server broker-1:9092 \
  --describe --group billing-ledger --state
GROUP            COORDINATOR (ID)          ASSIGNMENT-STRATEGY  STATE               #MEMBERS
billing-ledger   broker-3:9092 (3)          range                Stable              2
STATE含义什么时候该紧张
Stable成员稳定,分区已分配正常
PreparingRebalance成员正在加入/退出,等待重新分配超过几十秒,通常有成员卡在 poll
CompletingRebalance正在下发分配方案通常很短;长时间停留说明 coordinator 或成员异常
Empty组没有成员,offset 仍保留计划内停机正常;计划外出现就是 consumer 全挂了
Dead组已被删除,offset 也过期需要重建组/重置 offset

#MEMBERS 是当前成员数。把它和 --members 的分配放在一起看,才能发现「成员在、但分区没分到」的情况:

kafka-consumer-groups.sh --bootstrap-server broker-1:9092 \
  --describe --group billing-ledger --members --verbose
GROUP            CONSUMER-ID     HOST         CLIENT-ID      #PARTITIONS  ASSIGNMENT
billing-ledger   ...             /10.0.3.17   ...            2            payments.charged(0,1)
billing-ledger   ...             /10.0.3.18   ...            2            payments.charged(2)

这里 ledger-2 只分到 P2,P3 没有出现在它的 ASSIGNMENT 里;结合上一条命令,P3 没有 owner。这不是「分配不均」,而是「P3 没人要」——可能是消费者只订阅了部分分区,或者 rebalance 还没完成。--members 的输出比 lag 更能暴露「谁在干活、谁在闲着」。

最后看分区副本:

kafka-topics.sh --bootstrap-server broker-1:9092 \
  --describe --topic payments.charged
Topic: payments.charged  PartitionCount: 4  ReplicationFactor: 3
  Partition: 0  Leader: 1  Replicas: 1,2,3  Isr: 1,2,3
  Partition: 1  Leader: 2  Replicas: 2,3,1  Isr: 2,3
  Partition: 2  Leader: 3  Replicas: 3,1,2  Isr: 3,1,2
  Partition: 3  Leader: 1  Replicas: 1,2,3  Isr: 1,2,3

P1 的 Isr 只有 2,3,比 Replicas 少一个:有一个副本落后了。如果这个 topic 配置了 min.insync.replicas=2,P1 还能写;如果配置是 3,P1 的写入会被拒绝。Isr 变短是 acks=all 退化的前兆,比 lag 更值得告警。

把上面的读数归纳成一张判断表:

读数正常异常先查什么
lag 稳定且接近 0消费跟得上——
lag 单调上升—消费速率 < 生产速率消费者 CPU、下游延迟、分区倾斜
lag 锯齿后回落发版/扩缩容导致的短暂 rebalance每分钟都在锯齿rebalance 频率、max.poll.interval
lag 很高但完全平坦—消费者卡死或分区无 ownerCONSUMER-ID、线程栈、下游连接
单个分区 lag 高—热 key / 分区倾斜key 分布、分区数
lag 为 0 但下游延迟/重复—提交早于处理,或重放提交策略、幂等记录
lag 为 -新组首次启动前组空了、offset 过期--state、auto.offset.reset
Isr < Replicas短暂落后副本故障/磁盘慢broker 日志、磁盘、网络
PreparingRebalance 长时间—成员卡在 pollGC、下游超时、max.poll.records

rebalance 本身也要观测。除了 --state,consumer 日志里的 Revoke previously assigned partitions、(Re-)joining group、Coordinator moved 都是信号;统计每小时的出现次数,比事后看 lag 更能定位「为什么老是抖」。JMX 里的 assigned-partitions、commit-latency-avg、records-lag-max 可以配合看,但先看 --describe 的分区级 lag,它比聚合指标更能指出是哪个分区、哪个消费者。

lag 的绝对值没有意义

判断 lag 是否危险,要同时看三个东西:趋势(在涨还是在降)、速率(生产速率和消费速率)、分布(是所有分区都涨,还是某一个)。一个稳定的 10 万 lag 可能完全健康;一个从 0 开始每分钟涨 100 的 lag 才是事故前兆。

7.8.1 把 lag 换算成时间

lag 只有换算成「追上要多久」才有决策价值。假设当前 lag 是 L,消费速率是 c 条/秒,生产速率是 p 条/秒,那么追平时间近似为:

Tcatch-up=Lc−p,c>p T_{\text{catch-up}} = \frac{L}{c - p}, \quad c &gt; p

如果 c <= p,分母为零或负数,lag 永远不会自己降下来——这时加消费者、限流生产、或者降级下游,必须做其中一个,否则积压只会越来越大。

lag消费速率 c生产速率 p追平时间判断
470100/s80/s23.5 s正常波动
10,0001,000/s500/s20 s可接受
100,0001,000/s900/s1,000 s ≈ 16.7 min接近告警线
100,0001,000/s1,000/s永不必须干预
1,000,0005,000/s4,000/s1,000 s ≈ 16.7 min绝对数字大,但可控

这张表说明两件事。第一,同样的 10 万 lag,追平时间可以从 20 秒到无穷,只看 lag 会得出完全相反的结论。第二,消费速率和生产速率的差比绝对值更重要;c 只比 p 大 10% 时,任何一点抖动都会让差变成负数。

聚合 lag 还会掩盖单分区故障。一个 12 分区的 topic,11 个分区 lag 为 0、1 个分区 lag 为 100 万,总 lag 是 100 万;如果只看总和,会以为「整体还行」,实际上那个分区可能已经卡死。告警要同时看 max(partition_lag) 和「lag 超过阈值的分区数」,而不是只看 sum(lag)。

7.9 真实案例:Kafka 在 LinkedIn 的规模与运维

2019 年 10 月 8 日,LinkedIn 工程博客发表了 Jon Lee 和 Wesley Wu 的《How LinkedIn customizes Apache Kafka for 7 trillion messages per day》。公开的数字是:100 多个 Kafka 集群、4,000 多个 broker、100,000 多个 topic、700 万个分区,每天处理超过 7 万亿条消息;一些大集群超过 140 个 broker,单个集群托管 100 万个 replica。

这些数字本身不是重点,重点是它们带来的约束。把公开材料里的做法按「约束 → 机制」拆开:

规模/约束LinkedIn 的公开做法工程含义
700 万分区定制 controller,减少 UpdateMetadataRequest 的内存占用;缩短 broker 启停时间分区不是免费的并行度;元数据会压垮 controller
100,000+ topic创建 topic 时强制最小副本;按应用计量与配额(Bean Counter)topic 数量是运维成本;创建时就要约束
4,000+ broker、100+ 集群按业务隔离集群;Brooklin 做跨集群复制爆炸半径按集群切;复制是独立的一层
单集群 100 万 replicaCruise Control 自动做分区迁移和自愈;broker 上下线走维护模式手工 reassign 不可扩展;rebalance 必须自动化
每天 7 万亿条broker 级配额、按应用计量(Bean Counter);Avro + schema registry吞吐要能被归因和限流;schema 是共享契约
频繁发版-li 发布分支贴近上游;认证覆盖 rebalance、部署、滚动重启、稳定性、降级变更要按「会不会触发 rebalance」来验证

表中最后一行值得展开。LinkedIn 的 Kafka 分支不是 fork,而是在 Apache Kafka 的 release branch 上打补丁,补丁尽量 upstream-first;每个内部版本发布前要过一轮认证,认证项包括 rebalance、部署、rolling bounce、稳定性和 downgrade。这不是「大厂才需要」的流程,而是「Kafka 的变更会影响分区分配」这个事实的后果:一次 broker 重启、一次分区迁移、一次版本升级,都可能触发 rebalance,而 rebalance 会重放未提交的批次。把 rebalance 当成发布验证的一等公民,比事后查 lag 更划算。

从公开材料里能提炼出四条可迁移的做法,和规模无关:

  1. 分区预算是容量决策,不是创建 topic 时的随手填。分区数决定并行度,也决定 controller 元数据、rebalance 时长和恢复时间;LinkedIn 的 700 万分区需要定制 controller,普通团队即使只有几千个分区,也应该把「分区总数」当成一个要监控的配额。
  2. 保留和复制是按 workload 定的。活动追踪、指标、数据库 CDC 的保留和副本要求不同,混在一个集群里只能取最严格的那个,成本会失控;按用途隔离集群,才能让每个集群的 retention/replication 匹配它的 SLO。
  3. rebalance 要自动化,不要靠 SRE 手工 reassign。broker 故障是常态,Cruise Control 这类工具持续看磁盘、网络、CPU,自动迁移分区;没有自动化,集群越大,人工操作越慢、越容易出错。
  4. lag 要看行为,不要只看阈值。LinkedIn 开源的 Burrow 用滑动窗口看 offset 提交的趋势,把消费者判成 good/degraded/stopped,而不是「lag > 10000 就告警」。阈值告警在流量有季节性的系统里会大量误报。

7.9.1 元数据:700 万分区压垮 controller

Kafka 的 controller 负责维护 topic/partition/replica 的元数据,并把变更下发给 broker。分区数越多,controller 的内存、UpdateMetadataRequest 的体积、broker 启动时拉取元数据的时间都会增长。LinkedIn 的公开材料提到,大集群出现过 controller 内存压力和 controller 故障,甚至级联失败;他们通过复用 UpdateMetadataRequest 对象、减少日志、缩短 broker 启停时间等补丁来缓解。

这说明「分区数」不只是并行度参数,还是元数据预算。一个中等规模的集群,如果有几千个 topic、每个 topic 几十个分区,分区总数很容易到十万级;这时 controller 的堆、元数据下发延迟、broker 启动时间都要纳入容量规划。监控至少要有:

指标为什么
controller 堆使用与 GC 停顿元数据对象多,GC 会拖慢下发
UpdateMetadataRequest 大小/延迟元数据膨胀会拖慢 broker
broker 启动到可服务的时间重启变慢会拉长发布窗口
分区总数、topic 总数提前发现「创建 topic 不回收」

7.9.2 复制、保留与成本

7 万亿条/天是一个吞吐数字,乘以保留期和副本数才是存储成本。一个粗略的算法:

每天字节数 = 平均写入带宽 × 86400
总存储     = 每天字节数 × 保留天数 × 副本数 × 1.3(索引/段开销)

假设平均写入 1 GB/s、保留 7 天、3 副本:1 GB/s × 86400 ≈ 86.4 TB/天,乘 7 天再乘 3 约 1.8 PB。这个数字随保留期线性增长,所以「保留久一点」不是免费的运维选项,而是容量和成本的决策。

LinkedIn 的做法是按 workload 分集群:活动追踪、指标、数据库 CDC 的保留、副本和延迟要求不同,混在一起只能取最严格的那个。保留期的下界是「最大消费者停机时间 + 全量重放时间」,上界是预算;超过下界之后,每多一天都要问「这一天真的会被重放吗」,否则就是在为「可能有用」付钱。副本也是同理:3 副本是常见默认,但冷数据、可重建的派生数据可以用更低的副本数,前提是接受重建时间。

7.9.3 把 rebalance 当成发布对象

LinkedIn 的发布认证包含 rebalance、部署、rolling bounce、稳定性和 downgrade。这个列表值得抄,因为它覆盖的正是「变更如何影响分区分配」:

  • rebalance:滚动重启消费者,观察重复处理、lag 峰值、分区分配是否均匀;
  • rolling bounce:逐台重启 broker,观察 ISR 是否反复缩水、producer 是否收到 NotEnoughReplicas;
  • downgrade:新版本回退到旧版本,元数据和事务状态是否兼容;
  • stability:在持续写入下跑一段时间,看 controller、GC、磁盘是否稳定。

大多数团队不需要定制 Kafka 分支,但需要一个「发布前跑一遍 rebalance」的检查项。rebalance 不是异常,是发布的一部分;把它当成异常,就会在每次发版时被同一个重复扣款打中。

事实边界

本节只引用 LinkedIn 工程博客的公开数字和做法:集群/broker/topic/分区的规模、controller 与维护模式、Cruise Control/Brooklin/schema registry/Bean Counter、发布认证项。公开材料讲的是规模与运维机制,不是一次事故;本节不编造时间线,也不把「LinkedIn 的做法」当成所有团队的默认配置。原文链接:How LinkedIn customizes Apache Kafka for 7 trillion messages per day。

7.10 重试导致重复副作用:offset 提交失败后的二次扣款

7.4 的时间线不是假设。把场景换成一个钱包服务:它消费 payments.charged,调用支付服务商(PSP)扣款,然后写本地账本。某次滚动发版,consumer 超过了 max.poll.interval.ms,再平衡发生,offset 提交被拒;新 consumer 从旧 offset 重放,用户被扣了两次。

时间消费者/系统动作持久化状态
t0C1poll 到 500..509,已提交 offset=500committed=500
t1C1处理 500:插入 charges 行(自增 ID),用 uuid4() 生成 K1,调用 PSP 扣款成功一条 charge,PSP 已扣
t2C1写账本行,准备提交 offset账本一条
t3C1发版/GC,超过 max.poll.interval.ms 没有 poll分区即将被收走
t4coordinatorgeneration 42→43,P0 分配给 C2C1 成为僵尸
t5C1commitSync(501) 返回 CommitFailedExceptioncommitted 仍是 500
t6C2从 500 重放,再处理 500又一条 charge
t7C2生成新的 K2,调用 PSP 扣款PSP 扣第二次
t8对账一个订单两条 charge、两笔 PSP 扣款用户投诉

根因不是「offset 提交失败」——它是必然发生的;根因是幂等键在每次处理时重新生成,以及 charges 表没有以业务意图为唯一键。K1 和 K2 是两个不同的键,PSP 无法知道它们对应同一笔订单;本地两条 charge 记录也让对账看起来像两笔合法扣款。

修复要分四层,缺一层都会留下窗口:

层做法挡住什么
事件用 event_id 或 order_id 作为稳定键,重试不变外部系统能把两次调用识别为同一次
本地表charges(order_id) 主键,event_id 唯一同一订单不会插入第二条 charge
状态机pending → succeeded/failed;pending 时先查 PSP,不盲目再扣崩溃在「PSP 成功、本地未更新」的窗口
提交副作用完成后再提交 offset;提交失败可安全重放重复处理不再产生重复效果

表结构:

CREATE TABLE charges (
  order_id     TEXT PRIMARY KEY,
  event_id     UUID NOT NULL UNIQUE,
  amount_cents BIGINT NOT NULL,
  status       TEXT NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed')),
  idem_key     TEXT NOT NULL UNIQUE,
  psp_ref      TEXT,
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

order_id 主键是业务意图的粒度:一个订单只允许一条扣款记录。event_id 唯一是消费去重的粒度:同一条事件重放不会插入第二行。idem_key 是传给 PSP 的键,和 order_id 一样稳定;status 让「已经扣了但没记账」和「还没扣」区分开;psp_ref 是对账的锚点。

处理逻辑的关键是「先占位,再调用,失败可查」:

def handle(event, conn, psp):
    order_id = event["payload"]["order_id"]
    key = event["event_id"]                  # 稳定,不随重试变化
    row = conn.execute(
        "INSERT INTO charges (order_id, event_id, amount_cents, status, idem_key) "
        "VALUES (%s, %s, %s, 'pending', %s) "
        "ON CONFLICT (order_id) DO NOTHING RETURNING order_id",
        (order_id, event["event_id"], event["payload"]["amount_cents"], key),
    ).fetchone()
    if row is None:                          # 这个订单已经有一条 charge
        status, ref = conn.execute(
            "SELECT status, psp_ref FROM charges WHERE order_id = %s", (order_id,)
        ).fetchone()
        if status == "succeeded":
            return ref
        return psp.query(key)                # pending:先查,不盲目再扣
    ref = psp.charge(key, order_id, event["payload"]["amount_cents"])
    conn.execute(
        "UPDATE charges SET status = 'succeeded', psp_ref = %s WHERE order_id = %s",
        (ref, order_id),
    )
    return ref

这段代码有两个必须理解的点。第一,INSERT ... ON CONFLICT DO NOTHING 是原子占位:两个并发重放只有一个能插入成功,另一个走查询分支。第二,psp.charge 在数据库事务之外调用;如果 PSP 成功、进程在 UPDATE 前崩溃,记录停在 pending,下次重放会走 psp.query(key),用同一个键拿回原来的扣款结果,而不是再扣一次。状态机把「未知」变成了可查询的 pending,而不是靠重试猜。

query 返回 not_found 时才允许用同一个键重新 charge;如果 PSP 不支持按幂等键查询,就把 pending 记录交给对账任务,而不是在消费循环里盲目重试。没有查询能力的幂等键,只是一个「可能重复」的键。

对账是最后一道防线。即使前四层都做了,也可能有历史脏数据或代码 bug;每天跑一次「一个订单多笔扣款」的查询:

SELECT order_id, COUNT(*) AS charges, SUM(amount_cents) AS total
FROM psp_charges
WHERE created_at >= now() - interval '1 day'
GROUP BY order_id
HAVING COUNT(*) > 1;

对账发现重复后,退款/冲正本身也要幂等:用 order_id 作为退款键,先查是否已经退过,再调用 PSP。否则「修复重复」会制造新的重复。

例 7.10.1 · 一次重放,两笔扣款

某钱包服务在发版期间触发 rebalance。订单 ord-8891 的事件被处理两次:

  • 第一次:charges 插入一条自增 ID 的记录,PSP 用 K1 扣 128 元成功;账本写一条;提交 offset 失败。
  • 第二次:新 consumer 重放,charges 又插入一条(没有 order_id 唯一约束),PSP 用 K2 扣 128 元成功;账本再写一条。

对账时发现同一订单两条 charge、两笔 PSP 流水,共 256 元。修复后:

  • charges.order_id 主键,第二次插入冲突,走查询分支;
  • PSP 的键固定为 event_id,第二次调用即使发生,PSP 也返回第一次的结果;
  • 账本用 (consumer, event_id) 去重,不再追加第二条;
  • 提交失败后的重放只增加日志,不增加钱。

修复后的第一次重放仍会「执行」一次 handle,但 handle 是幂等的:效果只有一次。有效一次不是「不重放」,而是「重放不产生第二次效果」。

offset 提交失败不是根因

提交失败只是暴露了「处理位点领先于提交位点」这个事实。真正决定用户是否被扣两次的,是幂等键是否稳定、业务表是否有唯一约束、外部调用是否可查询。把 CommitFailedException 当成异常去重试提交,而不去修幂等,下一次 rebalance 还会再来。

7.11 事件设计清单

事件一旦发布,就是一份公开契约。消费者会按字段解析、按类型分支、按 event_id 去重;你改一个字段名,可能让三个团队当天上线失败。设计事件时,先把信封(envelope)定下来,再谈 payload。

{
  "event_id": "01J9Z8K3Q7YV2M6T4R1X5B8N0C",
  "type": "payment.charged",
  "version": 3,
  "occurred_at": "2026-10-19T09:31:02.114Z",
  "producer": "payments-api/2.14.0",
  "aggregate_id": "order-8891",
  "aggregate_seq": 7,
  "partition_key": "order-8891",
  "correlation_id": "trace-4f2a9c...",
  "causation_id": "01J9Z8K3Q7YV2M6T4R1X5B8N0B",
  "payload": {
    "order_id": "order-8891",
    "amount_cents": 12800,
    "currency": "CNY"
  }
}

信封里的字段各有职责,不能互相替代:

#字段/决策为什么合格的样子
1event_id去重、追踪、幂等键的候选全局唯一、写入时生成、永不改变
2type消费者按类型路由过去式事实:order.paid,不是 payOrder
3versionschema 演进整数,单调递增;消费者能处理未知版本
4occurred_at业务发生时间UTC,带时区;不是入库时间
5producer排障与归属服务名 + 版本号
6aggregate_id一致性边界订单号/账户号,和业务主键一致
7aggregate_seq顺序与旧事件拒绝每个聚合单调递增;有空洞时消费者能识别并等待/回退
8partition_key顺序保证的范围通常等于 aggregate_id;不要为 null
9payload业务事实自包含、可读、不含内部自增 ID
10schema跨语言契约Avro/Protobuf + registry;有兼容性检查
11幂等键外部副作用去重event_id 或业务键,重试不变
12保留/重放消费者能追多久保留期 ≥ 最大停机 + 重放时间;有归档
13PII合规与成本最小化、脱敏/加密、可删除
14删除语义压实 topic 的墓碑删除事件有明确 tombstone,消费者会处理
15大小上限broker/网络/内存默认 < 1 MB;大对象用 claim-check
16归属与 DLQ出问题谁处理有 owner、有死信队列、有回放手册

这张表里最容易被跳过的是第 7、8、12 项。没有 aggregate_seq,消费端只能用 occurred_at 猜顺序,而时钟不可靠;没有稳定的 partition_key,同一聚合的事件会散到不同分区;没有保留/重放设计,消费者停机一周后只能面对 OffsetOutOfRange。事件设计不是 JSON 字段设计,而是「身份、顺序、重放、演进」四件事的契约。

事件、命令、快照经常被混用,语义不同:

类型语义例子谁决定结果
命令(command)请求做一件事,可能被拒绝charge_order接收方
事件(event)已经发生的事实,不可撤销order.paid产生方
快照(snapshot)某个时刻的完整状态order.snapshot.v3聚合/投影

把命令当事件发,会让下游以为「请求一定成功」;把事件当命令发,会让下游重复执行。事件用过去式,命令用祈使句,命名是最便宜的类型检查。

分区键的规则可以压缩成四句话:

  1. 同一聚合的所有事件用同一个键,通常是 aggregate_id;
  2. 键一旦发布就不要改;要改就双写新旧 topic,做受控迁移;
  3. 有序流程不要用 null key;null key 的 sticky 分区没有 key 级顺序;
  4. 键的基数要足够高,避免把大部分流量压到一个分区;热 key 要单独设计。

schema 演进要按「旧消费者读新事件」和「新消费者读旧事件」两个方向检查:

变更兼容性做法
新增可选字段双向兼容给默认值;旧消费者忽略
新增必填字段破坏不要做;改成可选 + 服务端默认
删除字段对新消费者破坏先标记 deprecated,观察使用量再删
改字段类型破坏新增字段 + 双写/双读
改枚举值可能破坏消费者保留 unknown 分支,不要直接 raise
改语义破坏新增 type 或 version,不要原地改

保留和重放要一起设计。保留期至少覆盖「消费者最长停机时间 + 全量重放时间」;超过这个窗口的数据要归档到对象存储,并保留可重放的格式。压实 topic 适合保存「每个 key 的当前状态」,不适合保存「所有历史事件」;墓碑的保留时间(delete.retention.ms)要大于消费者的最大停机时间,否则删除事件会先消失,消费者永远不知道某个 key 被删了。

事件不是通知

「订单更新了,自己去查」不是事件,是通知:它把一致性责任推给了消费者,消费者还要猜「更新了什么、顺序是什么、查到的是不是新的」。事件应该携带足够自包含的事实(金额、状态、发生时间),让消费者不依赖回查就能做出决定;回查只能作为补充,不能作为主路径。

7.12 本章陷阱清单

12 条高频错误
  1. 把 offset 当消息 ID:offset 只在单个分区内有意义,压实后还会出现空洞;跨分区不可比较。事件 ID 要独立生成。
  2. 以为「Kafka 是至少一次」应用就至少一次:自动提交 + 异步处理实际是最多一次;语义由提交策略决定,不由 broker 决定。
  3. 先提交 offset 再处理:进程在处理前崩溃,消息永远不会再来;只有可丢的指标才适合这种写法。
  4. 处理成功、提交失败后不幂等:这是最常见的重复来源;提交失败是常态,必须假设每次处理都会被重放。
  5. 用处理时生成的 UUID 当幂等键:每次重试都是新键,外部系统无法识别为同一次;键要在首次调用前确定并复用。
  6. 增加分区后假设顺序还在:hash(key) % N 变了,同一 key 的历史和未来落在不同分区;要么接受乱序并用版本号,要么做受控迁移。
  7. 用 null key 还指望同一用户有序:null key 走 sticky 分区,同一用户的事件可能落在任意分区;有序流程必须显式指定 key。
  8. 消费者数超过分区数:多出的消费者分不到分区,只是空闲;并行度上限是分区数,加消费者不等于扩容。
  9. 把 acks=all 当不丢:ISR 缩水后 all 等于 1;min.insync.replicas=1、同机架副本、unclean leader election 都会让已确认写丢失。
  10. 认为 Kafka 事务覆盖数据库/HTTP 副作用:事务只覆盖 Kafka 的分区和 offset;外部写要靠 outbox、幂等键或对账。
  11. 重试 topic 不按原 key 分区:主流程不再阻塞,但同一聚合的事件在重试 topic 里乱序;重试也要用同一个分区键。
  12. 保留期短于最大停机时间:消费者恢复时 offset 已越界,重置到 earliest/latest 都会留下数据缺口;保留期是契约,不是运维细节。

7.13 本章出口检查

检查项达标线没达标去哪
队列 vs 日志能说出进度归谁、怎么重放、慢消费者的后果7.1
三种投递语义能画出丢失/重复窗口,说明有效一次的作用域7.2
分区与顺序给定 key 和分区数,能说出顺序范围与扩分区的后果7.3
消费者组能解释消费者数/分区数关系,以及热点分区为什么加消费者无效7.3
offset 提交能区分自动、同步、异步、事务四种提交的语义7.4
再平衡能画出僵尸消费者 + 提交被拒 + 重放的时间线7.4
ISR 与 acks能解释 acks=all 为什么仍可能丢,min.insync.replicas 挡住什么7.5
幂等生产者与事务能说出两者的作用域和三个边界7.5
Outbox/CDC/事件溯源能对比事件来源、侵入性、重放方式和主要风险7.6
lag 观测能从 --describe 读出 lag、owner、无提交 offset 的分区7.8
重复扣款修复能给出稳定键 + 唯一约束 + 状态机 + 对账四层7.10
事件设计16 项清单里至少能答出 14 项7.11
即时自测 · 7

Q1 一个任务队列要求「每个任务至少执行一次,慢任务不能被另一个 worker 重复执行」。选队列还是日志?说明慢任务的处理方式。

Q2 自动提交 + 异步处理,实际投递语义是什么?给出一种最小修复。

Q3 一个 topic 有 4 个分区,消费者组有 6 个消费者,key 是 user_id。加消费者能降 lag 吗?把分区加到 8 有什么风险?

Q4 acks=all、replication factor=3、min.insync.replicas=2。一个 follower 落后被踢出 ISR,写入还能继续吗?第二个 follower 也落后时呢?

Q5 offset 提交失败后重放,为什么「去重表 + 稳定幂等键」必须同时存在?只做其中一个会怎样?

答案

A1 选队列更直接:队列的可见性超时 + ack 机制天然适合「任务」语义,慢任务需要把可见性超时设得比最大处理时间长,或者让 worker 定期续期(heartbeat/change visibility),避免超时后被另一个 worker 抢走。如果任务需要重放、审计、多消费者,选日志,但要用 offset 提交 + 去重表实现「至少一次 + 有效一次」;日志没有可见性超时,慢任务只会让 lag 增长,不会自动重复。

A2 最多一次。自动提交把 poll 返回的末尾 offset 提交了,异步任务还没处理完;进程崩溃后这些消息不会再被读到。最小修复:关闭自动提交,处理完成后再 commitSync;如果必须异步,把任务持久化到 outbox/任务表,并让「任务落库」与「offset 提交」原子化,消费端再按任务 ID 去重。

A3 不能。4 个分区最多 4 个活跃消费者,另外 2 个分不到分区,只是空闲。把分区加到 8 会改变 hash(user_id) % N,同一用户的历史和未来可能落在不同分区,顺序保证断裂;需要双写迁移、版本号/序号,或者接受乱序并让消费端幂等。

A4 还能继续。ISR 变成 {leader, follower2},大小是 2,仍满足 min.insync.replicas=2,acks=all 等这两个副本;但容错余量从 1 变成 0。第二个 follower 也落后时,ISR 只剩 leader,小于 2,producer 收到 NotEnoughReplicas,写入失败;这是用可用性换持久性,避免接受可能丢的写。

A5 去重表挡的是「同一个 event_id 被处理两次」,稳定幂等键挡的是「重复调用外部系统」。只有去重表:如果第一次处理在写去重记录前就调用了 PSP 并崩溃,重放会再次调用,外部系统仍可能扣两次。只有稳定键:PSP 能去重,但本地账本、消息、通知仍可能重复写。两者作用在不同层,必须同时存在,再加上对账兜底。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 6 章 应用层7.1、7.2背景只借用「消息/协议/端到端」的视角;队列与日志的语义不在母本范围
张彦飞《深入理解 Linux 网络》第 4–7 章7.8背景连接、超时、收发路径只引用,不展开内核实现
《Python 网络编程》并发与客户端工程7.6、7.10精选借用工程风格;outbox、消费者去重代码为本书重写
《Linux 系统与内核》第 4 篇4.1–4.117.8交叉引用明确不重复 TCP/IP、socket、epoll、HTTP
本书自编 + Kafka 论文/官方文档Kafka 论文、KIP、consumer group 文档7.1–7.6、7.8完整只引用机制与事实,不写 Kafka 全配置手册
LinkedIn 工程博客 2019-10-08《How LinkedIn customizes Apache Kafka for 7 trillion messages per day》7.9完整只引用公开规模、工具与发布流程,不编造事故

这一章没有一一对应的母本章节:谢希仁和张彦飞讲的是网络分层与内核路径,不覆盖队列/日志语义、消费者组、outbox 和事件溯源。本书自编的部分集中在「机制 → 失败窗口 → 观测命令 → 修复」这条链上;Kafka 的 ISR、事务、幂等生产者等事实来自论文与官方文档,代码和事故场景是按工程实践重写的,不复制文档长段,也不把 LinkedIn 的规模当成默认配置。

下一站:消息把「一次状态变化」变成了可重放的流,但还有一类数据不在你的数据库里:对象、缓存和 CDN。进入 8 对象存储、缓存与 CDN,看不可变对象、条件写、缓存失效和回源风暴如何接住这条流的下游。

8 对象存储、缓存与 CDN

本章回答

为什么对象存储说「强一致」,业务还是读到旧头像、旧价格?为什么删了缓存还是脏,TTL 到底能保证什么?为什么一次批量过期会把源站打挂,重试又把它推得更深?上传超时重试,为什么会在对象存储里留下两个对象、在账单里留下两笔扣费?

读完你会

能用 ETag、If-None-Match、If-Match 写条件 PUT 并处理 412;能画出四种缓存模式的读写路径与失败窗口;能用 singleflight、空值缓存、TTL 抖动和版本号处理击穿、穿透、雪崩与热点;能设计 CDN 的 cache key、purge 与 stale-while-revalidate;能读懂 curl -I 的 Age/X-Cache;能把上传完成回调做成幂等。

第 7 章把数据从「一次请求」搬到了「一条流」:生产者写日志,消费者按 offset 读,投递语义是至少一次,重复只能靠消费端去重。到了本章,数据终于静下来了——图片在对象存储里,价格在数据库里,页面在 CDN 上。但「静下来」不等于「读得到最新的那一份」。用户点开商品页,看到的往往是某一层缓存里的副本,而不是源站刚提交的值。

先看一个很普通的事故。运营在后台把商品主图换成新图,上传接口返回 200,数据库里的 object_key 也更新了。十分钟后,客服收到投诉:用户看到的还是旧图。排查发现三件事同时成立:CDN 的 TTL 是 1 小时,服务本地缓存是 10 分钟,而对象存储的 key 没变、只是内容被覆盖了。源站是对的,链路上每一层缓存也都「按配置工作」,但用户看到的仍然是旧的。缓存不是数据库的透明加速器,它是一份可能旧、可能缺、可能被驱逐的物化视图;视图和源站之间隔着 TTL、失效消息、回源 race 和网络分区。

这一章要解决的就是这三层之间的「时间差」:对象存储这一层能提供什么保证(8.1、8.2),应用缓存怎么读写、什么时候会脏(8.3、8.4),miss 的形状如何把源站打挂(8.5、8.7),CDN 的边缘缓存和失效又多了哪些维度(8.6),以及出问题时看什么(8.8)。最后用 Cloudflare 2019-07-02 的边缘故障说明「全球配置 + 边缘计算」的爆炸半径(8.9),用一次上传重试说明对象存储的幂等只覆盖字节、不覆盖业务副作用(8.10)。

一句话先放在这里:强一致描述的是「读到的当前版本」,不是「你以为是最后一次写」;缓存描述的是「最多能旧多久」,不是「永远一致」。

8.1 对象存储的语义:PUT/GET、ETag、条件写、多段上传

把对象存储当文件系统,是第一个错误。PUT /avatars/u-1024.png 不是「打开文件、写一段、关闭」,而是「用一整个新对象替换这个 key 当前指向的对象」。没有 open/seek/append,没有原地修改,没有原子 rename,也没有真正的目录:list prefix 是遍历 key 空间,不是读目录项。理解这一点,才能理解为什么「只改一个对象的元数据」也常常是一次整体复制。

操作实际语义常见误解
PUT整对象替换;单 key 原子;后完成者成为当前版本以为可以局部更新、追加、改一行
GET / HEAD读当前版本;HEAD 返回元数据,不收 body以为 HEAD 不产生请求成本
DELETE删除当前版本;版本化桶里是加 delete marker以为字节立刻消失、旧版本也没了
COPY服务端复制出一个新对象以为 rename 是原子元数据操作
LIST按 prefix 分页扫描 key以为一次 LIST 是时间点快照
Multipartinitiate / upload part / complete以为 ETag 是整对象的 MD5
Presigned URL带签名的临时 bearer URL以为可以按 URL 单独撤销

8.1.1 ETag 是不透明的版本令牌,不是校验和

ETag 看起来像 MD5,因为单段 PUT 的 ETag 经常就是内容的 MD5。但这是实现细节,不是契约。多段上传的 ETag 是「各分片 MD5 再 MD5」再加一个 -N 后缀;启用 SSE-KMS/SSE-C 之后,ETag 不再是内容 MD5;不同厂商、不同加密方式、不同分片大小都会让 ETag 变化。把 ETag 当内容哈希去校验下载,会在换加密方式或换分片大小时突然失效。要做完整性校验,用对象存储提供的 checksum(SHA-256、CRC32C 等),或者自己算,不要用 ETag。

Last-Modified 也不是版本令牌。它只有秒级精度,两次写入可能落在同一秒;用它做条件写,等于用「秒」当 CAS 的版本号。要并发控制,用 ETag、version id,或者自己维护的单调版本号。

8.1.2 条件写:把「读-改-写」变成一次 CAS

没有条件写时,两个 writer 的「读出来、改一改、写回去」会丢失更新:两个人都读到 v1,各自写回 v2,后完成的覆盖先完成的,先完成的人还以为自己成功了。条件头把「我读到的是哪个版本」带进写请求:

  • If-None-Match: *:仅当 key 不存在时创建,已存在返回 412。适合「抢注」、幂等创建、防止覆盖。
  • If-Match: "<etag>":仅当当前 ETag 匹配时写入,不匹配返回 412。适合 CAS、乐观锁。
  • 并发条件写:S3 文档的说法是「第一个完成的写成功」,其余返回 412;不要把 412 当网络错误盲目重试,要重新读、合并、再写。
  • 版本化桶里,如果当前版本是 delete marker,If-None-Match: * 可能允许写入;这个语义要按具体实现的文档确认,不要凭直觉。
PUT /avatars/u-1024.png HTTP/1.1
Host: media-example.s3.us-east-1.amazonaws.com
Content-Type: image/png
Content-Length: 184320
If-Match: "9f2c1d7e5b1a4c3d8e6f0a2b4c6d8e0f"

HTTP/1.1 412 Precondition Failed
x-amz-request-id: 7Q3F9Y2K4M8P1R5T

同一个请求,如果 ETag 仍然是当前版本,会得到 200 OK 和新的 ETag;如果中间有人改过,才会得到 412。412 是正常的并发结果,不是故障;它告诉你「你基于的版本已经过期」,正确的动作是重读、合并、再带新 ETag 写一次,而不是换一个随机 ETag 硬写。

# create-only:第一次 200,第二次 412,不会覆盖已有对象
curl -sS -o /dev/null -w '%{http_code}\n' -X PUT \
  --data-binary @avatar.png \
  -H 'Content-Type: image/png' \
  -H 'If-None-Match: *' \
  https://media-example.s3.us-east-1.amazonaws.com/avatars/u-1024.png

这条命令的两次输出分别是 200 和 412。第二次的 412 说明对象已存在,调用方应该走「读取已有对象」或「换一个 key」的分支,而不是重试。写侧条件头不是一开始就有的:S3 在 2024 年才给 PutObject 补齐 If-Match/If-None-Match;在此之前,跨对象做 CAS 只能靠外部锁(DynamoDB、etcd)或版本化 key。即便现在有了条件写,它也只管一个 key,不管跨 key 的业务不变量。

8.1.3 多段上传:重试的单位是分片,不是对象

大对象走多段上传:CreateMultipartUpload 拿到 UploadId,按 part number(1–10000)并行上传分片,每片除最后一片外至少 5 MiB,最后 CompleteMultipartUpload 提交 part 列表和各自的 ETag。这个设计的目的就是让重试便宜:某个分片失败,只重传那一片;重传同一个 part number 会覆盖该分片,是安全的。

但 CompleteMultipartUpload 本身不是「可以无限重复」的。完成成功后 UploadId 就失效了,再发一次 complete 会得到 NoSuchUpload(404),而不是 200。「complete 超时」不能直接当失败重试:先 HEAD 目标对象,确认 size/checksum/ETag 是否符合预期;如果已经存在且匹配,就当作成功;只有确认没完成,才重建上传。这个细节在 8.10 会变成一次真实的重复扣费。

还有两个工程点。第一,未完成的分片会占存储、会计费,配一条 lifecycle 规则 AbortIncompleteMultipartUpload 自动清理。第二,分片大小是「重试成本」和「请求数」的折中:8–16 MiB 适合百 MB 级对象,分片太小请求多、连接复用差,太大则一次重传要重发很多字节。多段对象的 ETag 不是整对象 MD5,校验要用 composite checksum 或自己算。

8.1.4 预签名 URL:把写权限借出去,但别把 key 也借出去

预签名 URL 是把「某个方法 + 某个 key + 过期时间 + 部分 header」签名后交给客户端,客户端在有效期内可以直接读写,不必持有长期凭证。它本质上是 bearer token:URL 泄露,等于权限泄露;签发后无法按 URL 单独撤销,只能等过期、轮换凭证,或者走自己的代理。SigV4 的预签名最长 7 天;客户端时钟偏差会让签名提前失效。

上传场景最常见的错误,是让客户端决定最终 key。正确做法是服务端生成 key(例如 uploads/{upload_id}/source.mp4),签一个短 TTL 的 PUT,客户端直传;完成后服务端 HEAD 校验大小、content-type、checksum,再 COPY 到最终 key。这样客户端不能覆盖别人的对象,服务端也保留了「这次上传属于谁」的记录。直接把最终 key 的写权限签给客户端,等于把「覆盖任意对象」的能力也签了出去。

8.1.5 一次预签名直传的完整时序

把 8.1.4 的建议展开成一条可执行的链路:

客户端                     业务 API                     对象存储
  │  POST /uploads            │                            │
  ├──────────────────────────▶│ 生成 upload_id             │
  │  {upload_id, put_url}     │ 签 staging key 的 PUT      │
  │◀──────────────────────────┤                            │
  │  PUT staging (短 TTL)      │                            │
  ├───────────────────────────────────────────────────────▶│
  │  200 + ETag                │                            │
  │◀───────────────────────────────────────────────────────┤
  │  POST /uploads/{id}/complete                           │
  ├──────────────────────────▶│ HEAD staging:size/type/checksum
  │                           ├───────────────────────────▶│
  │                           │ COPY staging → final key   │
  │                           ├───────────────────────────▶│
  │  201 {object_key, etag}   │ 写元数据(幂等键 upload_id)│
  │◀──────────────────────────┤                            │

这条链路里有三个校验点:complete 时 HEAD staging,确认客户端真的传了、大小/类型符合预期;COPY 到 final key 时用服务端生成的目标 key,避免客户端覆盖;写元数据时用 upload_id 做唯一键,避免重试插入两行。staging key 的 TTL 要短(比如 1 小时),配 lifecycle 清理未完成的上传。

检查点要验证什么失败时
HEAD staging对象存在、size 在范围内、content-type 白名单400/422,要求重传
checksum客户端声明的 SHA-256 与服务端算的一致422,拒绝进入转码
COPY final目标 key 由服务端生成,源 key 属于本次 upload_id403/409,记录审计
元数据写入upload_id 唯一约束冲突时返回已有记录
定义 8.1.1 · 对象、ETag 与条件写

对象是不可变的字节序列加元数据,由一个 key 指向当前版本。ETag 是当前版本的不透明校验器(opaque validator):它通常随内容变化,但不保证是 MD5,也不保证跨实现可比。条件写把 If-None-Match/If-Match 带进写请求,让「读某个版本 → 基于它修改 → 写回」变成一次 compare-and-swap;失败返回 412,而不是静默覆盖。

对象存储的原子性只到 key

单次 PUT 对同一个 key 是原子的:读者要么看到旧对象,要么看到新对象,不会看到半个对象。但「对象 A 更新 + 对象 B 更新 + 数据库记录更新」没有原子性;跨 key 的原子更新必须由应用自己设计(版本号、manifest、事务表),对象存储不提供。

8.2 S3 强一致之后还剩什么坑

2020 年 12 月 1 日,S3 宣布对所有 GET、PUT、LIST 提供强读后写一致性:新对象、覆盖写、删除都算,所有 Region,不需要改配置,也不额外收费。在这之前,新对象的 PUT 是读后写一致的,但覆盖写和 DELETE 是最终一致,LIST 也是最终一致。这个变化很大,但它只解决了「单 key 的数据面」这一层。

操作2020-12 之前2020-12 之后
新对象 PUT → GET读后写一致读后写一致
覆盖 PUT → GET最终一致,可能读到旧值强一致,读到新值或更新值
DELETE → GET最终一致,可能还读得到强一致,404 或 delete marker
LIST最终一致,可能看不到新对象强一致,反映已提交变更
跨 key 原子不提供不提供
跨区域复制异步异步
桶配置/IAM最终一致最终一致

「强一致」在这里的准确含义是:对单个 key 的数据面操作,读到的顺序与成功写的顺序一致;它不意味着「最后一次用户意图获胜」,也不意味着「所有副本、所有区域、所有缓存同时更新」。下面这些坑,在 2020-12 之后仍然存在。

8.2.1 并发覆盖:两个 200,一个赢家

强一致不能阻止丢失更新。假设两个 writer 都往同一个 key 写:

t0   A 开始上传 v2(200 MB,慢)
t1   B 开始上传 v3(小文件,快)
t2   B 完成,PUT 200;当前版本 = v3
t3   A 完成,PUT 200;当前版本 = v2   ← A 的内容更旧,但它后完成

两个 PUT 都返回 200,A 和 B 都认为自己成功了。强一致保证的是:t3 之后所有读者都看到 v2;它不保证 B 的 v3 会被保留。这不是一致性 bug,是丢失更新(lost update)。修复只能靠条件写(If-Match)或版本化 key + 指针 CAS,不能靠「S3 已经强一致了」这句话。

8.2.2 跨 key 没有事务:对象 + 数据库的双写

上传完成后,应用通常要做两件事:把对象写到 media/{id},把元数据写到数据库。如果对象写成功、数据库提交失败,会留下孤儿对象;如果数据库先提交、对象写失败,会留下「指向不存在对象」的记录。对象存储的强一致只保证「这个对象写完之后读得到」,它不参与数据库的事务,也不能和数据库一起提交。

常见修法有三种:先写对象、再写数据库,数据库用幂等键,失败可重试;先写数据库(status=pending)、再写对象、最后把状态改成 ready,读路径过滤 pending;或者用 outbox 把「对象已就绪」变成一个可重放的事件。三种都需要对账:按前缀列出对象、按表列出记录,找孤儿和悬空引用。

8.2.3 跨区域复制是异步的

跨区域复制(CRR)把对象异步复制到另一个 Region。源 Region 的强一致不覆盖目标 Region:复制有延迟,目标 Region 的读可能读到旧版本;DELETE 的 delete marker 是否复制通常要单独开启;故障切换到目标 Region 时,最后一段写可能还没过去。「S3 强一致」是单 Region 桶内的事,不是全球强一致。如果业务要求跨区域读己之写,要么读源 Region,要么在应用层记录版本并等待复制完成,要么换一个提供全球一致性的存储系统。

8.2.4 版本化与 delete marker:删除不是删除

开启版本化后,DELETE 默认不是删字节,而是给当前版本加一个 delete marker;GET 返回 404,但旧版本还在,存储和费用也还在。要真正删除,得删掉具体 version id;要恢复,删掉 delete marker。合规场景还会开 Object Lock,让删除直接失败。「强一致地返回 404」不等于「数据已经不存在」:审计、隐私删除、成本清理都要按版本语义来做,不能只看 GET 的结果。

8.2.5 列表分页不是快照

LIST 现在是强一致的:它反映已提交的变更。但一次跨多页的 LIST 不是时间点快照:你读第 1 页和第 10 页之间,可能有新对象插入、旧对象删除;每一页都「当时正确」,合起来却不是同一个时刻的集合。做库存盘点、账单对账、迁移校验时,不能假设「LIST 一遍 = 一个一致快照」;要用版本 id、时间戳 cutoff、manifest,或者接受「可能多算/少算,再做第二轮对账」。

8.2.6 控制面仍然是最终一致

数据面强一致,不等于控制面强一致。桶配置、IAM/桶策略、版本化开关这些控制面变更仍然是最终一致的;S3 文档甚至建议开启版本化后等 15 分钟再发写请求。刚改完策略立刻 403、刚开版本化看不到 version id,都可能是传播窗口,不一定是代码 bug。数据面和控制面要分开观测、分开重试。

强一致保证了没有保证
单 key 的读后写、覆盖后读、删除后读跨 key 的原子更新
LIST 反映已提交变更多页 LIST 的时间点快照
HEAD、metadata、tags、ACL 的读一致并发写的「用户意图顺序」
单 Region 桶内的数据面跨区域复制、目标 Region 的读
成功写之后的当前版本CDN、服务缓存、浏览器里的副本
数据面桶配置、IAM、版本化开关的传播

8.2.7 一个可复现的强一致实验

不要只看文档,自己验证一次「覆盖写之后读到什么」。准备两个终端,A 反复 PUT 同一个 key,B 反复 GET 并记录 ETag:

# 终端 A:每 200ms 覆盖一次,ETag 随内容变化
for i in $(seq 1 50); do
  printf 'version=%s\n' "$i" > /tmp/obj.txt
  curl -sS -X PUT --data-binary @/tmp/obj.txt \
    -H 'Content-Type: text/plain' \
    https://media-example.s3.us-east-1.amazonaws.com/lab/race.txt >/dev/null
  sleep 0.2
done

# 终端 B:记录每次读到的 ETag 和内容
for i in $(seq 1 100); do
  curl -sS -D /tmp/h.txt \
    https://media-example.s3.us-east-1.amazonaws.com/lab/race.txt
  grep -i '^etag:' /tmp/h.txt
  sleep 0.1
done

你会看到:每次成功 PUT 之后的读,都能读到这次写的内容(或更新的版本),不会读到这次写之前的旧值——这是单 key 强一致。但这不能证明「先发起的写获胜」:如果 A 先发起、B 后发起但 B 先完成,最终版本是 A 的;两个 200 都真实。ETag 也不按内容新旧单调,后完成的写可能让内容回到更早的版本。要验证丢失更新,用两个不同内容的 writer 并发跑,最后看内容是不是「后完成者」,而不是「后发起者」。实验请在测试桶做,别在生产桶上刷请求。

定义 8.2.1 · 强一致、原子性与新鲜度

强一致(这里指单 key 读后写)回答的是「我读到的版本,是不是不早于某次成功写」。原子性回答的是「多个 key 的修改是不是全有或全无」。新鲜度回答的是「当前版本是不是最后一次用户意图」。S3 的 2020-12 变化只补了第一个;第二个要靠事务/manifest,第三个要靠条件写、版本号或业务排序。

例 8.2.1 · 两个上传者,一个被覆盖的版本

A 和 B 同时编辑同一张海报。A 先读 ETag v1,在本地改 3 分钟;B 读 v1,改 10 秒就上传,得到 v2;A 随后上传,没带 If-Match,得到 200,当前版本变成 v3(内容基于 v1)。B 的修改丢了。强一致让所有读者看到 v3,但没有任何机制告诉 A「你覆盖了 B」。如果 A 带 If-Match: v1,A 会收到 412,被迫重读 v2 并合并。

8.3 缓存模式:cache-aside/read-through/write-through/write-behind

缓存与 CDN:命中、回源与失效 客户端先到边缘 CDN,再到本地缓存;未命中才回源到对象存储/数据库,失效要同时覆盖边缘与本地。 缓存与 CDN:命中、回源与失效 客户端 浏览器/APP CDN 边缘 cache key / TTL 服务缓存 Redis/本地 源站 DB/对象存储 对象存储 S3 兼容 1 2 3 回源 读对象 HIT:直接返回 MISS:回源 + 回填 失效:purge 边缘 + 删本地 + 版本号 stale-while-revalidate 命中最怕陈旧;回源最怕击穿;失效最怕只清一半。
读图:缓存层数越多,命中率越高,失效路径也越长。一次 purge 只清边缘,本地缓存仍可能返回旧值;TTL 是兜底,不是一致性方案。

先看整条读路径。客户端先到 CDN 边缘,未命中再到服务缓存(Redis 或进程内),仍未命中才回源到数据库或对象存储;回填时沿着原路写回。缓存层数越多,命中率越高,失效路径也越长。一次 purge 只清边缘,服务缓存仍然可能返回旧值;TTL 是兜底,不是一致性方案。

四种缓存模式的区别,不是「谁更先进」,而是「谁负责回填」和「写什么时候算完成」。把写路径画出来,失败窗口就出来了。

模式读未命中写完成的条件主要失败窗口适合
cache-aside应用读 DB,回填缓存DB 提交 + 缓存删除/失效删除失败、回填 race、miss 风暴读多写少,能容忍短旧
read-through缓存自己读 DB 并回填取决于写路径,通常仍是 DB + 失效缓存成为读的硬依赖、loader 风暴访问层统一,托管缓存
write-through缓存同步写 DB,再返回缓存和 DB 都成功部分成功、写延迟叠加、写放大写后立刻读、写量中等
write-behind只写缓存,异步刷 DB缓存成功即返回缓存丢数据、乱序、重复刷指标/计数等可丢场景

8.3.1 cache-aside:应用自己管,缓存挂了还能退化

cache-aside(旁路缓存)是最常见的模式。读:先查缓存,命中返回;未命中查数据库,把结果写回缓存,设 TTL。写:先写数据库,提交后删除缓存(注意是删除,不是更新)。它的好处是缓存不是硬依赖:Redis 挂了,读路径可以退化为直接查库(前提是数据库扛得住);缓存里也只放被访问过的数据,不会把全量数据塞进去。

失败窗口有三个。第一,删除缓存失败:数据库已经是新值,缓存还是旧值,直到 TTL 过期;要靠 outbox/重试/CDC 补删。第二,读回填 race:读者在写者提交前读到旧值,写者删除缓存后读者又把旧值写回;8.4 会画这条时间线。第三,miss 风暴:热点 key 过期时,所有并发请求一起查库;8.5 用 singleflight 解决。

8.3.2 read-through:把回填逻辑收进缓存层

read-through 把「未命中时读数据库并回填」交给缓存库或缓存服务,应用只和缓存打交道。好处是回填逻辑统一,不会每个服务各写一套;坏处是缓存从「可选加速」变成了「读路径的硬依赖」:缓存不可用时,应用可能没有直连数据库的 fallback,或者所有请求同时 fallback,把数据库打挂。

read-through 通常还要配一个写策略:写入数据库后失效缓存(本质仍是 cache-aside 的写路径),或者 write-through。它不自动解决一致性;只是把「谁来加载」这件事集中了。选它之前先问:缓存挂了,读路径还能不能活?loader 有没有并发合并和超时?

8.3.3 write-through:写返回时,两边都该是新的

write-through 的写请求要等缓存和数据库都成功才返回。实现有两种:应用写缓存,缓存同步写数据库;或者应用在同一段代码里写数据库和缓存,都成功才返回。它的目标是「写完之后,缓存里也是新值」,适合写后立刻读的场景。

失败窗口在「两边」之间:缓存写成功、数据库失败,缓存里就是脏值,必须回滚缓存或失效;数据库成功、缓存失败,写请求返回失败但数据已经落库,客户端重试可能重复副作用。写延迟也会叠加:原来只等数据库,现在要等两个系统。写量大的时候,缓存会被频繁写穿,反而不如「写库 + 删缓存」。

8.3.4 write-behind:先答应,再慢慢写

write-behind(write-back)只写缓存就返回,后台异步把变更刷到数据库。它最快,也最危险:缓存节点挂掉,未刷盘的写就丢了;同一个 key 的多次写可能乱序;刷盘失败要重试,重试可能重复。它适合指标、计数、可以丢的遥测数据,不适合余额、库存、订单状态。

如果非要用,至少要有持久化的写队列、按 key 的顺序保证、幂等刷盘、积压监控和降级开关。「缓存是临时的」这句话只在 cache-aside/read-through 里成立;到了 write-behind,缓存就是主存储的一部分,它的持久性决定了数据的持久性。

8.3.5 四种模式的失败注入

把「缓存挂了 / DB 挂了 / 写一半」分别注入,观察四种模式:

注入cache-asideread-throughwrite-throughwrite-behind
缓存全挂读退化到 DB,可能压垮 DB读路径直接失败读写都失败写仍成功,但数据在缓存里丢
DB 全挂miss 失败;命中仍可读loader 失败;命中仍可读写失败;缓存可能已更新写成功,积压到 DB 恢复
写缓存失败写库成功,旧值留到 TTL取决于写路径写请求失败,DB 可能已提交写请求失败
进程崩溃无未持久化数据无未持久化数据无未持久化数据未刷盘写丢失

这张表说明:「缓存挂了怎么办」不是一句话,要按模式分别回答。cache-aside 要限流 DB;read-through 要有 fallback;write-through 要处理部分成功;write-behind 要接受丢数据或做持久化队列。

定义 8.3.1 · 四种缓存模式

cache-aside:应用负责读缓存、回源、回填;写数据库后失效缓存。read-through:应用只读缓存,缓存层负责未命中时回源和回填。write-through:写请求同步更新缓存与数据库,两边都成功才返回。write-behind:写请求只更新缓存,异步刷数据库。前两者的缓存是「副本」,write-behind 的缓存是「未持久化的主数据」。

缓存是物化视图,不是数据库

把缓存当成数据库的物化视图,会自然接受三件事:它可能旧(TTL/失效窗口)、可能缺(未命中/驱逐)、可能被重建(重启/扩容)。这三件事都不是 bug,除非业务要求「读到的必须是最新」。真正要设计的是:旧多久可以接受、缺了从哪里补、重建时源站扛不扛得住。

8.4 缓存一致性:TTL、失效、双写、延迟双删

缓存一致性不是「缓存和数据库强一致」。大多数系统做不到,也不需要;需要的是两句话:业务能容忍多旧的读,以及旧值最长能活多久。前者是产品决策,后者是工程实现。把这两句话写下来,比争论「要不要强一致」有用得多。

定义 8.4.1 · 失效窗口

失效窗口(invalidation window)是从「源站提交新值」到「所有可能返回旧值的缓存层都不再返回旧值」之间的时间。它由 TTL、删除操作的传播与重试、读者回填 race、CDN purge 的 fan-out 共同决定。对外该承诺的是「最大陈旧时间」,而不是「缓存一致」。

8.4.1 TTL:兜底,不是一致性

TTL 是最简单的失效机制:到点就过期,下一次读回源。它有三个容易被忽略的细节。第一,TTL 只是「最长能活多久」的上界,前提是缓存真的执行过期;Redis 的过期是惰性删除加后台抽样,逻辑上过期后 GET 不会返回它,但内存回收可能滞后。第二,缓存可能因为内存压力被提前驱逐,TTL 没到也会没;evicted_keys 会告诉你这件事。第三,所有 key 用同一个 TTL,会在同一时刻一起过期,把「缓存问题」变成「源站问题」。

TTL 应该按数据类别设,并且加抖动:价格 30–60 秒,商品详情 5–10 分钟,图片元数据 1 小时,静态资源按内容哈希走一年。抖动不是装饰,是把「同时过期」打散成「均匀回源」。但 TTL 不能替代失效:写完之后等 TTL 过期,等于把陈旧时间拉满。

8.4.2 失效:删缓存还是更新缓存

写路径的常见选择是「更新缓存」或「删除缓存」。优先删除,原因有两个:删除是幂等的,重复删不会错;更新需要你算出「新值」,而并发写时你算出的新值可能基于旧读,写进去反而制造脏值。删除之后,下一次读会自然回源,拿到的是数据库当前值。

删除的时机必须是「数据库提交之后」。如果先删缓存再写库,读者会在删完、库还没提交的窗口里读到旧值并回填,而写者提交后不会再删一次,旧值就留到 TTL。「先写库、后删缓存」不是强一致,只是把窗口缩到「读者回填 race」这一段;它仍然需要重试和兜底。

删除失败怎么办?不能让数据库回滚——缓存删除失败不是数据库写的失败。正确做法是把「失效」记到 outbox/消息队列,由 relay 重试;或者用 CDC 订阅 binlog,在提交后异步删。删除失败要告警,不能只依赖 TTL。

8.4.3 双写:两个系统,没有原子提交

「双写」指应用同时写数据库和缓存。它的问题不是顺序,而是没有原子提交:两个系统,任何一个都可能失败,失败之后你不知道另一个是否生效。先写缓存后写库,缓存可能领先;先写库后写缓存,缓存可能落后。重试其中一边,又可能把旧值写回去。

如果必须双写,至少要做到:写数据库是唯一的事实来源;缓存写失败不影响数据库事务;失败进重试队列;每次写带版本号,缓存只接受更大的版本。更好的做法是根本不做双写:写库 + 失效,或者写库 + outbox/CDC 驱动缓存更新。

8.4.4 延迟双删与版本号

「先写库、后删缓存」之后还剩一个 race:读者在写者提交前 miss,读到旧值;写者提交并删除缓存;读者把旧值回填。时间线如下:

读者 R                                  写者 W
  │ cache miss                            │
  │ SELECT → v1                           │
  │                                       │ UPDATE → v2(提交)
  │                                       │ DEL cache
  │ SET cache = v1                        │   ← 旧值回填,TTL 300s
  ▼                                       ▼
        读者之后的所有请求都读到 v1,直到 TTL 或下一次删除

「延迟双删」是对这个 race 的补丁:写库、删缓存之后,等 Δ(比如 500 ms–1 s)再删一次,把「读者在这段时间里回填的旧值」清掉。Δ 要覆盖「读数据库 + 写缓存」的 p99.9,而不是平均值;第二删要异步做,不能阻塞写请求;删失败要重试。它的局限也很清楚:如果读者 pause 了 2 秒(GC、调度),在第二删之后才回填,旧值仍然会留到 TTL。延迟双删是缩小窗口,不是消除窗口。

更稳的做法是版本化。缓存里存 (value, version),回填时用 Lua 做「只有版本更大才写」的 CAS:

def update_price(sku: str, cents: int) -> None:
    with db.tx() as tx:                       # 本地事务:价格 + outbox 一起提交
        row = tx.query_one(
            "UPDATE prices SET cents=%s, version=version+1 "
            "WHERE sku=%s RETURNING version", cents, sku)
        if row is None:
            raise NotFound(sku)
        tx.execute(
            "INSERT INTO outbox (topic, payload) VALUES (%s, %s)",
            "cache.invalidate",
            json.dumps({"key": f"price:{sku}", "version": row["version"]}),
        )
    # 提交后由 relay 删除缓存;删除失败会重试,数据库不回滚。
    # 读者回填时带 version,缓存只接受更大的 version(Lua CAS)。

这段代码的关键不是 outbox 的写法,而是「版本」贯穿了数据库、消息和缓存。写者提交 v2,读者即使回填 v1,CAS 也会拒绝。版本可以从数据库行号、单调序列、内容哈希来;对静态资源,内容哈希直接进 URL,根本不需要失效。

方案旧值窗口失败时代价
只靠 TTL0–TTL无法感知写,窗口拉满最简单,最旧
写库 + 删缓存读者回填 race,通常毫秒–秒删除失败 → TTL常用,需重试/告警
延迟双删缩小到 Δ 之后的极端停顿第二删失败 → TTL定时任务、额外删除
版本/CAS理论上不覆盖新值版本源出错则退化改造成本、Lua/原子操作
CDC 失效传播 lag消费失败 → 重试依赖 binlog、运维复杂

8.4.5 用 Lua 做缓存 CAS

版本化回填需要「比较版本 + 写入 + 设置 TTL」原子完成,Redis 里用一段 Lua:

-- KEYS[1] = cache key
-- ARGV[1] = version, ARGV[2] = value, ARGV[3] = ttl seconds
local cur = redis.call('HGET', KEYS[1], 'version')
if (not cur) or tonumber(cur) < tonumber(ARGV[1]) then
  redis.call('HSET', KEYS[1], 'version', ARGV[1], 'value', ARGV[2])
  redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
  return 1
end
return 0

调用方把 version 从数据库行或消息里带过来;返回 0 表示「缓存里已经是更新的版本」,这次回填被丢弃。要点:Lua 脚本里不要用 KEYS 之外的 key(集群模式要同 slot);TTL 要设,否则版本 key 永不过期;tonumber 失败要按「版本不可比」处理,不能默认覆盖。

操作原子性适用
DEL + 回源删除本身原子,回填不是常用;接受 race
SET 覆盖单命令原子,但会覆盖新值不适合并发写
Lua CAS比较+写+TTL 原子版本化回填
版本化 key写新 key,旧 key 自然过期静态资源/内容哈希
先删缓存再写库,为什么更糟

DEL 之后、数据库提交之前,读者 miss 并读到旧值,回填;写者提交后没有第二次删除。旧值会一直留到 TTL。这个顺序把「写者提交后删」这个唯一的机会也弄丢了,所以通常比「先写库、后删缓存」更差。

8.5 击穿、穿透、雪崩与热点 key

这四个词经常被混着用,但触发条件不同,药也不同。先把它们分开:

名称触发现象主要药残余风险
击穿单个热点 key 过期并发 miss 全部打到源站singleflight、逻辑过期、SWR单飞 leader 失败会放大给等待者
穿透查询不存在的 key缓存不命中,源站也没有空值缓存、Bloom filter、参数校验空值占内存、Bloom 误判
雪崩大量 key 同时过期/缓存集群挂源站 QPS 阶跃上升TTL 抖动、多级缓存、高可用、限流抖动后仍可能重叠
热点单 key QPS 超过单分片单分片 CPU/网络打满本地缓存、key 复制、拆分、合并内存成本、复制的一致性

8.5.1 击穿:一个热点 key 过期

一个 10 万 QPS 的热点 key,TTL 到期的瞬间,所有并发请求同时 miss。它们都去查数据库,数据库连接池只有 100 个连接,请求排队,超时,客户端重试,队列更长。击穿的本质不是「缓存没命中」,而是「未命中的请求没有合并」。

单飞(singleflight)把同一个 key 的并发回源合并成一次:第一个请求当 leader 去加载,其余请求等它的结果。它是进程内的,50 个 pod 最多 50 次回源,通常可以接受;如果源站连 50 次都扛不住,就需要跨进程的锁、逻辑过期或 CDN 层的 request collapsing。单飞也有代价:leader 慢,所有等待者都慢;leader 失败,错误会传播给所有等待者;等待超时后,调用方可能重试,反而增加负载。

8.5.2 穿透:查一个不存在的东西

穿透是「查一个不存在的 key」:缓存没有,数据库也没有,于是每次请求都穿透到数据库。它可能是用户输错了 ID,也可能是攻击者在扫描。空值缓存是最简单的药:数据库返回空时,缓存一个短 TTL 的 sentinel(20–60 秒),后续请求直接命中空值。TTL 要短,否则对象后来被创建了,用户还会看到「不存在」。

Bloom filter 可以在访问数据库之前挡掉大部分不存在的 key,但有假阳性(不存在的 key 被放过去,仍要查一次源站)和内存成本;它适合 key 空间大、查询随机的场景。参数校验(ID 格式、长度、字符集)和按用户/IP 限流是更便宜的第一道防线。注意:空值缓存和 Bloom 都不能替代「源站返回空」的正确处理,它们只是减少无效查询。

8.5.3 雪崩:同一时刻一起过期

雪崩是「大量 key 在同一时刻失效」:批量导入时统一设 TTL、缓存集群重启、某个分片挂掉、或者一次 purge 清了全站。源站 QPS 从「缓存扛住 95%」变成「全部打过来」,然后超时、重试、更糟。

TTL 抖动是最便宜的药:ttl = base + random(0, base * 0.2),把过期时间打散到 20% 的窗口。多级缓存(进程内 L1 + Redis L2 + CDN)能在 Redis 挂掉时挡住一部分;但 L1 的 TTL 要更短,否则一致性更差。缓存集群要高可用,重启要预热;源站要有并发上限和熔断,不能指望「缓存永远在」。最后,不要在事故中 flush 缓存:那会把「陈旧」变成「雪崩」。

8.5.4 热点:一个 key 超过一个分片

热点的 QPS 可能高到一个 Redis 分片扛不住,或者一个 key 的网络流量超过单机网卡。解决办法有四种:进程内 L1 缓存(最近最少使用,TTL 很短,比如 1–5 秒);key 复制(hot:{id}:0..N,读随机、写全部,用一点一致性换吞吐);按字段拆分(把大 value 拆成多个 key,读需要的部分);请求合并(单飞/队列)。监控上,redis-cli --hotkeys(需要 LFU 策略)、OBJECT FREQ、SLOWLOG 能找到热点;如果只有主从,考虑读写分离和本地缓存。

import json, random, threading

class SingleFlight:
    """同一进程内,把同一个 key 的并发回源合并成一次。"""
    def __init__(self) -> None:
        self._mu = threading.Lock()
        self._inflight: dict[str, dict] = {}

    def do(self, key: str, fn, wait_s: float = 2.0):
        with self._mu:
            call = self._inflight.get(key)
            if call is None:
                call = {"done": threading.Event(), "value": None, "error": None}
                self._inflight[key] = call
                leader = True
            else:
                leader = False
        if leader:
            try:
                call["value"] = fn()
            except BaseException as exc:
                call["error"] = exc
            finally:
                call["done"].set()
                with self._mu:
                    self._inflight.pop(key, None)
        elif not call["done"].wait(timeout=wait_s):
            raise TimeoutError(f"singleflight wait timeout: {key}")
        if call["error"] is not None:
            raise call["error"]
        return call["value"]

sf = SingleFlight()
MISSING = b'{"missing":true}'

def get_price(sku: str) -> dict:
    key = f"price:{sku}"
    raw = redis.get(key)
    if raw == MISSING:
        raise NotFound(sku)
    if raw is not None:
        metrics.inc("cache_hit")
        return json.loads(raw)
    metrics.inc("cache_miss")

    def load() -> dict:
        # 双重检查:等锁期间可能已被别的请求回填
        again = redis.get(key)
        if again == MISSING:
            raise NotFound(sku)
        if again is not None:
            return json.loads(again)
        row = db.query_one("SELECT cents, version FROM prices WHERE sku=%s", sku)
        if row is None:
            redis.set(key, MISSING, ex=random.randint(20, 40))
            raise NotFound(sku)
        ttl = 300 + random.randint(0, 60)       # 抖动,打散过期
        redis.set(key, json.dumps(row).encode(), ex=ttl)
        return row

    return sf.do(key, load)

这段代码有三个要点。第一,单飞只包住「回源 + 回填」,缓存命中不走单飞;第二,leader 在加载前做双重检查,避免等在锁上的请求重复查库;第三,wait_s 必须小于调用方的 deadline,否则等待者先超时、再重试,单飞反而变成放大器。跨进程时,单飞管不到别的 pod;可以用 Redis 锁、逻辑过期或 CDN 的 request collapsing,但要接受锁的 TTL 和失效问题(第 9 章)。

8.5.5 逻辑过期:永远命中,后台刷新

逻辑过期不在 Redis 上设 TTL,而是在 value 里放 expire_at;读到时如果过了 expire_at,先返回旧值,再异步刷新。这样热点 key 永远不会「同时 miss」,代价是刷新失败时旧值会一直用下去,必须有硬上界和告警。

import json, threading, time

def get_price_logical(sku: str) -> dict:
    key = f"price:{sku}"
    raw = redis.get(key)
    if raw is None:
        return refresh_sync(sku)          # 冷启动,走单飞
    item = json.loads(raw)
    if time.time() < item["expire_at"]:
        return item["value"]
    # 过期:先返回旧值,最多一个请求去刷新
    if redis.set(f"lock:{key}", "1", nx=True, ex=10):
        threading.Thread(target=refresh_sync, args=(sku,), daemon=True).start()
    return item["value"]

refresh_sync 就是上一节 cache-aside 的加载函数,这里省略实现。对比三种方案:

方案过期瞬间陈旧上界适用
TTL + singleflight等待 leader 回源leader 的加载时间普通热点
逻辑过期立即返回旧值刷新间隔 + 硬上界极热点、可容忍旧
CDN SWR边缘返回 staleSWR 窗口HTTP 静态/半静态
Redis 锁跨进程串行回源锁 TTL源站极敏感
定义 8.5.1 · 击穿、穿透、雪崩、热点

击穿:单个热点 key 过期,瞬间并发 miss。穿透:查询不存在的 key,缓存和源站都没有。雪崩:大量 key 同时失效,或缓存层整体不可用,源站被瞬时打满。热点:单个 key 的请求量超过一个缓存分片/节点的承载能力。四者的共同点是「miss 的分布」不同:一个点、一片不存在的点、同一时刻的一片、一个过热的点。

一句话

缓存把「读放大」从源站挪到了内存;击穿、穿透、雪崩、热点,就是这笔债在四种不同形状下同时到期。

8.6 CDN:anycast、边缘缓存、cache key、purge

CDN 不是「更快的 S3」,而是一层有很多副本的 HTTP 缓存。每个 PoP 有自己的缓存、自己的 TTL、自己的 cache key;一次内容更新要 fan-out 到所有副本,而浏览器那一层根本不接受你的 purge。理解 CDN,关键是理解「同一个 URL 在不同 PoP 上可能是不同版本」。

8.6.1 anycast:同一个 IP,很多个缓存

anycast 是把同一个 IP 前缀从很多 PoP 同时宣告出去,BGP 把用户路由到「网络意义上最近」的那个 PoP。好处是延迟低、DDoS 流量分散;对缓存的含义是:缓存是 per-PoP 的,不是全局的。同一个 URL 在法兰克福和新加坡可能是两次独立回源、两个不同的 Age;purge 必须到达所有 PoP,传播需要时间。路由变化还可能把一条连接换到另一个 PoP,造成一次 miss 或连接重置。anycast 不保证「同一个用户永远命中同一个 PoP」。

8.6.2 cache key:命中率的第一因

cache key 决定「哪些请求算同一个对象」。默认通常是 scheme + host + path + query;很多 CDN 还会把 Accept-Encoding、Accept-Language 或配置的 header 算进去。key 太粗,会把不同内容混在一起(比如把中文页面缓存给英文用户);key 太细,会让命中率归零(每个用户一个 key)。

维度是否进 key说明
path是资源身份
query是,但要规范化排序、去 tracking 参数、只保留业务参数
Accept-Encoding是gzip/br 的字节不同,通常由 CDN 自动处理
Accept-Language看源站只有服务端按语言返回不同 HTML 时才需要
Cookie通常否一旦进 key,命中率会被会话打碎;登录态走 private/no-store
Authorization否共享缓存不应缓存带授权的响应,除非明确 public
User-Agent/设备看源站自适应图片用独立 URL 或 Vary,不要把所有 UA 都塞进 key
随机/tracking 参数否utm_*、fbclid 要在边缘或源站剥掉

Vary 是标准里的「把请求头加进 key」的机制,但它很容易把基数放大:Vary: User-Agent 几乎等于「每个浏览器版本一个副本」,Vary: Cookie 几乎等于「每个会话一个副本」。能用 URL 区分的 variant,尽量用 URL;必须用 header 的,限制取值集合。还要小心 cache poisoning:如果源站会根据某个未进 key 的 header 改变响应,攻击者可以污染缓存;边缘要剥离或校验这类 header。

8.6.3 TTL、SWR 与 stale-if-error

HTTP/2 200
cache-control: public, max-age=60, s-maxage=600, stale-while-revalidate=30, stale-if-error=86400
age: 420
x-cache: HIT

这段响应头说明:浏览器可以存 60 秒;共享缓存(CDN)可以存 600 秒;过期后还有 30 秒可以「先返回旧的、同时后台刷新」;源站 5xx 时最多可以继续用旧值 1 天。Age: 420 表示这份响应已经在缓存里放了 420 秒,仍在 600 秒的 freshness 内,所以 X-Cache: HIT 是正常的。

指令作用常见误解
max-age浏览器/私有缓存的 freshness以为它也管 CDN
s-maxage共享缓存(CDN)的 freshness,覆盖 max-age以为 CDN 一定看 max-age
public / private是否允许共享缓存存储以为 private 只是「不共享给别的用户」
no-cache可以存,但每次复用前要 revalidate当成 no-store
no-store不存储以为 CDN 一定遵守(有些会配置覆盖)
must-revalidate过期后必须验证,不能直接给 stale和 SWR 混用,语义冲突
immutable有效期内不 revalidate(即使刷新)用在会变的 URL 上
stale-while-revalidate过期后 N 秒内先给 stale,同时刷新以为它会保证刷新成功
stale-if-error源站 5xx/超时时给 stale用来掩盖源站故障,旧值可能很旧

stale-while-revalidate 是 CDN 上最划算的指令之一:它把「过期瞬间的并发回源」变成「一个请求刷新、其他人拿旧值」,既降尾延迟又降源站峰值,代价是「最多旧 N 秒」。stale-if-error 则把可用性放在新鲜度前面;它能扛住源站短暂 5xx,但也会让一个已经发布错了的内容多活很久,所以要配合监控和手动 purge。

8.6.4 purge:失效是 fan-out,不是一次 DELETE

purge 是命令式的失效:你告诉 CDN「这个 URL/tag 不要了」,它去各个 PoP 标记/删除。它和 TTL 是两种机制:TTL 是声明式的「最多活多久」,purge 是「现在就别用了」。purge 的传播是异步的,通常秒级到分钟级;有速率限制;有些 CDN 支持按 tag/surrogate key 批量 purge,有些只支持单 URL 或前缀;wildcard purge 很危险,可能把不该清的也清了。

                    ┌──────────────────┐
   用户 ──anycast──▶│ 边缘 PoP          │──HIT──▶ 返回(Age: 420)
                    │ cache key        │
                    └────────┬─────────┘
                             │ MISS
                             ▼
                    ┌──────────────────┐
                    │ Shield / 区域缓存 │──HIT──▶ 回填边缘
                    └────────┬─────────┘
                             │ MISS
                             ▼
                    ┌──────────────────┐
                    │ 源站 / 对象存储    │
                    └──────────────────┘

   发布新版本:
     上传对象 ──▶ 更新指针/版本号 ──▶ purge URL 或 tag
                                          │
                          ┌───────────────┼───────────────┐
                          ▼               ▼               ▼
                        PoP A           PoP B           PoP C   (异步,秒级)
     浏览器缓存:purge 管不到 ──▶ 静态资源用内容哈希 URL,HTML 用短 TTL

这张图有两个关键点。第一,purge 只清 CDN,不清浏览器;静态资源要靠「内容哈希 URL + immutable」让浏览器自然换新,HTML 要靠短 TTL 或版本化。第二,purge 之后如果源站还没更新完,回源会把旧值又填回来;所以顺序必须是「先提交源站,再 purge」,并且 purge 要能重试/验证。

purge 范围传播风险
单 URL秒–分钟URL 多时请求量大、易漏
tag/surrogate key秒–分钟需要源站/边缘打 tag,覆盖要准
前缀/wildcard秒–分钟范围过大,可能清掉无关内容
全站分钟回源风暴,最后手段
浏览器无法 purge只能版本化 URL 或短 TTL

8.6.5 条件请求与 304:revalidate 不等于重新下载

CDN 的缓存过期后,不一定要重新下载整个对象。它可以用 If-None-Match/If-Modified-Since 向源站 revalidate;源站返回 304 Not Modified,CDN 更新头部、重置 Age,body 继续用本地的。这样省带宽,但不省一次 RTT;对 latency 敏感的路径,stale-while-revalidate 比 304 更好,因为用户不用等这次 RTT。

GET /static/app.js HTTP/1.1
Host: cdn.example.com
If-None-Match: "9f2c1d7e"

HTTP/1.1 304 Not Modified
ETag: "9f2c1d7e"
Cache-Control: public, max-age=600

注意:304 也会更新 Age 的基准,所以「Age 很小」不代表「刚回源拿了 body」;它可能只是刚 revalidate 过。排查「内容没更新」时,要同时看 ETag 和 body 的 checksum,不能只看 Age。

URL 规范化示例:

原始:
  /p?id=42&utm_source=wx&utm_campaign=spring&b=2&a=1
规范化后(排序、去 tracking、只留业务参数):
  /p?a=1&b=2&id=42

不要做的:
  /p?id=42&ts=1712345678      ← 每个请求一个 key,命中率 0
  /p?id=42&user=u-1024        ← 每个用户一个副本,既浪费又可能串数据
定义 8.6.1 · cache key、purge 与 SWR

cache key 是缓存用来判断「两个请求是否同一个对象」的字段组合;它决定命中率,也决定会不会把 A 的内容给 B。purge 是命令式失效,把指定 URL/tag 在边缘标记为不可用,传播是异步 fan-out。stale-while-revalidate 允许在 freshness 过期后的 N 秒内先返回旧值,同时由一个请求去源站刷新;它用「有界陈旧」换「回源峰值和尾延迟」。

CDN 不是一致性层

对象存储的强一致不会自动 purge CDN;数据库的提交也不会让边缘的 Age 归零。CDN 有自己的 TTL、自己的 key、自己的传播延迟。要判断用户看到什么,必须把「源站版本、CDN 缓存、浏览器缓存」三层分开看。

8.7 失败模式:缓存陈旧与回源超时风暴

缓存陈旧和回源风暴经常一起出现,但不是同一个故障。陈旧是「写完了,读还是旧」;回源风暴是「读太多了,源站扛不住」。它们会互相喂养:回源失败,缓存就填不上,陈旧时间变长;用户看到旧值又刷新、重试,回源更多,源站更慢。

先看一条完整的时间线。某价格服务有 10 万个 SKU,凌晨批量导入时把价格写进 Redis,TTL 统一 3600 秒,没有抖动;源站平时 600 QPS,数据库连接池 200,p99 40 ms。

09:00  批量导入 100k SKU,warmup 写入价格缓存,TTL=3600,无抖动
10:00  这批 key 在同一分钟内过期
10:00  源站 QPS 600 → 4,000;连接池 200 打满;p99 40ms → 3s
10:01  客户端 1s 超时,重试 2 次;回源 QPS ×3
10:02  回填因源站超时失败;key 仍未缓存;更多请求穿透
10:04  源站 503;CDN 没有 stale-if-error,502 透传
10:07  限流 + 预热 + 扩容;QPS 回落
10:12  部分 key 回填了只读副本的旧快照,陈旧继续

算一下第一波。100,000 个 key 集中在 60 秒内过期,平均每秒 1,667 次 miss,加上基线 600,源站要接 2,267 QPS;客户端重试 3 倍后是 6,800 QPS,而源站容量大约 1,500 QPS,过载 4.5 倍。如果 TTL 抖动 ±10%(54–66 分钟),同一批 key 摊到 12 分钟,平均只多 139 QPS,源站几乎无感。抖动不是优化,是把「阶跃」变成「斜坡」;阶跃会把排队系统推进非线性区,斜坡不会。

8.7.1 陈旧链:写成功了,读还是旧

陈旧链通常从一次「看起来成功」的写开始:

  1. 数据库提交新值;
  2. 删除缓存失败,或者只删了 CDN、没删服务缓存;
  3. 读者在删除前 miss、读到旧值,删除后把旧值回填;
  4. 旧值留到 TTL,或者留到下一次有人手动删;
  5. 用户刷新、客服清缓存,可能触发一次全站 purge,把陈旧问题升级成回源风暴。

每一步单看都合理:写数据库成功了;删除失败被 catch 住、记了日志;读者只是按 cache-aside 的逻辑回填;TTL 还没到。合起来就是「数据库里是新价格,用户看到旧价格」。这类问题的排查难点是「每一层都显示成功」:CDN 显示 HIT(它确实有内容),Redis 显示有 key(它确实没到期),源站显示 200(它确实返回了当前版本)。

8.7.2 回源链:miss 自己会放大

回源链是一个正反馈:

  1. 大量 key 同时失效(统一 TTL、缓存重启、分片故障、全站 purge);
  2. 并发 miss 打到源站;
  3. 源站排队,p99 上升;
  4. 客户端超时,重试;
  5. 回填请求也超时,缓存仍然没有值;
  6. 回到第 2 步,而且这一轮请求更多。

这条链的可怕之处是:缓存失效本来是一次性的,但回填失败让它变成持续性的。只要源站还在超时,缓存就填不上;缓存填不上,源站就不会恢复。止损的关键不是「再扩容一点」,而是打断反馈:限流回源、让部分请求拿 stale、合并 miss、给源站留出恢复的余量。

症状先查常见根因止血
用户看到旧值CDN 的 Age/X-Cache、Redis key、数据库 version删除失败、只清一层、回填 race、CDC lagpurge + 删 key + 版本号
回源 QPS 高回源监控、miss ratio、--hotkeysTTL 同步、缓存重启、热点、穿透限流、SWR、单飞、空值缓存
源站 5xx连接池、p99、慢查询、下游过载、慢查询、依赖故障降级、stale-if-error、扩容
命中率骤降evicted_keys、key 版本、发布记录内存驱逐、key 变更、分片迁移扩容、回滚 key、预热
412 增多条件写冲突率、热点 key并发写同一对象退避、合并写、分片

8.7.3 止损顺序:先止血,再找根因

事故中最重要的不是「找到根因」,而是「别让根因继续放大」。建议的顺序是:

  1. 止血:给回源加并发上限/排队上限;打开 stale-while-revalidate/stale-if-error;扩容只读副本;暂停非关键写和批量任务。
  2. 定位:是哪个 key/前缀、哪一层、从什么时候开始;把 deploy、purge、TTL 过期、缓存重启的时间线对齐。
  3. 修复:TTL 抖动、singleflight、空值缓存、预热、CDC 失效、版本号;一次只改一个变量。
  4. 复盘:把「最大陈旧时间」写成 SLI,把「回源 QPS/源站容量」写成告警,压测一次「缓存全挂」的场景。

有一条反直觉的规则:事故中不要 flush 缓存。flush 会把「部分 key 陈旧」变成「所有 key 同时 miss」,直接把回源链推到第 1 步。正确的动作是「精确删掉出问题的 key/前缀」+「让其他 key 继续命中」,而不是「清空重来」。

8.7.4 一张事故复盘表

缓存事故的复盘不要只写「Redis 挂了」。按层填表,才能找到「为什么一层的问题会传到用户」:

时间层证据当时应该做什么
10:00缓存expired_keys 尖峰、TTL 分布集中提前加抖动/预热
10:01源站回源 QPS 4,000、连接池满限流回源、开 SWR
10:02客户端超时率上升、重试 ×3重试预算、退避
10:04CDNX-Cache: MISS、502 透传stale-if-error、降级页
10:12数据回填值来自只读副本读主库/版本校验

这张表的每一行都对应一个可加的护栏;只写「加强监控」等于没复盘。

例 8.7.1 · 只清了一层

运营改了价格,CDN purge 成功,服务 Redis 没删。用户请求命中 CDN 回源,源站返回 Redis 里的旧价格;CDN 把这个旧价格当成新内容缓存 10 分钟。运营看到 CDN 控制台显示「已刷新」,以为问题解决了;实际上 Redis 的旧值又被复制到了边缘。修复不是再 purge 一次,而是先删 Redis,再 purge CDN,并确认源站返回的 version 已经变化。

定义 8.7.1 · 回源风暴

回源风暴(origin storm / thundering herd)是缓存 miss 率上升导致源站请求量超过其容量,延迟上升引发超时与重试,重试进一步放大请求量的正反馈。触发条件通常不是「流量涨了」,而是「miss 的形状变了」:大量 key 同时过期、缓存层重启、热点 key 失效、穿透攻击、或一次全站 purge。

一句话

缓存把「读放大」从源站挪到了内存;回源风暴就是这笔债在某一刻集中到期,而重试是利息。

8.8 观测:Age、X-Cache、命中率、S3 request id

缓存问题最难的地方是「你看不到缓存」:数据库有慢查询日志,缓存只有一个「命中/不命中」;CDN 在别人的机房里,你只能看响应头。观测要分三层做:边缘(CDN 响应头)、服务缓存(Redis stats)、源站(对象存储响应头/数据库)。先看一条最常用的命令。

curl -sSI https://cdn.example.com/static/app.9f2c1d7e.js
HTTP/2 200
date: Mon, 19 Oct 2026 02:15:31 GMT
cache-control: public, max-age=31536000, immutable
age: 8421
x-cache: HIT
x-cache-hits: 37
content-length: 84213

怎么读:

  • Age: 8421 表示这份响应在缓存里已经放了 8,421 秒(约 2 小时 20 分)。max-age=31536000 是一年,所以它仍然 fresh,X-Cache: HIT 正常。
  • X-Cache 不是标准头,不同厂商字段不同:Cloudflare 用 cf-cache-status,Fastly 用 X-Cache/X-Cache-Hits,Akamai 用 X-Cache: TCP_HIT 之类。没有 X-Cache 就看 server-timing、Via 或厂商自己的头。
  • 如果 Age > max-age 但仍是 HIT,说明缓存正在给 stale:可能开了 stale-while-revalidate/stale-if-error,也可能是缓存没遵守 TTL。看 Warning: 110(部分实现会加)、x-cache: STALE、以及源站是否 5xx。
  • Age: 0 + HIT 不矛盾:可能刚刚回源并写入,也可能是 revalidate 后重置了 Age;判断 miss 要看 X-Cache,不要只看 Age。
  • 连续两次 MISS 就要查 cache key:Cookie/Vary/Authorization 是否进了 key,响应是否 no-store/private,边缘是否在 purge 循环。

第二层是源站对象。对象存储的响应头能告诉你「这是哪个版本、哪个请求」:

curl -sSI https://media-example.s3.us-east-1.amazonaws.com/avatars/u-1024.png
HTTP/1.1 200 OK
ETag: "9f2c1d7e5b1a4c3d8e6f0a2b4c6d8e0f"
Last-Modified: Mon, 19 Oct 2026 01:40:02 GMT
x-amz-version-id: 3HL4kqtJlcpXroDTDmJrmspXd3dIbs1
x-amz-request-id: 7Q3F9Y2K4M8P1R5T
x-amz-server-side-encryption: AES256
Content-Length: 184320

怎么读:

  • x-amz-request-id 要打进日志。对象存储的支持工单基本都问这个 ID;没有它,只能靠时间范围猜。
  • x-amz-version-id 存在,说明桶开了版本化;排查「删了还在」时,先看当前版本是不是 delete marker。没有这个头,说明版本化没开。
  • ETag 是不透明令牌,不要拿它和本地 MD5 比较;多段上传、SSE-KMS 都会让它不等于内容 MD5。
  • Last-Modified 只有秒级精度,不能当 CAS 的版本;两个写在同一秒完成时,它可能一样。
  • 条件写返回 412 时,不要只看「失败率」;412 是并发冲突的正常信号,突增才说明热点 key 或重试策略有问题。

第三层是服务缓存。Redis 的 INFO stats 给的是累计值,适合看趋势和比例:

redis-cli INFO stats | grep -E 'keyspace_(hits|misses)|expired_keys|evicted_keys'
keyspace_hits:8421337
keyspace_misses:441902
expired_keys:120334
evicted_keys:0

怎么读:

  • 命中率 = hits / (hits + misses) = 8,421,337 / 8,863,239 ≈ 95.0%。但命中率本身不是目标:回源 QPS = 总 QPS × (1 - 命中率)。总 QPS 40,000、命中率 95% 时,源站要接 2,000 QPS;命中率掉到 92%,源站就要接 3,200 QPS,可能直接超过容量。
  • evicted_keys > 0 说明内存压力已经在驱逐 key;命中率会跟着掉,TTL 还没到也会没。先扩容/调 maxmemory,再谈优化。
  • expired_keys 的增速突然上升,通常对应「大量 key 同时过期」;把它和 TTL 分布、发布记录对齐。
  • keyspace_misses 包含「查不存在的 key」,不全是穿透;要配合应用侧的空值缓存命中计数、INFO commandstats 里的 cmdstat_get 一起看。
  • 单 key 诊断用 redis-cli --hotkeys(需要 LFU 策略)、OBJECT FREQ key、SLOWLOG GET 10、LATENCY DOCTOR;大 key 用 --bigkeys。

延迟和内存也要看两条:

redis-cli --latency -h cache.internal
redis-cli MEMORY USAGE price:sku-1024

--latency 的平均/最大延迟突然上升,可能是慢命令、网络或 fork;MEMORY USAGE 帮你判断大 key 是否值得拆分。

指标正常异常动作
Age小于 max-age大于 max-age 且 HIT查 SWR/SIE、purge 是否生效
X-Cache稳态以 HIT 为主持续 MISS / STALE查 cache key、Cookie/Vary、源站
回源 QPS≤ 源站容量 60%> 80% 或阶跃限流、预热、单飞、扩容
命中率按「回源 QPS」算,不设万能值1 个百分点换 5% 源站容量查驱逐、key 变更、热点
evicted_keys0 或偶发持续增长扩容、调 maxmemory、降 TTL
expired_keys 增速平滑尖峰TTL 抖动、预热、SWR
S3 5xx偶发突增退避、request id、跨区
412 比例低且平稳突增热点写、合并写、退避

正常/异常的分界不该拍脑袋。回源 QPS 用「源站容量的 60% 绿、80% 黄、100% 红」;命中率没有通用目标,只有「当前命中率下源站还剩多少 headroom」;Age > max-age 只有在配置了 SWR/SIE 且未超出窗口时才算正常。把这些写成告警表达式,比在事故中争论「95% 是不是正常」有用。

一次缓存故障的观测顺序
  1. curl -I 看边缘:X-Cache、Age、Cache-Control,判断是 HIT/MISS/STALE。
  2. 绕过 CDN 打源站(改 Host 或直连源站),对比两边的内容和版本。
  3. redis-cli INFO stats 看命中率、驱逐、过期;--hotkeys/SLOWLOG 找热点和慢命令。
  4. HEAD 对象存储:ETag、version-id、request-id,确认源站版本和删除语义。
  5. 对齐时间线:deploy、purge、TTL 过期、缓存重启,看哪个动作和故障起点重合。

8.8.1 把缓存命中写进结构化日志

指标告诉你「命中率掉了」,日志告诉你「哪个 key、哪个 PoP、哪一层」。每次回源打一条结构化日志,字段至少包括:

字段例子用途
cache_keyprice:sku-1024定位热点/失效范围
cache_layercdn / redis / local判断哪层 miss
cache_statusHIT / MISS / STALE命中类型
age_s421边缘已缓存多久
origin_status200 / 503回源结果
origin_ms182回源延迟
popfra边缘位置
request_id7Q3F9Y2K...串起源站日志
{"ts":"2026-10-19T02:15:31Z","cache_key":"price:sku-1024","cache_layer":"redis","cache_status":"MISS","origin_status":200,"origin_ms":182,"pop":"fra","request_id":"7Q3F9Y2K4M8P1R5T"}

采样要按「miss + 慢请求」加权,不能只采 1% 的随机请求:正常命中占 95%,随机采样里几乎全是 HIT,看不到问题。对回源失败、Age 超限、412 突增的请求,应该 100% 记录。

8.9 真实案例:2019-07-02 Cloudflare 边缘故障

2019 年 7 月 2 日,Cloudflare 的边缘网络发生了一次全球性故障,持续约 27 分钟。它不是因为缓存被击穿,也不是因为源站挂了;起因是一条 WAF 规则里的正则表达式,在边缘的 HTTP/HTTPS 处理进程里发生灾难性回溯,把所有处理 HTTP/HTTPS 的 CPU 核打满。对用户来说,现象是访问任何 Cloudflare 域名都返回 502——和「缓存全部 miss、源站不可达」看起来一样,但根因在边缘的计算层。

官方复盘给出的时间线大致如下(时间为复盘中的时间):

时间事件
13:31工程师合并了一条 XSS 检测规则的 PR
13:37CI 构建并跑完测试,通过
13:42规则通过 Quicksilver 全球下发,进入 simulate 模式
13:45第一条 PagerDuty 告警;随后全球流量下跌、502 增多
14:00定位到 WAF;排除攻击
14:07执行 global terminate,全球禁用 WAF
14:09流量和 CPU 恢复
14:52完成 negative/positive 验证后,WAF 重新启用

这条规则的核心正则可以化简成 .*.*=.*。在 PCRE 这类回溯引擎里,输入越长、匹配失败的位置越多,回溯次数会超线性增长;边缘每秒要处理大量请求,一条请求多花几毫秒,乘以并发就把 CPU 吃满。更关键的是,这条规则是以 simulate 模式下发的:它不拦截请求,但仍然要执行匹配。「只观察、不生效」不等于「没有成本」。

失败链可以画成这样:

规则变更(XSS 检测)
        │
        ▼
Quicksilver KV 秒级全球下发(180+ 城市)
        │
        ▼
每个 PoP 的 Lua WAF 用 PCRE 执行正则
        │
        ▼
.*.*=.* 灾难性回溯 → 单请求 CPU 时间暴涨
        │
        ▼
所有 HTTP/HTTPS 处理核 ~100% → 处理进程不可达
        │
        ▼
前端还有 CPU 的机器返回 502
        │
        ▼
Access/Jira/构建系统也不可达 → global terminate 难以执行

复盘里最值得 caching/CDN 工程师带走的,不是「正则写错了」,而是四个系统性条件同时成立:

  1. 全球配置面 = 全球爆炸半径。WAF 规则为了快速响应威胁,绕过了正常软件的 DOG/PIG/Canary 流程,直接全球下发;Quicksilver 让变更在秒级到达所有 PoP。速度是特性,也是风险。
  2. 功能测试不等于资源上界。CI 验证了「该拦的拦住、不该拦的放过」,但没有测「这条规则在最坏输入上花多少 CPU」。正则要有输入长度上限、匹配超时/预算,或者换线性时间的引擎(RE2/Thompson NFA)。
  3. simulate 也会执行。规则即使不拦截,也要跑匹配;「灰度/观察模式」不能假设零成本。
  4. 失效开关不能依赖被失效的系统。Cloudflare 用自家的 Access 做内部鉴权,Access 也在这条链路上;工程师一度无法登录控制台,只能走不常用的 bypass。kill switch 要独立、要演练。

还有一条工程教训:恢复不是「把开关拨回去」就完了。Cloudflare 在重新启用 WAF 前做了 negative test(确认问题确实是这条规则)和 positive test(确认回滚有效),并先在一个城市、去掉付费客户流量做验证。回滚也要有验证步骤,否则可能第二次把系统打挂。

把这些条件翻译成护栏,就是下面这张表:

条件当时的护栏可以补的护栏
全球秒级下发无 canary,WAF 规则按设计全球直推配置也走 canary/分批,设爆炸半径上限
正则 CPU 无界CI 只测功能输入长度上限、匹配预算、RE2 类引擎
simulate 仍执行以为观察模式无成本观察模式也做 CPU 压测/限流
kill switch 依赖 Access用自家 Access 鉴权独立 out-of-band 通道、定期演练
回滚未验证直接 re-enablenegative/positive test、分批恢复
事实边界

本节只写 Cloudflare 官方复盘公开的时间线、组件和结论,不推断未公开的客户影响数字。原文与完整正则分析见 Details of the Cloudflare outage on July 2, 2019。

8.10 重试导致重复副作用:上传重试与重复扣费

上传看起来是「幂等」的:同一个 key PUT 两次,内容一样,结果也一样。但只要上传完成会触发业务副作用,重试就不再幂等。更隐蔽的是:CompleteMultipartUpload 成功但响应丢失,重试会得到 NoSuchUpload;客户端把这个 404 当失败,重新走一遍上传和完成回调,于是对象存储里多一个对象、账单上多一笔扣费。

先看一个具体的算例。视频平台按分钟计费,客户端分片上传 200 MB 的视频,完成后调用 POST /v1/uploads/{id}/complete,服务端校验对象、触发转码、写一条计费记录。客户端超时 30 秒;服务端在第 31 秒完成。客户端重试 complete,服务端又记了一次「完成」,计费两次。

客户端                    上传 API                  对象存储                计费
  │  PUT part 1..N         │                         │                     │
  ├───────────────────────▶│                         │                     │
  │                        ├─ Complete ─────────────▶│                     │
  │  200(31s,响应丢了)    │◀────────────────────────┤                     │
  │ 30s 超时,重试          │                         │                     │
  ├─ complete U1 ─────────▶│ Complete 再试 ──────────▶│ NoSuchUpload (404)  │
  │                        ├─ HEAD object ──────────▶│ 200 + ETag/size     │
  │                        ├─ 判定已成功 ───────────────────────────────────▶│ charge key=upload:U1
  │  200 replayed          │◀───────────────────────────────────────────────┤

这里有两层幂等,必须分开看。

第一层是对象层。 同一个 key、同样的字节,PUT 重试是覆盖:关闭版本化时只留一个对象;开启版本化时每次 PUT 会产生一个新版本,存储和事件都会增加。更常见的是 key 里带了随机后缀:客户端每次重试生成新的 uploads/{uuid}.mp4,于是留下两个对象、两条 ObjectCreated 事件。对象层的「幂等」只在「key 稳定」的前提下成立。

第二层是副作用层。 上传完成会触发计费、转码、发通知、写数据库。这些动作是至少一次的:对象存储的事件通知可能重复,客户端重试 complete 也会重复调用。对象存储保证不了「这次上传只计费一次」,它只知道对象写没写成功。要把「对象已就绪」和「业务副作用已发生」分成两个状态,各自有幂等键。

CompleteMultipartUpload 的 404 是一个典型的陷阱。它返回 NoSuchUpload 有两种可能:第一次 complete 其实成功了,UploadId 已失效;或者 UploadId 从来不存在/已过期。两者都返回 404,不能靠错误码区分。正确做法是:先 HEAD 目标 key,比较 size、checksum、content-type;如果和这次上传的预期一致,就当作成功,继续走幂等 complete;只有确认对象不存在或不匹配,才重新 initiate。「重试返回 404」不等于「第一次失败」。

设计上,至少要有四样东西:

  1. 客户端生成的 upload_id(ULID/UUID),所有重试复用它;对象 key 由它决定,例如 uploads/{upload_id}/source.mp4,不要每次重试换 key。
  2. 一张 uploads 表,upload_id 是主键,状态机是 initiated → uploaded → completed → charged,completed 只能从 uploaded 迁移一次。
  3. 条件更新,用 WHERE status='uploaded' 保证只有一个请求能把状态推到 completed;0 行时读当前状态,completed 就返回原结果。
  4. 计费幂等键,用 upload:{upload_id} 调用账单;账单侧保存 key → charge_id,重放返回原 charge,不重复扣。

状态迁移可以写成一条 SQL:

UPDATE uploads
   SET status = 'completed', completed_at = now()
 WHERE upload_id = '01J8Z3K5'
   AND status = 'uploaded'
RETURNING object_key, etag, size_bytes;

如果返回 1 行,说明这次请求赢得了状态迁移,可以继续触发计费/转码;如果返回 0 行,再查一次当前状态:completed 就返回原结果(replayed=true),initiated/uploading 返回 409(还没传完),不存在返回 404。计费调用放在数据库提交之后,带 Idempotency-Key: upload:01J8Z3K5;即使服务在「扣费成功、写 charge_id 之前」崩溃,重试也会命中账单的幂等记录,拿到同一个 charge。

POST /v1/uploads/01J8Z3K5/complete HTTP/1.1
Host: api.example.com
Idempotency-Key: upload:01J8Z3K5

HTTP/1.1 201 Created
Idempotent-Replayed: false

HTTP/1.1 200 OK
Idempotent-Replayed: true

Idempotent-Replayed 是示例接口的约定(Stripe 风格);关键不是这个头名,而是「重放要返回原结果,并且让调用方知道这是重放」。如果重放返回 500 或「已存在」,客户端会以为失败,继续重试,反而放大问题。

把上面的状态机写成代码,注意「崩溃在扣费之后、写 charge_id 之前」这个窗口:

def complete_upload(upload_id: str) -> dict:
    row = db.query_one(
        "SELECT status, object_key, size_bytes, charge_id "
        "FROM uploads WHERE upload_id=%s", upload_id)
    if row is None:
        raise NotFound(upload_id)

    if row["status"] == "uploaded":
        won = db.query_one(
            "UPDATE uploads SET status='completed', completed_at=now() "
            "WHERE upload_id=%s AND status='uploaded' "
            "RETURNING status, object_key, size_bytes", upload_id)
        if won is not None:
            row = won
        else:
            # 输掉 CAS:重读当前状态,completed 就走重放/对账
            row = db.query_one(
                "SELECT status, object_key, size_bytes, charge_id "
                "FROM uploads WHERE upload_id=%s", upload_id)
    if row["status"] not in ("completed", "charged"):
        raise Conflict(f"upload is {row['status']}")

    if row.get("charge_id"):
        return {"object_key": row["object_key"], "replayed": True}

    head = storage.head(row["object_key"])
    if head is None or head.size != row["size_bytes"]:
        raise Conflict("object missing or size mismatch")

    charge = billing.charge(
        amount_cents=head.size * PRICE_PER_BYTE,
        idempotency_key=f"upload:{upload_id}",
    )
    db.execute(
        "UPDATE uploads SET status='charged', charge_id=%s "
        "WHERE upload_id=%s AND charge_id IS NULL",
        charge.id, upload_id)
    return {"object_key": row["object_key"], "replayed": False}

三个检查点各挡一类重复:状态迁移挡住「两个 complete 同时到」;HEAD 挡住「complete 404 但其实没成功」;账单幂等键挡住「扣费成功、写 charge_id 前崩溃」。如果进程在最后一条 SQL 之前崩溃,重试会看到 completed 且 charge_id 为空,带着同一个 upload:{upload_id} 再调一次账单,拿到同一笔 charge。幂等键要覆盖「重试的整个生命周期」,不能只覆盖 HTTP 请求。

把这条链路上的失败点列出来,就能看出每个检查点挡的是哪一类:

失败点客户端看到什么服务端实际状态正确处理
PUT 分片超时连接错误分片可能已写入用同一 part number 重传
Complete 超时30s timeout可能已完成,UploadId 失效HEAD 对账,不重建
Complete 404NoSuchUpload可能已完成,也可能从未存在HEAD + checksum 判断
扣费超时账单接口 timeoutcharge 可能已创建同一 Idempotency-Key 重试
写 charge_id 前崩溃无响应已扣费,charge_id 为空重试同一账单幂等键

算一笔账:每天 100 万次上传,0.3% 超时重试,就是 3,000 次重试;其中 10% 因为换 key 或重复 complete 造成重复计费,每天 300 笔;每笔 5 元,一天 1,500 元。金额不大,但退款、客服、对账的成本远高于 1,500 元;如果计费单位是「每分钟转码」或「每 GB 存储」,重复会持续累积。重复副作用不是一次性的错账,而是一条会自己长大的尾巴。

定义 8.10.1 · 对象幂等与副作用幂等

对象幂等:同一个 key、同样的字节重复 PUT,最终对象内容相同;开启版本化时,版本数会增加。副作用幂等:同一个逻辑上传(同一个 upload_id)重复触发完成回调,计费/转码/通知只生效一次。前者由对象存储的覆盖语义提供,后者必须由 upload_id + 状态机 + 计费幂等键自己保证。

版本化会把「覆盖」变成「新增版本」

如果桶开了版本化,同 key 重试 PUT 不会产生两个「当前对象」,但会产生两个版本;按版本计费、按事件触发的系统仍然会看到两次变更。排查「为什么多了一个对象」时,先确认是「key 不同」还是「版本不同」:前者是 key 设计问题,后者是版本化 + 事件去重问题。

8.11 缓存与 CDN 设计清单

把前面的内容压缩成一张表。设计评审时逐项过,比事后查事故便宜。

#检查项达标线不达标会怎样
1数据分级每类数据写明「可容忍最大陈旧时间」用「缓存一致」这种无法验收的词
2事实来源缓存可丢、可重建,DB/对象存储是唯一事实把 write-behind 缓存当账本
3key 设计稳定、可版本化、不含用户 id/随机数命中率归零或串用户
4TTLbase + 抖动,有硬上界同一时刻集体过期
5失效提交后删除,outbox/CDC 重试,失败告警删失败后靠 TTL 兜底
6回填 race版本/CAS,或延迟双删 + 接受残余窗口旧值回填后留满 TTL
7穿透空值缓存 + 参数校验 + 限流不存在的 ID 打穿源站
8击穿singleflight / 逻辑过期 / SWR热点 key 过期即雪崩
9雪崩多级缓存、HA、预热、源站限流缓存重启 = 全站故障
10热点L1 缓存 / key 复制 / 拆分 / 合并单分片被打满
11CDN key规范化 query,Vary 最小化,剥 tracking命中率低或缓存污染
12purgeURL/tag 可重试,浏览器靠版本化 URLpurge 了 CDN,用户还是旧
13观测Age/X-Cache、回源 QPS、evicted、request id出事只能猜
14上传upload_id + 条件 complete + 计费幂等键重试导致重复对象/扣费

不同数据类的策略不应该一样:

数据例子可容忍陈旧策略
静态资源JS/CSS/字体无限(URL 变则内容变)内容哈希 URL + immutable + 一年 TTL
图片/视频商品图、头像分钟–小时版本化 key + purge;不要覆盖同 key
价格/库存SKU 价格、库存数秒级短 TTL + 版本号;写后读走源站
权限/会话角色、token 黑名单秒级或不可旧不做共享缓存,或短 TTL + 主动撤销
HTML 壳首页、详情页秒–分钟s-maxage 30–60 + SWR
搜索结果列表页分钟TTL 1–5 分钟 + 抖动

这份清单里最容易被跳过的是第 1 项。团队往往直接讨论「用 Redis 还是本地缓存」,但没人回答「旧 5 分钟能不能接受」。没有这个答案,TTL 只能拍脑袋,事故后也无法判断「这次陈旧是不是 bug」。先把「最大陈旧时间」写成一句可验收的话,再选技术。

发布前的五个问题
  1. 这类数据最多能旧多久?谁批准这个数字?
  2. 写完到所有缓存失效,最长路径是什么?失败会重试吗?
  3. 缓存全挂时,源站能接住多少 QPS?降级是什么?
  4. CDN 的 cache key 包含哪些维度?哪些请求会永远 MISS?
  5. 出问题时,用哪三条命令区分「源站旧」和「缓存旧」?

8.12 本章陷阱清单

13 条高频错误
  1. 把 ETag 当 MD5:多段上传、SSE-KMS 的 ETag 不是内容 MD5;校验完整性要用 checksum。
  2. 以为 S3 强一致 = 跨 key 事务:对象 + 数据库的双写仍然没有原子性,需要状态机/outbox/对账。
  3. 两个 writer 都 200 就以为没丢更新:强一致只保证读者看到后完成者;丢失更新要靠 If-Match 或版本号。
  4. 先删缓存再写库:读者会在删完、库未提交的窗口回填旧值,而且写者提交后不会再删。
  5. 把双写当原子:数据库和缓存没有共同提交点;失败一边后重试另一边可能写回旧值。
  6. 把 TTL 当一致性方案:TTL 只是陈旧上界,且缓存可能被提前驱逐;写完等 TTL 等于把窗口拉满。
  7. no-cache 当 no-store:no-cache 可以存,只是每次复用前要 revalidate;no-store 才是别存。
  8. purge 只清 CDN:浏览器和服务本地缓存不受 CDN purge 影响;静态资源要靠版本化 URL。
  9. 所有 key 同一个 TTL:同一时刻集体过期,把缓存问题变成源站问题;TTL 要加抖动。
  10. 不缓存空值:不存在的 ID 每次都穿透到数据库;空值缓存 TTL 要短,创建后要能失效。
  11. singleflight 当全局锁:它只在一个进程内合并;50 个 pod 仍有 50 次回源,且 leader 失败会放大给等待者。
  12. 命中率当目标:真正要管的是「回源 QPS = 总 QPS × (1 - 命中率)」和源站 headroom。
  13. 上传重试换新 key / complete 404 当失败:换 key 会留下两个对象;complete 404 要先 HEAD 对账,再决定是否重建。

8.13 本章出口检查

检查项达标线没达标去哪
写出条件 PUT能写 If-None-Match: * 与 If-Match,并解释 412 的含义8.1
解释 ETag能说出为什么它不是内容 MD5、什么时候会变8.1
说出 S3 强一致的边界至少 3 个仍存在的坑:跨 key、并发覆盖、CRR、delete marker、控制面8.2
画出四种缓存模式能标出写路径和失败窗口,不把 write-behind 当普通缓存8.3
画出失效 race能画出「读者回填旧值」的时序,并说出两种修复8.4
区分四种 miss击穿/穿透/雪崩/热点各给一个药和一个残余风险8.5
写 singleflight能写出 per-key 合并、双重检查、等待超时8.5
设计 CDN cache key能说出哪些进 key、哪些不进,Vary 的风险8.6
读 curl -I能从 Age/X-Cache 判断 HIT/MISS/STALE,并知道下一步查什么8.8
复述 Cloudflare 案例能说出正则回溯、全球下发、simulate、kill switch 四个条件8.9
设计上传幂等能说出对象层与副作用层的区别,complete 404 怎么对账8.10
过一遍设计清单14 项里至少能答出 12 项8.11
即时自测 · 8

Q1 总 QPS 40,000,缓存命中率 96%,源站容量 2,000 QPS。现在源站接多少?命中率掉到 94% 呢?如果 20 万个 key 在 60 秒内同时过期,额外多少 QPS?你会先做哪三件事?

Q2 读者 miss 后读到 v1;写者提交 v2 并删除缓存;读者把 v1 写回,TTL 300 秒。旧值会留多久?延迟双删能完全解决吗?版本 CAS 为什么更稳?

Q3 curl -I 返回 Age: 7200、Cache-Control: max-age=600、X-Cache: HIT。这正常吗?你会怎么确认?

Q4 客户端 complete 超时后重试,收到 NoSuchUpload(404)。它应该重建上传吗?怎么判断第一次是否已经成功?

Q5 两个 writer 都带 If-Match: "v1" 写同一个 key,一个 200、一个 412。412 的一方应该怎么做?为什么不能直接换一个新 ETag 重试?

Q6 Cloudflare 那条 WAF 规则在 simulate 模式下为什么还能打挂边缘?如果让你加一条护栏,你加什么?

答案

A1 源站现在是 40,000 × 4% = 1,600 QPS,已经是容量的 80%;命中率掉到 94% 就是 2,400 QPS,超过容量 20%。20 万 key 在 60 秒内过期,额外约 3,333 QPS,叠加后源站要接约 4,933 QPS,是容量的 2.5 倍。先做三件事:给 TTL 加抖动、打开 stale-while-revalidate、给回源加并发上限/单飞;扩容和预热放在后面。

A2 旧值会留到 TTL 到期(300 秒)或下一次删除,因为回填发生在写者删除之后。延迟双删能清掉「在 Δ 内回填」的旧值,但如果读者 GC 停顿超过 Δ,第二删之后仍可能回填,所以不能完全解决。版本 CAS 更稳:缓存只接受更大的 version,读者回填 v1 会被拒绝,不依赖时间窗口。

A3 不正常:Age 已经 7,200 秒,超过 max-age=600,但 CDN 还在 HIT,说明它在给 stale。先看是否配置了 stale-while-revalidate/stale-if-error 且仍在窗口内;如果不是,查 purge 是否传播、源站是否已更新、缓存是否没遵守 TTL。绕过 CDN 直连源站对比内容,再决定精确 purge,不要直接全站 flush。

A4 不应该立刻重建。先 HEAD 目标 key,比较 size、checksum、content-type 与这次上传是否一致;一致就当作第一次已成功,继续走幂等的 complete/计费;不一致或对象不存在,才重新 initiate。根因是 complete 成功后 UploadId 失效,404 不能区分「已成功」和「从未成功」。

A5 412 表示「你基于的版本已经过期」:当前 ETag 已被别人改成 v2。正确动作是重新读 v2、合并自己的修改、带 If-Match: "v2" 重试,并加退避;如果合并有业务冲突,返回 409 让人处理。直接换一个新 ETag 硬写等于放弃 CAS,会把别人的更新覆盖掉。

A6 simulate 只表示不拦截,不表示不执行;正则仍要匹配,.*.*=.* 的回溯会耗尽 CPU。护栏优先级:给正则加输入长度上限和匹配 CPU 预算/超时,或换线性时间引擎;把配置下发也做 canary/分批;kill switch 走独立通道并定期演练。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 6 章 应用层8.1、8.6背景只借用 HTTP/DNS 的应用层视角;不重写协议细节
张彦飞《深入理解 Linux 网络》第 4–7 章(TCP、socket、连接)8.6、8.8背景anycast/连接只引用,不展开内核路径
《Python 网络编程》HTTP 客户端、并发与缓存8.5、8.10精选借用工程风格;singleflight/幂等代码为本书重写
《Linux 系统与内核》第 4 篇4.7(HTTP、DNS、TLS)8.6交叉引用明确不重复 TCP/HTTP/TLS 基础
本书自编 + S3/HTTP 规范S3 一致性模型、条件写、RFC 9111/58618.1、8.2、8.6完整只引用语义与公开事实,不复制文档长段
本书自编 + Cloudflare 复盘2019-07-02 边缘故障8.9完整只写官方公开时间线与结论,附原文链接

谢希仁和张彦飞都不讲对象存储、缓存失效和 CDN;这一章的自编部分集中在「语义 → 失败窗口 → 观测命令」这条链上。S3 的一致性模型、条件写、HTTP 缓存指令来自官方文档和 RFC,事故时间线来自 Cloudflare 官方复盘;代码和算例是按工程实践重写的,不复制厂商控制台手册,也不把某一家的实现当成行业标准。

下一站:缓存和 CDN 让读变快,但也让「谁有资格写」变得模糊:purge、双写、上传完成回调,最后都要回到「同一时刻只有一个写者」。进入 9 分布式锁、租约与选主,看锁为什么会过期、租约为什么依赖时钟、fencing 如何把旧持有者挡在资源外。

9 分布式锁、租约与选主

本章回答

为什么 SET NX 成功不等于互斥?租约的 TTL 到底在保护谁?进程 GC 停顿 12 秒、锁 TTL 只有 10 秒时,旧持有者还能不能写?Redlock 的争论到底在争什么?ZooKeeper 的临时节点和 etcd 的 lease 能提供什么、不能提供什么?选主和锁是不是一回事?什么时候根本不该用锁?

读完你会

能画出 acquire/renew/use/release/fence 五步协议;能用 Lua 写出不会误删他人锁的解锁与续期;能解释 Redis 主从切换为什么丢锁;能在资源端用单调 token 拒绝过期持有者;能用 etcdctl lease timetolive 和 redis-cli INFO 判断租约健康;能写出一份锁的使用边界与评审清单。

凌晨 02:00,对账任务由 12 个 Pod 竞争执行。代码里只有一行保护:

if redis.set("lock:recon:2026-10-18", owner, nx=True, px=10_000):
    run_reconciliation()

这段代码在测试环境跑了半年,没出过问题。某天夜里,Pod A 拿到锁后触发了一次 12 秒的 Full GC;锁的 TTL 是 10 秒,到期后 Redis 删掉了 key。Pod B 随即拿到锁,开始跑同一批退款对账。A 在 12 秒后恢复,它并不知道自己的租约已经过期,继续把内存里那批退款记录写进账本。B 也在写。第二天财务发现,同一批退款被记了两次。

三方都没有「写错」:

  • Pod A:「我 SET NX 成功了,锁在我手里。」
  • Redis:「TTL 到了,我按约定删 key,这是租约合同的一部分。」
  • Pod B:「我拿到锁了,当然可以执行。」

三个正确的局部行为,拼出了一次重复副作用。 问题不在于谁撒谎,而在于「持有锁」这件事本身有时效,而暂停的进程不会自动知道自己已经过期。

第 1 章把一次调用拆成了逻辑请求、物理尝试和 unknown;第 3 章讲复制时提到了故障切换与 fencing;第 5 章讲 Raft 时区分了「接受写」和「提交写」。这一章要把这些线索收拢到一个问题上:在异步、会暂停、没有全局时钟的环境里,如何让「同一时刻只有一个执行者」这件事尽量成立,并且在它不成立时不让副作用落地?

答案不是一个 key,而是三层:互斥、租约、fencing。本章路线:9.1 建立三层模型;9.2 讲 Redis 锁的加锁、解锁、续期和主从丢锁;9.3 讲 Redlock 与时钟假设;9.4 讲 ZooKeeper/etcd 的临时节点、lease 和 watch;9.5 讲选主与锁的区别;9.6 讲 fencing token 如何在资源端落地。然后进入工程部分:9.7 用 GC 停顿时间线拆失败模式,9.8 用两条命令看租约,9.9 复盘 2016 年的 Redlock 辩论,9.10 拆「锁过期 + 重试」造成的双执行,9.11 收束到使用边界,最后是陷阱清单和出口检查。

9.1 锁的三个层次:互斥、租约、fencing

「分布式锁」最容易被理解成「在 Redis 里放一个 key,谁设上谁赢」。这个理解只覆盖了第一层。真实系统里,锁至少由三个层次组成,每一层解决一个不同的问题,也各自引入新的失败模式。

层次回答的问题典型机制能挡住什么挡不住什么
互斥同一时刻最多一个持有者?SET NX、临时节点、CAS正常并发下的重复进入崩溃后不释放、分区、过期
租约持有者失效后多久释放?TTL + 续期、session timeout崩溃导致的永久占锁进程暂停、时钟跳变、网络隔离
fencing过期持有者还能写吗?单调 token + 资源端最大值任意暂停后的旧写资源端不校验、旁路写

9.1.1 第一层:互斥只保证「正常情况下只有一个」

互斥的定义很朴素:在任意时刻,最多只有一个客户端认为自己持有锁。用 Redis 实现时,SET key token NX PX ttl 把「判断不存在」和「写入」合成一个原子操作;用 ZooKeeper 实现时,多个客户端在同一个父节点下创建临时顺序节点,编号最小的那个获得锁。

这一层只回答「谁先到」。它默认锁服务本身是权威的,且持有者会主动释放。一旦持有者崩溃,key 就永远留在那里,其他客户端永远拿不到锁。于是有了第二层。

9.1.2 第二层:租约把「永久占用」变成「有期限的授权」

租约(lease)是锁服务给出的一份有时间上限的授权:在 [acquire, acquire + TTL] 这段时间内,锁服务承诺不再把锁发给别人;超过这个时间,锁服务可以单方面收回。持有者要在到期前续期(renew),续期成功就顺延,续期失败就必须停止工作。

租约解决了崩溃后的释放问题,但引入了一个更隐蔽的假设:持有者能在 TTL 内完成工作,或者至少能在 TTL 内发现自己已经失去租约。 进程被 GC 停顿、被 cgroup 限流、被虚拟机挂起、被 SIGSTOP 时,这个假设不成立。它既不能完成工作,也不能发现自己已经失去租约。

租约不是失败检测器。失败检测器回答「对方死了吗」,租约回答「到什么时候为止,我还承认你」。前者永远有假阳性,后者永远有时钟假设。

9.1.3 第三层:fencing 把「旧持有者」挡在资源外

fencing 的思路是:锁服务在每次授予锁时,额外发一个单调递增的 token;客户端在写资源时带上这个 token;资源端记录见过的最大 token,拒绝所有更小的 token。

租约过期与 fencing token 客户端 A 在 GC 停顿时租约过期;B 获得 fence=2;A 恢复后带 fence=1 的写被资源拒绝。 租约过期与 fencing token 客户端 A 锁服务 资源端 acquire → lease=30s, fence=1 A 暂停(GC) 租约继续走 租约到期 B acquire → fence=2 接受 fence=2 A 恢复:write(fence=1) 拒绝:1 < 2 锁的正确性不只在锁服务,还在被保护资源是否校验 token。
读图:加长 TTL 不能解决停顿,只能降低概率;真正的解法是资源端记住见过的最大 token,拒绝所有更小的 token。锁服务只负责发号,资源端负责执行。

图里 A 在 GC 停顿时租约过期,B 拿到 fence=2 并写入资源;A 恢复后带着 fence=1 写,被资源端拒绝。注意,锁服务并没有阻止 A 发请求,它只是发了一个号;真正让 A 的写失效的是资源端的比较逻辑。这就是本章最重要的一句话:

锁服务只负责发号,资源端负责执行。

如果资源端不校验 token,或者存在一条绕过校验的写路径(缓存、消息队列、运维脚本、另一个服务直连数据库),fencing 就形同虚设。很多「我们加了锁还是重复」的事故,根因不是锁服务,而是资源端从来没有真正成为唯一的写入口。

定义 9.1.1 · 分布式锁协议

一个可用的分布式锁协议至少包含五步:

  1. acquire:客户端用唯一 token 尝试获取锁,锁服务返回租约期限和 fencing token;
  2. renew:在租约到期前续期;续期失败或本地 deadline 已过,立即停止工作;
  3. use:在临界区内执行副作用,每次写资源都携带 fencing token;
  4. release:用 token 做比较后删除锁,不能无条件 DEL;
  5. fence:资源端在同一个原子操作里比较 token 与已记录的最大值,拒绝旧 token。

五步里任何一步缺失,协议都不完整:没有 renew,崩溃后锁会占死;没有 release 的比较,会误删别人的锁;没有 fence,过期持有者仍能写。

9.1.4 三个时间,不要混着用

锁的讨论里经常出现三个「时间」,它们由不同的人维护,含义也不同:

时间谁维护用来回答典型错误
锁服务的 TTLRedis / etcd / ZooKeeper什么时候可以把锁发给别人用客户端时钟去算服务端过期
客户端的 lease deadline持有者进程的单调时钟我什么时候必须停止工作暂停后继续用旧 deadline
资源的提交顺序数据库 / 存储 / 日志最终哪个写生效没有 token,只按到达顺序覆盖

第一和第二个时间之间没有共享时钟。锁服务按自己的时钟过期,客户端按自己的时钟判断「还剩多少时间」;两者只能靠保守的余量对齐,不能靠「应该差不多」。第三个时间更独立:即使前两个都判断正确,两个写到达资源的顺序也可能与获取锁的顺序不同。

一句话

锁不是一把钥匙,而是一份带过期时间的授权;授权会过期,而暂停的进程不会自动知道。

本章用两个贯穿例子:对账任务 lock:recon:2026-10-18,以及退款单 refund:8842。前者用来讲租约和 GC 停顿,后者用来讲重复副作用和资源端 fencing。

9.2 Redis 锁:SET NX PX、Lua 解锁、续期

Redis 是最常见的「顺手加个锁」的地方,也是误解最多的地方。这一节把 Redis 锁拆成四个动作:加锁、解锁、续期、故障切换。每个动作都给出可执行命令和失败路径。

9.2.1 加锁:一条命令,三个要点

TOKEN=$(python3 -c 'import secrets; print(secrets.token_hex(16))')
redis-cli SET lock:refund:8842 "$TOKEN" NX PX 30000

第一次执行返回:

OK

在锁未过期时再次执行,返回:

(nil)

这条命令有三个要点。

第一,NX 和 PX 必须在同一条命令里。 常见错误是:

redis-cli SETNX lock:refund:8842 "$TOKEN"   # 1
redis-cli EXPIRE lock:refund:8842 30        # 2

如果进程在 1 和 2 之间崩溃,key 就永远没有 TTL,锁永久泄漏。SET ... NX PX 是原子的,不会留下这个窗口。

第二,token 必须是随机且足够长的。 不要用 hostname、pid、user_id 或时间戳当 token:这些值可能重复,重复的 token 会让「比较后删除」失去意义。16 字节随机数的碰撞概率可以忽略。

第三,TTL 是租约,不是「业务超时」。 TTL 应该大于临界区 p99.9 的耗时,并留出续期和网络抖动的余量;但它不可能大到「绝对不会过期」,因为停顿没有上界。把 TTL 设成 5 分钟只是把事故概率降低,不是消除。

例 9.2.1 · 没有 token 的解锁

客户端 A 拿到锁,TTL 30 秒。A 的处理耗时 35 秒,锁在 30 秒时过期;B 拿到锁,开始处理同一张退款单。A 在 35 秒处理完后执行 DEL lock:refund:8842,把 B 的锁删掉了。C 随即拿到锁,三个客户端同时进入临界区。

A 的 DEL 本身没有「恶意」,它只是不知道自己已经不再是持有者。修复不是「让 A 更快」,而是让解锁变成「只有 token 匹配才删除」。

9.2.2 解锁:Lua 比较后删除

GET 再 DEL 不是原子操作:A 在 GET 之后、DEL 之前可能恰好过期,B 拿到锁,A 的 DEL 删掉 B 的锁。正确做法是把比较和删除放进同一个 Lua 脚本:

-- unlock.lua:只有 token 匹配才删除
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
end
return 0

调用:

redis-cli --eval unlock.lua lock:refund:8842 , "$TOKEN"

返回 1 表示删除成功,0 表示 token 不匹配(锁已过期或被别人持有)。Redis 执行 Lua 时不会被其他命令打断,所以「比较 + 删除」是一个原子动作。

这段脚本只保护锁 key,不保护资源。A 的解锁会正确地失败,但 A 仍然可能带着旧身份去写数据库。Lua 解锁解决的是「不要误删别人的锁」,不是「不要让过期持有者写资源」。 后者只能靠 fencing。

9.2.3 续期:在 deadline 之前,用同一个 token 延长

续期同样要比较 token:

-- renew.lua:token 匹配才延长 TTL
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0

一个最小的 Python 租约封装:

import secrets
import time

UNLOCK_LUA = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
end
return 0
"""

RENEW_LUA = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0
"""


class RedisLease:
    def __init__(self, client, key: str, ttl_ms: int = 30_000):
        self.client = client
        self.key = key
        self.ttl_ms = ttl_ms
        self.token = secrets.token_hex(16)
        self.deadline = 0.0

    def acquire(self) -> bool:
        ok = self.client.set(self.key, self.token, nx=True, px=self.ttl_ms)
        if ok:
            self.deadline = time.monotonic() + self.ttl_ms / 1000
        return bool(ok)

    def renew(self) -> bool:
        # 本地 deadline 已过,不再续期;即使 Redis 里 key 还在,也不能信
        if time.monotonic() >= self.deadline:
            return False
        ok = self.client.eval(RENEW_LUA, 1, self.key, self.token, self.ttl_ms)
        if ok:
            self.deadline = time.monotonic() + self.ttl_ms / 1000
        return bool(ok)

    def release(self) -> None:
        self.client.eval(UNLOCK_LUA, 1, self.key, self.token)

三个细节决定了这段代码是否可用:

  1. 续期间隔应小于 TTL 的 1/3,例如 TTL 30 秒、每 10 秒续一次;这样即使一次续期丢包,还有两次机会。
  2. renew() 返回 False 时,业务必须立刻停止写资源,而不是「再试一次」。续期失败可能意味着锁已经过期、被删除、或被别人持有;继续写就是过期写。
  3. 续期线程和业务线程共享同一个进程。Full GC 会同时暂停两者;cgroup 限流也一样。把续期放到独立 sidecar 可以避免「业务暂停导致续期停止」,但 sidecar 无法知道业务是否还活着,可能出现「锁一直续着,业务早已卡死」的另一种故障。
续期不是心跳

心跳的目的是「告诉别人我还活着」,续期的目的是「延长一份有期限的授权」。心跳丢了可以重连,续期失败必须停止工作。把续期写成「失败了就重试到成功」,等于把租约当成永久锁。

9.2.4 主从切换:锁 key 可能根本没到从库

Redis 主从复制默认是异步的。考虑下面这条时间线:

时间主库从库客户端 A客户端 B
t0SET NX 成功,返回 OK尚未收到认为持锁,开始处理—
t1写入 repl buffer网络延迟/排队处理中—
t2崩溃/被 Sentinel 判定下线被提升为新主暂停或继续—
t3—新主上没有锁 key仍认为持锁SET NX 成功
t4——写资源(无 fence)写资源(持锁)

t4 时刻,A 和 B 都认为自己有资格写。Redis 的 WAIT 命令可以等待指定数量的从库确认:

redis-cli SET lock:refund:8842 "$TOKEN" NX PX 30000
redis-cli WAIT 1 100

WAIT 1 100 返回 1 表示至少一个从库在 100 ms 内确认了写。但「确认收到」不等于「故障切换后一定被提升」:被提升的可能是另一个没有收到这条写的从库;确认也可能只到达了从库的内存,没落盘。WAIT 缩小窗口,不消除窗口。

min-replicas-to-write 和 min-replicas-max-lag 可以在「没有健康从库」时拒绝写,降低丢锁概率;但它们不改变「异步复制 + 提升可能落后」的事实。AOF 的 appendfsync everysec 最多丢 1 秒;always 更慢,也不保护「提升了一个没追上日志的从库」。RDB 快照丢得更多。

结论不是「Redis 不能用」,而是:Redis 锁在故障切换时可能同时存在两个持有者。 如果业务只能容忍「极低概率的重复」,可以接受;如果业务是扣款、退款、库存,必须加 fencing,或者把锁换成有 quorum 语义的系统。

9.2.5 过期、驱逐与重启:Redis 锁的其它敌人

除了主从切换,还有三条容易被忽略的丢锁路径:

路径触发方式后果防护
TTL 到期临界区超过 TTL,续期没赶上另一个客户端拿到锁TTL 留余量 + 续期 + fence
内存驱逐maxmemory-policy allkeys-lru 把锁 key 挤掉锁提前消失锁实例用 noeviction,或独立实例
重启/恢复未持久化,或从旧 RDB 恢复锁 key 消失不把 Redis 当唯一正确性来源

TTL 命令的返回值也值得记住:-2 表示 key 不存在(过期或被删),-1 表示 key 存在但没有过期时间。-1 在生产里通常是一个 bug:说明某段代码用了 SET 而不是 SET ... PX,或者续期时把 TTL 弄丢了。把 TTL == -1 做成告警,比事后排查「为什么锁永远不释放」便宜得多。

9.2.6 Redis 锁适合什么

用途是否适合理由
防止重复劳动(同一个报表只生成一次)适合重复的代价是浪费 CPU,不是数据错误
短临界区、可容忍极低概率重复适合实现简单,延迟低
扣款、退款、库存扣减不适合单独使用主从切换和暂停会制造双持有者,必须 fence
跨多个资源的强一致事务不适合Redis 锁不是事务,资源的提交顺序不受它控制

一个实用的判断:如果「锁失效」的后果可以用「再跑一次」修复,Redis 锁够用;如果后果是「钱多了一笔」或「库存少了一件」,就需要资源端 fencing,或者干脆不用锁。

9.3 Redlock 争议与时钟假设

Redis 作者 antirez 提出过 Redlock:用 N 个独立的 Redis master(通常 N=5,不做复制),客户端依次向它们申请锁;只有在多数派(3 个)上成功,并且总耗时小于 TTL,才认为锁获取成功。

9.3.1 算法:多数派 + 有效期

一个简化的 Python 版本:

import time


def acquire_redlock(clients, key: str, token: str, ttl_ms: int) -> int:
    start = time.monotonic()
    acquired = 0
    for client in clients:
        if client.set(key, token, nx=True, px=ttl_ms):
            acquired += 1

    elapsed_ms = (time.monotonic() - start) * 1000
    drift_ms = ttl_ms * 0.01 + 2          # 允许 1% 时钟漂移 + 2ms
    validity_ms = ttl_ms - elapsed_ms - drift_ms

    if acquired >= len(clients) // 2 + 1 and validity_ms > 0:
        return int(validity_ms)

    # 失败:尽力释放所有已经拿到的锁
    for client in clients:
        client.eval(UNLOCK_LUA, 1, key, token)
    return 0

实际实现要用 try/finally 保证失败时释放已经拿到的锁;释放失败只能等 TTL 过期。

这段代码里有三个时间量:ttl_ms 是每个 Redis 实例上的过期时间;elapsed_ms 是客户端观察到的获取耗时;validity_ms 是客户端认为自己还能安全使用锁的剩余时间。算法要求 validity_ms > 0,否则即使多数派成功也放弃。

Redlock 的设计目标是在没有复制、没有 Sentinel 的情况下,用多个独立实例提高可用性:少数实例故障不影响获取;多数实例故障时无法获取,但也不会错误地发放。它不要求实例之间同步,也不要求它们有共享时钟。

9.3.2 时钟假设:有效期是一个减法

Redlock 的安全性依赖三个假设:

  1. 网络延迟有上界:客户端能在一个已知的时间内完成多数派获取;
  2. 时钟漂移有上界:validity_ms 的减法不会因为时钟跳变而严重失真;
  3. 进程暂停有上界:客户端在拿到锁之后,不会暂停到超过 validity_ms 才继续执行。

前两个假设可以通过工程手段逼近:用单调时钟计算 elapsed_ms,把漂移上限写进公式,部署 NTP。第三个假设不行:GC、虚拟机挂起、cgroup 限流、SIGSTOP 都没有上界。

更麻烦的是,单调时钟也不是万能的。Linux 的 CLOCK_MONOTONIC 在系统挂起(suspend)期间通常不前进;CLOCK_BOOTTIME 才包含挂起时间。Python 的 time.monotonic() 在 Linux 上映射到 CLOCK_MONOTONIC。如果虚拟机被挂起 20 秒,进程恢复后可能认为只过了 1 秒,而锁服务的 TTL 已经按真实时间过期。这种情况下,本地 deadline 检查会给出错误的「还很安全」。

「用单调时钟」不等于「时钟安全」

单调时钟解决的是「NTP 把墙上时钟往回拨」的问题,不解决「进程/虚拟机根本没有运行」的问题。前者是时钟源问题,后者是执行模型问题;后者只能靠 fencing 或外部监督。

9.3.3 Kleppmann 的批评:暂停发生在检查之后

2016 年 2 月 8 日,Martin Kleppmann 发表《How to do distributed locking》,对 Redlock 提出了一组批评。核心不是「Redis 性能不好」,而是「Redlock 把正确性建立在时间假设上」。他的关键论证可以压缩成一条时间线:

时间客户端 A锁服务客户端 B资源
t0获取锁,validity=10s授予——
t1开始执行,尚未写资源———
t2GC 暂停 12 秒租约到期——
t3—锁可被获取获取锁—
t4——写资源接受 B 的写
t5恢复,认为锁还有效——A 也写,重复/覆盖

Kleppmann 的结论是:Redlock 无法防止「客户端在拿到锁之后、写资源之前暂停」;要防住这个,资源端必须能拒绝旧持有者,也就是 fencing token。他还指出,如果只是为了效率(避免重复劳动),单实例 Redis 就够了,不需要 5 个 master;如果为了正确性,Redlock 不够,需要 fencing 或共识系统。

9.3.4 antirez 的回应:假设之内是安全的

antirez 随后发表《Is Redlock safe?》回应。他的论证大致有三点:

  1. Redlock 假设时钟漂移有界,不是任意跳变的墙上时钟;算法里的 drift 项就是为漂移留的余量。只要漂移在有界范围内,有效性判断是可靠的。
  2. 进程暂停是客户端故障,不是锁算法的责任。如果客户端暂停到超过租约,它已经失去了持有资格;一个「暂停后仍继续写」的客户端,任何基于租约的锁都救不了它。要救,就得在资源端做 fencing。
  3. Redlock 的定位是效率,不是强一致。它比单实例 Redis 更可用,但不是什么都能防;需要绝对安全时,应该用 fencing 或共识系统。

这场辩论没有一个「谁对谁错」的判决。两人其实在回答不同的问题:Kleppmann 问「Redlock 能不能作为正确性原语」,antirez 答「在它声明的假设内,它可以安全地发放租约;假设之外要靠 fencing」。如果把辩论读成「Redis 不能做锁」,就错过了真正的工程结论。

场景只用 RedlockRedlock + 资源端 fence
正常并发互斥互斥
客户端崩溃TTL 后释放TTL 后释放
客户端 GC 暂停超过 TTL两个持有者,可能双写旧 token 被拒,安全
时钟跳变/挂起有效期判断可能失真token 仍然单调,安全
多数 Redis 实例故障无法获取(可用性下降)同样无法获取

9.3.5 工程结论:不要用 Redlock 买「正确性」

把争论落到选型上:

需求建议原因
避免重复劳动、短临界区单实例 Redis + TTL + token 解锁简单;重复的代价可控
高可用、能容忍极低概率重复Redlock 或 Redis Cluster + 监控多数派提高可用性,但不改变时间假设
扣款、退款、库存、迁移共识系统(etcd/ZooKeeper)的锁 + 资源端 fence锁服务本身要有 quorum;资源端要有最后一道闸
多资源原子操作把操作放进同一个共识状态机/事务锁只能串行化入口,不能保证多个资源的提交原子性

Redlock 的 5 个 master 买到的是可用性,不是正确性。把「多数派获取成功」当成「绝对不会双写」,是把效率工具当成了正确性证明。

9.4 ZooKeeper/etcd:临时节点、lease、watch

Redis 的锁是一个 key;ZooKeeper 和 etcd 的锁是一套「会话 + 临时节点/lease + watch」的机制。它们提供了更强的顺序保证,但没有消除租约和暂停的问题。

9.4.1 ZooKeeper:临时顺序节点 + 前驱 watch

ZooKeeper 的经典锁配方:

  1. 每个客户端在 /locks/recon 下创建一个临时顺序节点,例如 /locks/recon/lock-0000000001;
  2. 客户端 getChildren("/locks/recon"),如果自己的编号最小,获得锁;
  3. 否则,对前一个节点设置 exists watch,等待它被删除;
  4. 前驱消失后,重新读取子节点列表,确认自己是否最小;
  5. 释放时删除自己的节点;会话过期时,ZooKeeper 自动删除临时节点。
create -e -s /locks/recon/lock- ""
# /locks/recon/lock-0000000001
ls /locks/recon
# [lock-0000000001]
stat -w /locks/recon/lock-0000000000   # 前驱节点;不存在时 watch 创建事件

为什么要 watch 前驱,而不是 watch 父节点?如果所有等待者都 watch 父节点,每次有人释放,所有人都会收到通知并同时 getChildren,形成「羊群效应」(herd effect)。watch 前驱把唤醒范围缩小到一个客户端,释放路径变成一条链。

ZooKeeper 的会话(session)是租约的载体:客户端定期发 ping,服务端在 session timeout 内保持会话;超时后会话过期,临时节点被删除。会话过期是服务端单方面决定的,客户端可能因为 GC 停顿而不知道;恢复后它会收到 SessionExpired,此时必须放弃「我还持锁」的假设。

ZooKeeper 的读不一定是最新

ZooKeeper 的写是线性一致的(经过 leader),但读默认由所连接的 follower 提供,可能落后于其他客户端的最新写。锁配方的安全性不来自「读一定最新」,而来自 zxid 顺序、临时顺序节点的编号,以及「自己的创建已经提交」这一事实。需要强读时,先 sync 再读。

9.4.2 etcd:lease + revision 队列

etcd 的锁配方建立在 lease 上:

export ETCDCTL_API=3

LEASE=$(etcdctl lease grant 30 | awk '{print $2}')
etcdctl put /locks/recon/owner-a "host-a" --lease="$LEASE"
etcdctl lease timetolive "$LEASE" --keys

示例输出:

lease 694d7d8c2c4a4b3d granted with TTL(30s), remaining(21s)
/locks/recon/owner-a

客户端必须持续续约(keepalive);如果续约停止超过 TTL,etcd 会删除这个 lease 下的所有 key。锁的互斥不是靠 SET NX,而是靠 revision 顺序:

  1. 每个竞争者创建自己的 key(带 lease),etcd 返回一个全局递增的 revision;
  2. revision 最小的竞争者获得锁;
  3. 其他人 watch 自己的前一个 revision 对应的 key;
  4. 前驱被删除后,watch 触发,重新检查自己是否最小。

etcd 的 clientv3/concurrency 包把这套逻辑封装成 Mutex:NewSession 创建 lease,NewMutex 创建锁对象,Lock(ctx) 阻塞直到获得锁。它同样用「前驱 watch」避免羊群效应,并且 revision 天然单调,适合作为 fencing token 的来源。

9.4.3 临时节点/lease 的本质:服务端 TTL,不是客户端保险

ZooKeeper 的临时节点和 etcd 的 lease 都做同一件事:把「持有者是否还活着」的判断,变成「服务端在 TTL 内有没有收到续约」。这解决了崩溃后的释放,但也带来两个必须接受的边界:

边界ZooKeeperetcd后果
客户端暂停超过 TTL会话过期,临时节点删除lease 过期,key 删除旧持有者恢复后可能仍以为自己持锁
续约网络分区会话超时keepalive 失败锁被释放,业务被另一个持有者接管
服务端时钟/租约检查依赖 session tracker依赖 lease 过期队列服务端故障时可能延迟释放
客户端收到过期通知SessionExpiredkeepalive 返回失败通知可能晚于旧持有者的下一次写

这些系统能保证「锁的状态变更在服务端是线性一致的」,但不能保证「旧持有者在暂停后不会继续写外部资源」。第 5 章 5.11 引用的 Jepsen etcd 3.4.3 分析就指出:etcd 的 KV 操作在分区、暂停、时钟偏移下仍是严格可串行化的,但 lock API 不保证互斥;等待锁的路径存在 lease 有效性未复查的问题。这不是说 etcd 的实现「很差」,而是说 lease 模型的边界在 API 层也会显现。

9.4.4 watch 不是锁

watch 只是一个「状态变化通知」,不是「授权凭证」。它有三种常见的坑:

  1. ZooKeeper 的 watch 是一次性的:触发后必须重新注册;如果你在回调里做了一次耗时操作,期间的变化不会再有通知。
  2. etcd 的 watch 可能被取消或压缩:客户端太慢、历史 revision 被 compaction、网络断开,都会让 watch 中断;恢复后必须重新 list,不能假设「没收到事件就是没变化」。
  3. watch 事件不等于「轮到我」:收到前驱删除的通知后,必须重新读取当前子节点/revision 顺序,确认自己确实是最小;否则可能在一个已经过期的视图上进入临界区。
例 9.4.1 · watch 丢了,以为锁还在

客户端 B 在 etcd 上排队等锁,watch 自己的前驱。网络抖动导致 watch 流断开,B 的客户端库没有立即重连;前驱在这段时间被删除,B 没有收到事件。B 的应用层认为「还没轮到我」,一直等待,锁却已经空闲。另一种更危险的情况是:B 在断线前收到「前驱删除」的事件,但在重连后没有重新检查 revision,直接进入临界区;此时 C 可能已经用更小的 revision 拿到了锁。

正确做法:watch 断线后重新 get 当前锁前缀,按 revision 重新排序,再决定是否持有;不要把「曾经收到过事件」当成「现在持有锁」。

9.4.5 三者对比

维度RedisZooKeeperetcd
锁原语SET NX PX临时顺序节点lease + revision
一致性主从异步,故障切换可能丢锁写线性一致,读可能陈旧读写可线性一致(quorum)
会话/租约TTL,客户端续期session timeout,服务端心跳lease TTL,keepalive
等待机制轮询/阻塞命令watch 前驱(一次性)watch 前驱(流)
fencing token 来源INCR(故障切换可能回退)zxid / 节点编号revision
运维成本低中中

这张表不能直接推出「etcd 一定比 Redis 好」。etcd 的每次锁操作要走 quorum,延迟和运维成本更高;Redis 更简单,但故障切换时可能丢锁。选型的依据是「锁失效的后果」,不是「哪个系统更先进」。

9.5 选主:bully、Raft、lease

「选主」经常被当成「拿锁」的同义词:谁拿到锁,谁就是 leader。两者有交集,但不是一回事。

维度锁选主
目的保护一段临界区产生一个长期权威
持有时间秒级到分钟级天级到任期级
失败处理TTL 释放,重新竞争重新选举,term 递增
正确性来源锁服务 + 资源端 fencequorum + term/epoch + 资源端 fence
典型误用用锁做 leader 选举以为 leader 可以随便写外部资源

9.5.1 bully:教学算法,不是生产方案

bully 算法的规则很简单:每个节点有唯一 ID;检测到 coordinator 失联后,向所有 ID 更大的节点发 Election;收到 OK 就等待,收不到就自己当 coordinator 并广播 Coordinator。

A(1) 发现 coordinator 失联
  ├─ 向 B(2)、C(3)、D(4) 发 Election
  ├─ B、C 回 OK  → A 等待
  ├─ B、C 继续向更高 ID 发 Election
  ├─ D 没有更高 ID 可问 → 广播 Coordinator(D)
  └─ 所有节点接受 D 为 coordinator

bully 的假设是:消息延迟有上界、故障是 fail-stop、所有节点互相可达。网络分区时,两个分区可能各自选出 coordinator,形成双主。它不是为异步网络设计的,生产系统不用它做正确性保证;理解它的价值在于看清「选主 = 检测 + 竞争 + 宣告」这三步,以及每一步都依赖假设。

9.5.2 Raft:term + 多数派 + 日志限制

Raft 的选举比 bully 多三道保险:

  1. term:每届选举有一个递增的任期号;节点只接受不小于自己 term 的请求;同一 term 最多一个 leader。
  2. 多数派:候选人必须拿到超过半数节点的投票;两个多数派必然相交,所以同一 term 不会选出两个 leader。
  3. 日志限制:候选人必须拥有不落后于任何投票者的日志;这保证已提交的日志不会丢失。

但 Raft 只保证「同一 term 最多一个 leader 能提交日志」,不保证「旧 leader 立刻停止服务」。旧 leader 可能还在监听端口、还在返回读结果、还在接受客户端请求;它只是无法让写提交。第 5 章 5.9 已经拆过这个时间线:旧 leader 的写会超时/unknown,客户端重试到新 leader;如果旧 leader 还在执行外部副作用,就需要 fencing。

Raft 的 lease read 也有时钟假设:leader 在租约内直接读,依赖「租约到期前不会选出新 leader」和「检查租约到返回结果之间没有长暂停」。第 5 章 5.8 给出的条件是:

lease_duration + max_clock_drift < election_timeout
且(检查租约 → 返回结果)之间的进程暂停 < 剩余租约

第二个条件在 GC 停顿面前并不总是成立。所以 lease read 通常要配合 CheckQuorum(联系不到多数派就退位)和保守的租约长度;它不是「免费的线性一致读」。

9.5.3 lease 选主:外部租约 + 旧主自停

很多系统把选主做在外部存储上:候选者向 etcd/ZooKeeper/数据库申请一个带 TTL 的租约,续约成功就是 leader;续约失败就主动降级。典型流程:

1. 候选者 C 写 /leader/c 并带 lease,值为 (term, epoch)
2. 写成功且 revision 最小 → C 成为 leader
3. C 周期续约;续约失败 → C 停止服务,进入 follower
4. 其他候选者 watch /leader,发现租约消失后重新竞选
5. 存储端拒绝 epoch 小于已记录最大值的写

第 3 步的「停止服务」是行为变更,不是状态变更。很多实现只把角色改成 follower,但连接还在、缓存还在、定时任务还在跑;旧 leader 仍然可能写下游。要真正停止,需要:

  • 关闭监听端口/拒绝新请求;
  • 停止定时任务和后台线程;
  • 撤销对下游的凭证;
  • 在存储端用 epoch/fence 拒绝旧写;
  • 必要时用 STONITH/断电做物理隔离。
手段作用点能挡住什么盲区
旧主自停进程大多数旧主继续服务进程暂停时无法执行自停
网络隔离/撤权网络/凭证旧主无法访问下游本地缓存/本地盘仍可写
STONITH/断电物理机/虚拟机彻底停止旧主慢、可能误杀、云环境受限
存储 epoch/fence资源端旧 epoch 的写资源端不校验/旁路写

9.5.4 选主不是锁

把两者混为一谈,会导致两个错误:

  • 用锁做选主:拿 Redis 锁当 leader 选举,锁过期后旧 leader 还在跑定时任务,新 leader 也跑,两个都写下游。锁只能保证「同一时刻一个持有者」,不能保证「旧持有者停止副作用」。
  • 以为 leader 可以随便写:Raft 选出的 leader 对「日志提交」有权威,但对「外部资源」没有天然权威。外部资源只认 token/epoch,不认「我是 leader」这句话。

正确的组合是:选主产生 term/epoch;资源端校验 term/epoch;旧 leader 在失去 quorum 或租约后自停,但自停只是降低概率,fence 才是最后一道闸。

9.6 fencing token:把过期锁挡在资源外

fencing 的核心不是「让旧持有者不发请求」,而是「让旧持有者的请求在资源端无效」。这一节把 token 的来源、资源端的校验、以及常见的实现错误拆开。

9.6.1 token 从哪里来:必须单调

fencing token 必须满足两个条件:

  1. 单调:每次授予锁/提升 leader,token 严格递增;
  2. 全局可比较:同一个资源的所有写路径使用同一套 token 空间。

常见的 token 来源:

来源单调性跨故障切换备注
etcd revision集群内单调是(除非从旧快照恢复)推荐;与锁操作天然绑定
ZooKeeper zxid集群内单调是(除非从旧快照恢复)需要读取节点 stat
Raft term + log index单调是适合 leader epoch
数据库序列单调(可能有空洞)取决于主从切换异步复制可能回退
Redis INCR单实例单调可能回退故障切换后新主可能从旧值开始
时间戳不保证不保证时钟回拨会重复/倒退

时间戳不能当 fence。 两台机器的时钟可能差几秒,NTP 可能回拨;同一毫秒内也可能有两个持有者。Redis 的 INCR 在单实例上是单调的,但主从切换后新主的计数器可能落后于旧主;如果旧主已经发过 fence=100,新主从 fence=90 开始,资源端会拒绝所有新 token(安全但不可用),或者更糟:如果资源端的最大值也丢了,旧 token 可能被接受。

从旧快照恢复也会让 revision/zxid 回退。如果 token 可能回退,资源端的「严格大于」会拒绝合法的新持有者,造成安全性保留、活性丧失。工程上要么保证 token 源不倒退(持久化 + 恢复协议),要么在 token 里加一个 epoch(集群 ID/恢复代数),让比较变成 (epoch, counter) 的字典序。

9.6.2 资源端怎么校验:同一个原子操作

下面是一个最小但正确的实现。表里保存每个账户的 fence(已接受的最大 token):

CREATE TABLE accounts (
  id            TEXT PRIMARY KEY,
  balance_cents INTEGER NOT NULL,
  fence         INTEGER NOT NULL DEFAULT 0
);

扣款时,把「检查 token」和「修改余额」放进同一条 UPDATE:

class StaleToken(Exception):
    pass


def debit(conn, account_id: str, amount_cents: int, token: int) -> None:
    with conn:  # 事务:要么扣款并推进 fence,要么什么都不做
        cur = conn.execute(
            """
            UPDATE accounts
               SET balance_cents = balance_cents - ?,
                   fence = ?
             WHERE id = ?
               AND fence < ?
               AND balance_cents >= ?
            """,
            (amount_cents, token, account_id, token, amount_cents),
        )
        if cur.rowcount != 1:
            # 可能是旧 token,也可能是余额不足;用只读查询区分,仅用于报错
            row = conn.execute(
                "SELECT fence, balance_cents FROM accounts WHERE id = ?",
                (account_id,),
            ).fetchone()
            raise StaleToken(f"rejected token={token}, row={row}")

关键点在 WHERE fence < ? 和 SET fence = ? 同属一条语句。如果写成:

row = conn.execute("SELECT fence FROM accounts WHERE id = ?", (account_id,)).fetchone()
if token > row[0]:
    conn.execute("UPDATE accounts SET balance_cents = balance_cents - ? WHERE id = ?", ...)

就会有一个「检查后、写入前」的窗口:两个请求可能都通过检查,然后都写入。check-then-act 不是 fencing,只是把竞态窗口缩小了一点。

一次测试的输出:

B: debit(account=acct-1, amount=100, token=42) -> ok, fence=42
A: debit(account=acct-1, amount=100, token=41) -> StaleToken: rejected token=41, row=(42, 900)
B: debit(account=acct-1, amount=50,  token=42) -> StaleToken: rejected token=42, row=(42, 900)

注意第三行:同一个持有者用相同 token 重试,fence < 42 不成立,会被拒绝。这是有意的:token 相同不代表请求相同。如果同一个持有者需要重试同一笔扣款,应该用幂等键去重,而不是重复使用 fence。fencing 防的是「旧持有者」,幂等防的是「同一个持有者的重复请求」,两者不能互相替代。

9.6.3 一个安全性小定理

定理 9.6.1 · fencing 的安全性

设资源端对每个资源维护 max_fence,只接受满足 t > max_fence 的写,并在接受后令 max_fence = t;设 token 由单调递增的分配器产生。那么,一旦某个 token 为 t 的写被接受,任何 token t' < t 的写都不可能在此后提交。

证明

接受 t 的写之后,max_fence >= t。此后任何写要提交,必须满足 t' > max_fence >= t,即 t' > t;因此 t' < t 的写不可能通过校验。若两个写并发到达,资源端的比较与更新在同一个原子操作中完成,它们会被串行化:低 token 先提交、高 token 后提交时两者都成功,但顺序正确;高 token 先提交时低 token 被拒。无论哪种,一旦高 token 提交,低 token 不可能在其后提交。归纳可得结论。

∎

这个定理有两个容易被忽略的前提:

  • 资源端必须持久化 max_fence。如果重启后 max_fence 回到 0,旧 token 就能再次通过。max_fence 要和业务数据在同一个事务里落盘。
  • 所有写路径都必须经过这个校验。只要有一条旁路(缓存直写、消息队列消费者、运维 SQL、另一个服务),定理就不适用。

9.6.4 fencing 与幂等:两把不同的锁

机制防的是什么典型实现不防什么
fencing token旧持有者在失去锁之后继续写资源端 max_fence 比较同一逻辑请求的重复(需要幂等键复用结果)
幂等键同一个逻辑请求被多次执行唯一索引 + 结果复用旧持有者用新键写入
唯一约束重复记录UNIQUE(intent_id)不同 intent 的重复副作用
事务部分提交ACID跨资源的重复

一个常见的错误是「加了唯一索引,所以不用 fence」。唯一索引只能挡住「同一个键」的第二次插入;如果旧持有者用了一个新键(例如新生成的退款 ID),唯一索引不会拦它。反过来,严格 > 会把同一个 token 的第二次写拒绝掉,但客户端拿不到第一次的结果;如果重试换了新 token(例如重新拿锁),fence 也挡不住。需要幂等键把同一逻辑请求收敛到一次结果。三者是叠加关系,不是替代关系。

9.6.5 资源端不配合怎么办

fencing 要求资源端能比较 token。现实中有三类资源做不到:

资源类型能否 fence替代方案
自建数据库/存储能在表里加 fence 列,事务内比较
对象存储部分能条件写(ETag/If-Match)+ 元数据;或把 token 写进对象名/版本
第三方支付/邮件 API通常不能用对方提供的幂等键/去重窗口;不能声称「绝对不重复」
消息队列部分能分区内顺序 + 消费者侧去重;跨分区不保证

如果资源端无法 fence,正确的说法不是「我们加了锁所以安全」,而是「我们把重复窗口缩小到了 X,并且接受剩余风险」。这句话必须写进设计文档,而不是藏在代码注释里。

本章的底线

任何「锁 + 资源」的设计,都要能回答一个问题:如果持有者在拿到锁之后暂停了 TTL 那么久,恢复后继续写,资源端会不会拒绝? 如果答案是「不会」,那这把锁只提供了效率,不提供正确性。

9.7 失败模式:GC 停顿导致锁过期与重复执行

9.1 开头的故事不是假设。把一次 Full GC 放进时间线,会看到两个持有者如何在谁都没「违规」的情况下同时存在。

t(s)  客户端 A                          锁服务                        客户端 B                 资源
 0    acquire(TTL=10s, fence=41)  --▶  授予
 0.5  Full GC 开始(STW,整进程暂停)
10    (仍在 GC)                      租约到期,删除 key
10.2  (仍在 GC)                      B acquire(fence=42)  --▶      授予
10.4  (仍在 GC)                                                    B 写(fence=42)  --▶  接受,max=42
12.5  GC 结束,恢复
12.51 若每次写前检查 deadline:已过期 → 停止(正确实现)
12.52 若只在进入临界区前检查过:继续写(fence=41)  -------------------------------▶  拒绝(max=42)

9.7.1 谁在什么时候知道什么

到 t=12.5 为止,五个参与者的认知并不相同:

主体它认为实际情况
A 的业务线程「我拿到过锁,任务还没跑完」租约已过期约 2.5 秒
A 的续期线程被同一个 STW 暂停,无法续期同上
锁服务租约到期,key 已删除正确
B我拿到了锁,可以写正确
资源已接受的最大 token 是 42正确

如果 A 在恢复后每次写资源前都检查本地 deadline,它会发现已经过期并停止;这是一个正确的实现。事故往往发生在下面几种「检查不完整」的写法里:

  1. 只在进入临界区前检查一次。后面的循环、批量 flush、异步回调都不再检查;GC 可以发生在任意两次写之间。
  2. check-then-act。在 t=9.99 检查时还剩 0.01 秒,检查通过;紧接着又发生一次 200 ms 的暂停,写发生在 t=10.19,此时租约已经过期。检查和写不是原子的。
  3. 写路径在另一个线程/协程里。主线程检查了 deadline,工作线程从队列里取出任务继续写;工作线程没有继承这个检查。
  4. 本地时钟没有前进。虚拟机被挂起、CLOCK_MONOTONIC 不包含 suspend 时间,恢复后 A 以为只过了 1 秒;它甚至不会发现租约已经过期。
  5. 续期成功但写超时。续期线程活着,锁还在;业务线程卡在下游调用上,等它返回时早已超过本地 deadline。锁没丢,但业务已经不该继续。

这五种写法里,前三种靠「更仔细地检查」可以缓解;第四种靠本地检查完全无效;第五种说明「锁还在」不等于「这次操作还该做」。fencing 能覆盖前四种:旧 token 会被拒绝。第五种需要业务层的 deadline/取消机制;如果锁仍然有效、没有新持有者,fencing 不会拒绝一个当前 token 的写。

9.7.2 加长 TTL 为什么不是解法

「把 TTL 从 10 秒加到 60 秒」是最常见的反应。它确实能把「12 秒停顿」这类事故的概率降下来,但代价和边界都很清楚:

TTL12 秒停顿60 秒停顿崩溃后锁占用适用性
10 s双持有者双持有者10 s短任务
60 s安全双持有者60 s中等任务
300 s安全安全5 min长任务,但故障恢复慢

TTL 越长,崩溃后锁被占用的时间越长,可用性越差;TTL 越短,暂停导致双持有者的概率越高。这是一个没有最优解的权衡,因为停顿的分布没有上界。 工程上能做的是:把 TTL 设在「临界区 p99.9 + 续期余量」附近,把续期做勤,把本地 deadline 检查做全,然后在资源端加 fence。四件事缺一不可。

例 9.7.1 · 一次「检查过」的过期写

A 的临界区是一个 5000 条的批量循环。代码在循环开始前检查了一次 deadline,然后每写 100 条 flush 一次。GC 在第 3200 条时发生,持续 12 秒;恢复后循环继续,第 3300 条到第 5000 条都写在租约过期之后。A 的日志里甚至有一行 lease_ok=true,因为那是 12 秒前打印的。

修复不是「把检查移到循环里」这么简单:检查仍然可能刚好发生在 flush 之前。正确做法是每次 flush 都带 fence,资源端拒绝旧 token;同时把「本地 deadline 已过」作为停止条件,减少无效写。

9.7.3 停顿不止 GC 一种

停顿来源典型时长本地时钟会前进吗备注
Full GC / STW百毫秒–数十秒会堆越大、对象越多,越难预测
cgroup CPU 限流十毫秒–数秒会容器被 throttle,线程拿不到 CPU
虚拟机挂起/恢复秒–分钟可能不会CLOCK_MONOTONIC 不含 suspend
虚拟机 live migration百毫秒–数秒可能跳变迁移期间 vCPU 暂停
SIGSTOP/调试器任意会进程完全冻结
页错误/swap毫秒–秒会内存压力下的不可预测停顿
网络黑洞秒–分钟会进程在跑,但收不到续期响应

前六种是「进程没有运行」,最后一种是「进程在运行但通信失败」。两者的共同点是:持有者都无法在租约到期时做出反应。区别在于,网络黑洞至少还可能通过超时发现;进程暂停连「发现」都做不到。

9.7.4 三道防线,最后一道不能省

防线作用失效场景
本地 deadline 检查尽早停止,减少无效写暂停、check-then-act、异步线程
续期延长租约,覆盖长任务同一进程暂停、sidecar 与业务脱节
资源端 fence拒绝旧 token 的写资源端不校验、旁路写

前两道是效率:它们让系统在大多数时候不需要走到 fence,减少错误和重试。第三道是正确性:它不依赖进程是否运行、时钟是否准确、网络是否通畅。把三道防线当成「三选一」,是本章最常见的错误。

9.8 观测:lease TTL、锁等待、会话

锁的事故很难从业务日志里直接看出来:A 的日志说「我持锁」,B 的日志也说「我持锁」,两条都「没错」。要定位,必须看锁服务侧的租约状态、等待队列和应用侧的续期/拒绝指标。

9.8.1 etcd:看 lease 还剩多久、谁在排队

export ETCDCTL_API=3

LEASE=$(etcdctl lease grant 30 | awk '{print $2}')
etcdctl put /locks/recon/owner-a "host-a" --lease="$LEASE"
etcdctl lease timetolive "$LEASE" --keys
etcdctl get /locks/recon/ --prefix --keys-only

示例输出:

lease 694d7d8c2c4a4b3d granted with TTL(30s), remaining(21s)
/locks/recon/owner-a

再查锁前缀:

/locks/recon/owner-a
/locks/recon/owner-b

第一段是 timetolive 的输出:remaining(21s) 表示这个 lease 还能活 21 秒;下一行 --keys 列出挂在它下面的 key。第二段显示锁前缀下有两个 key:owner-a 是持有者,owner-b 是等待者。谁先谁后不由字母顺序决定,而由创建时的 revision 决定;可以用 etcdctl get /locks/recon/owner-b -w json 看 mod_revision。

怎么读:

观察正常异常先看什么
remaining在 0 < r <= TTL 内递减,续期后回到接近 TTL一直等于 TTL(续期过频/重置 bug)keepalive 间隔
remaining 到 0 后 key 消失租约正常过期key 还在但 lease 报 not foundetcd 健康/时钟
锁前缀下 key 数1 个持有者 + 少量等待者长时间大于 10临界区是否卡住、是否有人忘了释放
mod_revision单调递增回退是否从旧快照恢复
keepalive 日志周期性成功连续失败/断流网络、etcd leader 切换

remaining 是一个「剩余时间」,不是「已用时间」。看到它从 30 降到 21,说明已经过了 9 秒;看到它突然回到 30,说明刚刚续期成功。如果每次采样都看到 30,要么采样间隔刚好等于续期间隔,要么代码在反复「重设 TTL」而不是续期;后者会让锁永远不过期,崩溃后很难释放。

9.8.2 ZooKeeper:会话与临时节点

zkCli.sh -server zk1:2181
stat /locks/recon/lock-0000000001
# cZxid = 0x1a2b
# ephemeralOwner = 0x2d1a7c9f0e00001
# dataLength = 0

ephemeralOwner 非 0 表示这是临时节点,值就是创建它的会话 ID;为 0 表示持久节点,持有者崩溃后不会自动释放。读法:

观察正常异常先看什么
ephemeralOwner非 0,且与当前客户端会话一致为 0节点创建方式是否漏了 -e
节点是否存在持锁期间存在业务说持锁但节点消失会话是否已被服务端过期
客户端日志没有 SessionExpired出现后仍继续写业务是否处理了会话过期事件
会话超时接近配置的 sessionTimeout频繁重连/超时网络、GC、ZK ensemble 健康

会话过期后临时节点会被删除;如果业务日志仍说「持锁」,说明它没有处理 SessionExpired,必须立刻停止并重新竞选,而不是继续写资源。

9.8.3 Redis:看复制、TTL 和驱逐

redis-cli INFO replication
redis-cli INFO keyspace
redis-cli INFO stats | grep -E 'expired_keys|evicted_keys'
redis-cli INFO commandstats | grep -E 'cmdstat_(eval|del)'
redis-cli TTL lock:refund:8842
redis-cli GET lock:refund:8842
redis-cli --scan --pattern 'lock:*' | wc -l

示例输出:

# Replication
role:master
connected_slaves:1
slave0:ip=10.0.3.12,port=6379,state=online,offset=91827364,lag=0
master_repl_offset:91827364

# Keyspace
db0:keys=18422,expires=37,avg_ttl=91234

# Stats
expired_keys:1204
evicted_keys:0

# Commandstats
cmdstat_eval:calls=18422,usec=91234,usec_per_call=4.95
cmdstat_del:calls=12,usec=88,usec_per_call=7.33

怎么读:

字段正常异常后果
connected_slaves至少 1(如果依赖故障切换)0主库崩溃后锁无处恢复,丢锁概率上升
slave0.lag0–1 秒持续大于 5 秒从库落后,切换后可能没有锁 key
master_link_status(在从库上)updown从库数据陈旧,不能作为切换依据
db0.expires大于 0(有 TTL 的 key)0 但有锁 key锁没有 TTL,检查 TTL == -1
evicted_keys0大于 0锁可能被内存策略驱逐
expired_keys平稳短时激增大量锁在同一时间过期,临界区可能超时
cmdstat_eval有调用(如果用了 Lua)0 但业务有锁解锁可能用了裸 DEL

TTL 的读法要单独强调:

TTL lock:refund:8842
# 整数:剩余秒数,正常
# -1:key 存在但没有过期时间 —— 锁泄漏/续期丢 TTL,告警
# -2:key 不存在 —— 已过期、已释放,或根本没加上

正常的分界不是「TTL 大于 0」这么简单:TTL 应该小于等于配置的 TTL,并且在续期后回升。如果 TTL 大于配置值,说明有人用了更长的过期时间;如果 TTL 一直是 -1,说明锁永远不会自动释放;如果 TTL 是 -2 但业务日志说「持锁」,说明锁已经丢了而业务不知道。

9.8.4 应用侧:五个必须有的指标

锁服务只能告诉你「租约还在不在」,不能告诉你「业务是不是还在用一把已经过期的锁」。应用侧至少要记录:

指标含义异常信号
lock_acquire_total{result}获取成功/失败/超时失败率突然升高,说明争抢或锁泄漏
lock_renew_fail_total续期失败次数大于 0 就应告警;连续失败必须停止业务
lock_lost_total本地 deadline 已过但仍在执行的次数大于 0 说明代码路径没有检查 deadline
fence_reject_total资源端拒绝旧 token 的次数偶发正常;突增说明有旧持有者在写
lock_hold_seconds临界区持有时长直方图p99 接近 TTL 时,必须调整 TTL 或拆分任务

一个实用的仪表盘顺序:先看 fence_reject_total(有没有旧持有者在写),再看 lock_renew_fail_total(租约是否健康),再看 lock_hold_seconds(临界区是否过长),最后才看获取失败率。拒绝数是「已经发生的危险」,续期失败是「即将发生的危险」,获取失败只是「拿不到锁」。

观测不能证明互斥

INFO 和 timetolive 能证明「租约在续」「key 在」「没有驱逐」,不能证明「同一时刻只有一个持有者在写资源」。后者只能由资源端的 fence 拒绝日志和业务对账来证明。不要把「锁服务看起来正常」当成「没有双写」。

9.9 真实案例:Redlock 辩论

2016 年 2 月 8 日,Martin Kleppmann 发表《How to do distributed locking》,对 Redis 的 Redlock 算法提出批评。随后 antirez 发表《Is Redlock safe?》回应。这场辩论是分布式锁领域最著名的一次公开技术争论,也是本章很多结论的来源。

9.9.1 事实:两篇文章在争什么

项目内容
时间2016-02-08(Kleppmann 文章);随后 antirez 回应
系统Redis 的 Redlock:5 个独立 master、多数派获取、按有效期使用
失败链客户端获取锁 → GC/进程暂停 → 租约过期 → 另一个客户端获取 → 两个客户端都写资源
争点时钟/进程暂停下的安全性;Redlock 能否作为正确性原语;fencing 是否必需
结论没有统一判决;工程共识是「锁用于效率可以,用于正确性必须 fence 或共识」

Kleppmann 的论证不依赖 Redis 的实现细节,而是一个执行模型:进程可以在任意两条指令之间暂停任意长时间。只要这个模型成立,任何「拿到锁之后不再询问、直接写资源」的协议都不安全,因为暂停可能发生在「拿到锁」和「写资源」之间。Redlock 的有效期检查发生在获取锁时,无法约束获取之后的暂停。

antirez 的回应则强调假设:Redlock 不假设进程可以无限暂停;它假设时钟漂移有界、网络延迟有界,客户端在有效期内完成工作。如果客户端暂停超过有效期,那是客户端故障,应该由 fencing 或外部监督处理,而不是要求锁算法解决。

9.9.2 争点拆解

争点Kleppmannantirez工程落点
进程暂停无上界,必须防是客户端故障,锁算法不负责资源端 fence
时钟有效期依赖时间;时钟跳变/挂起会让判断失真漂移有界,drift 项留了余量单调时钟 + 余量,但仍不解决挂起
定位不能作为正确性原语在声明的假设内可安全发放租约;正确性需 fencing按后果选型
fencing必需需要绝对安全时必需扣款/库存必须
5 个 master不增加正确性,只增加可用性提高可用性,减少单点可用性 ≠ 正确性

这张表说明,两人并不是在同一个问题上互相否定:一个在问「能不能拿它保证不双写」,另一个在答「在声明的假设内它能安全工作」。把辩论简化成「Redis 做锁不靠谱」或「Kleppmann 不懂 Redis」,都错过了可操作的结论。

9.9.3 三条可操作的教训

  1. 把「锁的用途」写清楚。如果只是避免重复劳动,单实例 Redis + TTL + token 解锁足够;如果要保护钱/库存,锁服务本身要有 quorum,资源端要有 fence。
  2. 把「假设」写进设计文档。TTL 多长、续期多久、最大暂停多少、时钟漂移多少、资源端是否校验 token;这些不是实现细节,是协议的一部分。
  3. 用故障注入验证,而不是用文章站队。在测试环境注入 SIGSTOP、时钟跳变、主从切换,观察是否出现双写;如果没有 fence,几乎一定能复现。
事实边界

本节只引用两篇公开文章的核心论点、时间和系统背景,不复述原文长段。具体措辞、代码和后续讨论以原文为准:Kleppmann 的文章、antirez 的回应。Redis 官方文档的分布式锁页面也把 Redlock 作为一节讨论,可作为工程读本;具体结论以文档为准。

9.10 重试导致重复副作用:锁过期后的双执行

现在把 9.7 的停顿和 9.2 的过期拼成一个完整的业务事故。场景:退款 worker 用 Redis 锁保证「同一张退款单只处理一次」,锁 TTL 30 秒;下游支付网关(PSP)在高峰期一次退款调用要 45 秒。

9.10.1 失败链

时间Worker A锁PSPWorker B账本
t0获取锁,TTL 30s授予———
t1调用 refund(intent=R1)—开始处理—无记录
t30仍在等 PSP过期,删除 key仍在处理—无记录
t31仍在等 PSP——获取锁无记录
t32仍在等 PSP——读账本:无记录无记录
t33仍在等 PSP—收到第二笔 refund(R1)调用 PSP无记录
t45PSP 返回成功—第一笔成功仍在等—
t46写账本——仍在等写入 R1
t47——第二笔成功写账本再写一条 R1

表里是「账本没有 fence」的版本。如果账本带 fence,A 在 t46 的写会因为 41 小于 42 被拒绝,账本只留一条;但 PSP 在 t33 已经受理的第二笔扣款仍然发生。fence 保护账本,不保护 PSP;第三方副作用要靠它自己的幂等键和对账。

这里有两个不同的问题叠加:

  • 锁过期让 B 进入了临界区;如果没有 fence,A 和 B 都能写账本。
  • PSP 调用没有幂等键,所以即使账本端用唯一索引挡住了第二条记录,PSP 那边的两笔扣款已经发生。账本可以回滚一条记录,但钱不会自动回来。

如果 A 在 t45 之后还因为「超时」重试一次,而重试又用了新的退款 ID,就是第三笔。锁过期是触发条件,重试是放大器,缺少幂等键是根因。

9.10.2 为什么「锁 + 唯一索引」还不够

很多团队会加一个 UNIQUE(intent_id),认为这样就不会重复。它确实能挡住「同一 intent 写两条账本」,但挡不住:

  1. PSP 侧的重复扣款:账本只记录一条,但 PSP 扣了两次;需要 PSP 的幂等键或对账冲正。
  2. 重试/新持有者用了不同的 intent:A 用 R1,重试时生成了 R2,唯一索引不拦。
  3. 旁路写:另一个服务直接写账本,不经过唯一约束对应的入口。
  4. 部分成功:PSP 成功、账本写入失败;重试时 PSP 又扣一次。

所以修复必须是多层的:

层机制挡住什么挡不住什么
入口分布式锁正常并发下的同时进入过期后的双持有者
账本UNIQUE(intent_id)同一 intent 的两条记录不同 intent、PSP 侧扣款
账本fencing token旧持有者的写新持有者的重复、PSP 侧扣款
PSP幂等键 = intent_id同一 intent 的重复扣款不同 intent、PSP 不支持幂等
对账按 intent_id 核对 PSP 流水已发生的重复/遗漏实时性;对账本身可能漏

9.10.3 一个可用的退款状态机

import sqlite3


def refund_once(conn: sqlite3.Connection, psp, intent_id: str,
                amount_cents: int, token: int) -> str:
    # 1) 本地先占位:唯一约束保证同一 intent 只有一条
    with conn:
        conn.execute(
            "INSERT OR IGNORE INTO refund_intents(intent_id, status) VALUES (?, 'pending')",
            (intent_id,),
        )
        row = conn.execute(
            "SELECT status, psp_ref FROM refund_intents WHERE intent_id = ?",
            (intent_id,),
        ).fetchone()
        if row[0] == "done":
            return row[1]                  # 已经成功,直接复用结果

    # 2) 调 PSP,幂等键用 intent_id;超时后查询,不要盲目重试
    try:
        resp = psp.refund(idempotency_key=intent_id, amount_cents=amount_cents)
    except TimeoutError:
        resp = psp.get_refund(idempotency_key=intent_id)   # 查询而不是重发
        if resp is None:
            raise                           # 状态未知,交给对账/重试同一 intent

    # 3) 账本落账,带 fence;旧 token 会被拒绝
    with conn:
        cur = conn.execute(
            """
            UPDATE refund_intents
               SET status = 'done', psp_ref = ?, fence = ?
             WHERE intent_id = ? AND status = 'pending' AND fence < ?
            """,
            (resp.id, token, intent_id, token),
        )
        if cur.rowcount != 1:
            raise RuntimeError("stale token or duplicate transition")
    return resp.id

这段代码的重点不是语法,而是三个顺序:

  1. 先占位,再调 PSP。如果先调 PSP 再写本地,进程在两步之间崩溃,本地没有记录,重试会再扣一次;先占位至少留下「这个 intent 正在处理」的痕迹。
  2. PSP 超时后查询,不重发。查询也可能失败;失败就保持 pending,由后台对账按 intent_id 查 PSP,而不是生成新 ID 重试。
  3. 账本落账用 fence。旧持有者即使恢复,也不能把过期的结果写进账本;新持有者用更高的 token 完成落账。

这个状态机仍然不是「绝对一次」。它把重复窗口从「锁过期后的任意双写」缩小到「PSP 不支持幂等 + 对账失败」这类更窄、更可观测的路径。工程上要的是「可证明的边界 + 可对账的残留」,不是一句「我们加了锁」。

例 9.10.1 · 重试导致重复副作用:三次退款

退款单 R1,金额 100 元。Worker A 拿到锁后调用 PSP,45 秒才返回;锁 30 秒过期。Worker B 拿到锁,看到账本没有 R1,调用 PSP;PSP 没有幂等键,扣了第二笔。A 的客户端在 50 秒时超时,SDK 自动重试,生成了新的退款单 R2,PSP 扣了第三笔。

修复后:PSP 幂等键固定为 R1,账本对 intent_id 建唯一索引,落账带 fence,超时只查询不重发。三次退款变成一次扣款 + 一次可对账的 pending,重复窗口从「每次重试」缩小到「PSP 幂等窗口之外」。

9.11 锁的使用边界

前面十节都在讲「怎么把锁做对」。这一节反过来问:这件事真的需要锁吗? 很多分布式锁事故的根因不是锁写错了,而是从一开始就不该用锁。

9.11.1 能不用就不用

问题不一定需要锁更简单的做法
防止重复插入唯一索引 + INSERT ... ON CONFLICT数据库约束比锁可靠
防止重复扣款幂等键 + 状态机下游/账本去重
同一实体只允许一个写者按 key 分区 + 单写者Kafka 分区、分片、actor
并发更新冲突乐观锁/版本号/CASUPDATE ... WHERE version = ?
多步操作原子性数据库事务本地事务 + outbox
任务只跑一次队列 + 消费组不需要全局锁
读多写少的配置版本号 + 缓存不需要锁

这些方案的共同点是:把「互斥」变成「冲突检测」或「单写者拓扑」。它们不依赖一个额外的锁服务,也就没有租约、续期、主从丢锁这些问题。能用唯一索引解决的,不要用分布式锁;能用单写者分区解决的,不要用全局锁。

9.11.2 什么时候确实需要锁

场景为什么需要仍然要做的
外部稀缺资源(打印机、机器人、部署槽位)资源本身不支持并发租约 + 资源端状态检查
非幂等的第三方副作用对方没有幂等键尽量选支持幂等的下游;锁 + 对账/冲正,不能声称绝对一次
数据库迁移/DDL只能一个实例执行锁 + 人工确认 + 超时
定时任务单实例避免重复执行锁 + 幂等 + 监控
多步骤人工审批的串行化业务要求顺序状态机 + 锁只做入口

即使在这些场景,锁也只是「入口的串行化」。临界区内部的每一步仍然要问:如果锁在这里过期,会发生什么?资源端能不能拒绝?能不能用幂等键兜底?

9.11.3 锁的代价

引入一把分布式锁,等于引入一个新的分布式系统:

  • 依赖:锁服务挂了,业务是降级还是拒绝?拒绝会损失可用性,降级可能双写。
  • 延迟:每次 acquire/renew 都是一次网络往返;跨地域锁的 RTT 可能比临界区本身还长。
  • 复杂度:token、续期、超时、重连、watch、会话过期,每一项都要处理和测试。
  • 运维:Redis 主从、etcd quorum、ZK ensemble,各自的故障模式不同。
  • 观测:必须有续期失败、fence 拒绝、持有时长这些指标,否则出事时只能猜。

一把锁的代码可能只有 20 行,但它背后的故障模式有 20 种。不要为了省一个唯一索引,引入一套需要值班的锁基础设施。

9.11.4 锁评审的十二个问题

#问题答不上来的后果
1锁保护的资源是什么?粒度是哪个 key?锁太粗,吞吐下降;锁太细,保护不到
2锁失效(过期/丢失)的后果是什么?无法判断该用 Redis 还是共识系统
3临界区最长可能多久?TTL 是多少?TTL 太短导致双持有者,太长导致故障恢复慢
4续期失败时,业务怎么停?继续写过期数据
5资源端是否校验 fencing token?锁只是效率工具,不是正确性保证
6所有写路径都经过校验吗?缓存/队列/运维脚本绕过
7token 从哪来?故障切换/恢复后会不会回退?旧 token 被接受,或新 token 被误拒
8解锁是否比较 token?误删别人的锁
9锁服务故障时,业务是降级还是拒绝?可用性与正确性没有明确取舍
10等待锁的时间上限是多少?请求堆积、线程池耗尽
11有没有幂等键/唯一约束兜底?锁失效时直接双写
12观测指标和对账手段是什么?出事无法定位、无法补偿
锁评审的五分钟版

如果时间只够问五个问题,问这五个:

  1. 锁过期后,旧持有者还能写吗?资源端会不会拒绝?
  2. 续期失败时,业务代码走哪条分支?会不会继续写?
  3. 锁服务主从切换/恢复后,token 会不会回退?
  4. 有没有一条写路径绕过了 fence?
  5. 如果明天锁服务全挂,业务是拒绝服务还是双写?

9.11.5 反模式

  • 锁包住网络调用:临界区里调下游,下游慢导致锁过期,双持有者。把网络调用移到锁外,或者用状态机/异步。
  • 没有 TTL 的锁:崩溃后永久占用;TTL == -1 必须告警。
  • 有 TTL 但没有续期:长任务必然过期;要么续期,要么把任务拆短。
  • 无条件 DEL 解锁:误删别人的锁;必须 Lua 比较 token。
  • 一把全局锁:所有租户/资源共用,吞吐和爆炸半径都不可控;按资源分 key。
  • 把锁当队列:用 while not acquire(): sleep() 排队,浪费连接和 CPU;用 etcd/ZK 的等待队列或消息队列。
  • 用锁掩盖竞态:本该用唯一索引/幂等/事务的地方加锁,锁失效后问题依旧。
  • 只监控 acquire 成功:不看续期失败和 fence 拒绝,等于只看「门有没有开」,不看「有没有人从窗户进来」。

9.12 本章陷阱清单

14 条高频错误
  1. SET NX 成功就以为安全:它只证明「写入时 key 不存在」,不证明「写资源时锁还有效」;暂停、主从切换、驱逐都会让锁失效。
  2. 解锁直接 DEL:锁过期后可能删掉别人的锁;必须用 Lua 比较 token 再删。
  3. TTL 没有续期,或续期失败后继续写:长任务必然过期;续期失败必须停止业务,而不是重试到成功。
  4. 续期线程和业务共享同一个 GC:Full GC 会同时暂停两者;独立 sidecar 能续期,但可能掩盖业务卡死,仍需 fence。
  5. 以为主从复制不会丢锁:Redis 复制是异步的,WAIT 只缩小窗口;故障切换可能提升一个没有锁 key 的从库。
  6. 把 Redlock 当共识:多数派获取成功不等于线性一致;Redlock 买到的是可用性,不是正确性。
  7. 用时间戳或 INCR 当 fence:时钟会回拨,Redis 主从切换会让计数器回退;token 必须跨故障切换单调。
  8. 资源端只检查 token 存在,不比较大小:等于没有 fence;必须记录 max_fence 并拒绝更小的 token。
  9. check-then-act:先 SELECT fence 再 UPDATE,两个请求可能都通过检查;比较和写入必须在同一条语句/事务里。
  10. 只 fence 写路径:缓存、消息队列、运维 SQL、另一个服务都可能绕过;资源端必须是唯一写入口。
  11. TTL 小于下游 p99.9,或把网络调用放在锁内:锁在等下游时过期,双持有者;把慢调用移出临界区。
  12. 锁 key 粒度太粗/跨租户:一把全局锁让所有请求排队,爆炸半径不可控;按资源/租户分 key。
  13. 把锁当队列或重试机制:无限等待、无限重试会耗尽线程/连接;用等待队列、退避和超时。
  14. 只看 acquire 成功,不看 renew 失败/fence 拒绝:观测缺一半;没有拒绝日志,就无法证明 fencing 真的在工作。

9.13 本章出口检查

检查项达标线没达标去哪
画出锁的三层能说出互斥/租约/fencing 各挡什么、各漏什么9.1
写对 Redis 加锁/解锁SET NX PX 原子;Lua 比较 token 再删9.2
解释主从丢锁能画出「主库写成功、从库没收到、提升后双持有者」9.2.4
解释 Redlock 的时钟假设能说出进程暂停/挂起为什么破坏有效期9.3
用 ZK/etcd 的租约能说出临时节点/lease、watch 前驱、会话过期9.4
区分选主与锁能说出 term/epoch 与资源端 fence 的关系9.5
写资源端 fence单条 UPDATE ... WHERE fence < ?,持久化 max_fence9.6
画出 GC 停顿时间线标出 t0/t10/t12 与两个 token9.7
用命令看租约etcdctl lease timetolive 或 redis-cli INFO 能读出异常9.8
说出 Redlock 辩论的结论不是「谁赢」,而是「按后果选型 + fence」9.9
拆一次双执行能画出锁过期 + PSP 慢 + 重试的完整链9.10
判断该不该用锁能给出至少三种替代方案9.11
即时自测 · 9

Q1 Redis 锁 TTL 30 秒,业务执行 45 秒,续期线程在 GC 中暂停了 35 秒。描述从 t=0 到 t=50 的锁状态,并说明为什么「续期线程恢复后重试」不能保证安全。

Q2 redis-cli WAIT 1 100 返回 1,是否意味着主从切换后锁一定还在?为什么?

Q3 为什么不能用 time.time() 生成 fencing token?给出一个 token 回退的具体场景,并说明资源端会看到什么。

Q4 资源端校验 token 时,为什么不能先 SELECT fence 再 UPDATE?如果必须分两步,至少要加什么?

Q5 一个「每天 02:00 跑一次对账」的任务,除了分布式锁,还有哪些更简单的单实例方案?各有什么代价?

Q6 资源端已经用 fencing 拒绝了旧 token,为什么还需要幂等键?举一个 fence 挡不住的例子。

答案

A1 t=0 获取锁,TTL 到 t=30;续期线程从 t=1 到 t=36 被暂停,t=30 锁过期;t=31 另一个 worker 可以拿到锁;t=36 A 恢复,业务继续到 t=45,但 A 的锁早已不在。续期线程 t=36 恢复后「重试续期」也没用:如果锁已被 B 持有,Lua 比较 token 会失败;如果没人持有,Redis 里 key 已过期,续期也失败。更关键的是,即使续期成功,A 在 t=36 到 t=45 之间已经写了资源;安全不能靠「把 key 续回来」,只能靠资源端拒绝旧 token。

A2 不一定。WAIT 只表示至少一个从库在超时内确认了写;故障切换可能提升另一个没有收到这条写的从库,确认也可能只到达从库内存。异步复制 + 提升策略决定了「确认」不等于「切换后仍在」。要正确性,必须 fence 或使用有 quorum 提交的存储。

A3 墙上时钟可能被 NTP 回拨,也可能在虚拟机恢复后跳变;两台机器同一毫秒可能生成相同 token,或后生成的 token 更小。场景:A 在 12:00:00.900 拿到 fence=1000,时钟被回拨 2 秒,B 在 12:00:00.100 拿到 fence=999;资源端已经记录 1000,B 的写被拒(活性问题);如果资源端也重启丢了 max_fence,旧 token 又可能被接受。用 etcd revision、ZXID、Raft term+index 或 (epoch, counter)。

A4 因为 SELECT 和 UPDATE 之间有时间窗口:两个请求都读到 fence=41,都判断 42 > 41,然后都写入,max_fence 被覆盖成 42,但两笔副作用都发生了。必须把比较和写入放在同一条 UPDATE ... WHERE fence < ?(或同一个事务 + 行锁)里;如果业务必须分两步,至少要在事务里锁住行(支持 SELECT ... FOR UPDATE 的数据库用它;SQLite 可用 BEGIN IMMEDIATE 串行化写事务),并让所有写路径走同一事务。

A5 方案一:Kubernetes CronJob 的 concurrencyPolicy: Forbid(只保证同一 CronJob 内不并发,跨集群/跨命名空间不保证);方案二:队列 + 单消费者(吞吐低,但天然单写者);方案三:数据库唯一约束 + 任务执行记录(INSERT 抢占,失败即退出,不需要锁服务);方案四:按日期分片,每个分片一个写者。代价分别是:依赖调度器、吞吐、需要清理记录、分片设计复杂。

A6 fence 只保护「被 fence 的资源」。例如账本用 fence 拒绝了旧 worker 的写,但旧 worker 已经在第三方 PSP 上完成了一笔扣款;PSP 不认识你的 fence,只认它自己的幂等键。另一个例子:同一持有者重新拿锁后得到更大的 token,再把同一笔请求写一次,fence 会接受(token 更大);或者资源端用了 >=,同一 token 的重复写也会被接受。fence 只排序写者,不识别「同一逻辑请求」;需要幂等键/唯一约束。fence 防旧持有者,幂等防重复请求,两者叠加。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 5/6 章9.1、9.4背景只借用「分布式系统没有全局时钟/共享内存」的视角;不重写传输层与协议栈
张彦飞《深入理解 Linux 网络》第 4–7 章9.2、9.5背景连接、超时、阻塞只引用;不展开内核网络路径
《Python 网络编程》socket/HTTP/并发服务器9.2、9.6、9.10精选借用工程风格;锁、fence、退款状态机代码为本书重写
《Linux 系统与内核》第 2/3 篇进程、线程、调度、时钟9.3、9.7交叉引用只引用 CLOCK_MONOTONIC/CLOCK_BOOTTIME、STW、cgroup 限流等事实;不重写内核实现
本书自编 + Kleppmann/antirez/Redis/etcd/ZooKeeper 文档—9.3、9.4、9.9完整只引用公开事实与算法思想,不复制文章/文档长段;辩论双方观点以原文为准

下一站:锁能挡住并发的写,但挡不住「你看不见它」。进入 10 可观测性:trace、指标、日志、回放,看一次请求的 span 怎么串、指标怎么聚合、日志怎么关联,以及为什么「有监控」不等于「能定位」。

10 可观测性:trace、指标、日志、回放

本章回答

线上报警说 p99 变慢,为什么 dashboard 全绿、日志里也找不到异常?一次用户点击为什么在 trace 里变成三个 root span?指标上的错误率 30%,用户真的失败了 30% 吗?trace、指标、日志各自看不见什么,怎样用它们回答一个没有预先想过的问题?回放生产流量为什么可能再扣一次钱?

读完你会

能读写 W3C traceparent 并设计一棵可解释的 span 树;能区分头采样与尾采样,并估算 telemetry 的日增量与成本;能用 RED/USE 与 histogram 正确计算分位数;能写 SLI/SLO、错误预算与 burn rate 告警;能用 curl + jq 和 PromQL 定位 p99 的那一段;能设计隔离、幂等的回放与影子流量。

第 9 章把锁、租约和 fencing 讲到了资源端:即使旧持有者恢复,资源也会拒绝过期 token。但还有一个问题没有回答:你怎么知道凌晨两点锁过期了、旧持有者真的执行了、副作用落在了哪张表上? 锁的代码可以完全正确,系统仍然可以在无人察觉的情况下重复扣款;反过来,一段有 bug 的代码也可能在监控上看起来一切正常。

可观测性要解决的不是「多装几个 dashboard」,而是一个更窄、更硬的问题:当系统进入一个你没有预想过的状态时,你能否在不改代码、不重启、不猜的情况下,用已有数据回答「发生了什么、影响谁、从哪开始、能不能复现」。 这句话里有四个动词,恰好对应四种数据:指标告诉你「有多少、什么时候开始」,trace 告诉你「这一次请求花在哪」,日志告诉你「具体是哪条错误、哪个参数」,回放告诉你「同样的输入再来一次,行为是否一致」。

这一章的顺序是:先看三种数据各自的形状与盲区(10.1),再逐个深入 trace(10.2)、指标(10.3)、日志(10.4),然后把它们接成一条可运维的流水线(10.5),用 SLO 把「健康」变成可计算的预算(10.6),最后讲回放(10.7)。后半章回到失败:重试怎样制造重复 span 和双算指标(10.8),怎样用一条命令找到 p99 的那一段(10.9),GitLab 2017 年的数据库误删怎样暴露了「备份可观测性」的缺口(10.10),以及回放流量为什么是一个写负载(10.11)。

10.1 三支柱与它们的盲区

先看一次真实的排障。凌晨 02:14,checkout 服务的 p99 从 220 ms 涨到 1.9 s,告警触发。值班工程师打开 dashboard:CPU 42%,内存 61%,错误率 0.2%,下游 PSP 的成功率 99.98%,数据库连接池占用 30%。所有「资源指标」都正常。日志平台当天有 40 GB 日志,搜 timeout 只看到零星几条。trace 采样率 1%,翻了十分钟也没找到一条超过 1 s 的慢 trace。

四十分钟后,原因找到了:一个大租户的购物车有 300 个商品,结算路径对每个商品做了一次库存查询,形成 300 次串行 RPC。这个租户只占流量的 0.3%,所以它的慢请求几乎都被 1% 的头采样丢掉了;CPU 不高,是因为时间花在等下游,不在算;日志里没有慢,是因为应用只记录了「请求完成」,没有记录每个子调用的耗时。

这个例子把三支柱的盲区一次暴露完:

  • 指标知道「整体变慢了」,但聚合把租户、请求、调用链都抹掉了;
  • trace 能指出「慢在 300 次库存查询」,但采样把它丢了;
  • 日志有请求记录,却没有 span 结构和耗时分解,无法告诉你「慢在哪一段」。

三种数据不是三选一,也不是「都接上就完事」。它们是三种不同的数据模型,各自擅长回答不同形状的问题。把它们混为一谈,就会得到「什么都有,什么都查不出来」的可观测性。

定义 10.1.1 · 可观测性

可观测性(observability)是系统根据其外部输出(telemetry)推断内部状态的能力。工程上把它落成三个可操作的问题:

  1. 给定一个现象,能否找到对应的请求、租户、版本、依赖和代码路径?
  2. 给定一个请求,能否还原它在时间轴上的每一段耗时与每一次失败?
  3. 给定一段历史流量,能否在隔离环境里重放,并解释行为差异?

能回答这三个问题,才算「可观测」;只有 dashboard 和告警,只能算「可监控」。监控回答的是你已经想到的问题,可观测性要支撑你还没想到的问题。

10.1.1 三种数据模型的形状

支柱数据模型回答什么问题强项盲区成本驱动
trace有向树:trace_id + span_id + parent_span_id这一次请求花在哪、经过谁因果链、跨服务、单请求分解采样、只覆盖已埋点代码、异步易断链、时钟偏斜span 数量 × 属性大小 × 保留天数
metrics带标签的时间序列:名字 + labels + 时间戳 + 值整体多少、趋势、何时开始便宜、可长期保留、易聚合、适合告警聚合丢身份、基数爆炸、分位数是估计、抓取间隔漏尖峰活跃 series 数 × 抓取频率
logs带时间戳的事件:结构化字段或文本具体发生了什么、错误内容、参数细节最全、字段可任意扩展、易读量大、贵、难聚合、常缺上下文、易含敏感信息字节数 × 索引 × 保留天数

三种模型的关键差异不是「格式」,而是基数(cardinality)和聚合方式:

  • trace 的基数最高:每个请求一棵树,每个 span 一条记录;它天生是「按请求」的。
  • metrics 的基数受 label 组合控制:同一个指标名下的 series 数量是乘积;它天生是「按维度聚合」的。
  • logs 的基数是「事件数 × 字段数」;它天生是「按时间追加」的。

理解这一点,就能理解为什么「把 trace id 放进 metrics label」是灾难:trace id 的基数是请求数,metrics 后端会为每个请求建一条 series,几秒钟就能把内存打满。反过来,把 metrics 的聚合值塞进日志也没有意义:日志不适合做趋势。

10.1.2 trace 的盲区:采样与断链

trace 最容易被误解成「全量记录」。实际上,生产环境的 trace 几乎总是采样的,原因很简单:一次请求如果有 8 个 span,每个 span 平均 500 字节,20,000 req/s 就是:

20,000×8×500 B/s=80 MB/s≈6.9 TB/天 20{,}000 \times 8 \times 500\ \text{B/s} = 80\ \text{MB/s} \approx 6.9\ \text{TB/天}

6.9 TB/天只是原始 span,还没算索引、查询和副本。就算存储不要钱,网络和 collector 也扛不住。所以 1% 头采样对应的就是 69 GB/天,仍然不是一个小数目。采样不是可选项,而是可观测性设计的一部分;问题不是「要不要采样」,而是「采样决策在哪做、保留什么、丢了什么」。

头采样(在 trace 开始时按概率决定)的盲区是:它看不见未来。一个 0.3% 租户的慢请求,在 1% 采样下只有百分之一的概率被保留;如果这个租户的请求本身只占 0.3%,那它出现在样本里的概率是 0.003×0.01=0.000030.003 \times 0.01 = 0.00003。你想找的那条慢 trace,很可能从来就不存在。尾采样(等 trace 结束后按错误/延迟决定)能解决这个问题,但代价是 collector 要在内存里缓存整条 trace,而且所有 span 必须路由到同一个 collector 实例。

trace 的第二个盲区是断链:

  • 数据库驱动没有埋点,trace 到服务边界就断了;
  • 请求进了线程池/消息队列,context 没有传过去,子 span 变成孤儿;
  • 异步任务在另一个进程里执行,父 span 已经结束,时间轴上看起来像「凭空多了一段」;
  • 批量消费把 100 条消息塞进一个 span,丢失了每条消息的因果。

第三个盲区是时钟。span 的 start_time 来自各自机器的墙上时钟;两台机器差 50 ms,waterfall 上就会出现「子 span 比父 span 早开始」或者「两个本应串行的 span 重叠」。跨主机比较绝对时间要谨慎,比较同一台机器上的 duration 才可靠。

10.1.3 指标的盲区:聚合与基数

指标的第一个盲区是聚合本身。sum(rate(http_requests_total[5m])) 回答的是「总共多少」,不是「谁失败了」。当错误率从 0.2% 涨到 5%,你从聚合值里看不出是哪个租户、哪个路由、哪个版本;如果 label 里没有这些维度,你只能去日志里捞。

第二个盲区是基数。给指标加 label 很容易,代价却是指数级的:

1000 个 pod × 200 个路由 × 60 个状态码 × 10 个租户 = 120,000,000 条 series

即使只保留其中一部分,也足以让 Prometheus 或任何 TSDB 的内存和查询延迟失控。user_id、request_id、trace_id、原始 URL、错误消息,都不应该出现在 metrics label 里。它们属于 trace 属性和日志字段。

第三个盲区是分位数是估计值。Prometheus 的 histogram 把观测值放进预先定义的 bucket,histogram_quantile 在 bucket 内做线性插值;bucket 设计得不好,p99 可能偏差很大。更常见的问题是「对分位数再求平均」——把 10 个实例的 p99 加起来除以 10,得到的数没有任何统计意义。10.3 会给出具体的反例和正确写法。

第四个盲区是抓取间隔。15 s 抓一次,持续 5 s 的尖峰可能只被采到一个点,甚至完全错过;rate(...[1m]) 在低流量下会抖动,在 counter 重置时会给出负值或跳变。指标适合看趋势和预算,不适合还原每一次请求。

10.1.4 日志的盲区:成本与上下文

日志的盲区不是「不够多」,而是「太多且没有结构」。一个服务每天 40 GB 日志,其中 95% 是 INFO 级的请求开始/结束;真正有用的错误行淹没在里面。更常见的问题是日志缺少关联字段:

2026-10-19 02:14:03 ERROR charge failed: timeout

这条日志无法回答:哪个请求?哪个租户?第几次重试?下游是谁?超时前等了多久?如果它带了 trace_id、span_id、idempotency_key、attempt、peer、deadline_remaining_ms,同样的字节数能回答的问题多一个数量级。

日志的第二个盲区是成本。按 5,000 req/s、每条 1 KB 估算:

5,000×1 KB=5 MB/s=432 GB/天 5{,}000 \times 1\ \text{KB} = 5\ \text{MB/s} = 432\ \text{GB/天}

按每 GB 几毛到几美元不等的摄入价格,每天的成本是几百到几千美元,还没算索引和长期存储。日志不是「免费的证据」,它是按字节计费的。10.4 会给出字段设计、采样和分层保留的具体做法。

10.1.5 互补:从告警到复现的闭环

三种数据不是并列的「三个 dashboard」,而是一条证据链:指标发现异常,exemplar 把它落到一条 trace,trace 落到日志,最后用回放验证。这条链上任何一段断掉,排障就会退化成猜。

可观测性与监控不是同义词

监控(monitoring)回答「系统是否在已知的正常范围内」,依赖预先定义的指标和阈值。可观测性(observability)回答「为什么它不在正常范围内」,依赖高基数、可关联、可下钻的数据。前者是后者的入口,不是替代品。一个只有监控的系统可以告诉你「p99 超了」,但不能告诉你「哪个租户的哪一次调用慢在哪一段」。

三支柱之外还有第四类信号:持续 profiling(CPU/内存/锁的采样)。它不在本章的主线里,但在「CPU 不高、延迟却高」的场景里往往比 trace 更直接;第 11 章讲容量时会再遇到它。

10.2 Trace:context、span、采样

trace 的核心不是「一条时间轴」,而是一棵有因果关系的树。一次请求进入系统时创建 root span;每调用一个下游,创建一个 child span;child 的 parent_span_id 指向调用它的 span。所有 span 共享同一个 trace_id。把这棵树按开始时间画出来,就是常见的 waterfall。

定义 10.2.1 · trace、span 与 span context

trace 是一次逻辑请求的完整调用树,由全局唯一的 trace_id 标识。span 是树中的一个操作,包含 span_id、parent_span_id、名称、开始/结束时间、属性、事件和状态。span context 是跨进程传播的最小集合:trace_id、当前 span_id、采样标志,以及可选的 tracestate。

父子关系表示「谁发起了谁」;兄弟关系表示「同一个父 span 下的并发或串行调用」。没有父子关系的两个 span 即使时间重叠,也不能仅凭时间推断因果。

10.2.1 W3C traceparent:55 个字符的契约

跨服务传播 trace context 的事实标准是 W3C Trace Context。HTTP 请求头 traceparent 的格式是:

version-trace-id-parent-id-trace-flags
00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
字段长度示例规则
version2 hex00当前版本;ff 保留,禁止使用
trace-id32 hex4bf92f3577b34da6a3ce929d0e0e473616 字节;全零非法
parent-id16 hex00f067aa0ba902b78 字节;全零非法
trace-flags2 hex01最低位是 sampled;其余位保留

几个容易写错的细节:

  • 所有字段必须是小写十六进制;大写虽然能解析,但不符合规范,跨厂商时可能被拒绝。
  • trace-id 和 parent-id 不能全零;全零通常意味着「未初始化」,应该当作没有 context。
  • 版本 ff 非法;遇到未知版本时,规范要求按「不兼容」处理,而不是猜字段。
  • trace-flags 的 bit 0 是 sampled,01 表示采样,00 表示不采样;其他 bit 目前没有定义,不能拿来做业务标记。
  • tracestate 是另一个头,逗号分隔的 vendor 列表(如 vendor1=value1,vendor2=value2),有成员数量和长度限制;它适合放厂商的采样决策,不适合塞业务大字段。

一个最小但完整的 Python 实现:

import re
import secrets
from dataclasses import dataclass

TRACEPARENT_RE = re.compile(
    r"^(?P<version>[0-9a-f]{2})-"
    r"(?P<trace_id>[0-9a-f]{32})-"
    r"(?P<parent_id>[0-9a-f]{16})-"
    r"(?P<flags>[0-9a-f]{2})$"
)

@dataclass(frozen=True)
class SpanContext:
    trace_id: str
    span_id: str
    sampled: bool

def new_root_context(sampled: bool = True) -> SpanContext:
    return SpanContext(
        trace_id=secrets.token_hex(16),   # 32 hex = 16 bytes
        span_id=secrets.token_hex(8),     # 16 hex = 8 bytes
        sampled=sampled,
    )

def format_traceparent(ctx: SpanContext) -> str:
    flags = "01" if ctx.sampled else "00"
    return f"00-{ctx.trace_id}-{ctx.span_id}-{flags}"

def parse_traceparent(header: str) -> SpanContext | None:
    m = TRACEPARENT_RE.match(header.strip().lower())
    if not m:
        return None
    if m["version"] == "ff":
        return None
    trace_id, parent_id = m["trace_id"], m["parent_id"]
    if trace_id == "0" * 32 or parent_id == "0" * 16:
        return None
    return SpanContext(
        trace_id=trace_id,
        span_id=parent_id,
        sampled=bool(int(m["flags"], 16) & 0x01),
    )

这段代码可以直接放进网关或 SDK 的注入/提取逻辑。实际实现还要处理两件事:未知版本(version 不是 00 时,按规范尝试解析并忽略无法识别的尾部字段)和多个头(HTTP 允许重复头,必须拒绝或取第一个,不能拼接)。tracestate 的解析更复杂,建议直接用 OpenTelemetry 的实现,不要自己写。

trace_id 不是安全边界

trace_id 是随机值,不是认证凭证。不要用它做权限判断,也不要在 URL 里暴露完整的内部调用链。它可以出现在日志和响应头里用于排障,但不能替代 request_id、idempotency_key 或用户身份。

10.2.2 span 树:时间轴之外还要看「谁是谁的孩子」

一棵典型的 checkout trace:

trace 4bf92f3577b34da6a3ce929d0e0e4736
POST /v1/pay                       server   1912 ms   self  97 ms
├─ auth.verify                     client     21 ms
├─ cart.get                        client     38 ms
│  └─ SELECT cart_items            server     31 ms   (db)
├─ psp.charge                      client   1740 ms
│  └─ POST /charge                 server   1705 ms   (external PSP)
└─ db.commit                       client     16 ms

读这棵树时,先看三件事:

  1. root 的 self time:1912 - (21 + 38 + 1740 + 16) = 97 ms。如果 self time 很大,问题在本服务的排队、序列化、GC 或未埋点的代码;如果 self time 很小,问题在某个 child。
  2. child 的 span kind:psp.charge 是 CLIENT,POST /charge 是 SERVER;两者相差 35 ms,是网络与 PSP 入口排队。如果只有 CLIENT 没有 SERVER,说明对方没有埋点,你只能看到「从本端看出去」的耗时。
  3. 串行还是并行:auth、cart、psp 在时间轴上是否重叠。如果它们串行,端到端是四段之和;如果并行,端到端接近最慢的一段。很多「优化」之所以无效,是因为优化了不在关键路径上的 span。

span 的 kind 不是装饰性的枚举,它决定了这个 span 的耗时包含什么:

kind谁创建时间范围包含典型误用
SERVER服务端从收到请求到发出响应(含排队,取决于埋点位置)把整个 handler 包括等待下游都算成「服务处理」
CLIENT调用方从发起调用到收到响应(含网络、重试等待)把重试的多次尝试塞进一个 CLIENT span
PRODUCER消息生产者从发送到 broker 确认把「业务写库」也算进 PRODUCER
CONSUMER消息消费者从收到消息到处理完成批量消费只建一个 span,丢失单条消息的因果
INTERNAL进程内一段本地逻辑(函数、锁、序列化)用它包住跨进程调用,导致 kind 语义混乱

一个常见错误是「一个 span 同时当 CLIENT 和 SERVER」:在网关里既记录收到请求,又记录转发下游,却只创建一个 span。这样 waterfall 上看起来只有一段,无法区分「网关自身处理」和「下游耗时」。正确做法是至少两个 span:网关的 SERVER span 包含一个指向下游的 CLIENT child。

10.2.3 属性、事件与链接

span 除了时间,还携带三类信息:

  • 属性(attributes):键值对,描述这次操作的上下文。常用的是语义约定(semantic conventions)里的字段,例如 http.request.method、http.response.status_code、url.path、server.address、db.system、messaging.system。字段名会随 semconv 版本演进,落地时以 SDK 版本为准。
  • 事件(events):带时间戳的离散事件,例如 retry.scheduled、exception、cache.miss。事件适合记录「在 span 内发生了一次值得注意的事」,不适合记录大段日志。
  • 链接(links):指向其他 span 的因果引用,但不是父子关系。典型场景是批处理(一个 consumer span 链接到 100 个 producer span)和重试(第 2 次尝试链接到第 1 次)。

重试到底该用 child、event 还是 link?三种都可接受,但语义不同:

方式树形状适合回答代价
同一个 CLIENT span + event一次调用,多个事件「这次逻辑调用重试了几次」看不到每次尝试的独立耗时
每次尝试一个 child span一个父下多个兄弟「哪次尝试慢、打到哪个实例」span 数按尝试次数增长
每次尝试一个独立 trace + link多条 trace,用 link 关联客户端 SDK 无法复用 trace 时的补救后端要支持按 link 查询,成本高

无论选哪种,必须带 attempt 属性。没有它,后端和人都无法区分「两个并发下游调用」和「同一个调用的两次重试」;10.8 会展示这如何把指标算错。

10.2.4 头采样、尾采样与一致性

采样是 trace 成本的第一道闸门。三种常见策略:

策略决策时机决策依据优点缺点
头采样trace 开始时概率、trace-id 哈希、请求属性便宜、实现简单、无需缓存看不到未来的错误/延迟;稀有事件容易丢
尾采样trace 结束后错误、p99、特定属性、租户保留所有错误和慢 tracecollector 要缓存整条 trace;路由要按 trace-id 亲和
自适应采样运行时当前 QPS、错误率、预算在成本和覆盖率之间折中实现复杂;采样率变化会影响同比

头采样最重要的是一致性:同一个 trace 的所有服务必须做同样的决定,否则会出现「A 采了、B 没采」的残缺 trace。做法有两种:

  1. 父级采样(parent-based):如果收到的 traceparent 里 sampled=1,就继续采;如果是 0,就不采。根节点做概率决策。
  2. 按 trace-id 哈希:hash(trace_id) % 100 < rate。这样即使各服务独立决策,只要 rate 相同,结果也一致;rate 变化时按新 rate 重新哈希,仍能保证同一 trace 一致。

OpenTelemetry 的 TraceIDRatioBased 采样器就是按 trace-id 的低位做一致性哈希;ParentBased 则把父级决策放在最外层。生产里通常组合成 ParentBased(TraceIDRatioBased(0.01))。

尾采样的工程约束更硬:

  • 一条 trace 的所有 span 必须到达同一个 collector 实例,否则每个实例只看到一部分,无法判断「这条 trace 是否失败」。多 collector 部署时,要用按 trace-id 路由的负载均衡(例如 OTel Collector 的 loadbalancing exporter)。
  • collector 要在内存里保留 trace 直到决策窗口结束。20,000 req/s × 30 s = 60 万条 trace;每条 8 个 span、500 字节,就是 2.4 GB 的原始数据,还没算对象开销。
  • 长 trace(异步任务、流式处理)可能几分钟都不结束;决策窗口必须有上限,超时后按「未完成」处理,否则内存会被拖垮。
  • 尾采样只能决定「存不存」,不能决定「采不采」:所有 span 已经产生并传输到 collector,网络和 collector 的 CPU 成本已经付了。

一个常见的折中是「头采样 + 尾采样」两级:入口按 10% 头采样,collector 再对这 10% 做尾采样,保留其中所有错误和慢 trace。这样传输成本降了一个数量级,错误覆盖率仍然很高。代价是「本来就没被头采样选中的错误」永远看不到;所以错误率极低的系统,头采样比例不能太低。

采样是预算分配,不是随机丢弃

把采样率想成「你愿意为每一类问题付多少钱」。错误和慢请求是信息量最高的样本,应该给更高的保留率;健康请求的信息量低,可以大量丢弃。头采样的问题是它在「还不知道是不是错误」时就做了决定,所以它只能按先验概率分配预算;尾采样把决定推迟到信息出现之后,代价是缓存和路由。

10.2.5 时钟、异步与断链

trace 的时间轴假设「所有 span 的时钟可比」。这个假设在跨主机时不成立:

  • NTP 可能让两台机器差几十毫秒,甚至瞬间倒退;
  • 容器迁移后时钟源不同;
  • 同一台机器上,不同语言的 monotonic clock 起点不同。

因此,不要用两个 span 的绝对开始时间推断因果,父子关系由 parent_span_id 决定,不由时间决定。waterfall 上如果 child 比 parent 早开始,先怀疑时钟,不要怀疑代码。

异步是另一个断链来源。一个请求把任务丢进线程池/队列后立即返回,父 span 结束;任务在另一个线程里执行时,context 没有传过去,子 span 要么没有 parent,要么挂在「当前活跃 span」上(可能是另一个请求)。修复方式:

  • 线程池/协程:显式传递 context(Python 的 contextvars、Go 的 context.Context、Java 的 ThreadLocal + 包装器);
  • 消息队列:把 traceparent 放进消息头,消费端 extract 后创建 CONSUMER span;
  • 批处理:一条消息一个 link,不要只建一个「批次 span」;
  • 定时任务:每次执行创建新的 root trace,不要复用上一次的 context。

10.3 指标:RED/USE、直方图、分位数

指标的数据模型是「名字 + 标签 + 时间戳 + 数值」。它便宜、可长期保留、适合聚合和告警,但也最容易被误用:一个 avg 可能掩盖长尾,一个 sum 可能把重试算成新流量,一个 histogram_quantile 可能因为 bucket 设计而偏差几十个百分点。

10.3.1 四种类型与它们的语义

类型语义例子常见错误
counter单调递增的累计值http_requests_total对 counter 求平均;忘记 rate 处理重置
gauge可增可减的瞬时值queue_depth、inflight_requests对 gauge 用 rate;把采样值当总量
histogram按 bucket 累计的分布request_duration_secondsbucket 设计不合理;对分位数求平均
summary客户端计算的分位数request_duration_seconds{quantile="0.99"}跨实例聚合;无法合并

Prometheus 生态里,histogram 会展开成三组 series:

http_request_duration_seconds_bucket{le="0.05"} 900
http_request_duration_seconds_bucket{le="0.1"}  990
http_request_duration_seconds_bucket{le="0.25"} 999
http_request_duration_seconds_bucket{le="0.5"}  1000
http_request_duration_seconds_bucket{le="+Inf"} 1000
http_request_duration_seconds_sum               84.2
http_request_duration_seconds_count             1000

le 是「小于等于」,bucket 是累计的;+Inf 必须存在,且等于 _count。histogram_quantile(0.99, ...) 会在第 99 百分位落在的 bucket 内做线性插值:上例中 99% 分位在第 990 个请求,正好落在 le="0.1" 的边界,估计值是 100 ms;如果真实分布集中在 0.1–0.25 s 的左侧,插值可能高估或低估。

bucket 的设计要围绕 SLO 阈值,而不是围绕「看起来均匀」:

  • 如果 SLO 是「p99 ≤ 300 ms」,至少要有 0.1、0.25、0.3、0.5、1 这些边界;
  • 只在 0.1 和 1 两个 bucket 上算 p99,误差可能接近一个数量级;
  • bucket 越多,series 越多,成本越高;通常 10–20 个 bucket 足够。

10.3.2 RED 与 USE:服务看请求,资源看饱和度

RED 和 USE 是两套互补的检查表,不是两种产品:

框架维度适用对象回答
REDRate、Errors、Duration请求驱动的服务/接口流量多少、失败多少、慢不慢
USEUtilization、Saturation、Errors资源(CPU、内存、磁盘、连接池、队列)资源是否忙、是否排队、是否出错

一个 checkout 服务的 RED 表:

指标查询正常范围异常时先看
Ratesum(rate(http_server_requests_total{service="checkout"}[5m]))与上游 QPS 同量级突增/突降、重试放大
Errorssum(rate(...{status=~"5.."}[5m])) / sum(rate(...[5m]))< 0.5%按路由/依赖/版本拆
Durationhistogram_quantile(0.99, sum by (le) (rate(..._bucket[5m])))p99 < 300 ms按 span/下游拆

USE 用于资源:

资源UtilizationSaturationErrors
CPU1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m]))node_load1 / count(cpu)throttling、steal
内存1 - MemAvailable/MemTotalpage cache 回收、OOM killOOM、分配失败
连接池pool_in_use / pool_sizepool_wait_seconds p99获取超时、连接重置
队列queue_depth / queue_capacity入队等待时间拒绝、丢弃
磁盘1 - free/totaliowait、写放大只读挂载、IO 错误

RED 的盲区是「资源饱和度」:请求成功率 100%、p99 200 ms,但连接池已经 95%,下一次流量突增就会排队。USE 的盲区是「业务结果」:CPU 只有 30%,但所有请求都返回了错误的业务码。两套一起看,才既知道「用户是否满意」,也知道「系统还有多少余量」。

10.3.3 分位数不能平均:一个反例

假设两个实例在同一个 5 分钟窗口内:

实例请求数p99
A995100 ms
B51 s

把两个 p99 平均,得到 550 ms。但合并后的真实 p99 是多少?总共 1000 个请求,第 990 个(99% 分位)落在 A 的 995 个请求里;B 的 5 个慢请求只占 0.5%,不足以把第 990 个请求推到 1 s。合并后的 p99 仍然接近 100 ms。平均 p99 既不是合并 p99,也不是任何一个实例的 p99;它只是一个没有统计意义的数。

反过来,如果慢请求占 2%(例如 A 有 20 个请求、全部 1 s,B 有 980 个请求、p99 100 ms),平均 p99 仍是 550 ms,但合并 p99 会落在 1 s 一侧。偏差方向取决于流量和尾部占比,不能靠「取最大」或「取平均」修正。

正确做法只有一种:先把原始 bucket 合并,再算分位数。

# 正确:先 sum bucket,再 histogram_quantile
histogram_quantile(0.99,
  sum by (le) (
    rate(http_server_request_duration_seconds_bucket{service="checkout"}[5m])
  )
)

# 错误:对每个实例算完再平均
avg(
  histogram_quantile(0.99,
    sum by (le, instance) (
      rate(http_server_request_duration_seconds_bucket{service="checkout"}[5m])
    )
  )
)

第二条查询在语法上能跑,在统计上没有意义。它把「分位数」当成了可加的标量;分位数不是线性算子,q(p,A∪B)≠q(p,A)+q(p,B)2q(p, A \cup B) \neq \frac{q(p,A)+q(p,B)}{2}。

同样的错误也发生在时间维度:把周一的 p99 和周二的 p99 平均,不等于两天的合并 p99;如果周一的流量是周二的 10 倍,合并 p99 会更接近周一。要看趋势,应该画每天的 p99 曲线,而不是把曲线平均成一条。

summary 类型为什么不能跨实例聚合

summary 在客户端计算分位数,上报的是 {quantile="0.99"} 这样的瞬时值。两个实例的 p99 无法合并,因为你不知道它们各自的分布形状;Prometheus 也明确不建议对 summary 做聚合。需要跨实例分位数时,用 histogram,或者用支持合并的 sketch(如 DDSketch)。

10.3.4 exemplar:从分位数跳到那一条 trace

histogram 的另一个能力是 exemplar:在 bucket 上附带一个样本的 trace_id,让你从「p99 是 1.9 s」直接跳到「这是一条 1.9 s 的 trace」。OpenMetrics 的 exemplar 形如:

http_server_request_duration_seconds_bucket{le="2"} 17 # {trace_id="4bf92f3577b34da6a3ce929d0e0e4736"} 1.912 1710000000.123

exemplar 的采样率通常很低(例如每 100 个请求保留一个),否则它自己就会变成高基数数据。它的价值在于「桥接」:指标负责发现异常,exemplar 负责把异常落到一条具体 trace,trace 再落到日志。没有 exemplar 时,你只能按时间窗口去 trace 后端里碰运气。

10.3.5 基数:指标成本的第一性原理

一个指标的 series 数量是 label 取值的笛卡尔积。http_requests_total{service, route, method, status} 在 40 个服务、200 个路由、5 个方法、20 个状态码下是 80 万条 series;如果再加一个 tenant_id(1 万个租户),就是 80 亿条,任何 TSDB 都会崩。

控制基数的手段:

  • 规范化路由:/users/123/orders/456 记为 /users/{id}/orders/{id},不要把原始路径当 label;
  • 状态码分桶:2xx/3xx/4xx/5xx,或者只保留业务关心的几个码;
  • 避免 ID 类 label:user_id、request_id、trace_id、order_id 一律不进 metrics;
  • 限制枚举:错误码、租户等级、部署版本用受控枚举,不要用自由文本;
  • 定期审计:按指标名统计 series 数,找出异常增长的指标。
# 每个指标名的活跃 series 数,按从大到小排
topk(20, count by (__name__) ({__name__=~".+"}))

如果某个指标的 series 数在一天内从 1 万涨到 100 万,通常不是流量涨了 100 倍,而是某个 label 混进了高基数维度(例如把 error_message 当 label)。这类问题要在上线前用 cardinality 估算挡住,而不是等 Prometheus OOM 后再查。

10.3.6 抓取、rate 与重置

Prometheus 每 15 s 抓一次,rate(metric[5m]) 用窗口首尾样本估算速率。窗口内至少要有两个样本;counter 重置会被当作重置处理,但跨重置的 increase 会低估;rate 窗口至少是抓取间隔的 4 倍。告警用 5 m 平滑、排障用 1 m 看尖峰,两者可以共存,但要明确每个面板的窗口语义。

10.4 日志:结构化、关联、成本

日志是最古老的可观测性手段,也是最容易被写成「字符串拼接」的手段。一条好的日志不是「给人读的句子」,而是「带上下文的事件」:它有稳定的字段名、明确的级别、可关联的 trace/span,以及可计算的成本。

10.4.1 一条日志应该有哪些字段

字段必选示例用途
ts是2026-10-19T02:14:03.114Z排序、窗口聚合;统一 UTC
level是INFO/WARN/ERROR过滤;但不要用它表达业务影响
event是charge.settled稳定的事件名,便于聚合
service是checkout多服务混合查询
trace_id / span_id是4bf9... / 00f0...关联 trace
request_id建议req-9f2a没有 trace 时的兜底
idempotency_key有副作用时pay-o-8812归并重试、对账
attempt有重试时2区分物理尝试
duration_ms建议183单条日志的耗时
outcome建议ok/timeout/rejected受控枚举,便于统计
error.code出错时PSP_TIMEOUT机器可读的错误分类
peer建议10.2.7.11:8443判断是否总打到坏实例

字段名要稳定,不要在同一个服务里混用 traceId、trace_id、tid;否则查询时要写三个正则,告警也会漏。字段值要受控:outcome 用枚举,error.code 用错误码表,不要把异常堆栈当 error.code。

10.4.2 结构化日志的最小实现

Python 标准库的 logging 加上 contextvars 和 JSON formatter,就能得到可关联的结构化日志:

import contextvars
import json
import logging
import sys
import time

_trace_id = contextvars.ContextVar("trace_id", default=None)
_span_id = contextvars.ContextVar("span_id", default=None)

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created))
                  + f".{int(record.msecs):03d}Z",
            "level": record.levelname,
            "event": getattr(record, "event", record.getMessage()),
            "service": "checkout",
            "trace_id": _trace_id.get(),
            "span_id": _span_id.get(),
        }
        for key in ("request_id", "idempotency_key", "attempt",
                    "duration_ms", "outcome", "error_code", "peer"):
            value = getattr(record, key, None)
            if value is not None:
                payload[key] = value
        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)
        return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
log = logging.getLogger("checkout")
log.addHandler(handler)
log.setLevel(logging.INFO)

def handle_charge(req):
    token = _trace_id.set(req.trace_id)
    try:
        log.info("charge.started", extra={"event": "charge.started",
                                          "idempotency_key": req.key,
                                          "attempt": req.attempt})
        result = psp.charge(req)
        log.info("charge.settled", extra={"event": "charge.settled",
                                          "idempotency_key": req.key,
                                          "attempt": req.attempt,
                                          "duration_ms": result.ms,
                                          "outcome": "ok"})
        return result
    finally:
        _trace_id.reset(token)

这段代码有三个关键点:

  1. trace_id 放在 contextvars 里,异步/线程池只要复制 context 就能自动带上;不要在每个函数参数里手动传。
  2. event 是稳定的事件名,extra 里的字段是结构化字段;不要把整个字典 json.dumps 塞进 message,那样查询时还要二次解析。
  3. exception 单独放,不要把堆栈拼进 message;日志后端可以对 exception 做折叠和聚合。

10.4.3 关联:日志、trace、指标的三角

一条日志能回答「发生了什么」,但要和 trace 关联,必须满足两个条件:

  • 日志里有 trace_id 和 span_id,且和 trace 后端的 ID 格式一致(都是 32/16 位小写 hex);
  • 记录日志的位置在对应的 span 作用域内,而不是在异步回调里丢了 context。

常见的关联查询:

# 从错误日志拿到 trace_id
jq -r 'select(.level=="ERROR" and .event=="charge.failed") | .trace_id' /var/log/checkout/app.log \
  | tail -n 1

反过来,从指标跳到日志,靠的是 exemplar 或时间窗口 + 维度过滤;从 trace 跳到日志,靠的是 trace_id。三者之间的 ID 必须统一,否则「关联」只是口头上的。

10.4.4 成本:日志是按字节计费的

日志成本由三部分构成:摄入(ingest)、索引(index)、保留(retention)。摄入通常按 GB 计费,索引可能按字段数或查询量计费,保留按 GB·月计费。一个每天 432 GB 的服务,30 天保留就是 12.96 TB;即使压缩后打三折,仍然是数 TB。

控制成本的手段不是「少打日志」,而是「把日志分成不同的层」:

层内容保留采样例子
热错误、审计、支付30–90 天,可查100%charge.failed、refund.created
温请求摘要、状态迁移7–30 天10%–100%http.request、call.state
冷调试、详细参数1–7 天或关闭1% 或按需开启cache.lookup、payload.debug

采样要按「事件类型」而不是「随机行」:错误和审计永远 100%,调试日志可以按 trace 采样(保留整条 trace 的日志,而不是随机丢行)。随机丢行会让一条请求的日志残缺,反而更难查。

日志里最容易忽略的成本是重复:同一个异常在每一层都打一遍,一条请求产生 10 条几乎相同的 ERROR;或者把整个请求体打进日志,一条 100 KB。修复方式:

  • 只在异常第一次被捕获的地方打 ERROR,上层打 WARN 或只记录「已处理」;
  • 请求体只记录指纹、长度、关键字段,不记录全文;
  • 用 error.code 聚合,而不是让每条日志都带完整堆栈;
  • 对高频错误做限流(例如同一 error.code 每分钟最多 N 条),但保留计数指标。

日志的定位是「解释细节」,不是「承载所有信号」。能用指标回答的「多少/趋势」,不要用日志;能用 trace 回答的「哪一段」,不要用日志;日志负责回答「具体是什么」。

下一节把这些信号接进 OpenTelemetry 的流水线:SDK 怎么产生、collector 怎么缓冲和转发、哪些环节会悄悄丢数据。

10.5 OpenTelemetry 与 collector

OpenTelemetry(OTel)不是某个后端,而是一套 API、SDK、协议和 collector 的规范。它的价值在于把「产生 telemetry」和「把 telemetry 送到哪」解耦:应用只依赖 API/SDK,后端可以换;collector 负责缓冲、批处理、脱敏、采样和路由。

10.5.1 三层结构:API、SDK、collector

层作用什么时候用常见错误
API定义 tracer/meter/logger 接口库代码只依赖 API在库里直接初始化 SDK,导致版本冲突
SDK实现采样、批处理、导出应用启动时初始化一次每个请求新建 provider;同步导出
collector接收、处理、转发 OTLP多服务、多后端、需要尾采样把 collector 当「可选组件」,应用直连后端

OTLP 是 OTel 的原生协议,gRPC 默认端口 4317,HTTP 默认端口 4318。应用可以把 span 直接发给后端,但在生产里通常至少经过一层 collector,原因不是「架构好看」,而是:

  • 缓冲:后端短暂不可用时,collector 可以重试,应用不必阻塞;
  • 批处理:把大量小 span 合并成批次,降低网络和后端压力;
  • 脱敏/过滤:在离开信任域前删除敏感属性;
  • 尾采样:只有 collector 能看到完整 trace;
  • 多后端:同一份数据可以同时送监控、归档和审计,不必改应用。

10.5.2 一个可用的 collector 配置

下面是一个最小但完整的 collector 配置,包含接收、内存保护、批处理、脱敏和导出:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
    spike_limit_mib: 128
  attributes/scrub:
    actions:
      - key: http.request.header.authorization
        action: delete
      - key: db.statement.parameters
        action: delete
  batch:
    timeout: 5s
    send_batch_size: 1024
    send_batch_max_size: 2048

exporters:
  otlp:
    endpoint: otel-gateway:4317
    tls:
      insecure: true
    retry_on_failure:
      enabled: true
      initial_interval: 1s
      max_interval: 30s
    sending_queue:
      enabled: true
      queue_size: 5000

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, attributes/scrub, batch]
      exporters: [otlp]

这份配置里有四个关键参数:

  • memory_limiter 必须在 batch 之前,否则 batch 会先把内存吃满;limit_mib 要小于容器的 memory limit,留出余量给运行时。
  • batch.timeout 是「最长等多久」,send_batch_size 是「攒够多少就发」;两者共同决定延迟和吞吐。
  • retry_on_failure 的重试是有界的;如果后端长时间不可用,队列会满,新数据会被丢弃。必须监控 otelcol_exporter_send_failed_spans 和队列长度。
  • sending_queue 是内存队列,不是持久化队列;collector 重启会丢队列里的数据。对审计级数据,要么用持久化队列(如 Kafka),要么在应用侧落盘。

10.5.3 不要阻塞业务:批处理与丢弃策略

telemetry 的导出绝不能阻塞请求路径。一个常见的反模式是在 handler 里同步调用 exporter:

# 错误:每个请求都同步导出,后端慢时业务一起慢
with tracer.start_as_current_span("charge"):
    result = psp.charge(req)
    exporter.export(spans)      # 网络 I/O 在请求路径上

正确做法是 BatchSpanProcessor:span 结束后进入有界队列,后台线程按批次导出。队列满时的行为必须是「丢弃并计数」,而不是「阻塞等待」:

from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

provider = TracerProvider(
    resource=Resource.create({"service.name": "checkout", "deployment.environment": "prod"})
)
provider.add_span_processor(
    BatchSpanProcessor(
        OTLPSpanExporter(endpoint="http://127.0.0.1:4317", insecure=True),
        max_queue_size=2048,
        schedule_delay_millis=5000,
        max_export_batch_size=512,
        export_timeout_millis=30000,
    )
)
trace.set_tracer_provider(provider)

max_queue_size=2048 意味着最多缓存 2048 个 span;队列满后 SDK 会丢弃新 span 并增加 dropped_spans 计数。这个计数必须进 dashboard:丢弃 telemetry 是故障,不是静默优化。

10.5.4 尾采样与 collector 的路由

如果要用尾采样,collector 的部署方式会反过来影响架构:

  • 所有 span 必须按 trace_id 路由到同一个 collector 实例;否则每个实例只看到部分 trace,无法判断错误。
  • 常见做法是「agent 层不做尾采样,只做批处理和脱敏;gateway 层用 loadbalancing exporter 按 trace-id 路由,再做尾采样」。
  • gateway 需要足够的稳定内存;扩容/缩容会触发 trace 重新分布,正在决策窗口内的 trace 可能被拆到两个实例,导致决策不一致。
  • 尾采样的策略要可观测:保留率、丢弃原因、各策略命中的 trace 数,都要有指标。

尾采样策略通常按「错误全留、慢 trace 全留、其余按比例」组合。decision_wait 决定等多久,num_traces 决定内存里最多保留多少条 trace;两者相乘就是内存上界,必须按 collector 的预算调。

10.5.5 collector 自身的可观测性

collector 是 telemetry 的「数据面」,它自己也会故障。必须监控:

指标含义异常时
otelcol_receiver_refused_spans接收端拒绝的 span 数队列满/限流;扩容或降采样
otelcol_processor_dropped_spansprocessor 丢弃的 span 数内存保护/策略丢弃;查原因
otelcol_exporter_send_failed_spans导出失败数后端不可用/认证失败;查重试与队列
otelcol_exporter_queue_size发送队列长度持续接近上限;扩容或降载
otelcol_process_runtime_heap_alloc_bytescollector 堆内存接近 limit;查尾采样/大属性

一个常见事故是「collector 内存打满 → OOM → 所有服务看起来正常,但 trace 全没了」。如果没有 collector 的自监控,你会以为「没有慢请求」,实际上是「没有数据」。

自动埋点的边界

自动埋点(agent/instrumentation library)能覆盖 HTTP、数据库、消息队列等标准库,但覆盖不了业务语义:哪个是「下单」、哪个是「扣款」、哪个失败应该算业务错误。自动埋点给你骨架,手动埋点给你关键分支;两者都要有,不能只靠 agent。

10.6 SLO、错误预算与告警

凌晨的 p99 告警有一个老问题:它告诉你「慢了」,但不告诉你「用户是否受影响、要不要现在起床」。SLO 的作用是把「健康」从一个形容词变成一个可计算的比率,再把比率变成可消耗的预算。

定义 10.6.1 · SLI、SLO、错误预算与 burn rate

SLI(service level indicator)是一次可计算的测量:good events / valid events。SLO 是这个比率在某个窗口内要达到的目标,例如「过去 28 天,99.9% 的支付请求成功」。错误预算(error budget)是 1−SLO1 - \text{SLO},即允许的失败比例。burn rate 是当前错误率相对于预算消耗速率的倍数:

burn rate=1−SLI1−SLO \text{burn rate} = \frac{1 - \text{SLI}}{1 - \text{SLO}}

burn rate = 1 表示按当前速度刚好在窗口结束时用完预算;burn rate = 14.4 表示 1 小时就用掉 2% 的月度预算。

SLI 的定义比阈值更重要。以 checkout 为例:

SLIgood eventsvalid events说明
可用性返回 2xx/3xx 且业务成功的请求排除健康检查、用户主动取消5xx 和业务失败都算坏
延迟duration <= 300 ms 的请求所有成功受理的请求阈值来自用户感知,不是 p99 现状
新鲜度now - data_ts <= 60 s 的查询所有查询适用于缓存/数仓/Feed

30 天窗口下,不同 SLO 允许的不可用时间:

SLO每月预算直观感受
99.5%3 小时 36 分允许一次小事故
99.9%43 分 12 秒大多数团队的现实目标
99.95%21 分 36 秒需要冗余和快速切换
99.99%4 分 19 秒多副本 + 自动故障转移 + 演练

burn rate 告警要用多窗口:长窗口确认「真的在烧」,短窗口确认「现在还在烧」,两者同时满足才 page。否则一次 5 分钟的抖动会把已经恢复的系统再次叫醒。

长窗口短窗口burn rate 阈值消耗的月预算动作
1 h5 m14.42%page
6 h30 m65%page
3 d6 h110%ticket

PromQL 写法(假设 recording rule 已经产出 sli:checkout_availability:ratio_rate5m 等比率):

# 可用性 SLI:5 分钟窗口
sum(rate(http_server_requests_total{service="checkout", status!~"5.."}[5m]))
/
sum(rate(http_server_requests_total{service="checkout"}[5m]))

# 1 小时 burn rate(SLO = 99.9%)
(1 - sli:checkout_availability:ratio_rate1h) / (1 - 0.999)

# 多窗口告警:短窗口和长窗口同时超阈值
(
  (1 - sli:checkout_availability:ratio_rate5m) / (1 - 0.999) > 14.4
)
and
(
  (1 - sli:checkout_availability:ratio_rate1h) / (1 - 0.999) > 14.4
)

一条正常的输出:

{service="checkout"} 18.2

18.2 意味着按当前错误率,大约 24 分钟就会烧掉 1% 的月度预算;如果持续 1 小时,就是 2.5%。这时 page 是合理的;如果 burn rate 只有 1.2,应该开 ticket,而不是半夜叫人。

告警要区分「症状」和「原因」。

page 只给症状(SLO burn、用户可见失败、大面积超时),每条都要有 runbook、dashboard 和负责团队;ticket 给原因(磁盘 80%、证书到期、副本 lag),这些问题有时间处理。不要给每个 5xx 都配告警,用 SLO burn 聚合决定「是否值得叫人」。

错误预算不是「用完了就惩罚团队」,而是一个预先约定的决策规则:预算充足时加快发布,预算耗尽时冻结非必要变更、优先修复可靠性。没有这条规则,SLO 就只是墙上的数字。

10.7 回放:确定性重放、影子流量、事件溯源

排障的最后一步是「能不能复现」。可观测性数据能告诉你「发生了什么」,但不一定能让你「再发生一次」;回放(replay)就是把记录下来的输入、依赖响应和环境,在一个受控的地方重新执行。回放不是「把流量再打一遍」这么简单:它要么追求确定性,要么追求真实感,两者对隔离的要求不同。

回放方式输入追求主要风险
确定性重放录制的请求 + 依赖响应 + 种子同一输入产生同一输出非确定性来源没录全;副作用打到真实依赖
影子流量实时/近实时的生产请求副本真实分布、真实时序写请求打到生产;放大下游负载
事件溯源重放事件日志(offset/时间范围)重建状态、回填投影副作用重复(邮件、扣款);顺序/版本问题

10.7.1 确定性重放:先消灭非确定性

要让「同样的输入得到同样的输出」,必须记录所有非确定性来源:

  • 时钟:用虚拟时钟替换 now(),回放时按录制的时间戳推进;
  • 随机数/UUID:记录种子,或直接录制生成的值;
  • 外部依赖:把下游响应(包括错误和超时)录下来,回放时按请求匹配返回;
  • 并发/调度:记录关键事件的顺序;完全确定的多线程回放很难,通常退而求其次,只保证「业务结果一致」;
  • 配置/版本:记录代码版本、配置快照、feature flag 状态,否则回放的是另一套系统。

录制的粒度决定了回放的保真度。只录 HTTP 请求,回放时下游仍然真实调用,那只是「重发流量」,不是「确定性重放」;要复现一个超时引发的重试链,必须把那次超时也录下来。

10.7.2 影子流量:复制请求,不复制副作用

影子流量(shadow traffic / traffic mirroring)在入口代理处复制请求,发给新版本服务,响应被丢弃,用户不受影响。它适合验证「新版本能否处理真实流量」,但不适合验证「写路径」——除非写被隔离。

三个必须做的隔离:

  1. 写隔离:新版本连只读副本、独立的影子库,或把写操作改成 dry-run;绝不能连生产主库。
  2. 下游隔离:支付、邮件、短信、推送这些副作用,必须指向沙箱或 mock;否则「影子」只是「另一个生产」。
  3. 负载隔离:镜像流量按比例采样(例如 1%–5%),并限速;新版本故障时不能把下游拖垮。

一个常见的错误是「只丢弃响应,就算影子」。响应是丢弃了,但请求已经写进了数据库、已经调用了支付;用户看到的「没变化」,只是因为读的还是旧库。

10.7.3 事件溯源重放:重建状态,不是重发副作用

事件溯源系统把每次状态变更记成不可变事件;重放事件日志就能重建任意时刻的状态。它天然适合回放,但有两个陷阱:

  • 副作用在投影之外:订单投影重放时,如果投影里调用了「发确认邮件」,重放 100 万条订单就会发 100 万封邮件。副作用必须从投影中剥离,或者由幂等的下游消费。
  • 事件 schema 演进:三年前的事件可能缺少今天的必填字段;重放代码必须能处理旧版本,或者先做 upcast(把旧事件转换成新 schema)。

重放的顺序也要注意:同一分区内有序,跨分区不保证;如果投影依赖跨分区顺序,重放结果可能和线上不同。重放前先定义「一致」的判据:是最终状态一致,还是中间某个检查点一致?

10.7.4 隔离的代码边界

回放安全不能靠「操作的人小心」,要靠代码里的效果边界(effect boundary)。所有副作用——扣款、发券、发邮件、写外部系统——都必须经过一个网关,网关根据运行模式决定「执行、跳过还是打到沙箱」:

class EffectGateway:
    def __init__(self, mode: str, run_id: str):
        if mode not in {"live", "sandbox", "dry_run"}:
            raise ValueError(mode)
        self.mode = mode
        self.run_id = run_id

    def charge(self, key: str, amount_cents: int):
        if self.mode == "dry_run":
            return {"status": "skipped", "key": key}
        if self.mode == "sandbox":
            # 沙箱键加命名空间,避免与生产去重表碰撞
            return psp_sandbox.charge(f"sandbox:{self.run_id}:{key}", amount_cents)
        # live 只用于修复/回填:必须复用原键,让下游去重
        return psp.charge(key, amount_cents)

三种模式对应三种目的:

模式用途下游键
dry_run验证逻辑、压测不调用不生成
sandbox端到端联调沙箱加 run_id 命名空间
live修复/回填生产复用原键,绝不生成新键

live 模式是最危险的:它看起来像「正常执行」,但输入的是一段历史流量。唯一安全的做法是复用原始 idempotency_key,让下游把重复请求识别为同一逻辑请求,并确认键仍在去重窗口内;如果原始请求没有键,就不能用 live 回放,只能走 sandbox 或人工核对。

回放前的五个问题
  1. 这次回放是为了「验证行为」还是「修复状态」?两者的隔离要求不同。
  2. 输入里的非确定性来源(时钟、随机、外部响应)录全了吗?
  3. 副作用边界在哪里?dry-run 开关是否能覆盖所有写路径?
  4. 回放会打到哪里:生产库、影子库、沙箱,还是 mock?
  5. 失败时的 kill switch 是什么?谁有权按下它?

回放的成本也常被低估:一次全量回放可能消耗数小时的 CPU、打满下游配额、产生大量临时数据。按「采样 1%、限速到生产峰值的 20%、先跑只读路径」的顺序推进,比一次性全量安全得多。

10.8 失败模式:重试导致的重复 span 与错误指标

现在把镜头对准一个具体的失败:一次用户操作,在 trace 里变成三棵树,在指标里变成三次请求,在 SLO 里变成一次故障。 这不是 instrumentation 的边角问题,而是重试与可观测性叠加后最常见的失真。

10.8.1 一条时间线

时间客户端服务端trace/指标
t0用户点击,创建 trace T1,发送 attempt 1收到,开始处理T1 有一个 root span
t11 s 超时,SDK 重试;新起了一个 root trace T2仍在处理 attempt 1T1 结束(timeout),T2 开始
t2T2 也超时,再重试 T3attempt 1 成功但响应慢T2 timeout;T3 开始
t3T3 成功,用户看到成功返回结果后端看到 3 个 root、3 次请求、2 次「失败」

如果 SDK 复用了 trace id,形状会变成「一个 root + 三个 CLIENT child」;如果每次重试都新起 trace,就是「三棵独立的树」。两种形状在不同后端里都常见,但都会导致同一个问题:指标把物理尝试当成了逻辑请求。

Trace 瀑布:一次请求的 span 与重复 span 根 span 1 s;下游 A 240 ms,B 380 ms;一次重试产生第二个 B span,总耗时被拉长。 Trace 瀑布:一次请求的 span 与重复 span 0 ms 1000 ms root POST /tool edge auth/limit A rpc B rpc (attempt=1) B rpc (attempt=2) DB commit 重复 span 必须带 attempt/retry 标记,否则会被当成两次独立调用。
读图:trace 的价值是显示「时间花在哪」,不是「有没有报错」。重复 span 如果不标 attempt,会被误读成两个独立下游调用;指标也会把重试算成额外流量。

图里的 B rpc (attempt=1) 和 B rpc (attempt=2) 是同一个逻辑调用的两次尝试。如果它们没有 attempt 标记,读图的人会以为 B 被调用了两次;如果指标按 span 数或请求数计数,B 的 QPS 也会被算成两倍。

10.8.2 重复 span 的四种来源

来源现象怎么发现修复
SDK 重试新起 trace多个 root,时间上首尾相接同一 idempotency_key 对应多个 trace_id复用 trace context;至少带 logical_request_id
自动 + 手动埋点重复同一操作两个重叠的 SERVER span同 span kind、同 operation、时间重叠关闭重复的自动埋点,或给手动 span 改名为 INTERNAL
collector 至少一次导出同一 (trace_id, span_id) 出现两次后端按 span id 去重后数量下降后端幂等写入;不要靠「不会重复」的假设
异步 context 丢失孤儿 span 或挂错父节点parent 为空/指向无关请求显式传递 context;消息头带 traceparent

前两种是「产生了多余的 span」,第三种是「同一 span 被送了两次」,第四种是「span 的父子关系错了」。它们的修复方式不同,但都会让「span 数 = 请求数」这个假设失效。

10.8.3 指标双算:三个具体的数字

假设一次支付逻辑请求重试 3 次,每次都打到同一个服务:

  • 服务端 RPS:rate(http_server_requests_total[5m]) 会计 3 次,而不是 1 次;如果全站 dashboard 把所有服务的 RPS 相加,这个请求会被计 3 次以上。
  • 错误率:3 次尝试中 2 次超时、1 次成功,按 attempt 算是 66.7% 错误;按用户结果算是 0% 失败。SLO 如果按 attempt 算,会误判为严重故障。
  • 延迟直方图:3 次尝试都进了 histogram,_count 是 3;p99 可能被「重试等待」拉长,而不是被服务处理拉长。

更隐蔽的是跨服务双算:客户端记一次 CLIENT span,服务端记一次 SERVER span;如果 dashboard 用「所有 span 数」当请求量,一次调用就被算两次。正确的「请求量」只有一个来源:入口的 SERVER span,或者网关的访问日志;其他层的计数只能用于诊断,不能相加。

指标错误算法后果正确算法
请求量所有服务 RPS 相加按 hop 数放大入口 SERVER span / 网关日志
错误率按 attempt 算重试导致假故障按逻辑请求的最终结果
p99对实例/时间平均统计无意义合并 bucket 后 histogram_quantile
SLO burn用 attempt 的 SLI预算被重试烧光用逻辑请求的 SLI

10.8.4 修复:让 trace 和指标都说「逻辑请求」的语言

三层修复,缺一不可:

  1. trace 层:重试复用 trace_id;每次尝试是一个带 attempt 的 child span(或 event/link);root span 只创建一次。SDK 不支持复用时,至少把 logical_request_id 放进 span 属性,后端按它聚合。
  2. 指标层:区分 requests_total{phase="logical"} 和 attempts_total;SLO 只消费 logical;attempt 指标用于诊断重试率。
  3. 存储层:后端按 (trace_id, span_id) 幂等写入;采集端按 idempotency_key 去重;对账时用业务键而不是 trace id。

一个最小的 Python 重试埋点,重点是「同一个 trace,不同 attempt」:

from opentelemetry import trace
from opentelemetry.trace import SpanKind, Status, StatusCode

tracer = trace.get_tracer("checkout")

def charge_with_retry(client, req, ctx, attempts=3):
    # root span 在更外层创建;这里只创建每次尝试的 child
    for attempt in range(1, attempts + 1):
        with tracer.start_as_current_span(
            "rpc.charge",
            context=ctx,   # ctx: extract 得到的 opentelemetry.context.Context
            kind=SpanKind.CLIENT,
            attributes={
                "rpc.attempt": attempt,
                "rpc.logical_id": req.key,
                "server.address": "psp.internal",
            },
        ) as span:
            try:
                return client.charge(req)
            except TimeoutError as exc:
                span.set_status(Status(StatusCode.ERROR, "timeout"))
                span.add_event("retry.scheduled", {"attempt": attempt})
                if attempt == attempts:
                    raise

这段代码没有解决「SDK 自己新起 trace」的问题;如果 SDK 不暴露 context,你只能在入口生成 logical_request_id,让后端按它把多棵树拼起来。不要假设所有 SDK 都会正确传播 context,要在灰度环境里用一条真实重试验证。

例 10.8.1 · 一次支付,三棵树,虚高的 burn

某支付服务在大促期间 p99 从 300 ms 涨到 1.2 s,SLO burn rate 告警到 9。值班工程师看 trace,发现同一个 idempotency_key 对应 3 个 root trace:客户端 SDK 每次重试都新建 trace,服务端每次都记一条请求。指标上请求量是真实用户的 2.8 倍,错误率 31%,但用户侧成功率仍有 99.7%。

修复分两步:客户端 SDK 在重试时复用 trace context,服务端把 attempt 写进 span 属性;指标新增 phase="logical" 的计数器,SLO 改为按 logical 计算。修复后同样的流量下,burn rate 回落到 0.4,trace 数量减少 64%。

重复 span 和双算指标不是「数据多一点」,而是改变了你对系统的判断。当观测数据本身被重试放大,你会在错误的地方扩容、在错误的时间回滚、在错误的团队之间传锅。

10.9 观测:用 trace 找 p99 的那一段

这一节只做一件事:给定「checkout 的 p99 是 1.9 s」,用三步找到「这 1.9 s 花在哪」。顺序是「指标定位 span → 日志拿 trace id → trace 看瀑布」。

10.9.1 第一步:指标按 span 名拆 p99

histogram_quantile(0.99,
  sum by (le, span_name) (
    rate(span_duration_seconds_bucket{service_name="checkout"}[5m])
  )
)

一次输出:

{span_name="POST /v1/pay"}   1.92
{span_name="psp.charge"}     1.74
{span_name="cart.get"}       0.05
{span_name="db.commit"}      0.02

root 的 p99 是 1.92 s,psp.charge 的 p99 是 1.74 s,已经占了 90%;cart.get 和 db.commit 都很快。注意这里用 sum by (le, span_name) 先合并所有实例的 bucket,再算分位数;不要对每个实例算完再平均。

10.9.2 第二步:从日志拿 trace id,再用 curl + jq 拉 trace

TRACE_ID=$(jq -r 'select(.event=="http.request" and .duration_ms > 1500) | .trace_id' \
  /var/log/checkout/app.log | tail -n 1)
echo "$TRACE_ID"

curl -sS "http://127.0.0.1:16686/api/traces/${TRACE_ID}" \
  | jq -r '
      .data[0].spans
      | sort_by(.startTime)
      | .[]
      | [.spanID, (.duration / 1000 | floor), .operationName,
         ([.tags[]? | select(.key == "span.kind") | .value][0] // "-")]
      | @tsv
    '

一次输出(单位 ms):

a1b2c3d4	1912	POST /v1/pay	server
e5f6a7b8	21	auth.verify	client
c9d0e1f2	38	cart.get	client
93a4b5c6	1740	psp.charge	client
7e8f9a0b	1705	POST /charge	server
1c2d3e4f	16	db.commit	client

把这张表按父子关系画出来:

POST /v1/pay            server   1912 ms   self 97 ms
├─ auth.verify          client     21 ms
├─ cart.get             client     38 ms
├─ psp.charge           client   1740 ms
│  └─ POST /charge      server   1705 ms   (PSP 侧)
└─ db.commit            client     16 ms

读法:

观察含义下一步
root self time 大本服务排队/序列化/GC,或子调用没埋点看 CPU、GC、线程池、未埋点代码
CLIENT 远大于 SERVER网络、重试等待、客户端排队看 attempt、连接池、peer
只有 CLIENT 没有 SERVER对方没埋点,trace 到边界断了用对方日志/指标补,或推动对方接入
两个同名 span 重叠自动+手动重复埋点关掉一个,或改名区分
子 span 之和 > root并行 span 被重复相加,或时钟偏斜看时间轴,不要直接求和

例子里 psp.charge 的 CLIENT 是 1740 ms,PSP 的 SERVER 是 1705 ms,差 35 ms 是网络和 PSP 入口排队;root 的 self time 只有 97 ms。结论是「PSP 处理慢」,不是「checkout 代码慢」。如果 PSP 没有 SERVER span,你只能看到 1740 ms 的 CLIENT,仍然能判断「时间在外部依赖」,但无法区分「PSP 慢」和「网络慢」;这时要用 PSP 的状态页或独立监控补盲。

读什么先看 root 的 self time,再看最慢 child 的 CLIENT/SERVER 差值;不要先看总耗时。
正常root self time < 20% 总耗时;CLIENT 与 SERVER 差 < 2×RTT(p99);子 span 之和接近 root。
异常同名 span 重叠、子 span 之和大于 root、只有 CLIENT 没有 SERVER,或 p99 与单条 trace 差一个数量级。

10.9.3 第三步:回到指标确认不是个例

一条 trace 只能说明「这一次」。回到指标,按 peer 看 psp.charge 的分布:

histogram_quantile(0.99,
  sum by (le, peer) (
    rate(span_duration_seconds_bucket{service_name="checkout", span_name="psp.charge"}[5m])
  )
)

读法:所有 peer 都慢是整体问题,单个 peer 慢是局部问题;p99 高而 p50 正常是长尾,p50 也高是系统性变慢。

10.10 真实案例:GitLab 2017-01-31 数据库误删与恢复

2017 年 1 月 31 日,GitLab.com 发生了一次数据库误删事故。公开复盘给出的核心事实是:一名工程师在处理复制延迟时,在错误的主库上执行了删除命令,约 300 GB 数据被删;恢复只能回到约 6 小时前的快照,这段时间内的数据丢失。事故没有停留在「一个人手滑」,而是暴露了五道备份/恢复防线同时失效。

时间(UTC)事件
2017-01-31 23:25在错误的主库目录执行删除,约 300 GB 数据被移除
随后停止相关进程,评估恢复路径;发现多套备份不可用
恢复期从可用的快照恢复,快照约 6 小时前
之后公开复盘,说明数据丢失窗口与恢复过程

公开复盘列出的五道防线——逻辑备份(pg_dump)、对象存储备份、LVM 快照、流复制、云盘快照——在需要时都没有提供可用的恢复点。复盘没有把原因归为单点,而是每一道都有自己的缺口;其中 LVM 快照最近一份约 6 小时前,恢复只能回到那个时间点。对本章而言,重点不是记住哪一道先坏,而是这些缺口本来都可以用「最近一次成功」和「恢复演练」暴露出来。

每一道防线都可以变成一个可观测性指标,而不是一句「以后注意」:

# 备份超过 1 小时没有成功,就告警(阈值按 RPO 调整)
time() - backup_last_success_timestamp_seconds{job="postgres-backup"} > 3600
{job="postgres-backup", instance="db1"} 26820

26820 秒约 7.45 小时,已经远超 1 小时的阈值。比「失败计数」更重要的是「最后一次成功的时间」:任务可能一直在跑、一直「成功」,但产出的是空文件或旧版本格式。备份的 SLI 不是「任务退出码为 0」,而是「最近一次成功恢复的时间」和「恢复出的数据时间点」。

还可以用 absent() 做「死人开关」:如果指标本身消失了(exporter 挂了、抓取配置错了),也要告警,而不是安静地认为「没有问题」:

absent(backup_last_success_timestamp_seconds{job="postgres-backup"})

这起事故对本章的四个教训:

  1. 备份是一个系统,不是一个任务。 它的 SLI 是 RPO/RTO 和恢复成功率,不是 cron 的退出码;每季度至少做一次真实恢复演练。
  2. 对「缺失」告警。 错误日志会吵,沉默不会;备份、复制、telemetry 管线都需要 dead man’s switch。
  3. 破坏性操作要有闸门。 生产删除前的二次确认、快照、双人复核、最小权限,比事后补救便宜。
  4. 可观测性本身依赖被观测的系统。 dashboard、trace、日志都可能和数据库一起挂;runbook 要能离线使用,关键告警要有独立通道。
事实边界

本节只引用 GitLab 官方公开复盘中的结论:时间、约 300 GB 删除、约 6 小时数据丢失、五道备份/恢复机制失效。具体的命令、内部拓扑和恢复步骤不复述,也不把责任归因于单个人。官方复盘:Postmortem of database outage of January 31。

10.11 重试导致重复副作用:回放流量没有幂等

回放最危险的形态,是「以为自己在做只读验证,实际上打的是一个写路径」。下面这个案例里,没有人写错业务代码,也没有人故意绕过幂等;问题出在回放工具把「影子流量」理解成了「复制请求」。

10.11.1 一条时间线

某团队要把 checkout 迁移到新版本,决定用生产入口的访问日志做影子流量:复制 90 分钟的请求,发给新版本,丢弃响应,对比新旧版本的返回。新版本部署在生产 namespace,连同一个数据库,因为「这样最真实」。

时间回放工具新版本 checkout下游 PSP结果
t0读取访问日志,重放 POST /v1/pay收到请求,创建订单—开始产生真实订单
t1日志里没有 Idempotency-Key,工具为每条请求生成新 UUID按新键写入,调用 PSP扣款成功第一笔重复扣款
t290 分钟共重放 210 万条请求,其中 0.09% 是支付创建 1,842 条支付扣款 1,842 次用户被重复扣款
t3对比脚本发现「响应不一致」,但为时已晚订单表多出 1,842 条退款流程启动事故

根因有四层,每一层单独看都「有道理」:

  1. 影子流量被当成只读:复制请求不等于复制读请求;POST /v1/pay 本身就是写。
  2. 幂等键在回放时重新生成:访问日志没有记录 Idempotency-Key,工具为了「让请求合法」生成了新 UUID;服务端看到的是全新的逻辑请求。
  3. 环境没有隔离:新版本连的是生产库和 PSP 生产凭证;响应丢弃了,副作用没有丢弃。
  4. 没有 dry-run 闸门:回放工具没有「只读模式」,也没有按路由白名单限制;支付路径被包含在全量回放里。

10.11.2 三种模式与它们的边界

目的模式输入副作用键
验证新版本行为dry_run / 影子环境请求副本不执行,或只写影子库不生成真实键
修复历史状态live 回填原始事件/请求执行,但必须幂等复用原始键
联调sandbox采样请求沙箱下游加 run_id 命名空间

关键区别是:验证行为时,你不需要真实副作用;修复状态时,你需要真实副作用,但必须让下游能识别「这是同一个逻辑请求」。两者都不能「生成新键 + 打生产」。

例 10.11.1 · 重试导致的重复副作用:影子流量里的 1,842 笔扣款

某团队用生产访问日志做 90 分钟影子回放,验证 checkout 新版本。回放工具发现日志里没有幂等键,于是为每条请求生成新 UUID;新版本连生产库和 PSP,响应被丢弃。结果:1,842 笔重复扣款,退款和客服成本远超迁移收益。

修复分四步:

  1. 回放工具增加 --mode=dry-run,默认不调用任何写接口;写路径必须显式 allowlist。
  2. 影子环境使用独立数据库和 PSP 沙箱凭证;生产凭证在回放 namespace 里不可用。
  3. 如果必须打生产做回填,复用原始 Idempotency-Key,并在回放前用只读查询确认该键仍在去重窗口内。
  4. 回放前做 1% 采样 + 限速,先跑只读路径,确认对比指标正常后再扩大。

回放和重试共享同一个教训:「再执行一次」只有在能识别「这是同一个意图」时才安全。重试的键在客户端,回放的键在日志/事件里;两者都不能在「再执行」的那一刻重新生成。

10.12 可观测性设计清单

新增一个关键路径时,先把下面 12 个问题答完。答不上来的,不是「以后补」,而是「现在就没有可观测性」。

#检查项达标线
1每个入口请求有 trace_id/span_id日志、响应头、下游调用都能关联
2每个逻辑请求只有一个 root span重试复用 trace;attempt 有标记
3span kind 正确CLIENT/SERVER 分离,不混用
4采样策略有文档头/尾、比例、错误保留、成本估算
5RED/USE 指标覆盖路由与资源rate/errors/duration + 连接池/队列/CPU
6histogram bucket 围绕 SLO阈值附近有 bucket;分位数从 bucket 合并
7label 基数受控无 user_id/trace_id/原始 URL
8日志字段稳定且脱敏trace_id、event、outcome、error.code
9collector 有内存/批处理/重试丢弃量、队列长度有监控
10SLO 有 SLI、窗口、预算、burn 告警按逻辑请求算,不按 attempt
11告警有 runbook 和负责人page 只给症状,ticket 给原因
12回放有 dry-run/隔离/幂等写路径默认关闭;live 复用原键

这张表的前 4 项决定「能不能查」,中间 5 项决定「查得准不准、贵不贵」,最后 3 项决定「出事时能不能行动」。很多团队只做 trace 和 RED,结果有数据但没有 SLO,有 dashboard 但没有 runbook;出事时仍然靠猜。

10.13 本章陷阱清单

12 条高频错误
  1. 把 trace_id 当请求 ID 或幂等键:trace 是一次调用链的标识,不是业务身份;重试可能换 trace,幂等键必须稳定。
  2. 重试时新起 root trace:同一逻辑请求变成多棵树,指标和 SLO 都被放大;复用 context,至少带 logical_request_id。
  3. 采样率固定 1%,不看错误:稀有错误和慢请求被丢掉;用尾采样或错误全留。
  4. 对分位数求平均:avg(p99) 没有统计意义;先合并 bucket 再 histogram_quantile。
  5. 对 gauge 用 rate:队列深度、inflight 不是 counter;用 avg_over_time 或直接看瞬时值。
  6. 把 user_id/trace_id 放进 metrics label:基数爆炸,TSDB 内存和查询延迟失控;ID 属于 trace/日志。
  7. 日志只打字符串、不带 trace_id:无法与 trace/其他服务关联;结构化字段 + context 注入。
  8. 在请求路径同步导出 telemetry:后端慢会把业务拖慢;用 BatchSpanProcessor + 有界队列。
  9. 按 attempt 算 SLO:重试两次后成功,用户成功但 SLO 算失败;SLO 只看逻辑请求的最终结果。
  10. 备份任务成功就当可恢复:退出码为 0 不等于能恢复;看最近成功恢复时间,定期演练。
  11. 把回放当只读:复制 POST 就是写;没有 dry-run/隔离/原键,回放会重复扣款。
  12. 不监控 collector 自身:collector OOM 后 trace 全丢,dashboard 看起来「一切正常」;监控丢弃量、队列、堆内存。

10.14 本章出口检查

检查项达标线没达标去哪
解释三支柱的盲区每种至少说出两个盲区和一个互补手段10.1
读写 traceparent能生成/解析,指出全零、ff、大小写问题10.2
画 span 树能标出 root self time、CLIENT/SERVER、串行/并行10.2
选择采样策略能根据错误率/成本选头采样、尾采样或两级10.2
写 RED/USE 查询能给出 rate/errors/duration 与资源饱和度查询10.3
正确算分位数能解释为什么不能平均,并写出合并 bucket 的 PromQL10.3
写 SLO 与 burn 告警能算预算、burn rate,并写出多窗口条件10.6
设计安全回放能区分 dry-run/sandbox/live,说明键的处理10.7、10.11
诊断重复 span能说出四种来源和三层修复10.8
用命令找慢 span能独立完成 PromQL → trace id → curl+jq → 读图10.9
复述 GitLab 教训能说出五道防线与「对缺失告警」10.10
即时自测 · 10

Q1 traceparent: 00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01 有什么问题?解析时应该接受还是拒绝?

Q2 实例 A 的 p99 是 100 ms(995 个请求),实例 B 的 p99 是 1 s(5 个请求)。平均 p99 是多少?合并后的 p99 大约是多少?应该怎么写查询?

Q3 一个系统 99.9% SLO,过去 1 小时的错误率是 1.44%。burn rate 是多少?按这个速度,多久烧掉 2% 的月度预算?

Q4 客户端 SDK 每次重试都新建 trace,服务端按请求计数。一次用户支付重试 3 次后成功,指标上的错误率和用户失败率分别是多少?怎么修?

Q5 回放生产访问日志验证新版本,为什么「丢弃响应」不等于「没有副作用」?安全回放有哪几种模式?分别怎么处理幂等键?

Q6 一条 trace 的 root 是 1912 ms,四个 child 分别是 21、38、1740、16 ms。root 的 self time 是多少?如果两个 child 在时间上重叠,直接相加会有什么问题?

答案

A1 trace-id 是大写,不符合「小写十六进制」的规范;严格实现应拒绝或先规范化为小写再接受。其他字段合法:版本 00、parent-id 非全零、flags 01 表示采样。生产上建议「解析时兼容大写、转发时统一小写」,避免跨厂商丢 context。

A2 平均 p99 是 550 ms;合并后的 p99 约 100 ms,因为第 990 个请求落在 A 的 995 个请求内,B 的 5 个慢请求只占全局 0.5%。正确写法是先 sum by (le) 合并 bucket,再 histogram_quantile(0.99, ...);不能对实例的 p99 求平均。

A3 burn rate =1.44%/0.1%=14.4= 1.44\% / 0.1\% = 14.4。1 小时消耗 14.4×1/720=2%14.4 \times 1/720 = 2\% 的月度预算;正好对应「1 h/5 m 窗口、阈值 14.4」的 page 告警。

A4 按 attempt 算,3 次中 2 次超时、1 次成功,错误率 66.7%;按用户结果算,最终成功,失败率 0%。修复:重试复用 trace context,span 带 attempt;指标区分 logical 与 attempt;SLO 按 logical 的最终结果计算。

A5 响应被丢弃只影响调用方看到的结果,请求已经到达服务端并执行了写库、扣款等副作用。安全模式:dry_run 不执行副作用、不生成真实键;live 回填复用原始 idempotency_key,让下游去重;sandbox 打沙箱并给键加 run_id 命名空间。

A6 self time =1912−(21+38+1740+16)=97= 1912 - (21 + 38 + 1740 + 16) = 97 ms。如果 child 在时间上重叠,直接相加会重复计算重叠区间,得到大于 root 的和;应该看时间轴的并集,或只沿关键路径相加。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 5/6 章(运输层、应用层)10.1、10.2背景只借用「请求跨层传输」的视角;不重写 TCP/HTTP
张彦飞《深入理解 Linux 网络》第 4–7 章(连接、协议栈)10.1、10.9背景网络耗时只作为 span 的一段,不展开内核路径
《Python 网络编程》HTTP 客户端、并发与日志10.2、10.4精选借用工程风格;traceparent、结构化日志代码为本书重写
《Linux 系统与内核》第 4 篇4.4–4.11(网络与协议栈)10.1、10.5交叉引用明确不重复 TCP 握手、HTTP 语义、epoll、DNS/TLS
W3C Trace Context、OpenTelemetry 规范traceparent、OTLP、collector10.2、10.5引用只引用格式与组件语义,不复制规范长段
Google SRE WorkbookSLO、错误预算、burn rate10.6引用借用多窗口告警的参数,例子为本书自编
本书自编 + GitLab 公开复盘GitLab 2017-01-3110.10完整只引用公开结论,不复制原文,不归因个人

下一站:知道了「怎么看见系统」,下一步要回答「看见快撑不住时怎么办」。进入 11 容量、限流、降级、故障演练,看 Little’s law、利用率曲线、令牌桶、熔断与爆炸半径。

11 容量、限流、降级、故障演练

本章回答

一台机器到底能扛多少 QPS,为什么这个数会随下游延迟塌缩?为什么利用率刚过 70% 延迟就开始抖,90% 时一次小抖动就能把系统推过临界点?压测为什么经常「测得很漂亮、上线就崩」,开放压测和闭环压测差在哪?令牌桶、漏桶、滑动窗口和分布式限流各自保护谁?熔断、舱壁、降级应该在什么顺序上动作?自动扩缩容为什么救不了秒级尖峰?故障演练的假设怎么写、爆炸半径怎么控、game day 怎么收场?

读完你会

能用 Little's law 把 QPS、并发、延迟换算成容量,并算出含故障域余量的副本数;能解释 1/(1-ρ) 曲线并判断系统处在膝点哪一侧;能设计开放/闭环压测并识别 coordinated omission;能实现令牌桶、熔断器和带白名单的 tc netem 注入脚本;能写出可执行、可回滚、有停止条件的故障演练方案;能说清压测回放为什么会制造真实订单,以及怎样在工具层堵住它。

一份压测报告写着「单实例 2000 QPS,p99 80 ms」。上线后流量刚到 900 QPS,接口开始大面积超时,CPU 却只有 35%。排查后发现:压测时下游是 mock,响应 1 ms;生产里每次请求要查两次数据库,p99 300 ms。线程都卡在等数据库,CPU 自然不高。压测测的不是这台机器,而是「这台机器加它依赖的那条路径」;只测前者,得到的数字在生产里没有意义。

上一章讲的是「看见」:trace、指标、日志、回放,让我们知道系统正在发生什么。这一章讲「撑住」:在流量超过能力之前,用容量规划预留空间,用限流挡住超额请求,用降级保住核心路径,用演练验证这些手段真的会动作。观测和治理是一对:没有观测的降级是盲降,没有降级的观测只是事后记录。 这两个话题经常被拆成两个团队的工作——SRE 做容量,开发做业务。但事故从不按组织边界发生。一次下游变慢会同时触发超时、重试、队列堆积、自动扩容、熔断抖动;如果容量模型、限流阈值、熔断参数、演练假设分别由四个人拍脑袋,它们在事故现场一定互相打架。本章的目标是把它们放进同一套数字里:用 Little’s law 统一 QPS、并发和延迟,用利用率曲线统一容量和排队,用同一条时间线统一重试、限流、熔断和扩容。

路线是这样:11.1 建立容量口径和 Little’s law,11.2 解释利用率曲线和变异性,11.3 讲压测方法(基线、梯度、开放/闭环),11.4 讲限流算法与分布式实现,11.5 讲降级、熔断状态机和舱壁,11.6 讲自动扩缩容的陷阱,11.7 讲故障演练的假设、爆炸半径和 game day。11.8 把前面所有机制放进一条重试风暴时间线;11.9 给出 tc、wrk、ss 的观测方法;11.10 复盘 2021-12-07 AWS us-east-1 故障;11.11 拆解压测回放打到生产造成的重复副作用;最后是清单、陷阱和出口检查。

11.1 容量:Little’s law 与利用率曲线

容量不是一个 QPS 数字,而是一个三元组:在什么延迟目标下、以什么错误率、能承载多少有效请求。同样一台机器,响应 1 KB 和 1 MB 的容量不同,命中缓存和回源的容量不同,下游 10 ms 和 300 ms 的容量更不同。把容量说成「单机 2000 QPS」,就像把一辆车的载重说成「能开 200 公里/小时」,却不说路况和油耗。 先看一个最小模型。一个有 CC 个并发槽位的服务(线程、协程、连接都算),每个请求平均占用槽位 WW 秒。稳定状态下,槽位要么在忙要么空闲,所以每秒能完成的请求数最多是:

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

这就是 Little’s law 的变形。Little’s law 的原式是

L=λW L = \lambda W

其中 LL 是系统内的平均在途请求数,λ\lambda 是到达率,WW 是平均停留时间。它不假设泊松到达,也不假设服务时间分布,只要求系统稳定(长期看进入等于离开)。正因为它假设这么少,才适合做容量估算的第一把尺子。 代入数字。200 个工作线程,每个请求占用线程 80 ms:

λmax⁡=2000.08=2500 req/s \lambda_{\max} = \frac{200}{0.08} = 2500\ \text{req/s}

下游变慢,每个请求占用线程的时间涨到 400 ms,线程数没变:

λmax⁡′=2000.4=500 req/s \lambda_{\max}&#x27; = \frac{200}{0.4} = 500\ \text{req/s}

容量掉了 80%,而 CPU 可能只涨了一点——线程都在等下游,不在计算。容量不是 CPU 决定的,是「并发数 ÷ 延迟」决定的。这也是为什么「加 CPU」经常救不了 IO 密集型服务:瓶颈是并发槽位和被拉长的 WW,不是算力。

并发槽位 C平均停留 W吞吐上限 C/W系统状态
20080 ms2500 req/s健康
200200 ms1000 req/s下游开始变慢
200400 ms500 req/s容量塌缩,队列增长
2001 s200 req/s已过载,排队时间远超处理时间
2004 s50 req/s连接/线程被长期占用,接近不可用

这张表解释了一个常见困惑:「CPU 只有 30%,为什么 QPS 上不去?」因为 WW 变大了,C/WC/W 就小了。第 2.6 节已经用过这个公式定并发上限;这里把它反过来,用目标吞吐和实测延迟算需要多少并发:

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

目标 1200 QPS、p99 250 ms,则 C≈360C \approx 360。用 p99 而不是 p50,是因为 p50 只覆盖一半请求;按 p50 算出来的并发,在高峰期一定排队。安全系数 1.2 是给突发和重试留的,不是给「拍脑袋」留的。

定义 11.1.1 · 容量口径

容量(capacity)是在给定工作负载、给定 SLO(延迟分位与错误率)下,一个系统长期可承载的有效请求速率。它由三个自变量共同决定:请求的资源成本(CPU、内存、连接、IO)、请求的停留时间 WW、以及可用的并发槽位 CC。

容量必须以 goodput 计量:只有满足 SLO 的成功请求才算容量。把 500、超时、重试都算进 QPS,会得到一个漂亮但无法交付的数字。

工作负载必须写清楚,否则容量数字不可比。至少固定六项:

维度为什么影响容量压测时必须固定
请求/响应体积序列化、拷贝、带宽、GC用生产 p50 与 p99 两档
读/写比例写有事务、锁、复制开销按生产比例,不要只压读
缓存命中率未命中会放大下游负载冷缓存、热缓存各测一轮
下游延迟直接进入 WWmock 不够,要接真实依赖或等价延迟
依赖调用次数每请求 1 次和 5 次差 5 倍记录 fan-out
并发模型线程池、协程、连接池的上限不同与生产同配置

11.1.1 Headroom:余量不是浪费

如果按「峰值刚好打满」规划容量,系统会在第一次抖动时过载。Headroom(余量)是刻意留出的空闲能力,用来吸收三件事:流量突发、故障切换、恢复期的冷缓存。

场景目标利用率依据
日常稳态≤ 50%给日峰值、批处理、重试留空间
日峰值≤ 70%越过膝点后延迟非线性上升(11.2)
单故障域失效后≤ 85%剩余节点要接住转移流量
故障恢复期≤ 50%缓存冷、连接重建、队列积压

三可用区、每个区承载 1/3 流量。如果每区稳态 50%,丢一个区后剩余两区各承担 1.5 倍,变成 75%,仍在可接受区间;如果每区稳态 60%,丢一个区后变成 90%,一次小抖动就会越过临界点。余量不是浪费,它是故障时的缓冲资本;把稳态跑到 80% 的团队,等于把故障切换的预算提前花光了。

11.1.2 算一次副本数:加法比乘法重要

假设峰值 3600 QPS,单副本在 p99 200 ms 下能承载 500 QPS,目标 headroom 30%,三可用区、允许丢一个区。先算需要的总能力:

3600×1.3=4680 req/s 3600 \times 1.3 = 4680\ \text{req/s}

再除以单副本能力:

⌈4680/500⌉=10 个副本 \lceil 4680 / 500 \rceil = 10\ \text{个副本}

10 个副本分到 3 个区,无法均分;按 4/3/3 放,丢一个区后剩下 6 或 7 个,最多 3500 QPS,接不住 3600 的峰值。正确做法是按「每个区都能均分,且任意一个区失效后剩余能力仍 ≥ 峰值」来算:每区 4 个,共 12 个,丢一个区后剩 8 个,能力 4000 QPS,够了。副本数要按故障后的剩余能力倒推,而不是按正常时的总量正推。

11.1.3 容量单位:把不同资源换算成同一把尺子

一个服务有 CPU、内存、连接、带宽、数据库连接、下游配额六种资源,任何一种先耗尽,它就是瓶颈。容量规划要找出「当前瓶颈」和「故障后的新瓶颈」,而不是平均地给每种资源加一点。

资源计量方式常见瓶颈信号扩容手段
CPU核数 × 利用率运行队列、CPU throttling加副本/升配
内存工作集、堆外GC 频率、OOM、page cache 抖动升配、减少对象
连接文件描述符、连接池pool_wait、TIME_WAIT 堆积调池、加副本
数据库连接活跃连接数锁等待、连接拒绝代理、分库、限流
IOPS/带宽磁盘队列、网卡await、重传、带宽打满分片、压缩、CDN
下游配额QPS/并发配额429、配额耗尽申请配额、缓存、批量

这张表最重要的用法是「故障时的迁移」:CPU 平时是瓶颈,丢一个可用区后,数据库连接可能变成新瓶颈;因为剩余副本要接更多流量,每个副本打开的连接数上升,数据库先拒绝连接。容量评审要问「坏一个域之后,下一个瓶颈是谁」,而不是只问「现在够不够」。

11.2 排队论直觉:为什么 70% 就开始抖

11.1 用 C/WC/W 算的是「稳态吞吐上限」,它假设请求来了立刻被处理。真实系统有队列:请求到达时如果没有空闲槽位,就要等。等待时间不是线性的,它随利用率上升急剧变大。理解这条曲线,才能理解为什么 70% 是常见的告警线,而不是 95%。 先定义利用率。对一个有 cc 个服务台、每个服务台服务率 μ\mu 的系统:

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

ρ\rho 是瓶颈资源的利用率,不是 CPU 利用率,也不是「平均负载」。如果数据库连接池只有 20 条,每请求占用连接 50 ms,那么连接池的服务率是 20/0.05=40020/0.05 = 400 req/s;到达 360 req/s 时,连接池的 ρ=0.9\rho = 0.9,哪怕 CPU 只有 40%。

利用率与延迟:70% 之后的非线性 横轴利用率 ρ,纵轴相对延迟;ρ 接近 1 时排队时间按 1/(1-ρ) 增长。 利用率与延迟:70% 之后的非线性 利用率 ρ 延迟 50% 70% 90% 膝点 70% 90%:小抖动即过载 留 headroom 不是浪费,是给排队和重试留空间。
读图:容量规划不能只看 CPU 平均值。70% 是常见的告警起点,90% 时一次小抖动就可能把队列推满;重试会在高利用率下进一步放大到达率。

11.2.1 M/M/1:一条 1/(1-ρ) 的曲线

最简单的模型是单队列、单服务台、泊松到达、指数服务时间(M/M/1)。平均停留时间:

W=1μ−λ=1/μ1−ρ W = \frac{1}{\mu - \lambda} = \frac{1/\mu}{1 - \rho}

其中 1/μ1/\mu 是平均服务时间。也就是说,排队把服务时间放大了 1/(1−ρ)1/(1-\rho) 倍。排队等待时间(不含服务)是:

Wq=W−1μ=ρ1−ρ⋅1μ W_q = W - \frac{1}{\mu} = \frac{\rho}{1-\rho} \cdot \frac{1}{\mu}

利用率 ρ放大倍数 1/(1-ρ)服务 20 ms 时的 W其中排队 Wq
0.502.040 ms20 ms
0.703.367 ms47 ms
0.805.0100 ms80 ms
0.9010.0200 ms180 ms
0.9520.0400 ms380 ms
0.99100.02 s1.98 s

这张表就是「70% 开始抖」的来源。70% 时,排队已经是服务时间的 2.3 倍;90% 时是 9 倍。从 70% 到 90%,利用率只涨了 29%,排队时间却涨了 3.8 倍。延迟对利用率的弹性不是 1,而是 ρ/(1−ρ)\rho/(1-\rho)。 更麻烦的是反向关系:延迟上升会让并发占用时间变长,C/WC/W 下降,等效服务能力下降,ρ\rho 进一步上升。这就是正反馈的种子,11.8 会把它接成完整的重试风暴。

11.2.2 M/M/c:加机器能缓解,但不能取消膝点

真实服务通常有多个服务台(线程、副本、连接)。M/M/c 的平均排队时间要用 Erlang C 公式算,但趋势和 M/M/1 一样:ρ→1\rho \to 1 时发散。下面是服务时间 1 个单位、ρ=0.9\rho=0.9 时的计算结果:

服务台数 cErlang C(要排队的概率)平均排队 Wq(服务时间的倍数)平均停留 W
10.9009.0010.00
20.8534.265.26
40.7881.972.97
80.7020.881.88
160.5910.371.37
320.4580.141.14

同样是 90% 利用率,单台要排队 9 个服务时间,32 台只要 0.14 个。池化(pooling)确实能显著降低排队,这也是为什么「把小实例合成大池子」常常有效。但注意:ρ\rho 固定为 0.9 时,c 从 1 加到 32,W 从 10 降到 1.14,仍然比服务时间多 14%;而且一旦 ρ\rho 到 0.99,c=32 的 W 也会涨到约 4 个服务时间。加服务台把曲线压平,但没有取消膝点。

11.2.3 变异性:均值相同,p99 可以差十倍

M/M/1 假设到达和服务都是指数分布(变异系数 Ca=Cs=1C_a = C_s = 1)。真实系统很少满足:流量有批处理、定时任务、重试脉冲;服务时间有 GC、锁等待、磁盘抖动、下游长尾。Kingman 公式给出了一个够用的近似:

Wq≈ρ1−ρ⋅Ca2+Cs22⋅1μ W_q \approx \frac{\rho}{1-\rho} \cdot \frac{C_a^2 + C_s^2}{2} \cdot \frac{1}{\mu}

其中 CaC_a 是到达间隔的变异系数(标准差/均值),CsC_s 是服务时间的变异系数。C2=1C^2 = 1 对应指数分布,C2>1C^2 &gt; 1 表示比指数更「尖峰/重尾」。

服务时间 20 ms、ρ=0.9\rho=0.9 时:

到达变异 Ca2C_a^2服务变异 Cs2C_s^2Wq说明
11180 ms教科书 M/M/1
14450 ms服务端有 GC/锁等待
41450 ms流量成批到达(定时任务、重试脉冲)
44720 ms批到达 + 长尾服务,最糟

这解释了三个常见现象:

  • 同一份代码,白天 p99 比夜里高:白天到达是突发的(Ca2C_a^2 大),夜里是均匀的。
  • GC 一次,p99 抖一片:GC 让服务时间出现长尾(Cs2C_s^2 大),即使平均服务时间没变。
  • 重试会把 Ca2C_a^2 推高:重试不是均匀到达,而是一波一波同步到达;退避和抖动的作用之一就是降低 Ca2C_a^2。
一句话

排队延迟不是「平均服务时间 × 请求数」,而是「服务时间 × 波动」。利用率决定放大器,变异性决定输入信号的幅度;两者相乘,才是你看到的 p99。

11.2.4 70% 不是定律,是经验起点

70% 来自「ρ=0.7\rho=0.7 时排队约 2.3 个服务时间」这一量级,不是普适定律。变异性低、池化程度高、有限流兜底的服务,ρ=0.8\rho=0.8 也可能很稳;有批处理、锁等待、下游长尾的服务,ρ=0.6\rho=0.6 就该警惕。正确的做法不是背 70%,而是在自己的系统上测出膝点:固定变异性,逐级加负载,画出「利用率—p99」曲线,把 p99 开始非线性上升的点当作告警线,把更低的点当作规划线。11.3 的梯度压测就是干这个的。

别用 CPU 利用率代替 ρ

CPU 利用率是「CPU 忙的时间比例」,ρ\rho 是「瓶颈资源的利用率」。两者只有在 CPU 恰好是瓶颈时才接近。一个服务 CPU 60%、数据库连接池 100%,ρ\rho 是 1(连接池),系统已经在无界排队;此时看 CPU 会得出「还有 40% 余量」的错误结论。先找瓶颈,再算 ρ\rho。

11.3 压测:基线、梯度、开放/闭环

压测的目的不是「打出一个好看的 QPS」,而是回答四个问题:

  1. 在目标 SLO 下,单副本/单集群能承载多少 goodput?
  2. 瓶颈资源是什么,它在什么负载下先饱和?
  3. 过载时系统怎么坏:快速失败、排队、雪崩,还是抖动?
  4. 扩容/降级/限流是否按预期生效?

四个问题对应五类压测,而不是一次「打满」。

类型目的负载形状时长主要产出
基线单请求成本、依赖 fan-out1–10 rps5–10 minp50/p99、CPU/请求、调用链
梯度找膝点与最大 goodput25/50/75/100/125% 峰值每级 5–10 min利用率—延迟曲线、瓶颈
浸泡内存泄漏、连接泄漏、GC 退化70% 峰值恒定2–24 h内存/句柄/延迟漂移
尖峰突发吸收、自动扩容30 s 内 3–5 倍数分钟队列、扩容时延、错误率
破坏性过载行为、熔断/限流逐步加到 150–200%直到 SLO 破失败模式、恢复时间

11.3.1 基线:先把一次请求拆开

基线压测用极低负载(1–10 rps),目的是拿到「一次请求在无排队时的真实成本」,而不是测容量。要记录:

  • 端到端 p50/p99,以及每个下游 span 的耗时;
  • 每请求的 CPU 时间(不是 CPU 利用率)、内存分配、GC 次数;
  • 下游调用次数与 fan-out;
  • 请求/响应体积,缓存命中率。

基线的价值在于给后面的数字提供解释。梯度压测到 800 QPS 时 p99 从 50 ms 涨到 400 ms,如果基线显示下游 p99 只有 30 ms,那 350 ms 的增量就是排队;如果基线下游 p99 已经 200 ms,那多半是依赖先饱和。没有基线,梯度压测只能看到「变慢了」,说不出「慢在哪」。

11.3.2 梯度:一级一级加,直到膝点

梯度压测从低到高逐级加载,每级保持 5–10 分钟,等指标稳定后再进下一级。每级记录:

指标用途稳定判据
goodput有效容量连续 2 min 波动 < 5%
p50/p99/p999延迟曲线p99 连续 2 min 无上升趋势
错误率(按码)过载信号区分 429/503/500/超时
瓶颈资源利用率找膝点CPU、连接池、队列、IO 各自算
队列等待是否开始堆积排队时间 > 服务时间即警戒
下游延迟/错误是否先饱和与基线对比

停止条件必须事先写死,不能现场「再看看」:

  • p99 超过 SLO 的 1.5 倍,且持续 2 分钟;
  • 错误率超过 1%,或 5xx 超过 0.1%;
  • 队列等待超过服务时间的 50%;
  • 下游开始出现 429/超时,且不是压测器自身造成;
  • 压测器 CPU 超过 70%(结果已不可信)。

11.3.3 开放与闭环:最容易被忽略的测量错误

压测器有两种基本模型:

  • 闭环(closed-loop):固定 NN 个并发客户端,每个客户端发完一个请求、收到响应后再发下一个。wrk -c 200 就是闭环。
  • 开放(open-loop):按固定速率发请求,不管上一个是否返回。真实用户、定时任务、上游服务调用更接近开放模型。

闭环压测有一个著名偏差,叫 coordinated omission(协调遗漏)。假设你用 100 个并发客户端压一个服务,正常情况下每个请求 10 ms,能打 10,000 rps。服务端卡了 1 秒:

  • 闭环:100 个客户端都卡住,1 秒内只发出 100 个请求;压测器记录的 p99 是 1 秒,但「本应在这 1 秒内发出的 10,000 个请求」根本没有被记录。真实用户在那 1 秒里会排队,等待时间会累积到几秒甚至几十秒。
  • 开放:压测器仍按 10,000 rps 发;1 秒内积压 10,000 个请求,延迟会如实反映排队。

闭环压测在系统变慢时自动降低压力,等于把最需要观测的过载区间跳过去了。这不是 wrk 的 bug,而是闭环模型的固有性质;wrk 适合测「给定并发下的吞吐」,不适合测「给定到达率下的延迟」。

一个最小修正:记录延迟时,用「请求计划发送时刻」而不是「实际发送时刻」作为起点。开放压测器通常是这样的:

import asyncio
import random
import time


async def send_once(i: int) -> float:
    """替换成真实请求;这里用随机睡眠模拟服务时间。"""
    await asyncio.sleep(random.uniform(0.005, 0.05))
    return time.monotonic()


async def open_loop(rate_per_s: float, duration_s: float):
    start = time.monotonic()
    total = int(rate_per_s * duration_s)
    latencies = []
    tasks = []
    for i in range(total):
        scheduled = start + i / rate_per_s
        delay = scheduled - time.monotonic()
        if delay > 0:
            await asyncio.sleep(delay)
        tasks.append(asyncio.create_task(measure(i, scheduled, latencies)))
        if len(tasks) >= 2000:
            await asyncio.gather(*tasks)
            tasks.clear()
    await asyncio.gather(*tasks)
    latencies.sort()
    return latencies


async def measure(i: int, scheduled: float, out: list) -> None:
    done = await send_once(i)
    out.append((done - scheduled) * 1000.0)   # 关键:从计划时刻算起


lat = asyncio.run(open_loop(1000, 10))
print(f"n={len(lat)} p50={lat[len(lat)//2]:.1f}ms p99={lat[int(len(lat)*0.99)]:.1f}ms")

measure 里的 scheduled 是「这个请求本应发出的时刻」。如果服务端卡住,done - scheduled 会把排队时间算进去;闭环压测则会把这批请求「藏起来」。这个差别在系统健康时只有几毫秒,在过载时能差两个数量级。

11.3.4 压测器本身也会成为瓶颈

开放压测要维持恒定速率,压测器必须有足够的并发和精度:

Cgen≥λtarget×Wp99×1.2 C_{\text{gen}} \ge \lambda_{\text{target}} \times W_{p99} \times 1.2

目标 20,000 rps、p99 500 ms,需要约 12,000 个在途请求。如果单台压测机只能维持 2,000 个并发连接,就需要 6 台;否则压测器先排队,测出来的是压测器的上限。

三个检查点:

  • 压测机 CPU 持续 > 70%:调度延迟会污染结果;
  • 压测机网卡/带宽打满:请求发不出去,看起来像服务端慢;
  • 压测机 ss -s 的 TIME_WAIT 暴涨:端口耗尽,新建连接失败。

压测数字分「冷」「热」两种。预热 2–5 分钟(或直到 p99 连续 2 分钟稳定),丢弃预热期数据再计时;冷启动单独测一轮,记录「从 0 到满容量」的时间,它是 11.6 的关键输入。

11.4 限流:令牌桶、漏桶、滑动窗口、分布式限流

限流保护的不是「请求」,而是「瓶颈资源」。在网关按 QPS 限流,保护的是后端线程;在客户端按并发限流,保护的是下游连接池;在数据库代理按连接数限流,保护的是数据库。限流点要放在瓶颈之前,越靠前越便宜:在网关拒绝一个请求的成本,比让它走到数据库再超时低两个数量级。 限流还有一层常被忽略的语义:它限制的是「成本」,不是「次数」。一个查询 10 万行的请求和一个按主键查一行的请求,都算 1 QPS,但对数据库的压力差几个数量级。生产系统通常给请求分配 cost(按行数、字节数、下游 fan-out),令牌桶按 cost 扣减。

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

令牌桶有两个参数:速率 rr(每秒补充多少 token)和桶容量 BB(最多攒多少)。请求消耗 token,不够就拒绝或排队。空闲时桶攒满,允许一次 BB 的突发;持续打满时,长期速率被限制在 rr。

import threading
import time


class TokenBucket:
    """线程安全的最小令牌桶;返回 (是否放行, 建议等待秒数)。"""

    def __init__(self, rate_per_s: float, burst: float):
        self.rate = float(rate_per_s)
        self.burst = float(burst)
        self._tokens = float(burst)
        self._updated = time.monotonic()
        self._lock = threading.Lock()

    def _refill(self, now: float) -> None:
        self._tokens = min(self.burst, self._tokens + (now - self._updated) * self.rate)
        self._updated = now

    def allow(self, cost: float = 1.0) -> tuple[bool, float]:
        if cost <= 0 or cost > self.burst:
            raise ValueError(f"bad cost: {cost}")
        now = time.monotonic()
        with self._lock:
            self._refill(now)
            if self._tokens >= cost:
                self._tokens -= cost
                return True, 0.0
            return False, (cost - self._tokens) / self.rate

三个细节:

  • 用单调时钟:墙上时钟被 NTP 调整时,令牌会突然多出或少掉;
  • 用锁保护读-算-写:多线程下先读后写会超发,和分布式场景的分两次 GET/SET 是同一个错误;
  • cost > burst 直接报错:一个永远不可能被放行的请求,应该在配置校验时发现,而不是在线上永远 429。

一个算例:rate=100/s、burst=200,空闲 10 秒后突然来 250 个请求。桶最多攒 200 个,前 200 个通过,后 50 个被拒;被拒的请求等待约 0.5 秒后可再通过。如果客户端立刻重试 50 次,等于把拒绝又打回去,所以拒绝必须带 Retry-After。

11.4.2 漏桶与滑动窗口

漏桶(leaky bucket)把请求放进一个有固定流出速率的队列:不管进来多快,出去总是匀速。它和令牌桶的区别是「整形」与「限速」(shaping vs policing):漏桶让下游看到平滑流量,代价是排队延迟;令牌桶允许突发通过,不引入额外排队。

固定窗口最简单:每个窗口一个计数器,超过阈值拒绝。它的经典问题是边界双倍:限制「每分钟 100 次」,客户端在 12:00:59 打 100 次、12:01:00 再打 100 次,两秒内通过了 200 次。对下游来说,这和没限流差不多。

滑动窗口计数用前一窗口的加权值修正边界:

estimate=prev×(1−telapsedTwindow)+current \text{estimate} = \text{prev} \times \left(1 - \frac{t_{\text{elapsed}}}{T_{\text{window}}}\right) + \text{current}

例:限制 100/min,上一窗口用了 80,当前窗口已过 15 秒(25%),本窗口已用 30,则估计值为 80×0.75+30=9080 \times 0.75 + 30 = 90,本窗口最多再放 10 个。它只需要存两个计数,精度足够大多数 API 限流。

算法突发是否整形内存分布式实现适用
固定窗口边界可 2×否O(1)INCR + EXPIRE粗粒度配额
滑动窗口日志精确否O(请求数)ZSET低频、强精确
滑动窗口计数近似否O(1)Lua高频 API
令牌桶允许 BB否O(1)Lua通用限流
漏桶不允许是O(队列)较难出口整形、保护下游
并发限制不适用否O(1)信号量保护连接池/线程池

最后一行值得单独说:并发限制常常比 QPS 限制更可靠。QPS 限制假设每个请求的成本固定;一旦下游变慢,同样的 QPS 会占用更多并发,QPS 限制就挡不住了。并发限制直接约束 LL;由 Little’s law L=λWL = \lambda W,它会自动随 WW 调整:延迟翻倍时,同样的并发只允许一半的吞吐。第 2.6 节的 Gate 就是并发限制。

11.4.3 分布式限流:本地、全局与热点

单机令牌桶在 N 个副本上会变成 N 倍限额:每个副本都以为自己有 1000/s,实际总量是 N×1000。分布式限流有三种常见结构:

结构做法优点缺点
全局限流所有请求查同一个 Redis/限流服务精确每次请求多一次 RTT;限流器成为单点
本地分片每个副本分到 R/NR/N,本地桶无额外 RTT负载不均时浪费配额
两层本地桶(快路径)+ 全局桶(总量)兼顾延迟与总量实现复杂;两层参数要配合

两层结构的常用参数:本地桶速率 R/NR/N、突发 B/NB/N;全局桶速率 RR、突发 BB。请求先过本地桶,再过全局桶;本地桶挡住大部分突发,只有接近配额时才查全局。分区时,本地桶仍能工作,但总量可能超出 BB;这是一个显式的取舍:要可用性就接受短时超发,要精确就接受限流器不可用时拒绝。

Redis Lua 的最小实现:

-- KEYS[1] = bucket key
-- ARGV = rate_per_s, burst, now_ms, cost
local rate  = tonumber(ARGV[1])
local burst = tonumber(ARGV[2])
local now   = tonumber(ARGV[3])
local cost  = tonumber(ARGV[4])

local data   = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1]) or burst
local ts     = tonumber(data[2]) or now

local delta = math.max(0, now - ts) / 1000.0
tokens = math.min(burst, tokens + delta * rate)

local allowed = 0
if tokens >= cost then
  tokens = tokens - cost
  allowed = 1
end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(burst / rate * 1000) + 1000)
return {allowed, math.floor(tokens)}

now 由调用方传入会有时钟偏差;更稳的做法是在脚本里用 redis.call('TIME') 取服务端时间。无论哪种,读、算、写必须在同一个脚本里完成,分两次 GET/SET 在并发下一定超发。 热点 key 是分布式限流的常见故障:一个大租户的 key 落在同一个 Redis 分片上,把该分片打满。解决办法是把配额拆成 SS 个子桶(例如 16 个),按 hash(request_id) % S 选一个,每个子桶速率 R/SR/S;总量近似 RR,误差最多 BB。代价是每个请求仍只查一个子桶,不会增加 RTT。

11.4.4 限流的响应:429、503 与 Retry-After

限流命中后返回什么,决定了上游会不会立刻再打回来:

场景状态码响应头上游动作
租户配额耗尽429Retry-After、X-RateLimit-Scope: tenant退避到指定时间,不要换 key 重试
全局限流429Retry-After、X-RateLimit-Scope: global降速或排队
服务自身过载503Retry-After + 抖动退避、切实例、降级
请求已过期503/504不建议重试放弃,走 unknown 收敛

Retry-After 要给一个「估计的恢复时间」,不能给 0(等于让客户端立刻重试),也不能给得比客户端 deadline 还长(等于让它放弃)。客户端应在 [0.8, 1.2] × Retry-After 内随机化,避免所有客户端在同一秒回来。第 2.7 节已经展开过 429/503 的语义,这里只强调:限流器给出的等待时间,必须和重试预算、熔断器一起工作;单独一个 429 挡不住没有预算的重试。

11.5 降级:优先级、熔断、舱壁

降级不是「出错时返回默认值」这么简单。真正的降级是:在容量不足时,有意识地放弃一部分功能,把资源让给核心路径。它需要先回答「什么最重要」,再回答「怎么关」,最后回答「关了之后用户看到什么」。

11.5.1 优先级:先决定放弃什么

降级的前提是排序。一个电商交易链路,从最不重要到最重要可能是:

优先级功能降级动作用户影响
P4行为埋点、推荐曝光上报直接丢弃/采样 1%无感,报表略少
P3个性化推荐、猜你喜欢返回热门/空列表转化率下降
P2商品评价、问答只返回缓存的前 3 条信息变少
P2搜索联想关闭,改为提交后搜索多一次操作
P1非核心读(历史订单列表)只读缓存,TTL 延长可能看到旧数据
P1写操作(下单、支付)限流 + 排队,不轻易关直接资损
P0登录、支付、库存扣减不降级,只扩容/限流不可用即事故

这张表没有通用答案,但有一个通用要求:优先级必须提前写进配置和演练,不能在事故现场开会决定。事故现场的信息最少、压力最大,临时排序一定偏向「谁声音大」。

11.5.2 熔断器:三态状态机

熔断器(circuit breaker)是一个有状态的开关:下游错误率过高时,快速失败一段时间,给下游恢复的机会,也释放自己的线程。它有三个状态:

              失败率 ≥ 50%(窗口内 ≥ 20 次)
    ┌──────────┐ ─────────────────▶ ┌──────────┐
    │  CLOSED  │                    │   OPEN   │
    └──────────┘ ◀───────────────── └──────────┘
         ▲        连续 3 次探测成功         │
         │                                 │ open_timeout 到期
         │       ┌────────────┐            │
         └────── │ HALF_OPEN  │ ◀──────────┘
          探测成功│ 只放 3 个   │
                 └────────────┘
                        │ 任一探测失败 → OPEN(退避 ×2,封顶 60s)

三个状态的行为:

  • CLOSED:正常放行,统计滑动窗口内的成功/失败。窗口内请求数达到最小值(例如 20),且失败率超过阈值(例如 50%),转到 OPEN。
  • OPEN:所有请求立即失败,不碰下游。持续 open_timeout(例如 10 s),到期转到 HALF_OPEN。
  • HALF_OPEN:只放少量探测请求(例如 3 个)。全部成功则回到 CLOSED;任一失败则回到 OPEN,并把 open_timeout 翻倍(封顶 60 s),避免下游还没恢复就被反复冲击。
import enum
import random
import threading
import time
from collections import deque


class State(enum.Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"


class CircuitBreaker:
    def __init__(self, fail_threshold=0.5, min_requests=20, window_s=10.0,
                 open_s=10.0, probes=3, max_open_s=60.0):
        self.fail_threshold = fail_threshold
        self.min_requests = min_requests
        self.window_s = window_s
        self.open_s = open_s
        self.max_open_s = max_open_s
        self.probes = probes
        self._state = State.CLOSED
        self._events = deque()          # (monotonic, ok)
        self._open_until = 0.0
        self._open_for = open_s
        self._probe_left = 0
        self._probe_ok = 0
        self._lock = threading.Lock()

    def allow(self) -> bool:
        with self._lock:
            now = time.monotonic()
            if self._state is State.OPEN:
                if now < self._open_until:
                    return False
                self._state = State.HALF_OPEN
                self._probe_left = self.probes
                self._probe_ok = 0
            if self._state is State.HALF_OPEN:
                if self._probe_left <= 0:
                    return False
                self._probe_left -= 1
            return True

    def record(self, ok: bool) -> None:
        with self._lock:
            now = time.monotonic()
            if self._state is State.HALF_OPEN:
                if ok:
                    self._probe_ok += 1
                    if self._probe_ok >= self.probes:
                        self._state = State.CLOSED
                        self._open_for = self.open_s
                        self._events.clear()
                else:
                    self._trip(now)
                return
            self._events.append((now, ok))
            while self._events and now - self._events[0][0] > self.window_s:
                self._events.popleft()
            if self._state is State.CLOSED and len(self._events) >= self.min_requests:
                fails = sum(1 for _, ok in self._events if not ok)
                if fails / len(self._events) >= self.fail_threshold:
                    self._trip(now)

    def _trip(self, now: float) -> None:
        # CLOSED 的第一次跳闸用 open_s;HALF_OPEN 再失败才指数退避
        if self._state is State.HALF_OPEN:
            self._open_for = min(self.max_open_s, self._open_for * 2)
        else:
            self._open_for = self.open_s
        self._state = State.OPEN
        self._open_until = now + self._open_for * random.uniform(0.8, 1.2)
        self._events.clear()

四个实现细节比状态机本身更容易出错:

  1. 失败要分类:400、404、422 是业务结果,不是下游故障;把它们算进失败率,会在流量结构变化时误熔断。只统计超时、连接错误、5xx。
  2. 窗口要有最小请求数:低流量时 1 个失败就是 100% 失败率,会反复跳闸。min_requests=20 挡住这种噪声。
  3. HALF_OPEN 只放探测:如果半开状态放开全部流量,下游还没恢复就被再次打满,等于没有熔断。
  4. OPEN 时间要抖动:所有实例同时到期、同时探测,会形成新的同步脉冲;uniform(0.8, 1.2) 是最低要求。

11.5.3 舱壁:把故障关在隔间里

舱壁(bulkhead)来自船体设计:一个舱进水,其他舱还能浮。软件里的舱壁是按依赖/租户/优先级隔离资源池,让一个慢依赖只耗尽自己的那部分线程,而不是全公司的线程。

结构无舱壁有舱壁
线程池全局 200 线程,一个慢依赖占满每个依赖 40 线程,互不侵占
连接池所有下游共用一个池每个下游独立池 + 独立超时
队列一个全局 FIFO,慢请求堵住快请求按优先级/租户分队列
部署所有租户共享实例大租户独立实例/分片

数字算例:5 个下游,全局 200 线程。按 500 req/s 到达,一个下游从 20 ms 变成 2 s,每个请求占线程 2 s,200 线程在 0.4 s 内被占满;其他 4 个下游的正常请求全部排队。改成每个下游 40 线程后,慢下游最多占 40 个,另外 160 个线程继续服务健康下游。舱壁的代价是资源利用率下降(每个池都可能有空闲),换来的是故障不扩散;这是值得的交易,但要按依赖的实际流量分配,不能平均分。

11.5.4 降级策略:关什么、怎么关、怎么让用户知道

策略做法适用风险
缓存兜底返回过期缓存,TTL 可延长读多写少、可容忍旧值数据陈旧,必须标记
默认值返回空列表/热门/默认配置推荐、联想、非核心展示用户以为「没有」
只读模式关闭写入口,读继续存储故障、主从切换用户无法下单
异步受理返回 202 + 查询 ID写操作可延后客户端要轮询
采样/截断只返回前 N 条、采样日志大响应、报表结果不完整
关闭非核心关闭推荐、评价、动画容量不足体验下降
快速失败503 + Retry-After自身过载需要上游配合退避

降级要满足四个条件,否则它会把「可用性问题」变成「正确性问题」:

  1. 可观测:响应里带 degraded=true 或 X-Degraded: cache,指标里单独计数;
  2. 有界:缓存兜底必须有 TTL 上限,不能把一小时前的库存当成现在;
  3. 可逆:降级开关要能一键恢复,不需要重新发布;
  4. 演练过:没演练过的降级路径,大概率在真正需要时抛异常。
例 11.5.1 · 一次「降级」把库存卖成负数

大促期间库存服务超时,网关触发降级:读库存返回本地缓存的旧值。缓存 TTL 原本 1 秒,但降级代码为了「更可用」把 TTL 改成了 10 分钟。结果 10 分钟内所有用户都看到「有货」,下单量超过真实库存 3 倍;写路径没有降级,超卖在支付后才暴露。 修复不是「不要降级」,而是:读降级只允许 5 秒 TTL、返回 degraded=true、库存扣减仍以数据库条件更新为准、并在降级期间对下单做并发限制。降级可以牺牲新鲜度,不能牺牲不变量。

11.6 自动扩缩容的陷阱

自动扩缩容看起来是容量问题的银弹:流量涨了加副本,流量降了减副本。它确实能处理分钟级的趋势变化,但处理不了秒级尖峰,也会引入一批新的失败模式。

t=0s     流量 2×,p99 开始上升,队列增长
t=15s    指标采集窗口还没满,扩缩容控制器看不到
t=30s    窗口触发,决定扩容(目标副本 +6)
t=35s    调度器创建 Pod,拉镜像/挂卷
t=60s    尖峰已经结束(很多尖峰只有 30–60s)
t=75s    Pod Ready(镜像已缓存;冷启动 JVM 要 120s+)
t=90s    新 Pod 开始接流量,但本地缓存是冷的
t=150s   缓存预热完成,单 Pod 达到额定容量

这张时间线的意思是:扩容的响应时间通常是分钟级,而尖峰的生命周期常常是秒级。等扩容完成,尖峰已经过去;如果尖峰没过去,新副本又会在冷缓存状态下被压垮。

11.6.1 七个常见陷阱

陷阱现象根因对策
指标延迟尖峰结束才扩容采集 + 窗口 + 冷却用短窗口 + 预测式预扩容
冷启动新副本一上来就超时JIT、缓存、连接池未预热预热接口、最小副本、启动探针
缩容抖动副本数来回跳指标在阈值附近波动稳定窗口 5–10 min、步长限制
有状态服务扩容触发 rebalance/选主分片迁移、日志重放不按 CPU 扩缩;预分片、垂直扩容
指标选错CPU 低但队列满IO 密集/等下游用并发、队列深度、p99 做信号
依赖上限副本加了,数据库崩了连接数/配额是全局瓶颈max 副本 = 依赖容量 / 单副本连接
反馈振荡扩容→缓存冷→更慢→再扩容扩容降低单副本命中率预热、限制扩容速度、保留热副本

11.6.2 扩缩容的边界条件

自动扩缩容有三个必须手算的边界:

  1. 最小副本数由故障域决定,不由流量决定。三可用区、允许丢一个区,最小副本至少 3(每区 1),且要保证剩余两区能接住峰值。
  2. 最大副本数由下游容量决定。100 个副本、每个 20 条数据库连接,就是 2000 条连接;数据库上限 500 时,第 26 个副本开始就会连接失败。max 副本必须满足 Nmax⁡×conns per replica≤DB limit×0.8N_{\max} \times \text{conns per replica} \le \text{DB limit} \times 0.8。
  3. 缩容速度由在途请求和连接 draining 决定。副本被摘除时,正在处理的请求可能还要跑几秒;缩容太快会把它们变成 5xx。

11.6.3 扩缩容不是容量规划

自动扩缩容解决「趋势变化」,容量规划解决「上限和故障」:秒级尖峰、单可用区失效、下游瓶颈,扩缩容都来不及或解决不了,只能靠提前留 headroom、按故障域算副本、按依赖容量设 max。把自动扩缩容当容量规划,等于把安全带当刹车。

11.7 故障演练:假设、爆炸半径、game day

故障演练(chaos engineering / game day)不是「随机杀几个 Pod 看会不会挂」,而是用一个可证伪的假设,去验证系统在特定故障下的行为。没有假设的演练,最多叫「故障注入」;没有停止条件的演练,叫「制造事故」。

11.7.1 假设怎么写

一个好的演练假设包含五要素:故障、范围、时长、可观测的预期、机制。

在单可用区 10% 的实例上注入 200 ms 网络延迟、持续 5 分钟,支付接口的 p99 不超过 800 ms、错误率不超过 0.5%,因为客户端 deadline 为 1 s、重试预算 5%、且熔断器在下游错误率超过 50% 时会打开。

这句话里每一项都可检验:

要素例子不可检验的写法
故障200 ms 延迟、2% 丢包「网络抖动一下」
范围单可用区、10% 实例「小范围」
时长5 分钟「一会儿」
预期p99 ≤ 800 ms、错误率 ≤ 0.5%「不能挂」
机制deadline、重试预算、熔断「应该没事」

假设必须是可证伪的:如果结果是 p99 1.2 s,假设就被推翻,这就是一次有效演练。反之,如果假设写成「系统应该保持稳定」,无论发生什么都能解释成「稳定」,那演练没有信息量。

11.7.2 爆炸半径:从假故障到真生产

爆炸半径(blast radius)是「这次演练最多能影响多少用户/数据/钱」。它必须逐级放大,每一级都有独立的价值和独立的停止条件:

级别环境/范围能验证什么风险
L0单元测试 + fake 依赖熔断/重试/降级逻辑无
L1staging 全量配置、部署、告警链路无真实用户
L2生产 canary 1%真实流量下的行为1% 用户
L3生产单可用区故障切换、容量余量一个 AZ
L4生产 game day(多故障)组织协同、runbook受控的全站风险
L5区域级切换灾备、DNS、数据恢复极高,需单独审批

大多数团队应该先把 L0–L2 做扎实,再碰 L3。直接上 L4 的「勇敢」,通常来自对爆炸半径的低估。演练的每一步都要能独立回滚;不能回滚的演练,不是演练,是发布。

11.7.3 注入手段

常用手段:tc netem 做延迟/丢包/乱序,iptables 或网络策略做分区,kill -9 做进程崩溃,fallocate 做磁盘满,故障注入代理返回 5xx,容器时钟或 libfaketime 做时钟偏移。每种手段都要先确认爆炸半径:tc 影响整块网卡,分区可能造成双主,杀进程可能丢数据。

11.7.4 一个可用的 netem 注入脚本

下面这个脚本把「爆炸半径」写进了代码:网卡白名单、拒绝 loopback、到时自动清理、支持 dry-run。

#!/usr/bin/env bash
# netem_drill.sh — 在指定网卡注入延迟/丢包,到时自动清理。
# 用法:
#   DRY_RUN=1 ./netem_drill.sh apply      # 只打印计划,不改内核
#   IFACE=eth0 DURATION_S=300 ./netem_drill.sh apply
#   ./netem_drill.sh clear
set -euo pipefail

IFACE="${IFACE:-eth0}"
ACTION="${1:-apply}"
DELAY_MS="${DELAY_MS:-120}"
JITTER_MS="${JITTER_MS:-30}"
LOSS_PCT="${LOSS_PCT:-2}"
RATE="${RATE:-100mbit}"
DURATION_S="${DURATION_S:-300}"
DRY_RUN="${DRY_RUN:-0}"
ALLOW_IFACES="${ALLOW_IFACES:-eth0 ens5 enp0s3}"

die() { echo "error: $*" >&2; exit 1; }

need_root() {
  if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
    die "需要 root 或 CAP_NET_ADMIN;可先用 DRY_RUN=1 检查计划"
  fi
}

check_iface() {
  local allowed=0 candidate
  for candidate in $ALLOW_IFACES; do
    [[ "$IFACE" == "$candidate" ]] && allowed=1
  done
  [[ "$allowed" -eq 1 ]] || die "IFACE=$IFACE 不在白名单: $ALLOW_IFACES"
  [[ "$IFACE" != "lo" ]] || die "拒绝在 loopback 注入,会打断本机所有服务"
  [[ -d "/sys/class/net/$IFACE" ]] || die "网卡不存在: $IFACE"
}

run() {
  if [[ "$DRY_RUN" == "1" ]]; then
    printf '[dry-run] %s\n' "$*"
  else
    "$@"
  fi
}

cleanup() {
  run tc qdisc del dev "$IFACE" root 2>/dev/null || true
}

check_iface
[[ "$DRY_RUN" == "1" ]] || need_root

case "$ACTION" in
  apply)
    run tc qdisc replace dev "$IFACE" root netem \
      delay "${DELAY_MS}ms" "${JITTER_MS}ms" \
      loss "${LOSS_PCT}%" rate "$RATE"
    run tc -s qdisc show dev "$IFACE"
    if [[ "$DURATION_S" -gt 0 && "$DRY_RUN" != "1" ]]; then
      trap cleanup EXIT INT TERM
      echo "注入中:${DURATION_S}s 后自动清理(Ctrl-C 立即清理)"
      sleep "$DURATION_S"
    fi
    ;;
  clear)
    cleanup
    ;;
  show)
    tc -s qdisc show dev "$IFACE"
    ;;
  *)
    die "用法: $0 {apply|clear|show}"
    ;;
esac

脚本里有四个安全设计,值得抄到任何注入工具里:

  1. 白名单:只允许预声明的网卡,避免 IFACE=eth1 打错到管理网;
  2. 拒绝 loopback:本机所有服务共用 lo,注入等于自杀;
  3. 自动清理:trap cleanup EXIT INT TERM,即使脚本被 Ctrl-C 也能恢复;
  4. dry-run:先打印将执行的 tc 命令,确认参数再真跑。

11.7.5 Game day:角色、时间线、停止条件

Game day 是一次有组织的演练,不是一个人的即兴操作。最小配置四个角色:

角色职责不能兼任
指挥(IC)决定开始/停止、对外沟通注入操作者
注入者执行脚本、记录时间点指挥
观察者看指标、判断假设是否成立注入者
记录者时间线、决策、异常—

时间线通常分四段:

  1. 准备(T−7 天):写假设、评审爆炸半径、准备回滚、通知相关团队、冻结变更;
  2. 基线(T−10 min):记录当前 SLO、错误率、容量,确认没有正在进行的故障;
  3. 注入(T0):按计划注入,观察者盯住预设指标,指挥有权随时叫停;
  4. 恢复与复盘(T+):清理注入、确认指标回到基线、24 小时内出复盘。

停止条件必须提前写死,至少包括:

  • SLO 燃烧率超过阈值(例如 1 小时烧掉 10% 错误预算);
  • 真实用户错误率超过 1%,且持续 2 分钟;
  • 注入脚本无法清理,或清理后指标不恢复;
  • 出现数据损坏/重复扣款迹象;
  • 关键依赖(数据库、DNS、认证)出现非预期错误。

演练的第一原则是「可停止」。如果一次注入无法在 1 分钟内撤销,它就不该在生产做。第二原则是「可观测」:如果连「假设是否成立」都判断不了,演练只会产生争论,不会产生知识。

演练结束后必须做的事
  1. 把注入期间的时间线、指标截图、告警记录归档;
  2. 逐条对照假设,标记「成立/不成立/无法判断」;
  3. 对每个「不成立」写出根因、修复项、负责人和复验日期;
  4. 把这次演练的脚本和参数收进仓库,下一次从同一基线复跑;
  5. 如果没有任何发现,先怀疑「故障太轻」或「观测太粗」,而不是庆祝系统完美。

11.8 失败模式:重试风暴(重复请求)与过载

重试风暴不是「流量太大」,而是「系统用重试把一次变慢变成了持续过载」。它的形状固定:某个依赖变慢 → 超时率上升 → 客户端重试 → 总到达率上升 → 队列变长 → 完成时间更长 → 超时率更高。每一轮都在给正反馈加柴,而原始用户流量可能一点没涨。

11.8.1 一条具体的时间线

假设一个服务有 200 个工作线程,正常时每个请求占用线程 80 ms(其中 40 ms 等下游),容量 2500 req/s;平时流量 1500 QPS,利用率 60%。客户端超时 1 s,失败后最多重试 1 次(最多 2 次尝试),没有重试预算,也没有熔断。 某天下午,一个下游的 p99 从 40 ms 涨到 400 ms,平均占用线程时间变成 440 ms,容量塌缩到约 455 req/s。下面五轮的数字是示意(最后一行是干预后的对比),但数量级符合真实系统:

轮次原始 QPS依赖延迟有效容量超时率 p重试 QPS总到达goodput队列
11500120 ms12502%3015301230小
21500250 ms69018%2701770680增长
31500400 ms45542%6302130450接近满
41500600 ms31262%9302430310溢出
51500600 ms31265%9752475305丢弃
6(熔断+降载)1500600 ms31265%0320312下降

看第二列和第七列:原始流量一直是 1500,涨的是重试。系统不是被更多用户压垮的,是被自己对失败的补偿压垮的。到第 5 轮,总到达是原始的 1.65 倍,goodput 只剩 305——多出来的 975 个请求几乎全在浪费线程和带宽。

11.8.2 正反馈环

       ① 下游变慢
            │
            ▼
       ② 完成时间变长 ─────▶ ③ 超时率上升
            ▲                        │
            │                        ▼
       ⑥ 有效容量下降 ◀── ⑤ 队列/并发上升 ◀── ④ 客户端重试
            │
            └────────────────▶ 回到 ①(下一轮更慢)

这个环的增益可以写成(不限制重试次数时):

A=11−p⋅r A = \frac{1}{1 - p \cdot r}

其中 pp 是失败率,rr 是每个失败请求平均再发的次数。p⋅r≥1p \cdot r \ge 1 时没有有限的不动点,系统只能靠外部干预(限流、熔断、关重试)退出。p⋅r<1p \cdot r &lt; 1 时也有放大:p=0.2,r=1p=0.2, r=1 时 A=1.25A=1.25,p=0.5,r=1p=0.5, r=1 时 A=2A=2。 真实系统的 pp 不是常数,它随负载上升。把 p(A)p(A) 代进去,稳定性条件是在不动点处 ∣r⋅A0⋅p′(A∗)∣<1|r \cdot A_0 \cdot p&#x27;(A^*)| &lt; 1;接近饱和时 p′p&#x27; 急剧变大,这个乘积很容易越过 1。这就是为什么 70% 时一次抖动可能自愈,90% 时同样的抖动会直接雪崩。 代入数字:失败率 42% 时无限重试的放大倍数是 1/(1−0.42)=1.721/(1-0.42)=1.72,65% 时是 1/(1−0.65)=2.861/(1-0.65)=2.86;重试放大不是线性的,失败率越高,每一次重试越可能触发下一次重试。

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

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

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

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

11.8.4 亚稳态:原因消失了,系统还没好

重试风暴最危险的形态是亚稳态故障(metastable failure):触发因素已经消失,系统却停在坏状态里。原因有三:队列积压还没排空;故障期间排下的重试定时器在几十秒后同时到达;缓存大量失效,恢复期每次未命中都要回源。退出亚稳态不能只靠「等」,必须主动把入口压到系统能处理的水平,等队列排空、缓存预热、重试定时器消化完,再逐步放量。恢复期的容量不是稳态容量。

例 11.8.1 · 一次 30 秒的依赖抖动变成 40 分钟故障

某服务 300 个副本、每副本 50 并发,正常 QPS 12,000。一个下游出现 30 秒抖动,p99 从 30 ms 涨到 2 s。客户端超时 800 ms、重试 2 次;健康检查每 5 秒一次,超时也重试。 30 秒内,超时率从 1% 涨到 55%,总到达被重试放大到 2.5 倍;健康检查失败导致一半副本被摘除,剩余副本的并发被瞬间打满。依赖 30 秒后恢复,但队列和重试定时器又花了 40 分钟才排空。最终 SLO 燃烧掉当天 60% 的错误预算。 修复用了四件事:客户端重试预算 5%、熔断器(错误率 50% 打开 10 秒)、健康检查不重试且失败阈值从 1 次调到 3 次、入口限流按「当前健康副本 × 单副本容量」动态计算。同样的故障注入在复验中只造成 90 秒的 p99 上升。

11.9 观测:tc/netem、wrk、qdisc、metrics

容量和演练的观测有两个方向:从外面看系统能承受多少(压测器、延迟分位、错误率),从里面看瓶颈在哪(qdisc、连接、队列)。只做前者,你会知道「撑不住」,但不知道为什么;只做后者,你会看到很多曲线,但不知道离过载还有多远。

11.9.1 qdisc:队列和丢包在哪里

tc -s qdisc show dev eth0

一次 netem 注入期间的输出:

qdisc netem 8002: root refcnt 2 limit 1000 delay 120ms 30ms loss 2% rate 100Mbit
 Sent 184320000 bytes 152000 pkt (dropped 3040, overlimits 0 requeues 0)
 backlog 0b 0p requeues 0

怎么读:

字段含义正常异常
delay/jitter/loss当前注入参数与演练计划一致与计划不符,说明规则被覆盖
droppedqdisc 丢弃的包接近配置的 loss 比例远大于配置,可能是 limit 溢出
backlog当前排队字节/包数接近 0 或短时波动持续增长,队列已满
overlimits超过 qdisc 限额的次数0>0 说明整形能力不足

backlog 是最有用的一个:它是「内核里正在排队的包」。如果 backlog 持续增长,说明出口带宽或 qdisc 处理能力不够,应用层的 p99 会跟着涨;此时加应用副本没用,瓶颈在网卡/整形规则。

11.9.2 ss:连接和端口

ss -s

输出:

Total: 412
TCP:   389 (estab 301, closed 52, orphaned 3, timewait 33)
UDP:   7
RAW:   0
FRAG:  0
字段含义正常异常
estab已建立连接与并发量同量级远高于并发,连接泄漏
timewait主动关闭后的等待数千以内数万,端口耗尽风险
orphaned应用已关闭但内核仍持有接近 0持续增长,fd 泄漏

timewait 高不一定是故障,但配合「新建连接失败/cannot assign requested address」就是端口耗尽。压测器上尤其常见:短连接 + 高 QPS 会在几十秒内把本地端口用光,看起来像服务端拒绝连接。

11.9.3 wrk:压测器的视角

wrk -t4 -c200 -d30s --latency -s post.lua http://127.0.0.1:8081/v1/search

输出:

Running 30s test @ http://127.0.0.1:8081/v1/search
  4 threads and 200 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     4.82ms   12.31ms 250.10ms   91.22%
    Req/Sec     9.87k     1.21k    12.34k    72.31%
  Latency Distribution
     50%    2.91ms
     75%    4.12ms
     90%    8.90ms
     99%   42.10ms
  1182341 requests in 30.07s, 1.21GB read
  Socket errors: connect 0, read 12, write 0, timeout 0
Requests/sec:  39321.55
Transfer/sec:     41.22MB

怎么读:

  • p50 与 p99 的比值:健康系统通常 < 10;这里 42.1/2.91 ≈ 14.5,尾部偏长,要看下游或 GC。
  • +/- Stdev 是「落在均值 ± 一个标准差内的请求比例」:91% 说明大部分请求稳定,少数长尾拉高了均值。
  • Socket errors:connect 非零是建连失败(端口/防火墙);read 非零是服务端提前断开;timeout 非零是客户端等不到响应。
  • Non-2xx(未显示时为 0):任何非 2xx 都要按错误码分类,不能只看 Requests/sec。

必须记住:wrk -c200 是闭环压测。它测的是「200 并发下能跑多快」,不是「到达率 40,000 时延迟多少」。要测后者,用固定速率的压测器(如 wrk2 -R)或 11.3.3 的开放压测器;否则会在系统变慢时自动减压,漏掉最关键的过载区间。

11.9.4 metrics:错误率与排队时间

PromQL 查错误率:

sum(rate(http_server_requests_total{job="api", code=~"5.."}[1m]))
/
sum(rate(http_server_requests_total{job="api"}[1m]))

示例输出 0.004,即 0.4% 的 5xx,低于 1% 的警戒线;但要结合流量看:如果总 QPS 从 12,000 掉到 3,000,0.4% 可能只是「大部分请求根本没进来」。

查排队时间:

histogram_quantile(0.99,
  sum by (le) (rate(http_server_queue_wait_seconds_bucket{job="api"}[5m])))

示例输出 0.018,即 p99 排队 18 ms,相对 200 ms 的服务时间不算高;如果超过服务时间的 50%(100 ms),说明已接近膝点,应该扩容或限流,而不是继续加负载。

读什么先看 goodput 与 p99 是否同时恶化,再看 qdisc 的 backlog 和连接池等待;不要只看 CPU。
正常p99/p50 < 10,5xx < 0.5%,qdisc backlog 接近 0,队列等待 < 服务时间的 20%。
异常p99/p50 > 20,或 backlog 持续增长,或队列等待 > 服务时间,或重试比例 > 20%。

11.10 真实案例:2021-12-07 AWS us-east-1 故障

2021 年 12 月 7 日,AWS 位于北弗吉尼亚的 us-east-1 区域发生了一次影响面很大的故障。AWS 官方的复盘把原因写得很克制:

项公开事实
时间2021-12-07
触发内部网络中的一次自动化活动触发了非预期行为
直接影响连接主 AWS 网络的网络设备出现拥塞
影响范围控制面与多项服务受到影响
恢复停止自动化、恢复网络设备、逐步恢复流量
复盘官方 post-event summary

这场故障不是「某台机器坏了」,而是自动化触发了网络拥塞。它和本章的关系,不是「网络细节」,而是四个容量与治理问题:

  1. 控制面在数据面的恢复路径上。控制面受损时,自动扩缩容、健康检查、服务发现、甚至控制台本身都可能不可用;依赖「出问题后自动扩容」的系统,恰好失去了扩容能力。
  2. 自动化的爆炸半径没有上限。一条自动化动作可以在几秒内影响大量设备;如果没有速率限制、分批执行、人工确认和回滚开关,自动化就是最高效的故障放大器。
  3. 重试与健康检查会放大恢复期负载。服务不可达时,客户端重试、负载均衡健康检查、内部依赖的探活会同时增加;恢复期系统最脆弱,却要面对高于稳态的请求量。
  4. 恢复容量不等于稳态容量。设备恢复后,路由收敛、连接重建、缓存预热都需要时间;按稳态流量一次性放量,会把刚恢复的系统再次推倒。
本章机制在这次故障中的角色缺失时的后果
容量余量故障期间可用容量下降,需要 headroom 吸收剩余容量被瞬间打满
限流/降载恢复期把入口压到可控水平队列与重试把恢复拖长
熔断/降级依赖不可用时快速失败,保住核心路径线程被慢依赖耗尽
自动化边界限制单次变更的影响范围一次动作波及全区域
事实边界

本节只引用 AWS 官方复盘中的公开结论:时间、内部网络自动化活动、网络设备拥塞、控制面与多项服务受影响、停止自动化并逐步恢复。具体设备、命令、时间线和内部拓扑不复述,也不把「自动化」写成唯一根因——公开复盘强调的是自动化触发了非预期行为。原文链接:AWS post-event summary。

11.11 重试导致重复副作用:压测回放打到生产

第 10 章讲过回放:把生产流量录下来,在别处重放,用来验证新版本或复现问题。回放很有用,但它有一个致命前提:回放的是请求,不是请求的后果。如果回放工具没有隔离写路径、没有复用幂等键,它会把「测试流量」变成真实的订单、扣款和发货。

11.11.1 一次回放事故的时间线

某团队要验证新的搜索排序,从生产网关录制了 30 分钟流量(约 21,000 个请求,其中 1,400 个是写请求),准备在 staging 回放。回放工具的目标地址来自环境变量 BASE_URL;staging 的配置里漏了这一项,工具回退到默认值——生产网关的内部域名。回放又恰好从生产跳板机启动,DNS 解析到生产。

时间事件结果
t0录制 30 分钟生产流量21,000 请求,含 1,400 写
t1回放工具启动,BASE_URL 未设置,回退到生产目标指向生产
t2回放工具为「避免去重干扰」重新生成幂等键每个请求都是新逻辑请求
t3以 50 rps 回放 7 分钟21,000 请求打到生产
t4写请求全部执行380 个重复订单、190 笔重复支付
t5次日财务对账发现退款、客诉、PSP 争议

这里有三层错误,每一层单独看都「有理由」:

  • 配置回退:BASE_URL 为空时回退到生产,理由是「方便本地调试」;
  • 重新生成幂等键:理由是「复用原键会被服务端去重,测不出真实行为」;
  • 没有写保护:回放工具只区分「读/写」用于统计,不用于拦截。

三层叠加,测试流量就变成了真实副作用。事故的根因不是回放,而是回放工具默认拥有生产写权限。

11.11.2 三种回放模式,三种隔离级别

回放不是一种模式。至少要把「影子流量」「正确性回放」「压力回放」分开,每种模式的写路径和幂等键策略不同:

模式目的写路径幂等键数据
影子(shadow)比较新旧逻辑的输出全部丢弃或只写影子表不需要脱敏、只读
正确性回放复现 bug、验证修复只允许测试租户/测试环境复用原键,让服务端去重脱敏,测试账号
压力回放验证容量禁止打到生产;测试环境用合成数据确定性生成,按逻辑请求稳定合成/脱敏

关键区别在幂等键:

  • 正确性回放必须复用原键。原请求的键是 K1,回放还用 K1;服务端要么返回原结果,要么按原键去重。这样即使回放打到了有真实数据的环境,也不会产生第二次副作用。
  • 压力回放不能复用原键,否则所有回放请求都命中去重表,测不到真实负载;但新键必须是确定性的(例如 HMAC(secret, original_request_id)),保证同一逻辑请求的多次重试仍用同一个键。
  • 影子模式根本不该有写路径。写请求在代理层丢弃,或改写到一个只读的影子存储;任何「先写再删」的方案都不算影子。

11.11.3 在工具层堵住,而不是靠人记得

回放工具应该内置三道闸门:环境白名单、租户白名单、写路径开关。下面是一个最小检查器:

import hashlib
import hmac

WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
TEST_TENANTS = {"tenant-test", "tenant-canary"}


def replay_key(req, mode: str, tenant: str, secret: bytes) -> str:
    if mode not in {"shadow", "correctness", "load"}:
        raise ValueError(f"unknown mode: {mode}")
    if req.method in WRITE_METHODS:
        if mode == "shadow":
            raise PermissionError("shadow 模式不允许写请求")
        if tenant not in TEST_TENANTS:
            raise PermissionError(f"租户 {tenant} 不在回放白名单")
    if mode == "correctness" and req.method in WRITE_METHODS:
        if not req.original_idempotency_key:
            raise ValueError("正确性回放缺少原始幂等键")
        return req.original_idempotency_key          # 复用,服务端去重
    if mode == "load" and req.method in WRITE_METHODS:
        digest = hmac.new(secret, req.request_id.encode(), hashlib.sha256).hexdigest()
        return "replay:" + digest[:32]                # 确定性新键
    return ""                                         # 读请求/shadow 不写

这段代码的重点不是 HMAC,而是默认拒绝:shadow 遇到写请求直接抛异常,未知租户直接拒绝,correctness 没有原键也拒绝。默认允许的工具,迟早会在某个配置缺失的夜晚打到生产。

除此之外,还需要三层外部约束:

层措施能挡住什么
网络staging 出站白名单,禁止访问生产网关/PSP配置写错也打不到生产
凭证测试环境使用独立的 PSP key、独立的数据库账号即使请求到达,也无法扣款
工具--dry-run 打印目标/租户/写请求数;生产目标需要二次确认人为失误
数据脱敏、合成用户、禁止真实手机号/卡号隐私与合规
运行回放速率上限、kill switch、先 1 rps canary影响范围失控
例 11.11.1 · 压测回放导致的 190 笔重复支付

某支付团队把生产流量回放到 staging 做压测,回放工具默认连接串来自 PAYMENT_URL。一次发布把 staging 的 PAYMENT_URL 误配成生产 PSP 的地址,且回放工具为每个请求生成了新的幂等键。7 分钟内 190 笔支付被重复扣款,第二天对账才发现。 修复分四层:staging 网络层禁止访问生产 PSP;PSP 凭证按环境隔离;回放工具默认 shadow 模式,写请求必须显式 --allow-write --tenants tenant-test;正确性回放复用原始幂等键。复验时同样的误配只产生了 0 笔扣款,因为请求在出口就被拦住了。

11.12 容量与演练清单

把前面的内容压成两张表。容量清单用于上线前评审,演练清单用于每次 game day 前后对照。

#容量检查项达标线证据
1容量口径写明 SLO、负载模型、goodput 定义一页文档
2单副本容量有基线 + 梯度压测,不是拍脑袋压测报告
3瓶颈资源指出当前瓶颈和故障后的下一个瓶颈曲线 + 资源表
4膝点测出 p99 非线性上升的利用率梯度数据
5headroom稳态 ≤ 50%,峰值 ≤ 70%容量表
6故障域余量丢一个 AZ 后仍能接住峰值容量测算表
7依赖上限副本数 × 连接数 ≤ 下游上限 × 0.8连接数测算
8扩缩容边界min 由故障域定,max 由依赖定配置 + 评审
9限流有租户/全局两层,按 cost 计费配置 + 429 指标
10过载行为503 + Retry-After,不排队到超时破坏性压测
11自动扩缩容时延从 0 到满容量的时间有数字冷启动压测
#演练检查项达标线证据
1假设五要素齐全、可证伪演练文档
2爆炸半径有级别、有范围、有独立回滚评审记录
3停止条件量化、提前写死、指挥有权叫停runbook
4观测关键指标有面板,能判断假设面板链接
5数据保护写路径隔离、幂等键策略明确工具配置
6协同IC/注入/观察/记录四角色值班表
7恢复1 分钟内能撤销注入计时记录
8复盘每项发现都有负责人和复验日期复盘单

11.13 本章陷阱清单

12 条高频错误
  1. 用 CPU 利用率当容量口径:CPU 60% 但连接池 100% 时,系统已经在无界排队;先找瓶颈,再算 ρ\rho。
  2. 只压平均不压峰值:按日均流量规划,晚高峰的 2 倍峰值会直接越过膝点;容量按峰值和故障域算。
  3. 用闭环压测报开放延迟:系统变慢时闭环自动减压,漏掉 coordinated omission;固定到达率要用开放压测。
  4. 把压测 mock 当真实依赖:mock 1 ms、生产 300 ms,容量差一个数量级;基线必须接真实依赖或等价延迟。
  5. 把 headroom 当浪费:稳态跑到 80%,丢一个可用区后剩余节点直接过载;余量是故障预算。
  6. 限流按请求数不按成本:一个重查询和一次主键查询都算 1 QPS,瓶颈资源仍会被打穿;按 cost 计费。
  7. 单机限流当全局限流:N 个副本各限 1000/s,总量是 N×1000;要有全局桶或按副本分摊。
  8. 429 不给 Retry-After 或给 0:客户端立刻重试,限流器变成随机数生成器;给估计恢复时间并加抖动。
  9. 熔断统计业务错误:400/404/422 被算成失败,流量结构一变就误熔断;只统计超时、连接错误、5xx。
  10. 熔断没有半开或没有退避:恢复瞬间放开全部流量,下游二次崩溃;半开只放探测,OPEN 时间指数退避。
  11. 舱壁做成全局线程池:一个慢依赖吃光所有线程,其他依赖陪葬;按依赖/租户隔离资源池。
  12. 回放工具默认有生产写权限:配置缺失、复用新键、无白名单,压测流量变成真实订单;默认 shadow,写路径显式开启。

11.14 本章出口检查

检查项达标线没达标去哪
用 Little’s law 算容量给定 C 和 W 能算吞吐,给定目标能算并发11.1
算含故障域的副本数丢一个 AZ 后剩余能力 ≥ 峰值11.1
解释 1/(1-ρ)能说出 70%/90% 的放大倍数11.2
区分开放/闭环能解释 coordinated omission 并给出修正11.3
设计梯度压测有基线、步长、停止条件、预热11.3
选限流算法能说出令牌桶/漏桶/滑动窗口/并发限制的边界11.4
实现分布式限流有 Lua 原子脚本、热点分片、两层结构11.4
画熔断状态机三态、阈值、半开探测、退避齐全11.5
设计舱壁按依赖隔离线程/连接,有数字11.5
说清扩缩容边界min 由故障域定,max 由依赖定11.6
写演练假设五要素齐全、可证伪、能回滚11.7
解释回放风险能说出三种模式和幂等键策略11.11
即时自测 · 11

Q1 一个服务有 240 个工作线程,每个请求占用线程 120 ms。下游变慢后占用时间涨到 600 ms。两种情况下容量各是多少?如果目标流量是 1500 QPS,第二种情况会发生什么?

Q2 服务时间 25 ms,利用率 0.9,到达和服务都近似指数分布。用 Kingman 公式估算平均排队时间;如果把服务变异系数平方提高到 4,排队时间变成多少?

Q3 用 100 个并发客户端做闭环压测,目标 5000 QPS。服务端卡了 2 秒,压测器报告的 p99 是 2 秒。这个数字为什么低估了真实用户的等待?应该怎么测?

Q4 令牌桶 rate=100/s、burst=200,空闲 10 秒后一次来 250 个请求。多少能通过?被拒的请求最短等多久?如果客户端立刻重试,会发生什么?

Q5 熔断器窗口内 30 个请求、失败 12 个,阈值 50%、最小请求数 20。它会不会打开?如果这 12 个失败里有 8 个是 404,结论有什么不同?

Q6 压测回放打到生产,最危险的三件事是什么?分别用什么措施挡住?

答案

A1 第一种 240/0.12=2000240/0.12 = 2000 QPS;第二种 240/0.6=400240/0.6 = 400 QPS。目标 1500 QPS 时,第二种情况的需求是容量的 3.75 倍,队列会无界增长,超时和重试会把有效容量进一步压低。

A2 Kingman:Wq≈0.90.1×1+12×25=225W_q \approx \frac{0.9}{0.1} \times \frac{1+1}{2} \times 25 = 225 ms;Cs2=4C_s^2=4 时 1+42=2.5\frac{1+4}{2}=2.5,Wq≈562.5W_q \approx 562.5 ms。均值没变,只是变异性变大,排队时间翻了 2.5 倍。

A3 闭环压测在服务端卡住时只发出 100 个请求,另外本应在 2 秒内发出的约 10,000 个请求没有被记录;真实用户会排队,等待时间累积。要用开放压测(固定到达率),并从「计划发送时刻」计算延迟。

A4 前 200 个通过,后 50 个被拒;被拒请求最短等约 0.5 秒(50/100)。如果客户端立刻重试,这 50 个请求会在令牌还没补充时再次被拒,形成无效循环;应返回 Retry-After 并退避。

A5 失败率 12/30=40%12/30 = 40\%,低于 50%,不会打开。若其中 8 个是 404,真实失败只有 4 个,失败率 13.3%,更不该打开;业务错误必须从熔断统计中排除。

A6 配置回退到生产、写路径未隔离、幂等键被重新生成。对应措施:网络/凭证隔离、回放工具默认 shadow 并对写请求显式授权、正确性回放复用原键(压力回放用确定性新键)。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 5/6 章11.9背景只借用「链路/队列/丢包」的视角;TCP 拥塞控制不重写
张彦飞《深入理解 Linux 网络》第 4–7 章11.7、11.9背景tc/qdisc 只讲观测与注入,不展开内核实现
《Python 网络编程》并发与客户端11.3、11.4、11.5精选借用并发风格;令牌桶、熔断器、开放压测器为本书重写
《Linux 系统与内核》第 4 篇4.4–4.1111.7、11.9交叉引用明确不重复 TCP、qdisc 内核路径、容器网络
Google SRE 工作手册SLO、错误预算、过载保护11.2、11.5、11.6引用只引用概念与公式,不复制案例
本书自编 + AWS 公开复盘us-east-1 2021-12-0711.10完整只引用公开结论,不编造内部细节

下一站:容量、限流、降级、演练都有了,下一步要把它们落到一个能跑的工程里。进入 12 项目一:幂等工具调用后端,用一张 calls 表、一个幂等键和一个可注入故障的本地服务,把前面十一章的约束真正跑一遍。

12 项目一:幂等工具调用后端

本章回答

用 Python 标准库 + SQLite 做一个可运行的工具调用后端:客户端超时重试时,服务端保证同一个幂等键只产生一次副作用;进程崩溃、响应丢失、并发同键都不会让副作用翻倍。

读完你会

写出幂等表、请求指纹、状态机、租约与唯一约束;用代理制造「响应丢失」并证明只扣一次;用 kill -9 制造「提交后崩溃」并验证恢复;能解释为什么「重试次数」不是幂等。

第 1 章说「超时不是失败」,第 2 章给了 RPC 契约,第 6 章讲了事务与唯一约束。现在把这三件事压成一个能跑的服务:projects/idempotent-tool/。

这个项目故意做小:一个 HTTP 接口、一张 calls 表、一张 effects 表、一个 SQLite 文件。它不依赖 Redis、Kafka、Docker,也不需要云账号。小不是为了简单,而是为了让你能在一屏内看到状态、租约、唯一约束、崩溃恢复四件事如何咬合。把这几件事看清,换成 MySQL、PostgreSQL 或对象存储,逻辑是一样的。

本章按「需求 → 数据模型 → API → 算法 → 并发 → 崩溃 → 测试 → 故障注入 → 压测 → 复盘」组织。每一节都有可执行命令;不要只读代码。

12.1 目标与验收:一条命令、三个故障

先定义「做完」的标准。不是「代码能跑」,而是下面五条同时成立:

#验收项命令通过标准
1同键重试只一次副作用python3 -m unittest -v test_server.py7 个测试全过
2丢响应后同键收敛bash fault_inject.sh --drop-responseeffects == 1
3提交后崩溃可恢复bash fault_inject.sh --crash重启后 status=succeeded,effects == 1
4并发同键只一次bash fault_inject.sh --concurrent20 并发后 effects == 1
5同键不同参拒绝手工 curl 两次不同 amount第二次 409

这五条对应五个不变量。项目后面所有的代码、测试、脚本,都是为了让这五条成立。

幂等记录:同一把钥匙只能产生一次副作用 新键创建记录;执行中持有租约;成功后同键请求直接返回结果;失败可重试;租约过期可被接管。 幂等记录:同一把钥匙只能产生一次副作用 new 键不存在 in_progress lease_until 未到 succeeded 结果 + effect_id failed 可重试/不可重试 expired 租约到期,可接管 begin commit error lease 超时 同键重试接管 同键请求 返回原结果 attempts 不增加 参数不同 → 409 键相同 + 指纹不同 = 客户端 bug,必须拒绝,不能覆盖。
读图:幂等表不是「缓存响应」,而是一台状态机:键、指纹、状态、租约、结果、副作用 ID 缺一不可。只有状态机才能同时处理并发重复、崩溃恢复和参数冲突。

读这张图要抓住三件事:同键请求走「返回原结果」而不是「再执行一次」;执行中靠租约防并发;租约过期后靠 effects 的唯一约束兜底。三者缺一不可:

  • 只有键,没有状态机:并发同键会创建两条记录;
  • 只有状态机,没有租约:两个 worker 同时领取同一条记录;
  • 只有租约,没有唯一约束:租约误判(GC 停顿)时仍可能执行两次。

12.2 需求与不变量

12.2.1 功能需求

需求说明
发起工具调用POST /v1/tool-calls,带 Idempotency-Key、tool、args
查询调用GET /v1/tool-calls/{call_id},返回状态/结果/错误
按键查询GET /v1/tool-calls/by-key/{key},用于 unknown 收敛
统计GET /stats,返回 calls/effects 计数
异步执行返回 202 + call_id,worker 后台执行;客户端轮询

工具类型只有四个:charge(有副作用)、send_email(有副作用)、sleep(无副作用,用于制造长执行)、fail(模拟失败)。这不是业务,而是四种执行路径的样本。

12.2.2 不变量

定义 12.2.1 · 五条不变量
  1. 键唯一:calls.idem_key 唯一,一个键只对应一条逻辑请求。
  2. 副作用唯一:effects.call_id 唯一,一次逻辑请求最多一条副作用。
  3. 指纹一致:同键不同指纹返回 409,不覆盖原结果。
  4. 租约互斥:同一时刻最多一个 worker 持有未过期租约。
  5. 恢复不重放终态:重启只重放 pending 或租约过期的 running。

这五条里,第 2 条是最后一道闸门。前四条都可能因为时钟、GC、进程暂停、代码 bug 失效;只要 effects.call_id 有唯一约束,重复执行最多变成「重复尝试」,不会变成「重复副作用」。

12.2.3 非目标

  • 不做多租户隔离、鉴权、配额;这些是网关层的事。
  • 不做真实支付/发券;effects 表模拟外部副作用。
  • 不做分布式部署;SQLite 单机。多实例部署时,把 calls/effects 换成共享数据库,唯一约束仍然成立。
  • 不做 exactly-once 传输;只做「至少一次投递 + 幂等副作用」。

12.3 数据模型:calls、effects、audit

三张表,各司其职:

CREATE TABLE calls (
  call_id      TEXT PRIMARY KEY,
  idem_key     TEXT NOT NULL UNIQUE,
  fingerprint  TEXT NOT NULL,
  client_id    TEXT,
  seq          INTEGER,
  tool         TEXT NOT NULL,
  args_json    TEXT NOT NULL,
  status       TEXT NOT NULL,
  result_json  TEXT,
  error_json   TEXT,
  attempt      INTEGER NOT NULL DEFAULT 0,
  lease_until  REAL,
  owner        TEXT,
  effect_id    TEXT,
  created_at   REAL NOT NULL,
  updated_at   REAL NOT NULL
);
CREATE INDEX idx_calls_status ON calls(status, lease_until);
CREATE INDEX idx_calls_client_seq ON calls(client_id, seq);

CREATE TABLE effects (
  effect_id   TEXT PRIMARY KEY,
  call_id     TEXT NOT NULL UNIQUE,
  tool        TEXT NOT NULL,
  amount      INTEGER,
  created_at  REAL NOT NULL
);

CREATE TABLE audit (
  id       INTEGER PRIMARY KEY AUTOINCREMENT,
  ts       REAL NOT NULL,
  call_id  TEXT,
  event    TEXT NOT NULL,
  detail   TEXT
);

12.3.1 为什么 calls.idem_key 要唯一

唯一约束是并发去重的第一道闸门。两个并发请求同时到达,SQLite 的 BEGIN IMMEDIATE 会把写事务串行化:

  • 第一个事务插入 K1,提交;
  • 第二个事务再插 K1,撞唯一约束;
  • 服务端捕获冲突,改为查询已存在的记录并返回。

如果没有唯一约束,两个事务都可能插入成功,产生两条 call;后面即使 effects 唯一,客户端也会拿到两个不同的 call_id,无法判断哪个是「原请求」。

12.3.2 为什么 effects.call_id 要唯一

租约可能误判。假设 worker A 执行 charge 时 GC 停顿 5 秒,租约过期;worker B 接管同一条 call,也执行 charge。如果 effects 没有唯一约束,就会扣两次。

有了 effects.call_id UNIQUE,B 的插入会失败(或 INSERT OR IGNORE 忽略),B 仍然把 call 标成 succeeded,返回同一个 effect_id。外部副作用必须用这个 effect_id 作为下游幂等键,否则数据库唯一约束只能保护本地表。

12.3.3 为什么需要 attempt 和 owner

attempt 是物理尝试次数,用于观测「这个请求被重试了几次」。owner 是当前租约持有者(pid:thread_id),用于排障:看到 owner 是已经退出的进程,就知道需要恢复。

lease_until 用 time.time()(墙上时钟)存储,因为要跨进程/重启比较;租约时长要大于工具的最长执行时间,否则会被误接管。项目默认 30 秒,sleep 最多 10 秒,留了余量。

12.3.4 审计表不是装饰

audit 记录 created、claimed、succeeded、failed、retry_reset、recover 等事件。它不参与正确性,但对排障很关键:当用户说「我重试了三次」时,你可以从 audit 还原每次 attempt 的时间线,而不是只看最终状态。

12.4 API 契约

12.4.1 发起调用

POST /v1/tool-calls HTTP/1.1
Host: 127.0.0.1:8081
Idempotency-Key: demo-1
Content-Type: application/json

{"tool":"charge","args":{"amount":100},"client_id":"demo","seq":1}

规则:

  • Idempotency-Key 必填;缺失返回 400。
  • tool 必填;args 必须是对象。
  • 服务端计算 fingerprint = SHA-256(canonical(tool, args))。
  • 新键:插入 pending,返回 202 + call。
  • 已存在且指纹相同:返回 200/202 + 原 call,不重新执行。
  • 已存在但指纹不同:409。
  • 已存在且 failed 且 retryable=true:重置为 pending,返回 202。

响应示例:

{
  "call_id": "c_9f2a1b3c4d5e6f70",
  "idem_key": "demo-1",
  "status": "pending",
  "attempt": 0,
  "result": null,
  "error": null,
  "query_url": "/v1/tool-calls/c_9f2a1b3c4d5e6f70"
}

12.4.2 查询

curl -sS http://127.0.0.1:8081/v1/tool-calls/c_9f2a1b3c4d5e6f70 | python3 -m json.tool
curl -sS http://127.0.0.1:8081/v1/tool-calls/by-key/demo-1 | python3 -m json.tool

按 call_id 查询用于客户端拿到 202 后轮询;按 key 查询用于「客户端连 call_id 都没拿到」的 unknown 场景。

12.4.3 状态码

状态码何时客户端动作
202新请求/重试受理轮询 call_id,或稍后按键查询
200已终态(成功/失败)读 result/error,不要重发新键
400缺键/坏 JSON/参数错修正请求
409同键不同指纹换键(新逻辑请求)或修参数
500服务端异常按 unknown 查询/同键重试
503过载/未就绪退避,按 Retry-After

注意 202 不是「成功」。客户端看到 202 只能认为「已受理」,必须轮询或按键查询直到终态。

12.4.4 错误结构

{"error":{"code":"idempotency_conflict",
          "message":"same key, different fingerprint"}}

错误码要稳定;不要用自然语言给客户端做分支。项目里的错误码只有 missing_idempotency_key、bad_request、idempotency_conflict、not_found、retry_exhausted。

12.5 核心算法:键、指纹、状态机、租约

12.5.1 键与指纹

客户端生成 Idempotency-Key,同一个逻辑请求的所有重试复用它。服务端不生成键,因为服务端生成的键在第二次请求时会变。

指纹的规范化:

import hashlib, json

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

def fingerprint(tool: str, args: dict) -> str:
    payload = json.dumps({"tool": tool, "args": args}, sort_keys=True,
                         separators=(",", ":"), ensure_ascii=False)
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()

sort_keys=True 解决 key 顺序;separators 去掉空格差异;ensure_ascii=False 保持中文稳定。还不够:浮点数 1 vs 1.0、Unicode 归一化、空值省略,都要在契约里统一(第 1.10 节)。

12.5.2 创建或获取

核心逻辑是「一个写事务里查 + 插」:

def create_or_get(key, fp, tool, args, client_id, seq):
    with db_conn() as conn:
        conn.execute("BEGIN IMMEDIATE")
        row = conn.execute("SELECT * FROM calls WHERE idem_key=?", (key,)).fetchone()
        if row is not None:
            if row["fingerprint"] != fp:
                raise ValueError("same key, different fingerprint")
            if row["status"] == "failed" and retryable(row):
                conn.execute("UPDATE calls SET status='pending' ...")
                conn.commit()
                return reload(row["call_id"]), True
            conn.commit()
            return row, False
        call_id = "c_" + uuid.uuid4().hex[:16]
        conn.execute("INSERT INTO calls(...) VALUES(...,'pending',0,...)")
        conn.commit()
        return reload(call_id), True

BEGIN IMMEDIATE 直接拿写锁,避免「两个事务都读到不存在、都插入」的竞态。SQLite 的默认延迟事务是 BEGIN DEFERRED,两个读事务可以同时通过,然后一个写失败;用 IMMEDIATE 把冲突提前到事务开始。

12.5.3 领取与执行

def claim(call_id, owner):
    with db_conn() as conn:
        conn.execute("BEGIN IMMEDIATE")
        row = select(call_id)
        if row.status in ("succeeded", "failed"):
            return None
        if row.status == "running" and row.lease_until > now():
            return None
        conn.execute("UPDATE calls SET status='running', attempt=attempt+1, "
                     "lease_until=?, owner=? WHERE call_id=? AND ...", ...)
        conn.commit()
        return row

UPDATE ... WHERE status!='succeeded' AND ... 的条件更新是关键:即使两个 worker 同时进入,只有一个能更新成功(rowcount == 1),另一个看到 rowcount == 0 退出。这和「先查再改」是两种语义:后者有 TOCTOU 窗口。

12.5.4 提交副作用

def succeed(call_id, result, effect_id):
    with db_conn() as conn:
        conn.execute("BEGIN IMMEDIATE")
        conn.execute("INSERT OR IGNORE INTO effects(effect_id, call_id, tool, amount, created_at) "
                     "VALUES(?,?,?,?,?)", (...))
        conn.execute("UPDATE calls SET status='succeeded', result_json=?, "
                     "effect_id=?, lease_until=NULL, owner=NULL WHERE call_id=?", (...))
        conn.commit()

副作用插入与状态更新在同一个事务里。但外部副作用(真实扣款)不能和本地事务原子提交;项目用 effects 表模拟,真实系统要用「下游幂等键 + 本地状态 + 对账」三件套。第 12.14 节会讲 Stripe 的做法。

12.5.5 状态机

pending → running → succeeded | failed;failed 且 retryable 可以回到 pending;succeeded/failed 是终态。unknown 在客户端侧,不在服务端状态表里:服务端总是知道自己的记录状态,客户端不知道。

当前事件下一个条件
pendingworker 领取running租约可用
running工具成功succeededeffects 唯一
running工具失败failederror.retryable
running租约过期pending恢复线程
failed同键重试pendingretryable=true
succeeded同键请求succeeded返回原结果

12.6 并发:SQLite 事务、写锁与唯一约束

12.6.1 为什么用 BEGIN IMMEDIATE

SQLite 默认的事务是 BEGIN DEFERRED:事务开始时只拿读锁,第一次写才升级为写锁。两个并发请求可能都读到「键不存在」,然后都尝试插入;第一个成功,第二个在升级写锁时失败(SQLITE_BUSY),或者更糟,两个都插入(如果没有唯一约束)。

项目用 BEGIN IMMEDIATE 在事务开始就申请写锁,把「查 + 插」变成串行:

conn.execute("BEGIN IMMEDIATE")
try:
    row = conn.execute("SELECT * FROM calls WHERE idem_key=?", (key,)).fetchone()
    if row is None:
        conn.execute("INSERT INTO calls(...) VALUES(...)")
    conn.commit()
except Exception:
    conn.rollback()
    raise

代价是写吞吐受限于单写者。SQLite 的 WAL 模式允许「一写多读」,但同一时刻只有一个写事务。对教学项目够用;对高并发生产,换成 PostgreSQL/MySQL,思路不变:唯一约束 + 事务。

12.6.2 连接、WAL 与 busy_timeout

每个线程开自己的连接,不要跨线程共享 sqlite3.Connection(它不是线程安全的):

def db_conn(path):
    conn = sqlite3.connect(path, timeout=10, isolation_level=None)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA synchronous=FULL")
    conn.execute("PRAGMA busy_timeout=5000")
    try:
        yield conn
    finally:
        conn.close()
  • journal_mode=WAL:读写不互相阻塞,崩溃恢复更好;
  • synchronous=FULL:提交时 fsync,牺牲一点性能换持久性;
  • busy_timeout=5000:遇到写锁时等 5 秒再报错,避免瞬时并发直接 500。

12.6.3 并发同键的三种结局

12 个并发请求使用同一个键,可能出现三种结局:

结局机制结果
一个插入,其他查到唯一约束 + BEGIN IMMEDIATE所有请求返回同一个 call_id
一个执行,其他等待租约未过期时 claim 返回 None其他请求 202,轮询后看到 succeeded
一个执行,租约过期后另一个接管租约过期 + effects 唯一副作用仍只有一条,attempt 增加

项目测试 test_concurrent_same_key_one_effect 用 12 个线程验证第一种/第二种:

test_concurrent_same_key_one_effect ... ok

测试断言:

ids = {r["call_id"] for r in results}
self.assertEqual(len(ids), 1, results)
self.wait_status(results[0]["call_id"])
self.assertEqual(self.effects(), before + 1)

注意 results 里可能有些请求返回 pending、有些返回 running、有些返回 succeeded;状态不同不重要,call_id 相同才重要。

12.6.4 为什么不用「先查再插」的乐观锁

乐观锁的做法是:

SELECT ...; if not exists: INSERT ... ON CONFLICT DO NOTHING; SELECT ...

它也能工作,但需要处理 ON CONFLICT 后的重查,而且「查不到」和「插入冲突」之间有一个窗口。BEGIN IMMEDIATE 把窗口关掉,代码更短。代价是写串行;在教学项目里,这是正确的取舍。

12.6.5 连接池与线程数

ThreadingHTTPServer 为每个连接开线程;ThreadPoolExecutor(max_workers=4) 执行工具。两个池是分开的:

  • HTTP 线程池:处理请求、读写 SQLite;
  • 工具线程池:执行 charge/sleep,可能很慢。

如果把工具执行放在 HTTP 线程里,长工具会占满连接;项目用 202 + 后台 worker 避免这一点。第 11 章会讲这两个池的容量怎么估。

12.7 崩溃恢复:只重放该重放的

12.7.1 启动时的恢复

进程重启后,内存里的 in_flight 集合丢失,但 SQLite 里还有 running 记录。恢复逻辑:

UPDATE calls
SET status='pending', lease_until=NULL, owner=NULL, updated_at=?
WHERE status='running' AND (lease_until IS NULL OR lease_until <= ?)

只恢复租约过期的 running;租约未过期的记录可能还有另一个 worker 在执行(多实例场景),不要抢。单机重启时所有租约都「过期」了吗?不一定:租约默认 30 秒,重启可能在 30 秒内完成,lease_until 还在未来,恢复会跳过,记录会卡住。

项目的 App.start() 先调用 recover(),然后启动 scheduler;scheduler 每 200 ms 再调用一次 recover()。如果重启很快,最多等租约到期(30 秒)才恢复。这是可用性与安全性的取舍:宁可等,也不要抢一个可能还在执行的 worker。生产环境可以用「进程启动时把所有 owner 属于本机的 running 记录立即回收」来加速,因为同一台机器重启后旧进程一定不在了。

12.7.2 提交后崩溃

项目支持一个测试用的 chaos 参数:_crash_after_commit。执行路径:

claim → 执行 charge → BEGIN IMMEDIATE
  → INSERT effects
  → UPDATE calls SET status='succeeded'
  → COMMIT
  → os._exit(1)          # 响应还没写回客户端

崩溃后数据库里是 succeeded + 一条 effect。重启后:

  • recover() 只处理 running,不会动 succeeded;
  • 客户端用同一个键重试,create_or_get 查到 succeeded,直接返回原结果;
  • effects 仍然只有一条。

测试输出(fault_inject.sh --crash):

calls: [('c_acbd4b8f43d741eb', 'succeeded', 1)]
effects: [('c_acbd4b8f43d741eb', 'eff_e48a346e200ee715')]
status=succeeded attempt= 1
{"calls": 1, "effects": 1, "by_status": {"succeeded": 1}}

这里的关键不是「崩溃后能恢复」,而是「恢复后不会把已经提交的副作用再执行一次」。succeeded 是终态,恢复逻辑必须跳过它。

12.7.3 崩溃在「副作用之后、状态更新之前」

如果外部副作用已经发生,但本地事务还没提交,崩溃后本地表里是 running,恢复会重新执行。此时:

  • 本地 effects 表没有记录,唯一约束帮不上忙;
  • 下游必须用 effect_id 做幂等键,才能挡住第二次扣款。

项目用「同事务插入 effects + 更新 calls」模拟了「本地原子」,但真实外部调用不可能和本地事务原子。这就是为什么幂等键必须传到下游。第 12.14 节的 Stripe 做法也是同一个思路:key 由客户端生成,服务端保存结果,重试返回原结果。

12.7.4 恢复的边界

场景恢复行为风险
崩溃在 claim 之前记录是 pending,重新提交无
崩溃在 claim 之后、副作用之前running 租约过期,重新执行无副作用
崩溃在副作用之后、本地提交之前running 租约过期,重新执行下游可能重复,需要下游幂等
崩溃在本地提交之后succeeded,跳过无
崩溃在响应写出之前succeeded,客户端 unknown,同键查询无

这张表说明:本地事务只能保证「本地状态 + 本地副作用记录」原子,不能保证「外部副作用 + 本地状态」原子。后者必须靠下游幂等或对账。

12.8 客户端重试与轮询

12.8.1 同键重试

client.py 的核心循环:

def call_tool(base, key, tool, args, timeout=1.0, retries=3):
    for attempt in range(retries + 1):
        try:
            status, payload = request_json(
                "POST", base + "/v1/tool-calls",
                body={"tool": tool, "args": args},
                headers={"Idempotency-Key": key},
                timeout=timeout)
            if status in (200, 201, 202, 409, 400, 422):
                return payload
        except (TimeoutError, URLError, ConnectionError):
            pass
        time.sleep(min((2 ** attempt) * 0.1, 1.0) + random.random() * 0.05)
    return {"error": {"code": "retry_exhausted"}}

三个细节:

  1. key 在循环外生成,循环内不变;
  2. 只有超时/连接错误/429/503 才重试;400/409 立即返回;
  3. 退避加随机抖动,避免同步重试。

12.8.2 故意换键的演示

客户端有一个 --new-key-on-retry 参数:

python3 client.py --base http://127.0.0.1:8081 \
  --key demo-bad --tool charge --amount 100 \
  --timeout 0.1 --retries 3 --new-key-on-retry

每次重试的键变成 demo-bad-0、demo-bad-1……服务端会创建多条 call、多条 effect。这个参数不是给人用的,是用来复现「重试换键 = 重复副作用」的。运行后看 /stats,effects 会大于 1。

12.8.3 轮询

202 只表示受理。客户端应轮询:

def poll(base, call_id, timeout=5.0):
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        status, payload = request_json("GET", f"{base}/v1/tool-calls/{call_id}")
        if payload.get("status") in ("succeeded", "failed"):
            return payload
        time.sleep(0.1)
    return payload

轮询要有上限和退避,不要无限循环;生产环境用 Retry-After 或指数退避。

12.8.4 客户端不知道 call_id 怎么办

如果第一次请求超时,客户端可能连 call_id 都没拿到。此时用「按幂等键查询」:

curl -sS http://127.0.0.1:8081/v1/tool-calls/by-key/demo-1

如果查询也失败,用同一个键重试 POST;服务端幂等表会返回原 call。不要生成新键。

12.9 代码走读:server.py

12.9.1 结构

server.py
├─ fingerprint / canonical_args      请求指纹
├─ CallStore                         SQLite 状态机
│  ├─ create_or_get                  查/插 + 冲突
│  ├─ claim                          租约领取
│  ├─ succeed / fail                 终态迁移
│  ├─ recover                        崩溃恢复
│  └─ stats                          观测
├─ Runner                            工具执行 + 租约
│  ├─ submit                          去重提交
│  ├─ _execute                        claim → invoke → succeed/fail
│  └─ _invoke                         charge/send_email/sleep/fail
├─ App                               生命周期 + scheduler
└─ Handler                           HTTP 契约

12.9.2 三个关键函数

create_or_get:处理「新键 / 旧键 / 冲突 / 可重试失败」四种情况。它返回 (call, should_submit),只有 should_submit=True 才提交 worker。

claim:用条件 UPDATE 领取,rowcount 判断是否成功。它不信任内存状态,所有判断都回到 SQLite。

succeed:在一个事务里插入 effect + 更新 call。INSERT OR IGNORE 让重复插入不报错,但仍然保证只有一条 effect。

12.9.3 错误路径

位置异常处理
_read_json坏 JSON/空 body400
create_or_get同键不同指纹409
claim已被别人领取/终态直接返回,不执行
_invokeToolErrorfail 落状态
_invoke未预期异常fail,retryable=true
succeed数据库错误事务回滚;worker 记录错误

项目没有做「失败后无限自动重试」。failed 且 retryable=true 的记录只有客户端再次同键请求才会重置,避免服务端自己形成重试风暴。

12.9.4 为什么没有用 ORM

ORM 会把事务边界藏起来,读者看不清 BEGIN IMMEDIATE、唯一约束、rowcount。这个项目的目的是教学,所以直接用 SQL。真实项目可以用 ORM,但必须知道它生成的事务和锁。

12.10 测试:七个场景

12.10.1 测试清单

cd projects/idempotent-tool
python3 -m unittest -v test_server.py
测试验证
test_same_key_returns_same_call同键同参只执行一次,返回原 call
test_different_key_creates_new_call不同键创建新 call
test_same_key_different_args_conflict同键不同参 409
test_concurrent_same_key_one_effect12 并发同键只有一条 effect
test_failed_retryable_can_be_retried可重试失败同键重试
test_crash_after_commit_then_restart提交后崩溃、重启恢复
test_delay_response_then_retry_same_key代理丢首个响应、同键收敛

一次实际输出(节选):

test_same_key_returns_same_call ... ok
test_different_key_creates_new_call ... ok
test_same_key_different_args_conflict ... ok
test_concurrent_same_key_one_effect ... ok
test_failed_retryable_can_be_retried ... ok
test_crash_after_commit_then_restart ... ok
test_delay_response_then_retry_same_key ... ok

----------------------------------------------------------------------
Ran 7 tests in 1.916s

OK

12.10.2 测试没有覆盖什么

  • 多进程并发:测试用线程,SQLite 写锁在进程间也成立,但没有验证多实例;
  • 真实外部副作用:effects 表是本地模拟,没有验证下游幂等;
  • 磁盘满/数据库损坏:没有注入;
  • 网络分区:项目一是单机服务,没有多副本;
  • 长时间运行:没有验证 WAL 增长、租约漂移。

这些不是「不重要」,而是留给第 13 章(分区/复制)和第 11 章(故障演练)。一个项目的测试边界要写清楚,不能假装覆盖了所有失败。

12.10.3 如何加一个测试

以「同键不同参数返回 409」为例:

def test_same_key_different_args_conflict(self):
    self.post("k-conflict", args={"amount": 1})
    status, payload = self.post("k-conflict", args={"amount": 2})
    self.assertEqual(status, 409, payload)
    self.assertEqual(payload["error"]["code"], "idempotency_conflict")

关键是第二个请求必须复用同一个 key;如果测试里重新生成 key,就测不到冲突。

12.11 观测与故障注入:丢响应的证据链

12.11.1 先看 stats,再看日志

项目提供两个观测面:

curl -sS http://127.0.0.1:8081/stats | python3 -m json.tool
{"calls": 1, "effects": 1, "by_status": {"succeeded": 1}}

calls 是逻辑请求数,effects 是副作用数。正常情况下,对同一个键,calls 和 effects 都应该只在第一次增加;如果 calls 增加而 effects 没增加,说明请求被去重了;如果 effects 增加超过 calls,说明有重复副作用(项目里不应发生)。

按 call 看状态迁移:

sqlite3 /tmp/idem.db "SELECT call_id,status,attempt,effect_id FROM calls;"
sqlite3 /tmp/idem.db "SELECT call_id,count(*) FROM effects GROUP BY call_id HAVING count(*)>1;"
sqlite3 /tmp/idem.db "SELECT call_id,event,detail FROM audit ORDER BY id;"

期望:

c_9f2a...|succeeded|1|eff_...
(第二条查询无输出)
c_9f2a...|created|charge
c_9f2a...|claimed|8123:617...
c_9f2a...|succeeded|eff_...

第二条查询必须为空。如果有输出,说明 effects.call_id 的唯一约束被绕过(例如建表时漏了 UNIQUE)。

12.11.2 故障注入脚本

cd projects/idempotent-tool
bash fault_inject.sh --dry-run
bash fault_inject.sh --drop-response
bash fault_inject.sh --crash
bash fault_inject.sh --concurrent

脚本自己创建临时 DB、启动服务、清理进程,不需要 root。三个场景分别验证:

场景注入期望
--drop-response代理丢弃第一个响应客户端第二次同键请求拿到结果,effects=1
--crash服务端提交后 os._exit(1)重启后按 key 查到 succeeded,effects=1
--concurrent20 个并发同键请求只有一个 call_id,effects=1

12.11.3 一次丢响应的完整证据

--drop-response 的实际输出(节选):

[fault] 场景 1:服务端执行成功,但第一次响应被代理丢弃;客户端用同一个键重试
{"attempt": 0, "error": "timed out", "key": "drop-1792374004"}
{"attempt": 1, "http": 202, "key": "drop-1792374004", ...}
...
{"calls": 1, "effects": 1, "by_status": {"succeeded": 1}}

这段输出对应第 1 章的「三种真相」:客户端第一次看到 timeout,服务端其实已经执行;第二次同键请求被服务端识别为重复,返回原 call。客户端仍然看到「几次尝试」,但副作用只有一次。

12.11.4 代理模式

chaos_proxy.py 支持四种模式:

模式行为用途
pass透传基线
drop-first丢第一个响应,之后透传模拟「执行成功、响应丢失」
drop-response所有响应都丢模拟持续网络故障
delay-response延迟响应模拟慢网络/超时
duplicate-request把请求转发两次模拟重复投递

duplicate-request 是最危险的:服务端会收到两个不同的物理请求,如果它们用同一个键,幂等表能挡住;如果客户端每次换键,就会产生两条 effect。这正是第 12.15 节要对比的两种结局。

12.12 压测:数字与边界

12.12.1 压测方法

用 Python 标准库起 20 个并发线程,向本机服务发 300 个请求:

import concurrent.futures, json, time, urllib.request

BASE = "http://127.0.0.1:8081"

def one(i):
    body = json.dumps({"tool": "charge", "args": {"amount": 1}}).encode()
    req = urllib.request.Request(
        BASE + "/v1/tool-calls", data=body, method="POST",
        headers={"Content-Type": "application/json",
                 "Idempotency-Key": f"bench-{i}"})
    t = time.perf_counter()
    with urllib.request.urlopen(req, timeout=5) as resp:
        resp.read()
    return time.perf_counter() - t

start = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as ex:
    lat = sorted(x * 1000 for x in ex.map(one, range(300)))
wall = time.perf_counter() - start
print("rps", 300 / wall, "p50", lat[150], "p99", lat[296])

12.12.2 本机实测

环境:macOS 15 / Apple Silicon / Python 3.13 / SQLite 3.50,服务端 8 个 worker,300 个不同键:

n=300  wall=0.430s  rps=697.9
p50=1.75ms  p95=138.75ms  p99=258.16ms  max=380.65ms
http_202=300  status0=0
calls=300  effects=300

同一把键、300 个并发请求:

n=300  wall=0.152s  rps=1975.9
p50=1.20ms  p95=39.36ms  p99=112.61ms
calls=1  effects=1

12.12.3 怎么读这组数字

  • 不同键的 p99 是 258 ms:瓶颈在 SQLite 单写者 + 8 个 worker 的排队,不是网络。把 worker 加到 32,p99 可能更差,因为写锁竞争更激烈。
  • 同键的 p99 是 112 ms:大部分请求在读已经提交的记录,只有第一个请求执行副作用;吞吐更高,但这不是「服务更快」,而是「大部分请求没做事」。
  • calls=1, effects=1:同键压测的核心验收。如果 effects 随并发增长,说明去重失效。
  • status0=0:300 个请求都拿到了 HTTP 响应;如果有 status0,先看 accept backlog、连接池、服务端异常。

12.12.4 压测的边界

这不是生产压测:

  • 单机回环,没有网络延迟和丢包;
  • charge 只写本地表,没有真实下游;
  • 没有混合读写、没有长尾请求;
  • 没有测崩溃恢复期间的成功率。

把数字当成「这个实现的量级」,不要当成「生产容量」。第 11 章会讲怎么在真实环境做梯度压测、容量估算和故障演练。

12.12.5 一个容易被忽略的指标:attempt 分布

压测时除了 p99,还要看 attempt 的分布:

sqlite3 /tmp/idem.db "SELECT attempt, count(*) FROM calls GROUP BY attempt ORDER BY attempt;"

正常情况下,同键重试的 attempt 应该集中在 1;如果大量记录 attempt > 1,说明:

  • 租约太短,正常执行被误判过期;
  • worker 不够,请求排队超过租约;
  • 工具执行时间超过预期;
  • 有进程反复崩溃/重启。

attempt 是幂等系统里最容易被忽略的指标:它不直接反映延迟,但能提前暴露「租约与执行时间不匹配」的问题。第 12.13 节会把它和丢响应的时间线放在一起看。

12.13 失败模式:丢响应后的重复调用

12.13.1 时间线

客户端                         服务端                         SQLite
  │ POST K1, timeout=1s          │                              │
  ├─────────────────────────────▶│ create_or_get(K1)            │
  │                              ├─────────────────────────────▶│ INSERT pending
  │                              │ claim → running              │
  │                              │ charge                       │
  │                              ├─────────────────────────────▶│ INSERT effects
  │                              │ succeed                      │
  │                              ├─────────────────────────────▶│ UPDATE succeeded
  │        ... 响应在代理处丢失 ...│                              │
  │ timeout                      │                              │
  │ POST K1(同键重试)            │                              │
  ├─────────────────────────────▶│ create_or_get(K1)            │
  │                              ├─────────────────────────────▶│ SELECT succeeded
  │ 200 + 原结果                  │                              │
  │◀─────────────────────────────┤                              │

关键点:第二次请求没有走到 charge,而是在 create_or_get 就返回了原结果。去重发生在执行之前,不是执行之后。

12.13.2 如果没有幂等键

把 K1 换成 K2:

第一次:POST K1 → 执行 → 扣 100
超时
第二次:POST K2 → 执行 → 扣 100

服务端看到的是两个不同的逻辑请求,任何去重表都救不了。这就是为什么第 1 章说「重试时换键」是最常见的重复副作用来源。

12.13.3 如果服务端没有 effects 唯一约束

即使客户端复用 K1,如果两个 worker 同时执行:

worker A: claim → running → charge → INSERT effects
worker B: claim(租约过期)→ running → charge → INSERT effects

没有唯一约束,两条 effect 都会插入。calls 可能只有一条,但 effects 有两条;对账时发现「一次调用扣了两次」。

12.13.4 如果租约太长/太短

  • 太长:worker 崩溃后,记录要等很久才恢复,客户端一直 202;
  • 太短:正常执行被误判过期,另一个 worker 接管,attempt 增加;靠 effects 唯一兜底。

项目的默认 30 秒是「最长工具执行 10 秒 + 余量」。生产环境要按 p99 执行时间设置,并监控 attempt > 1 的比例。

12.14 真实案例:Stripe 的 idempotency key

Stripe 的支付 API 用 Idempotency-Key 解决同一个问题:客户端重试 POST 时,服务端不能重复创建扣款。

公开文档的关键点:

  • 客户端为每个「逻辑请求」生成一个唯一的 key,通常用 UUID;
  • 同一个 key 的重试返回第一次的结果,而不是重新执行;
  • key 有保留期限;过期后同键可能被当成新请求(具体期限以文档为准);
  • 如果同键但参数不同,Stripe 会返回错误,而不是静默覆盖;
  • key 应该由客户端生成,而不是服务端;服务端生成的 key 在重试时会变。

这和项目的设计一一对应:

Stripe项目
Idempotency-Key headerIdempotency-Key header
保存第一次响应calls.result_json
同键返回原结果create_or_get 返回 succeeded
同键不同参报错409 idempotency_conflict
key 过期项目未实现 TTL,生产要加

差异也要说清楚:Stripe 是支付公司,内部有账务/对账/风控;项目只有一个 SQLite 文件。不要因为项目跑通就以为「支付幂等」做完了;真实系统还要处理部分退款、币种、结算延迟、下游超时、对账差异。

事实边界

本节只引用 Stripe 公开文档的接口语义,不引用其内部实现。链接见 A.3 参考文献。

12.15 重试导致重复副作用:同一把钥匙的两种结局

12.15.1 结局 A:复用 K1

# 第一次(可能超时)
curl -sS -X POST http://127.0.0.1:8081/v1/tool-calls \
  -H 'Idempotency-Key: K1' -H 'Content-Type: application/json' \
  -d '{"tool":"charge","args":{"amount":100}}'

# 重试(同一把钥匙)
curl -sS -X POST http://127.0.0.1:8081/v1/tool-calls \
  -H 'Idempotency-Key: K1' -H 'Content-Type: application/json' \
  -d '{"tool":"charge","args":{"amount":100}}'

结果:

calls=1  effects=1
第二次返回同一个 call_id,attempt 不增加

12.15.2 结局 B:换 K2

curl -sS -X POST http://127.0.0.1:8081/v1/tool-calls \
  -H 'Idempotency-Key: K2' -H 'Content-Type: application/json' \
  -d '{"tool":"charge","args":{"amount":100}}'

结果:

calls=2  effects=2
两个不同的 call_id,两条扣款

12.15.3 对照表

维度复用 K1换 K2
服务端看到的逻辑请求1 个2 个
去重是否可能可能不可能
副作用1 条2 条
客户端复杂度需要保存 key每次生成新 key(简单但错)
失败后的对账按 key 查只能按用户/时间/金额猜

正确做法不是「不重试」,而是「重试时携带身份」。这也是第 1、2 章反复强调的那句话。

12.15.4 如果用户又点了一次

用户手动点「重试」时,客户端可能生成新 key。这不算 bug,但业务上要区分「同一个意图的重试」和「用户发起了一个新意图」。常见做法:

  • 按钮在请求进行中禁用;
  • 用「业务键」(用户 + 券模板 + 活动 + 时间窗)做第二层去重;
  • 对账时按业务键聚合,而不是按 HTTP 请求。

项目只做了第一层(幂等键);第二层留给业务设计。

12.16 本章陷阱清单

12 条高频错误
  1. 重试时重新生成 UUID:服务端看到新请求,去重表失效;key 必须在第一次发送前生成并保存。
  2. 把 call_id 当幂等键:call_id 是服务端生成的,第二次请求拿不到同一个;幂等键必须客户端生成。
  3. 只对成功响应做去重:服务端在 pending/running 时也要能识别重复,返回原 call 而不是新建。
  4. 同键不同参静默返回旧结果:用户以为改了金额,实际没改;必须 409。
  5. 用 SELECT 再 INSERT 而没有唯一约束:并发下会插两条;唯一约束是底线。
  6. 用 BEGIN DEFERRED 做「查 + 插」:两个事务都可能读到不存在;用 BEGIN IMMEDIATE 或唯一约束兜底。
  7. 把租约当成绝对互斥:GC/暂停会让租约过期;effects 唯一约束和下游幂等才是最终保障。
  8. 租约短于工具执行时间:正常执行被接管,attempt 暴涨;租约要覆盖 p99 执行时间。
  9. 恢复时重放 succeeded:会重复副作用;恢复只处理 pending 和租约过期的 running。
  10. 以为本地事务能覆盖外部副作用:外部调用不在 SQLite 事务里;必须把 effect_id 传给下游做幂等。
  11. 压测用同一个键就宣称高吞吐:大部分请求只是读已提交记录;要用不同键测真实写入路径。
  12. 没有 TTL/清理:幂等表会无限增长;生产要按业务保留期清理,并处理「过期后同键重放」。

12.17 本章出口检查

检查项达标线没达标去哪
说清五条不变量能背出键唯一、effect 唯一、指纹、租约、恢复12.2
画出数据模型calls/effects/audit 三表与唯一约束12.3
写对 API 契约202/200/409/503 的语义12.4
实现创建或获取BEGIN IMMEDIATE + 唯一约束 + 指纹12.5、12.6
实现租约领取条件 UPDATE + rowcount12.5
解释崩溃恢复只重放 pending/过期 running12.7
用代理丢响应drop-first + 同键重试12.11
用 kill 制造崩溃提交后退出,重启查询12.11
读懂 statscalls/effects 的关系12.11
做一次压测不同键/同键各一组,报告 p50/p9912.12
说出 Stripe 的对应关系key、结果保存、冲突12.14
解释 K1/K2 两种结局复用 vs 换键12.15
即时自测 · 12

Q1 客户端第一次请求超时,服务端已提交。客户端换新键重试,服务端会创建几条 call、几条 effect?

Q2 同键不同参数,服务端返回 200 + 第一次结果,会有什么业务风险?应该返回什么?

Q3 两个 worker 同时领取同一条 call,只有一个 rowcount=1,另一个怎么办?

Q4 崩溃发生在「插入 effects 之后、更新 calls 之前」,恢复后会怎样?项目如何避免重复副作用?

Q5 为什么同键 300 并发压测的 calls=1, effects=1,但吞吐比不同键更高?

Q6 租约设置为 1 秒,工具执行平均 2 秒,会发生什么?怎么修?

答案

A1 两条 call、两条 effect。新键被当成新逻辑请求,服务端没有依据把它和第一次关联起来。

A2 用户以为第二次参数(例如金额)生效了,实际返回的是第一次结果,造成静默错误。应返回 409 idempotency_conflict,让客户端修正键或参数。

A3 claim 返回 None,worker 直接退出,不执行工具;请求返回 202/200,客户端轮询已有 call。rowcount=0 是并发控制的正常分支,不是错误。

A4 如果 effects 已提交、calls 还是 running,恢复会重新执行;但 effects.call_id 唯一约束会让第二次插入被忽略。项目把「插入 effects + 更新 calls」放在同一个事务里,所以本地不会出现这个中间态;外部副作用仍需要下游幂等。

A5 同键请求大部分在 create_or_get 就读到 succeeded,直接返回,没有走工具执行和写状态;吞吐高不代表写入路径快,压测要区分「读已提交」和「执行新请求」。

A6 正常执行会被误判过期,另一个 worker 接管,attempt 增加;effects 唯一约束避免重复副作用,但延迟和日志会变差。修法:租约 > p99 执行时间,并监控接管率。

母本对应

母本章节本书落点程度说明
《Python 网络编程》socket/HTTP 服务、并发服务器12.4、12.8、12.9精选借用 HTTP 服务与客户端工程风格;代码全部重写
谢希仁《计算机网络》第 8 版第 6 章 应用层12.4背景HTTP 语义只作背景,不重写报文
张彦飞《深入理解 Linux 网络》第 6–7 章(连接、socket)12.6、12.9背景连接/线程模型只引用,不展开内核
《Linux 系统与内核》第 4 篇4.4–4.1112.6、12.8交叉引用明确不重复 TCP/socket/epoll/HTTP
本书自编 + Stripe 文档Idempotent requests12.14完整只引用公开接口语义,不复制实现
本书项目代码projects/idempotent-tool/12.3–12.12完整所有代码为本书编写,测试实测

下一站:单机幂等做完了,但数据只有一份。进入 13 项目二:小型复制 KV/一致性读,把状态复制到三台机器,并处理分区、选主和线性一致读。

13 项目二:小型复制 KV/一致性读

本章回答

三个 Python 进程如何用简化 Raft 选出一个 leader,把一次 put 复制到多数派并提交?客户端超时重试时,(client_id, seq) 怎样保证状态机只应用一次?linearizable、lease、stale 三条读路径各自看到了什么,为什么分区期间旧 leader 的读必须被拒绝?

读完你会

能画出三节点选举与日志复制的时间线;能解释 prevLogIndex/prevLogTerm、冲突截断、当前 term 提交规则和 no-op 的作用;能用 kv_client.py 与 /status 复现一次分区、杀主和恢复;能写出一个检查「读后写」与重复 seq 的一致性检查器,并说清它没有证明什么。

第 5 章讲了 Raft 的规则,第 3、4 章讲了复制与一致性模型,第 12 章把一个单机幂等服务拆到了状态机、租约和唯一约束。现在把这三条线接起来:projects/replicated-kv/ 用三个 Python 进程、一个 SQLite 文件和一个 HTTP 接口,实现选举、日志复制、提交、线性一致读和客户端去重。

这个项目仍然故意做小。它没有成员变更、没有日志压缩、没有快照安装,也没有真实网络分区;它只有三个节点、两种 RPC(RequestVote 与 AppendEntries)、三条读路径和一个 (client_id, seq) 去重表。小不是为了绕过难点,而是为了让你能在一屏内看到term、日志索引、commitIndex、appliedIndex、lease 五个数字如何互相约束。把这五个数字看清,换成 etcd、TiKV 或 MySQL Group Replication,协议名不同,约束是一样的。

本章按「验收 → 不变量 → 架构 → 选举 → 复制 → 客户端 → 读 → 持久化 → 代码 → 测试 → 故障注入 → 检查器 → 复盘」组织。每一节都给出可执行命令或可核对输出;不要只读代码。

13.1 目标与验收:三条命令与六个测试

先定义「做完」。不是「三个进程能启动」,而是下面六条同时成立:

#验收项命令通过标准
1写入并线性一致读回python3 -m unittest -v test_kv.py6 个测试全过
2旧 leader 分区后读失败bash fault_inject.sh --partition <leader>线性一致读返回 503,多数派选出新 leader
3杀 leader 后不丢已提交值python3 -m unittest -v test_kv.FailoverTest.test_kill_leader_then_new_leader_keeps_committed_value新 leader 选出且旧值仍在
4同 (client_id, seq) 重复写只应用一次test_3_duplicate_put_same_seq第二次 duplicate=true,版本不变
5三节点 dedup 表一致测试内直接查三个 SQLite每个节点该 client_id 只有 1 行
6恢复后不重复应用重启节点后查 kv 与 dedup版本不倒退,dedup 不重复

这六条对应六个可观察结果。项目后面所有代码、测试和脚本,都是为了让这六条成立;如果某条不成立,先回到对应小节,不要靠「再跑一次」掩盖时序问题。

三节点复制:日志、matchIndex 与 commitIndex leader 把日志复制到两个 follower;多数派 matchIndex 达到 3 后 commitIndex 推进到 3,状态机应用。 三节点复制:日志、matchIndex 与 commitIndex 客户端 put k=v Leader A term=2, commit=2 Follower B match=2 Follower C match=3 append AppendEntries AppendEntries ack 2 ack 3 日志索引 1 已提交 2 已提交 3 已提交 4 未提交 commitIndex = 3:多数派(A、C)match ≥ 3 状态机 apply 到 3;读请求至少等待 applied ≥ readIndex 若 leader 崩溃:新 leader 必须包含 index 3 的日志(选举限制)。
读图:提交不是「leader 写了」而是「多数派持久化了」。图中 follower B 只到 2,但 A+C 已到 3,所以 3 可以提交;如果 leader 在提交前崩溃,新 leader 必须带上已提交的日志。

读这张图要抓住三件事:提交不是 leader 写了,而是多数派持久化了;commitIndex 推进后还要等状态机 apply;选举限制保证新 leader 一定包含已提交日志。三者缺一不可:

  • 只有复制,没有多数派提交:leader 崩溃后,客户端见过成功响应的写可能丢失;
  • 只有 commitIndex,没有 apply:读请求可能看到比 commitIndex 旧的 kv 表;
  • 只有投票,没有日志新旧检查:一个落后节点也可能当选,已提交值被覆盖。

13.1.1 非目标

不做原因生产对应
成员变更固定三节点足够展示 quorum;联合共识是另一章的内容etcd 的 learner/成员变更 API
日志压缩与快照教学项目运行时间短;快照要处理安装与截断Raft snapshot + InstallSnapshot
真实网络分区不引入 root、iptables、netem;用应用层 RPC 阻断tc netem、安全组、交换机 ACL
多 key 事务只支持单 key put/get;事务会引入 MVCC/2PCPercolator、Spanner
鉴权与 TLS本机回环,默认信任边界内mTLS、token、审计

这些非目标不是「以后再说」的借口,而是本章的边界。读完后你应该能判断:换到生产,哪些地方必须补,哪些不变量可以原样保留。

13.2 不变量与故障模型

Raft 的论文有五条安全性,但落到这个项目,最重要的是下面五条。它们不是口号,每一条都能在代码里找到对应位置。

定义 13.2.1 · 项目二的五条不变量
  1. 选举安全:同一 term 最多一个 leader;每个节点每个 term 最多投一票,且投票持久化。
  2. 日志匹配:如果两个日志在相同 index 有相同 term,则该 index 及之前的日志完全相同;prevLogIndex/prevLogTerm 是它的检查点。
  3. 提交规则:leader 只直接提交当前 term 的日志项;旧 term 的日志项通过提交当前 term 的项间接提交。
  4. 去重:状态机对同一 (client_id, seq) 最多应用一次;重复写返回第一次的结果。
  5. ReadIndex:线性一致读必须确认多数派仍认这个 leader,再等 last_applied >= readIndex,才能读本地状态机。

这五条里,第 1、2、3 条保证「已提交的写不会丢」,第 4 条保证「重复的写不会变成两个副作用」,第 5 条保证「读不会退回旧值」。它们分别对应选举、复制、提交、客户端、读路径;任何一条单独看都不够。

13.2.1 故障模型

故障项目里怎么出现协议应该怎么应对项目没有覆盖的
进程崩溃kill leader,或测试里 terminate()重新选举;持久化 term/vote/log磁盘损坏、半写
网络分区/admin/partition 阻断 RPC少数派不能提交;旧 leader 停止服务读真实丢包、延迟、乱序
消息延迟本机回环,延迟很低超时、重试、随机选举跨机房 RTT 与抖动
消息重复客户端重试、RPC 重发(client_id, seq) 去重重复 RPC 的所有边界
节点暂停没有注入 GC/STWlease 可能过期;ReadIndex 仍安全进程暂停、时钟跳变
客户端超时响应丢失、leader 切换同 seq 重试;查询状态客户端崩溃后的状态恢复

项目默认非拜占庭:节点不会伪造消息,但会崩溃、会暂停、会丢包。这个假设很重要;如果节点会说谎,投票和日志匹配都不够,需要另一套协议。

崩溃与分区的区别

崩溃是「进程不在了」,分区是「进程还在,但另一半听不到它」。前者可以靠重启恢复;后者更危险,因为旧 leader 仍然认为自己活着,仍然能读本地 SQLite。第 13.13 节会看到:linearizable 读会拒绝,lease 读却可能在一个时间窗内返回旧值。

13.3 架构:三个进程、两种 RPC、一个状态机

三个节点各自是一个 raft_node.py 进程,监听 7101/7102/7103;每个进程既是 Raft 节点,也是 HTTP 服务端。客户端只跟 leader 打交道,通过 307 跟随重定向。

                       ┌─────────────────────────────┐
                       │  kv_client.py               │
                       │  put / get / status         │
                       │  307 跟随,最多 3 跳          │
                       └──────────────┬──────────────┘
                                      │ HTTP
              ┌───────────────────────┼───────────────────────┐
              │                       │                       │
              ▼                       ▼                       ▼
     ┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
     │ node 1 :7101    │     │ node 2 :7102    │     │ node 3 :7103    │
     │ role/term       │     │ role/term       │     │ role/term       │
     │ log[]           │     │ log[]           │     │ log[]           │
     │ commit/applied  │     │ commit/applied  │     │ commit/applied  │
     │ SQLite          │     │ SQLite          │     │ SQLite          │
     └────────┬────────┘     └────────┬────────┘     └────────┬────────┘
              │  RequestVote / AppendEntries(双向 HTTP)       │
              └───────────────────◀───┼───▶───────────────────┘
                                      │
                         多数派 = 2(三节点中的任意两台)

这张图里有三个容易忽略的点:

  1. RPC 是双向的:leader 向 follower 发 AppendEntries,follower 也会向 leader 发 RequestVote(选举时)。不存在单独的「从节点只读」通道。
  2. 每个节点都有完整日志和状态机:不是分片,不是主从只读副本;三个节点都能在选举后成为 leader。
  3. 客户端不参与 quorum:客户端只负责跟随 307、复用 (client_id, seq);它不知道 commitIndex,也不应该猜。

13.3.1 HTTP 接口

方法路径谁调用作用
GET/healthz测试/运维进程存活,返回节点 id
GET/status客户端/测试/排障角色、term、commit、applied、lease、blocked_peers
POST/client/put客户端写一个 key;leader 才接受
GET/client/get?key=&consistency=客户端三种一致性读
POST/request-vote候选节点拉票
POST/append-entriesleader复制日志/心跳
POST/admin/partition故障注入阻断与指定 peer 的 RPC
POST/admin/heal故障注入清除阻断

/admin/* 没有鉴权,只适合本机教学;生产环境暴露它等于把集群交给任何人。

13.3.2 三个线程与一把锁

线程周期做什么共享状态
_tick_loop20 msfollower 检查选举超时;leader 发心跳、检查 quorum ackrole、election_deadline、last_quorum_ack
_apply_loop20 ms把 commit_index 之前的日志应用到 SQLitelast_applied、kv、dedup
HTTP handler每请求处理投票、复制、客户端读写全部,靠 Node.lock(RLock)保护

Node.lock 是粗粒度锁:选举、复制状态、日志数组、lease 都在它下面。这样做的好处是不会出现「日志和 commitIndex 各改各的」;代价是吞吐受限,且持锁时不能做慢 I/O。项目里 _post 和 SQLite 写入都在锁外或短临界区,读者可以对照代码看边界。

13.3.3 运行参数

参数默认值含义调大/调小的后果
--election-ms500选举超时基准;实际随机 0.5–1.5 倍太小易误选;太大故障恢复慢
--heartbeat-ms100leader 心跳间隔太大 follower 易超时;太小 RPC 多
--lease-ms800leader lease 时长太长分区期间旧主读窗口大;太短读容易 lease_expired
quorum2(peers+1)//2+1三节点固定 2;五节点为 3

测试里把三个值改成 350/80/600,是为了让 6 个测试在几秒内跑完;默认值用于手工演示。参数不是「随便调」:lease_ms 与 election_ms 的关系直接决定 lease 读的安全性,第 13.7 节会展开。

13.4 选举:term、随机超时与投票限制

先看一次选举的时间线。三个节点刚启动,都是 follower,current_term=0,各自在 [0.5, 1.5] × election_ms 后超时。

node 1                 node 2                 node 3
  │                      │                      │
  │ follower             │ follower             │ follower
  │ election deadline    │ election deadline    │ election deadline
  │ 最早到期              │                      │
  ├── term=1, voted=1 ───▶│                      │
  │   RequestVote         │                      │
  │◀── vote_granted=true ─┤                      │
  ├── RequestVote ────────┼─────────────────────▶│
  │◀── vote_granted=true ─┼──────────────────────┤
  │ votes={1,2,3} >= 2   │                      │
  ├── become_leader       │                      │
  │   append no-op(idx=1) │                      │
  ├── AppendEntries ─────▶│                      │
  ├── AppendEntries ──────┼─────────────────────▶│
  │◀── success match=1 ───┤                      │
  │◀── success match=1 ───┼──────────────────────┤
  │ commit_index=1        │                      │

图里有两个细节:候选人的票先投给自己;拿到多数派后立刻追加 no-op,而不是等第一个客户端写。no-op 的作用在第 13.5 节讲,这里先记住:新 leader 需要一条当前 term 的日志来建立提交点。

13.4.1 角色与 term

角色会做什么会拒绝什么
follower响应投票、接受 AppendEntries、选举超时后变 candidateterm 更小的 RPC
candidateterm+1、投自己、并行拉票同 term 的第二个 candidate 的票
leader追加日志、复制、推进 commit、服务读不响应投票(同 term 已有 leader)

term 是逻辑时钟,不是「谁的时间戳大」。每次选举 term+1;节点看到更高 term 就无条件更新并退回 follower。term 只用于选举和拒绝旧消息,不用来判断日志是否最新;日志新旧由 (last_log_term, last_log_index) 决定。

13.4.2 随机超时:把冲突摊开

_reset_election_deadline() 的实现是:

lo, hi = self.election_ms * 0.5, self.election_ms * 1.5
self.election_deadline = mono() + random.uniform(lo, hi) / 1000.0

如果三个节点同时超时,它们会同时 term+1、同时投自己、同时拉票,很可能谁都不够多数派,然后下一轮再撞。随机区间的目的不是「公平」,而是降低同时竞选的概率;它不能消除 split vote,只能让系统在期望几轮内收敛。

Raft 论文用固定的 150–300 ms(或类似区间),工程上常按网络 RTT 的倍数设置。太小,网络抖动就会触发误选;太大,leader 崩溃后恢复慢。项目的 500 ms 基准对应本机回环,生产要按 p99 RTT 和 GC 停顿调。

13.4.3 投票规则:三张否决票

handle_vote 的决策可以压成三张否决票:

否决条件代码里的检查为什么
term 更小if term < self.current_term: deny旧 term 的候选人没有资格
这个 term 已经投给别人voted_for not in (None, cand)一 term 一票,防止两个 leader
候选人日志不够新last_log_term < self.last_log_term,或 term 相同但 last_log_index < self.last_log_index保证新 leader 包含已提交日志

三条都通过才投票。投票前先把 voted_for 写进 SQLite 的 meta 表;如果先回复再持久化,节点在回复后崩溃、重启后可能再次投票,同一 term 出现两张票。

一段简化后的代码:

def handle_vote(self, body: dict) -> dict:
    term = int(body.get("term", 0))
    if term < self.current_term:
        return {"term": self.current_term, "vote_granted": False}
    if term > self.current_term:
        self._step_down(term)
    cand = int(body.get("candidate_id", 0))
    up_to_date = (
        int(body.get("last_log_term", 0)) > self.last_log_term
        or (int(body.get("last_log_term", 0)) == self.last_log_term
            and int(body.get("last_log_index", 0)) >= self.last_log_index)
    )
    granted = False
    if self.voted_for in (None, cand) and up_to_date:
        self.voted_for = cand
        self._persist_meta()          # 先持久化,再回复
        self._reset_election_deadline()
        granted = True
    return {"term": self.current_term, "vote_granted": granted}

这段代码没有检查「候选人是不是自己」;候选人会给自己投票,走的是 _start_election 里的 votes = {self.id},不经过 handle_vote。

为什么一 term 一票就够了

三节点的两个多数派必然相交({1,2} 与 {2,3} 共享 2)。如果每个节点在 term T 只投一票,两个候选人不可能同时拿到两个多数派;所以同一 term 最多一个 leader。这个论证不依赖时钟、不依赖网络延迟,只依赖「投票持久化 + 多数派相交」。

13.4.4 选举限制:为什么落后节点不能当选

假设 node 3 落后,只应用到 index 5;node 1 和 node 2 已经提交到 index 7。node 3 超时后 term+1 拉票:

  • node 1 的 last_log_index=7,node 3 只有 5,拒绝;
  • node 2 同理拒绝;
  • node 3 最多拿到自己一票,不够 2,选举失败。

反过来,如果 node 3 的日志是 (term=3, index=7),node 1 是 (term=2, index=7),node 3 的 last term 更大,node 1 会投票——因为 term 更大的日志意味着它包含了旧 term 的所有已提交项(Raft 的 log matching + leader completeness)。

这条规则是「已提交值不丢」的第二道闸门。第一道是多数派提交,第二道是选举限制;缺了任何一道,旧 leader 崩溃后都可能选出一个没有最新日志的节点。

13.4.5 什么时候会没有 leader

现象原因处理
三个 candidate 反复竞选随机超时撞车/split vote等下一轮;检查网络与 election_ms
一个 candidate 拿不到票日志落后或 term 太旧先补齐日志;不要手工改 term
leader 频繁 step down收不到多数派 ack、或看到更高 term查分区、last_quorum_ack、隔离节点的 term
客户端一直 503 no_leader没有多数派,或选举被阻断先恢复网络,再查 /status

_tick_loop 里还有一条「check quorum」的简化:leader 超过 2 × election_ms 没有收到成功复制,就 _step_down()。它让旧 leader 在分区后尽快停止服务写,但它不是 lease,不能阻止旧 leader 在窗口内继续读;第 13.13 节会看到这个区别。

13.5 日志复制与提交:prevLogIndex、冲突截断与当前 term 规则

选举只解决「谁来写」,复制解决「写到哪里才算数」。Raft 的日志是一个从 1 开始的数组,每个 entry 有 idx、term、command;leader 的 next_index[peer] 是下一次要发给该 peer 的起点,match_index[peer] 是已确认复制的最高 index。

13.5.1 一次 put 的复制路径

client                leader A              follower B           follower C
  │ POST /client/put     │                     │                     │
  ├─────────────────────▶│ role=leader?        │                     │
  │                      │ append idx=4,term=2 │                     │
  │                      ├─ AppendEntries ────▶│                     │
  │                      ├─ AppendEntries ─────┼────────────────────▶│
  │                      │◀─ success match=4 ──┤                     │
  │                      │  A+B = 2 = quorum   │                     │
  │                      │  commit_index=4     │                     │
  │                      │  apply idx=4        │                     │
  │◀─ 200 + version=4 ───┤                     │                     │

注意顺序:leader 先追加本地日志,再并行复制;拿到多数派 match_index >= 4 后才推进 commit_index;_apply_loop 把 entry 应用到 SQLite 的 kv 表;最后才返回 200。客户端看到 200 时,值已经在 leader 的状态机里,且多数派持久化了日志。

13.5.2 AppendEntries 的五个关键字段

字段作用如果错了
termleader 的任期follower 可能接受旧 leader
prev_log_index新 entry 前一条的 index无法定位前缀
prev_log_term前一条的 term只看 index 会接受冲突日志
entries从 prev+1 开始的日志空洞、重复、顺序错
leader_commitleader 已知的提交位置follower 提交滞后或越界

follower 的处理顺序是:

  1. 如果 term < current_term,拒绝;
  2. 如果 term > current_term,更新 term、退回 follower;
  3. 如果 prev_log_index > last_log_index,拒绝,并返回自己的 last_log_index;
  4. 如果 prev_log_index > 0 且 log[prev-1].term != prev_log_term,拒绝,并返回 prev-1;
  5. 对 entries 逐条:如果 index 已存在且 term 相同,跳过;如果 term 不同,从该 index 截断;如果 index 不连续,拒绝;
  6. 追加新 entry;
  7. commit_index = min(leader_commit, last_log_index)。

第 3、4 步是「日志匹配」检查。只比 index 不比 term 是不够的:两个节点可能在同一个 index 上有不同 term 的 entry(旧 leader 未提交的尾巴),只看 index 会把冲突日志当成一致。

13.5.3 冲突截断:删掉未提交的尾巴

假设 node 3 在 term 2 是 leader,追加了 index 6、7,但只复制到 node 3 自己就分区了;node 1 和 node 2 在 term 3 选出新 leader,日志只到 index 5。恢复后,新 leader 给 node 3 发 prev_log_index=5, prev_log_term=3:

  • node 3 的 index 6 是 term 2,与 leader 的 index 6(term 3)冲突;
  • node 3 从 index 6 开始截断,删掉 6、7;
  • 接受新 leader 的 6、7。

被截断的 entry 从未提交,所以删除是安全的。已提交的 entry 永远不会被截断,因为它已经在多数派上,而新 leader 的选举限制保证它包含这些 entry。

_truncate_from(idx) 同时删 SQLite 的 log 表和内存 self.log:

def _truncate_from(self, idx: int) -> None:
    conn = self._conn()
    try:
        conn.execute("DELETE FROM log WHERE idx>=?", (idx,))
    finally:
        conn.close()
    self.log = self.log[:idx - 1]

如果进程在 DELETE 之后、内存截断之前崩溃,重启时 _load() 从 SQLite 重新读日志,内存会回到正确状态;顺序反过来(先改内存再删库)则可能留下「内存没有、磁盘有」的尾巴。持久化代码的顺序要和恢复路径一起看,不能只看正常运行。

13.5.4 提交规则:为什么只直接提交当前 term

_advance_commit() 从后往前找最大的 n,满足「自己 + 足够多的 follower 的 match_index >= n」,并且 log[n-1].term == current_term:

def _advance_commit(self) -> None:
    if self.role != "leader":
        return
    for n in range(self.last_log_index, 0, -1):
        count = 1 + sum(1 for p in self.peers
                        if self.match_index.get(p, 0) >= n)
        if count < self.quorum:
            continue
        if self.log[n - 1]["term"] != self.current_term:
            continue          # 旧 term 的 entry 不能直接提交
        if n > self.commit_index:
            self.commit_index = n
            self._persist_meta()
        return

为什么不能直接提交旧 term 的 entry?因为「多数派复制了」不等于「未来不会选出另一个 leader 覆盖它」。Raft 的 Figure 8 给出了经典反例:一个旧 term 的 entry 在多数派上,但另一个节点可能在更晚的 term 当选并覆盖它。只有当前 term 的 entry 提交后,它之前的旧 term entry 才随之一并提交。

这条规则和 no-op 配合:

  • 新 leader 当选后追加一条当前 term 的 no-op;
  • no-op 复制到多数派后提交;
  • no-op 之前的旧 term entry 被间接提交;
  • 状态机按顺序 apply,旧 entry 不会跳过。

如果新 leader 不追加 no-op,它可能没有任何当前 term 的 entry 可提交,旧 term 的日志就只能一直悬着;读请求也会看到一个不确定的 commitIndex。

13.5.5 复制失败时如何回退

_replicate_peer() 在失败时执行:

match = int(resp.get("match_index", 0))
self.next_index[peer] = max(1, min(ni - 1, match + 1))
  • 如果 follower 的日志太短,返回 match_index=last_log_index,leader 跳到 match+1,一次跳过一大段;
  • 如果 prev_log_term 不匹配,follower 返回 prev-1,leader 只回退一格,下一次再试。

第二种情况在长日志上会很慢:如果冲突点在很前面,leader 需要多次 RTT 才能回退到匹配点。生产实现常用「按 term 二分」或「返回冲突 term 的第一个 index」来加速;项目为了代码短,只回退一格。这是教学简化,不是性能建议。

13.5.6 commitIndex 与 appliedIndex 不是一回事

指标含义谁推进读之前要等吗
commit_index多数派已持久化的最高日志leader 的 _advance_commit,follower 的 leader_commit线性一致读要等 applied >= readIndex
last_applied状态机已经应用到 SQLite 的最高日志_apply_loop是;否则读到旧 kv
log_len本地日志长度append/truncate不等于 commit;可能含未提交尾巴

在 /status 里,正常情况下三者满足 last_applied <= commit_index <= log_len。last_applied < commit_index 是短暂的 apply lag;如果持续很久,说明 apply 线程卡住或 SQLite 写入失败。log_len > commit_index 是未提交尾巴,正常;分区恢复后会被截断或提交。

13.6 客户端协议:307 跟随与 (client_id, seq) 去重

客户端只做三件事:找到 leader、带上身份、在超时后复用身份。kv_client.py 的 follow() 处理第一件,handle_put 的 dedup 处理第二件,seq 的生成规则处理第三件。

13.6.1 307 跟随

follower 收到写请求时返回:

HTTP/1.1 307 Temporary Redirect
Location: http://127.0.0.1:7103
Content-Type: application/json

{"error":"not_leader","leader_id":3,"term":12}

307 与 302 的区别是:307 要求客户端保持原方法和 body 重发。kv_client.py 的 follow() 最多跟 3 跳,超过返回 508 too_many_redirects:

def follow(base, method, path, body=None, timeout=2.0, hops=3):
    current = base.rstrip("/")
    for _ in range(hops):
        status, payload, headers = request(current, method, path, body, timeout)
        if status == 307:
            loc = headers.get("location")
            if not loc:
                return status, payload, current
            current = loc.rstrip("/")
            continue
        return status, payload, current
    return 508, {"error": "too_many_redirects"}, current

三个边界:

  • 如果 follower 不知道 leader(leader_id=None),返回 503 no_leader,不是 307;客户端应退避,而不是换一个新 seq 重发。
  • 如果 leader_id 指向自己(异常状态),_redirect() 会返回 307 到自己;follow() 可能在同一地址上打转,直到 3 跳后 508。生产实现应避免这种自指。
  • follow() 只跟随 307,不自动重试 503/超时;重试策略在调用方。kv_client.py 的 put 只发一次,超时后需要人工/脚本重跑。

13.6.2 身份:client_id 与 seq

kv_client.py 的默认 client_id 是 cli-<8位随机十六进制>,seq 默认 1。每次运行 put,如果没有显式传 --client-id,都会生成新的 client_id;这意味着两次命令行调用不会被当成同一个逻辑请求。做重试实验时必须显式固定这两个值:

# 第一次:写入并记住 (c1, 7)
python3 kv_client.py --key foo --value bar --client-id c1 --seq 7 put

# 同键重试:服务端应返回 duplicate=true,版本不变
python3 kv_client.py --key foo --value bar --client-id c1 --seq 7 put

# 新逻辑请求:seq 变成 8,服务端会再写一条日志
python3 kv_client.py --key foo --value bar --client-id c1 --seq 8 put

一次实际输出(节选):

{
  "http": 200,
  "served_by": "http://127.0.0.1:7101",
  "response": {
    "key": "foo",
    "value": "bar",
    "version": 2,
    "duplicate": true,
    "served_by": 1,
    "term": 1
  }
}

第二次响应的 version 还是 2,duplicate=true,没有 index 字段;第一次响应会有 index=2。index 是这次追加的日志位置,version 是第一次应用时的日志位置;去重命中时,服务端返回的是 dedup.result_json,所以只有 version。

13.6.3 去重表:主键就是身份

CREATE TABLE dedup (
  client_id      TEXT NOT NULL,
  seq            INTEGER NOT NULL,
  result_json    TEXT NOT NULL,
  applied_index  INTEGER NOT NULL,
  PRIMARY KEY (client_id, seq)
);

handle_put 在追加日志前先查一次:

existing = self._get_dedup(client_id, seq)
if existing is not None:
    return 200, {**existing, "duplicate": True,
                 "served_by": self.id, "term": self.current_term}, {}

但这只是「提议前去重」,不是最终保障。真正的保障在 _apply_entry:应用每条 put 时,先查 dedup,命中就跳过;没命中才写 kv 并插入 dedup。为什么两层都要?

  • 提议前查,能避免大部分重复日志,减少日志膨胀;
  • apply 时查,能覆盖「新 leader 还没 apply 完就收到重试」「两个 entry 同时带着同一个 seq」的竞态。

如果只在提议前查,两个并发请求可能都查不到、都追加,最后状态机应用两次;如果只在 apply 时查,日志里会有大量重复项,但状态机仍然只应用一次。正确性靠 apply 时的唯一约束,性能靠提议前的快速路径。

13.6.4 三种重复语义

场景服务端行为结果是否安全
同 (client_id, seq)、同 value返回 duplicate=true 与原结果kv 不变,日志不新增安全
同 (client_id, seq)、不同 value仍返回第一次结果,duplicate=true第二次 value 被静默忽略项目未做 409,是已知边界
不同 seq、同 key/value当成新请求追加日志version 增加,kv 值相同对 put 幂等;对 incr/append 会重复副作用
缺 client_id 或 seq不查 dedup、不写 dedup每次都是新写去重静默失效

第三行很重要:这个项目的 put 是覆盖写,所以「重复写同一个 value」只多一条日志、值不变;如果换成 incr、append、transfer,同样的重试就会变成两次副作用。去重保护的是「同一逻辑请求」,不是「同一个 key」。

第四行是接口层的坑:handle_put 只校验 key/value,不强制 client_id/seq;不传时 _apply_entry 的 if client_id is not None and seq is not None 直接跳过 dedup。生产接口应把这两个字段设为必填,或在缺失时拒绝。

13.6.5 202 与 503 不是成功

handle_put 在两种情况不返回 200:

  • _wait_commit 超时,但角色仍是 leader:返回 202 + {"status":"pending","index":...};
  • _wait_commit 超时,且已经不是 leader:返回 503 leadership_lost。

202 表示「日志已追加,但还没确认提交」;客户端不能当成功,也不能换新 seq,应该用同一个 seq 重试或轮询。kv_client.py put 把 202 也当成功(return 0 if status in (200, 202)),因为它只是演示客户端;生产客户端要区分 200 与 202,并对 202 做轮询。

503 可能是 no_leader、leadership_lost、no_quorum、apply_lag。它们的共同点是「这次请求没有完成」;不同点是「能不能换 leader 重试」。leadership_lost 可以跟 307 到新 leader;no_leader 要先等选举;apply_lag 可以稍后重试同一请求。

13.7 一致性读:linearizable、lease、stale

写路径只关心「多数派提交」,读路径要回答另一个问题:我怎么知道现在读到的值,不是某个旧 leader 留下的旧值? 项目给了三条路径,代价和安全性各不相同。

ReadIndex:线性一致读为什么要一次心跳 leader 记录当前 commitIndex 作为 readIndex,向多数派确认自己仍是 leader,再等 applied ≥ readIndex。 ReadIndex:线性一致读为什么要一次心跳 客户端 Leader Follower 多数派 GET k (linearizable) heartbeat(确认 leadership) ack(多数派) readIndex = commit 记录当前提交位置 等待 applied ≥ readIndex 返回 200 + value 若心跳拿不到多数派 → 拒绝读,不返回旧值 lease read 省掉心跳,但依赖时钟;ReadIndex 不依赖时钟,代价是多一次 RTT。
读图:如果 leader 已经被分区隔离,它本地仍有数据,直接读会返回旧值。ReadIndex 的心跳确认「多数派还认我」,然后再等状态机追上;两步缺一不可。

13.7.1 三条路径

consistency谁可以服务机制失败时安全性
linearizable仅 leader记录 commit_index 作为 readIndex;向多数派发 AppendEntries 确认 leadership;等 applied >= readIndex;读本地 kv503 no_quorum / apply_lag分区下安全;多一次 RTT
lease仅 leaderlease_until > now 时直接读 commit_index 对应的状态机;等 applied503 lease_expired / apply_lag依赖 lease 与时钟;分区窗口内可能旧读
stale任意节点直接读本地 last_applied 的 kv200,stale=true;可能 found=false明确允许旧读;不能用于强一致

三条路径都返回 read_index、applied_index、served_by、term、leader_id。这些字段不是装饰:排障时先看 read_index 与 applied_index 是否相等,再看 served_by 是不是你以为的节点。

13.7.2 ReadIndex 的两步

_read_index() 的实现可以简化成:

def _read_index(self):
    with self.lock:
        if self.role != "leader":
            return None
        ri = self.commit_index
        term = self.current_term
        peers = list(self.peers)
    acks = 1
    for p in peers:
        if p in self.blocked_peers:
            continue
        resp = self._replicate_peer(p)   # 复用 AppendEntries 作为心跳
        if resp and resp.get("term") == term and resp.get("success"):
            acks += 1
    with self.lock:
        if self.role != "leader" or self.current_term != term:
            return None
        if acks >= self.quorum:
            self.last_quorum_ack = mono()
            return ri
    return None

两步缺一不可:

  1. 确认 leadership:向多数派发一次 AppendEntries,收到同 term 的成功响应。如果旧 leader 被分区,它拿不到多数派,返回 503;如果它已经看到更高 term,_step_down 会让 role != leader,也返回 503。
  2. 等 apply 追上:ri 是发起读时的 commit_index,但状态机可能还没应用到那里;_wait_applied(ri) 等到 last_applied >= ri,再读 kv。如果只做第一步,可能读到一个比 commit_index 旧的 kv 表。

为什么 ri 要在确认 leadership 之前记录?因为读的线性化点可以放在「确认多数派」那一刻:所有在 readIndex 之前提交的写都已经在本地日志里,确认之后新提交的写可以排在这条读之后。把 ri 记在确认之后也不一定错,但会要求更强的同步;项目选择前者,和 etcd 的 ReadIndex 思路一致。严格实现还应把「当前 term 已有一条已提交日志」作为 ReadIndex 的前置条件;项目在 _become_leader 追加 no-op,但 _read_index 没有显式检查这一点,依赖 no-op 很快提交。

13.7.3 lease 读:省一次 RTT,换一个假设

lease 路径不向多数派发心跳,只看本地 lease_until:

elif consistency == "lease":
    with self.lock:
        if self.role != "leader" or mono() >= self.lease_until:
            return 503, {"error": "lease_expired"}, {}
        ri = self.commit_index
    if not self._wait_applied(ri, timeout=1.0):
        return 503, {"error": "apply_lag", "read_index": ri}, {}

lease_until 在每次成功的 AppendEntries 响应后延长 lease_ms。它的安全论证是:如果 leader 还能在 lease 内服务读,那么多数派在 lease 到期前不会选出新 leader;只要 lease 没有过期,本地读就不会落后于新 leader 的写。

这个论证有两个前提:

  • 时钟假设:lease 用单调时钟计算,进程暂停/GC 可能让实际时间超过 lease;项目没有处理进程暂停。
  • lease 必须短于选举超时:否则 follower 可以在旧 leader 的 lease 到期前选出新 leader,旧 leader 仍会服务读。

项目的默认值是 lease_ms=800、election_ms=500(随机 250–750 ms)。800 > 750,所以存在一个时间窗:新 leader 已经选出并提交了新写,旧 leader 的 lease_until 还没到,lease 读仍会返回旧值。测试里用的是 600/350(随机 175–525 ms),窗口更大。这不是说 lease 读一定错,而是说:这个项目的 lease 路径是演示,不是线性一致的替代品;需要强一致时用 linearizable。README 也把 lease 列为「依赖单调时钟与本地假设」的已知边界。

13.7.4 stale 读:把旧值标出来

stale 路径不检查角色,直接 ri = last_applied,读本地 kv,返回 stale=true。它可能:

  • 读到一个 follower 还没 apply 的 key,返回 found=false;
  • 读到一个旧版本,version 小于 leader 的最新版本;
  • 在 leader 切换期间读到前任的旧值。

这些都不是 bug,而是契约:调用方明确选择了「可以旧」。如果业务把它当强一致读用,后果由调用方承担。测试 test_2_stale_read_from_follower 就是等 follower 追上后,断言 stale=true 且值正确;它没有断言 follower 一定落后,因为「旧」是允许的,不是保证。

13.7.5 读路径的选择

业务建议理由
余额、库存、配置开关linearizable读到旧值会导致错误决策
高频只读、可容忍秒级旧值stale任意节点可服务,扩展性好
读多写少、能接受 lease 假设lease省一次 RTT;但要先验证 lease < election
调试/对账stale + 版本比对先看各节点 applied_index,再决定是否重读

这张表没有「默认选 lease」。默认应该是 linearizable;只有当你能量化「旧读的代价」并且验证过 lease 假设时,才降到 lease/stale。

13.8 持久化与恢复:SQLite 里到底存了什么

Raft 的正确性依赖「投票、term、日志在回复 RPC 之前已经持久化」。项目用 SQLite 存四张表:

CREATE TABLE meta (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL
);
CREATE TABLE log (
  idx INTEGER PRIMARY KEY,
  term INTEGER NOT NULL,
  command TEXT NOT NULL
);
CREATE TABLE kv (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL,
  version INTEGER NOT NULL,
  client_id TEXT,
  seq INTEGER
);
CREATE TABLE dedup (
  client_id TEXT NOT NULL,
  seq INTEGER NOT NULL,
  result_json TEXT NOT NULL,
  applied_index INTEGER NOT NULL,
  PRIMARY KEY (client_id, seq)
);

meta 里存四个键:current_term、voted_for、commit_index、last_applied。log 存日志;kv 和 dedup 是状态机。四张表在同一个 node-<id>.db 文件里,WAL 模式,synchronous=FULL。

13.8.1 什么时候写盘

事件写入是否在回复前
收到更高 termcurrent_term、清空 voted_for是
投票给候选人voted_for是
追加日志log 一行是(leader 复制前)
推进 commitcommit_index是(返回客户端前)
apply 一条先写 kv/dedup,再写 last_applied是(两条事务,中间可崩溃)
截断冲突删除 log 从 idx 起是(接受新日志前)

「回复前持久化」是安全性的底线。如果节点先回复投票、再写 voted_for,它在两者之间崩溃重启,可能在同一 term 投第二票;如果 leader 先返回 200、再写 commit_index,客户端可能看到一个尚未持久化的成功。

13.8.2 连接与 PRAGMA

每次操作新开一个连接,避免跨线程共享 sqlite3.Connection:

def _conn(self) -> sqlite3.Connection:
    conn = sqlite3.connect(self.db_path, timeout=10, isolation_level=None)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA synchronous=FULL")
    conn.execute("PRAGMA busy_timeout=5000")
    return conn
  • WAL:读写不互相阻塞,崩溃恢复更好;
  • synchronous=FULL:提交时 fsync,牺牲吞吐换持久性;
  • busy_timeout=5000:写锁冲突时等 5 秒,避免瞬时并发直接报错;
  • isolation_level=None:关闭 Python 的隐式事务,由代码显式 BEGIN IMMEDIATE 控制边界。

代价是每次操作都建连接、设 PRAGMA,开销不小;教学项目可以接受。生产会用连接池,但必须保证「同一个连接不被多线程同时使用」以及「事务边界清晰」。

13.8.3 重启恢复的顺序

Node.__init__ 的顺序是:

  1. _init_db():建表(IF NOT EXISTS);
  2. _load():从 meta 读 term/vote/commit/applied,从 log 读日志;
  3. _reset_election_deadline():给一个随机选举超时;
  4. 启动 _tick_loop 与 _apply_loop。

_load() 之后,节点不会主动重放日志;它只是恢复内存状态。_apply_loop 会从 last_applied + 1 开始,一直 apply 到 commit_index。如果崩溃发生在「apply 了 kv 但还没写 last_applied」,重启后会重放同一条 entry:

  • kv 的 upsert 是幂等的,同一个 version 再写一次不会产生新版本;
  • dedup 的插入是 INSERT OR IGNORE,同一 (client_id, seq) 不会重复;
  • 所以重放不会产生重复副作用。

如果崩溃发生在「追加日志之后、commit 之前」,重启后 commit_index 不包含这条 entry;它可能被新 leader 截断,也可能在新 leader 上重新提交。两种结果都安全,因为客户端还没看到成功。

13.8.4 崩溃窗口

崩溃位置磁盘状态重启后风险
收到投票后、持久化前voted_for 未写可能再投一次同 term 两票;项目先写后回,避免
追加日志前无 entry重新接收无
追加日志后、复制前本地有未提交 entry等待/被截断无(未提交)
多数派复制后、commit 前多数派有 entry,commit 未推进新 leader 可能提交或覆盖客户端未看到成功
commit 后、返回前已提交客户端 unknown,重试同 seq去重返回原结果
apply 后、last_applied 前kv 已写,meta 未写重放同一条幂等 upsert,无重复
返回后已提交、已 apply正常无

这张表说明:日志、meta、状态机虽然在同一个 SQLite 文件里,却不是跨表原子提交;项目靠顺序、幂等和恢复兜底,不能保证「外部副作用 + 本地状态」原子。项目没有外部副作用;如果 put 要调用下游,必须把 version 或 (client_id, seq) 作为下游幂等键,思路同第 12 章。

13.8.5 没有做的恢复

  • 快照:日志不压缩,重启要重放全部日志;长期运行会越来越慢。
  • 日志校验与磁盘满:没有 checksum,SQLite 只做页级校验;写入失败会抛异常,HTTP 层没有统一映射,可能表现为连接断开。
  • 半写/断电:依赖 SQLite 的 WAL + FULL;没有额外的 fsync 目录。
  • 跨节点修复:没有主动反熵;落后 follower 只能靠 leader 的 AppendEntries 追上。

这些不是「不重要」,而是本章的边界。生产实现要把它们补上,尤其是快照和磁盘满;否则「重启能恢复」只是一句在空库上成立的话。

13.9 代码走读:从 tick 到 apply 的三条路径

raft_node.py 有 700 多行,但结构不复杂。先看骨架:

raft_node.py
├─ Node
│  ├─ 持久化        _conn / _init_db / _load / _persist_meta
│  ├─ 日志          _append_log / _truncate_from / last_log_index
│  ├─ 计时          _reset_election_deadline / _tick_loop / _step_down
│  ├─ 选举          _start_election / _ask_vote / _become_leader
│  ├─ 复制          _replicate_all / _replicate_peer / _advance_commit
│  ├─ 读            _read_index / _wait_commit / _wait_applied
│  ├─ RPC           handle_vote / handle_append
│  ├─ 状态机        _apply_loop / _apply_entry
│  └─ 客户端        handle_put / handle_get / _redirect / status
├─ Server           ThreadingHTTPServer
├─ Handler          /healthz /status /client/* /request-vote /append-entries /admin/*
└─ main             argparse、信号、serve_forever

13.9.1 三条主路径

路径调用链关键状态出错时
选举_tick_loop → _start_election → _ask_vote → _become_leaderterm、voted_for、votes拿不到 quorum,等下一轮
写handle_put → _append_log → _replicate_peer → handle_append → _advance_commit → _apply_entrynext_index、match_index、commit_index202/503;客户端同 seq 重试
读handle_get → _read_index → _replicate_peer → _wait_applied → SELECT kvread_index、last_applied503 no_quorum/apply_lag

选举路径的入口是 _tick_loop:每 20 ms 检查一次,follower 到期就 _start_election,leader 到心跳间隔就 _replicate_all。_start_election 在锁内完成 term+1、投自己、持久化,然后为每个 peer 起一个线程发 RequestVote;每个线程收到响应后回到锁内检查 term 和票数。_become_leader 也在这个锁内追加 no-op、初始化 next_index/match_index、设置 lease。

写路径的入口是 handle_put。它先检查角色,再查 dedup,然后追加日志、触发复制、等待 commit。等待用 Condition,不是忙等:_wait_commit 每 50 ms 醒一次,检查 commit_index 和角色;_advance_commit 推进后 notify_all。如果等待超时但仍是 leader,返回 202;如果已经不是 leader,返回 503。

读路径的入口是 handle_get。linearizable 先 _read_index,再 _wait_applied,最后查 kv;lease 只检查 lease_until 和 applied;stale 直接读本地。三条路径最后都走同一个 SELECT key,value,version FROM kv,但前面的门槛不同。

13.9.2 五个容易看错的函数

函数看起来像实际在做什么看错的后果
_replicate_peer只发日志同时承担心跳、回退 next_index、推进 commit、延长 lease以为读路径不会改状态
_read_index只读会发 AppendEntries,可能推进 commit以为读是纯本地操作
_advance_commit找到多数派就提交还要求 entry 是当前 term旧 term 日志被直接提交,破坏安全性
handle_append只追加还会截断冲突、更新 term/leader、推进 follower commit以为 follower 的日志只增不减
_apply_entry只写 kv还负责 dedup 的最终判定以为提议前去重就够了

13.9.3 错误路径

位置异常/条件处理客户端看到
handle_put非 leader_redirect()307 或 503
handle_put同 seq 已应用返回原结果200 duplicate=true
handle_putcommit 超时且仍 leader返回 pending202
handle_putcommit 超时且失去 leadership返回 leadership_lost503
handle_get非 leader 且 linearizable/lease_redirect()307 或 503
handle_getReadIndex 拿不到 quorumno_quorum503
handle_getapplied 追不上apply_lag503
handle_appendprev 不匹配返回 match_index 帮助回退leader 内部处理
handle_vote日志不够新/已投票拒绝candidate 内部处理

项目没有统一的异常映射:SQLite 写失败、JSON 解析失败、连接断开,可能表现为 500、连接重置或空响应。教学项目可以接受;生产要在 Handler 外层加 try/except,把内部错误映射成稳定的 5xx,并记录 trace id。

13.9.4 并发与线程

_replicate_all() 为每个 peer 起一个线程,_read_index() 又在请求线程里同步调用 _replicate_peer()。这意味着同一时刻可能有多个 AppendEntries 在飞;next_index 和 match_index 都在锁内更新,响应回来时还会检查 term,所以旧 term 的响应不会覆盖新状态。

代价是线程数随「心跳频率 × peer 数」增长。三节点、100 ms 心跳,大约每秒 20 个短线程;本机可以接受,生产会用固定 worker 池和每 peer 一个发送队列。这不是 Raft 协议的一部分,而是工程的实现选择;协议只要求消息最终送达、幂等处理。

13.10 测试:六个场景与它们的边界

测试入口:

cd projects/replicated-kv
python3 -m unittest -v test_kv.py

一次实测输出:

test_kill_leader_then_new_leader_keeps_committed_value ... ok
test_partition_leader_linearizable_read_fails_then_new_leader ... ok
test_1_put_get_linearizable ... ok
test_2_stale_read_from_follower ... ok
test_3_duplicate_put_same_seq ... ok
test_4_read_index_after_write ... ok

----------------------------------------------------------------------
Ran 6 tests in 4.126s

OK

13.10.1 六个测试分别证明什么

测试动作断言证明了
test_1_put_get_linearizableput k1,立刻 linearizable get200,值 v1,stale=false基本写读闭环
test_2_stale_read_from_follower向 follower 轮询 stale get最终读到 v2,stale=truefollower 能追上,stale 明确标记
test_3_duplicate_put_same_seq同 (c-k3,7) 写两次第二次 duplicate=true,version 相同;三节点 dedup 各 1 行去重在 leader 和状态机都生效
test_4_read_index_after_write连续 5 次 put 后立刻 get每次读到刚写的值ReadIndex 能等到 apply
test_kill_leader...put kk 后 terminate leader新 leader 仍读到 vv已提交值不丢
test_partition...put kp 后隔离 leader旧 leader 读 503;新 leader 读到 vp;heal 后仍 vp分区下旧主读被拒,恢复不破坏值

test_3 里有一个容易忽略的细节:它不只查 leader 的 dedup,而是轮询三个节点的 SQLite,直到每个节点的 dedup 都只有 1 行。因为 follower 的 apply 可能晚于 leader,测试要等复制追上;这也说明「去重」不是 leader 内存里的一个集合,而是每个状态机自己的表。

13.10.2 测试没有覆盖什么

  • lease 读的分区窗口:测试只断言 linearizable 读 503,没有在旧 leader 的 lease 未过期时读旧值;
  • 202 与同 seq 不同 value:测试的 put 都在 2 秒内提交,没有制造「仍 leader 但 commit 超时」;项目对同 seq 不同 value 静默返回旧值,也没有断言 409;
  • 缺 client_id/seq:测试从不省略,去重关闭的路径没覆盖;
  • 崩溃重启:terminate() 后不再启动同一节点,没有验证 SQLite 恢复;
  • 真实网络与多 key 并发:本机回环,没有延迟、丢包、乱序;也没有验证不同 key 的写交错与同 key 不同 client 的 last-writer-wins。

把这些边界写清楚,比「6 个测试全过」更有价值。测试通过只说明这 6 条路径在当下时序成立,不说明协议在所有故障下正确。

13.10.3 加一个测试:缺 seq 时去重失效

可以这样加一个测试,专门暴露接口层的坑:

def test_missing_seq_disables_dedup(self):
    body = {"key": "k5", "value": "v5", "client_id": "c-k5"}
    status, payload, _ = follow(self.leader_base, "POST", "/client/put", body)
    self.assertEqual(status, 200, payload)
    self.assertFalse(payload["duplicate"])

这个测试会通过,而且它通过本身就是一个警告:服务端没有强制身份字段,去重可以被客户端静默绕过。生产接口应把 client_id/seq 设为必填,或在缺失时返回 400。

13.11 故障注入与观测:分区、杀主与证据链

这一节把「怎么知道它真的按协议工作」落到命令上。先看 /status,再做故障注入;不要只看测试的绿色 OK。

13.11.1 先看 status

cd projects/replicated-kv
./run_cluster.sh start
python3 kv_client.py status
curl -sS http://127.0.0.1:7101/status | python3 -m json.tool

kv_client.py status 会依次访问三个 base,输出每个节点的 /status。一次实际输出(节选,省略 peers 与节点自身的 base):

{
  "base": "http://127.0.0.1:7101",
  "http": 200,
  "status": {
    "node": 1,
    "role": "leader",
    "term": 1,
    "leader_id": 1,
    "commit_index": 1,
    "last_applied": 1,
    "log_len": 1,
    "last_log_term": 1,
    "quorum": 2,
    "lease_remaining_ms": 739,
    "blocked_peers": []
  }
}
字段正常异常信号
role恰好一个 leader,其余 follower两个 leader 同 term;全部 candidate
term三节点最终一致长期分歧;隔离节点 term 飞涨
leader_id三节点指向同一个 idfollower 指向不存在的 id;leader 指向别人
commit_index单调不减;leader 与多数派一致倒退(除非重启加载旧库);落后很多
last_applied接近 commit_index长期小于 commit,说明 apply 卡住
log_len>= commit_index大于 commit 是未提交尾巴;异常增长要查选举/重复写
last_log_termleader 通常等于 term(no-op 之后)远小于 term,可能还没提交当前 term
lease_remaining_msleader > 0;follower 0leader 长期 0,lease 读会 503
blocked_peers空分区注入未清除;故障演练后忘了 heal

正常/异常的分界不是单个数字,而是三节点放在一起看:一个节点 commit_index=5、另一个 commit_index=3,可能只是复制滞后;如果持续几分钟,才是问题。一个节点 log_len=100、commit_index=3,说明它有一条很长的未提交尾巴,常见于反复选举或旧 leader 分区。

13.11.2 分区与杀主

bash fault_inject.sh --dry-run
bash fault_inject.sh --partition 3
bash fault_inject.sh --heal
bash fault_inject.sh --kill-leader

--dry-run 只打印命令,不注入。--partition 3 会向节点 3 的 /admin/partition 发送 {"peers":[1,2],"enabled":true};节点 3 的 blocked_peers 变成 {1,2},它不再向 1/2 发 RPC,1/2 发来的 RPC 也会因为 from_id 在 blocked_peers 里被拒绝。效果是双向阻断,但实现是「应用层拒绝」,不是「网络丢包」。

一次分区后的线性一致读(旧 leader 仍可达):

{
  "http": 503,
  "served_by": "http://127.0.0.1:7101",
  "response": {"error": "no_leader", "term": 5}
}

具体 error 取决于时序:如果旧 leader 还没 step down,_read_index 拿不到 quorum,返回 no_quorum;如果它已经 step down 且不知道新 leader,返回 no_leader;如果它知道新 leader,返回 307。三种都说明「这次读没有完成」,但客户端动作不同:no_quorum 等恢复,no_leader 等选举,307 跟随。

--kill-leader 的实际输出(节选):

[fault] kill leader node=2 pid=48213
[fault] 新 leader = 3

脚本随后对 failover 做一次 linearizable get;这个 key 通常不存在,所以返回 200 found=false。脚本只证明「新 leader 选出来了」,不证明「已提交值还在」;后者由 test_kill_leader_then_new_leader_keeps_committed_value 断言。

13.11.3 直接查 SQLite

sqlite3 .run/node-1.db "SELECT idx,term,command FROM log ORDER BY idx;"
sqlite3 .run/node-1.db "SELECT client_id,seq,applied_index FROM dedup;"
sqlite3 .run/node-1.db "SELECT key,value,version FROM kv;"

期望:

  • log 的 idx 连续;term 单调不减;command 里能看到 noop 与 put;
  • dedup 的 (client_id,seq) 唯一;同一个 seq 不应出现两行;
  • kv.version 不超过 commit_index,且等于最后一次应用该 key 的日志 index。

如果 dedup 出现两行同一个 (client_id,seq),说明主键被绕过(例如手工建表时漏了 PRIMARY KEY);如果 kv.version 大于 commit_index,说明 apply 越过了提交点,属于严重 bug。

观测的盲区

/status 是节点自己的视角,不是全局真相。旧 leader 在分区期间仍会报告 role=leader、term 也可能很高;不能只看单个节点的 /status 就下结论。至少要看多数派一侧的节点,再用客户端读验证。

13.12 一致性检查器:把「读到旧值」变成可判定的失败

测试是固定的六个场景;真实排障需要从一次运行的历史里判断「这条读是否违反了实时序」。完整的线性一致性判定很复杂(需要搜索所有可能的线性化点),但针对这个项目,可以先做一个实时序检查器:它不证明全部线性一致性,只检查两类高频错误。

13.12.1 检查哪两件事

  1. 读后写:如果一条写 W 在时间上已经返回,之后才开始的一条非 stale 读 R 读同一个 key,那么 R 看到的版本不能早于 W。
  2. 重复身份:同一个 (client_id, seq) 不能对应两个不同的 value;检查器看到 value 冲突就报警。

第一类检查是 sound 的:只要违反,就一定不是线性一致;第二类检查针对去重契约。它不检查「并发的两个写谁先」,也不检查跨 key 的事务,所以通过不等于线性一致。

13.12.2 历史格式

每条事件一行 JSON,时间用同一个单调时钟:

{"op":"put","key":"k","value":"v1","client_id":"c1","seq":1,"start":0.000,"end":0.030,"ok":true,"duplicate":false,"version":2}
{"op":"get","key":"k","version":2,"start":0.040,"end":0.045,"ok":true,"stale":false}
{"op":"put","key":"k","value":"v2","client_id":"c2","seq":1,"start":0.050,"end":0.080,"ok":true,"duplicate":false,"version":3}
{"op":"get","key":"k","version":2,"start":0.090,"end":0.095,"ok":true,"stale":false}

最后一条读发生在 v2 返回之后,却看到 version 2;检查器应报错。

13.12.3 一个可运行的检查器

把下面的脚本存成 /tmp/check_history.py,它只读历史文件,不改项目:

#!/usr/bin/env python3
"""实时序检查器:读后写 + 重复 (client_id, seq)。"""
import json
import sys


def check(events):
    problems = []
    writes = []
    seen = {}
    for e in sorted(events, key=lambda x: x["start"]):
        if e["op"] == "put" and e.get("ok"):
            ident = (e.get("client_id"), e.get("seq"))
            if None not in ident:
                if ident in seen and seen[ident]["value"] != e["value"]:
                    problems.append(
                        f"seq {ident} reused for {seen[ident]['value']} and {e['value']}")
                seen.setdefault(ident, e)
            if not e.get("duplicate"):
                writes.append(e)
    for r in sorted(events, key=lambda x: x["start"]):
        if r["op"] != "get" or not r.get("ok") or r.get("stale"):
            continue
        done = [w for w in writes
                if w["key"] == r["key"] and w["end"] <= r["start"]]
        if not done:
            continue
        newest = max(done, key=lambda w: w["version"])
        if r.get("version") is None or r["version"] < newest["version"]:
            problems.append(
                f"get {r['key']} at {r['start']:.3f} saw {r.get('version')}, "
                f"but put finished at {newest['end']:.3f} with version {newest['version']}")
    return problems


if __name__ == "__main__":
    events = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")
              if line.strip()]
    found = check(events)
    print(f"events={len(events)} problems={len(found)}")
    for p in found:
        print("-", p)
    sys.exit(1 if found else 0)

正常历史(把最后一条读的 version 改成 3):

$ python3 /tmp/check_history.py history.jsonl
events=4 problems=0

而上面那份原始历史(最后一条读在 v2 返回后仍看到 version 2)会输出:

events=4 problems=1
- get k at 0.090 saw 2, but put finished at 0.080 with version 3

13.12.4 检查器没有证明什么

  • 并发写的顺序:两个写重叠时,检查器不知道哪个先线性化;它只按返回时间做约束。
  • 跨 key 原子性:项目没有多 key 事务,检查器也不检查。
  • stale 读:检查器跳过 stale=true 的读;它们本来就被允许旧。
  • 完整的线性一致性:要枚举读的线性化点,或用 WG/Jepsen 的算法;这个脚本只是第一道筛子。
  • 故障注入下的历史:它需要你先把事件记录下来;项目本身不产出这份 JSONL。

这份检查器的价值在于:它把「我觉得读到了旧值」变成一条可复现的失败记录。排障时先跑它,再决定要不要上完整判定。

13.13 失败模式:分区期间的旧主读

这是本章最重要的失败模式。分区期间,旧 leader 的进程还活着,SQLite 里还有数据,/status 还会返回 role=leader;如果读路径不确认 quorum,它就会把旧值返回给客户端。

13.13.1 时间线

t0  A 是 leader;k=v1 已提交到 A+B(index=4)
t1  A 与 B、C 之间分区;A 仍认为自己是 leader
t2  B、C 选出 C;C 提交 k=v2(index=5),A 不知道
t3  客户端向 A 发 GET consistency=linearizable
      A: _read_index 向 B/C 要 ack,全部失败 → 503 no_quorum
t4  客户端向 A 发 GET consistency=lease
      A: lease_until 还没过期 → 读本地 kv → 返回 v1(旧值)
t5  A 超过 2×election_ms 没有 quorum ack → step_down
t6  客户端再向 A 发 GET → 307 到 C,或 503 no_leader
t7  分区恢复;C 的 AppendEntries 截断 A 的未提交尾巴

第 t4 步是危险点。lease 读不向多数派确认,只看本地 lease_until;如果新 leader 已经在旧 lease 到期前选出并提交,旧 leader 仍然会服务读。默认参数下 lease_ms=800、election_ms=500(随机 250–750 ms),新 leader 可以在旧 lease 到期前产生;测试参数 600/350 的窗口更大。所以这个项目的 lease 读不能当作线性一致读使用。

13.13.2 三条读路径在分区下的表现

路径旧 leader 的行为返回值安全性
linearizable_read_index 拿不到多数派 ack503 no_quorum,或 step down 后 307/503安全;拒绝比返回旧值好
leaselease 未过期时直接读本地200 + 旧值不安全窗口;依赖 lease < election 与时钟
stale任意节点本地读200 + 旧值 + stale=true契约允许;调用方自负

注意 linearizable 也不是「绝对不会返回旧值」:它要求 _read_index 成功,且 _wait_applied 追上。如果 _read_index 成功但 leader 在返回前被推翻,读仍然可以线性化在确认 quorum 的那一刻;只要它返回的是 readIndex 对应的状态,就是合法的。真正危险的是「没有确认 quorum 就读」。

13.13.3 测试证据

test_partition_leader_linearizable_read_fails_then_new_leader 做的正是这条时间线:

  1. 向 leader 写 kp=vp,确认 200;
  2. 隔离 leader 与另外两个节点;
  3. 等 1.2 秒,向旧 leader 发 linearizable get,断言 503;
  4. 等新 leader,向新 leader 发 linearizable get,断言 200 且值仍为 vp;
  5. 解除隔离,再读一次,断言值仍是 vp。

它没有测 lease 读,也没有测「旧 leader 在 lease 窗口内返回 v1」;这是测试的空白,不是协议的安全保证。你可以手工补一个实验:在 t4 时刻对旧 leader 发 --consistency lease,观察它是否返回旧值;如果返回 200,说明 lease 窗口真实存在。

13.13.4 怎么修

措施作用代价
默认只用 linearizable分区时拒绝读,不返回旧值多一次 RTT;leader 不可用时读不可用
lease < election timeout降低旧 lease 窗口需要保守参数和时钟假设
check quorum / 自停旧 leader 尽快停止服务仍有一个检测窗口
fencing token挡住旧 leader 对下游的写需要资源端配合;读路径不适用
把 stale 明确标注让调用方选择不能修复强一致需求

没有一种措施能「既零延迟又强一致」。ReadIndex 用一次 RTT 换安全;lease 用时钟假设换延迟;stale 用正确性换可用性。选哪条,取决于业务能不能承受旧值。

13.14 真实案例:etcd 的 ReadIndex

etcd 是 Raft 的工业实现之一。它公开的读路径设计和这个项目高度对应:

  • 线性一致读是默认:客户端默认的读要经过 leader,不能随便从 follower 本地读;
  • ReadIndex:leader 记录当前的 commit index 作为 readIndex,向多数派确认自己仍然是 leader,然后等状态机 apply 到 readIndex,再返回;
  • serializable read:显式选择后,可以从本地状态机读,可能旧;它不是默认;
  • lease read:一种省心跳的快速路径,依赖 leader lease 与时钟假设;不是所有配置/版本都默认开启。

失败链正是第 13.13 节的时间线:分区 → 旧 leader 仍有本地数据 → 客户端读到旧值;ReadIndex 在返回前再确认多数派,确认不了就失败,而不是返回旧值。

Jepsen 在 2020 年对 etcd 3.4.3 做过公开分析,把选举、线性一致读和故障注入放在一起检验。这里不引用具体数字,只引用结论层面的教训:

  1. 默认路径要安全:强一致读不能靠调用方记得加参数;默认拒绝旧读,显式选择才降级。
  2. 读路径也要故障注入:只测「leader 崩溃后能选新 leader」不够,还要测「旧 leader 还能不能服务读」。
  3. lease 是性能优化,不是安全证明:它把正确性押在时钟和租约上;分区/暂停时要有退路。
etcd项目二差异
默认线性一致读默认 consistency=linearizable两者默认都安全;项目允许显式降级
ReadIndex + 等 apply_read_index + _wait_applied项目复用 AppendEntries 当心跳
serializable readconsistency=stale项目明确标 stale=true
lease readconsistency=lease项目的 lease 参数关系不满足安全条件
成员变更/快照未实现项目的已知边界
事实边界

本节只引用 etcd 公开的 ReadIndex/lease read 设计思路与 Jepsen 的公开分析,不引用 etcd 源码,也不编造延迟、QPS 或事故数字。Jepsen 报告:https://jepsen.io/analyses/etcd-3.4.3;Raft 论文:https://raft.github.io/raft.pdf。更多文献见 A.3 论文、规范与公开复盘索引。

13.15 重试导致重复副作用:客户端重试写

第 12 章讲的是「服务端执行了,但响应丢了」;这一章讲的是「写已经复制并提交了,但客户端没看到 200」。两者的修复思路相同:重试必须携带同一个逻辑身份。

13.15.1 同一把钥匙的三种结局

# 第一次:客户端 c1 的逻辑请求 seq=7
python3 kv_client.py --key pay --value 100 --client-id c1 --seq 7 put

# 响应丢失后,同键重试
python3 kv_client.py --key pay --value 100 --client-id c1 --seq 7 put

# 错误做法:换一个新 seq
python3 kv_client.py --key pay --value 100 --client-id c1 --seq 8 put

三次调用的结果(节选):

调用duplicateversion日志状态机
第一次 (c1,7)false2新增 idx=2应用一次
第二次 (c1,7)true2不新增不应用
第三次 (c1,8)false3新增 idx=3再应用一次

对这个项目的 put 来说,第三次只是把同一个 value 又写了一遍,kv 的值不变;如果命令是 incr、append 或 transfer,第三次就是第二次副作用。去重键保护的是逻辑请求,不是 key;换 seq 等于告诉服务端「这是一个新请求」。

13.15.2 为什么 apply 时还要去重

考虑这个时序:

  1. 旧 leader 追加了 (c1,7),已提交但响应丢失;
  2. 新 leader 当选,但还没 apply 到这条 entry;
  3. 客户端重试 (c1,7) 到新 leader;
  4. 新 leader 的 handle_put 查 dedup,查不到(还没 apply),于是又追加一条 (c1,7);
  5. apply 线程先应用第一条,写入 dedup;再应用第二条,命中 dedup,跳过。

结果:日志里有两条 (c1,7),状态机只应用一次。提议前去重是快速路径,apply 时去重是正确性保障。如果没有第 5 步,客户端重试就会变成两次写;如果只有第 5 步,日志会膨胀,但不影响正确性。

13.15.3 四种重试姿势

客户端行为服务端看到结果评价
同 (client_id,seq)、同 value重复身份返回原结果,duplicate=true正确
同 (client_id,seq)、不同 value重复身份返回第一次结果,静默忽略新值项目未做 409;生产要拒绝
新 seq、同 value新请求新日志、新版本;put 值相同对 put 无害;对 incr 重复
缺 client_id/seq无身份每次都是新写去重失效,最危险

13.15.4 结论

这个项目实现的是「至少一次投递 + 状态机幂等」,不是「网络层 exactly-once」。客户端可能重试多次,服务端可能收到多个物理请求,甚至日志里出现重复 entry;只要 (client_id, seq) 不变,状态机就只应用一次。正确性的锚点是身份,不是重试次数,也不是 HTTP 连接。

13.16 本章陷阱清单

12 条高频错误
  1. 把 307 当失败,换节点后换新 seq 重发:新 seq 是新逻辑请求,去重救不了;307 应跟随,重试应复用原 seq。
  2. 不传 client_id/seq:handle_put 不强制这两个字段,缺省时 dedup 静默关闭,重复写会变成多条日志。
  3. 同 seq 不同 value 以为会覆盖:项目直接返回第一次结果,第二次 value 被忽略;生产应加指纹并返回 409。
  4. 只在 handle_put 查 dedup:新 leader 还没 apply 时查不到,仍会追加重复 entry;正确性靠 _apply_entry 的唯一约束。
  5. 把 lease 读当线性一致读:默认 lease_ms=800 大于 election_ms 的上界 750,分区时有旧读窗口;强一致用 linearizable。
  6. 以为 stale 读会失败:它返回 200 和 stale=true,可能 found=false;调用方必须自己判断。
  7. 把 commit_index 当 last_applied:commit 只表示多数派持久化,状态机可能还没 apply;读之前要等 applied。
  8. 直接提交旧 term 的日志:多数派复制不等于可提交;必须当前 term 的 entry 先提交,旧 entry 才能间接提交。
  9. 只看 prev_log_index 不看 prev_log_term:同 index 不同 term 是冲突日志,必须截断;只比 index 会接受错误历史。
  10. 以为 no-op 只是占位:no-op 让新 leader 建立当前 term 的提交点,并间接提交旧 term 已提交项;没有它,commitIndex 可能长期不动。
  11. 把 /admin/* 暴露到生产:/admin/partition 和 /admin/heal 没有鉴权,任何人都能切断集群。
  12. 把三节点本机测试当跨机房验证:没有真实延迟、丢包、时钟跳变、磁盘故障;而且 /admin/partition 是应用层拒绝而非丢包,隔离节点的高 term 仍可能通过错误响应泄漏,heal 后出现选举抖动;参数要按生产重测。

13.17 本章出口检查

检查项达标线没达标去哪
说出五条不变量选举安全、日志匹配、提交规则、去重、ReadIndex13.2
画出三节点架构三个进程、两种 RPC、一个状态机、quorum=213.3
解释选举限制能说出 (last_log_term, last_log_index) 如何否决落后候选人13.4
解释日志匹配prevLogIndex/prevLogTerm、冲突截断、回退规则13.5
解释当前 term 提交为什么旧 term 的 entry 不能直接提交;no-op 的作用13.5
解释 307 与去重Location、follow() 上限、(client_id,seq) 两层去重13.6
区分三条读路径linearizable/lease/stale 的机制、失败码、安全性13.7、13.13
读懂 /status能指出 leader、term、commit、applied、lease、blocked_peers13.11
复现分区与杀主fault_inject.sh --partition、--kill-leader、--heal13.11
解释旧主读为什么 linearizable 503、lease 可能旧、stale 明确旧13.13
做一次一致性检查能写出读后写与重复 seq 的检查器,并说明它没证明什么13.12
说出 etcd 的对应ReadIndex、serializable、lease 的取舍13.14
即时自测 · 13

Q1 新 leader 为什么要在当选后追加一条 no-op?如果没有 no-op,commit_index 和读路径会受什么影响?

Q2 follower 收到 prev_log_index=5, prev_log_term=2,但它本地 index 5 的 term 是 3。它会返回什么?leader 如何回退?

Q3 为什么 linearizable 读要「先确认多数派,再等 applied」,只做其中一步会怎样?

Q4 旧 leader 被分区,但 lease_remaining_ms 还有 300 ms;客户端用 lease 读,可能看到什么?为什么 linearizable 不会这样?

Q5 客户端第一次 put 超时,服务端其实已提交;客户端用同一个 (client_id,seq) 重试,日志和状态机各会发生什么?如果换成新 seq 呢?

答案

A1 no-op 在当前 term 建立一条可提交的日志,提交后把之前旧 term 的已提交项一并带过,状态机才能安全推进 commit_index。没有 no-op,新 leader 在第一条当前 term 写提交之前,commit_index 可能停在旧位置,读路径会读到更旧的状态或 503;no-op 把这个窗口尽量缩短。

A2 它发现 prev_log_term 不匹配,返回 success=false 和 match_index=prev-1=4。leader 把 next_index 从 6 调到 min(6-1, 4+1)=5;下一次 prev=4,如果 index 4 匹配,就从 index 5 开始覆盖冲突项,否则继续回退。

A3 只确认多数派、不等 applied:leader 可能仍是 leader,但本地 kv 还没应用到 readIndex,读到旧值。只等 applied、不确认多数派:旧 leader 可能已经被分区,本地状态再新也可能落后于新 leader 的写。两步分别解决「我还是不是 leader」和「我的状态机够不够新」。

A4 可能返回 200 和旧值。lease 读只看本地 lease_until,不向多数派确认;如果新 leader 在旧 lease 到期前已选出并提交,旧 leader 仍会服务读。linearizable 会先 _read_index 向多数派要 ack,拿不到就 503,不会返回旧值。

A5 同 seq 重试:handle_put 查 dedup,命中则返回 200 duplicate=true 和原 version;日志不新增,状态机不重复应用。如果新 leader 还没 apply,可能先追加一条重复 entry,但 _apply_entry 的 dedup 会让第二次跳过,仍然只应用一次。换新 seq:服务端当成新逻辑请求,新增日志并再应用一次;对 put 是覆盖,对 incr/transfer 就是第二次副作用。

母本对应

母本章节本书落点程度说明
谢希仁《计算机网络》第 8 版第 5 章 运输层、第 6 章 应用层13.3、13.6背景只借用「请求经过网络与 HTTP」的视角;TCP/HTTP 细节不重写
张彦飞《深入理解 Linux 网络》第 4–7 章(TCP、socket、连接)13.3、13.9背景连接/线程模型只引用,不展开内核路径
《Python 网络编程》socket/HTTP 服务、并发服务器13.9、13.10精选借用标准库 HTTP 服务的工程风格;代码全部重写
《Linux 系统与内核》第 4 篇4.4–4.11(网络与协议栈)13.3、13.8交叉引用明确不重复 TCP/socket/epoll/HTTP
本书自编 + Raft 论文/etcd 文档Raft、ReadIndex、lease read13.4–13.7、13.14完整只引用公开设计事实,不复制论文或 etcd 源码
本书项目代码projects/replicated-kv/13.1–13.12完整所有代码为本书编写,6 个测试实测

下一站:项目二证明了「三节点可以选主、复制、线性一致读」,但还没有回答「集群怎么在线扩容、日志怎么压缩、跨机房延迟怎么影响选举」。进入 A.4 交付自检与回归清单,把本章的验收命令、故障注入和检查器固化成交付前的回归清单。

A.1 母本与主题覆盖矩阵

1 怎么读

这张表有两个用途:

  1. 主题覆盖:任务书要求的数据面主题,每一项都必须能在正文找到一个可跳转的节;tools/check_coverage.py 会逐行核对锚点是否存在。
  2. 母本对应:四本母本(谢希仁、张彦飞、《Python 网络编程》、《Linux 系统与内核》)的哪些章节被本书引用、哪些明确不重复。母本不覆盖的分布式协议,标为「本书自编 + 论文/规范」。

「深度」列的含义:

  • 完整:概念、机制、失败路径、观测、例子都在正文;
  • 精选:核心机制在正文,冷门细节指路;
  • 背景:只借用事实/视角,不展开;
  • 交叉引用:本书不写,指向 Linux 书对应篇章。

2 主题覆盖(55 项,缺口 0)

#主题本书落点锚点深度说明
1请求路径与延迟预算1.1、1.4sec-distributed.1.1.1完整12 个 hop、逐跳预算、p99 放大
2部分失败与 unknown 状态1.3、1.5、1.6sec-distributed.1.1.5完整七类失败、状态机、超时三种真相
3超时与 deadline 传播2.2sec-distributed.1.2.2完整绝对 deadline、逐跳换算、取消语义
4重试、退避与抖动2.3sec-distributed.1.2.3完整full jitter、错误分类、hedged request
5重试预算与放大倍数2.5sec-distributed.1.2.5完整正反馈、同步重试、止损顺序
6幂等键与请求指纹2.4sec-distributed.1.2.4完整键粒度、规范化、唯一约束
7去重表与状态机12.3、12.5sec-distributed.5.12.5完整calls/effects/audit、claim、succeed
8背压与并发上限2.6sec-distributed.1.2.6完整Little’s law、有界队列、拒绝策略
9限流与过载保护2.7sec-distributed.1.2.7完整令牌桶、429/503、Retry-After
10单主复制3.1、3.2sec-distributed.2.3.2完整同步/异步/半同步、RPO/RTO
11多主复制与冲突解决3.5、3.6sec-distributed.2.3.5完整LWW、版本向量、CRDT
12无主复制与 quorum3.7sec-distributed.2.3.7完整R+W>N、读修复、hinted handoff、Merkle
13复制延迟与读己之写3.8sec-distributed.2.3.8完整lag 来源、会话保证
14线性一致性4.2sec-distributed.2.4.2完整实时序、全序、判定与反例
15顺序一致性4.3sec-distributed.2.4.3完整与线性一致的差异
16因果一致性与会话保证4.4sec-distributed.2.4.4完整happens-before、RYW、单调读
17最终一致性与收敛4.5sec-distributed.2.4.5完整收敛条件、反熵、读修复
18CAP 与 PACELC 的精确表述4.6sec-distributed.2.4.6完整分区时选什么、无分区时的 L/C
19Paxos5.3sec-distributed.2.5.3完整prepare/promise/accept/accepted
20Multi-Paxos 与活锁5.4sec-distributed.2.5.4完整双提案者互锁、稳定 leader
21Raft 选举5.5sec-distributed.2.5.5完整term、投票、随机超时、选举限制
22Raft 日志复制与提交5.6sec-distributed.2.5.6完整prevLogIndex/term、当前 term 提交
23成员变更与快照5.7sec-distributed.2.5.7精选单步/联合共识、快照截断
24线性一致读(ReadIndex/lease)5.8、13.7sec-distributed.2.5.8完整ReadIndex、lease read、follower read
25ACID 与事务边界6.1sec-distributed.3.6.1完整C 是应用不变量、短事务
26隔离级别与异常6.2sec-distributed.3.6.2完整脏读/不可重复/幻读/写偏斜
27MVCC 可见性6.3、6.4sec-distributed.3.6.3完整版本链、read view、xmin/xmax
28分布式事务 2PC/TCC/Saga6.6sec-distributed.3.6.6完整阻塞、空回滚、补偿、选型
29Outbox 与幂等消费6.7sec-distributed.3.6.7完整双写、CDC、去重
30队列与日志的区别7.1sec-distributed.3.7.1完整删除 vs 保留、重放
31投递语义7.2sec-distributed.3.7.2完整最多/至少/有效一次
32分区、顺序与消费者组7.3sec-distributed.3.7.3完整分区键、顺序范围、rebalance
33offset 提交与再平衡7.4sec-distributed.3.7.4完整提交窗口、重复投递
34Kafka ISR/acks/事务7.5sec-distributed.3.7.5完整acks=all、min.insync、幂等生产者
35CDC 与事件溯源7.6sec-distributed.3.7.6完整outbox、binlog、重放
36对象存储语义8.1、8.2sec-distributed.3.8.1完整ETag、条件写、多段上传、强一致
37缓存模式与一致性8.3、8.4sec-distributed.3.8.3完整cache-aside、双写、延迟双删
38缓存击穿/穿透/雪崩8.5sec-distributed.3.8.5完整singleflight、空值、随机 TTL
39CDN 与边缘缓存8.6sec-distributed.3.8.6完整anycast、cache key、purge
40分布式锁9.1、9.2sec-distributed.4.9.2完整SET NX PX、Lua、续期、主从丢锁
41租约与 fencing token9.6、9.7sec-distributed.4.9.6完整单调 token、资源端校验、GC 停顿
42选主与分裂脑9.5sec-distributed.4.9.5完整bully、Raft、lease、旧主自停
43trace 与上下文传播10.2sec-distributed.4.10.2完整traceparent、span、采样
44指标与分位数10.3sec-distributed.4.10.3完整RED/USE、histogram、exemplar
45结构化日志10.4sec-distributed.4.10.4完整字段、trace id、成本
46SLO 与错误预算10.6sec-distributed.4.10.6完整SLI/SLO、burn rate
47回放与影子流量10.7sec-distributed.4.10.7完整确定性重放、副作用隔离
48容量与 Little’s law11.1sec-distributed.4.11.1完整L=λW、headroom
49排队与利用率曲线11.2sec-distributed.4.11.2完整1/(1-ρ)、变异性
50压测方法11.3sec-distributed.4.11.3完整基线、梯度、开放/闭环
51降级、熔断与舱壁11.5sec-distributed.4.11.5完整熔断状态机、隔离、优先级
52故障演练与爆炸半径11.7sec-distributed.4.11.7完整假设、注入、回滚、game day
53项目一:幂等工具调用后端12.1–12.17sec-distributed.5.12.1完整可运行服务、7 测试、3 类故障
54项目二:小型复制 KV13.1–13.17sec-distributed.5.13.1完整三节点、选举、复制、一致性读
55项目二的一致性读(ReadIndex/lease/stale)13.7、13.13sec-distributed.5.13.7完整三条读路径、分区旧主、测试证据

校验:python3 tools/check_coverage.py。任何一行锚点不存在、或 REQUIRED 主题缺失,都会退出非 0。

3 母本对应

3.1 谢希仁《计算机网络》第 8 版

母本章节本书落点锚点处理
谢希仁第 1 章 概述(分层、协议)1.1、1.11sec-distributed.1.1.1背景:只借用分层视角;不重写体系结构
谢希仁第 5 章 运输层(TCP/UDP)1.1、1.8、2.2sec-distributed.1.2.2交叉引用:TCP 状态机、拥塞控制见 Linux 书 4.4–4.6
谢希仁第 6 章 应用层(HTTP/DNS)8.6、12.4sec-distributed.3.8.6背景:HTTP 语义只用于缓存/重试;报文细节不重写
谢希仁第 7 章 网络安全2.2、8.1sec-distributed.1.2.2背景:只提 mTLS/鉴权边界,不写 TLS 握手
谢希仁第 8–10 章(音视频、无线、下一代互联网)——淘汰:与数据面主线无关

3.2 张彦飞《深入理解 Linux 网络》

母本章节本书落点锚点处理
张彦飞第 4–5 章(TCP 连接、收发)1.1、2.2、12.6sec-distributed.5.12.6背景:连接复用/超时只引用;不重写内核路径
张彦飞第 6 章(连接队列、握手)1.1、1.8sec-distributed.1.1.1交叉引用:队列/握手细节见 Linux 书 4.5、4.11
张彦飞第 7–8 章(socket、epoll)12.6、13.9sec-distributed.5.13.1背景:只讲用户态服务模型;epoll 实现不重写
张彦飞第 9–10 章(性能、容器网络)11.1、11.9sec-distributed.4.11.1精选:容量/观测借用指标;容器网络见 Linux 书第 5 篇

3.3 《Python 网络编程》

母本章节本书落点锚点处理
Python 网络编程socket/HTTP 客户端与服务端12.4、12.8、13.6sec-distributed.5.12.8精选:工程风格;代码全部重写
Python 网络编程并发服务器/线程12.6、12.9sec-distributed.5.12.9精选:只讲与本项目相关的线程/队列
Python 网络编程协议解析/超时2.2、2.3sec-distributed.1.2.3背景:不复制示例,不写 socket API 手册

3.4 《Linux 系统与内核》

母本章节本书落点锚点处理
Linux 书第 4 篇 4.1–4.12(网络)1.1、2.2、8.6、12.6sec-distributed.1.1.1交叉引用:TCP/IP、socket、epoll、HTTP/DNS/TLS 不重写
Linux 书第 5 篇 5.1–5.6(容器/K8s)11.6、11.7sec-distributed.4.11.7交叉引用:容器/编排基础不重写
Linux 书第 2/3 篇(系统编程/内核)12.6、13.8sec-distributed.5.13.8背景:线程/文件/持久化只借用概念

3.5 本书自编 + 论文/规范

材料本书落点锚点处理
Raft 论文(Ongaro & Ousterhout, 2014)5.5–5.8、13.4–13.7sec-distributed.2.5.5自编讲解 + 代码;不复制论文原文/图
Paxos 论文(Lamport, 1998/2001)5.3–5.4sec-distributed.2.5.3自编简化模拟器;不复制原文
Dynamo 论文(DeCandia et al., 2007)3.7sec-distributed.2.3.7只引用 quorum/vector clock 思想
Spanner 论文(Corbett et al., 2012)6.10sec-distributed.3.6.10只引用 TrueTime/2PC 公开结论
Jepsen: MongoDB 3.4(2017)4.10sec-distributed.2.4.10只引用公开结论 + 链接
Jepsen: etcd 3.4.3(2020)5.11、9.9sec-distributed.2.5.11只引用公开结论 + 链接
Stripe Idempotent requests12.14sec-distributed.5.12.14只引用公开接口语义
AWS S3 2017 / GitHub 2018 / Cloudflare 2019 / AWS 2021 / GitLab 20172.9、3.11、8.9、11.10、10.10sec-distributed.1.2.9只引用官方复盘事实 + 链接

缺口检查:以上主题表 55 行全部有锚点;母本表中「淘汰」行不需要落点。check_coverage.py 只对主题表做强制校验,母本表用于人工审计。

A.2 术语表

只收本书反复使用、且容易混淆的词。定义按「在本书语境下」写,不追求百科全书式完整。

A–C

ACID 原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。注意 ACID 的 C 是「事务不破坏应用不变量」,不是「副本一致」。

at-least-once(至少一次) 消息/请求至少被处理一次,允许重复。工程上通常配合幂等消费。

at-most-once(最多一次) 最多处理一次,可能丢失。适合可丢的指标、日志。

backpressure(背压) 下游把「我处理不过来」的信号传回上游,让上游降速;不是简单地把队列加大。

CAP 分区(Partition)发生时,在一致性(C)与可用性(A)之间取舍的定理。精确表述见第 4 章;不要把它简化成「三选二」。

causal consistency(因果一致) 如果操作 A 因果先于 B,则所有节点看到的顺序都满足 A 在 B 前;并发操作可以不同顺序。

commit index 复制日志中已提交的位置;只有提交后的日志项才能应用到状态机(Raft 的规则)。

consistent hashing(一致性哈希) 把 key 映射到环上,节点增减时只迁移邻近区间;用于缓存分片与无主复制。

CRDT 无冲突复制数据类型;通过交换律/结合律/幂等性让副本最终收敛,不依赖协调。

D–F

deadline 请求的绝对截止时间;跨进程传播。与「超时」不同:超时是本地等待上限,deadline 是全局预算。

durable(持久) 写入在崩溃后仍可读。复制到多数派不等于持久,还要看是否 fsync、是否提交。

eventual consistency(最终一致) 在没有新写入的情况下,所有副本最终会收敛到同一个值;不规定多久。

exactly-once(精确一次) 端到端只产生一次效果。严格意义上在不可靠网络上无法仅靠传输实现,通常由「至少一次 + 幂等/去重/事务」组合出来。

fencing token 单调递增的令牌;资源端只接受比上次更大的 token,从而挡住过期锁持有者。

FLP 在异步网络中,即使只有一个进程可能崩溃,也不存在同时保证安全与活性的确定性共识算法。工程上用超时/随机化绕过。

G–L

gossip 节点周期性交换状态,最终传播到全集群;用于成员发现、失败检测、反熵。

happens-before(先行发生) 分布式系统中的偏序:同一进程的程序序、消息发送先于接收、传递闭包。

idempotency key(幂等键) 客户端为一次逻辑请求生成的唯一键;重试时复用,服务端据此去重。

isolation level(隔离级别) 事务之间的可见性规则;与副本一致性是不同维度。

linearizability(线性一致) 存在一个全序,扩展了每个操作的实时区间,且读返回全序中最近一次写的值。强于顺序一致。

Little’s law L = λW:系统中平均请求数 = 到达率 × 平均停留时间。容量估算的起点。

LWW(last-write-wins) 用时间戳/版本号决定冲突胜者;简单,但会丢并发写,且依赖时钟。

M–R

MVCC 多版本并发控制;读操作读快照,写操作产生新版本,减少读写阻塞。

PACELC 分区时在 A/C 之间选(PAC);无分区时在延迟(L)与一致(C)之间选(ELC)。

Paxos 一类共识协议;经典的两阶段(prepare/accept)保证安全性,Multi-Paxos 用稳定 leader 提升吞吐。

quorum 多数派;R + W > N 保证读写集合相交,但不自动等于线性一致。

Raft 以可理解性为目标的共识算法;强 leader、term、日志匹配、选举限制。

read index leader 在服务线性一致读前确认自己仍是 leader 并等待状态机追上提交位置的一种机制。

read repair(读修复) 读时发现副本版本不一致,顺手把最新值写回旧副本。

RPO / RTO 恢复点目标(能丢多少数据)/ 恢复时间目标(多久恢复)。复制拓扑的选择由它们驱动。

S–Z

Saga 把长事务拆成一系列本地事务,失败时执行补偿;没有全局隔离性。

sequential consistency(顺序一致) 存在一个全序,所有进程看到的顺序一致,且尊重每个进程的程序序;不要求与实时时间一致。

split brain(分裂脑) 两个节点同时认为自己是主,接受写入;通常由分区 + 过期租约导致。

stale read(陈旧读) 读到了比已确认写入更旧的值;是否允许取决于一致性契约。

state machine replication(状态机复制) 所有副本按相同顺序应用相同命令,得到相同状态;复制日志的核心。

TCC(Try-Confirm-Cancel) 业务层面的两阶段:预留资源、确认、取消;需要业务实现三个接口。

tombstone(墓碑) 删除标记;在分布式存储中用于让删除也参与复制与冲突解决。

two-phase commit(2PC) 协调者先询问参与者能否提交,再统一提交/回滚;阻塞式协议,协调者故障时参与者可能卡住。

vector clock(版本向量) 每个节点维护的计数器向量,用于判断因果先后与并发冲突。

write skew(写偏斜) 两个事务各读一部分、各写另一部分,合起来破坏不变量;快照隔离下仍可能发生,需要 SSI 或显式锁。

WAL(write-ahead log) 先写日志再改数据页;崩溃恢复的基础。

术语在正文首次出现时给英文与缩写;本表只做速查,不替代章节里的定义盒。

A.3 论文、规范与公开复盘索引

只列本书实际引用或建议延伸阅读的材料。链接以官方/原始来源优先;产品文档会变,引用时注明版本或访问时间。

1 共识与复制

材料为什么读链接
L. Lamport, The Part-Time Parliament (1998)Paxos 的原始论文;理解 prepare/accept 的安全性https://lamport.azurewebsites.net/pubs/lamport-paxos.pdf
L. Lamport, Paxos Made Simple (2001)更短的 Paxos 描述https://lamport.azurewebsites.net/pubs/paxos-simple.pdf
D. Ongaro, J. Ousterhout, In Search of an Understandable Consensus Algorithm (2014)Raft 原始论文;选举、日志复制、成员变更https://raft.github.io/raft.pdf
D. Ongaro, Consensus: Bridging Theory and Practice (2014)Raft 博士论文;实现细节与证明https://web.stanford.edu/~ouster/cgi-bin/papers/OngaroPhD.pdf
M. Fischer, N. Lynch, M. Paterson, Impossibility of Distributed Consensus with One Faulty Process (1985)FLP 不可能性;理解为什么需要超时/随机化https://dl.acm.org/doi/10.1145/3149.214121
G. DeCandia et al., Dynamo: Amazon’s Highly Available Key-value Store (2007)无主复制、quorum、vector clock、 hinted handoffhttps://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf
J. C. Corbett et al., Spanner: Google’s Globally-Distributed Database (2012)TrueTime、外部一致性、2PC 的组合https://research.google/pubs/pub39966/

2 一致性与验证

材料为什么读链接
M. Herlihy, J. Wing, Linearizability: A Correctness Condition for Concurrent Objects (1990)线性一致的形式定义https://dl.acm.org/doi/10.1145/78969.78972
L. Lamport, How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs (1979)顺序一致的定义https://dl.acm.org/doi/10.1109/TC.1979.1675439
P. Bailis et al., Highly Available Transactions: Virtues and Limitations (2014)事务与一致性模型的组合边界https://www.vldb.org/pvldb/vol7/p181-bailis.pdf
S. Gilbert, N. Lynch, Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services (2002)CAP 的形式化证明https://dl.acm.org/doi/10.1145/564585.564601
D. Abadi, Consistency Tradeoffs in Modern Distributed Database System Design (2012)PACELC 的提出https://www.cs.umd.edu/~abadi/papers/abadi-pacelc.pdf
Jepsen, MongoDB 3.4.0-rc3 (2017)陈旧读、丢失更新、时钟偏斜的公开分析https://jepsen.io/analyses/mongodb-3-4-0-rc3
Jepsen, etcd 3.4.3 (2020)Raft 实现的严格可串行化、watch 顺序与锁的互斥边界https://jepsen.io/analyses/etcd-3.4.3
P. Bailis, K. Kingsbury, The Network Is Reliable (2014)真实网络分区比想象中常见https://queue.acm.org/detail.cfm?id=2655736

3 事务、存储与消息

材料为什么读链接
H. Berenson et al., A Critique of ANSI SQL Isolation Levels (1995)隔离级别与异常的经典辨析https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-95-51.pdf
M. J. Cahill et al., Serializable Isolation for Snapshot Databases (2008)PostgreSQL SSI 的理论基础https://dl.acm.org/doi/10.1145/1376616.1376690
J. Gray, A. Reuter, Transaction Processing: Concepts and Techniques (1992)事务处理的系统教材书店/图书馆
H. Garcia-Molina, K. Salem, Sagas (1987)Saga 的原始论文与补偿语义https://dl.acm.org/doi/10.1145/38713.38742
P. Helland, Life beyond Distributed Transactions (2007)大规模系统为什么避免 2PChttps://queue.acm.org/detail.cfm?id=3025012
J. Kreps, N. Narkhede, J. Rao, Kafka: a Distributed Messaging System for Log Processing (2011)日志式消息系统的设计https://notes.stephenholiday.com/Kafka.pdf
Kafka Documentation: DesignISR、acks、幂等生产者、事务https://kafka.apache.org/documentation/#design
Amazon S3 Consistency ModelS3 的强一致读后写语义https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html#ConsistencyModel

4 工程与事故复盘

材料为什么读链接
D. Kegel, The Tail at Scale (2013)长尾、对冲请求、延迟放大https://research.google/pubs/pub40801/
B. Beyer et al., Site Reliability Engineering (2016)SLO、错误预算、on-callhttps://sre.google/sre-book/table-of-contents/
LinkedIn Engineering, How LinkedIn customizes Apache Kafka for 7 trillion messages per day (2019)分区元数据、rebalance、配额与跨集群复制https://engineering.linkedin.com/blog/2019/apache-kafka-trillion-messages
AWS, Summary of the Amazon S3 Service Disruption in the Northern Virginia Region (2017)重启、缓存重建、重试风暴https://aws.amazon.com/message/41926/
GitHub, October 21 Post-Incident Analysis (2018)网络分区、MySQL 切换、数据一致性https://github.blog/news-insights/company-news/oct21-post-incident-analysis/
AWS, Summary of the AWS Service Event in the US-East-1 Region (2021)控制面故障、重试与恢复https://aws.amazon.com/message/12721/
GitLab, Postmortem of database outage of January 31, 2017备份、恢复、观测与流程https://about.gitlab.com/blog/2017/02/10/postmortem-of-database-outage-of-january-31/
M. Kleppmann, How to do distributed locking (2016)Redlock 与 fencing 的争论https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
antirez, Is Redlock safe? (2016)对上述批评的回应http://antirez.com/news/101
Stripe, Idempotent requests幂等键的工业实践https://docs.stripe.com/api/idempotent_requests
U.S. SEC, SEC Charges Knight Capital With Violations of Market Access Rule (2013)旧代码路径重复下单、部署不一致https://www.sec.gov/news/press-release/2013-222
Cloudflare, Details of the Cloudflare outage on July 2, 2019边缘配置推送与全局故障https://blog.cloudflare.com/details-of-the-cloudflare-outage-on-july-2-2019/

5 规范

规范用途
RFC 9293 (TCP)只在解释连接复用/超时时引用;不重写
RFC 9110 (HTTP Semantics)方法幂等性、状态码、Retry-After
RFC 7231(已被 9110 取代)历史引用,理解 Idempotency-Key 的语境
W3C Trace Contexttraceparent / tracestate 传播格式
OpenTelemetry Specificationtrace/metrics/logs 的 API 与语义约定
引用原则

本书不复制论文原文、不搬运事故复盘的长段文字;只提取可核对的事实(时间、组件、失败链条、官方结论)并给出链接。若你发现链接失效,按标题搜索原始来源,不要依赖二手转述。

A.4 交付自检与回归清单

这份清单给两类人用:写完一章的作者,和准备把书交付出去的维护者。它只列可机械检查或可复现的项,不列「文笔是否优美」。

1 单章交付

#检查项命令/证据通过标准
1行数wc -l src/partN/chXX.md1000–1400
2章首两栏搜索 chapter-index 与 本章回答两项都在
3编号连续检查 H2 编号 X.Y.Z无跳号、无重复
4锚点唯一python3 tools/check_book.py0 重复
5盒子配平同上0 未闭合
6只用允许盒子同上仅 def/thm/ex/intuition/method/errata/note/proof/sol
7代码围栏有语言同上0 个无语言围栏
8失败模式搜索 失败模式至少 1 个,且属于分区/超时/重复/乱序
9观测命令搜索 观测至少 1 个可复制命令
10真实案例搜索 真实案例至少 1 个,含可核对链接或时间线
11重试重复副作用搜索 重复副作用至少 1 个案例
12陷阱清单末尾 ::: errata8–15 条
13出口检查末尾表格8–12 行,含「没达标去哪」
14母本对应## 母本对应至少 3 行,含「不重复」说明
15下一站最后一段有链接且目标存在
16图figure/pre.layout至少 1 张,SVG id 唯一,有读图结论

2 全书交付

#检查项命令通过标准
1构建mdbook build .退出码 0,无 warning 级别的断链
2结构质检python3 tools/check_book.py0 问题、0 断链、0 重复锚点
3覆盖矩阵python3 tools/check_coverage.py0 缺口
4脚本语法python3 tools/check_projects.py所有 .sh 过 bash -n,所有 .py 过 py_compile
5项目一测试python3 -m unittest -v projects/idempotent-tool/test_server.py全通过
6项目二测试python3 -m unittest -v projects/replicated-kv/test_kv.py全通过
7故障注入bash projects/*/fault_inject.sh --dry-run退出码 0
8重试案例覆盖grep -c 重复副作用 src/part*/ch*.md每章 ≥ 1
9跨书链接抽查 ../linux/... 等相对链接目标页面存在
10禁止项搜索占位标记、未完成提示句0 处
11图 idgrep -o 'id="[^"]*"' src/assets/figures/*.md跨文件唯一
12覆盖矩阵与正文一致抽查 10 个锚点全部能跳转

3 回归清单(每次改正文后)

  1. 改了某章的节号 → 同步改该章锚点、目录、覆盖矩阵、其他章的链接;
  2. 改了代码 → 重跑项目测试与 fault_inject.sh --dry-run,更新正文里的行号/输出;
  3. 改了图 → 重跑 python3 tools/build_figures.py,检查 SVG id 是否仍唯一;
  4. 改了 SUMMARY → 重跑 check_book.py,确认新增页面存在且非空;
  5. 改了协议事实 → 在 CHECK-distributed.md 里记一条,写清依据(论文/规范/版本)。

4 常见「假通过」

现象为什么是假通过正确做法
check_book.py 过了,但章只有 600 行脚本只查 ≥150 行用单章清单第 1 项单独卡 1000–1400
所有代码块都标了 text规避了语言检查可运行代码必须标真实语言;伪代码才用 text
链接指向 # 或空页面没触发断链链接必须有真实目标;跨书用绝对站点路径
覆盖矩阵每行都写「第 X 章」没有锚点,无法核对每行必须有可跳转的 sec-... 锚点
测试只跑 happy path重复/超时/崩溃没覆盖项目测试至少包含丢响应、并发重复、崩溃恢复
故障脚本只 echo没注入任何故障--dry-run 可只打印;真实模式必须改变系统状态
交付前的 10 分钟
cd distributed
mdbook build . >/tmp/build.log 2>&1; echo "build=$?"
python3 tools/check_book.py; echo "check=$?"
python3 tools/check_coverage.py; echo "coverage=$?"
python3 tools/check_projects.py; echo "projects=$?"
python3 -m unittest discover -s projects/idempotent-tool -p 'test_*.py' >/dev/null; echo "p1=$?"
python3 -m unittest discover -s projects/replicated-kv -p 'test_*.py' >/dev/null; echo "p2=$?"

六个 0 才算交付;任何一个非 0,先修再谈内容。