封面与导览
分布式系统与数据面
查看范围、前置与边界
第 1、2 章精读远程调用的状态与重试预算;第 6 章精读 MVCC,第 7、8 章选读;第 12 章做项目一,注入丢响应故障,证明副作用只发生一次。约 12–16 小时。
展开路线 →先快读第 1 章的失败分类,再精读第 3、9、10 章的分区、租约与观测;第 11 章写一份爆炸半径受控的演练剧本。约 10–14 小时。
展开路线 →精读第 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 年公开文档」理解。
为什么写这本书
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 语法 |
| 队列/日志、投递语义、消费者组、CDC | Kafka 的每个配置项逐条解释 |
| 对象存储语义、缓存模式、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_WAIT | 4.5 | 连接池与重试章引用,不重画状态机 |
| socket、epoll、连接队列 | 4.8–4.11 | 项目章只用 Python 标准库的 HTTP server |
| HTTP、DNS、TLS | 4.7 | 不解释报文;只讲 HTTP 语义对重试的影响 |
| 容器、namespace、cgroups | 5.1–5.5 | 项目不依赖容器,最多用进程隔离 |
| Kubernetes 基础 | 5.6 | 只在「发布与容量」处引用 |
- 把「TCP 超时」当成本书的「请求超时」。TCP 的 RTO 是传输层重传计时器;本书的 deadline 是应用层「我最多等到什么时候」,两者可以差一个数量级。
- 把「HTTP 503」当成一种失败。503 是语义,不是原因;本书要求同时看
Retry-After、连接是否被复用、服务端是否已经执行。 - 把「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_call | ms |
T | 客户端设置的超时/deadline | ms |
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 失败模型
本书只用三种失败模型,出现更复杂的模型会明确标注:
| 记号 | 名称 | 允许的失败 | 典型系统 |
|---|---|---|---|
C | crash-stop | 进程崩溃后不再恢复 | 理论分析、简单选主 |
C-R | crash-recovery | 崩溃后重启,持久化状态保留、内存状态丢失 | 大多数数据库、Raft 节点 |
N | 网络分区/异步 | 消息可丢、可重复、可乱序、可延迟任意长;节点不一定崩溃 | 跨机房系统 |
B | Byzantine | 节点可能发送错误消息 | 区块链;本书只提一句 |
默认模型是 C-R + N:节点会崩溃重启,网络会分区、会重复、会乱序,但节点不会伪造消息。凡是依赖「消息一定到达」「时钟一定同步」「节点不会同时暂停」的结论,都会在正文里标出假设。
3 请求与状态
| 记号 | 含义 |
|---|---|
req | 一次逻辑请求,由 (client_id, seq) 唯一标识 |
key | 幂等键,客户端生成,同一逻辑请求重试时必须复用 |
fp | 请求指纹,对方法、路径、关键参数做规范化哈希 |
attempt | 一次物理尝试;同一 req 可以有多次 attempt |
state ∈ {pending, running, succeeded, failed, unknown} | 请求/幂等记录的状态 |
lease_until | 租约到期时间(绝对时间) |
fence | fencing 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 + WSL2 | uname -a |
| Python | 3.11+(3.13 亦可) | python3 -V |
| SQLite | 3.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/lsof | curl --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 端口与清理
| 项目 | 端口 | 清理 |
|---|---|---|
| 项目一 | 8081 | pkill -f idempotent-tool/server.py |
| 项目二 | 7101–7103 | ./run_cluster.sh stop |
| 本书预览 | 3000 | Ctrl-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 指向旧库。
python3 -V≥ 3.11;python3 -c "import sqlite3; print(sqlite3.sqlite_version)"≥ 3.35;mdbook build .退出码 0;python3 tools/check_book.py退出码 0;python3 -m unittest discover -s projects/idempotent-tool -p 'test_*.py'通过;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 三条路线的共同底线
无论选哪条路线,下面五件事必须做,否则这本书对你只是「读过」:
- 亲手制造一次超时但成功,并用幂等键重试;
- 亲手制造一次重复投递,观察副作用是否翻倍;
- 亲手制造一次分区/旧主,观察读到的值;
- 用一条命令判断一次过载(不是看 CPU,而是看队列与延迟);
- 写一份故障演练剧本,包含假设、爆炸半径、回滚和观测点。
- 只读第 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/Outbox | 6 |
| D6 | 设计事件流 | 能定义分区键、顺序保证、消费者幂等、重放策略 | 7 |
| D7 | 设计缓存策略 | 能给出 TTL、失效、击穿保护、热点 key 方案 | 8 |
| D8 | 设计锁与选主 | 锁带租约与 fencing;选主带 quorum 与降级路径 | 9 |
| D9 | 设计观测 | 每个关键路径有 RED 指标、trace、结构化日志与 SLO | 10 |
| 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 提交与业务事务的原子性,或 outbox | 7、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、I3 | J1、J2、D5、D7、V1 |
| SRE | J1、J3、J8、J9、J10、D8、D9、D10、V1、V4 | D3、D4、I7 |
| 存储 | J3、J4、J5、D3、D4、I4、I5、I6、I7、V2 | D5、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 从一次工具调用到分布式请求
一次工具调用离开本机之后,究竟经过了哪些 hop?为什么本地函数调用只有「成功/失败」,远程调用却多出一个「未知」?超时、重试、重复、乱序分别在哪个环节发生,为什么会把一次操作变成两次副作用?
能画出一次远程调用的状态机并标出 unknown;能用 curl -w 和 trace id 把一次请求的延迟切成若干段;能写出远程调用契约的 12 个必答问题;能解释为什么「超时后重试」是最常见的重复副作用来源。
一个内部工具平台提供「发券」能力。客户端调用 POST /tool/issue-coupon,服务端做三件事:在数据库里写一条发券记录、调用下游券系统、把券号返回给客户端。某天网络抖动,客户端在 3 秒时超时;SDK 的重试策略是「超时重试 3 次,指数退避」。三次重试都超时,用户看到「发放失败」,又点了一次。最后用户收到 4 张券。
这个事故里没有哪台机器真正宕机,也没有哪段代码写错。三方的行为单独看都合理:
- 客户端:「超时就是失败,失败当然要重试。」
- 服务端:「每个请求我都处理了,是网络没把响应带回去。」
- 下游:「我们只保证至少一次,重复调用要上游去重。」
把三句话放在一起,重复副作用就出现了。分布式系统的很多故障不是「组件坏了」,而是「组件对同一个事实的理解不一致」:客户端认为失败,服务端认为成功,下游认为被调用了两次。本章要做的,就是把这三种理解拆开,落到一条请求路径、一个状态机和一个契约上。
这一章是全书的地基。后面的 RPC、复制、一致性、事务、队列、缓存、锁、观测、容量,都会反复回到本章的三个问题:这次调用成功了吗?读到的值是最新的吗?出事时看什么?先把这三个问题的语言统一,后面才不会各说各话。
1.1 一次工具调用的十二个 hop
先看一条具体的路径。假设客户端在浏览器里点「发放」,请求最终到达工具服务的 /v1/tool-calls:
图上的五个方框只是粗粒度。把一次调用拆细,至少经过下面十二个 hop。每一个 hop 都有自己的队列、超时和失败模式;把它们列全,是为了知道「延迟到底花在哪」以及「失败到底发生在哪」。
| # | hop | 典型耗时 | 这里的失败会长什么样 |
|---|---|---|---|
| 1 | 客户端序列化请求体 | 0.1–2 ms | 参数太大、编码错误、循环引用 |
| 2 | 连接池取连接 / 建连 | 0–200 ms | 池耗尽、握手超时、DNS 失败 |
| 3 | 请求写入内核发送缓冲 | 0.01–1 ms | 发送缓冲满、连接被 RST |
| 4 | 网络传输到接入层 | 0.5–50 ms | 丢包、抖动、跨地域 RTT |
| 5 | 接入层排队 | 0–500 ms | 限流队列满、线程池满 |
| 6 | 鉴权 / 路由 | 0.1–10 ms | token 过期、路由规则不命中 |
| 7 | 服务 A 排队 | 0–1 s | 工作线程不足、GC 停顿 |
| 8 | 服务 A 调服务 B | 1–500 ms | 下游慢、连接池耗尽、超时 |
| 9 | 服务 B 执行工具 | 1 ms–30 s | 下游超时、第三方限流、业务失败 |
| 10 | 数据库提交 | 0.5–50 ms | 锁等待、磁盘满、主从切换 |
| 11 | 响应回传 | 0.5–50 ms | 响应丢失、连接被中间设备断开 |
| 12 | 客户端解析响应 | 0.1–5 ms | 半包、协议不匹配、字段缺失 |
这张表至少说明三件事。
第一,延迟不是一个数,而是一条分布。 同一段代码在不同负载下,hop 5 和 hop 7 可以从 0 ms 变成 500 ms。你在监控上看到的 p99,往往是某个 hop 的排队时间,而不是「服务处理慢」。
第二,失败不是一个点,而是一条链。 客户端看到的是 hop 11 的「响应没回来」,但根因可能在 hop 7 的线程池、hop 10 的锁等待,甚至 hop 4 的一次丢包。只看客户端错误码,等于只看最后一环。
第三,重试会改变路径,而不是回到原点。 第二次尝试可能命中另一个实例、另一个可用区、另一个连接;它可能绕过第一次已经完成的副作用,也可能在同一张表上再写一次。重试不是「再试一次」,而是「再产生一次请求」。
逻辑请求(logical request)是用户意图的一次表达,由客户端生成的 (client_id, seq) 或幂等键唯一标识。物理尝试(attempt)是逻辑请求的一次实际发送;同一逻辑请求可以有多次物理尝试,每次尝试可能落在不同连接、不同实例、不同数据中心。
服务端必须能区分这两者:只看到物理尝试,就会把重试当成新请求;只有把它们归并到同一个逻辑请求,去重才有可能。
这个区分看起来只是命名,实际决定了接口设计。很多系统的请求体里没有 client_id/seq,服务端只能按「每次 HTTP 请求」计数;客户端一重试,服务端就多一条记录。没有逻辑请求 ID,就没有去重的锚点。
一个常见的反例是「用数据库自增 ID 当请求 ID」:自增 ID 是服务端生成的,第二次尝试会拿到新的 ID,服务端无法知道它和第一次是同一个意图。正确的做法是客户端在第一次发送前就生成键,并在所有重试中复用它。
{
"client_id": "tool-console-7f3a",
"seq": 1842,
"idempotency_key": "tool-console-7f3a:1842",
"tool": "issue_coupon",
"args": {"user_id": "u-1024", "amount": 100},
"deadline_ms": 3000
}
seq 是客户端本地的单调序号,用来防止「同一个键被不同请求复用」。服务端应当把 (client_id, seq) 和 idempotency_key 都存下来:前者用于审计,后者用于去重;两者不一致时,按冲突处理,而不是猜。
1.2 本地调用与远程调用的七个差异
本地函数调用和远程调用看起来都是「传参、执行、返回」,但至少有七个本质差异。理解这七个差异,比背会 gRPC 的四种调用方式更有用。
| # | 差异 | 本地调用 | 远程调用 | 后果 |
|---|---|---|---|---|
| 1 | 延迟 | 纳秒–微秒 | 毫秒–秒,且长尾 | 同步等待会放大排队 |
| 2 | 失败 | 抛异常或返回错误码 | 可能「不知道」 | 需要 unknown 状态 |
| 3 | 内存 | 共享地址空间 | 各自独立 | 不能靠指针/锁共享状态 |
| 4 | 数据 | 对象引用 | 必须序列化 | 版本、兼容、体积 |
| 5 | 并发 | 线程/协程可控 | 多实例、多副本 | 幂等与顺序问题 |
| 6 | 边界 | 进程内 | 信任边界/网络边界 | 鉴权、重放、注入 |
| 7 | 时钟 | 同一个时钟 | 不同机器、可能漂移 | 不能拿时间戳定序 |
1.2.1 延迟:长尾不是异常,是常态
本地调用的 p99 可能是 p50 的 2 倍;跨网络的 p99 可能是 p50 的 20 倍。原因不神秘:一次远程调用要经过多跳队列,任何一跳的尾部都会叠加到总延迟上。第 1.4 节会把延迟拆开算;这里先记住一个结论:同步调用链越长,p99 越接近「最慢那一跳的 p99 之和」。
1.2.2 失败:从二元到三元
本地调用要么返回,要么抛异常;即使崩溃,调用方也通常能通过进程状态知道「没执行完」。远程调用多出第三种状态:请求可能已经到达并执行,但响应在回程丢失。调用方既不能当成功,也不能当失败。
很多 SDK 把这种状态映射成 TimeoutException,然后在上层被当成失败重试。这是本书反复强调的头号错误:超时是「本地不再等待」,不是「远端没有执行」。
1.2.3 内存:不能共享,只能复制
本地调用可以把一个大对象传进去,函数内部修改它,调用方立刻看到。远程调用必须把对象序列化成字节,接收方反序列化出一份副本;两边修改互不可见。这带来三个问题:
- 大对象传输成本高,可能触发序列化/反序列化的 CPU 尖峰;
- 结构变更需要版本兼容,旧客户端可能不认识新字段;
- 不能把「锁」放在调用方,必须把并发控制放到服务端或存储层。
1.2.4 数据:序列化不是免费的
JSON 可读但体积大;Protobuf 紧凑但有 schema 演进规则;Thrift、Avro、MessagePack 各有取舍。序列化层最容易被忽略的失败是兼容性:新增必填字段、修改枚举值、删除字段后复用编号,都会让旧客户端在新服务端上失败。第 2 章会把「契约」单独列一节。
1.2.5 并发:多实例让「顺序」消失
本地调用通常只有一个进程,锁和队列可以保证顺序。远程调用可能落到多个实例、多个副本,甚至多个地域。没有全局队列,就没有全局顺序;要么把顺序收敛到单个分区/单个 leader,要么让操作可交换(幂等、CRDT)。
1.2.6 边界:信任模型变了
本地调用默认「调用方和被调用方在同一个信任域」。远程调用跨进程、跨主机、跨租户,必须考虑鉴权、重放、注入、限流。一个典型的错误是「内网调用不加鉴权」:内网也会被横向移动,攻击者可以伪造请求。
1.2.7 时钟:时间戳不是顺序
两台机器的墙上时钟可能差几毫秒到几秒,NTP 调整还可能让时钟倒退。用 time.time() 给请求排序,在跨机器时不可靠;用单调时钟算超时,用逻辑时钟/版本号定序。第 4 章会讲线性一致为什么不能靠「谁的时间戳大」。
远程调用不是「更慢的本地调用」,而是「在一个会丢包、会重复、没有全局时钟、没有共享内存的环境里,重新定义一次函数调用」。
1.2.8 一个对照实验:把本地调用改成远程调用
把下面的函数从本地调用改成 HTTP 调用,观察失败语义怎么变:
# 本地版本:调用方能看到异常,也能确定函数是否返回
def issue_coupon_local(user_id: str) -> str:
record = db.insert_coupon(user_id) # 副作用
return record.coupon_id
# 远程版本:调用方只能看到 HTTP 结果或超时
def issue_coupon_remote(user_id: str, key: str) -> str:
resp = http.post("/v1/coupons", json={"user_id": user_id},
headers={"Idempotency-Key": key}, timeout=1.0)
return resp.json()["coupon_id"]
本地版本里,如果 db.insert_coupon 成功但后续代码抛异常,调用方至少知道「异常发生在我可控的栈里」,可以查数据库。远程版本里,超时可能发生在服务端写库之前、之后、或者响应回程;调用方的栈里没有任何服务端状态。
| 场景 | 本地调用 | 远程调用 |
|---|---|---|
| 参数错误 | 立即抛 ValueError | 可能 400,也可能网关 502 |
| 执行中崩溃 | 进程退出,调用方通常也崩溃 | 连接 RST/超时,服务端可能已提交 |
| 执行成功但返回失败 | 几乎不会(同一进程) | 响应丢失、序列化失败、代理超时 |
| 重试 | 重新调用函数,副作用再来一次 | 重新发请求,副作用可能再来一次 |
| 幂等 | 调用方自己控制 | 必须跨进程传递键 |
这个对照实验说明:远程调用不是「加一个 HTTP 客户端」那么简单,它把错误处理从「异常捕获」变成了「状态收敛」。
1.3 请求状态机:unknown 为什么必须单独存在
把一次逻辑请求的状态画出来,会得到下面这张图:
图中最重要的是 unknown。它不是「失败」的同义词,也不是「成功」的模糊态,而是一个需要额外动作才能离开的状态:查询服务端、用同一个键重试、或者等待对账。
一次逻辑请求的状态取值:
pending:服务端已接收,尚未开始执行;running:正在执行,持有租约(lease);succeeded:副作用已提交,结果可查询;failed:确定未提交,可安全重试或放弃;unknown:客户端无法判断服务端是否执行;只能通过查询或同键重试收敛。
状态迁移必须满足:succeeded 和 failed 是终态(除非业务补偿);unknown 只能由 running 在超时/响应丢失时进入;unknown 不能直接当 failed 处理。
为什么不能把 unknown 折成 failed?因为两者的安全动作不同:
| 状态 | 客户端可以做什么 | 不可以做什么 |
|---|---|---|
failed | 换新键重试、返回用户失败、触发补偿 | 假设副作用已发生 |
unknown | 用同一个键重试、查询状态、等待对账 | 换新键重发、直接告诉用户失败 |
succeeded | 返回结果、复用结果 | 再次执行副作用 |
running | 轮询、等待、超时后转 unknown | 并发再发一次(除非有租约接管) |
这张表是第 12 章项目一的全部设计基础。项目里的 calls 表会有 status、lease_until、attempts、effect_id 四个字段,分别对应「状态机」「租约」「尝试次数」「副作用幂等 ID」。
一个可运行的 Python 状态迁移检查器长这样:
from enum import Enum
class State(str, Enum):
PENDING = "pending"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
UNKNOWN = "unknown"
ALLOWED = {
State.PENDING: {State.RUNNING, State.FAILED},
State.RUNNING: {State.SUCCEEDED, State.FAILED, State.UNKNOWN},
State.UNKNOWN: {State.SUCCEEDED, State.FAILED, State.RUNNING},
State.SUCCEEDED: set(),
State.FAILED: {State.RUNNING}, # 只允许同键重试
}
def transition(cur: State, nxt: State) -> State:
if nxt not in ALLOWED[cur]:
raise ValueError(f"illegal transition: {cur} -> {nxt}")
return nxt
这段代码的关键不在 ALLOWED 表,而在 FAILED -> RUNNING 只允许同键重试。如果调用方换了一个新键,服务端会创建一条新记录,状态机无法阻止重复副作用;状态机只能约束「同一次逻辑请求」,不能约束「用户又点了一次」。后者需要业务层的去重键(例如「用户 + 券模板 + 活动 + 时间窗」),第 12 章会展开。
- 「unknown 就是失败,重试就行」:换新键重试会重复副作用;必须复用原键。
- 「查询状态一定可靠」:查询也可能超时/读到旧副本;查询本身要带一致性要求(第 4 章)。
- 「服务端返回 500 就一定是 failed」:500 可能是提交后序列化失败、也可能是网关错误;要看错误码语义和幂等记录。
1.3.2 unknown 的三种收敛路径
unknown 不能一直悬着。工程上有三条收敛路径,各有代价:
客户端 服务端 存储
│ GET /calls/K1 │ │
├────────────────────▶│ 查 calls by key │
│ ├────────────────────▶│
│ 200 succeeded │ │
│◀────────────────────┤ │
│ │ │
│ POST /calls (K1) │ │
├────────────────────▶│ 命中幂等记录 │
│ 200 succeeded │ 直接返回原结果 │
│◀────────────────────┤ │
│ │ │
│ 等待/对账 │ 异步 worker 扫描 │
│ │ running 超租约 → 重试 │
| 路径 | 何时用 | 代价 | 风险 |
|---|---|---|---|
| 查询状态 | 有 call_id 或幂等键查询接口 | 一次额外 RTT | 查询可能读到旧副本,需要一致性读 |
| 同键重试 | 没有查询接口,但服务端支持幂等 | 一次额外执行(被去重) | 服务端没做去重时会重复 |
| 等待对账 | 异步任务、支付、跨日结算 | 延迟高 | 对账逻辑本身可能出错 |
三条路径可以组合:先查询,查不到再同键重试;异步任务则靠 worker 扫描租约过期的记录。关键是不要用「换新键重发」来收敛 unknown——那等于放弃身份。
如果查询走从库,可能读不到刚提交的记录;客户端会误以为「没执行」,然后同键重试。重试本身是安全的(服务端去重),但会增加延迟。更稳妥的做法是让查询接口支持「按幂等键读主库」或「读己之写」会话保证(第 4 章)。
1.4 延迟预算:p50 骗人,p99 决定体验
假设一个接口的延迟分布如下:
| 分位 | 延迟 | 含义 |
|---|---|---|
| p50 | 80 ms | 一半请求比这快 |
| p90 | 220 ms | 10% 请求超过 220 ms |
| p99 | 1.2 s | 1% 请求超过 1.2 s |
| p999 | 4.5 s | 千分之一超过 4.5 s |
如果只看 p50,你会觉得「80 ms 很快」;但对一个每天 1 亿次调用的接口,p99 意味着每天有 100 万次请求超过 1.2 秒。用户感知的是他这一次的延迟,不是平均值。
更麻烦的是,p99 会被调用链放大。一个请求要串行调用 10 个服务,每个服务独立地有 1% 的概率超过 1 秒,那么至少一个服务超 1 秒的概率是:
也就是说,单服务 p99 是 1 秒,端到端 p90 左右就可能超过 1 秒。这就是「长尾放大」(tail amplification)。第 2 章的对冲请求(hedged request)和第 11 章的容量规划都会回到这个公式。
1.4.1 把预算按 hop 切开
假设产品要求端到端 p99 ≤ 1 s。你不能给每个 hop 都设 1 s 超时——那样最坏情况会叠加成 12 s。正确做法是先留余量,再按 hop 分配:
| hop | 预算 | 依据 |
|---|---|---|
| 客户端排队/序列化 | 50 ms | 本地 CPU,可观测 |
| 建连/连接池 | 100 ms | 池命中时接近 0;未命中才花 |
| 网络到接入层 | 80 ms | 同城 RTT + 抖动 |
| 接入层排队 | 100 ms | 限流/线程池 |
| 服务 A 处理 | 250 ms | 含一次下游 RPC |
| 服务 B 处理 | 300 ms | 工具执行,允许慢 |
| 数据库提交 | 120 ms | p99 锁等待 |
| 响应回传 + 客户端解析 | 100 ms | 回程与反序列化 |
| 合计 | 1100 ms | 留 100 ms 余量给未知抖动 |
这张表是预算,不是 SLA。每个 hop 的实际超时应比预算略小(例如 80%),这样上游还有时间做降级;如果每个 hop 都等到预算上限,端到端一定超。
1.4.2 deadline 不是 timeout
timeout 是「我愿意等多久」,deadline 是「这个请求最晚必须在什么时候完成」。两者在单跳里看起来一样,在多跳里差别巨大:
- 服务 A 收到请求时已经过了 200 ms,它给服务 B 的剩余预算应该
deadline - now,而不是重新给 1 s; - 如果 A 给 B 重新 1 s,B 可能执行到第 1.1 s,A 早已超时返回,B 的副作用却发生了;
- 如果 A 把绝对 deadline 传给 B,B 会在剩余预算内尽快失败,减少无效执行。
Python 里的绝对 deadline:
import time
class Deadline:
def __init__(self, budget_ms: int):
self._end = time.monotonic() + budget_ms / 1000.0
def remaining_ms(self) -> int:
return max(0, int((self._end - time.monotonic()) * 1000))
def expired(self) -> bool:
return self.remaining_ms() == 0
跨进程传递时,不要传 remaining_ms,也不要用墙上时钟;传「剩余预算」会在每一跳被重新解释,传墙上时钟会受 NTP 影响。工程上的折中是:在 RPC header 里传 grpc-timeout(相对时间),由每一跳在接收时立刻换算成本地单调时钟的 deadline;或者传 x-request-deadline-ms 并在网关做一次校准。两者都要在契约里写清楚「谁负责换算」。
墙上时钟会被 NTP 向前/向后调整。如果发送方和接收方的时钟差 2 秒,接收方可能认为 deadline 已过(立刻失败),也可能认为还有很多时间(继续执行)。单调时钟只在同一台机器内有意义,不能直接跨机器传;所以协议里通常传相对预算,接收方用本地单调时钟换算。
1.4.3 一个排队算例:为什么 70% 就开始抖
假设一个服务有 10 个工作线程,每个请求平均处理 10 ms,服务率 μ = 100 req/s,到达率 λ = 90 req/s。利用率:
用最简单的 M/M/1 近似(单队列、单服务台;多线程时用 M/M/c,趋势相同),平均停留时间:
也就是说,处理时间只有 10 ms,但排队让平均停留时间变成 100 ms。把利用率再提高:
| 利用率 ρ | λ (req/s) | 平均停留 W | 相对处理时间 |
|---|---|---|---|
| 0.5 | 50 | 20 ms | 2× |
| 0.7 | 70 | 33 ms | 3.3× |
| 0.9 | 90 | 100 ms | 10× |
| 0.95 | 95 | 200 ms | 20× |
| 0.99 | 99 | 1 s | 100× |
这张表说明:容量不是「CPU 没满就能扛」,而是「排队时间是否还能接受」。当利用率到 90%,一次 10% 的流量突增就会把 ρ 推到 0.99,延迟放大 10 倍;如果重试再增加 10% 流量,系统直接进入正反馈。第 11 章会把这条曲线和重试风暴接起来。
1.5 部分失败:超时只是其中一种
分布式系统里的「失败」至少有七种。把它们混成一种,就会用错恢复手段。
| 失败 | 表现 | 检测信号 | 常见错误处理 |
|---|---|---|---|
| 崩溃(crash) | 进程退出,连接 RST | 连接错误、健康检查失败 | 立即重试到其他实例 |
| 超时(timeout) | 客户端不再等待 | deadline 到期 | 当失败重试(错) |
| 分区(partition) | 两组节点互相不可达 | 心跳失败、quorum 丢失 | 两边都继续写(错) |
| 重复(duplicate) | 同一请求被执行多次 | 幂等键冲突、日志重复 | 只靠重试次数限制(不够) |
| 乱序(reorder) | 消息/请求到达顺序与发送不同 | 版本号倒退、旧值覆盖新值 | 用时间戳排序(不可靠) |
| 消息丢失(loss) | 请求/响应消失 | 超时、序列号空洞 | 无限重试(放大) |
| 拜占庭(byzantine) | 节点发送错误信息 | 校验失败、签名不匹配 | 普通重试(无效) |
本书默认不处理拜占庭失败(除了第 5 章提一句),因为大多数数据面系统的信任模型是「节点不会伪造消息」,但会崩溃、会暂停、会丢包。
1.5.1 失败检测器不完美
「节点挂了」这件事本身就不是可判定的。你只能根据心跳超时推测它挂了;但对方可能只是 GC 停顿、网络抖动、或者 CPU 被抢占。任何基于超时的失败检测都会产生假阳性:把慢节点判死,触发主从切换,然后旧主恢复,出现双主。
这就是为什么第 3 章要讲 fencing、第 9 章要讲租约、第 5 章要讲 quorum:不要把「检测到失败」当成「失败已确认」。检测只是触发动作的信号,动作本身必须满足安全性(不会因为误判而产生两个写者)。
1.5.2 失败的处理顺序
遇到一次失败,正确的处理顺序是:
- 分类:是确定没执行、确定失败、还是 unknown?
- 收敛:unknown 先查询/同键重试,不要换键;
- 隔离:把故障实例从负载均衡里摘掉,避免重试继续打到它;
- 降级:如果依赖不可用,返回缓存/默认值/排队,而不是无限重试;
- 观测:记录 request_id、attempt、状态迁移,供事后对账。
这个顺序不能反。很多事故的根因不是「第一次失败」,而是「失败后立刻重试,重试把故障扩散到整个集群」。
请求失败
├─ 有明确错误码且语义为「未执行」 → failed → 可换实例重试(同键)
├─ 有明确错误码且语义为「已拒绝」 → failed → 不重试,返回用户
├─ 超时/连接断开/5xx 无体 → unknown → 查询状态;查不到则同键重试
└─ 重试预算耗尽 → 返回 503 + Retry-After,交给上游/用户
决策树的第一行不是「重试」,而是「分类」。分类错了,后面全错。
1.5.3 心跳检测:为什么「假阳性」不可避免
故障检测器通常用心跳实现:
import time
class HeartbeatDetector:
def __init__(self, timeout_s: float = 3.0):
self.timeout_s = timeout_s
self.last_seen = {} # node -> monotonic timestamp
def observe(self, node: str) -> None:
self.last_seen[node] = time.monotonic()
def alive(self, node: str) -> bool:
seen = self.last_seen.get(node)
return seen is not None and time.monotonic() - seen < self.timeout_s
这段代码能用,但它有三个无法消除的误差来源:
- 网络延迟:心跳在队列里排了 3.1 s,节点本身活着,却被判死;
- GC/STW:进程暂停 2 s,心跳线程没运行,发送方看到的是「没心跳」;
- 调度:容器被 CPU 限流,心跳线程抢不到时间片。
所以任何基于超时的心跳都会产生假阳性(把活节点判死)和假阴性(把死节点判活,直到超时)。工程上不是追求「检测准确」,而是让「误判的后果可控」:
| 误判 | 后果 | 防护 |
|---|---|---|
| 假阳性(活→死) | 触发主从切换,可能出现双主 | fencing、quorum、旧主自停 |
| 假阴性(死→活) | 请求继续打到死节点,超时 | 客户端重试、负载均衡摘除 |
第 3 章的分区、第 5 章的选举、第 9 章的租约,都是围绕「误判之后怎么不产生两个写者」设计的。心跳只能触发怀疑,不能证明死亡;这是分布式系统里最贵的一课。
1.6 失败模式:超时后的三种真相
现在把镜头拉近到一次超时。客户端在 T = 1 s 返回 timeout,服务端可能处于三种状态:
第一种:请求没到。 报文在网络上丢了,或者连接根本没建立。服务端没有任何记录,客户端重试是安全的(但仍应复用同一个键,避免「第一次其实到了、只是响应丢了」的边界情况)。
第二种:执行了,响应丢了。 服务端完成了副作用,但在返回路径上连接被重置、代理超时、客户端进程被 kill。服务端有记录,客户端不知道。此时换新键重试会重复执行;同键重试会被去重。
第三种:执行并提交,响应丢了。 服务端不仅执行了,还把结果写进了数据库/下游。这是最危险的一种,因为「补偿」也可能重复。此时唯一安全的做法是:用同一个键查询/重试,让服务端返回已提交的结果。
三种真相在客户端看来完全一样:都是 timeout。这就是为什么「超时」不能作为重试的充分条件。重试可以发生,但必须带上能让服务端识别「这是同一个逻辑请求」的键。
客户端发送 issue_coupon(user=u-1024, key=K1),1 s 超时。
- 结局 A:请求丢在客户端到网关之间。服务端无记录。客户端用 K1 重试,服务端创建记录并执行一次。
- 结局 B:服务端执行成功,写库成功,响应在网关处超时。客户端用 K1 重试,服务端查到 K1 已成功,直接返回原券号,不重复发券。
- 结局 C:服务端执行成功,但下游券系统也超时;服务端把记录标为
unknown并返回 202。客户端轮询GET /calls/K1,最终看到succeeded或failed。
如果客户端在 B/C 中改用 K2 重试,服务端会创建第二条记录,下游会再发一张券。事故不是「重试」本身,而是「重试没有携带身份」。
1.6.1 服务端应该返回什么
面对不确定的执行结果,服务端有三种返回策略:
| 策略 | 返回 | 优点 | 缺点 |
|---|---|---|---|
| 同步等待 | 200 + 结果 | 客户端简单 | 长事务占用连接,超时后仍未知 |
| 异步受理 | 202 + call_id | 快速释放连接,可查询 | 客户端要轮询,状态最终一致 |
| 快速失败 | 503 + Retry-After | 保护服务端 | 客户端可能误以为没执行 |
没有一种策略适合所有场景。发券、支付这类有副作用的操作,通常选「异步受理 + 可查询」;纯读操作可以同步等待;过载时快速失败并明确「未执行」。关键是返回体里要能区分「已执行」「未执行」「未知」,而不是把所有失败都塞进 500。
一个可用的响应契约:
{
"call_id": "c-9f2a",
"status": "unknown",
"attempt": 2,
"retry_after_ms": 500,
"query_url": "/v1/tool-calls/c-9f2a"
}
status 是服务端对逻辑请求状态的判断,不是 HTTP 状态码的复述。HTTP 200 + status=unknown 是合法的:请求被受理了,但执行结果还没确定。客户端不应看到 200 就认为成功,也不应看到 unknown 就换键重发。
1.7 重试如何把一次调用变成两次副作用
重试的数学很简单,后果却不简单。假设一个客户端对每次请求最多重试 3 次,上游网关又对失败请求重试 3 次。最坏情况下,下游收到:
次请求。如果下游每次都会发一张券,用户就可能收到 16 张。重试是有成本的,而且成本按层数指数放大。
更隐蔽的是,重试不一定发生在「失败」之后。很多 SDK 的超时重试逻辑是:
def call_with_retry(fn, retries=3):
for attempt in range(retries + 1):
try:
return fn() # 超时抛 TimeoutError
except TimeoutError:
if attempt == retries:
raise
time.sleep(2 ** attempt) # 1s, 2s, 4s
这段代码把 TimeoutError 当成「确定失败」,但超时只说明本地不再等待。第二次 fn() 会重新生成请求 ID、重新走一遍完整路径;服务端看到的是一个新请求。
一个最朴素的修复是「重试时复用同一个请求 ID」:
def call_with_idempotency(fn, key: str, retries=3):
for attempt in range(retries + 1):
try:
return fn(idempotency_key=key) # 服务端按 key 去重
except TimeoutError:
if attempt == retries:
raise
time.sleep((2 ** attempt) * 0.1 + random.random() * 0.05)
注意这里加了抖动(jitter)。没有抖动,所有客户端会在同一时刻重试,形成「重试风暴」;第 2.3 节会给出完整的退避公式。
1.7.1 一个重复副作用的完整链路
把发券事故拆成时间线:
| 时间 | 客户端 | 服务端 | 下游 | 结果 |
|---|---|---|---|---|
| t0 | 发送 K1 | 收到,开始执行 | — | — |
| t1 | — | 写库成功 | 调用发券 | — |
| t2 | — | 等待下游 | 发券成功,响应慢 | 副作用已发生 |
| t3 | 3 s 超时,重试 K1 | 收到重复请求 | — | 若按 K1 去重,返回处理中 |
| t4 | 再次超时,换 K2 重试 | 收到新请求 | 再发一张 | 重复 |
| t5 | 用户看到失败,手动重试 | 收到 K3 | 再发一张 | 第三次重复 |
事故的根因不是某一个时刻,而是「重试键在中途变了」:t3 还在用 K1,t4 却换成了 K2。只要键不变,服务端就有机会把 t4 识别为同一个逻辑请求;键一变,任何去重表都无能为力。
某工具平台的发券接口没有幂等键,客户端 SDK 在超时后自动重试 3 次,用户手动重试 1 次。服务端每次请求都插入一条 coupon_issue 记录并调用下游,下游按「请求 ID」去重,但每次重试的请求 ID 都是新生成的。结果:一次用户操作产生 4 条发券记录、4 张券。
修复分三层:
- 客户端:在用户点击时生成
idempotency_key,所有自动/手动重试复用它; - 服务端:
coupon_issue表对idempotency_key建唯一索引;重复请求返回原记录; - 下游:用
coupon_issue.id作为发券的幂等 ID,保证即使服务端重放,券也只发一次。
三层缺一不可。只改客户端,恶意/旧版客户端仍能绕过;只改服务端,下游仍可能重复;只改下游,服务端会积累重复记录。
1.7.2 为什么「最多重试 3 次」不是保护
「3 次」限制的是单个客户端的尝试次数,不是系统的总请求量。考虑 1 万个客户端同时超时:
- 每个客户端重试 3 次,瞬间多出 3 万个请求;
- 服务端本来就慢,多出的请求让它更慢;
- 更多客户端超时,触发更多重试;
- 最终形成正反馈,直到服务端完全不可用。
这就是重试风暴。防护手段不是「把 3 改成 1」,而是:
- 重试预算:重试请求量不得超过原始请求量的一定比例(例如 10%);
- 退避 + 抖动:把重试摊开,避免同步;
- 熔断:下游错误率超过阈值时,直接失败一段时间,不再重试;
- 幂等:即使重试到达,也不产生额外副作用。
一个最小的重试预算实现:
import random, time
class RetryBudget:
def __init__(self, ratio: float = 0.1, min_tokens: int = 10):
self.ratio = ratio
self.tokens = float(min_tokens)
self.last = time.monotonic()
def _refill(self, requests: int) -> None:
now = time.monotonic()
self.tokens = min(self.tokens + requests * self.ratio * (now - self.last), 1000)
self.last = now
def allow(self, requests: int = 1) -> bool:
self._refill(requests)
if self.tokens >= 1:
self.tokens -= 1
return True
return False
def backoff(self, attempt: int) -> float:
return min(2 ** attempt * 0.1, 2.0) * (0.5 + random.random())
这里的 tokens 以「原始请求量 × ratio」补充,重试消耗 token;预算耗尽直接失败。第 2 章会把这段扩展成带 Retry-After 和服务端反馈的版本。
第 2 章和第 11 章会分别从协议和容量角度展开。
1.8 观测:用 curl -w 与 trace id 切开延迟
在讨论更多机制之前,先学会看一次请求。curl -w 可以把一次 HTTP 调用拆成多个阶段:
curl -sS -o /dev/null \
-w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} \
ttfb=%{time_starttransfer} total=%{time_total} code=%{http_code}\n' \
http://127.0.0.1:8081/v1/tool-calls/c-9f2a
一次本机测试的输出:
dns=0.000021 connect=0.000312 tls=0.000000 ttfb=0.004812 total=0.004901 code=200
怎么读:
| 字段 | 含义 | 异常时先看什么 |
|---|---|---|
time_namelookup | DNS 解析耗时 | 解析慢/失败;本地 hosts 是否生效 |
time_connect | TCP 建连耗时 | 连接池未命中、SYN 重传、防火墙 |
time_appconnect | TLS 握手耗时 | 证书链、会话复用、CPU |
time_starttransfer | 首字节时间(TTFB) | 服务端排队/处理慢,或响应大 |
time_total | 总耗时 | 传输/客户端解析 |
注意:time_connect 包含 time_namelookup,time_starttransfer 包含前面所有阶段。不要把它们相加。这个细节和 TCP 握手本身无关,属于 curl 的计时定义;TCP 状态机、握手细节见 Linux 书 4.5,本书不重写。
如果 TTFB 高、time_connect 正常,问题通常在服务端排队或下游;如果 time_connect 高,先查连接池、DNS、网络,而不是业务代码。
1.8.1 trace id:把客户端和服务端串起来
curl 只能看到客户端视角。要知道服务端把时间花在哪,需要 trace id。最小实现是在请求头里带一个 ID,并在每一层日志里打印:
TRACE_ID=$(python3 -c 'import uuid; print(uuid.uuid4().hex)')
curl -sS -H "X-Trace-Id: $TRACE_ID" \
-H 'Idempotency-Key: demo-1' \
-H 'Content-Type: application/json' \
-d '{"tool":"charge","args":{"amount":100}}' \
http://127.0.0.1:8081/v1/tool-calls
服务端日志:
ts=2026-10-19T09:31:02.114Z trace=d7c1... span=root op=POST /v1/tool-calls status=202 dur=3ms
ts=2026-10-19T09:31:02.117Z trace=d7c1... span=db op=insert calls dur=1ms
ts=2026-10-19T09:31:02.121Z trace=d7c1... span=tool op=charge dur=180ms status=ok
这条日志说明:总耗时 3 ms 是假象,工具执行在异步线程里继续了 180 ms;202 返回的是「已受理」,不是「已完成」。只看访问日志的 dur,会把异步执行的服务看成「很快」。
1.8.2 一条命令判断「是不是重试」
如果你怀疑重复来自重试,先数请求,而不是猜:
# 按幂等键统计服务端收到的请求数(示例:从结构化日志里抽)
jq -r 'select(.event=="tool_call") | [.idempotency_key, .attempt] | @tsv' /tmp/tool.log \
| sort | uniq -c | sort -rn | head
期望:
3 K1 1
1 K1 2
1 K2 1
K1 有 3 次 attempt 1,说明客户端在并发重试(同一逻辑请求的多个物理尝试同时到达);K2 是新键,说明客户端在中途换了键。两种情况的修复不同:前者要靠服务端并发去重,后者要靠客户端契约。
1.8.3 把 trace context 传下去
trace id 不能只打印在入口日志里,要跟着请求走。Python 可以用 contextvars 做一个最小实现:
import contextvars, uuid
_trace_id = contextvars.ContextVar("trace_id", default=None)
def new_trace_id() -> str:
tid = uuid.uuid4().hex
_trace_id.set(tid)
return tid
def current_trace_id() -> str:
tid = _trace_id.get()
if tid is None:
tid = new_trace_id()
return tid
def headers_with_trace(extra: dict | None = None) -> dict:
h = {"X-Trace-Id": current_trace_id()}
if extra:
h.update(extra)
return h
服务端收到请求后,应该:
- 优先复用
X-Trace-Id(调用方已经生成); - 没有就生成一个;
- 在每个下游调用、每条日志、每个异步任务里带上;
- 在响应头里回传,方便客户端关联。
这不是完整的 OpenTelemetry,但足以把「客户端超时」和「服务端执行」串起来。第 10 章会讲完整的 trace/span/采样,以及为什么「有 trace id」不等于「有可观测性」。
- 只在入口打印,不传给下游:下游日志里没有 trace id,无法串起来。
- 每个重试生成新 trace id:同一逻辑请求的多次尝试应该共享 trace id,用
attempt区分 span。 - 把 trace id 当请求 ID:trace 是一次调用链的标识,不是幂等键;两者不能混用。
1.9 真实案例:Knight Capital 的重复订单
2012 年 8 月 1 日,美国做市商 Knight Capital 在纽交所开盘后约 45 分钟内发出大量错误订单,造成约 4.4 亿美元损失,最终被另一家公司收购。SEC 在 2013 年对其处以 1200 万美元罚款,理由是违反了市场访问规则(Rule 15c3-5)。
公开复盘的关键事实:
- 当天部署了一段新代码,但 8 台服务器中只有 7 台更新成功;剩下 1 台仍运行旧版本。
- 旧版本里有一个早已废弃的订单执行路径(内部代号 “Power Peg”),被一个复用的配置开关重新激活。
- 这台旧服务器开始向市场发送大量重复/错误订单;其他服务器正常,因此监控没有立刻发现。
- 系统没有「按账户/按策略的订单幂等闸门」,也没有在发现异常后自动停止该策略的能力。
它不是「网络超时导致重试」的案例,而是重复副作用的另一个典型形状:同一段逻辑被旧代码路径反复执行,而没有一道「这个意图是否已经执行过」的闸门。把它放在第 1 章,是因为它和发券事故共享同一个根因:
执行动作没有身份,重复就无法被识别。
| 维度 | Knight Capital | 发券事故 | 共同点 |
|---|---|---|---|
| 重复来源 | 旧代码路径 + 配置开关 | 超时重试 + 换键 | 同一意图被多次执行 |
| 识别手段 | 无订单幂等 ID | 无幂等键 | 缺少逻辑请求身份 |
| 止损手段 | 人工发现后关停 | 用户投诉后对账 | 没有自动闸门 |
| 观测 | 监控未覆盖该路径 | 只看 HTTP 200/500 | 观测粒度不足 |
教训不是「部署要小心」这种空话,而是三条可执行的设计:
- 每个有副作用的操作都要有幂等身份:订单 ID、发券键、支付 intent,至少在存储层有唯一约束。
- 部署要能发现「版本不一致」:Knight 的 7/8 是典型的灰度不一致;健康检查不能只看端口,要看版本/能力。
- 异常时要能自动停:按策略/账户的断路器,比人工发现快得多。
本节只引用公开事实:时间、损失规模、SEC 处罚与 Rule 15c3-5 的结论。具体的代码细节来自公开报道与监管文件,不复述原文。链接见 A.3 参考文献。
1.10 设计一张远程调用契约
把前面的讨论压缩成一张契约。每次新增一个远程调用,先回答下面 12 个问题;答不上来的,不要写代码。
| # | 问题 | 为什么必须回答 | 写在哪 |
|---|---|---|---|
| 1 | 这次调用的逻辑请求 ID 是什么? | 没有身份就无法去重 | 请求头/体 |
| 2 | 谁生成幂等键,粒度是什么? | 决定重复能否被识别 | 客户端 |
| 3 | 超时预算多少,怎么随 hop 递减? | 防止无效执行和级联超时 | 契约 |
| 4 | 超时后是 unknown 还是 failed? | 决定客户端下一步 | 错误码 |
| 5 | 哪些错误可重试,哪些不可? | 避免对不可恢复错误重试 | 错误码表 |
| 6 | 重试次数、退避、抖动、预算? | 防止重试风暴 | 客户端配置 |
| 7 | 服务端如何判定重复? | 去重表/唯一约束 | 存储设计 |
| 8 | 重复请求返回什么? | 原结果/处理中/冲突 | 响应契约 |
| 9 | 参数不同但键相同怎么办? | 客户端 bug,必须拒绝 | 409 |
| 10 | 服务端过载时返回什么? | 让上游降速,而不是堆积 | 429/503 |
| 11 | 结果怎么查询?查询一致性是什么? | unknown 的出口 | 查询接口 |
| 12 | 观测字段有哪些? | 事后对账/排障 | 日志/指标/trace |
1.10.1 一份可用的契约模板
{
"request": {
"idempotency_key": "string, client-generated, stable across retries",
"client_id": "string",
"seq": "int64, monotonically increasing per client",
"deadline_ms": "int, remaining budget at send time",
"tool": "string",
"args": "object, canonicalized before fingerprinting"
},
"response": {
"call_id": "string",
"status": "pending|running|succeeded|failed|unknown",
"attempt": "int",
"result": "object|null",
"error": {"code": "string", "retryable": "bool", "message": "string"},
"retry_after_ms": "int|null",
"query_url": "string"
},
"errors": {
"409": "same key, different fingerprint",
"429": "rate limited; honor Retry-After",
"503": "overloaded or dependency unavailable; may be retryable"
}
}
模板里有三个容易漏的字段:
deadline_ms是发送时的剩余预算,不是原始超时;接收方要立刻换算成本地 deadline。attempt是物理尝试序号,用于观测和去重,不参与业务语义。error.retryable是服务端对「这个错误能否重试」的判断;客户端不要自己猜。
1.10.2 指纹:同键不同参必须拒绝
幂等键只能保证「同一个键」被处理一次,不能保证「参数没变」。如果客户端用 K1 先发 amount=100,再发 amount=1000,服务端直接返回第一次的结果,用户会以为第二次也生效了。正确做法是:
- 对请求参数做规范化(排序 key、去掉空白、统一数字格式);
- 计算 SHA-256 指纹;
- 存
(idempotency_key, fingerprint); - 同键不同指纹 → 409,并返回第一次的指纹摘要,便于排障。
import hashlib, json
def fingerprint(tool: str, args: dict) -> str:
canonical = json.dumps(
{"tool": tool, "args": args},
sort_keys=True, separators=(",", ":"), ensure_ascii=False,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
规范化不是「可选优化」。{"a":1,"b":2} 和 {"b":2,"a":1} 在 JSON 语义上相同,但直接 json.dumps 会得到不同字符串;不排序就会把同一个请求误判为冲突。反过来,浮点数 1 和 1.0、Unicode 归一化、空值省略,也需要在契约里统一。
- 这个接口有没有副作用?有副作用就必须有幂等键。
- 超时后客户端会做什么?如果答案是「重试」,键是否复用?
- 服务端去重的唯一约束建在哪张表、哪个字段?
- 同键不同参返回什么?是否可能静默覆盖?
- 过载时返回什么?上游是否会因此更糟?
1.10.3 错误码分类:哪些能重试
错误码不是给人看的注释,是给客户端做决策的输入。至少要把下面几类分开:
| 状态码 | 语义 | 可重试 | 客户端动作 |
|---|---|---|---|
| 400 | 参数格式错误 | 否 | 修正参数,不要重试 |
| 401/403 | 未认证/无权限 | 否 | 刷新凭证/提权 |
| 404 | 资源不存在 | 否 | 检查 ID |
| 409 | 同键不同指纹 / 状态冲突 | 否 | 换键或查询原请求 |
| 422 | 语义校验失败 | 否 | 修正业务参数 |
| 429 | 限流 | 是(按 Retry-After) | 退避后重试 |
| 500 | 服务端内部错误 | 视语义 | 若可能已提交,按 unknown 处理 |
| 502/504 | 网关/上游超时 | 视语义 | 按 unknown 查询/同键重试 |
| 503 | 过载/依赖不可用 | 是(按 Retry-After) | 退避 + 降级 |
最容易出错的是 500 和 504。500 可能是「写库成功后序列化响应失败」,此时副作用已经发生;504 可能是网关等不到上游响应,上游可能已提交。这两类不能简单标成可重试,必须先走 unknown 收敛。
反过来,400/401/403/404/422 重试一万次也不会成功,只会浪费连接和配额。把「不可重试」的错误也重试,是重试风暴的另一个来源。
错误码分类的本质是回答「这个错误有没有可能通过再试一次改变」。如果答案是否定的,重试只是把确定性失败放大成资源浪费。
1.10.4 契约演进:新增字段、删除字段、改语义
远程调用的契约一旦发布,就不能假设「所有客户端同时升级」。你必须按「旧客户端 + 新服务端」「新客户端 + 旧服务端」两种组合设计。
| 变更 | 旧客户端 → 新服务端 | 新客户端 → 旧服务端 | 兼容策略 |
|---|---|---|---|
| 新增可选字段 | 兼容(旧客户端不传) | 兼容(旧服务端忽略) | 默认值 + 忽略未知字段 |
| 新增必填字段 | 破坏(旧客户端不传) | 破坏(旧服务端不认) | 不要做;改用可选 + 服务端默认 |
| 删除字段 | 兼容(旧客户端传了被忽略) | 破坏(新客户端依赖旧字段) | 先标记 deprecated,观察使用量再删 |
| 修改字段语义 | 破坏 | 破坏 | 新增字段,双写/双读一段时间 |
| 修改枚举值 | 可能破坏 | 可能破坏 | 保留未知值分支,不要 default: raise |
| 修改错误码 | 可能破坏 | 可能破坏 | 新增错误码,旧码保留映射 |
一个常见的坑是「新增必填字段」。服务端以为客户端会升级,但移动端可能有一年没更新的版本;这些请求全部 400,用户看到的是「服务不可用」。正确做法是:新增字段一律可选,服务端给默认值;确实需要强制时,用版本化接口(/v2/...),而不是在原接口上加必填。
1.10.5 契约的版本化
接口版本化有三种常见方式:
| 方式 | 例子 | 优点 | 缺点 |
|---|---|---|---|
| URL 版本 | /v1/tool-calls | 直观、易缓存 | URL 膨胀,网关路由复杂 |
| Header 版本 | Accept-Version: 2 | URL 稳定 | 不易调试、缓存键要带 header |
| 字段版本 | schema_version | 细粒度 | 每个字段都要处理兼容 |
本书的项目一只用一个版本,不加版本号;这是教学简化,不是生产建议。生产环境至少要有一个「契约测试」:用旧版客户端请求新版服务端,断言不会 4xx/5xx;再用新版客户端请求旧版服务端,断言降级行为符合预期。
- 新字段是否可选?默认值是什么?
- 旧客户端不传新字段时,服务端行为是否与之前一致?
- 新客户端遇到旧服务端的未知字段/错误码,是否会崩溃?
- 删除字段前,是否统计过还有多少流量在使用?
- 语义变更是否通过新增字段 + 双写完成?
- 是否有契约测试覆盖「旧客户端 × 新服务端」?
1.11 本书的边界与不重复清单
本书假设你已经具备以下基础;没有的话,先补 Linux 书对应篇章,再回来。
| 前置知识 | 在哪 | 本书为什么不在重复 |
|---|---|---|
| TCP/UDP、可靠传输、拥塞控制 | Linux 4.4–4.6 | 本书只讲应用层 deadline 与超时语义 |
| TCP 建立/关闭、TIME_WAIT | Linux 4.5 | 连接池/重试只引用,不重画状态机 |
| socket、epoll、连接队列 | Linux 4.8–4.11 | 项目用 Python 标准库,不实现事件循环 |
| HTTP、DNS、TLS | Linux 4.7 | 只讲 HTTP 语义对重试的影响 |
| 容器、K8s 基础 | Linux 第 5 篇 | 项目不依赖容器编排 |
| 操作系统进程/线程/锁 | Linux 第 2、3 篇 | 只借用概念,不重讲实现 |
反过来,本书覆盖而母本不覆盖的内容,会在每章末尾的「母本对应」里标成「本书自编 + 论文/规范」。这不是为了显得原创,而是为了诚实:谢希仁、张彦飞和《Python 网络编程》不讲 Raft、MVCC、幂等表;这些必须靠论文、规范和自己写代码。
1.11.1 本书的章节依赖
1 请求/失败 ──▶ 2 RPC ──▶ 12 项目一
│ │
▼ ▼
3 复制 ──▶ 4 一致性 ──▶ 5 共识 ──▶ 13 项目二
│ │
▼ ▼
6 事务 ◀──── 7 队列/流 ────▶ 8 缓存/CDN
│
▼
9 锁/租约 ──▶ 10 观测 ──▶ 11 容量/演练
这张图不是「必须先读左再读右」的硬约束,而是概念依赖:不知道 unknown,就很难理解为什么 Raft 的客户端要去重;不知道 quorum,就很难理解为什么缓存一致性不能只靠 TTL。
1.11.2 三条阅读路线
不同的读者可以从这一章走向不同的方向:
| 路线 | 下一章 | 适合谁 | 先跳过什么 |
|---|---|---|---|
| 工程实现 | 2 RPC → 12 项目一 | 后端/平台工程师 | 第 3–5 章的协议证明可后补 |
| 一致性 | 3 复制 → 4 一致性 → 5 共识 | 存储/基础设施工程师 | 第 6–8 章可后读 |
| 运行治理 | 10 观测 → 11 容量 → 9 锁 | SRE/on-call | 第 6–8 章按需 |
无论走哪条,第 1 章的三个概念都要带走:逻辑请求与物理尝试的区分、unknown 状态、超时不是失败。这三件事在后面每一章都会以不同形式出现。
1.12 本章陷阱清单
- 把超时当失败:超时只说明客户端不再等待;服务端可能已执行。正确做法是进入 unknown,查询或同键重试。
- 重试时换新键:每次重试重新生成 UUID,服务端无法去重;这是重复副作用最常见的直接原因。
- 用服务端自增 ID 当幂等键:第二次请求会拿到新 ID;键必须由客户端在首次发送前生成。
- 只对「写接口」做幂等:读接口也可能触发副作用(如「读时刷新缓存/计数」);只要有副作用就要有身份。
- 把
Retry-After当建议:它是服务端给出的最小等待时间;忽略它会加重过载。 - 重试所有 5xx:500 可能是「已提交但序列化失败」;要按错误码语义分类,不能按状态码段位重试。
- 不做抖动:所有客户端在同一时刻重试,形成同步脉冲;指数退避必须加 jitter。
- 没有重试预算:单客户端 3 次,乘以百万客户端就是三百万次额外请求;预算按比例限制。
- 只看平均延迟:p50 正常不代表没问题;要看 p99/p999 和慢请求 trace。
- 把
time_connect与time_starttransfer相加:curl 的计时是包含关系,不是并列;相加会重复计算。 - 用
time.time()算超时:墙上时钟会被 NTP 调整;超时用time.monotonic()。 - 以为「内网调用不用鉴权/幂等」:内网也会横向移动、也会重试;信任边界不因网络位置而消失。
1.13 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 画出一次调用的路径 | 至少 8 个 hop,标出每 hop 的失败模式 | 1.1 |
| 解释逻辑请求与物理尝试 | 能说出为什么服务端必须区分两者 | 1.1、1.3 |
| 画出请求状态机 | 包含 unknown,且能说出离开 unknown 的两种动作 | 1.3 |
| 解释超时的三种真相 | 能分别说出服务端状态与安全动作 | 1.6 |
| 计算重试放大 | 给定两层各 3 次重试,算出 16 | 1.7 |
| 设计幂等键 | 能定义键的粒度、生成方、复用规则 | 1.10 |
| 计算指纹 | 知道要规范化、排序、SHA-256 | 1.10 |
| 用 curl -w 切延迟 | 能区分 DNS/连接/TTFB/总耗时 | 1.8 |
| 用 trace 找慢 span | 能从日志定位最慢下游 | 1.8 |
| 说出三个真实教训 | 能复述 Knight 的 7/8 部署与重复订单 | 1.9 |
| 写契约的 12 个问题 | 至少能答出 10 个 | 1.10 |
Q1 客户端 1 s 超时,服务端 1.2 s 完成并提交。客户端收到 timeout 后直接用新键重试,会发生什么?应该怎么做?
Q2 为什么 curl 的 time_connect 和 time_starttransfer 不能相加?
Q3 一个接口的 p50 是 30 ms,p99 是 3 s。产品说「平均很快」,你怎么反驳?
Q4 幂等键用 user_id + timestamp 生成,有什么问题?
Q5 服务端收到同键但不同参数的请求,应该返回 200 + 第一次结果,还是 409?为什么?
Q6 重试预算设为「原始请求的 10%」,当原始 QPS 是 1000 时,最多允许多少次重试?如果重试仍然失败怎么办?
Q7 为什么不能用两台机器的 time.time() 判断哪个请求先发生?
Q8 一次调用经过 10 个服务,每个服务 1% 概率超过 1 s。端到端至少一个服务超 1 s 的概率是多少?这说明了什么?
答案
A1 服务端会创建第二条记录并再次执行,产生重复副作用。正确做法是用原键重试,或先查询原 call_id 的状态;只有确认原请求 failed 且副作用未发生,才考虑新键。
A2 time_connect 包含 DNS 解析,time_starttransfer 包含连接、TLS、服务端处理到首字节;它们是累计值,不是互斥的阶段。要算「服务端处理」应该用 time_starttransfer - time_appconnect(近似),而不是把几项相加。
A3 平均值会被大量快请求拉低;p99 是 3 s 意味着 1% 的用户体验很差。按每天 1 亿次调用算,每天有 100 万次超过 3 s;如果这些请求集中在高峰,会拖垮重试和队列。
A4 同一用户在同一秒内重试会生成相同的键(碰撞),不同逻辑请求可能被误判为重复;同一用户跨秒重试又会生成不同的键(无法去重)。键应由客户端为每个逻辑请求生成一次,并在所有重试中复用,例如 UUID/ULID 或 client_id:seq。
A5 返回 409。200 + 第一次结果会让客户端误以为第二次参数也生效了,可能造成「用户以为改了金额,实际没改」的静默错误。409 强制客户端修正键或参数。
A6 原始 1000 QPS 时,重试上限约 100 QPS。预算耗尽后不应继续重试,而应返回 503 + Retry-After,让上游/用户降速;同时触发告警,检查下游是否恢复。
A7 两台机器的墙上时钟可能相差几毫秒到几秒,NTP 还会调整甚至倒退;时间戳不能用来确定跨机器的先后。用逻辑时钟、版本号或共识协议定序。
A8 1 - 0.99^10 ≈ 9.6%。即使每个服务都很可靠,串行链路也会放大长尾;这要求减少串行 hop、设置合理 deadline、对慢请求做对冲/降级。
母本对应
| 母本 | 章节 | 本书落点 | 程度 | 说明 |
|---|---|---|---|---|
| 谢希仁《计算机网络》第 8 版 | 第 5 章 运输层、第 6 章 应用层 | 1.1、1.8 | 背景 | 只借用「请求经过网络分层」的视角;TCP 细节不重写 |
| 张彦飞《深入理解 Linux 网络》 | 第 4–7 章(TCP、socket、连接) | 1.1、1.2、1.8 | 背景 | 连接池/建连失败只引用,不展开内核路径 |
| 《Python 网络编程》 | socket/HTTP 客户端、并发服务器 | 1.8、1.10 | 精选 | 借用工程风格;代码为本书重写 |
| 《Linux 系统与内核》第 4 篇 | 4.1–4.11(网络与协议栈) | 1.1、1.8、1.11 | 交叉引用 | 明确不重复 TCP/IP、socket、epoll、HTTP/DNS/TLS |
| 《Linux 系统与内核》第 5 篇 | 5.1–5.6(容器与 K8s) | 1.11 | 交叉引用 | 本书不写容器编排 |
| 本书自编 + 公开复盘 | Knight Capital 2012、SEC 2013 | 1.9 | 完整 | 只引用公开事实,不复制原文 |
下一站:知道了「一次调用可能未知」,下一步要把它变成可执行的协议。进入 2 RPC:超时、重试、幂等、背压,看 deadline 怎么传、重试怎么限、幂等怎么做、背压怎么建。
2 RPC:超时、重试、幂等、背压
一次 RPC 在 stub、IDL 和连接池之间到底发生了什么?为什么 deadline 必须逐跳传播,而不能每跳重置?重试该怎么退避、怎么设预算、什么时候该对冲?幂等键和指纹如何挡住重复副作用?背压和限流分别在保护谁?
能写出一个带绝对 deadline 的调用链并解释 grpc-timeout 的换算;能实现 full jitter 退避、重试预算和令牌桶;能设计幂等键、指纹与去重表;能用 Little's law 算出并发上限和队列长度;能在一张契约表上答完 RPC 的十五个问题。
上一章留下三个词:unknown、逻辑请求、物理尝试。它们描述的是「一次调用可能失败得不明不白」。这一章要把这三个词变成可执行的协议:调用方在什么时刻放弃、放弃后做什么、服务端如何识别重复、系统在过载时如何拒绝。
很多团队以为用了 gRPC 或 Thrift 就自动获得了这些能力。事实相反:RPC 框架只负责「把参数变成字节、把字节变成响应」,它不负责「这次调用到底算不算成功」。超时、重试、幂等、背压这四件事,框架最多提供钩子,语义必须由使用方定义。定义错了,框架会把错误放大得更快——它让重试变得太容易,让连接复用得太深,让一次用户点击在 200 ms 内变成 16 个请求。
这一章的顺序按「一次调用的生命周期」排:先看 stub 做了什么(2.1),再看到达服务端之前的时间预算怎么传(2.2),然后看失败后怎么重试(2.3)以及重试凭什么安全(2.4)。2.5 把超时和重试接成一条正反馈时间线;2.6、2.7 讨论系统被压垮时,队列和限流如何把压力挡在门外。2.8 给出客户端与服务端各自的观测面,2.9 用 S3 2017-02-28 说明重试如何改变恢复曲线,2.10 用一笔支付说明「两个键」为什么会留下两条扣款记录。最后三节是契约检查表、陷阱清单和出口检查。
2.1 RPC 到底做了什么:stub、IDL、序列化、连接复用
先看一行代码:
resp = payment_stub.Charge(ChargeRequest(order_id="o-8812", amount_cents=1999),
timeout=1.0, metadata=(("idempotency-key", "pay-o-8812"),))
这行代码在服务端对应一个普通函数 def Charge(req, ctx)。中间隔着的东西,比大多数调用方想象的要多。把它们列清楚,才能知道哪些失败该由谁负责。
| 阶段 | 谁在做 | 具体动作 | 失败会长什么样 |
|---|---|---|---|
| 1 | stub | 把语言对象转成 IDL 消息 | 类型不匹配、必填缺失、超长 |
| 2 | 序列化器 | 编码成字节,必要时压缩 | CPU 尖峰、超出 max message size |
| 3 | stub/channel | 附加 metadata:deadline、trace、auth、幂等键 | 键丢失、header 被代理剥掉 |
| 4 | 名字解析/负载均衡 | 选一个后端地址 | 解析失败、选到不健康实例 |
| 5 | 连接池 | 取一条已有连接或新建 | 池耗尽、握手超时、GOAWAY |
| 6 | 传输层 | 分帧、写 socket、等响应 | 半包、连接重置、流控阻塞 |
| 7 | 服务端 | 解帧、反序列化、分发到 handler | 解码失败、线程池满、找不到方法 |
| 8 | handler | 执行业务、访问数据库/下游 | 业务异常、锁等待、下游超时 |
| 9 | 回程 | 序列化响应、写回、客户端解析 | 响应丢失、超时、版本不兼容 |
这张表和第 1 章的十二个 hop 是同一件事的两种切法:第 1 章按「网络位置」切,这张表按「软件层次」切。切法不同,用途也不同——前者用来定位延迟,后者用来定位责任。
2.1.1 stub 不是魔法,它只是把五件事藏起来了
生成代码里的 PaymentStub 通常做五件事,且只做五件事:
- 类型检查:把
ChargeRequest编译成一个有字段、有默认值的类; - 序列化:调用 IDL 运行时把对象编码成字节;
- 元数据拼装:把 deadline、trace context、认证 token、幂等键塞进 header;
- 连接管理:从 channel 里取连接、做负载均衡、处理重连;
- 错误映射:把 wire 上的状态码映射成语言里的异常类型。
它明确不做的事同样重要:
- 不保证「最多执行一次」;它连「执行了几次」都不知道;
- 不保证顺序;多路复用下,响应到达顺序与请求发送顺序无关;
- 不保证取消能传到服务端;
cancel()通常只是本地停止等待; - 不替你决定业务幂等键;metadata 里的键是调用方自己放的;
- 不替你选超时预算;
timeout=1.0是你写的,不是框架推荐的。
stub 是 IDL 编译器生成的客户端代理,负责「方法签名 ↔ 消息」的双向映射。channel 是 stub 背后的连接抽象,负责名字解析、负载均衡、连接复用与重连。逻辑调用是一次 stub 方法调用所表达的业务意图;一次逻辑调用可以产生多次物理尝试(2.3),也可能在 channel 内部被透明重试。
关键区别:stub 的生命周期通常与进程相同,channel 的生命周期与「目标服务」相同,逻辑调用的生命周期只到 deadline 为止。把三者混为一谈,就会出现「stub 重试了,业务又重试一次」的双层放大。
2.1.2 IDL 定义的是成功路径,不是失败路径
一个典型的 proto 定义:
syntax = "proto3";
message ChargeRequest {
string order_id = 1;
int64 amount_cents = 2;
string currency = 3;
reserved 4, 5; // 曾经是 card_number / cvv,永远不要复用
reserved "card_number";
}
message ChargeResponse {
string charge_id = 1;
string status = 2; // succeeded | failed | unknown
int64 amount_cents = 3;
}
service Payment {
rpc Charge(ChargeRequest) returns (ChargeResponse);
}
这段 IDL 能表达「参数长什么样」,不能表达:
- 这个方法是幂等的还是非幂等的;
- 超时后调用方应该重试还是查询;
- 哪些错误码可以重试;
- 服务端过载时返回什么;
- 同一个幂等键配不同参数该怎么处理。
这些语义要么写在注释里(然后被忽略),要么写进契约文档和错误码表(2.11),要么由中间件强制(例如服务端校验 idempotency-key 必须存在)。IDL 是类型契约,不是行为契约;把行为契约寄托在 .proto 注释上,是团队协作里最常见的隐形债务。
字段编号还有一条硬规则:删除字段时用 reserved 占住编号和名字。否则下一个同事把编号 4 复用成 user_id,旧客户端发来的 card_number 会被解码成 user_id,产生极难排查的串字段。这条规则与 RPC 的失败语义无关,却经常在「重试 + 旧客户端」的场景里引爆。
2.1.3 序列化的成本:体积、CPU、兼容性
同一份 ChargeRequest(order_id、金额、币种、两个嵌套对象)在不同编码下的量级:
| 编码 | 体积(示例) | 编码 CPU | 可读性 | 兼容性策略 |
|---|---|---|---|---|
| JSON | 约 210 B | 中 | 好 | 字段名、忽略未知字段 |
| Protobuf | 约 75 B | 低 | 差 | 字段编号 + 默认值 |
| MessagePack | 约 95 B | 低 | 差 | 位置/键名约定 |
| 压缩后的 JSON | 约 120 B | 高(小包不划算) | 好 | 同 JSON |
体积差 2–3 倍,在 1 KB 以下的小请求上通常不是瓶颈;真正贵的是大对象和深嵌套。一个 2 MB 的响应在客户端反序列化时可能占用 10 MB 内存和几十毫秒 CPU,而这发生在「 deadline 只剩 50 ms」的时候。压缩也有阈值:小于 1 KB 的包压缩后可能更大,还多一次 CPU。
兼容性比体积更常出事。三条规则足够挡住 90% 的事故:
- 新字段一律可选,服务端给默认值;不要新增
required; - 枚举保留一个
UNSPECIFIED = 0,客户端遇到未知值不要抛异常; - 删除字段先标
deprecated,观察一个发布周期,再用reserved删除。
2.1.4 连接复用:省下的是握手,付出的是耦合
短连接的成本可以算出来。假设同城 RTT 为 0.5 ms、跨地域 RTT 为 40 ms:
| 建连方式 | 额外往返 | 同城成本 | 跨地域成本 |
|---|---|---|---|
| TCP 三次握手 | 1 RTT | 0.5 ms | 40 ms |
| TCP + TLS 1.3 | 2 RTT | 1 ms | 80 ms |
| TCP + TLS 1.2 | 3 RTT | 1.5 ms | 120 ms |
| 连接池命中 | 0 | 约 0 | 约 0 |
TCP 握手与 TLS 握手的细节见 Linux 书 4.5,本书不重画状态机。这里只需要一个结论:在跨地域调用上,短连接会把每次调用的固定成本抬高两个数量级。1000 QPS 的服务如果每次新建连接,就是每秒 1000 次握手;连接池把这块成本摊薄到接近零。
但复用不是免费的,它把三个问题引入了调用路径:
- 队头阻塞:HTTP/1.1 一条连接同时只能有一个在途请求;HTTP/2 用 stream 解决了应用层队头,但 TCP 层的丢包仍会阻塞同连接的所有 stream。QUIC 把 stream 独立化,代价是 UDP 上的拥塞控制与中间设备兼容性。
- 流控与并发上限:HTTP/2 有
SETTINGS_MAX_CONCURRENT_STREAMS,常见默认值是 100。超过上限的请求会在客户端排队,表现为「服务端 CPU 不高,但客户端 p99 很高」。这个队列不在你的业务指标里,却真实存在。 - 生命周期耦合:服务端重启、缩容、证书轮换都会发
GOAWAY。GOAWAY里带last_stream_id:编号大于它的 stream 没被处理,可以安全重试;小于等于它的可能已经执行,不能盲目重试。很多「透明重试」的实现只对未处理的 stream 生效,原因就在这里。
连接池大小本身也是一道并发限制:池 50 条、每条 1 个在途请求,等价于并发上限 50;下游延迟从 20 ms 涨到 200 ms,池子的吞吐上限就从 2500 QPS 掉到 250 QPS。
只看 pool_open 没意义。要看 pool_wait_seconds(取连接等了多久)、pool_in_use / pool_max(饱和度)、pool_timeouts_total(等不到连接)。如果 pool_wait 的 p99 超过 5 ms,说明池子已经成为排队点;此时调大池子可能只是把队列从客户端挪到服务端。
2.1.5 分帧:长度前缀与它的两个坑
RPC 在字节流上要解决「一条消息到哪里结束」。最常见的是长度前缀帧:
import struct
MAX_FRAME = 4 * 1024 * 1024
def encode_frame(payload: bytes) -> bytes:
if len(payload) > MAX_FRAME: # 先校验长度,再分配内存
raise ValueError(f"frame too large: {len(payload)}")
return struct.pack(">I", len(payload)) + payload
# decode 端按 4 字节长度前缀循环切分;不足一帧时保留残余,等下一次 recv
两个坑:
- 先校验再分配。如果实现写成
buf[4:4+n]再判断长度,一个伪造的 4 GB 长度前缀就能让进程 OOM。校验必须发生在分配之前。 - 半包与粘包是常态。一次
recv可能只拿到半个长度前缀,也可能拿到两条完整消息加半条;解码端必须能处理任意切分。这不是 TCP 的缺陷,而是字节流的定义;细节见 Linux 书 4.4。
帧格式还决定了「错误如何表达」。gRPC 把状态码放在 HTTP/2 的 trailers 里;如果连接在 trailers 之前断开,客户端只知道「流断了」,不知道服务端是否执行。这正是第 1 章 unknown 的物理来源。
2.2 超时不是重试的触发器:deadline 传播
下面这条时间线在很多系统里都出现过:
t(ms) actor event 0 client send, budget=1000 30 gateway recv, remaining=970 30 gateway forward, timeout=1000 // BUG: resets the budget 80 svc-A recv, assumes 1000ms 120 svc-A query 1000 client timeout -> unknown 3200 svc-A commit done, side effect already happened
客户端在 1 s 时已经放弃,服务端在 3.2 s 才提交。客户端如果重试,就会产生第二次副作用;如果客户端不重试,用户看到失败但钱已经扣了。两种结果都不可接受。根因不是「超时设得太短」,而是网关把剩余预算重置成了一个新的 1 s。
2.2.1 绝对 deadline 与剩余预算
timeout 是「我愿意等多久」,deadline 是「这个请求最晚必须在什么时刻完成」。单跳里两者等价,多跳里差别巨大:
| 概念 | 表示 | 谁维护 | 跨进程怎么传 |
|---|---|---|---|
| 单跳 timeout | 时长(如 1 s) | 每个调用方各自决定 | 不传 |
| 绝对 deadline | 单调时钟的终点 | 整条调用链共享 | 不能直接传单调时钟 |
| 剩余预算 | deadline - now | 每一跳到达时计算 | 传相对值,接收方立刻换算 |
| 每跳余量 | 留给回程与排队的 buffer | 发送方扣减 | 不传,体现在剩余预算里 |
单调时钟只在同一台机器内有意义,所以跨进程不能传「绝对时刻」。工程上的标准做法是传剩余预算,接收方在收到的那一刻用本地单调时钟换算成自己的绝对 deadline:
接收方 deadline = 本地 monotonic_now + 收到的剩余预算
这个换算必须发生在「收到请求的第一时间」,不能等到业务代码里再算。中间经历的反序列化、排队、鉴权都要从预算里扣掉。
gRPC 的 grpc-timeout 就是这种相对预算,格式是「正整数 + 单位」,单位取 H/M/S/m/u/n,数值最多 8 位。发送方取「当前剩余预算」,接收方解码后立刻换算。它不传绝对时间戳,因此不受两端墙上时钟偏差影响。
2.2.2 每跳该留多少余量
预算不能平均分,也不能每跳都留固定 100 ms。余量至少覆盖三件事:
- 回程网络时间:响应从服务端回到调用方的时间,通常约 1 个 RTT;
- 调用方的处理时间:反序列化、写日志、唤醒等待者;
- 抖动:p99 与中位数的差,而不是中位数本身。
一个可用的公式:
其中 。同城 RTT 0.5 ms 时 margin 约 6 ms;跨地域 RTT 40 ms 时 margin 约 85 ms。如果算出来小于 0,正确动作是立刻失败,而不是给一个最小值继续调用——预算已经不够完成一次有意义的往返,继续调用只会制造一个注定被放弃的副作用。
设逻辑请求的端到端 deadline 为 ,路径上有 跳,第 跳的排队、处理、传输时间分别为 ,第 跳留给下游的预算为 。若每一跳都满足
且 ,则最坏情况下整条链在 之前结束;若某一跳把 重置为固定值 ,则端到端最坏时间可能达到 ,与 无关。
对第一段,把不等式从 累加到 ,得到 。每跳实际耗时不超过 ,所以总耗时不超过 。
对第二段,构造每跳都耗时接近 的执行:第 1 跳在 时把请求交给第 2 跳,第 2 跳重新获得 的预算,于是总时间约为 。这与 无关,因此重置预算会破坏端到端上限。
∎这个定理看起来是废话,但它的推论经常被忽略:deadline 不是「每次尝试的超时」,而是「所有尝试共享的总预算」。第一次尝试花了 800 ms,第二次尝试就只剩 200 ms;如果重试策略还按「每次 1 s」设置,第二次尝试必然在服务端继续执行,而客户端早已放弃。
2.2.3 一个可用的 Deadline 实现
import math
import time
_UNIT_MS = {"H": 3_600_000, "M": 60_000, "S": 1_000,
"m": 1, "u": 0.001, "n": 0.000001}
class Deadline:
"""绝对截止时间。内部只用单调时钟,不碰墙上时钟。"""
__slots__ = ("_end",)
def __init__(self, end_mono: float):
self._end = end_mono
@classmethod
def budget(cls, ms: int) -> "Deadline":
return cls(time.monotonic() + ms / 1000.0)
def remaining_ms(self) -> int:
return max(0, math.ceil((self._end - time.monotonic()) * 1000))
def child(self, margin_ms: int) -> "Deadline":
# margin_ms 应包含排队、解码与回程;子 deadline 绝不能晚于父 deadline
return Deadline(self._end - margin_ms / 1000.0)
def expired(self) -> bool:
return self.remaining_ms() <= 0
def encode_grpc_timeout(ms: int) -> str:
ms = max(1, ms)
for unit in ("m", "S", "M", "H"): # 选能放进 8 位数字的最细单位
value = math.ceil(ms / _UNIT_MS[unit])
if value <= 99_999_999:
return f"{value}{unit}"
return "99999999H"
def decode_grpc_timeout(value: str) -> Deadline:
if len(value) < 2 or value[-1] not in _UNIT_MS:
raise ValueError(f"bad grpc-timeout: {value!r}")
return Deadline.budget(math.ceil(int(value[:-1]) * _UNIT_MS[value[-1]]))
三个细节值得单独说:
- 用
math.ceil而不是int:剩余 0.4 ms 时int会得到 0,编码成0m是非法的;ceil得到 1 ms,虽然仍然会立刻超时,但至少是一个合法值。 child()只减不加。如果margin_ms大于剩余预算,子 deadline 落在过去,expired()立刻为真,调用方快速失败——这是期望行为。remaining_ms()返回 0 表示「已过期」,不要用它区分「刚好 0」和「负数」;两者都该失败。
2.2.4 超时之后:取消是提示,不是回滚
客户端超时后通常会发 RST_STREAM 或 Cancel。服务端收到取消,应该怎么做?
| 服务端状态 | 收到取消后的正确动作 | 错误动作 |
|---|---|---|
| 还没开始执行 | 丢弃请求,不写任何记录 | 继续执行 |
| 正在执行,未提交 | 尽快停止,回滚事务 | 忽略取消,跑完再说 |
| 已提交,未返回 | 记录结果,供查询;返回 CANCELLED 也无妨 | 尝试「撤销」已提交的写 |
| 正在调用下游 | 把取消传给下游;下游可能已经执行 | 假设下游一定没执行 |
关键点:取消只能减少无效工作,不能撤销已经发生的副作用。服务端在提交前检查一次 deadline 是好习惯,但检查与提交之间仍有窗口;数据库提交后进程崩溃,取消也来不及。所以「超时后是否重复」最终仍要靠幂等键(2.4)解决,而不是靠取消。
如果服务端发现自己的 deadline 只剩 10 ms,它调下游至少需要 20 ms,那么这次下游调用注定在客户端放弃之后才完成。正确做法是直接返回 DEADLINE_EXCEEDED,并把「未执行」写清楚;继续调用只会产生一个无人认领的副作用。这个判断要放在调用下游之前,而不是之后。
2.3 重试策略:指数退避、抖动、预算、对冲
重试的前提不是「失败了」,而是「失败了,且再试一次有可能成功,且再试一次的副作用可控」。这三个条件缺一不可。把不满足条件的错误也重试,是重试风暴的另一个源头。
2.3.1 哪些错误可以重试
| 错误 | 语义 | 可重试 | 条件 |
|---|---|---|---|
UNAVAILABLE | 连接失败/实例不可达 | 是 | 有剩余预算;最好换实例 |
DEADLINE_EXCEEDED | 本地或上游超时 | 视情况 | 只在还有预算且请求幂等时 |
RESOURCE_EXHAUSTED | 限流/配额耗尽 | 是 | 遵守 Retry-After |
ABORTED | 并发冲突/事务中止 | 是 | 业务可重放,且退避后重试 |
INTERNAL | 服务端内部错误 | 否 | 可能已提交,按 unknown 处理 |
INVALID_ARGUMENT | 参数错误 | 否 | 重试一万次也一样 |
PERMISSION_DENIED | 无权限 | 否 | 刷新凭证或提权 |
NOT_FOUND | 资源不存在 | 否 | 检查 ID |
ALREADY_EXISTS | 唯一约束冲突 | 否 | 查已有记录,不要换键 |
CANCELLED | 调用方取消 | 否 | 除非是新逻辑请求 |
HTTP 状态码的映射在 1.10.3 已经给过,这里只强调一条:DEADLINE_EXCEEDED 是最容易被误判为可重试的错误。它的语义是「我没等到结果」,不是「服务端没执行」。只有在请求带幂等键、且剩余预算足够时才重试;否则应该进入 unknown 收敛。
2.3.2 指数退避与三种抖动
设第 次重试( 从 0 开始)的基准等待为
无抖动时,所有失败客户端会在同一时刻重试,形成同步脉冲。三种常见抖动:
| 策略 | 等待时间 | 期望等待 | 特点 |
|---|---|---|---|
| 无抖动 | 同步脉冲,最差 | ||
| Full jitter | 打散最好,但可能立即重试 | ||
| Equal jitter | 保留最小等待,打散稍弱 | ||
| Decorrelated | 取决于历史 | 适应性强,难分析 |
Full jitter 的公式就是一行:
import random
def full_jitter(attempt: int, base: float = 0.05, cap: float = 2.0) -> float:
"""attempt 从 0 开始;返回值单位是秒。"""
ceiling = min(cap, base * (2 ** attempt))
return random.uniform(0, ceiling)
取 base=50ms、cap=2s,前七次重试的等待范围:
| 重试序号 | 基准 | full jitter 范围 | 期望等待 |
|---|---|---|---|
| 1 | 50 ms | 0–50 ms | 25 ms |
| 2 | 100 ms | 0–100 ms | 50 ms |
| 3 | 200 ms | 0–200 ms | 100 ms |
| 4 | 400 ms | 0–400 ms | 200 ms |
| 5 | 800 ms | 0–800 ms | 400 ms |
| 6 | 1600 ms | 0–1600 ms | 800 ms |
Full jitter 有一个副作用:它有概率抽到接近 0 的等待,等于「立即重试」。在服务端已经过载时,这会增加瞬时压力。缓解办法是给一个最小等待(例如 max(10ms, ...)),或者用 equal jitter。没有一种抖动对所有场景最优;关键是不要用无抖动。
2.3.3 重试预算:限制放大倍数,而不是次数
「最多重试 3 次」限制的是单个客户端,不是系统总量。1 万个客户端各重试 3 次,就是 3 万个额外请求。重试预算(retry budget)限制的是重试请求占原始请求的比例:
class RetryBudget:
"""每个原始请求存入 ratio 个 token,每次重试消耗 1 个。
稳态下重试比例不超过 ratio;burst 允许冷启动时先借一些 token。
"""
def __init__(self, ratio: float = 0.1, burst: int = 20):
self.ratio = ratio
self.burst = float(burst)
self.tokens = float(burst)
def on_request(self) -> None:
self.tokens = min(self.burst, self.tokens + self.ratio)
def allow_retry(self) -> bool:
if self.tokens >= 1.0:
self.tokens -= 1.0
return True
return False
参数怎么定:
| 参数 | 建议起点 | 依据 | 调错的后果 |
|---|---|---|---|
ratio | 5%–10% | 正常重试率通常 < 1% | 太高:放大;太低:偶发失败也失败 |
burst | 10–50 | 覆盖冷启动/短抖动 | 太小:开局就耗尽 |
max_attempts | 2–3 | 与 deadline 一起算 | 太大:尾部尝试注定超时 |
base | 20–100 ms | 约 1 个 RTT | 太小:同步脉冲 |
cap | 1–2 s | 小于 deadline | 太大:退避吃掉全部预算 |
Envoy 的 retry_budget 用 budget_percent(默认 20%)和 min_retry_concurrency(默认 3)实现同一思路:预算按「活跃请求数 × 百分比」计算,低于最小值时至少允许 3 个并发重试。这说明预算不是「每秒固定次数」,而是与当前流量成比例。
重试预算要和熔断配合。预算只限制「重试多少」,不阻止「原始请求继续打进来」;如果下游已经不可用,正确动作是熔断一段时间,让原始请求也快速失败,而不是让预算把重试压到 0 后继续用原始流量压垮它。
2.3.4 对冲请求:用额外负载换尾延迟
对冲(hedged request)不是「失败后重试」,而是「还没失败就再发一份」:请求发出后等 ,如果还没返回,就向另一个实例再发一份,谁先返回用谁,另一份取消。
| 维度 | 普通重试 | 对冲 |
|---|---|---|
| 触发条件 | 失败/超时 | 超过延迟阈值,尚未失败 |
| 额外负载 | 失败请求数 × 重试次数 | 约 比例的正常请求翻倍 |
| 改善的指标 | 错误率 | p99/p999 尾延迟 |
| 适用请求 | 幂等写/读 | 只读或幂等写;非幂等写禁用 |
| 主要风险 | 重试风暴 | 阈值估低导致大面积双发 |
对冲的代价可以算:如果阈值取 p95,约 5% 的请求会被对冲,总请求量增加 5%;如果阈值取 p50,一半请求翻倍,成本立刻不可接受。更隐蔽的是,p95 会随负载漂移:下游变慢时,p95 阈值也应该变,否则对冲比例会从 5% 涨到 50%。
对冲只适合没有副作用或副作用可去重的请求。对支付、下单这类写操作,第二份请求即使被取消,也可能在服务端已经执行;取消不是回滚。要用对冲,必须先有 2.4 的幂等键。
2.3.5 把重试、deadline、预算接在一起
import time
class RetryPolicy:
def __init__(self, max_attempts=3, base=0.05, cap=2.0, budget=None):
self.max_attempts = max_attempts
self.base = base
self.cap = cap
self.budget = budget or RetryBudget()
def execute(self, call, deadline):
"""call(attempt, deadline) 抛出的异常需带 retryable 属性。"""
self.budget.on_request() # 每个逻辑请求只充值一次
for attempt in range(self.max_attempts):
try:
return call(attempt, deadline)
except RpcError as exc:
if not exc.retryable or attempt + 1 == self.max_attempts:
raise
if not self.budget.allow_retry():
raise RetryBudgetExhausted() from exc
delay = full_jitter(attempt, self.base, self.cap)
# 重试不能延长 deadline:睡完再加一次最小调用时间仍不够,就放弃
if deadline.remaining_ms() <= delay * 1000 + 20:
raise
time.sleep(delay)
raise AssertionError("unreachable")
这段代码有三个约束,缺一不可:
- 重试共享同一个 deadline,
call每次拿到的是同一个对象,不能新建; - 睡之前检查剩余预算,否则会在 deadline 之后才发出注定失败的请求;
- 预算耗尽直接抛,不要「再试最后一次」。
RetryBudgetExhausted 应该映射成 503 + Retry-After,而不是 500。500 会让上游以为「服务端有 bug,可能值得重试」,503 才表达「现在别来」。
客户端 SDK 对每个请求最多重试 3 次(共 4 次尝试),API 网关对上游失败也重试 3 次。两层都独立重试时,最坏情况下游收到 个请求。如果下游每个请求发一张券,就产生 16 张券。
修复不是「把 3 改成 1」,而是:
- 只保留一层重试(通常放在最接近用户、最了解业务语义的一层);
- 另一层只做「快速失败 + 返回 Retry-After」;
- 两层都保留时,内层预算必须显著小于外层,且外层要知道内层已经重试过(通过
attempt或x-retry-count传递)。
2.4 幂等:键、指纹、去重表、状态机
重试要安全,必须让服务端能识别「这是同一个逻辑请求」。识别靠两样东西:键(identity)和指纹(content)。键回答「是不是同一个意图」,指纹回答「参数有没有变」。
2.4.1 键的粒度:一个用户意图,一个键
| 场景 | 键的粒度 | 生成时机 | 复用范围 |
|---|---|---|---|
| 用户点击「支付」 | 一次支付意图 | 创建支付单时 | 该支付单的所有重试/轮询 |
| 下单 | 一个订单 | 进入结算页时 | 提交订单的所有尝试 |
| 发券 | 一次发放任务 | 创建任务时 | 任务的所有重试 |
| 内部任务重放 | 一个任务 ID + 执行序号 | 任务入队时 | 同一次执行的所有重试 |
| 读请求 | 通常不需要 | — | — |
键必须满足四个条件:
- 客户端生成:服务端自增 ID 在第二次请求时会变,无法作为身份;
- 在首次发送前生成并持久化:不能放在 retry 循环里,也不能只放内存;
- 在所有重试中复用:包括自动重试、手动重试、跨进程恢复后的重试;
- 粒度等于业务意图:太粗会把两个合法请求误判为重复,太细挡不住重复。
常见的错误生成方式:
| 写法 | 问题 |
|---|---|
uuid4() 写在 retry 循环内 | 每次尝试都是新键,等于没有键 |
user_id + timestamp | 同秒内碰撞、跨秒无法去重 |
hash(请求体) | 两个合法的相同请求(如两次充值 100 元)被误判为重复 |
| 服务端自增 ID | 第二次请求拿到新 ID |
| 数据库主键 | 写库前还没有主键,第一次超时时无法带上 |
推荐 client_id:seq(客户端本地单调序号)或 UUIDv7/ULID。前者便于审计和排序,后者不需要中心协调。无论哪种,都要把键写进业务记录,而不是只放在日志里。
2.4.2 指纹:同键不同参必须拒绝
键只保证「同一个键只处理一次」。如果客户端用 K1 先发 amount=100,再发 amount=1000,服务端直接返回第一次的结果,用户会以为第二次也生效了。正确做法是对参数做规范化后计算指纹,存 (key, fingerprint):
import hashlib
import json
from decimal import Decimal
def _default(obj):
if isinstance(obj, Decimal):
return format(obj, "f") # 1.0 -> "1.0",但金额应统一为分
raise TypeError(f"non-canonical type: {type(obj)!r}")
def canonical_json(args: dict) -> str:
return json.dumps(args, sort_keys=True, separators=(",", ":"),
ensure_ascii=False, default=_default)
def fingerprint(method: str, args: dict) -> str:
payload = method + "\n" + canonical_json(args)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
规范化要处理的边界:
| 边界 | 处理方式 | 不处理的后果 |
|---|---|---|
| 对象 key 顺序 | sort_keys=True | 同一请求不同指纹,误报 409 |
| 空白/缩进 | separators=(",", ":") | 同上 |
| 浮点数 | 金额统一用整数分;禁止 float | 1 与 1.0 不同指纹 |
| Unicode | 统一 NFC 归一化 | 同形字符不同指纹 |
| 空值/缺省 | 契约规定「省略 = 默认值」 | null 与缺省被当成不同请求 |
| 易变字段 | 从指纹中排除(trace id、客户端时间) | 每次重试指纹都变,永远冲突 |
同键不同指纹时返回 409,并在响应里带上第一次指纹的前 8 位,方便排障。不要「以新参数覆盖旧结果」,也不要「返回旧结果但假装成功」——前者破坏幂等,后者制造静默错误。
2.4.3 去重表:唯一约束是最后一道闸门
应用层的「先查再插」在并发下一定会漏:两个请求同时查到「不存在」,然后都插入。唯一约束必须由存储层保证:
CREATE TABLE idempotency_key (
scope TEXT NOT NULL, -- 租户/接口,避免不同业务撞键
key TEXT NOT NULL,
fingerprint TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('running','succeeded','failed')),
lease_until INTEGER NOT NULL, -- 毫秒时间戳,防执行者崩溃后永久 running
http_status INTEGER,
response_body TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
PRIMARY KEY (scope, key)
);
CREATE INDEX idx_idem_expires ON idempotency_key (expires_at);
状态机只有三个状态,比第 1 章的请求状态机更窄,因为去重表只关心「结果是否可复用」:
claim (INSERT)
│
▼
┌──────────┐ handler ok ┌───────────┐
│ running │ ─────────────▶ │ succeeded │
└──────────┘ └───────────┘
│ handler error
▼
┌──────────┐
│ failed │
└──────────┘
│ same key + retryable, lease expired
└──────────────▶ running
succeeded 是终态:同键重试直接返回原响应,不再进入 running;只有 failed 或租约过期的 running 可以被重新 claim。
一个可用的 claim 流程(SQLite,isolation_level=None 即自动提交):
def claim(db, scope: str, key: str, fp: str, ttl_ms: int, lease_ms: int = 5000):
"""返回 (state, payload)。state ∈ claimed/replay/conflict/in_progress。"""
now = int(time.time() * 1000)
cur = db.execute(
"INSERT INTO idempotency_key"
"(scope,key,fingerprint,status,lease_until,created_at,expires_at) "
"VALUES (?,?,?,'running',?,?,?) "
"ON CONFLICT(scope,key) DO NOTHING",
(scope, key, fp, now + lease_ms, now, now + ttl_ms))
if cur.rowcount == 1:
return "claimed", None
row = db.execute(
"SELECT fingerprint,status,lease_until,http_status,response_body "
"FROM idempotency_key WHERE scope=? AND key=?", (scope, key)).fetchone()
fp0, status, lease, code, body = row
if fp0 != fp:
return "conflict", fp0[:8]
if status == "succeeded":
return "replay", (code, body)
if status == "running" and lease > now:
return "in_progress", max(1, lease - now)
# 租约过期或上次失败:条件更新,只有一个请求能接管
cur = db.execute(
"UPDATE idempotency_key SET status='running', lease_until=? "
"WHERE scope=? AND key=? "
"AND (status='failed' OR (status='running' AND lease_until<=?))",
(now + lease_ms, scope, key, now))
return ("claimed", None) if cur.rowcount == 1 else ("in_progress", lease_ms)
这段代码的关键是最后那条条件 UPDATE:两个请求同时发现租约过期时,只有一个能把 lease_until 改到未来,另一个的 rowcount 是 0。如果没有这个条件,两个执行者会同时跑 handler,去重表形同虚设。
lease_until 还解决「执行者崩溃后记录永远 running」的问题。租约过期后允许同键接管,但接管者必须假设「前任可能已经执行了下游副作用」;所以下游调用也必须带幂等键,不能只靠这张表。
2.4.4 TTL 与回收
去重记录不能永久保留(存储成本),也不能太早删除(重试还没结束)。TTL 至少覆盖:
经验值:同步接口 24 小时,支付/退款 7 天,异步任务 30 天。回收任务必须跳过 running 且租约未过期的记录;删除 succeeded 记录前,要先确认没有客户端还在用这个键重试。
- 响应体要限长:把 10 MB 的响应存进去,去重表会比业务表还大;超过阈值只存摘要和查询指针。
- 不要把下游调用放在数据库事务里:长事务会持锁,连接池很快耗尽;先短事务 claim,提交后再执行。
- 状态更新要带条件:
UPDATE ... WHERE status='running' AND lease_until=?,否则会覆盖别人刚写的结果。 - 键要加 scope:不同租户可能生成相同的键;
(scope, key)才是唯一键。
到这里,重试的「安全底座」齐了:deadline 控制总预算,退避和预算控制放大,幂等键和去重表控制重复。接下来把这三者放进一条失败时间线,看它们如何一起失效。
2.5 失败模式:超时 + 重试风暴
重试风暴不是「流量太大」,而是「系统用重试把一次变慢变成了持续过载」。它的形状固定:某个依赖变慢 → 超时率上升 → 客户端重试 → 总请求量上升 → 队列变长 → 延迟进一步上升 → 超时率更高。每一轮都在给正反馈加柴。
2.5.1 一条具体的时间线
假设一个服务有 500 个工作线程,正常时每次请求占用线程 100 ms(其中 60 ms 等下游),那么它的吞吐上限是:
平时流量 2000 QPS,利用率 40%,很健康。某个下游开始抖动,平均处理时间从 100 ms 涨到 600 ms,p99 到 1.5 s。吞吐上限立刻变成:
注意这里 CPU 可能只用了 20%:线程都在等下游,不在算。容量不是 CPU 决定的,是「并发数 ÷ 延迟」决定的。
客户端超时设 1 s,重试 2 次,没有预算。下面三轮的数字是示意,但数量级符合真实系统:
| 轮次 | 原始 QPS | 超时率 | 重试 QPS | 总 QPS | 在途需求 | 结果 |
|---|---|---|---|---|---|---|
| 1 | 2000 | 20% | 400 | 2400 | 1440 | 排队 607,等待约 0.7 s |
| 2 | 2000 | 45% | 900 | 2900 | 1740 | 等待约 1.1 s,超时率再升 |
| 3 | 2000 | 65% | 1300 | 3300 | 1980 | 有效吞吐掉到 700 以下 |
关键在第三列和第五列:原始流量一直是 2000,没有涨;涨的是重试。系统并没有被更多用户访问,它是被自己对失败的补偿压垮的。
更糟的是,重试请求比原始请求更「贵」:它往往带着更短的剩余 deadline,服务端如果按 FIFO 排队,会在快到期时才执行,然后超时,再触发下一轮重试。一个注定超时的请求,越晚被处理,浪费越大。
2.5.2 正反馈的数学形状
把「总负载」记为 ,原始负载 ,重试比例 ,超时率是负载的函数 ,则下一轮的负载为:
不动点存在不等于稳定。在不动点附近线性化,稳定条件是:
是「负载每增加一点,超时率上升多少」。当系统接近饱和时, 会急剧变大,这个乘积很容易超过 1;此时任何小的扰动都会把系统推出不动点,进入发散。这就是为什么 70% 利用率时一次小抖动可能没事,90% 时同样的抖动会直接雪崩(第 1 章的排队算例给过这条曲线)。
重试把「错误率」变成了「流量」,而流量又变成「错误率」。只要这个环路的增益大于 1,系统就没有稳定点,只有「还活着」和「已经挂了」两种状态。
2.5.3 同步重试:连接在睡觉,线程也在睡觉
很多 SDK 的重试是同步的:sleep 期间仍握着连接和线程。假设连接池 50 条、每个请求重试 2 次、每次退避 1 s,单个客户端实例的吞吐上限约为 req/s。不是因为服务端只能处理 16 个,而是因为客户端把连接的时间花在「握着连接睡觉」。异步重试(把请求放回队列、释放连接、到点再取)能把这段时间还给你,但会增加实现复杂度和乱序风险。
一个折中是「重试不占连接,但占一个轻量 timer」:连接归还池子,退避结束后重新取连接、重新解析后端。这要求请求对象可重放(幂等键已持久化),也要求重试时不要复用可能已被污染的连接。
2.5.4 止损顺序:先关放大器,再谈扩容
事故现场的常见错误是「先扩容」。扩容慢(分钟级),而且如果瓶颈是下游或队列,扩容只会把更多请求送进同一个瓶颈。正确的顺序:
| 顺序 | 动作 | 目的 | 观测信号 |
|---|---|---|---|
| 1 | 关闭客户端自动重试,或把预算降到 1% | 切断正反馈 | 重试 QPS 立刻下降 |
| 2 | 入口限流/降载到当前容量 | 让队列不再增长 | 队列长度、排队时间下降 |
| 3 | 返回 503 + Retry-After + 抖动 | 让上游也降速 | 429/503 比例上升,goodput 稳定 |
| 4 | 熔断最慢的依赖,走降级 | 释放线程 | 线程池等待下降 |
| 5 | 扩容/恢复下游 | 恢复容量 | 延迟与错误率同步下降 |
| 6 | 逐步恢复重试 | 避免二次冲击 | 重试比例 < 5% |
第 1 步经常被跳过,因为「重试是客户端的行为,服务端改不了」。但大多数公司同时拥有客户端和服务端;即使只有服务端,也可以通过 Retry-After、GOAWAY、关闭 keep-alive 来迫使客户端退避。重试策略是系统级配置,不是某个 SDK 的私有选项。
第 11 章会把这条时间线扩展成容量规划与故障演练;本节只需要记住一个判据:当重试 QPS 与原始 QPS 的比值超过 20%,且错误率还在上升时,系统已经进入风暴,不要再加任何重试。
2.6 背压:并发上限、队列与 Little’s law
背压(backpressure)是「让上游知道下游满了,并主动减速」。它和限流的区别是:限流按配额拒绝,背压按当前处理能力拒绝;限流保护的是「别人」,背压保护的是「自己」。
2.6.1 Little’s law:并发、吞吐、延迟只能三选二
Little’s law 说:稳定系统里,平均在途请求数等于到达率乘以平均停留时间:
换一种写法,如果你把并发上限固定为 ,那么吞吐和延迟的关系是:
| 并发上限 C | 平均延迟 W | 吞吐上限 | 说明 |
|---|---|---|---|
| 50 | 20 ms | 2500 req/s | 健康 |
| 50 | 50 ms | 1000 req/s | 正常 |
| 50 | 200 ms | 250 req/s | 下游变慢,容量塌缩 |
| 50 | 1 s | 50 req/s | 已经过载,队列无限增长 |
这张表解释了一个常见困惑:「CPU 只有 30%,为什么 QPS 上不去?」因为线程都在等下游, 变大了, 就小了。并发上限是吞吐的天花板,延迟是天花板的高度。
反过来,用 Little’s law 定并发上限:
目标 1000 QPS、p99 延迟 250 ms,则 。注意用 p99 而不是 p50:p50 只覆盖一半请求,按它算出来的并发会在高峰期排队。
2.6.2 队列该有多长
队列的作用是吸收短时突发,不是吸收持续过载。持续过载时,队列只会把「快速失败」变成「慢速失败」,还额外占用内存和 deadline。
一个可用的估算:
峰值 1200 QPS、服务能力 1000 QPS、突发持续 2 s,则至少需要 400 个槽位。超过这个长度的队列,在突发结束后仍然需要 2 s 才能排空;如果突发持续 10 s,队列需要 2000 个槽位,此时更合理的动作是限流,而不是加队列。
队列还有三个隐性成本:
- 内存:每个排队请求持有请求体、上下文、deadline 对象;10 万个排队请求可能吃掉几 GB。
- 过期工作:排队时 deadline 已经过了的请求,出队后应该直接丢弃,而不是继续执行。
- 公平性:FIFO 会让一个慢客户端的大请求堵住后面所有小请求;按优先级/租户分队列更稳。
2.6.3 一个带 deadline 的并发闸门
import threading
class Overloaded(Exception):
pass
class Gate:
"""有界并发 + 有界等待。等待时间不得超过调用方剩余预算。"""
def __init__(self, limit: int):
self._sem = threading.BoundedSemaphore(limit)
self._lock = threading.Lock()
self.inflight = 0
self.rejected = 0
def enter(self, max_wait_s: float) -> None:
if not self._sem.acquire(timeout=max(0.0, max_wait_s)):
with self._lock:
self.rejected += 1
raise Overloaded("concurrency limit")
with self._lock:
self.inflight += 1
def leave(self) -> None:
with self._lock:
self.inflight -= 1
self._sem.release()
调用方传入的 max_wait_s 不是固定值,而是「剩余预算减去一次最小处理时间」。如果剩余预算只有 30 ms,而处理至少需要 50 ms,max_wait_s 为负,enter 立即失败——这是正确的:不要让一个注定超时的请求进入队列。
拒绝时返回什么,决定了上游会不会立刻再打回来:
| 拒绝原因 | 返回 | 带什么 | 上游应该做什么 |
|---|---|---|---|
| 自身并发满 | 503 | Retry-After: 1 + 抖动 | 退避,不要立即重试 |
| 租户配额耗尽 | 429 | Retry-After + 配额重置时间 | 降速或换租户 |
| 请求已过期 | 504 或 503 | 不建议重试 | 放弃,走 unknown 收敛 |
| 依赖熔断 | 503 | Retry-After | 降级/读缓存 |
把队列从 1000 加到 10000,不会增加吞吐,只会增加延迟和内存。真正的背压是「拒绝」,而且要在请求进入昂贵资源之前拒绝:在网关拒绝,比在数据库连接池前拒绝便宜一个数量级;在连接池前拒绝,比让请求超时后再重试便宜两个数量级。
2.7 限流与过载:令牌桶、429/503、Retry-After
限流回答「你能用多少」,背压回答「我现在还能处理多少」。两者经常一起出现,但语义不同:限流是配额(quota),背压是容量(capacity)。配额可以买,容量不能。
2.7.1 令牌桶:允许突发,限制均值
令牌桶有两个参数:速率 (每秒补充多少 token)和桶容量 (最多攒多少)。请求消耗 token,token 不够就拒绝或排队。
import time
class TokenBucket:
def __init__(self, rate_per_s: float, burst: int):
self.rate = float(rate_per_s)
self.burst = float(burst)
self.tokens = float(burst)
self.updated = time.monotonic()
def _refill(self) -> None:
now = time.monotonic()
self.tokens = min(self.burst, self.tokens + (now - self.updated) * self.rate)
self.updated = now
def allow(self, cost: float = 1.0) -> bool:
self._refill()
if self.tokens >= cost:
self.tokens -= cost
return True
return False
def retry_after_ms(self, cost: float = 1.0) -> int:
self._refill()
missing = max(0.0, cost - self.tokens)
return int(missing / self.rate * 1000) + 1
参数选择:
rate等于长期允许的均值,例如每租户 100 req/s;burst决定能吸收多长的突发。burst=200意味着空闲 2 s 后可以一次性打 200 个;burst不是越大越好:它把「瞬时并发」放大,下游必须能接住这个瞬时值。
常见限流算法对比:
| 算法 | 突发处理 | 精度 | 内存 | 分布式实现 |
|---|---|---|---|---|
| 固定窗口 | 边界处可双倍 | 低 | O(1) | 简单,但边界不公平 |
| 滑动窗口日志 | 精确 | 高 | O(请求数) | 贵,适合低频 |
| 滑动窗口计数 | 近似 | 中 | O(窗口数) | Redis 常用 |
| 令牌桶 | 允许 突发 | 高 | O(1) | Redis Lua 原子化 |
| 漏桶 | 不允许突发 | 高 | O(1) | 适合整形输出 |
固定窗口有一个经典边界问题:限制「每分钟 100 次」,客户端在 12:00:59 打 100 次、12:01:00 再打 100 次,两秒内实际通过了 200 次。对下游来说,这和没有限流差不多。
分布式令牌桶要把「读-算-写」做成原子操作,通常用 Redis Lua:脚本里读 tokens 和 ts,按 now-ts 补充,判断并扣减,最后一次写回。分两次 GET/SET 会在并发下超发。
2.7.2 429 与 503:谁该退避,退多久
| 状态码 | 语义 | 作用域 | Retry-After | 客户端动作 |
|---|---|---|---|---|
| 429 | 配额/速率超限 | 租户、API key、IP | 建议给 | 退避到指定时间,不要立即重试 |
| 503 | 服务过载/依赖不可用 | 整个服务或实例 | 建议给 | 退避 + 切换实例/降级 |
| 504 | 网关等上游超时 | 单次请求 | 不给 | 按 unknown 处理,不要盲目重试 |
Retry-After 的值可以是秒数(Retry-After: 3)或 HTTP 日期(Retry-After: Wed, 21 Oct 2026 07:28:00 GMT)。服务端给秒数更简单,但要注意:
- 加抖动:如果 1 万个客户端都收到
Retry-After: 3,它们会在第 3 秒同时回来。客户端应该在[0.8, 1.2] × Retry-After内随机化。 - 不要给太小:
Retry-After: 0等于「立刻重试」,会加剧过载;至少给一个估计的恢复时间。 - 不要给太大:超过客户端 deadline 的
Retry-After等于让客户端放弃;如果确实要这么久,应该返回 503 并让客户端走异步查询。
服务端如何区分「自己过载」和「依赖失败」:
| 信号 | 自身过载 | 依赖失败 |
|---|---|---|
| 本服务 CPU | 高(>80%) | 可能低(线程在等) |
| 队列等待 | 高且持续上升 | 可能正常 |
| 下游延迟/错误 | 正常 | 高 |
| 本服务 handler 耗时 | 高(含排队) | 高(含等待下游) |
| 线程池 | 满,任务排队 | 大量线程处于 waiting |
| 正确动作 | 限流/降载/扩容自己 | 熔断/降级/切换下游 |
这张表决定返回 429 还是 503:如果是自己过载,返回 503 并限流;如果是租户超额,返回 429;如果是依赖失败,返回 503,但要在响应体里说明是依赖不可用(可选,避免泄露内部拓扑),并考虑降级读缓存。
某 API 只有一个全局令牌桶,速率 1000 req/s。一个大租户用脚本重试,瞬间打到 900 req/s,其他租户共享剩下的 100 req/s,全部超时。
修复分两层:
- 每个租户一个桶(速率按套餐分配),再加一个全局桶(保护总容量);
- 请求先过租户桶,再过全局桶;任一失败都返回 429,并在响应头带
X-RateLimit-Scope: tenant|global。
只加全局桶会让「一个租户拖垮所有人」;只加租户桶会让「所有租户同时打满」拖垮服务端。两层缺一不可。
限流还会和重试相互作用:客户端收到 429 后如果立即重试,等于把限流器当成了随机数生成器。429 的语义是「现在别来」,不是「换个连接再来」;重试必须遵守 Retry-After,并且消耗重试预算。
2.8 观测:客户端和服务端各看什么
RPC 的观测难点在于:同一个逻辑请求在客户端和服务端会产生不同数量的记录。客户端看到 1 次逻辑调用、3 次物理尝试;服务端看到 3 条请求日志,其中 2 条命中去重表。如果不把 attempt 和 idempotency_key 带进指标,两边永远对不上账。
2.8.1 两边各自的黄金指标
| 侧 | 指标 | 类型 | 回答什么问题 |
|---|---|---|---|
| 客户端 | rpc_client_attempt_duration_seconds | histogram | 每次尝试慢在哪 |
| 客户端 | rpc_client_retries_total{reason,attempt} | counter | 重试多少、为什么 |
| 客户端 | rpc_client_deadline_remaining_ms | histogram | 发出时还剩多少预算 |
| 客户端 | pool_wait_seconds / pool_in_use | histogram/gauge | 连接池是不是瓶颈 |
| 服务端 | rpc_server_requests_total{method,code} | counter | 收到多少、错误分布 |
| 服务端 | rpc_server_queue_wait_seconds | histogram | 排队多久 |
| 服务端 | idempotency_hits_total{result} | counter | 去重命中/冲突 |
| 服务端 | rpc_server_rejected_total{reason} | counter | 限流/过载拒绝 |
客户端和服务端都要打 method、attempt、outcome 三个标签。标签基数是成本:method 有限,attempt 最多 5 个值,outcome 建议收敛到 5 类(ok / retryable / fatal / timeout / rejected)。不要把 idempotency_key 或 user_id 放进指标标签,基数会爆炸;它们属于日志和 trace。
2.8.2 一条命令:看连接复用与总耗时
for i in 1 2 3 4 5; do
curl -sS -o /dev/null \
-w "i=$i code=%{http_code} total=%{time_total} ttfb=%{time_starttransfer} connects=%{num_connects}\n" \
-H "Idempotency-Key: obs-pay-$i" \
-H 'Content-Type: application/json' \
-d '{"order_id":"o-8812","amount_cents":1999}' \
http://127.0.0.1:8081/v1/payments
done
一次本机测试的输出:
i=1 code=201 total=0.018213 ttfb=0.017904 connects=1
i=2 code=201 total=0.006911 ttfb=0.006102 connects=0
i=3 code=201 total=0.005442 ttfb=0.004910 connects=0
怎么读:
| 现象 | 含义 | 下一步 |
|---|---|---|
第 1 次 connects=1,之后 connects=0 | 连接池生效,复用了连接 | 正常 |
每次都 connects=1 | 连接池没生效/被禁用/服务端发 Connection: close | 查客户端配置与服务端 keep-alive |
connects=0 但 total 逐次上升 | 连接复用但排队(流控/池竞争) | 看 pool_wait、服务端 inflight |
ttfb 接近 total | 时间花在服务端处理/排队 | 看服务端 queue wait 与下游 span |
total 远大于 ttfb | 时间花在响应传输/客户端读取 | 看响应体大小、带宽、客户端解析 |
num_connects 是 curl 的字段,表示本次传输新建了几条连接;0 表示完全复用。它和 time_connect 不同:time_connect 只在新建连接时非零,复用连接时接近 0,容易让人误以为「网络很快」。
2.8.3 PromQL:重试比例与按 attempt 的 p99
重试比例必须用「重试次数 / 原始请求数」,不是「重试次数 / 总尝试次数」——后者在重试风暴时反而下降,因为分母被重试撑大了:
sum(rate(rpc_client_retries_total{service="payment"}[5m]))
/
sum(rate(rpc_client_requests_total{service="payment"}[5m]))
输出:
0.184
18.4% 已经超过 10% 的警戒线。再看按 attempt 分组的 p99:
histogram_quantile(0.99,
sum by (le, attempt) (
rate(rpc_client_attempt_duration_seconds_bucket{service="payment"}[5m])
)
)
输出:
{attempt="1"} 0.212
{attempt="2"} 1.024
{attempt="3"} 1.481
读法:第 1 次尝试 p99 是 212 ms,第 2 次是 1.02 s,第 3 次是 1.48 s。重试没有变快,反而一次比一次慢,说明重试打到了已经过载的实例或更深的队列;此时继续重试只会延长故障。正常系统里,第 2、3 次尝试的 p99 应该接近第 1 次(甚至更低,因为换到了健康实例)。
2.8.4 日志:把同一个逻辑请求串起来
一条可用的尝试日志:
{"ts":"2026-10-19T09:41:02.114Z","event":"rpc_attempt","method":"Payment/Charge",
"idempotency_key":"pay-o-8812","attempt":2,"outcome":"DEADLINE_EXCEEDED",
"deadline_remaining_ms":18,"peer":"10.2.7.11:8443","trace_id":"d7c1e2..."}
四个字段不能省:idempotency_key(把多次尝试归并)、attempt(区分第几次)、deadline_remaining_ms(判断是不是「发出去就注定超时」)、peer(判断是不是总打到同一个坏实例)。
用 jq 按逻辑请求聚合:
jq -r 'select(.event=="rpc_attempt") | [.idempotency_key,.attempt,.outcome] | @tsv' /tmp/payment.log \
| sort | uniq -c | sort -rn | head
输出:
12 pay-o-8812 1 DEADLINE_EXCEEDED
3 pay-o-8812 2 DEADLINE_EXCEEDED
1 pay-o-8812 3 OK
这个键一共留下 16 条尝试记录,其中 12 条是 attempt=1:说明 12 个并发请求共用了同一个键,客户端在并发重试或去重逻辑失效。如果同一个 order_id 出现两个不同的 idempotency_key,那就是 2.10 的重复扣款前兆。
pool_wait p99 < 5 ms。peer 且都超时。2.9 真实案例:S3 2017-02-28 的重启与重试
2017 年 2 月 28 日,AWS 位于北弗吉尼亚(us-east-1)的 S3 发生了一次持续约 4 小时的服务中断。AWS 官方的复盘写得克制,但事实链很清楚:
| 时间(当地时间) | 事件 |
|---|---|
| 09:37 | S3 计费团队的一名授权人员执行一条移除服务器的命令,输入参数写错 |
| 随后 | 命令移除了比预期更多的服务器,波及索引(index)与放置(placement)两个核心子系统 |
| 随后 | 两个子系统必须整体重启;数据量大、校验耗时长,重启比预期慢 |
| 恢复期 | 缓存需要重建,大量请求失败并被客户端重试,进一步加重负载 |
| 约 4 小时后 | 服务恢复 |
公开复盘的关键句是:一条输入错误的命令导致大量 S3 子系统被移除;重启后缓存重建、请求重试放大;影响约 4 小时。官方链接:Summary of the Amazon S3 Service Disruption。
2.9.1 为什么把它放进 RPC 一章
这场事故的根因是运维工具的参数校验与爆炸半径,不是 RPC。但它对本章有两个直接教训:
第一,重试决定了恢复的斜率。 故障期间,客户端看到的是超时和 5xx,SDK 按默认策略重试。重试没有造成最初的故障,却让恢复期的请求量高于正常水平;而恢复期的服务端恰恰最脆弱——缓存是冷的,索引在重建,能处理的并发远低于稳态。故障前的容量规划不能直接用于故障恢复期。
第二,恢复期需要主动的背压,而不是被动等待。 如果服务端在恢复期继续按稳态接收请求,队列会被填满,冷缓存下的每次未命中都要回源,回源又加重索引负担。正确动作是:入口限流到「当前能处理的量」,返回 503 + Retry-After,并临时关闭客户端的自动重试。AWS 在恢复期间采取了一系列限制性动作,本质都是「把流量降到系统能恢复的水平」。
把这两点翻译成本章的机制:
| 机制 | 在事故中的角色 | 如果缺失 |
|---|---|---|
| deadline 传播 | 让客户端尽早放弃,减少无效等待 | 连接被长时间占用,恢复更慢 |
| 重试预算 | 限制恢复期的额外流量 | 重试量随错误率线性上升 |
| 背压/限流 | 把入口流量压到冷缓存能承受的水平 | 队列堆积,缓存无法预热 |
| 幂等 | 重试到达时不产生额外副作用 | 恢复后出现重复对象/重复计费 |
本节只引用 AWS 公开复盘中的事实:时间、命令参数错误、索引与放置子系统、整体重启、缓存重建、重试放大、约 4 小时影响。具体命令、内部拓扑和恢复步骤不复述;也不把「重试」写成事故根因——根因是破坏性工具缺少边界,重试是放大器。
2.10 重试导致重复副作用:支付请求的两个键
现在看一个比发券更贵的例子。用户在一次结算里只点了一次「支付」,最后却被扣了两笔。系统里没有崩溃,数据库也没有丢数据;问题出在两个键上:请求键(request key)与效果键(effect key)。
2.10.1 时间线
| 时间 | 客户端 | 支付服务 | PSP(收单方) | 结果 |
|---|---|---|---|---|
| t0 | 创建支付意图 pi_1,生成键 K1 | — | — | 用户只点了一次 |
| t1 | 发送 K1 | 插入 payment_intent(pi_1),调用 PSP | — | — |
| t2 | 等待响应 | 等 PSP | 扣款成功,响应慢 | 钱已扣 |
| t3 | 2 s 超时,重试;重试代码重新生成了 K2 | 收到 K2,按请求键去重,未命中 | — | 第二条 intent |
| t4 | 等待 | 再次调用 PSP | 用 K2 扣款成功 | 第二次扣款 |
| t5 | 收到 t3 的响应,显示成功 | 两条 intent 都是 succeeded | 两笔 charge | 用户被扣两次 |
第二天对账时,财务发现同一个 order_id 下有两笔金额相同的扣款,psp_charge_id 不同,intent_id 也不同。
2.10.2 根因不是「重试」,是键的生命周期错了
这段重试代码是常见的写法:
def pay(order_id: str, amount_cents: int) -> dict:
for attempt in range(3):
key = str(uuid.uuid4()) # BUG:每次尝试都生成新键
try:
return client.charge(order_id, amount_cents,
metadata={"idempotency-key": key})
except TimeoutError:
continue
raise PaymentFailed(order_id)
键在循环内部生成,意味着每次物理尝试都是一个新的逻辑请求。服务端的去重表按 (scope, key) 建唯一约束,K1 和 K2 是两个不同的键,两条记录都合法。去重表没有失效,它只是从未收到同一个键。
修复的第一步是把键的生命周期提到「支付意图」上,并持久化:
def pay(intent: PaymentIntent) -> dict:
# intent.request_key 在创建支付意图时生成并写库,重试只读不写
for attempt in range(3):
try:
return client.charge(
intent.order_id, intent.amount_cents,
metadata={"idempotency-key": intent.request_key,
"attempt": str(attempt)})
except TimeoutError:
continue
raise PaymentFailed(intent.intent_id)
但这还不够。因为「请求键」只保护支付服务这一层;如果支付服务自己重试、或者消息队列重投,PSP 仍可能收到两次扣款。所以还需要效果键(effect key):把业务意图的 ID 作为下游的幂等键。
| 键 | 生成方 | 作用域 | 保护的是 | 存储位置 |
|---|---|---|---|---|
请求键 request_key | 客户端 | 支付服务的一次调用 | 传输层重试 | 支付意图行 |
效果键 effect_key | 支付服务 | PSP 的一次扣款 | 副作用本身 | 支付意图行 + PSP metadata |
业务键 (merchant_id, order_id) | 业务 | 一个订单 | 用户重复点击/多入口 | 唯一约束 |
三个键的粒度不同,缺一层就有一类重复能穿过去:
- 只有请求键:支付服务重试下游时会重复扣款;
- 只有效果键:客户端换键重试会创建两条 intent,虽然 PSP 只扣一次,但账目上多一条;
- 只有业务键:同一订单的合法多次支付(例如补款)无法表达。
2.10.3 数据库约束:最后一道闸门
CREATE TABLE payment_intent (
intent_id TEXT PRIMARY KEY,
merchant_id TEXT NOT NULL,
order_id TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
request_key TEXT NOT NULL,
fingerprint TEXT NOT NULL,
effect_key TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('running','succeeded','failed')),
psp_charge_id TEXT,
created_at INTEGER NOT NULL,
UNIQUE (merchant_id, order_id),
UNIQUE (merchant_id, request_key)
);
CREATE UNIQUE INDEX idx_effect_key ON payment_intent (merchant_id, effect_key);
UNIQUE (merchant_id, order_id) 挡住「同一订单创建两个 intent」;UNIQUE (merchant_id, request_key) 挡住「同键并发插入」;effect_key 的唯一索引保证一个意图只能对应一个效果。三层约束里,业务键最容易被忽略,因为它看起来「不属于 RPC」;但重复扣款的最终表现,往往就是业务表里多了一条合法记录。
2.10.4 对账与修复
事故已经发生后的处理顺序:
- 按
order_id聚合:找出同一订单下status='succeeded'且金额相同的多条 intent; - 核对 PSP:用
psp_charge_id拉取收单方流水,确认是否真的多扣; - 退款:用独立的
refund_id作为退款幂等键,不要用原charge_id直接重试退款; - 修数据:保留两条 intent 作为审计记录,把其中一条标记为
duplicate,不要物理删除; - 补约束:上线
UNIQUE (merchant_id, order_id)前,先清理历史重复,否则建索引会失败。
修复后的回归测试要覆盖三个场景:同一请求键重试(应返回原结果)、同一订单换键重试(应 409 或返回原 intent)、PSP 超时后重试(PSP 侧应只扣一次)。第三个场景必须用 PSP 的沙箱验证,不能只测自己的服务。
某支付服务在重试循环内生成 Idempotency-Key,服务端按该键去重。一次网络抖动导致客户端重试一次,服务端收到两个不同的键,创建了两条 payment_intent,PSP 扣了两次。对账发现时已过 26 小时,退款还产生了额外的手续费损失。
修复:请求键在创建 intent 时生成并写库;服务端对 (merchant_id, order_id) 加唯一约束;调用 PSP 时用 intent_id 作为效果键。三层都上线后,同样的故障注入只产生一条 intent、一笔扣款。
这两个键的区分,是本章最重要的工程结论之一:请求键解决「同一次调用被重发」,效果键解决「同一个副作用被执行两次」。前者属于 RPC 层,后者属于业务/存储层;只在 RPC 层做幂等,挡不住业务层的重放。
2.11 一份 RPC 契约检查表
新增一个 RPC 接口时,把下面 15 个问题答完再写代码。答不上来的,不是「以后再补」,而是「现在就没想清楚」。
| # | 问题 | 达标线 | 写在哪 |
|---|---|---|---|
| 1 | 这个接口有副作用吗? | 有副作用必须有幂等键 | 接口文档 |
| 2 | 逻辑请求的身份是什么? | 客户端生成、全局唯一、可持久化 | 请求头/体 |
| 3 | 幂等键的粒度是什么? | 等于一次业务意图,而不是一次 HTTP 请求 | 契约 |
| 4 | 键在什么时候生成、存在哪? | 首次发送前,写入业务行 | 客户端/存储 |
| 5 | 同键不同参怎么办? | 409,并返回首次指纹摘要 | 错误码表 |
| 6 | 端到端 deadline 是多少? | 有数字,且按 hop 递减 | 契约 |
| 7 | 每跳留多少余量? | ≥ 2×RTT(p99) + 处理时间 | 客户端配置 |
| 8 | 哪些错误可重试? | 有明确列表,默认不重试 | 错误码表 |
| 9 | 退避、抖动、上限? | full jitter,base/cap 有依据 | 客户端配置 |
| 10 | 重试预算多少? | 5%–10%,耗尽返回 503 | 客户端/网关 |
| 11 | 超时后是 unknown 还是 failed? | 明确区分,不混用 500 | 错误码表 |
| 12 | 服务端如何去重? | 唯一约束 + 条件更新,不靠先查后插 | 存储设计 |
| 13 | 重复请求返回什么? | 原结果 / 202+查询 / 409,三选一 | 响应契约 |
| 14 | 过载时返回什么? | 429/503 + Retry-After + 抖动 | 响应契约 |
| 15 | 观测字段有哪些? | key、attempt、deadline、peer、outcome | 日志/指标 |
配套的默认参数(可按业务调整,但不能没有):
| 四件套 | 默认值 | 上调条件 | 下调条件 |
|---|---|---|---|
| 超时 | 端到端 1–3 s;单跳 ≤ 剩余预算 | 业务确实长耗时,且有异步查询 | 用户交互路径 |
| 重试 | 最多 2 次,full jitter,base 50 ms | 依赖偶发瞬时失败 | 依赖已过载 |
| 幂等 | 键 + 指纹 + 唯一约束,TTL 24 h | 支付/退款 7 天 | 纯读接口可不用 |
| 背压 | 并发上限 = 目标 QPS × p99 × 1.2 | 有突发流量且下游能吸收 | 下游已饱和 |
这张表不能替代设计评审,但它能挡住最常见的五类事故:键在循环里生成、deadline 每跳重置、重试无预算、同键不同参静默覆盖、过载时返回 500。
2.12 本章陷阱清单
- 把 timeout 当 deadline:每跳重置成固定超时,端到端变成 ;必须传剩余预算并逐跳扣减。
- 重试时重新生成幂等键:键在 retry 循环里生成,等于每次都是新请求;键必须在首次发送前生成并持久化。
- 用请求体哈希当幂等键:两次合法的相同请求(如两次充值 100 元)会被误判为重复;哈希只能当指纹,不能当身份。
- 只做客户端幂等:服务端没有唯一约束,并发插入仍会产生两条记录;唯一约束必须在存储层。
- 同键不同参返回旧结果:用户以为新参数生效了,实际没有;必须 409。
- 重试没有预算:单客户端 3 次,乘以百万客户端就是三百万次;预算按比例限制,耗尽返回 503。
- 指数退避不加抖动:所有客户端在同一时刻重试,形成同步脉冲;至少用 full jitter。
- 重试不可重试的错误:400/401/403/404 重试一万次也不会成功,只会浪费连接和配额。
- 重试延长 deadline:每次尝试都拿新预算,最后一次尝试注定在客户端放弃后执行;所有尝试共享一个 deadline。
- 无限堆队列:队列只吸收短时突发;持续过载时加队列只会把快速失败变成慢速失败。
- 把连接池当并发上限:池 50 条、每条 1 个在途请求,等价于并发 50;下游变慢时吞吐按 塌缩。
- 忽略
Retry-After:它是服务端给出的最小等待时间;忽略它会让过载持续,重试变成攻击。
2.13 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出 stub 做的五件事 | 能区分类型、序列化、metadata、连接、错误映射 | 2.1 |
| 解释 IDL 的边界 | 能说出它不定义幂等/超时/重试 | 2.1 |
| 写 deadline 传播 | 能写出 child = parent - queue - margin,并解释 grpc-timeout 的相对预算与 8 位限制 | 2.2 |
| 实现 full jitter | 能写出 U(0, min(cap, base*2^i)) | 2.3 |
| 设重试预算 | 能给出 ratio/burst/max_attempts 的依据 | 2.3 |
| 设计幂等键 | 能说清粒度、生成方、持久化、TTL | 2.4 |
| 设计去重表 | 有唯一约束、条件更新、指纹冲突处理 | 2.4 |
| 用 Little’s law 算并发 | 给定 QPS 与 p99 能算出 C | 2.6 |
| 区分 429/503 | 能说出作用域与 Retry-After 语义 | 2.7 |
| 用 PromQL 看重试比例 | 能写出重试/原始请求的比值查询 | 2.8 |
| 复述 S3 教训 | 能说出重试放大恢复期负载 | 2.9 |
| 解释两个键 | 能区分请求键与效果键的保护范围 | 2.10 |
Q1 客户端总预算 1200 ms。网关排队 30 ms,留给回程的余量 20 ms;服务 A 排队 80 ms,余量 20 ms。服务 A 转发给服务 B 时,grpc-timeout 应填多少?
Q2 attempt=4(第 5 次重试),base=100 ms,cap=2 s。full jitter 的等待范围与期望值是多少?
Q3 某服务原始 5000 QPS,重试预算 ratio=8%、burst=50。若 30% 请求失败且都可重试,预算能放行多少重试?会发生什么?
Q4 目标 800 QPS,p99 延迟 300 ms,安全系数 1.2。按 Little's law,并发上限应设为多少?
Q5 支付请求的请求键和效果键分别保护什么?只做其中一层会漏掉哪类重复?
答案
A1 1200 − 30 − 20 = 1150 ms 到达 A;A 再扣 80 + 20,剩 1050 ms,填 1050m(若只能填整秒,填 1S,并接受精度损失)。
A2 基准 ms,未到 cap;范围 0–1600 ms,期望 800 ms。若 cap=2 s,第 5 次基准 3200 ms 被截到 2000 ms。
A3 稳态预算约 次/秒,加上 burst 50 的初始额度;30% 失败意味着期望 1500 次重试,预算只能放行约 400 次,其余返回 503。正确动作是降低原始流量或修复依赖,而不是提高 ratio。
A4 。用 p99 而不是 p50;若延迟涨到 1 s,同样并发只能支撑约 288 QPS。
A5 请求键保护「同一次逻辑调用被重发」,挡住传输层重试;效果键保护「同一个副作用被执行两次」,挡住服务端/队列/PSP 重放。只做请求键,支付服务重试下游会重复扣款;只做效果键,换键重试会多一条 intent 记录;还需要业务键挡住用户重复点击。
母本对应
| 母本 | 章节 | 本书落点 | 程度 | 说明 |
|---|---|---|---|---|
| 谢希仁《计算机网络》第 8 版 | 第 5 章 运输层、第 6 章 应用层 | 2.1、2.2 | 背景 | 只借用「请求经过网络分层」的视角;TCP 细节不重写 |
| 张彦飞《深入理解 Linux 网络》 | 第 4–7 章(TCP、socket、连接) | 2.1、2.2、2.6 | 背景 | 连接复用与池耗尽只引用,不展开内核路径 |
| 《Python 网络编程》 | socket/HTTP 客户端、并发服务器 | 2.1、2.3、2.6 | 精选 | 借用工程风格;deadline、退避、令牌桶代码为本书重写 |
| 《Linux 系统与内核》第 4 篇 | 4.4–4.7(TCP/HTTP/DNS/TLS) | 2.1、2.2、2.8 | 交叉引用 | 明确不重复 TCP 握手、HTTP/2 帧、epoll、DNS/TLS |
| gRPC 文档与 Envoy 文档 | grpc-timeout、retry budget、Retry-After | 2.2–2.4、2.7 | 引用 | 只引用语义与参数名,不复制文档长段 |
| 本书自编 + AWS 公开复盘 | S3 2017-02-28 | 2.9 | 完整 | 只引用公开事实,不复制原文 |
下一站:知道了「一次调用怎么超时、怎么重试、怎么去重」,下一步要回答「同一份数据放在多个副本上,谁来写、谁来读、延迟多久」。进入 3 复制:主从、多主、无主,看复制日志、故障切换与读己之写。
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 状态。那时我们默认「服务端的数据只有一份」。这一章要把这一份数据变成多份,并回答由此产生的三个新问题:
- 多份副本之间,谁先写、谁后写、谁说了算?
- 主库挂了,谁接替?接替的一瞬间,哪些已经告诉用户「成功」的写可能消失?
- 用户写完立刻去读,读到的是自己的写,还是另一个副本上的旧值?
复制是后面所有章节的地基。一致性模型(第 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 秒的从库,用户开始看到旧数据;为了地理就近,你在两个洲都开主库,冲突解决立刻变成业务问题。拓扑不是「先进程度」的排序,而是这三种取舍的不同组合。
3.1.1 RPO 与 RTO:把「高可用」拆成两个可测的数
「高可用」是一个太粗的词。运维和业务真正能对齐的是两个数:
- RPO(Recovery Point Objective):故障发生时,最多可以丢多少数据。用时间表示就是「最多回退到多久之前」,用写量表示就是「最多丢多少条已确认写」。
- RTO(Recovery Time Objective):从故障发生到服务恢复,最多允许多久。
| 场景 | RPO | RTO | 常见复制方式 |
|---|---|---|---|
| 支付账务 | 0(对已确认写) | 小于 1 分钟 | 同步 quorum + 受控自动切换 |
| 订单主库 | 小于 5 秒 | 小于 5 分钟 | 半同步 + 人工确认切换 |
| 商品目录 | 小于 5 分钟 | 小于 30 分钟 | 异步复制 + 多副本读 |
| 行为日志/分析 | 小于 1 小时 | 小于 4 小时 | 异步批复制,允许重放 |
这张表里最容易误读的是「RPO = 0」。它的准确含义是:对那些已经向客户端返回成功的写,在承诺的故障模型内不丢。它不覆盖三种情况:
- 写还在主库内存/WAL 缓冲里,没 fsync,也没复制;
- 主库返回成功,但 ack 条件只包含「副本收到」,不包含「副本落盘」;
- 故障模型之外的相关性故障,例如三副本同机架、同交换机、同一次固件升级。
把这些边界写进契约,比在事故后争论「不是三副本吗」有用得多。
复制(replication)是把同一份逻辑数据的多个副本,按某种顺序和协议持续同步的过程。副本不是一个备份文件,而是一个可以独立接受读(有时也包括写)的服务实例。
RPO 是故障后可接受的最大数据丢失量,通常用时间或写条数表示;RTO 是故障后可接受的最大恢复时间。两者都必须绑定故障模型:是单节点崩溃、单机架掉电、单地域隔离,还是区域性灾难。脱离故障模型谈 RPO/RTO 没有意义。
3.1.2 复制不是备份
这是运维里最常见、也最贵的一句话混淆。复制和备份都会「多一份数据」,但它们防的是不同的故障。
| 维度 | 复制 | 备份 |
|---|---|---|
| 目的 | 故障切换、读扩展、就近访问 | 恢复逻辑错误、误删、勒索加密 |
| 时效 | 持续、秒级同步 | 周期快照、时间点副本 |
| 删除是否传播 | 会,主库删副本也删 | 不会,历史时间点保留 |
| 典型位置 | 同城/跨城机房,在线 | 对象存储、离线磁带、异地 |
| 验证方式 | lag、校验和、切换演练 | 恢复演练、恢复时间、完整性校验 |
| 主要风险 | 相关性故障、误操作同步扩散 | 备份本身不可恢复、从没演练过 |
一个 DELETE FROM orders 的误操作,会在几秒内被复制到所有副本;备份则保留误操作之前的那个时间点。反过来,备份的 RTO 通常是小时级,不能拿它当高可用。复制保证「机器坏了还有一份」,备份保证「数据错了还能回到过去」;两者不能互相替代。
副本数只说明「有几份」,不说明「分布在哪」。三份副本在同一个可用区,和一份副本没有本质区别;一次交换机升级、一次电源维护、一次存储固件 bug,都可能同时带走它们。设计 RPO 时,要先画故障域(机架、可用区、地域、账号、控制面),再看副本是否真的跨域。跨域放置的代价是 RTT,这正是下一节要算的账。
3.1.3 同步与异步的延迟代价
同步复制和异步复制的区别,不是「可靠/不可靠」,而是「主库在什么时候向客户端返回成功」。
同步复制的写延迟可以近似写成:
其中 是必须确认的副本集合。跨地域 RTT 的物理下限由光速和路由决定:同城两个可用区可能 1–3 ms,跨 1000 公里可能 20–40 ms,跨洲可能 150–250 ms。同步复制不是加一点开销,而是把最远那个副本的 RTT 加到每一次写的关键路径上。
异步复制则是:
主库本地提交后就返回,副本在后台追。写延迟接近本地盘,但故障时最多丢一个 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)。
一次写从客户端到「可见」,至少经过下面几步:
- 客户端把写请求发给 leader;
- leader 在本地 WAL/binlog 里追加一条记录,拿到 LSN/GTID/offset;
- leader 把日志流式发给 follower;
- follower 写入自己的 WAL/relay log,按配置选择是否 fsync;
- follower 回 ack;
- leader 根据同步策略判断「提交点」是否到达,推进 commit index;
- leader 把日志 apply 到状态机,向客户端返回成功;
- 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。
提交点(commit point)是一条日志记录已经持久化在同步策略要求的副本集合上、不会因为一次受控 failover 而消失的位置。可见性(visibility)是一条记录已经被 apply 到某个副本的读路径上、能被读请求看到的状态。
提交不等于可见:leader 可以在 commit index 推进后立刻返回客户端,而 follower 的 apply 还在后面追。判断「读到的值新不新」时,要区分「已提交但未 apply」和「根本没复制到」。
3.2.2 半同步的 quorum 变体
「半同步」常被实现成「至少一个副本确认」。这个策略简单,但有两个隐患:
- 固定副本依赖:如果指定的那个副本挂了,主库要么阻塞,要么降级为异步;降级的那一刻,RPO 从 0 变成了 lag。
- 不保证提升安全:如果自动切换提升了一个没有被同步确认过的副本,之前「已确认」的写仍然可能丢。
更稳妥的写法是 quorum:等 k 个副本确认,且规定新 leader 必须来自「拥有全部已提交日志」的副本集合。k 的选择决定了写延迟和可用性:
| N | k | 容忍副本故障 | 写延迟 | 备注 |
|---|---|---|---|---|
| 3 | 2 | 1 | 第 2 慢 RTT | 最常见;跨 3 个 AZ |
| 3 | 3 | 0 | 最慢 RTT | 任何副本慢都影响写 |
| 5 | 3 | 2 | 第 3 慢 RTT | 跨 5 个故障域,成本高 |
| 5 | 2 | 3 | 第 2 慢 RTT | 写快,但提升约束更复杂 |
等待多个副本会把长尾放大。假设每个副本有 1% 的概率让一次写超过 50 ms,且彼此独立,那么全同步等 3 个副本时,至少一个慢的概率是:
也就是说,单副本 p99 是 50 ms,三副本全同步的 p99 可能已经超过 50 ms 一个数量级。同步复制的尾延迟不是平均值,而是「最慢那个副本」的分布。这解释了为什么很多系统宁愿选 k=2 而不是 k=3:多等一个副本,换来的故障容忍度可能不值那一段尾延迟。
3.2.3 提交与可见性:两个常见事故
第一个事故是「读从库读不到刚写的值」:写已提交,但从库 apply 还没到,不是丢数据而是可见性滞后(3.8 用会话保证解决)。第二个事故是「主库返回成功,切换后数据没了」:ack 点在 after_commit,自动切换又提升了另一个落后副本,客户端看到的成功在新主上并不存在。RPO = 0 需要三个条件同时成立:ack 覆盖持久化、提升只选拥有全部提交日志的副本、旧主被 fence 住;缺一个,成功就可能是假的。
主库 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 或撤权。
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 能正确累加,但同样不能阻止「减到负数」;不变量需要额外的协调或约束。
写冲突(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 = 1 | 10:00:00.000 | 快 1 秒 | — | 10:00:01.000 |
| B 写 x = 2 | 10: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)或约束。
若事件 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 听起来先进,就把需要强不变量的业务塞进去。
用户的购物车在两个设备上同时操作:设备 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)是代表。
3.7.1 quorum 的数学:相交是必要条件
设每个 key 有 N 个副本,写需要 W 个确认,读需要 R 个响应。如果
那么任意一个写集合与任意一个读集合必然相交。证明用鸽巢原理:两个集合都是 N 的子集,大小之和超过 N,不可能不相交。
| N | W | R | R + W > N | 含义 |
|---|---|---|---|---|
| 3 | 2 | 2 | 是 | 容忍 1 个副本故障;读写都联系多数派 |
| 3 | 1 | 1 | 否 | 写快读快,但读可能完全错过写 |
| 3 | 3 | 1 | 是 | 写必须全部成功,读一个即可;写可用性差 |
| 5 | 3 | 3 | 是 | 容忍 2 个副本故障;跨 5 个故障域 |
相交只说明「至少一个副本见过这次写」,不说明「读一定能拿到它」。这是本章最容易被跳过的一步,也是面试和事故里最常见的误解。
若一次写已经收到 W 个副本的确认,一次读联系了 R 个副本,且 R + W > N,则读集合与写集合至少有一个公共副本。
这个定理只保证「公共副本存在」,不保证:该副本的写已经持久化、该副本在本次读的响应里、读路径会比较版本、以及该写与并发写之间有全序。四个条件任意一个不成立,读仍可能返回旧值或未完成的值。
3.7.2 R + W > N 的八个边界
下面八条,每一条都能单独让 R + W > N 失效。把它们当成检查表,而不是理论注脚。
| # | 边界 | 具体怎么失效 | 修复 |
|---|---|---|---|
| 1 | 读返回第一个响应 | 读集合里有最新值,但客户端取了先到的旧值 | 读完 R 个,按版本取最大 |
| 2 | ack 早于 fsync | 副本回 ack 后崩溃,写丢失 | ack 前 fsync;或用 remote_apply |
| 3 | sloppy 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 最多保证「至少一个副本知道这次写」,它缺三样东西:
- 全序:无主复制没有 leader/term,两个并发写没有天然顺序;用 LWW 时间戳排序,会引入 3.6.1 的时钟问题。
- 实时序:读可能看到未完成的写,之后该写消失;也可能在写完成前读到它,之后又读不到。线性一致不允许这种「读到的值倒退」。
- 提交语义:W 个 ack 不等于 W 个已提交的版本;如果 ack 在 fsync 之前,或写只到达一个副本就被读出来,都不满足「已完成」的定义。
在单写者、版本单调、读满 R 个并按版本取最大、ack 前 fsync、无 sloppy quorum、无成员变更这些假设同时成立时,quorum 读写可以接近「regular register」的语义:读不会返回比最近完成写更旧的值(除非并发)。但它仍然不是线性一致,因为并发写没有全序,多 key 操作也没有原子性。想把 quorum 变成线性一致,最终还是要引入共识(第 5 章)。
有些文档把 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) # 超时回主库,保底但不放大延迟
这段代码有三个工程细节:
write_lsn必须在主库提交之后取,不能取事务开始时的位置;- 副本比较的是
pg_last_wal_replay_lsn(),不是pg_last_wal_receive_lsn();收到 WAL 不等于 apply; - 超时后回主库,而不是一直等;否则一台慢副本会把整个会话拖死。
真实客户端不要把 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 如何止损:先停写,再选权威,最后对账
双主止损的顺序不能反。很多二次事故是因为「先对账,发现两边都有理,继续写,越对越乱」。
- 停止两边写入:把数据库设为只读,或撤销应用的写权限;不要只靠应用层开关,应用可能有多个写入路径。
- fence 旧 leader:撤销凭证、隔离网络、STONITH,或让存储端拒绝低 epoch 的写;确保它即使恢复也不能写。
- 确定权威一侧:按 term/epoch、已提交日志、quorum 选出的 leader 为准;不要按「哪边数据多」「哪边时间戳新」选。
- 保留分叉证据:把两边的 binlog/WAL、差异行导出,供审计和修复;不要急着删除旧主的日志。
- 按操作 ID 对账:用业务操作号、幂等键、唯一约束比对,而不是逐行 diff;逐行 diff 无法区分「丢失的写」和「合法的旧值」。
- 重建旧主:以权威侧为源,重新做一份副本;不要直接把旧主加回集群继续复制,否则会把分叉带回。
- 复盘触发条件:为什么旧主在失去 quorum 后还能写?租约、fencing、只读开关哪一个失效了?
3.9.3 分裂脑与「设计上的多主」
| 维度 | 分裂脑(事故) | 多主复制(设计) |
|---|---|---|
| 写权限 | 两个 leader 都以为自己唯一合法 | 多个 leader 明确共享写权限 |
| 冲突预期 | 没有预期,通常没有解决机制 | 预期冲突,有检测/合并机制 |
| 数据分叉 | 意外,恢复时才发现 | 常态,按版本/CRDT 合并 |
| 主要风险 | 不变量被破坏、复制线程停 | 合并语义不符合业务 |
| 处理方式 | 停写、fence、选权威、对账 | 检测、合并、补偿 |
分裂脑不是「多主复制的一种」。多主复制至少承认冲突存在,并准备了检测和合并;分裂脑是单主系统在故障切换时意外多出一个写者,通常没有任何冲突处理。把两者混为一谈,会得出「多主本来就会冲突,所以双主没关系」的错误结论。
分裂脑(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_lsn | IO 线程读取位置 | RTT、丢包、跨域带宽、MTU |
| 3 | 备库落盘 | 备库 WAL/relay log fsync | write_lsn - flush_lsn | Relay_Log_Space、relay log fsync | 磁盘慢、IO 争用、fsync 排队 |
| 4 | 备库重放 | apply/startup 进程 | flush_lsn - replay_lsn | Read_Source_Log_Pos - Exec_Source_Log_Pos | CPU、锁、长事务、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 MB | 16–128 MB | 大于 128 MB,或 SQL 线程停止 |
| MySQL GTID 缺口 | 空 | 1–2 个事务且快速收敛 | 持续扩大,或 Last_SQL_Error 非空 |
| Kafka ISR | 全部副本 | 偶发收缩后恢复 | 持续缺少副本,或 under-replicated 大于 0 达 5 分钟 |
阈值只是起点,真正要告警的是「趋势」:lag 持续增长、副本位置不再推进、ISR 反复抖动。单次尖刺通常可以忽略,持续 5 分钟的偏移才值得叫人。
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 已被 fence | B 成为新主,收到 K1 | B 没有 K1 记录 |
| t4 | — | — | 执行扣款,返回成功 | 第二次扣款 |
| t5 | — | A 恢复,binlog 被回放到 B | balance = 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"
关键点:
- 同一事务:去重记录和业务写要么都提交,要么都回滚;分开写会留下「扣了钱但没记录」的窗口。
- 同一复制流:去重表必须和业务表在同一个数据库/同一个日志里;如果它在缓存或另一个集群,failover 可能只带走一半。
- op_id 由客户端生成:服务端生成的话,重试会拿到新 ID;客户端生成并在所有重试中复用。
- 指纹校验:同 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做幂等键,不能只在数据库里去重。
applied_ops 只能保证「数据库里的这条命令执行一次」。如果 effect 里调用了外部支付 API,而 API 调用成功、数据库事务回滚,外部副作用仍然发生了。端到端的「有效一次」需要:数据库事务 + outbox/消息 + 外部幂等键 + 对账。第 6 章和第 7 章会分别从本地事务和消息投递的角度继续这条链。
3.13 本章陷阱清单
- 三副本放在同一个机架:副本数不等于故障域数;一次机架掉电就能全带走。先画故障域,再谈 RPO。
- 把复制当备份:
DELETE会同步到所有副本;复制防机器故障,备份防逻辑错误,两者不能互相替代。 - 以为异步复制 RPO = 0:异步只保证主库本地提交,lag 窗口内的已确认写会在切换时丢。
- 以为配了半同步就一定不丢:ack 点在
after_commit、提升落后副本、旧主未 fence,任意一个都能让「已确认」变成「已丢失」。 - 只看 lag 秒数:
Seconds_Behind_Source和replay_lag在空闲/长事务/时钟偏斜下会失真;用 LSN/GTID/ISR 位置差。 - 以为 R + W > N 就是线性一致:它只保证集合相交,不保证读比较版本、写已持久化、并发写有全序,也不保证不读到未完成的写。
- 读只取第一个响应:读集合里哪怕有最新值,先到的旧值也会被返回;必须读满 R 个并按版本取最大。
- 把 sloppy quorum 当普通 quorum:hint 节点不在 home set,读集合可能完全不相交;读要查 hint 或等回放。
- ack 早于 fsync:副本回了 ack 但没落盘,崩溃后写消失;「写成功」变成了「写曾经在内存里」。
- 先提升后 fence:旧主还持有写权限,新主已接受写,双主窗口被打开;正确顺序是先 fence 再提升(或至少同时)。
- LWW 用应用时钟/
updated_at:时钟偏斜会让后发生的写输给先发生的写,静默丢更新;LWW 只适合可丢旧值的场景。 - 重试换幂等键,或去重表与业务写分开提交:服务端无法识别同一意图;崩溃窗口会留下「副作用已发生、记录不存在」。
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 |
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 是客户端收到响应的时刻:
| # | 进程 | 操作 | inv | resp | 返回 |
|---|---|---|---|---|---|
| 1 | P1 | write(x, 1) | 0 | 10 | ok |
| 2 | P2 | read(x) | 4 | 8 | 1 |
| 3 | P1 | write(x, 2) | 12 | 16 | ok |
| 4 | P2 | read(x) | 18 | 22 | 2 |
| 5 | P3 | cas(x, 2, 3) | 20 | 25 | true |
| 6 | P3 | cas(x, 2, 4) | 26 | 30 | false |
这张表里已经藏着几个容易读错的点:
- 操作 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、新值 v | ok | 把 k 设为 v,总是成功 |
cas(k, e, n) | 键 k、期望值 e、新值 n | true / false | 当前值等于 e 时设为 n 并返回 true;否则不改并返回 false |
操作(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 发起却返回旧值,这才是真的违反线性一致。提交时间在操作区间内部,响应时间才是区间右端。
记录历史时有四个坑:
- 不要用墙上时钟跨机器比较。
time.time()会被 NTP 调整,两台机器的偏差可能是毫秒,也可能是秒。用time.monotonic_ns()在单机记录;跨客户端时先测偏差,只有偏差远小于操作间隔才敢比较。 - 不要只记录服务端的开始/结束。服务端处理时间只是区间的一部分,无法替代客户端的
resp。 - 不要丢掉失败的 CAS。
false也是一次合法的返回;把它当「没发生」会掩盖顺序错误。 - 不要把 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)的直觉是:每个操作看起来都在它的调用与响应之间某个瞬间原子地生效,整个系统像只有一个副本。 直觉很好用,但判断事故时不够精确。精确版本需要存在一个全序。
历史 H 是线性一致的,当且仅当存在一个对所有已完成操作的全序 S,满足:
- 扩展实时序:若
a <rt b,则a在S中排在b之前; - 合法:从初始状态出发,按
S依次执行每个操作,每个操作的返回值都符合该对象的顺序规范(对寄存器而言:读返回此前最后一次写的值;CAS 的返回值与实际状态一致)。
等价说法:每个操作都存在一个线性化点(linearization point),落在它的 [inv, resp] 区间内;所有操作按线性化点排序后,结果与顺序规范一致。
图里只画了一个写和一个读,但已经包含判定所需的全部信息:写 x=1 在 t1 返回,读在 t2 > t1 才开始,读必须返回 1(或更新的值)。读返回 0 就是陈旧读,违反线性一致;读超时只是 unknown,不构成反例,因为待定操作可以被丢弃。
并发允许两种结果。 如果读在写返回之前就发起了(区间重叠),读返回 0 或 1 都可能是线性一致的:把读的线性化点放在写之前,它看到 0;放在写之后,它看到 1。很多人把「线性一致」误读成「读必须立刻看到正在进行的写」,这是错的。线性一致只要求存在某一个合法的全序,不要求读偏向新值。
反例的最小结构是「写已返回,读才开始,却读到旧值」:
| 历史 | 写区间 | 读区间 | 读返回 | 判定 |
|---|---|---|---|---|
| A | write(x,1) [0,10] | read(x) [12,20] | 1 | 线性一致 |
| B | write(x,1) [0,10] | read(x) [11,20] | 0 | 违反:读开始时写已返回 |
| C | write(x,1) [0,10] | read(x) [5,15] | 0 | 线性一致:可把读排在写前 |
| D | read(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)
四点读法:
H_OK的读在写返回之后开始,见证全序只有一种合法排列。H_STALE的读返回 0,但写已完成;任何全序都必须把写放在读前,读应返回 1,所以搜索失败。H_SC在 4.3 节会看到它顺序一致;检查器返回False只说明「不线性一致」,不说明「不一致」。判定器回答的是某个模型,不是笼统的『一致/不一致』。H_CAS说明 CAS 成功会改变状态,后续读必须看到 2;CAS 不是「读一下再写」,而是一个原子操作。
4.2.2 复杂度与工程判定
暴力搜索最坏是 n!,而判定线性一致性在一般情况下是 NP 完全的(Wing 与 Gong, 1993)。真实历史动辄百万操作,不能直接套上面的代码。工程上有四类优化:
| 优化 | 做法 | 代价 |
|---|---|---|
| 按对象分片 | 每个 key 独立检查;线性一致是局部性质 | 跨对象事务另算 |
| 约束传播 | 读的值只能来自某个写;先固定候选写,再查实时序 | 实现复杂 |
| 记忆化 | 对 (已放置集合, 状态) 去重;同一状态不重复搜索 | 状态爆炸时仍慢 |
| 分窗 | 把长历史切成小窗口,分别在每个窗口内检查 | 可能漏掉跨窗反例 |
Jepsen 的 Knossos 用的是这类搜索加剪枝;它报告的是一个具体反例(哪几个操作无法排列),而不是一个概率。工程上更实用的做法是:先用 session 保证、版本号和业务不变量做粗筛,再把可疑窗口喂给检查器。检查器不是证明系统正确,它只能证明「这段有限历史存在/不存在合法全序」。没有反例不等于正确,只等于这段历史没暴露问题。
若系统中每个对象的历史都线性一致,且对象之间没有跨对象的原子约束,则整个系统的历史线性一致。证明思路:把各对象的全序与实时序取并集,做拓扑排序;不同对象的操作可交换,冲突只可能在同一对象内,而同一对象已有合法全序(完整证明见 Herlihy 与 Wing, 1990)。
顺序一致没有这个性质:两个各自顺序一致的对象,合起来可能不满足顺序一致。这也是分片系统敢说「每分片线性一致」的理论依据,但它不保证跨分片快照一致(4.7 节)。
4.2.3 边界:线性一致不保证什么
线性一致只约束单对象的读/写/CAS 历史,不自动提供下面这些东西:
- 跨对象的原子读。分别线性一致地读 A 和 B,合起来可能不是某个时刻的快照。
- 事务的隔离。两个线性一致的写仍可能丢失更新,如果它们是「读-改-写」而不是 CAS。
- 低延迟。线性一致读通常要一次 leader/quorum 往返;跨地域时这往返就是几十毫秒。
- 分区期间的可用性。4.6 节会证明,分区时线性一致和可用性不能同时成立。
- 会话保证的跨副本版本。线性一致对单对象已经蕴含读己之写和单调读(同一客户端的操作在实时序上不重叠);但如果你用的是「每个副本各自线性一致」,把多个副本的读拼起来,会话仍可能回退(4.8 节)。
4.2.4 一次搜索的回溯记录
把 H_STALE 的搜索过程写出来,比看代码更容易理解「为什么不存在全序」:
| 步骤 | 可选操作 | 当前状态 | 说明 |
|---|---|---|---|
| 1 | write(x,1) | x=1 | 读的实时序前驱只有写,必须先放写 |
| 2 | read(x)=0 | x=1 | 读要求当前值为 0,失败;无其他候选 |
| — | — | — | 回溯到步骤 1,仍无其他候选,返回 False |
H_SC 的回溯更能说明「实时序」和「程序序」的差别:
| 步骤 | 可选操作 | 当前状态 | 说明 |
|---|---|---|---|
| 1 | write(x,1) | x=1 | B 的写有实时序前驱 A 的写 |
| 2 | write(x,2) | x=2 | C 的第一次读在 B 写返回之后,B 必须先放 |
| 3 | read(x)=1 | x=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 年提出的模型。它保留了「所有操作存在一个全序」,但删掉了「全序必须尊重实时序」这一条。
历史 H 是顺序一致的,当且仅当存在一个对所有已完成操作的全序 S,满足:
- 尊重程序序:若在同一进程中
a先于b被调用,则a在S中排在b之前; - 合法:按
S执行时,每个操作的返回值符合顺序规范。
注意:S 不需要尊重跨进程的实时序。一个在实时上早已返回的写,可以被排在一个后来才发起的读之后。
最小的「顺序一致但不线性一致」历史就是上面的 H_SC:
| 进程 | 操作 | 区间 | 返回 |
|---|---|---|---|
| A | write(x,1) | [0,5] | ok |
| B | write(x,2) | [6,10] | ok |
| C | read(x) | [11,15] | 1 |
| C | read(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,不一定是线性一致。为了让同一客户端的读不退步,读还要固定副本或带上「已见前缀」令牌;否则第二个读打到更落后的副本,程序序就被破坏了。
| 实现 | 全序来源 | 读路径 | 得到什么 | 代价 |
|---|---|---|---|---|
| 单点序列器 + 全序广播 | 序列器分配序号 | 本地前缀 | 顺序一致 | 序列器是瓶颈/单点 |
| 主库串行日志 | 主库日志顺序 | 本地已应用 | 顺序一致(若读不确认) | 主库故障切换复杂 |
| 每写都等确认 | 主库/quorum | leader 确认 | 线性一致 | 每次读一次往返 |
| 无全序,仅反熵 | 无 | 任意副本 | 最终一致 | 冲突合并 |
注意第三行:「顺序一致」和「线性一致」的差别往往就是读要不要等确认。 等确认就把读放进了实时序,不等就只保证「存在某个全序」。这也解释了为什么很多系统把「强读」做成一个显式选项:默认本地读是 SC/陈旧读,关键读才付一次往返。
顺序一致的另一个代价是全序本身。没有全局日志、没有序列器、没有共识,就没有 SC;多个副本各自按本地顺序应用,得到的是因果或最终一致。SC 不是「不协调的强一致」,它只是「不要求实时确认的强顺序」。
4.4 因果一致性与会话保证
线性一致和顺序一致都要求全序,代价是协调。很多系统退一步:只要求有因果关系的操作被所有副本以相同顺序看到,并发操作可以各排各的。 这就是因果一致(causal consistency)。
先看它要防的那个经典反例。论坛里,Alice 发帖 P,Bob 读到 P 后回复 R,Carol 读到了 R 却读不到 P:
| 步骤 | 进程 | 操作 | 观察到 |
|---|---|---|---|
| 1 | Alice | write(post, P) | ok |
| 2 | Bob | read(post) | P |
| 3 | Bob | write(reply, R) | ok |
| 4 | Carol | read(reply) | R |
| 5 | Carol | read(post) | not_found |
Carol 看到了回复却看不到被回复的帖子,这是「因果倒置」。因果一致禁止的正是这种形状;它不禁止 Carol 在 Bob 回复之前看不到 P(那时 P 和 R 并发),也不要求 Carol 看到 P 的那一刻 Bob 的回复已经存在。
happens-before(→)是满足以下规则的最小关系:
- 程序序:同一进程中先发起的操作
→后发起的操作; - 读到即因果:若读
r读到了写w的值,则w → r; - 传递:若
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 < V2 | V1 <= 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)是最容易被口号化的模型。它的正式承诺其实很短:如果不再有新的写,所有副本最终会返回同一个值。 注意三个词:最终(没有时间上界)、所有(每个副本)、同一个(收敛,不是「都最新」)。
对每个对象 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 s | 10:00:00 | 10:00:05 | W1 |
W2 x=2 | 0 | 10:00:02 | 10: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:
| 时间 | 事件 | A | B | 读到的值 |
|---|---|---|---|---|
| t0 | 客户端写 A,A 返回成功 | 1 | 0 | — |
| t1 | A 在复制给 B 之前宕机 | 1 | 0 | 从 B 读 → 0(陈旧) |
| t2 | 客户端读 B,触发读修复 | 1 | 0 | 读修复无法从 B 修 A(B 没有新值) |
| t3 | A 恢复,反熵交换 Merkle 树 | 1 | 1 | 从 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」不是「所有副本一样」,而是线性一致(原子对象)。
- 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(外部一致性) | C | C(付 TrueTime 等待) | 跨地域提交要等不确定性窗口 |
| etcd(默认线性一致读/写) | C | C(一次 ReadIndex/leader 往返) | 少数派拒绝服务 |
etcd(--consistency=s) | A(可能陈旧) | L(本地读,不协调) | 同一集群,按请求切换 |
| DynamoDB(最终一致读) | A | L | 强一致读可另行指定,按请求付延迟 |
Cassandra(QUORUM 读/写) | A/C 可调(多数派才可用) | L | quorum 提高交集,不自动等于线性一致 |
| MongoDB(v1 + majority + linearizable) | C | C(主库读) | 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:
| 步骤 | 操作 | 副本一致性 | 事务隔离 | 结果 |
|---|---|---|---|---|
| 1 | T1 在 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 L | t-1 已提交,rev=58 | 响应没有到达客户端 | 继续接受新请求 |
| follower F1 | 本地状态停在 rev=57 | rev=58 已经存在 | 用旧值回答读请求 |
这就是第 1 章说的「部分失败」在数据面的版本:每个节点都只掌握了事实的一部分,但每个节点都在基于自己那部分事实做决定。 分区不会「制造旧数据」,它只是让旧数据的存在时间从毫秒级变成无界;真正造成事故的是客户端把「读到的值」当成了「当前的值」。
陈旧读(stale read)是一次读返回了某个已经提交、但已经被更新的写覆盖的值。它与脏读不同:脏读读到的是未提交的数据;陈旧读读到的数据在某个时刻是合法的,只是不是最新版本。
形式化地说,若写 w 在 t_w 返回,读 r 在 t_r > t_w 发起(t_r 晚于 w 的响应),而 r 返回的版本早于 w 写入的版本,则 r 是陈旧读。这个定义默认 w 与 r 访问同一对象;跨对象的「不一致快照」是 4.7 节的问题,不是单对象陈旧读。
分区期间陈旧读的放大路径有五条,按出现频率排序:
| # | 路径 | 机制 | 为什么分区时更严重 |
|---|---|---|---|
| 1 | 异步复制 lag | follower 的 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_revision | key 最后一次修改的 revision | 应等于最近一次写回执的 revision | 小于写回执,说明读到了旧版本 |
raftIndex / raftAppliedIndex | 已提交日志索引 / 已应用索引 | 两者接近;follower 落后 leader 有界 | applied 长期落后,或与 leader 差距持续扩大 |
Status.errors | 成员报告的异常 | 空 | 非空,优先排查成员健康 |
| 延迟 | 本地读 vs leader 确认 | s 通常 0.1–1 ms;l 多一次往返 | l 在无 quorum 时超时,是可用性代价 |
三条读法:
- 线性一致读成功时,结果不能比已经返回的写旧。 如果写回执是 rev=52,读却返回 rev=51,这不是「最终一致」,而是线性一致读的实现 bug(或者你以为发的是
l,实际发的是s)。 - serializable 读落后多少,要有一个上界。 例如「读流量只允许打到 applied lag ≤ 5 s 的副本」;超过就摘除或降级。没有上界的
s读,在分区时可能落后几分钟。 - 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}
服务端根据模式选择读路径,并在响应里带回证据:
| 模式 | 服务端行为 | 响应证据 | 失败时客户端 |
|---|---|---|---|
linearizable | leader/ReadIndex 确认 | revision、served_by | 返回错误,不降级 |
bounded_staleness | 本地读,检查 lag ≤ 上界 | revision、applied_index、lag_ms | 超界则换副本或升级为线性读 |
session | 追到会话 token | applied_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 是单调递增的,但它被锚定在节点的本地墙上时钟上。于是出现了分析里的经典场景:
- 节点 A 是 primary,与集群隔离 10 秒,期间接受
w1、w2,optime 记为 10、11,但无法复制给多数派。 - 另一侧 B 当选 primary,接受
w3,optime 记为 10,并以多数派写确认;w3复制到了 C。 - 隔离解除,A 与 C 选举。A 的 optime 11 大于 C 的 10,A 当选。
- 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 线性一致 |
这份分析给出的教训可以直接迁移到任何系统:
- 「多数派确认」是一个条件承诺,不是无条件事实。 它依赖选举协议不会让一个缺少该写的节点当选;如果选举依赖墙上时钟,时钟偏斜就是数据丢失的故障注入项。
- 读一致性不能修复写丢失。 如果已确认的写被回滚,
linearizable读再强也只能返回「当前存在的值」;它无法让不存在的写复活。 - 协议版本是配置的一部分。 同一个 MongoDB 版本,v0 与 v1 的一致性行为不同;升级/切换协议要和升级代码一样做验证。分析特别指出 v1 不是 Raft,保留了 rollback 等额外复杂度。
- 测试要注入时钟偏斜和分区,而不只是杀进程。 只测「节点崩溃后重启」会漏掉「隔离主库 + 快时钟赢得选举」这条路径。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 章的项目二提供了分区注入接口,按下面的表设计用例:
| 故障 | 注入方式 | 观察 | 期望 |
|---|---|---|---|
| 隔离 follower | fault_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)。于是:
| 时间 | 事件 | A | B | 读/写版本 |
|---|---|---|---|---|
| t0 | 初始 | 100 | 0 | rev=57 |
| t1 | t-1 提交,响应丢失 | 0 | 100 | rev=58 |
| t2 | 客户端超时,生成 t-2 重试 | 0 | 100 | — |
| t3 | t-2 从副本读 A | 0 | 100 | 读到 100,rev=57 |
| t4 | guard 通过,t-2 扣款 | -100 | 200 | rev=59 |
| t5 | 对账发现流水有两笔 100 出账,但用户只授权了一笔 | -100 | 200 | 客户 A 多损失 100 |
注意两个错误是独立的,任何一个被修复都能阻止事故:
- 如果 t-2 复用 t-1 的幂等键,服务端的唯一约束会发现「这个意图已经执行」,直接返回原结果;
- 如果 t-2 的读是线性一致的(或读己之写),它会看到 A=0,guard 拒绝;
- 如果扣款是原子条件写(
balance >= 100),即使 guard 通过,第二次扣款也会失败; - 如果对账把流水与用户授权关联起来,能在 t4 之后立即发现并冻结,或者扣款有「单日单账户最多转 100」的业务约束,也能兜住。
四个「如果」对应四层防御,不能只做一层。幂等键解决「同一个意图被执行两次」,条件写解决「基于旧值的写覆盖新值」,线性一致读解决「看到旧值」,对账解决「前三个都漏了」。 它们防的不是同一件事。
客户端发起 transfer(t-1, A, B, 100)。服务端提交成功,响应丢失;客户端超时后用新键 t-2 重试。重试的读落到 lag 副本,看到 A=100,guard 通过;服务端没有把流水关联到用户授权(或业务去重键),也没有条件扣款,于是再次扣款。结果 A=-100、B=200。
修复分四层:
- 身份:客户端为每个逻辑转账生成
transfer_id,所有自动/手动重试复用它;服务端transfers.id主键唯一。 - 原子写:扣款用
UPDATE ... WHERE balance >= amount或 CAS(版本号),不要先读后写。 - 读:guard 读走 leader/线性一致读,或至少带
applied_index >= 写回执的会话 token。 - 对账:把流水与用户授权/外部账单定期核对;发现同一授权对应多笔出账或账实不符时告警并冻结,而不是等用户投诉。
只做第 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 | 高 | 中 | 键设计/唯一约束 |
一个可执行的五问决策树:
- 读错会不会直接造成资损、超卖、越权或不可逆的外部副作用? 会 → 决策读走线性一致/leader,写走条件更新或事务。
- 能不能用幂等 + 对账把错误变成可修复的? 能 → 可以降一档,但必须真的实现对账和补偿,而不是写在文档里。
- 能接受多旧? 给出毫秒/版本上界;不能给出上界 → 不要选「最终一致」作为决策读。
- 并发写是否可交换/可合并? 可合并 → CRDT/版本向量;不可合并 → 单写者、CAS 或锁(第 9 章)。
- 分区时业务要可用还是要正确? 两者都要 → 把操作拆开:展示读降级、决策写失败关闭;不要指望一个开关同时满足。
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 本章陷阱清单
- 把 CAP 当「三选二」:P 是网络属性,不是产品开关;分区时是在 C 和 A 之间选,无分区时三者可以同时成立,但强一致仍要付延迟(PACELC)。
- 把「最终一致」当成「很快一致」:定义没有时间上界;没有反熵、读修复、tombstone 保留和对账,收敛可能永远不到来。
- 用只读副本读余额再做扣款判断:读是陈旧的,guard 通过不代表余额够;这是 4.11 节二次转账的直接原因。
- 以为开了 majority 写就等于线性一致:MongoDB v0 的案例证明,选举协议依赖墙上时钟时,多数派确认的写仍可能被回滚。
- 把陈旧读当成脏读:陈旧读读到的是已提交的旧值,调高事务隔离级别不会修复;要改读路径(leader/ReadIndex/会话 token)。
- 把线性一致当成串行化:单键线性一致不提供跨键快照,也不阻止丢失更新;多对象原子性要靠事务/条件写。
- 用墙上时钟给跨机器操作排序:时钟偏斜会让「后发生的写」输给「时间戳更大的写」;用逻辑时钟、版本号或单观察者记录。
- 「先读后写」不加 CAS/条件更新:读和写之间的时间窗足够两个请求都通过检查;原子性要落在写路径上。
- 重试时换新的幂等键:服务端无法把第二次请求归并到第一次;键必须由客户端为逻辑请求生成一次,并在所有重试中复用。
- 把会话保证当成全局保证:读己之写/单调读只保护单个客户端;其他用户仍可能看到旧值,跨设备也不自动生效。
- 只在无分区、时钟同步的环境测一致性:Jepsen 的故障注入专门制造隔离主库和时钟偏斜;不注入这两个,测不到选举/回滚类问题。
- 检查器把 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 |
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.6 | 4.9 | 交叉引用 | 明确不重复 TCP/IP、epoll、HTTP/DNS/TLS、Kubernetes |
| 本书自编 + Gilbert & Lynch、Abadi、Jepsen | CAP 证明、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 和所有超时机制的关键。
共识:一组进程对某个值达成一致,满足 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 章) | 共识(本章) |
|---|---|---|
| 解决的问题 | 数据放在几个副本上 | 哪个值/哪条命令排在哪个位置 |
| 典型机制 | 主从、多主、无主、quorum | Paxos、Raft、Zab |
| 安全边界 | 复制 lag、冲突、双主 | 两个 leader、两个值、丢已提交日志 |
| 失败后果 | 读到旧值、写冲突 | 已确认的写丢失、状态机分叉 |
| 与读的关系 | 决定副本是否新 | 决定「已提交」的边界 |
最后强调本章的故障模型:崩溃-恢复(crash-recovery)+ 网络分区 + 部分同步(partial synchrony)。我们不考虑拜占庭节点(会撒谎、会伪造消息),也不考虑磁盘静默损坏。拜占庭共识需要 3f+1 个节点和签名等额外机制,是另一个话题;把崩溃模型下的结论套到拜占庭环境,会低估所需副本数。
5.2 FLP、故障模型与 quorum
初学者常问:「既然异步网络里消息可能丢、可能慢,那共识是不是不可能?」答案是:在完全异步、确定性算法、允许一个进程崩溃的模型下,要求所有正确进程都终止,确实不可能。 这就是 FLP 不可能性定理(Fischer、Lynch、Paterson,1985)。
在一个完全异步的分布式系统中,即使消息可靠、网络最终会送达,只要允许一个进程崩溃,且算法是确定性的,就不存在一个共识协议能同时保证 agreement、validity 和 termination。
注意限定词:完全异步、确定性、一个崩溃、必须终止。少一个限定词,结论就可能不同;多一个「部分同步」假设,工程上就有活路。
FLP 的直觉不复杂:在异步模型里,你无法区分「进程已经崩溃」和「进程只是极慢」。假设协议在某一步必须等某个进程的消息才能决定;调度器可以一直延迟这个消息,让协议停在「还差一步」的状态。如果协议为了终止而选择不等,它又可能在一个「其实还活着、只是消息晚到」的场景里做出错误决定。调度器可以把系统在两种几乎相同的全局状态之间来回摆,让任何确定性协议都无法安全地打破平局。
这不是说「共识不能做」。它说的是:安全性和活性不能同时无条件保证。工程系统的选择是:安全性质永远不妥协(绝不出现两个值、绝不丢已提交日志),活性性质只在「网络最终稳定、时钟漂移有界」的假设下保证。超时、随机化、leader 选举,都是在这个边界上做工程。
| 故障模型 | 进程会怎样 | 网络会怎样 | 本章是否覆盖 |
|---|---|---|---|
| 崩溃-停止(crash-stop) | 崩溃后不再恢复 | 可靠或可能丢 | 是(简化) |
| 崩溃-恢复(crash-recovery) | 崩溃后重启,内存丢失、磁盘保留 | 可能丢、可能分区 | 是(主模型) |
| 遗漏(omission) | 可能漏发/漏收消息 | 可能丢 | 是(分区的一种) |
| 网络分区 | 进程都活着,但互相不可达 | 双向/单向/非传递 | 是 |
| 时钟故障 | 时钟跳变、漂移 | 正常 | 是(lease read 的重点) |
| 拜占庭 | 任意撒谎、伪造 | 任意 | 否 |
超时在共识里扮演什么角色?它不是「证明对方死了」,而是一个不完美的故障检测器:在部分同步假设下,超时最终能发现真正崩溃的进程,但也会误判「只是慢」的进程。误判的代价是多余的选举,不是安全性的丧失——只要投票规则和 quorum 规则不被绕过。把超时当证据,是很多实现错误的起点;把超时当怀疑,才是正确用法。
quorum 是共识的另一个支柱。大多数协议用多数派(majority):N 个节点中需要 floor(N/2)+1 个。为什么是多数派?因为任意两个多数派必然相交。
| N | 多数派大小 | 能容忍的崩溃数 f | 备注 |
|---|---|---|---|
| 1 | 1 | 0 | 没有容错,退化成本地日志 |
| 2 | 2 | 0 | 任何一台挂掉都无法形成多数派 |
| 3 | 2 | 1 | 最常见的入门配置 |
| 4 | 3 | 1 | 成本比 3 节点高,容错没变 |
| 5 | 3 | 2 | 生产常见;可跨三个可用区 |
| 6 | 4 | 2 | 仍然只容忍 2,成本更高 |
| 7 | 4 | 3 | 跨五个可用区时才会考虑 |
把相交性写成计数: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) │ │ │ │
四个消息的语义:
- prepare(n):proposer 请求 acceptor 用一个比 n 小的提案号做承诺。
- promise(n, accepted):acceptor 承诺「不再接受小于 n 的提案」,并返回自己已经接受过的最大 ballot 及其值(如果有)。
- accept(n, v):proposer 请求 acceptor 接受「ballot 为 n、值为 v」的提案。
- 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 相交也救不了。
为什么必须这样做?看一个反例:
假设 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)比较,但两者解决的不是同一个问题:
| 维度 | Paxos | 2PC |
|---|---|---|
| 目标 | 让一组进程对值达成一致 | 让多个资源要么全提交、要么全回滚 |
| 容错 | 少数派故障仍可继续(有 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 的数量和冲突概率:
- 稳定 leader(distinguished proposer):同一时刻只允许一个节点发起提案;其他节点把请求转发给它。这也是 Raft 直接内置 leader 的原因。
- 随机退避:冲突后等待一个随机区间再重试,打破同步脉冲。
- ballot 空间分区:每个 proposer 使用
(epoch, node_id)形式的 ballot,避免两个节点生成相同的 ballot;ballot 相同但值不同会直接破坏安全性。 - 抢占与租约: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-Paxos | Raft |
|---|---|---|
| leader 选举 | 协议之外,靠外部机制或额外 Paxos | 协议内置,term + 随机超时 + 投票 |
| 日志 | 槽位,允许空洞 | 连续索引,强日志匹配 |
| 提交规则 | 每个槽位多数派接受 | 当前 term 规则 + 多数派 matchIndex |
| 成员变更 | 通常靠外部配置管理 | 联合共识/单步变更,写进日志 |
| 读路径 | 需要额外设计(ReadIndex/lease) | 同样需要额外设计,协议不免费提供 |
| 可理解性 | 论文较抽象,实现变体多 | 为可理解性设计,状态机明确 |
这张表不是说 Paxos 更差。Paxos 更通用,适合需要灵活槽位、批量、跨地域优化的系统;Raft 把 leader 选举、日志、成员变更打包成一套更约束的协议,牺牲一些灵活性换取可实现性和可测试性。选型时不要问「哪个更强」,要问「团队能不能把它实现对、测出来」。
5.5 Raft:term、角色、选举
Raft 把共识拆成三个相对独立的部分:leader 选举、日志复制、安全性。它用 term(任期)把所有决策串起来,用随机化选举超时打破平票,用日志匹配性质保证「相同索引 + 相同 term ⇒ 前缀相同」。
Raft 的每个节点在任意时刻处于三种角色之一:
| 角色 | 行为 | 关键状态 |
|---|---|---|
| Follower | 被动接收 AppendEntries/RequestVote,重置选举计时器 | currentTerm、votedFor、日志 |
| Candidate | 超时后自增 term,给自己投票,向其他节点拉票 | 获得票数、选举超时 |
| Leader | 向所有 follower 发心跳和日志,处理客户端请求 | nextIndex[]、matchIndex[] |
term 是一个单调递增的逻辑时钟。每个 term 最多有一个 leader;节点收到更大的 term 时,立即更新 currentTerm 并退回 follower;收到更小的 term 时拒绝。term 不是墙钟,不能用来判断「谁先发生」;它只用来给选举和日志条目划分纪元。
一次选举的步骤:
- follower 在选举超时(例如 150–300 ms 的随机值)内没有收到 leader 的心跳,进入 candidate。
currentTerm++,给自己投一票,重置选举计时器。- 向所有节点发
RequestVote(term, candidateId, lastLogIndex, lastLogTerm)。 - 接收方在以下条件都满足时投票:
term >= currentTerm;本 term 还没投给别人(或已经投给该候选人);候选人的日志不旧于自己。 - 候选人拿到多数票,成为 leader;否则等待新的随机超时,term++ 后重来。
图里 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 interval | 50–100 ms | 必须小于 election timeout 的 1/3;建议留出 5–10 倍余量 |
| election timeout | 150–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 复制日志:
| 字段 | 含义 | 作用 |
|---|---|---|
term | leader 的任期 | follower 用来更新自己的 term |
leaderId | leader 标识 | 方便 follower 重定向客户端 |
prevLogIndex | 新条目前一条的索引 | 一致性检查的锚点 |
prevLogTerm | 新条目前一条的 term | 一致性检查的锚点 |
entries[] | 要追加的条目 | 可以为空(心跳) |
leaderCommit | leader 已知的提交索引 | follower 据此推进自己的 commitIndex |
follower 的处理逻辑:
- 如果
term < currentTerm,拒绝。 - 如果
prevLogIndex超过自己的日志末尾,拒绝。 - 如果
prevLogIndex处的 term 不等于prevLogTerm,拒绝。 - 删除自己日志中与
entries冲突的后缀(相同 index 但不同 term 的条目及其之后的所有条目)。 - 追加新条目。
- 如果
leaderCommit > commitIndex,把commitIndex推进到min(leaderCommit, 新日志末尾)。 - 按顺序应用索引不大于
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 个节点里不可能不相交。一次只改一个,就把「两个不相交多数派」的可能性排除了。
联合共识的流程:
- leader 收到配置变更请求,先把
C_old,new写入日志并提交;从这一刻起,任何决定都需要C_old的多数派 和C_new的多数派同时同意。 - 在
C_old,new提交后,再写入C_new并提交;之后只用C_new的多数派。 - 如果 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 的处理:
- 如果快照的
lastIncludedIndex不大于自己的commitIndex,忽略(自己已经更新)。 - 如果自己的日志在
lastIncludedIndex处有匹配的 term,保留该索引之后的日志后缀。 - 否则清空整个日志,把快照作为新的起点。
- 把状态机重置为快照状态,更新
commitIndex、lastApplied、lastIncludedIndex/Term。 - 继续接收后续日志。
快照最容易出的三个错:
- 快照了未提交的状态:状态机里可能包含还没被多数派确认的命令;恢复后这些命令可能被覆盖,状态就错了。快照必须基于
lastApplied,而lastApplied只能追到commitIndex。 - 截断太多:leader 把 follower 还需要的前缀日志删了,导致 follower 只能全量传快照;网络和磁盘压力骤增。通常要保留一段日志尾部,或者根据最慢 follower 的进度决定。
- 把快照当备份:快照是日志压缩,不是灾难恢复。它和日志在同一块磁盘上,磁盘损坏时一起丢;真正的备份要独立存储、定期校验、可恢复演练(第 3 章)。
崩溃恢复的顺序同样重要:
- 读取最新快照,校验校验和与
lastIncludedIndex/Term; - 把状态机恢复到快照状态,设置
commitIndex = lastApplied = lastIncludedIndex; - 从
lastIncludedIndex + 1开始重放日志,直到日志末尾; - 只有
lastApplied追上commitIndex后,才对外服务读; - 向 leader 确认自己的日志/任期,接受后续 AppendEntries。
如果第 2 步把 commitIndex 设得比 lastApplied 大,又在第 4 步之前服务读,就会读到「提交了但没 apply」的旧状态;如果第 3 步重放时又 apply 了快照里已有的命令,就会重复执行副作用。快照恢复和日志重放必须严格按索引拼接,不能靠「大概对齐」。
5.8 线性一致读:ReadIndex、lease read、follower read
Raft 只保证「日志的顺序」,不自动保证「读是线性一致的」。一个 leader 可能在分区后仍然自认为 leader;一个 follower 可能落后很多;一个本地读可能绕过共识,直接返回旧值。
先把三种读路径摆出来:
| 路径 | 做法 | 线性一致? | 额外 RTT | 关键假设 |
|---|---|---|---|---|
| 本地读 | 直接读本地状态机 | 否 | 0 | 无;可能读到任意旧值 |
| ReadIndex | leader 确认多数派仍承认自己,再读已应用状态 | 是 | 1 次心跳往返 | 多数派可达;不需要时钟 |
| Lease read | leader 在租约内直接读 | 是(在租约假设下) | 0 | 时钟漂移有界、进程不会被长暂停 |
| Follower read | follower 向 leader 要 readIndex,等 apply 后读 | 是(正确实现) | 1–2 次 RTT | follower 能联系 leader;不能本地猜 |
5.8.1 ReadIndex:用一次心跳确认「我还是 leader」
ReadIndex 的步骤:
- leader 必须已经提交过一条当前 term 的日志(没有的话,先提交一条 no-op)。这是为了确保
commitIndex包含当前 term 的屏障,避免读到旧 term 的提交边界。 - 向多数派发送心跳(或带空 entries 的 AppendEntries),确认自己仍然是 leader。
- 记录
readIndex = commitIndex。 - 等待
lastApplied >= readIndex。 - 从状态机读取,返回结果和对应的 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 直接读本地」,而是:
- follower 向 leader 请求一个
readIndex(leader 走 ReadIndex 流程,或返回当前 commitIndex 并附上租约/任期证明)。 - follower 等待自己的
lastApplied >= readIndex。 - 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 压力;接受陈旧 |
| 低延迟、时钟可控、有 CheckQuorum | lease 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」 |
同一条写,在分区前后有三个不同的结局:
- 分区前已经提交:它已经复制到多数派,新 leader 一定包含它(选举限制);客户端收到成功,数据不会丢。
- 分区后打到旧 leader:旧 leader 只能追加、不能提交,客户端收到超时/unknown;这条写可能被新 leader 覆盖,也可能在新 leader 上以新的提案重新提交。
- 分区后打到新 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 触发退位。
所以「分区后的旧主」要分三层处理:
- 提交层:写必须拿到多数派确认才回复成功;旧 leader 不能凭本地日志回复。Raft 的提交规则已经覆盖这一层,前提是实现正确(5.6)。
- 读层:线性一致读必须走 ReadIndex,或者使用有严格时钟/暂停假设的 lease read;串行化本地读要显式标注「可能陈旧」。
- 副作用层:旧 leader 对下游资源的操作必须带 fencing token(term/epoch/lease ID),资源端拒绝更小的 token;否则「旧主还在写」无法靠共识协议本身阻止。
- 真 leader,暂时失联:它还能联系多数派,只是某个 follower 不可达;它不是旧主,读路径仍然有效。
- 少数派 leader,尚未退位:它拿不到多数派,写不能提交;ReadIndex 会失败,但 lease read 可能还在窗口内。
- 已退位但客户端还在打它:它自己知道不是 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 INDEX | leader 最高,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_seconds | WAL fsync 延迟直方图 | 与基线比;p99 持续升高异常 | 磁盘 IO、fsync 争用、云盘抖动 |
etcd_network_peer_round_trip_time_seconds | peer 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,或者干脆把操作放进同一个共识状态机里做条件写。
四条教训:
- 线性一致读要作为故障注入的必测项,不能只测「集群健康时 get/put 对不对」。分区、暂停、时钟偏移下读到的值,才是用户会遇到的。
- 每个 API 单独证明。KV 的线性一致不等于 lock 的互斥,不等于 watch 的 exactly-once,也不等于 lease 的活性;共识是底座,不是万能证明。
- 租约/锁必须有 fencing 或资源端校验。不要把「持有锁」当成「下游一定会接受我的写」;下游要能拒绝旧 token。
- 用可复现的历史检查器。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 也生效了。注意三个细节:
- 去重表和副作用必须在同一次 apply 中更新。如果先扣库存、再异步写
seen,中间崩溃会留下「扣了但没记住」的状态;恢复后重放又会扣一次。状态机 apply 应该是单线程、确定性的,去重表和业务状态一起进入快照。 client_id/seq必须由客户端生成,并在所有重试中复用。服务端生成的自增 ID 每次都会变,无法去重(第 1 章的逻辑请求/物理尝试)。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 小时」了事。
有些 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 本章陷阱清单
- 把共识当成「所有节点都同意」:共识只要求多数派参与决策;要求全体一致会让单机故障阻塞整个系统。
- 把「多数派有日志」当成「已提交」:提交是协议动作;旧 term 条目即使多数派有,也可能被后续 leader 覆盖(5.6)。
- 用 term 大小判断日志新旧:先比最后 term,再比最后 index;只看 term 或只看 index 都会选错 leader。
- 投票后不落盘就回复:重启后重复投票,同一 term 可能出现两个 leader(5.13)。
- 选举超时太小或没有随机化:频繁选举、平票、活锁;超时必须远大于 RTT,并加随机抖动。
- 以为写走 Raft 读就自动线性一致:本地读/串行化读可能陈旧;线性一致读要走 ReadIndex 或受约束的 lease read(5.8)。
- lease read 不看时钟和暂停:GC 停顿、时钟跳变会让旧 leader 在租约过期后继续服务读。
- 成员变更直接切换:旧/新多数派可能不相交,出现双 leader;要用单步变更或联合共识(5.7)。
- 快照未提交状态/截断太多:恢复后状态回退,或慢 follower 只能全量传快照;快照必须基于
lastApplied,并保留必要日志尾部。 - 重试时换 request id:每次重试生成新 ID,状态机无法去重;请求 ID 必须由客户端生成并在所有重试中复用(5.12)。
- 去重只放在网关/leader 内存:leader 切换或进程重启后去重状态丢失,重复提案照样执行。
- 只看
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 8 | 5.6 |
| 设计成员变更 | 能说出单步变更/联合共识的适用场景和风险 | 5.7 |
| 设计读路径 | 能对比 ReadIndex/lease read/follower read 的假设和代价 | 5.8 |
| 处理旧主分区 | 能区分「接受/提交/读/外部副作用」四层,并给出止损 | 5.9 |
| 读 etcd 指标 | 能解释 has_leader、leader_changes、RAFT TERM、apply gap | 5.10 |
| 做状态机去重 | 能写出 (client_id, seq) 去重表,并说明快照/TTL | 5.12 |
如果你要动手做第 13 章的项目二(小型复制 KV),本章的检查顺序是:先实现选举和日志复制,再用「当前 term 提交」规则做提交,然后加 ReadIndex 读,最后加状态机去重和快照。不要先做 lease read:它看起来省了一次 RTT,实际上把时钟/暂停的假设引入了系统,调试成本远高于 ReadIndex。
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);分布式里还要靠复制。这里有一个非常常见的错觉:提交成功不等于数据已经到了多数派。
| 字母 | 单机谁保证 | 分布式里变成什么 | 最常见的破法 |
|---|---|---|---|
| A | undo/rollback + WAL | 2PC 的原子提交,或 Saga 的补偿 | 协调者崩溃、补偿失败、部分提交 |
| C | 约束、触发器 + 应用检查 | 跨服务不变量、对账、补偿 | 只在一个库检查,另一个库不知道 |
| I | 锁 / MVCC | 没有全局隔离,中间状态会外泄 | 把「本地事务」当成「全局事务」 |
| D | WAL fsync + 副本 | quorum 复制或异步复制 | 主库宕机,异步备库还没收到 |
6.1.1 分布式里的 A:不是一个布尔值
单机事务的原子性是二值的:提交了就是全做,回滚了就是全不做。跨服务之后,「全做」需要所有参与者一起提交,「全不做」需要所有参与者一起回滚;而参与者可能在不同机器、不同数据库、不同可用区,任何一方都可能在关键时刻失联。
工程上有三种做法,它们对 A 的保证强度依次递减:
- 2PC(两阶段提交):协调者先问所有参与者「能不能提交」,全部同意后再统一提交。它提供原子提交,但参与者在投票后要一直持有锁,直到收到最终决定;协调者崩溃时,参与者会卡在 in-doubt 状态。2PC 的细节和失败窗口在 6.6。
- TCC(Try-Confirm-Cancel):每个服务提供「预留 / 确认 / 取消」三个动作,Try 先冻结资源,Confirm 真正扣减,Cancel 释放。它把原子性换成了「业务层的预留 + 补偿」,需要处理空回滚、悬挂和幂等。
- 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 不是数据库的一个开关,而是一条链。链上任何一段是异步的,链的末端就可能丢数据。异步复制的主库宕机、备库提升,是生产上「已提交数据丢失」最常见的原因之一。
事务边界(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 锁住所有在岗医生),或者把不变量变成数据库能检查的约束。
变体 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 |
这张表有三个容易踩的坑:
- PostgreSQL 的
READ UNCOMMITTED不是「真的允许脏读」,它被静默提升为READ COMMITTED。想复现脏读,在 PG 上做不到。 - PostgreSQL 的
REPEATABLE READ是快照隔离(SI),不是标准意义上的锁式 RR。它没有幻读,但允许写偏斜;很多团队以为「RR 就安全了」,其实只是换了一类异常。 - 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 的思路是:写不覆盖旧值,而是产生一个新版本;读按自己的快照选择看哪个版本。这样读不阻塞写、写不阻塞读,代价是旧版本要保留、要回收。
图里有一条 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_id | m_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 是否可见;可见才继续往回找,不可见则这个版本仍然有效。
快照隔离(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_ID | xmin、xmax、cid |
| 快照结构 | read view:m_ids/min/max/creator | snapshot:xmin/xmax/xip |
| 旧版本回收 | purge 线程清理 undo | VACUUM/autovacuum 清理死元组 |
| 回收受阻的症状 | History list length 增长、undo 表空间膨胀 | 表/索引膨胀、n_dead_tup 高、vacuum 滞后 |
| 更新方式 | 原地更新聚簇索引,旧值进 undo | 写新元组,旧元组留堆,索引可能新增条目 |
| 二级索引 | 存主键,可见性可能要回表 | 存 TID,指向具体元组;HOT 可减少索引更新 |
| 锁 | 行锁、间隙锁、next-key lock(RR) | 行锁;无间隙锁;SSI 用谓词锁 |
| 序列化冲突 | 靠锁/死锁检测;无 SSI | SSI,返回 40001 |
| 长事务的主要代价 | undo 膨胀、purge 滞后 | 表膨胀、vacuum 滞后、xid 年龄 |
| DDL | 8.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 length | pg_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 TABLE | DDL 等待长事务,后续查询排队 |
| 谓词锁(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 哪些错误值得重试
不是所有数据库错误都能重试。把「唯一键冲突」重试一百次,只会得到一百次冲突;把「语法错误」重试,只会浪费连接。大致分三类:可重试、不可重试、结果未知。
| 错误 | PostgreSQL | MySQL | 能重试吗 | 动作 |
|---|---|---|---|---|
| 序列化失败 | 40001 | —(无 SSI) | 能 | 退避后重试整个事务 |
| 死锁 | 40P01 | 1213 | 能 | 退避后重试整个事务 |
| 锁等待超时 | 55P03(lock_timeout) | 1205 | 能,但要先查原因 | 缩短事务/调整顺序,再重试 |
| 唯一键冲突 | 23505 | 1062 | 不能(除非是幂等插入) | 当作「已存在」处理 |
| 语法/类型错误 | 42601/22P02 | 1064 | 不能 | 修代码 |
| 连接断开 | 08006 | 2013 | 能,但结果未知 | 重连 + 幂等键,按 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()))
这段代码有四个刻意的选择:
- 只捕获
psycopg.Error并检查sqlstate,不靠错误消息文本匹配;消息会随版本/语言变化。 - 重试整个
conn.transaction()块,而不是块里的某条语句;这样每次重试都是一个干净的事务。 - 退避带抖动,否则一批同时死锁的请求会在同一时刻再次相撞。
- 最多 5 次,并且没有无限循环;重试耗尽后把错误抛给上层,由上层决定降级还是返回用户。
如果连接本身断了(08006),上面的 with psycopg.connect(...) 不会自动重连;生产代码需要在外层重建连接,并且用幂等键保证「断线前的提交可能已经生效」不会变成重复副作用。这一点在 6.8 和 6.11 会展开。
6.5.5 减少死锁的四条规则
- 统一加锁顺序:所有事务按主键/账户 ID 排序后依次加锁;不要一个按 A→B、一个按 B→A。
- 缩短事务:把网络调用、文件 IO、用户交互挪到事务外;事务里只留必要的读写。
- 一次锁够:能一条
UPDATE ... WHERE完成的,不要先SELECT再UPDATE;能批量更新的,不要逐行循环。 - 设置超时:
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;能用本地事务 + 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 的保证是「最终一致」,不是「原子」。它有四个必须承认的事实:
- 中间状态对外可见:T1 创建了订单、T2 还没扣款时,别的请求能看到这个订单。要用业务状态(
PENDING/RESERVED)把它标出来,让其他操作知道「还没完成」。 - 补偿不是回滚:退款不是「撤销扣款」,而是一笔新的正向交易;它可能失败、可能被风控拦截、可能产生手续费。补偿动作必须幂等,并且要能重试。
- 没有隔离:另一个事务可能读到中间状态并据此做决定。工程上用「语义锁」(semantic lock)来处理:在业务层标记「这个实体正在事务中」,其他事务要么等待,要么拒绝。
- 补偿也可能失败:
C3失败时,需要有重试、死信、人工介入。Saga 的尽头往往是一张对账工单,这不是耻辱,而是设计的一部分。
6.6.5 怎么选
| 方案 | 原子性 | 隔离性 | 延迟 | 复杂度 | 适用 |
|---|---|---|---|---|---|
| 2PC/XA | 强原子提交 | 参与者本地隔离,无全局快照 | 高(2 RTT + fsync) | 中 | 跨库短事务、同构数据库、能接受阻塞 |
| TCC | 业务级原子 | 靠预留实现 | 中 | 高(三个接口 + 控制表) | 支付、库存、座位等资源预留 |
| Saga | 最终一致 | 无,需要语义锁 | 低 | 中高(补偿链 + 对账) | 长流程、跨多个服务、能接受中间状态 |
| Outbox | 本地原子 + 最终一致 | 无 | 低 | 低 | 事件通知、异步解耦(6.7) |
选型时先回答三个问题:
- 这个操作能不能接受「中间状态被看到」? 不能,就别用 Saga;考虑 2PC 或把数据放到一个库里。
- 参与方能不能长时间持锁? 不能,就别用 2PC;考虑 TCC 或 Saga。
- 补偿失败时,业务能不能人工介入? 不能,就别把 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 就不会重复」:outbox 只保证「业务数据和消息一起提交」,不保证「消息只被消费一次」;重复仍然会发生,去重必须在消费端做。
- 「outbox 可以随便删」:删除已发布记录前要确认所有消费者都已处理;保留窗口太短,重放/补数就没有依据。
- 「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 成功 | 已提交 | 扣款已生效 |
| t3 | 1 s 超时,重试同一请求 | 网关/连接已断,响应没回去 | — | 客户端认为失败 |
| t4 | — | 收到重试,视为新请求 | BEGIN;再扣 A;再加 B;再插一条 | — |
| t5 | — | COMMIT 成功 | 两条 transfers | A 少了 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 发消息,消息重发会让下游再执行一次。
所以完整的修复通常是三层:
| 层 | 机制 | 挡住什么 | 挡不住什么 |
|---|---|---|---|
| 接入层 | 客户端幂等键 + 服务端去重表 | 同一意图的重复请求 | 客户端换键、用户重复点击 |
| 存储层 | 唯一约束 + 同事务写业务/去重记录 | 同一键的重复写入 | 外部调用、跨库效果 |
| 业务层 | 状态机 + 对账 + 补偿 | 中间状态、遗漏、错账 | 需要人工介入的极端情况 |
某支付服务在高峰期出现「用户投诉扣了两次」。排查发现:客户端超时 1 s,SDK 自动重试一次;服务端 transfers 表没有 request_id,每次请求都插入新行;两次请求相隔 1.2 s,都成功提交。数据库日志显示两条 transfers,金额相同,时间相差 1.2 s。
修复分三步:
- 客户端在用户点击时生成
request_id,所有自动/手动重试复用它; - 服务端建唯一索引
transfers(request_id),先插入、再扣款,冲突时返回原记录; - 对历史数据按
(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_age | OLTP < 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 | 偶尔有 | 频繁出现 | 加锁顺序/热点行问题 |
- 先找最老的事务:
xact_age/trx_started排序,看它在等什么、持有什么。 - 再找锁等待的根:
pg_blocking_pids/data_locks,顺着链找到 blocker;被阻塞者只是受害者。 - 最后看趋势:回滚率、死锁数、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;跨大洋的写延迟是物理距离决定的,不是软件能优化的。
- 只读副本落后:快照读不阻塞,但读到的可能是旧时间点;需要「读最新」时,仍要选足够新的时间戳。
五条可以带走的教训:
- 原子提交和一致性是两件事:2PC 解决「一起提交」,TrueTime 解决「时间顺序」,缺一不可。
- 不确定性可以被等待消除:commit-wait 的本质是用延迟换确定性;这个交易在很多系统里都成立。
- 协调者必须高可用:2PC 的单点不是理论问题,是值班时真会遇到的阻塞。
- 只读路径可以绕过 2PC:快照读是 Spanner 性能的关键;写路径的代价不能套到读上。
- 不是所有系统都能复制这套方案:它依赖 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;
这个结果是候选集,不是判决书:同一秒内的同额流水可能真是两笔。确认重复后用冲正流水抵消,不要物理删除,否则审计链和「余额 = 流水之和」都会断。
退款服务收到一条「退款 100 元」的消息,执行 INSERT INTO ledger (account_id, amount) VALUES ('A', 100),提交成功,然后给消息队列回 ack。ack 在网络上丢了,消息队列重投;服务再次插入,又成功。结果 A 账户多了 200 元。
修复:
- 消息带
event_id,消费端在同一个事务里写processed_events和ledger; ledger.request_id唯一,重放时ON CONFLICT返回原记录;- 对账任务按
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 本章陷阱清单
- 把超时当失败直接重试:超时只说明本地不再等待,服务端可能已提交;必须带幂等键进入 unknown,而不是换新键重发。
- 在数据库事务里调用外部服务:回滚不会撤销已发出的扣款/发券/短信;外部调用要移到事务外,或用 outbox/TCC。
- 以为 RR 就没有写偏斜:快照隔离只保证读一致,不保证跨行不变量;值班、预算、库存都可能被两个「各自合法」的事务破坏。
- 在同一个事务里重试单条 SQL:PostgreSQL 语句失败后事务已 aborted(25P02),必须先回滚再重开。
- 重试所有数据库错误:唯一键冲突、语法错误、类型错误重试一万次也不会成功,只会浪费连接和放大过载。
- 用自增 ID 当幂等键:自增 ID 是服务端生成的,每次重试都是新值;键必须由客户端在首次发送前生成。
- 唯一约束建了,但代码在冲突后继续扣款:
ON CONFLICT DO NOTHING不返回行时,应用必须返回原结果,而不是继续执行副作用。 - 把 2PC 当银弹:2PC 只保证原子提交,不保证隔离、不保证高可用;协调者崩溃时参与者会阻塞在 in-doubt。
- Saga 补偿不幂等:补偿动作会被重试,退款/解冻重复执行就是二次事故;补偿必须带幂等键。
- TCC 不处理空回滚和悬挂:Cancel 先到、Try 后到都会留下永远不释放的预留资源;事务控制表要记录
cancel并拒绝迟到的 Try。 - outbox 发完就删:保留窗口短于消费端的最大重投递时间,重放/补数就没有依据;删除前确认消费位点。
- 只看
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 |
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.11 | 6.9 | 交叉引用 | 明确不重复 TCP/IP、socket、epoll |
| 本书自编 + 数据库教材/论文 | Gray 2PC、Garcia-Molina Saga、Spanner OSDI 2012 | 6.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 开始了」。
队列(queue)把每条消息当作一个待办任务:broker 持有消息状态,消费者 ack 后删除;未 ack 的消息在可见性超时后重新投递。日志(log)把每条记录当作一个不可变的事实:只能追加,按分区内的 offset 排序,broker 按保留策略删除整段,消费者组通过提交 offset 声明自己的进度。
两者的核心差异不是「有没有分区」,而是消费进度由谁拥有:队列由 broker 拥有,日志由消费者拥有。进度归谁,谁就负责重复与丢失的语义。
日志里的 offset 是一个 64 位整数,表示记录在分区内的位置。记录一旦写入,offset 就固定;新记录追加在末尾。消费者提交的 committed offset 是「下一条要读的位置」,不是「最后一条读过的位置」。如果处理完 offset 5 之后提交,提交的值是 6。
图中消费者处理到 msg 5,但只提交到 4。按图里的「最后处理位点」口径,4 及之前已确认,重启从 5 开始读,msg 5 被再处理一次。Kafka API 的 committed offset 是「下一条要读」,同一个状态提交的值是 5;两种口径差一位,读监控、日志和文档时必须先确认用的是哪一种,否则会多算或少算一条。「处理成功」和「提交成功」是两个动作,两者之间有一个无法消除的窗口;这个窗口就是本章后面所有去重逻辑的起点。
还有三个位置需要区分,它们经常被混为一谈:
| 位置 | 含义 | 谁维护 |
|---|---|---|
| LEO(log end offset) | 分区末尾下一条记录的 offset | broker |
| 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 放在业务事务提交之前,进程崩溃后消息已删、业务没做,形成丢失。日志的 commit 同理:它只表示「这个消费者组读到这里了」,不表示副作用已经落库。ack/commit 是投递层的确认,不是业务层的确认;两者之间必须有一个幂等或事务边界来兜底。
7.1.1 什么时候该选哪一个
两种模型没有优劣,只有匹配。把业务问题压缩成五个问题,答案会指向其中一边:
| 问题 | 偏向队列 | 偏向日志 |
|---|---|---|
| 一条消息只该被一个 worker 处理? | 是 | 否(多个消费者组各自读全量) |
| 处理完还需要回看/重放? | 很少 | 经常 |
| 需要严格的全序还是按 key 有序? | 单队列 FIFO | 分区内有序 |
| 消费者出错后,重投递由谁负责? | broker(可见性超时/DLQ) | 消费者(offset + 幂等) |
| 峰值吞吐靠什么扩? | 增加竞争消费者 | 增加分区 |
典型的错配是把「任务」放进日志:任务要求「一个任务只执行一次」,日志却允许多个消费者组各读一遍;要做对,必须给任务加唯一 ID、去重表和完成状态,等于在日志上重建队列。反过来,把「事件流」放进队列:事件需要回放给新消费者、需要保留一周做审计,队列 ack 就删,做不到;只能靠 DLQ 和额外的归档表拼出来。
一个折中的判断方法是问「这条消息的价值在消费之后是否还存在」。任务完成后价值就结束了,队列合适;事件是事实,消费之后仍然对审计、重放、新消费者有价值,日志合适。如果一条消息既需要「只做一次」又需要「永久可回放」,通常要用日志 + 幂等消费,而不是队列 + 归档。
7.2 投递语义:最多一次、至少一次、有效一次
讨论「消息会不会丢」之前,先接受一个事实:发送方无法区分「消息没到」和「消息到了但确认丢了」。这是第 1 章 unknown 状态在消息系统里的翻版。面对这个不确定性,只有两种选择:
- 不重试:最多一次。确认丢了就丢了,至少不会重复。
- 重试:至少一次。确认丢了就再发,重复由接收方处理。
「恰好一次投递」在不可靠网络上做不到;工程上说的 exactly-once 指的是恰好一次效果(effectively once):消息可能被投递多次,但副作用只发生一次。这个区别不是文字游戏,它决定了你在哪一层写去重代码。
最多一次(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 次重复。重复的规模取决于「一次重放涉及多少条」和「处理器有多不幂等」,而不是取决于「重复率」。
| 场景 | 重放记录数 | 不幂等比例 | 重复效果 |
|---|---|---|---|
| 单条失败重试 | 1 | 1% | 0.01 次 |
| 一批 500 条重放 | 500 | 1% | 5 次 |
| 5 分钟分区重放(1 万/s) | 3,000,000 | 0.1% | 3,000 次 |
| 全量重放一天(1 亿条) | 100,000,000 | 0.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 集中,单分区 lag | rebalance 变慢,恢复时间变长 |
| 单分区文件大,保留/压实变慢 | 每分区批次变小,吞吐下降 |
| 扩容时要重映射 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 的回调里必须用同步提交,否则分区已经转走,异步提交会直接失败。
已提交 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 | 不等待,发出去就算成功 | 可能丢 | 最高 | 可丢的指标/日志 |
1 | leader 本地写入 | 未复制的部分丢失 | 高 | 可容忍少量丢失 |
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 之前、且事务已提交的记录,未提交事务之后的记录一律不可见。
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) 去重,重复的批次直接丢弃,同时保证分区内的顺序。
它有三个边界:
- 只在同一个 producer 会话内有效。producer 重启会拿到新 PID,之前的 sequence 记录不再匹配;跨会话的重复仍然要靠业务去重。
- 只覆盖 Kafka 内部的写入。它不知道你的数据库、HTTP 调用有没有重复。
- 有配置前提:需要
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 + LSO | Kafka 内部 | 外部存储的重复 |
| 端到端有效一次 | 上述机制 + 业务幂等 | 整条链路 | 需要业务自己实现 |
这张表的最后一行是关键: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 这些事件依次应用的结果;要查当前状态,可以重放,也可以定期做快照。
这带来两个好处:完整的审计历史,以及「回到任意时刻」的能力。也带来两个硬问题:
- schema 演进。事件一旦写入就不能改,字段改名、类型变化、语义修正都要靠版本化和 upcaster(读旧版本、转新版本)。不像数据库可以
ALTER TABLE回填,事件日志里的旧事件永远在那里。 - 重放副作用。重放
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 连接器读的是数据库日志,日志里的每一次变更都会被投递一次。连接器重启、位点提交失败、快照与增量重叠,都会导致重复。CDC 只是把「业务代码写 outbox」换成了「数据库日志自动产出」,至少一次的语义没有变。
- 「有了 outbox 就不会重复」:outbox 只保证业务行和消息行同事务,relay 仍然是至少一次;重复必须在消费端去掉。
- 「CDC 不需要业务改造,所以没有顺序问题」:CDC 按日志顺序读,但消费端多实例、分区重平衡都会打乱处理顺序;顺序要落到分区键上。
- 「事件溯源就是把所有表改成 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 放进重试队列,继续处理 e2 | cancelled |
| 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。
订单服务发布两个事件,但用了不同的 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,而不是假设事件一定按业务顺序到达。
顺序修复通常有三种手段,按成本从低到高:
- 版本号/序号:每个聚合维护单调
seq,消费者保存last_seq;seq <= last_seq直接跳过,seq == last_seq + 1应用,出现空洞则缓存或回退重放。缓存要有上限,否则一个永远不来的事件会让内存无限增长。 - 状态机:
cancelled是终态,迟到的created只能记录为异常事件,不能把状态改回 active。状态机比序号更懂业务,但需要为每个聚合类型单独设计。 - 数据库 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) | 持续增长是正常的;突然下降说明日志被截断/重建 |
LAG | LEO - 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 很高但完全平坦 | — | 消费者卡死或分区无 owner | CONSUMER-ID、线程栈、下游连接 |
| 单个分区 lag 高 | — | 热 key / 分区倾斜 | key 分布、分区数 |
| lag 为 0 但下游延迟/重复 | — | 提交早于处理,或重放 | 提交策略、幂等记录 |
lag 为 - | 新组首次启动前 | 组空了、offset 过期 | --state、auto.offset.reset |
Isr < Replicas | 短暂落后 | 副本故障/磁盘慢 | broker 日志、磁盘、网络 |
PreparingRebalance 长时间 | — | 成员卡在 poll | GC、下游超时、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 是否危险,要同时看三个东西:趋势(在涨还是在降)、速率(生产速率和消费速率)、分布(是所有分区都涨,还是某一个)。一个稳定的 10 万 lag 可能完全健康;一个从 0 开始每分钟涨 100 的 lag 才是事故前兆。
7.8.1 把 lag 换算成时间
lag 只有换算成「追上要多久」才有决策价值。假设当前 lag 是 L,消费速率是 c 条/秒,生产速率是 p 条/秒,那么追平时间近似为:
如果 c <= p,分母为零或负数,lag 永远不会自己降下来——这时加消费者、限流生产、或者降级下游,必须做其中一个,否则积压只会越来越大。
| lag | 消费速率 c | 生产速率 p | 追平时间 | 判断 |
|---|---|---|---|---|
| 470 | 100/s | 80/s | 23.5 s | 正常波动 |
| 10,000 | 1,000/s | 500/s | 20 s | 可接受 |
| 100,000 | 1,000/s | 900/s | 1,000 s ≈ 16.7 min | 接近告警线 |
| 100,000 | 1,000/s | 1,000/s | 永不 | 必须干预 |
| 1,000,000 | 5,000/s | 4,000/s | 1,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 万 replica | Cruise 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 更划算。
从公开材料里能提炼出四条可迁移的做法,和规模无关:
- 分区预算是容量决策,不是创建 topic 时的随手填。分区数决定并行度,也决定 controller 元数据、rebalance 时长和恢复时间;LinkedIn 的 700 万分区需要定制 controller,普通团队即使只有几千个分区,也应该把「分区总数」当成一个要监控的配额。
- 保留和复制是按 workload 定的。活动追踪、指标、数据库 CDC 的保留和副本要求不同,混在一个集群里只能取最严格的那个,成本会失控;按用途隔离集群,才能让每个集群的 retention/replication 匹配它的 SLO。
- rebalance 要自动化,不要靠 SRE 手工 reassign。broker 故障是常态,Cruise Control 这类工具持续看磁盘、网络、CPU,自动迁移分区;没有自动化,集群越大,人工操作越慢、越容易出错。
- 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 重放,用户被扣了两次。
| 时间 | 消费者/系统 | 动作 | 持久化状态 |
|---|---|---|---|
| t0 | C1 | poll 到 500..509,已提交 offset=500 | committed=500 |
| t1 | C1 | 处理 500:插入 charges 行(自增 ID),用 uuid4() 生成 K1,调用 PSP 扣款成功 | 一条 charge,PSP 已扣 |
| t2 | C1 | 写账本行,准备提交 offset | 账本一条 |
| t3 | C1 | 发版/GC,超过 max.poll.interval.ms 没有 poll | 分区即将被收走 |
| t4 | coordinator | generation 42→43,P0 分配给 C2 | C1 成为僵尸 |
| t5 | C1 | commitSync(501) 返回 CommitFailedException | committed 仍是 500 |
| t6 | C2 | 从 500 重放,再处理 500 | 又一条 charge |
| t7 | C2 | 生成新的 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。否则「修复重复」会制造新的重复。
某钱包服务在发版期间触发 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 是幂等的:效果只有一次。有效一次不是「不重放」,而是「重放不产生第二次效果」。
提交失败只是暴露了「处理位点领先于提交位点」这个事实。真正决定用户是否被扣两次的,是幂等键是否稳定、业务表是否有唯一约束、外部调用是否可查询。把 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"
}
}
信封里的字段各有职责,不能互相替代:
| # | 字段/决策 | 为什么 | 合格的样子 |
|---|---|---|---|
| 1 | event_id | 去重、追踪、幂等键的候选 | 全局唯一、写入时生成、永不改变 |
| 2 | type | 消费者按类型路由 | 过去式事实:order.paid,不是 payOrder |
| 3 | version | schema 演进 | 整数,单调递增;消费者能处理未知版本 |
| 4 | occurred_at | 业务发生时间 | UTC,带时区;不是入库时间 |
| 5 | producer | 排障与归属 | 服务名 + 版本号 |
| 6 | aggregate_id | 一致性边界 | 订单号/账户号,和业务主键一致 |
| 7 | aggregate_seq | 顺序与旧事件拒绝 | 每个聚合单调递增;有空洞时消费者能识别并等待/回退 |
| 8 | partition_key | 顺序保证的范围 | 通常等于 aggregate_id;不要为 null |
| 9 | payload | 业务事实 | 自包含、可读、不含内部自增 ID |
| 10 | schema | 跨语言契约 | Avro/Protobuf + registry;有兼容性检查 |
| 11 | 幂等键 | 外部副作用去重 | event_id 或业务键,重试不变 |
| 12 | 保留/重放 | 消费者能追多久 | 保留期 ≥ 最大停机 + 重放时间;有归档 |
| 13 | PII | 合规与成本 | 最小化、脱敏/加密、可删除 |
| 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 | 聚合/投影 |
把命令当事件发,会让下游以为「请求一定成功」;把事件当命令发,会让下游重复执行。事件用过去式,命令用祈使句,命名是最便宜的类型检查。
分区键的规则可以压缩成四句话:
- 同一聚合的所有事件用同一个键,通常是
aggregate_id; - 键一旦发布就不要改;要改就双写新旧 topic,做受控迁移;
- 有序流程不要用 null key;null key 的 sticky 分区没有 key 级顺序;
- 键的基数要足够高,避免把大部分流量压到一个分区;热 key 要单独设计。
schema 演进要按「旧消费者读新事件」和「新消费者读旧事件」两个方向检查:
| 变更 | 兼容性 | 做法 |
|---|---|---|
| 新增可选字段 | 双向兼容 | 给默认值;旧消费者忽略 |
| 新增必填字段 | 破坏 | 不要做;改成可选 + 服务端默认 |
| 删除字段 | 对新消费者破坏 | 先标记 deprecated,观察使用量再删 |
| 改字段类型 | 破坏 | 新增字段 + 双写/双读 |
| 改枚举值 | 可能破坏 | 消费者保留 unknown 分支,不要直接 raise |
| 改语义 | 破坏 | 新增 type 或 version,不要原地改 |
保留和重放要一起设计。保留期至少覆盖「消费者最长停机时间 + 全量重放时间」;超过这个窗口的数据要归档到对象存储,并保留可重放的格式。压实 topic 适合保存「每个 key 的当前状态」,不适合保存「所有历史事件」;墓碑的保留时间(delete.retention.ms)要大于消费者的最大停机时间,否则删除事件会先消失,消费者永远不知道某个 key 被删了。
「订单更新了,自己去查」不是事件,是通知:它把一致性责任推给了消费者,消费者还要猜「更新了什么、顺序是什么、查到的是不是新的」。事件应该携带足够自包含的事实(金额、状态、发生时间),让消费者不依赖回查就能做出决定;回查只能作为补充,不能作为主路径。
7.12 本章陷阱清单
- 把 offset 当消息 ID:offset 只在单个分区内有意义,压实后还会出现空洞;跨分区不可比较。事件 ID 要独立生成。
- 以为「Kafka 是至少一次」应用就至少一次:自动提交 + 异步处理实际是最多一次;语义由提交策略决定,不由 broker 决定。
- 先提交 offset 再处理:进程在处理前崩溃,消息永远不会再来;只有可丢的指标才适合这种写法。
- 处理成功、提交失败后不幂等:这是最常见的重复来源;提交失败是常态,必须假设每次处理都会被重放。
- 用处理时生成的 UUID 当幂等键:每次重试都是新键,外部系统无法识别为同一次;键要在首次调用前确定并复用。
- 增加分区后假设顺序还在:
hash(key) % N变了,同一 key 的历史和未来落在不同分区;要么接受乱序并用版本号,要么做受控迁移。 - 用 null key 还指望同一用户有序:null key 走 sticky 分区,同一用户的事件可能落在任意分区;有序流程必须显式指定 key。
- 消费者数超过分区数:多出的消费者分不到分区,只是空闲;并行度上限是分区数,加消费者不等于扩容。
- 把
acks=all当不丢:ISR 缩水后all等于1;min.insync.replicas=1、同机架副本、unclean leader election 都会让已确认写丢失。 - 认为 Kafka 事务覆盖数据库/HTTP 副作用:事务只覆盖 Kafka 的分区和 offset;外部写要靠 outbox、幂等键或对账。
- 重试 topic 不按原 key 分区:主流程不再阻塞,但同一聚合的事件在重试 topic 里乱序;重试也要用同一个分区键。
- 保留期短于最大停机时间:消费者恢复时 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 |
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.11 | 7.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 是时间点快照 |
| Multipart | initiate / 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_id | 403/409,记录审计 |
| 元数据写入 | upload_id 唯一约束 | 冲突时返回已有记录 |
对象是不可变的字节序列加元数据,由一个 key 指向当前版本。ETag 是当前版本的不透明校验器(opaque validator):它通常随内容变化,但不保证是 MD5,也不保证跨实现可比。条件写把 If-None-Match/If-Match 带进写请求,让「读某个版本 → 基于它修改 → 写回」变成一次 compare-and-swap;失败返回 412,而不是静默覆盖。
单次 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 并发跑,最后看内容是不是「后完成者」,而不是「后发起者」。实验请在测试桶做,别在生产桶上刷请求。
强一致(这里指单 key 读后写)回答的是「我读到的版本,是不是不早于某次成功写」。原子性回答的是「多个 key 的修改是不是全有或全无」。新鲜度回答的是「当前版本是不是最后一次用户意图」。S3 的 2020-12 变化只补了第一个;第二个要靠事务/manifest,第三个要靠条件写、版本号或业务排序。
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 边缘,未命中再到服务缓存(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-aside | read-through | write-through | write-behind |
|---|---|---|---|---|
| 缓存全挂 | 读退化到 DB,可能压垮 DB | 读路径直接失败 | 读写都失败 | 写仍成功,但数据在缓存里丢 |
| DB 全挂 | miss 失败;命中仍可读 | loader 失败;命中仍可读 | 写失败;缓存可能已更新 | 写成功,积压到 DB 恢复 |
| 写缓存失败 | 写库成功,旧值留到 TTL | 取决于写路径 | 写请求失败,DB 可能已提交 | 写请求失败 |
| 进程崩溃 | 无未持久化数据 | 无未持久化数据 | 无未持久化数据 | 未刷盘写丢失 |
这张表说明:「缓存挂了怎么办」不是一句话,要按模式分别回答。cache-aside 要限流 DB;read-through 要有 fallback;write-through 要处理部分成功;write-behind 要接受丢数据或做持久化队列。
cache-aside:应用负责读缓存、回源、回填;写数据库后失效缓存。read-through:应用只读缓存,缓存层负责未命中时回源和回填。write-through:写请求同步更新缓存与数据库,两边都成功才返回。write-behind:写请求只更新缓存,异步刷数据库。前两者的缓存是「副本」,write-behind 的缓存是「未持久化的主数据」。
把缓存当成数据库的物化视图,会自然接受三件事:它可能旧(TTL/失效窗口)、可能缺(未命中/驱逐)、可能被重建(重启/扩容)。这三件事都不是 bug,除非业务要求「读到的必须是最新」。真正要设计的是:旧多久可以接受、缺了从哪里补、重建时源站扛不扛得住。
8.4 缓存一致性:TTL、失效、双写、延迟双删
缓存一致性不是「缓存和数据库强一致」。大多数系统做不到,也不需要;需要的是两句话:业务能容忍多旧的读,以及旧值最长能活多久。前者是产品决策,后者是工程实现。把这两句话写下来,比争论「要不要强一致」有用得多。
失效窗口(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,根本不需要失效。
| 方案 | 旧值窗口 | 失败时 | 代价 |
|---|---|---|---|
| 只靠 TTL | 0–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 | 边缘返回 stale | SWR 窗口 | HTTP 静态/半静态 |
| Redis 锁 | 跨进程串行回源 | 锁 TTL | 源站极敏感 |
击穿:单个热点 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 ← 每个用户一个副本,既浪费又可能串数据
cache key 是缓存用来判断「两个请求是否同一个对象」的字段组合;它决定命中率,也决定会不会把 A 的内容给 B。purge 是命令式失效,把指定 URL/tag 在边缘标记为不可用,传播是异步 fan-out。stale-while-revalidate 允许在 freshness 过期后的 N 秒内先返回旧值,同时由一个请求去源站刷新;它用「有界陈旧」换「回源峰值和尾延迟」。
对象存储的强一致不会自动 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 陈旧链:写成功了,读还是旧
陈旧链通常从一次「看起来成功」的写开始:
- 数据库提交新值;
- 删除缓存失败,或者只删了 CDN、没删服务缓存;
- 读者在删除前 miss、读到旧值,删除后把旧值回填;
- 旧值留到 TTL,或者留到下一次有人手动删;
- 用户刷新、客服清缓存,可能触发一次全站 purge,把陈旧问题升级成回源风暴。
每一步单看都合理:写数据库成功了;删除失败被 catch 住、记了日志;读者只是按 cache-aside 的逻辑回填;TTL 还没到。合起来就是「数据库里是新价格,用户看到旧价格」。这类问题的排查难点是「每一层都显示成功」:CDN 显示 HIT(它确实有内容),Redis 显示有 key(它确实没到期),源站显示 200(它确实返回了当前版本)。
8.7.2 回源链:miss 自己会放大
回源链是一个正反馈:
- 大量 key 同时失效(统一 TTL、缓存重启、分片故障、全站 purge);
- 并发 miss 打到源站;
- 源站排队,p99 上升;
- 客户端超时,重试;
- 回填请求也超时,缓存仍然没有值;
- 回到第 2 步,而且这一轮请求更多。
这条链的可怕之处是:缓存失效本来是一次性的,但回填失败让它变成持续性的。只要源站还在超时,缓存就填不上;缓存填不上,源站就不会恢复。止损的关键不是「再扩容一点」,而是打断反馈:限流回源、让部分请求拿 stale、合并 miss、给源站留出恢复的余量。
| 症状 | 先查 | 常见根因 | 止血 |
|---|---|---|---|
| 用户看到旧值 | CDN 的 Age/X-Cache、Redis key、数据库 version | 删除失败、只清一层、回填 race、CDC lag | purge + 删 key + 版本号 |
| 回源 QPS 高 | 回源监控、miss ratio、--hotkeys | TTL 同步、缓存重启、热点、穿透 | 限流、SWR、单飞、空值缓存 |
| 源站 5xx | 连接池、p99、慢查询、下游 | 过载、慢查询、依赖故障 | 降级、stale-if-error、扩容 |
| 命中率骤降 | evicted_keys、key 版本、发布记录 | 内存驱逐、key 变更、分片迁移 | 扩容、回滚 key、预热 |
| 412 增多 | 条件写冲突率、热点 key | 并发写同一对象 | 退避、合并写、分片 |
8.7.3 止损顺序:先止血,再找根因
事故中最重要的不是「找到根因」,而是「别让根因继续放大」。建议的顺序是:
- 止血:给回源加并发上限/排队上限;打开
stale-while-revalidate/stale-if-error;扩容只读副本;暂停非关键写和批量任务。 - 定位:是哪个 key/前缀、哪一层、从什么时候开始;把 deploy、purge、TTL 过期、缓存重启的时间线对齐。
- 修复:TTL 抖动、singleflight、空值缓存、预热、CDC 失效、版本号;一次只改一个变量。
- 复盘:把「最大陈旧时间」写成 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:04 | CDN | X-Cache: MISS、502 透传 | stale-if-error、降级页 |
| 10:12 | 数据 | 回填值来自只读副本 | 读主库/版本校验 |
这张表的每一行都对应一个可加的护栏;只写「加强监控」等于没复盘。
运营改了价格,CDN purge 成功,服务 Redis 没删。用户请求命中 CDN 回源,源站返回 Redis 里的旧价格;CDN 把这个旧价格当成新内容缓存 10 分钟。运营看到 CDN 控制台显示「已刷新」,以为问题解决了;实际上 Redis 的旧值又被复制到了边缘。修复不是再 purge 一次,而是先删 Redis,再 purge CDN,并确认源站返回的 version 已经变化。
回源风暴(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_keys | 0 或偶发 | 持续增长 | 扩容、调 maxmemory、降 TTL |
expired_keys 增速 | 平滑 | 尖峰 | TTL 抖动、预热、SWR |
| S3 5xx | 偶发 | 突增 | 退避、request id、跨区 |
| 412 比例 | 低且平稳 | 突增 | 热点写、合并写、退避 |
正常/异常的分界不该拍脑袋。回源 QPS 用「源站容量的 60% 绿、80% 黄、100% 红」;命中率没有通用目标,只有「当前命中率下源站还剩多少 headroom」;Age > max-age 只有在配置了 SWR/SIE 且未超出窗口时才算正常。把这些写成告警表达式,比在事故中争论「95% 是不是正常」有用。
curl -I看边缘:X-Cache、Age、Cache-Control,判断是 HIT/MISS/STALE。- 绕过 CDN 打源站(改 Host 或直连源站),对比两边的内容和版本。
redis-cli INFO stats看命中率、驱逐、过期;--hotkeys/SLOWLOG找热点和慢命令。HEAD对象存储:ETag、version-id、request-id,确认源站版本和删除语义。- 对齐时间线:deploy、purge、TTL 过期、缓存重启,看哪个动作和故障起点重合。
8.8.1 把缓存命中写进结构化日志
指标告诉你「命中率掉了」,日志告诉你「哪个 key、哪个 PoP、哪一层」。每次回源打一条结构化日志,字段至少包括:
| 字段 | 例子 | 用途 |
|---|---|---|
cache_key | price:sku-1024 | 定位热点/失效范围 |
cache_layer | cdn / redis / local | 判断哪层 miss |
cache_status | HIT / MISS / STALE | 命中类型 |
age_s | 421 | 边缘已缓存多久 |
origin_status | 200 / 503 | 回源结果 |
origin_ms | 182 | 回源延迟 |
pop | fra | 边缘位置 |
request_id | 7Q3F9Y2K... | 串起源站日志 |
{"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:37 | CI 构建并跑完测试,通过 |
| 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 工程师带走的,不是「正则写错了」,而是四个系统性条件同时成立:
- 全球配置面 = 全球爆炸半径。WAF 规则为了快速响应威胁,绕过了正常软件的 DOG/PIG/Canary 流程,直接全球下发;Quicksilver 让变更在秒级到达所有 PoP。速度是特性,也是风险。
- 功能测试不等于资源上界。CI 验证了「该拦的拦住、不该拦的放过」,但没有测「这条规则在最坏输入上花多少 CPU」。正则要有输入长度上限、匹配超时/预算,或者换线性时间的引擎(RE2/Thompson NFA)。
- simulate 也会执行。规则即使不拦截,也要跑匹配;「灰度/观察模式」不能假设零成本。
- 失效开关不能依赖被失效的系统。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-enable | negative/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」不等于「第一次失败」。
设计上,至少要有四样东西:
- 客户端生成的
upload_id(ULID/UUID),所有重试复用它;对象 key 由它决定,例如uploads/{upload_id}/source.mp4,不要每次重试换 key。 - 一张
uploads表,upload_id是主键,状态机是initiated → uploaded → completed → charged,completed只能从uploaded迁移一次。 - 条件更新,用
WHERE status='uploaded'保证只有一个请求能把状态推到completed;0 行时读当前状态,completed就返回原结果。 - 计费幂等键,用
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 404 | NoSuchUpload | 可能已完成,也可能从未存在 | HEAD + checksum 判断 |
| 扣费超时 | 账单接口 timeout | charge 可能已创建 | 同一 Idempotency-Key 重试 |
| 写 charge_id 前崩溃 | 无响应 | 已扣费,charge_id 为空 | 重试同一账单幂等键 |
算一笔账:每天 100 万次上传,0.3% 超时重试,就是 3,000 次重试;其中 10% 因为换 key 或重复 complete 造成重复计费,每天 300 笔;每笔 5 元,一天 1,500 元。金额不大,但退款、客服、对账的成本远高于 1,500 元;如果计费单位是「每分钟转码」或「每 GB 存储」,重复会持续累积。重复副作用不是一次性的错账,而是一条会自己长大的尾巴。
对象幂等:同一个 key、同样的字节重复 PUT,最终对象内容相同;开启版本化时,版本数会增加。副作用幂等:同一个逻辑上传(同一个 upload_id)重复触发完成回调,计费/转码/通知只生效一次。前者由对象存储的覆盖语义提供,后者必须由 upload_id + 状态机 + 计费幂等键自己保证。
如果桶开了版本化,同 key 重试 PUT 不会产生两个「当前对象」,但会产生两个版本;按版本计费、按事件触发的系统仍然会看到两次变更。排查「为什么多了一个对象」时,先确认是「key 不同」还是「版本不同」:前者是 key 设计问题,后者是版本化 + 事件去重问题。
8.11 缓存与 CDN 设计清单
把前面的内容压缩成一张表。设计评审时逐项过,比事后查事故便宜。
| # | 检查项 | 达标线 | 不达标会怎样 |
|---|---|---|---|
| 1 | 数据分级 | 每类数据写明「可容忍最大陈旧时间」 | 用「缓存一致」这种无法验收的词 |
| 2 | 事实来源 | 缓存可丢、可重建,DB/对象存储是唯一事实 | 把 write-behind 缓存当账本 |
| 3 | key 设计 | 稳定、可版本化、不含用户 id/随机数 | 命中率归零或串用户 |
| 4 | TTL | base + 抖动,有硬上界 | 同一时刻集体过期 |
| 5 | 失效 | 提交后删除,outbox/CDC 重试,失败告警 | 删失败后靠 TTL 兜底 |
| 6 | 回填 race | 版本/CAS,或延迟双删 + 接受残余窗口 | 旧值回填后留满 TTL |
| 7 | 穿透 | 空值缓存 + 参数校验 + 限流 | 不存在的 ID 打穿源站 |
| 8 | 击穿 | singleflight / 逻辑过期 / SWR | 热点 key 过期即雪崩 |
| 9 | 雪崩 | 多级缓存、HA、预热、源站限流 | 缓存重启 = 全站故障 |
| 10 | 热点 | L1 缓存 / key 复制 / 拆分 / 合并 | 单分片被打满 |
| 11 | CDN key | 规范化 query,Vary 最小化,剥 tracking | 命中率低或缓存污染 |
| 12 | purge | URL/tag 可重试,浏览器靠版本化 URL | purge 了 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」。先把「最大陈旧时间」写成一句可验收的话,再选技术。
- 这类数据最多能旧多久?谁批准这个数字?
- 写完到所有缓存失效,最长路径是什么?失败会重试吗?
- 缓存全挂时,源站能接住多少 QPS?降级是什么?
- CDN 的 cache key 包含哪些维度?哪些请求会永远 MISS?
- 出问题时,用哪三条命令区分「源站旧」和「缓存旧」?
8.12 本章陷阱清单
- 把 ETag 当 MD5:多段上传、SSE-KMS 的 ETag 不是内容 MD5;校验完整性要用 checksum。
- 以为 S3 强一致 = 跨 key 事务:对象 + 数据库的双写仍然没有原子性,需要状态机/outbox/对账。
- 两个 writer 都 200 就以为没丢更新:强一致只保证读者看到后完成者;丢失更新要靠
If-Match或版本号。 - 先删缓存再写库:读者会在删完、库未提交的窗口回填旧值,而且写者提交后不会再删。
- 把双写当原子:数据库和缓存没有共同提交点;失败一边后重试另一边可能写回旧值。
- 把 TTL 当一致性方案:TTL 只是陈旧上界,且缓存可能被提前驱逐;写完等 TTL 等于把窗口拉满。
no-cache当no-store:no-cache可以存,只是每次复用前要 revalidate;no-store才是别存。- purge 只清 CDN:浏览器和服务本地缓存不受 CDN purge 影响;静态资源要靠版本化 URL。
- 所有 key 同一个 TTL:同一时刻集体过期,把缓存问题变成源站问题;TTL 要加抖动。
- 不缓存空值:不存在的 ID 每次都穿透到数据库;空值缓存 TTL 要短,创建后要能失效。
- singleflight 当全局锁:它只在一个进程内合并;50 个 pod 仍有 50 次回源,且 leader 失败会放大给等待者。
- 命中率当目标:真正要管的是「回源 QPS = 总 QPS × (1 - 命中率)」和源站 headroom。
- 上传重试换新 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 |
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/5861 | 8.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。
图里 A 在 GC 停顿时租约过期,B 拿到 fence=2 并写入资源;A 恢复后带着 fence=1 写,被资源端拒绝。注意,锁服务并没有阻止 A 发请求,它只是发了一个号;真正让 A 的写失效的是资源端的比较逻辑。这就是本章最重要的一句话:
锁服务只负责发号,资源端负责执行。
如果资源端不校验 token,或者存在一条绕过校验的写路径(缓存、消息队列、运维脚本、另一个服务直连数据库),fencing 就形同虚设。很多「我们加了锁还是重复」的事故,根因不是锁服务,而是资源端从来没有真正成为唯一的写入口。
一个可用的分布式锁协议至少包含五步:
- acquire:客户端用唯一 token 尝试获取锁,锁服务返回租约期限和 fencing token;
- renew:在租约到期前续期;续期失败或本地 deadline 已过,立即停止工作;
- use:在临界区内执行副作用,每次写资源都携带 fencing token;
- release:用 token 做比较后删除锁,不能无条件
DEL; - fence:资源端在同一个原子操作里比较 token 与已记录的最大值,拒绝旧 token。
五步里任何一步缺失,协议都不完整:没有 renew,崩溃后锁会占死;没有 release 的比较,会误删别人的锁;没有 fence,过期持有者仍能写。
9.1.4 三个时间,不要混着用
锁的讨论里经常出现三个「时间」,它们由不同的人维护,含义也不同:
| 时间 | 谁维护 | 用来回答 | 典型错误 |
|---|---|---|---|
| 锁服务的 TTL | Redis / 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 分钟只是把事故概率降低,不是消除。
客户端 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)
三个细节决定了这段代码是否可用:
- 续期间隔应小于 TTL 的 1/3,例如 TTL 30 秒、每 10 秒续一次;这样即使一次续期丢包,还有两次机会。
renew()返回False时,业务必须立刻停止写资源,而不是「再试一次」。续期失败可能意味着锁已经过期、被删除、或被别人持有;继续写就是过期写。- 续期线程和业务线程共享同一个进程。Full GC 会同时暂停两者;cgroup 限流也一样。把续期放到独立 sidecar 可以避免「业务暂停导致续期停止」,但 sidecar 无法知道业务是否还活着,可能出现「锁一直续着,业务早已卡死」的另一种故障。
心跳的目的是「告诉别人我还活着」,续期的目的是「延长一份有期限的授权」。心跳丢了可以重连,续期失败必须停止工作。把续期写成「失败了就重试到成功」,等于把租约当成永久锁。
9.2.4 主从切换:锁 key 可能根本没到从库
Redis 主从复制默认是异步的。考虑下面这条时间线:
| 时间 | 主库 | 从库 | 客户端 A | 客户端 B |
|---|---|---|---|---|
| t0 | SET 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 的安全性依赖三个假设:
- 网络延迟有上界:客户端能在一个已知的时间内完成多数派获取;
- 时钟漂移有上界:
validity_ms的减法不会因为时钟跳变而严重失真; - 进程暂停有上界:客户端在拿到锁之后,不会暂停到超过
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 | 开始执行,尚未写资源 | — | — | — |
| t2 | GC 暂停 12 秒 | 租约到期 | — | — |
| t3 | — | 锁可被获取 | 获取锁 | — |
| t4 | — | — | 写资源 | 接受 B 的写 |
| t5 | 恢复,认为锁还有效 | — | — | A 也写,重复/覆盖 |
Kleppmann 的结论是:Redlock 无法防止「客户端在拿到锁之后、写资源之前暂停」;要防住这个,资源端必须能拒绝旧持有者,也就是 fencing token。他还指出,如果只是为了效率(避免重复劳动),单实例 Redis 就够了,不需要 5 个 master;如果为了正确性,Redlock 不够,需要 fencing 或共识系统。
9.3.4 antirez 的回应:假设之内是安全的
antirez 随后发表《Is Redlock safe?》回应。他的论证大致有三点:
- Redlock 假设时钟漂移有界,不是任意跳变的墙上时钟;算法里的
drift项就是为漂移留的余量。只要漂移在有界范围内,有效性判断是可靠的。 - 进程暂停是客户端故障,不是锁算法的责任。如果客户端暂停到超过租约,它已经失去了持有资格;一个「暂停后仍继续写」的客户端,任何基于租约的锁都救不了它。要救,就得在资源端做 fencing。
- Redlock 的定位是效率,不是强一致。它比单实例 Redis 更可用,但不是什么都能防;需要绝对安全时,应该用 fencing 或共识系统。
这场辩论没有一个「谁对谁错」的判决。两人其实在回答不同的问题:Kleppmann 问「Redlock 能不能作为正确性原语」,antirez 答「在它声明的假设内,它可以安全地发放租约;假设之外要靠 fencing」。如果把辩论读成「Redis 不能做锁」,就错过了真正的工程结论。
| 场景 | 只用 Redlock | Redlock + 资源端 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 的经典锁配方:
- 每个客户端在
/locks/recon下创建一个临时顺序节点,例如/locks/recon/lock-0000000001; - 客户端
getChildren("/locks/recon"),如果自己的编号最小,获得锁; - 否则,对前一个节点设置
existswatch,等待它被删除; - 前驱消失后,重新读取子节点列表,确认自己是否最小;
- 释放时删除自己的节点;会话过期时,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 的写是线性一致的(经过 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 顺序:
- 每个竞争者创建自己的 key(带 lease),etcd 返回一个全局递增的 revision;
- revision 最小的竞争者获得锁;
- 其他人 watch 自己的前一个 revision 对应的 key;
- 前驱被删除后,watch 触发,重新检查自己是否最小。
etcd 的 clientv3/concurrency 包把这套逻辑封装成 Mutex:NewSession 创建 lease,NewMutex 创建锁对象,Lock(ctx) 阻塞直到获得锁。它同样用「前驱 watch」避免羊群效应,并且 revision 天然单调,适合作为 fencing token 的来源。
9.4.3 临时节点/lease 的本质:服务端 TTL,不是客户端保险
ZooKeeper 的临时节点和 etcd 的 lease 都做同一件事:把「持有者是否还活着」的判断,变成「服务端在 TTL 内有没有收到续约」。这解决了崩溃后的释放,但也带来两个必须接受的边界:
| 边界 | ZooKeeper | etcd | 后果 |
|---|---|---|---|
| 客户端暂停超过 TTL | 会话过期,临时节点删除 | lease 过期,key 删除 | 旧持有者恢复后可能仍以为自己持锁 |
| 续约网络分区 | 会话超时 | keepalive 失败 | 锁被释放,业务被另一个持有者接管 |
| 服务端时钟/租约检查 | 依赖 session tracker | 依赖 lease 过期队列 | 服务端故障时可能延迟释放 |
| 客户端收到过期通知 | SessionExpired | keepalive 返回失败 | 通知可能晚于旧持有者的下一次写 |
这些系统能保证「锁的状态变更在服务端是线性一致的」,但不能保证「旧持有者在暂停后不会继续写外部资源」。第 5 章 5.11 引用的 Jepsen etcd 3.4.3 分析就指出:etcd 的 KV 操作在分区、暂停、时钟偏移下仍是严格可串行化的,但 lock API 不保证互斥;等待锁的路径存在 lease 有效性未复查的问题。这不是说 etcd 的实现「很差」,而是说 lease 模型的边界在 API 层也会显现。
9.4.4 watch 不是锁
watch 只是一个「状态变化通知」,不是「授权凭证」。它有三种常见的坑:
- ZooKeeper 的 watch 是一次性的:触发后必须重新注册;如果你在回调里做了一次耗时操作,期间的变化不会再有通知。
- etcd 的 watch 可能被取消或压缩:客户端太慢、历史 revision 被 compaction、网络断开,都会让 watch 中断;恢复后必须重新 list,不能假设「没收到事件就是没变化」。
- watch 事件不等于「轮到我」:收到前驱删除的通知后,必须重新读取当前子节点/revision 顺序,确认自己确实是最小;否则可能在一个已经过期的视图上进入临界区。
客户端 B 在 etcd 上排队等锁,watch 自己的前驱。网络抖动导致 watch 流断开,B 的客户端库没有立即重连;前驱在这段时间被删除,B 没有收到事件。B 的应用层认为「还没轮到我」,一直等待,锁却已经空闲。另一种更危险的情况是:B 在断线前收到「前驱删除」的事件,但在重连后没有重新检查 revision,直接进入临界区;此时 C 可能已经用更小的 revision 拿到了锁。
正确做法:watch 断线后重新 get 当前锁前缀,按 revision 重新排序,再决定是否持有;不要把「曾经收到过事件」当成「现在持有锁」。
9.4.5 三者对比
| 维度 | Redis | ZooKeeper | etcd |
|---|---|---|---|
| 锁原语 | 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 递增 |
| 正确性来源 | 锁服务 + 资源端 fence | quorum + 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 多三道保险:
- term:每届选举有一个递增的任期号;节点只接受不小于自己 term 的请求;同一 term 最多一个 leader。
- 多数派:候选人必须拿到超过半数节点的投票;两个多数派必然相交,所以同一 term 不会选出两个 leader。
- 日志限制:候选人必须拥有不落后于任何投票者的日志;这保证已提交的日志不会丢失。
但 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 必须满足两个条件:
- 单调:每次授予锁/提升 leader,token 严格递增;
- 全局可比较:同一个资源的所有写路径使用同一套 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 一个安全性小定理
设资源端对每个资源维护 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,它会发现已经过期并停止;这是一个正确的实现。事故往往发生在下面几种「检查不完整」的写法里:
- 只在进入临界区前检查一次。后面的循环、批量 flush、异步回调都不再检查;GC 可以发生在任意两次写之间。
- check-then-act。在
t=9.99检查时还剩 0.01 秒,检查通过;紧接着又发生一次 200 ms 的暂停,写发生在t=10.19,此时租约已经过期。检查和写不是原子的。 - 写路径在另一个线程/协程里。主线程检查了 deadline,工作线程从队列里取出任务继续写;工作线程没有继承这个检查。
- 本地时钟没有前进。虚拟机被挂起、
CLOCK_MONOTONIC不包含 suspend 时间,恢复后 A 以为只过了 1 秒;它甚至不会发现租约已经过期。 - 续期成功但写超时。续期线程活着,锁还在;业务线程卡在下游调用上,等它返回时早已超过本地 deadline。锁没丢,但业务已经不该继续。
这五种写法里,前三种靠「更仔细地检查」可以缓解;第四种靠本地检查完全无效;第五种说明「锁还在」不等于「这次操作还该做」。fencing 能覆盖前四种:旧 token 会被拒绝。第五种需要业务层的 deadline/取消机制;如果锁仍然有效、没有新持有者,fencing 不会拒绝一个当前 token 的写。
9.7.2 加长 TTL 为什么不是解法
「把 TTL 从 10 秒加到 60 秒」是最常见的反应。它确实能把「12 秒停顿」这类事故的概率降下来,但代价和边界都很清楚:
| TTL | 12 秒停顿 | 60 秒停顿 | 崩溃后锁占用 | 适用性 |
|---|---|---|---|---|
| 10 s | 双持有者 | 双持有者 | 10 s | 短任务 |
| 60 s | 安全 | 双持有者 | 60 s | 中等任务 |
| 300 s | 安全 | 安全 | 5 min | 长任务,但故障恢复慢 |
TTL 越长,崩溃后锁被占用的时间越长,可用性越差;TTL 越短,暂停导致双持有者的概率越高。这是一个没有最优解的权衡,因为停顿的分布没有上界。 工程上能做的是:把 TTL 设在「临界区 p99.9 + 续期余量」附近,把续期做勤,把本地 deadline 检查做全,然后在资源端加 fence。四件事缺一不可。
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 found | etcd 健康/时钟 |
| 锁前缀下 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.lag | 0–1 秒 | 持续大于 5 秒 | 从库落后,切换后可能没有锁 key |
master_link_status(在从库上) | up | down | 从库数据陈旧,不能作为切换依据 |
db0.expires | 大于 0(有 TTL 的 key) | 0 但有锁 key | 锁没有 TTL,检查 TTL == -1 |
evicted_keys | 0 | 大于 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 争点拆解
| 争点 | Kleppmann | antirez | 工程落点 |
|---|---|---|---|
| 进程暂停 | 无上界,必须防 | 是客户端故障,锁算法不负责 | 资源端 fence |
| 时钟 | 有效期依赖时间;时钟跳变/挂起会让判断失真 | 漂移有界,drift 项留了余量 | 单调时钟 + 余量,但仍不解决挂起 |
| 定位 | 不能作为正确性原语 | 在声明的假设内可安全发放租约;正确性需 fencing | 按后果选型 |
| fencing | 必需 | 需要绝对安全时必需 | 扣款/库存必须 |
| 5 个 master | 不增加正确性,只增加可用性 | 提高可用性,减少单点 | 可用性 ≠ 正确性 |
这张表说明,两人并不是在同一个问题上互相否定:一个在问「能不能拿它保证不双写」,另一个在答「在声明的假设内它能安全工作」。把辩论简化成「Redis 做锁不靠谱」或「Kleppmann 不懂 Redis」,都错过了可操作的结论。
9.9.3 三条可操作的教训
- 把「锁的用途」写清楚。如果只是避免重复劳动,单实例 Redis + TTL + token 解锁足够;如果要保护钱/库存,锁服务本身要有 quorum,资源端要有 fence。
- 把「假设」写进设计文档。TTL 多长、续期多久、最大暂停多少、时钟漂移多少、资源端是否校验 token;这些不是实现细节,是协议的一部分。
- 用故障注入验证,而不是用文章站队。在测试环境注入
SIGSTOP、时钟跳变、主从切换,观察是否出现双写;如果没有 fence,几乎一定能复现。
本节只引用两篇公开文章的核心论点、时间和系统背景,不复述原文长段。具体措辞、代码和后续讨论以原文为准:Kleppmann 的文章、antirez 的回应。Redis 官方文档的分布式锁页面也把 Redlock 作为一节讨论,可作为工程读本;具体结论以文档为准。
9.10 重试导致重复副作用:锁过期后的双执行
现在把 9.7 的停顿和 9.2 的过期拼成一个完整的业务事故。场景:退款 worker 用 Redis 锁保证「同一张退款单只处理一次」,锁 TTL 30 秒;下游支付网关(PSP)在高峰期一次退款调用要 45 秒。
9.10.1 失败链
| 时间 | Worker A | 锁 | PSP | Worker B | 账本 |
|---|---|---|---|---|---|
| t0 | 获取锁,TTL 30s | 授予 | — | — | — |
| t1 | 调用 refund(intent=R1) | — | 开始处理 | — | 无记录 |
| t30 | 仍在等 PSP | 过期,删除 key | 仍在处理 | — | 无记录 |
| t31 | 仍在等 PSP | — | — | 获取锁 | 无记录 |
| t32 | 仍在等 PSP | — | — | 读账本:无记录 | 无记录 |
| t33 | 仍在等 PSP | — | 收到第二笔 refund(R1) | 调用 PSP | 无记录 |
| t45 | PSP 返回成功 | — | 第一笔成功 | 仍在等 | — |
| 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 写两条账本」,但挡不住:
- PSP 侧的重复扣款:账本只记录一条,但 PSP 扣了两次;需要 PSP 的幂等键或对账冲正。
- 重试/新持有者用了不同的 intent:A 用 R1,重试时生成了 R2,唯一索引不拦。
- 旁路写:另一个服务直接写账本,不经过唯一约束对应的入口。
- 部分成功: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
这段代码的重点不是语法,而是三个顺序:
- 先占位,再调 PSP。如果先调 PSP 再写本地,进程在两步之间崩溃,本地没有记录,重试会再扣一次;先占位至少留下「这个 intent 正在处理」的痕迹。
- PSP 超时后查询,不重发。查询也可能失败;失败就保持
pending,由后台对账按intent_id查 PSP,而不是生成新 ID 重试。 - 账本落账用 fence。旧持有者即使恢复,也不能把过期的结果写进账本;新持有者用更高的 token 完成落账。
这个状态机仍然不是「绝对一次」。它把重复窗口从「锁过期后的任意双写」缩小到「PSP 不支持幂等 + 对账失败」这类更窄、更可观测的路径。工程上要的是「可证明的边界 + 可对账的残留」,不是一句「我们加了锁」。
退款单 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 |
| 并发更新冲突 | 乐观锁/版本号/CAS | UPDATE ... 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 | 所有写路径都经过校验吗? | 缓存/队列/运维脚本绕过 |
| 7 | token 从哪来?故障切换/恢复后会不会回退? | 旧 token 被接受,或新 token 被误拒 |
| 8 | 解锁是否比较 token? | 误删别人的锁 |
| 9 | 锁服务故障时,业务是降级还是拒绝? | 可用性与正确性没有明确取舍 |
| 10 | 等待锁的时间上限是多少? | 请求堆积、线程池耗尽 |
| 11 | 有没有幂等键/唯一约束兜底? | 锁失效时直接双写 |
| 12 | 观测指标和对账手段是什么? | 出事无法定位、无法补偿 |
如果时间只够问五个问题,问这五个:
- 锁过期后,旧持有者还能写吗?资源端会不会拒绝?
- 续期失败时,业务代码走哪条分支?会不会继续写?
- 锁服务主从切换/恢复后,token 会不会回退?
- 有没有一条写路径绕过了 fence?
- 如果明天锁服务全挂,业务是拒绝服务还是双写?
9.11.5 反模式
- 锁包住网络调用:临界区里调下游,下游慢导致锁过期,双持有者。把网络调用移到锁外,或者用状态机/异步。
- 没有 TTL 的锁:崩溃后永久占用;
TTL == -1必须告警。 - 有 TTL 但没有续期:长任务必然过期;要么续期,要么把任务拆短。
- 无条件
DEL解锁:误删别人的锁;必须 Lua 比较 token。 - 一把全局锁:所有租户/资源共用,吞吐和爆炸半径都不可控;按资源分 key。
- 把锁当队列:用
while not acquire(): sleep()排队,浪费连接和 CPU;用 etcd/ZK 的等待队列或消息队列。 - 用锁掩盖竞态:本该用唯一索引/幂等/事务的地方加锁,锁失效后问题依旧。
- 只监控 acquire 成功:不看续期失败和 fence 拒绝,等于只看「门有没有开」,不看「有没有人从窗户进来」。
9.12 本章陷阱清单
SET NX成功就以为安全:它只证明「写入时 key 不存在」,不证明「写资源时锁还有效」;暂停、主从切换、驱逐都会让锁失效。- 解锁直接
DEL:锁过期后可能删掉别人的锁;必须用 Lua 比较 token 再删。 - TTL 没有续期,或续期失败后继续写:长任务必然过期;续期失败必须停止业务,而不是重试到成功。
- 续期线程和业务共享同一个 GC:Full GC 会同时暂停两者;独立 sidecar 能续期,但可能掩盖业务卡死,仍需 fence。
- 以为主从复制不会丢锁:Redis 复制是异步的,
WAIT只缩小窗口;故障切换可能提升一个没有锁 key 的从库。 - 把 Redlock 当共识:多数派获取成功不等于线性一致;Redlock 买到的是可用性,不是正确性。
- 用时间戳或
INCR当 fence:时钟会回拨,Redis 主从切换会让计数器回退;token 必须跨故障切换单调。 - 资源端只检查 token 存在,不比较大小:等于没有 fence;必须记录
max_fence并拒绝更小的 token。 - check-then-act:先
SELECT fence再UPDATE,两个请求可能都通过检查;比较和写入必须在同一条语句/事务里。 - 只 fence 写路径:缓存、消息队列、运维 SQL、另一个服务都可能绕过;资源端必须是唯一写入口。
- TTL 小于下游 p99.9,或把网络调用放在锁内:锁在等下游时过期,双持有者;把慢调用移出临界区。
- 锁 key 粒度太粗/跨租户:一把全局锁让所有请求排队,爆炸半径不可控;按资源/租户分 key。
- 把锁当队列或重试机制:无限等待、无限重试会耗尽线程/连接;用等待队列、退避和超时。
- 只看 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_fence | 9.6 |
| 画出 GC 停顿时间线 | 标出 t0/t10/t12 与两个 token | 9.7 |
| 用命令看租约 | etcdctl lease timetolive 或 redis-cli INFO 能读出异常 | 9.8 |
| 说出 Redlock 辩论的结论 | 不是「谁赢」,而是「按后果选型 + fence」 | 9.9 |
| 拆一次双执行 | 能画出锁过期 + PSP 慢 + 重试的完整链 | 9.10 |
| 判断该不该用锁 | 能给出至少三种替代方案 | 9.11 |
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 结构和耗时分解,无法告诉你「慢在哪一段」。
三种数据不是三选一,也不是「都接上就完事」。它们是三种不同的数据模型,各自擅长回答不同形状的问题。把它们混为一谈,就会得到「什么都有,什么都查不出来」的可观测性。
可观测性(observability)是系统根据其外部输出(telemetry)推断内部状态的能力。工程上把它落成三个可操作的问题:
- 给定一个现象,能否找到对应的请求、租户、版本、依赖和代码路径?
- 给定一个请求,能否还原它在时间轴上的每一段耗时与每一次失败?
- 给定一段历史流量,能否在隔离环境里重放,并解释行为差异?
能回答这三个问题,才算「可观测」;只有 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 就是:
6.9 TB/天只是原始 span,还没算索引、查询和副本。就算存储不要钱,网络和 collector 也扛不住。所以 1% 头采样对应的就是 69 GB/天,仍然不是一个小数目。采样不是可选项,而是可观测性设计的一部分;问题不是「要不要采样」,而是「采样决策在哪做、保留什么、丢了什么」。
头采样(在 trace 开始时按概率决定)的盲区是:它看不见未来。一个 0.3% 租户的慢请求,在 1% 采样下只有百分之一的概率被保留;如果这个租户的请求本身只占 0.3%,那它出现在样本里的概率是 。你想找的那条慢 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 估算:
按每 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。
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
| 字段 | 长度 | 示例 | 规则 |
|---|---|---|---|
version | 2 hex | 00 | 当前版本;ff 保留,禁止使用 |
trace-id | 32 hex | 4bf92f3577b34da6a3ce929d0e0e4736 | 16 字节;全零非法 |
parent-id | 16 hex | 00f067aa0ba902b7 | 8 字节;全零非法 |
trace-flags | 2 hex | 01 | 最低位是 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 是随机值,不是认证凭证。不要用它做权限判断,也不要在 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
读这棵树时,先看三件事:
- root 的 self time:
1912 - (21 + 38 + 1740 + 16) = 97 ms。如果 self time 很大,问题在本服务的排队、序列化、GC 或未埋点的代码;如果 self time 很小,问题在某个 child。 - child 的 span kind:
psp.charge是 CLIENT,POST /charge是 SERVER;两者相差 35 ms,是网络与 PSP 入口排队。如果只有 CLIENT 没有 SERVER,说明对方没有埋点,你只能看到「从本端看出去」的耗时。 - 串行还是并行:
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、特定属性、租户 | 保留所有错误和慢 trace | collector 要缓存整条 trace;路由要按 trace-id 亲和 |
| 自适应采样 | 运行时 | 当前 QPS、错误率、预算 | 在成本和覆盖率之间折中 | 实现复杂;采样率变化会影响同比 |
头采样最重要的是一致性:同一个 trace 的所有服务必须做同样的决定,否则会出现「A 采了、B 没采」的残缺 trace。做法有两种:
- 父级采样(parent-based):如果收到的
traceparent里sampled=1,就继续采;如果是0,就不采。根节点做概率决策。 - 按 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 的
loadbalancingexporter)。 - 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_seconds | bucket 设计不合理;对分位数求平均 |
| 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 是两套互补的检查表,不是两种产品:
| 框架 | 维度 | 适用对象 | 回答 |
|---|---|---|---|
| RED | Rate、Errors、Duration | 请求驱动的服务/接口 | 流量多少、失败多少、慢不慢 |
| USE | Utilization、Saturation、Errors | 资源(CPU、内存、磁盘、连接池、队列) | 资源是否忙、是否排队、是否出错 |
一个 checkout 服务的 RED 表:
| 指标 | 查询 | 正常范围 | 异常时先看 |
|---|---|---|---|
| Rate | sum(rate(http_server_requests_total{service="checkout"}[5m])) | 与上游 QPS 同量级 | 突增/突降、重试放大 |
| Errors | sum(rate(...{status=~"5.."}[5m])) / sum(rate(...[5m])) | < 0.5% | 按路由/依赖/版本拆 |
| Duration | histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m]))) | p99 < 300 ms | 按 span/下游拆 |
USE 用于资源:
| 资源 | Utilization | Saturation | Errors |
|---|---|---|---|
| CPU | 1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) | node_load1 / count(cpu) | throttling、steal |
| 内存 | 1 - MemAvailable/MemTotal | page cache 回收、OOM kill | OOM、分配失败 |
| 连接池 | pool_in_use / pool_size | pool_wait_seconds p99 | 获取超时、连接重置 |
| 队列 | queue_depth / queue_capacity | 入队等待时间 | 拒绝、丢弃 |
| 磁盘 | 1 - free/total | iowait、写放大 | 只读挂载、IO 错误 |
RED 的盲区是「资源饱和度」:请求成功率 100%、p99 200 ms,但连接池已经 95%,下一次流量突增就会排队。USE 的盲区是「业务结果」:CPU 只有 30%,但所有请求都返回了错误的业务码。两套一起看,才既知道「用户是否满意」,也知道「系统还有多少余量」。
10.3.3 分位数不能平均:一个反例
假设两个实例在同一个 5 分钟窗口内:
| 实例 | 请求数 | p99 |
|---|---|---|
| A | 995 | 100 ms |
| B | 5 | 1 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])
)
)
)
第二条查询在语法上能跑,在统计上没有意义。它把「分位数」当成了可加的标量;分位数不是线性算子,。
同样的错误也发生在时间维度:把周一的 p99 和周二的 p99 平均,不等于两天的合并 p99;如果周一的流量是周二的 10 倍,合并 p99 会更接近周一。要看趋势,应该画每天的 p99 曲线,而不是把曲线平均成一条。
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)
这段代码有三个关键点:
trace_id放在contextvars里,异步/线程池只要复制 context 就能自动带上;不要在每个函数参数里手动传。event是稳定的事件名,extra里的字段是结构化字段;不要把整个字典json.dumps塞进message,那样查询时还要二次解析。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 层用
loadbalancingexporter 按 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_spans | processor 丢弃的 span 数 | 内存保护/策略丢弃;查原因 |
otelcol_exporter_send_failed_spans | 导出失败数 | 后端不可用/认证失败;查重试与队列 |
otelcol_exporter_queue_size | 发送队列长度 | 持续接近上限;扩容或降载 |
otelcol_process_runtime_heap_alloc_bytes | collector 堆内存 | 接近 limit;查尾采样/大属性 |
一个常见事故是「collector 内存打满 → OOM → 所有服务看起来正常,但 trace 全没了」。如果没有 collector 的自监控,你会以为「没有慢请求」,实际上是「没有数据」。
自动埋点(agent/instrumentation library)能覆盖 HTTP、数据库、消息队列等标准库,但覆盖不了业务语义:哪个是「下单」、哪个是「扣款」、哪个失败应该算业务错误。自动埋点给你骨架,手动埋点给你关键分支;两者都要有,不能只靠 agent。
10.6 SLO、错误预算与告警
凌晨的 p99 告警有一个老问题:它告诉你「慢了」,但不告诉你「用户是否受影响、要不要现在起床」。SLO 的作用是把「健康」从一个形容词变成一个可计算的比率,再把比率变成可消耗的预算。
SLI(service level indicator)是一次可计算的测量:good events / valid events。SLO 是这个比率在某个窗口内要达到的目标,例如「过去 28 天,99.9% 的支付请求成功」。错误预算(error budget)是 ,即允许的失败比例。burn rate 是当前错误率相对于预算消耗速率的倍数:
burn rate = 1 表示按当前速度刚好在窗口结束时用完预算;burn rate = 14.4 表示 1 小时就用掉 2% 的月度预算。
SLI 的定义比阈值更重要。以 checkout 为例:
| SLI | good events | valid 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 h | 5 m | 14.4 | 2% | page |
| 6 h | 30 m | 6 | 5% | page |
| 3 d | 6 h | 1 | 10% | 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)在入口代理处复制请求,发给新版本服务,响应被丢弃,用户不受影响。它适合验证「新版本能否处理真实流量」,但不适合验证「写路径」——除非写被隔离。
三个必须做的隔离:
- 写隔离:新版本连只读副本、独立的影子库,或把写操作改成 dry-run;绝不能连生产主库。
- 下游隔离:支付、邮件、短信、推送这些副作用,必须指向沙箱或 mock;否则「影子」只是「另一个生产」。
- 负载隔离:镜像流量按比例采样(例如 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 或人工核对。
- 这次回放是为了「验证行为」还是「修复状态」?两者的隔离要求不同。
- 输入里的非确定性来源(时钟、随机、外部响应)录全了吗?
- 副作用边界在哪里?dry-run 开关是否能覆盖所有写路径?
- 回放会打到哪里:生产库、影子库、沙箱,还是 mock?
- 失败时的 kill switch 是什么?谁有权按下它?
回放的成本也常被低估:一次全量回放可能消耗数小时的 CPU、打满下游配额、产生大量临时数据。按「采样 1%、限速到生产峰值的 20%、先跑只读路径」的顺序推进,比一次性全量安全得多。
10.8 失败模式:重试导致的重复 span 与错误指标
现在把镜头对准一个具体的失败:一次用户操作,在 trace 里变成三棵树,在指标里变成三次请求,在 SLO 里变成一次故障。 这不是 instrumentation 的边角问题,而是重试与可观测性叠加后最常见的失真。
10.8.1 一条时间线
| 时间 | 客户端 | 服务端 | trace/指标 |
|---|---|---|---|
| t0 | 用户点击,创建 trace T1,发送 attempt 1 | 收到,开始处理 | T1 有一个 root span |
| t1 | 1 s 超时,SDK 重试;新起了一个 root trace T2 | 仍在处理 attempt 1 | T1 结束(timeout),T2 开始 |
| t2 | T2 也超时,再重试 T3 | attempt 1 成功但响应慢 | T2 timeout;T3 开始 |
| t3 | T3 成功,用户看到成功 | 返回结果 | 后端看到 3 个 root、3 次请求、2 次「失败」 |
如果 SDK 复用了 trace id,形状会变成「一个 root + 三个 CLIENT child」;如果每次重试都新起 trace,就是「三棵独立的树」。两种形状在不同后端里都常见,但都会导致同一个问题:指标把物理尝试当成了逻辑请求。
图里的 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 和指标都说「逻辑请求」的语言
三层修复,缺一不可:
- trace 层:重试复用
trace_id;每次尝试是一个带attempt的 child span(或 event/link);root span 只创建一次。SDK 不支持复用时,至少把logical_request_id放进 span 属性,后端按它聚合。 - 指标层:区分
requests_total{phase="logical"}和attempts_total;SLO 只消费 logical;attempt 指标用于诊断重试率。 - 存储层:后端按
(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,要在灰度环境里用一条真实重试验证。
某支付服务在大促期间 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 的状态页或独立监控补盲。
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"})
这起事故对本章的四个教训:
- 备份是一个系统,不是一个任务。 它的 SLI 是 RPO/RTO 和恢复成功率,不是 cron 的退出码;每季度至少做一次真实恢复演练。
- 对「缺失」告警。 错误日志会吵,沉默不会;备份、复制、telemetry 管线都需要 dead man’s switch。
- 破坏性操作要有闸门。 生产删除前的二次确认、快照、双人复核、最小权限,比事后补救便宜。
- 可观测性本身依赖被观测的系统。 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 | 扣款成功 | 第一笔重复扣款 |
| t2 | 90 分钟共重放 210 万条请求,其中 0.09% 是支付 | 创建 1,842 条支付 | 扣款 1,842 次 | 用户被重复扣款 |
| t3 | 对比脚本发现「响应不一致」,但为时已晚 | 订单表多出 1,842 条 | 退款流程启动 | 事故 |
根因有四层,每一层单独看都「有道理」:
- 影子流量被当成只读:复制请求不等于复制读请求;
POST /v1/pay本身就是写。 - 幂等键在回放时重新生成:访问日志没有记录
Idempotency-Key,工具为了「让请求合法」生成了新 UUID;服务端看到的是全新的逻辑请求。 - 环境没有隔离:新版本连的是生产库和 PSP 生产凭证;响应丢弃了,副作用没有丢弃。
- 没有 dry-run 闸门:回放工具没有「只读模式」,也没有按路由白名单限制;支付路径被包含在全量回放里。
10.11.2 三种模式与它们的边界
| 目的 | 模式 | 输入 | 副作用 | 键 |
|---|---|---|---|---|
| 验证新版本行为 | dry_run / 影子环境 | 请求副本 | 不执行,或只写影子库 | 不生成真实键 |
| 修复历史状态 | live 回填 | 原始事件/请求 | 执行,但必须幂等 | 复用原始键 |
| 联调 | sandbox | 采样请求 | 沙箱下游 | 加 run_id 命名空间 |
关键区别是:验证行为时,你不需要真实副作用;修复状态时,你需要真实副作用,但必须让下游能识别「这是同一个逻辑请求」。两者都不能「生成新键 + 打生产」。
某团队用生产访问日志做 90 分钟影子回放,验证 checkout 新版本。回放工具发现日志里没有幂等键,于是为每条请求生成新 UUID;新版本连生产库和 PSP,响应被丢弃。结果:1,842 笔重复扣款,退款和客服成本远超迁移收益。
修复分四步:
- 回放工具增加
--mode=dry-run,默认不调用任何写接口;写路径必须显式 allowlist。 - 影子环境使用独立数据库和 PSP 沙箱凭证;生产凭证在回放 namespace 里不可用。
- 如果必须打生产做回填,复用原始
Idempotency-Key,并在回放前用只读查询确认该键仍在去重窗口内。 - 回放前做 1% 采样 + 限速,先跑只读路径,确认对比指标正常后再扩大。
回放和重试共享同一个教训:「再执行一次」只有在能识别「这是同一个意图」时才安全。重试的键在客户端,回放的键在日志/事件里;两者都不能在「再执行」的那一刻重新生成。
10.12 可观测性设计清单
新增一个关键路径时,先把下面 12 个问题答完。答不上来的,不是「以后补」,而是「现在就没有可观测性」。
| # | 检查项 | 达标线 |
|---|---|---|
| 1 | 每个入口请求有 trace_id/span_id | 日志、响应头、下游调用都能关联 |
| 2 | 每个逻辑请求只有一个 root span | 重试复用 trace;attempt 有标记 |
| 3 | span kind 正确 | CLIENT/SERVER 分离,不混用 |
| 4 | 采样策略有文档 | 头/尾、比例、错误保留、成本估算 |
| 5 | RED/USE 指标覆盖路由与资源 | rate/errors/duration + 连接池/队列/CPU |
| 6 | histogram bucket 围绕 SLO | 阈值附近有 bucket;分位数从 bucket 合并 |
| 7 | label 基数受控 | 无 user_id/trace_id/原始 URL |
| 8 | 日志字段稳定且脱敏 | trace_id、event、outcome、error.code |
| 9 | collector 有内存/批处理/重试 | 丢弃量、队列长度有监控 |
| 10 | SLO 有 SLI、窗口、预算、burn 告警 | 按逻辑请求算,不按 attempt |
| 11 | 告警有 runbook 和负责人 | page 只给症状,ticket 给原因 |
| 12 | 回放有 dry-run/隔离/幂等 | 写路径默认关闭;live 复用原键 |
这张表的前 4 项决定「能不能查」,中间 5 项决定「查得准不准、贵不贵」,最后 3 项决定「出事时能不能行动」。很多团队只做 trace 和 RED,结果有数据但没有 SLO,有 dashboard 但没有 runbook;出事时仍然靠猜。
10.13 本章陷阱清单
- 把 trace_id 当请求 ID 或幂等键:trace 是一次调用链的标识,不是业务身份;重试可能换 trace,幂等键必须稳定。
- 重试时新起 root trace:同一逻辑请求变成多棵树,指标和 SLO 都被放大;复用 context,至少带
logical_request_id。 - 采样率固定 1%,不看错误:稀有错误和慢请求被丢掉;用尾采样或错误全留。
- 对分位数求平均:
avg(p99)没有统计意义;先合并 bucket 再histogram_quantile。 - 对 gauge 用
rate:队列深度、inflight 不是 counter;用avg_over_time或直接看瞬时值。 - 把 user_id/trace_id 放进 metrics label:基数爆炸,TSDB 内存和查询延迟失控;ID 属于 trace/日志。
- 日志只打字符串、不带 trace_id:无法与 trace/其他服务关联;结构化字段 + context 注入。
- 在请求路径同步导出 telemetry:后端慢会把业务拖慢;用 BatchSpanProcessor + 有界队列。
- 按 attempt 算 SLO:重试两次后成功,用户成功但 SLO 算失败;SLO 只看逻辑请求的最终结果。
- 备份任务成功就当可恢复:退出码为 0 不等于能恢复;看最近成功恢复时间,定期演练。
- 把回放当只读:复制 POST 就是写;没有 dry-run/隔离/原键,回放会重复扣款。
- 不监控 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 的 PromQL | 10.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 |
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 小时消耗 的月度预算;正好对应「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 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、collector | 10.2、10.5 | 引用 | 只引用格式与组件语义,不复制规范长段 |
| Google SRE Workbook | SLO、错误预算、burn rate | 10.6 | 引用 | 借用多窗口告警的参数,例子为本书自编 |
| 本书自编 + GitLab 公开复盘 | GitLab 2017-01-31 | 10.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 公里/小时」,却不说路况和油耗。 先看一个最小模型。一个有 个并发槽位的服务(线程、协程、连接都算),每个请求平均占用槽位 秒。稳定状态下,槽位要么在忙要么空闲,所以每秒能完成的请求数最多是:
这就是 Little’s law 的变形。Little’s law 的原式是
其中 是系统内的平均在途请求数, 是到达率, 是平均停留时间。它不假设泊松到达,也不假设服务时间分布,只要求系统稳定(长期看进入等于离开)。正因为它假设这么少,才适合做容量估算的第一把尺子。 代入数字。200 个工作线程,每个请求占用线程 80 ms:
下游变慢,每个请求占用线程的时间涨到 400 ms,线程数没变:
容量掉了 80%,而 CPU 可能只涨了一点——线程都在等下游,不在计算。容量不是 CPU 决定的,是「并发数 ÷ 延迟」决定的。这也是为什么「加 CPU」经常救不了 IO 密集型服务:瓶颈是并发槽位和被拉长的 ,不是算力。
| 并发槽位 C | 平均停留 W | 吞吐上限 C/W | 系统状态 |
|---|---|---|---|
| 200 | 80 ms | 2500 req/s | 健康 |
| 200 | 200 ms | 1000 req/s | 下游开始变慢 |
| 200 | 400 ms | 500 req/s | 容量塌缩,队列增长 |
| 200 | 1 s | 200 req/s | 已过载,排队时间远超处理时间 |
| 200 | 4 s | 50 req/s | 连接/线程被长期占用,接近不可用 |
这张表解释了一个常见困惑:「CPU 只有 30%,为什么 QPS 上不去?」因为 变大了, 就小了。第 2.6 节已经用过这个公式定并发上限;这里把它反过来,用目标吞吐和实测延迟算需要多少并发:
目标 1200 QPS、p99 250 ms,则 。用 p99 而不是 p50,是因为 p50 只覆盖一半请求;按 p50 算出来的并发,在高峰期一定排队。安全系数 1.2 是给突发和重试留的,不是给「拍脑袋」留的。
容量(capacity)是在给定工作负载、给定 SLO(延迟分位与错误率)下,一个系统长期可承载的有效请求速率。它由三个自变量共同决定:请求的资源成本(CPU、内存、连接、IO)、请求的停留时间 、以及可用的并发槽位 。
容量必须以 goodput 计量:只有满足 SLO 的成功请求才算容量。把 500、超时、重试都算进 QPS,会得到一个漂亮但无法交付的数字。
工作负载必须写清楚,否则容量数字不可比。至少固定六项:
| 维度 | 为什么影响容量 | 压测时必须固定 |
|---|---|---|
| 请求/响应体积 | 序列化、拷贝、带宽、GC | 用生产 p50 与 p99 两档 |
| 读/写比例 | 写有事务、锁、复制开销 | 按生产比例,不要只压读 |
| 缓存命中率 | 未命中会放大下游负载 | 冷缓存、热缓存各测一轮 |
| 下游延迟 | 直接进入 | mock 不够,要接真实依赖或等价延迟 |
| 依赖调用次数 | 每请求 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%,三可用区、允许丢一个区。先算需要的总能力:
再除以单副本能力:
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 用 算的是「稳态吞吐上限」,它假设请求来了立刻被处理。真实系统有队列:请求到达时如果没有空闲槽位,就要等。等待时间不是线性的,它随利用率上升急剧变大。理解这条曲线,才能理解为什么 70% 是常见的告警线,而不是 95%。 先定义利用率。对一个有 个服务台、每个服务台服务率 的系统:
是瓶颈资源的利用率,不是 CPU 利用率,也不是「平均负载」。如果数据库连接池只有 20 条,每请求占用连接 50 ms,那么连接池的服务率是 req/s;到达 360 req/s 时,连接池的 ,哪怕 CPU 只有 40%。
11.2.1 M/M/1:一条 1/(1-ρ) 的曲线
最简单的模型是单队列、单服务台、泊松到达、指数服务时间(M/M/1)。平均停留时间:
其中 是平均服务时间。也就是说,排队把服务时间放大了 倍。排队等待时间(不含服务)是:
| 利用率 ρ | 放大倍数 1/(1-ρ) | 服务 20 ms 时的 W | 其中排队 Wq |
|---|---|---|---|
| 0.50 | 2.0 | 40 ms | 20 ms |
| 0.70 | 3.3 | 67 ms | 47 ms |
| 0.80 | 5.0 | 100 ms | 80 ms |
| 0.90 | 10.0 | 200 ms | 180 ms |
| 0.95 | 20.0 | 400 ms | 380 ms |
| 0.99 | 100.0 | 2 s | 1.98 s |
这张表就是「70% 开始抖」的来源。70% 时,排队已经是服务时间的 2.3 倍;90% 时是 9 倍。从 70% 到 90%,利用率只涨了 29%,排队时间却涨了 3.8 倍。延迟对利用率的弹性不是 1,而是 。 更麻烦的是反向关系:延迟上升会让并发占用时间变长, 下降,等效服务能力下降, 进一步上升。这就是正反馈的种子,11.8 会把它接成完整的重试风暴。
11.2.2 M/M/c:加机器能缓解,但不能取消膝点
真实服务通常有多个服务台(线程、副本、连接)。M/M/c 的平均排队时间要用 Erlang C 公式算,但趋势和 M/M/1 一样: 时发散。下面是服务时间 1 个单位、 时的计算结果:
| 服务台数 c | Erlang C(要排队的概率) | 平均排队 Wq(服务时间的倍数) | 平均停留 W |
|---|---|---|---|
| 1 | 0.900 | 9.00 | 10.00 |
| 2 | 0.853 | 4.26 | 5.26 |
| 4 | 0.788 | 1.97 | 2.97 |
| 8 | 0.702 | 0.88 | 1.88 |
| 16 | 0.591 | 0.37 | 1.37 |
| 32 | 0.458 | 0.14 | 1.14 |
同样是 90% 利用率,单台要排队 9 个服务时间,32 台只要 0.14 个。池化(pooling)确实能显著降低排队,这也是为什么「把小实例合成大池子」常常有效。但注意: 固定为 0.9 时,c 从 1 加到 32,W 从 10 降到 1.14,仍然比服务时间多 14%;而且一旦 到 0.99,c=32 的 W 也会涨到约 4 个服务时间。加服务台把曲线压平,但没有取消膝点。
11.2.3 变异性:均值相同,p99 可以差十倍
M/M/1 假设到达和服务都是指数分布(变异系数 )。真实系统很少满足:流量有批处理、定时任务、重试脉冲;服务时间有 GC、锁等待、磁盘抖动、下游长尾。Kingman 公式给出了一个够用的近似:
其中 是到达间隔的变异系数(标准差/均值), 是服务时间的变异系数。 对应指数分布, 表示比指数更「尖峰/重尾」。
服务时间 20 ms、 时:
| 到达变异 | 服务变异 | Wq | 说明 |
|---|---|---|---|
| 1 | 1 | 180 ms | 教科书 M/M/1 |
| 1 | 4 | 450 ms | 服务端有 GC/锁等待 |
| 4 | 1 | 450 ms | 流量成批到达(定时任务、重试脉冲) |
| 4 | 4 | 720 ms | 批到达 + 长尾服务,最糟 |
这解释了三个常见现象:
- 同一份代码,白天 p99 比夜里高:白天到达是突发的( 大),夜里是均匀的。
- GC 一次,p99 抖一片:GC 让服务时间出现长尾( 大),即使平均服务时间没变。
- 重试会把 推高:重试不是均匀到达,而是一波一波同步到达;退避和抖动的作用之一就是降低 。
排队延迟不是「平均服务时间 × 请求数」,而是「服务时间 × 波动」。利用率决定放大器,变异性决定输入信号的幅度;两者相乘,才是你看到的 p99。
11.2.4 70% 不是定律,是经验起点
70% 来自「 时排队约 2.3 个服务时间」这一量级,不是普适定律。变异性低、池化程度高、有限流兜底的服务, 也可能很稳;有批处理、锁等待、下游长尾的服务, 就该警惕。正确的做法不是背 70%,而是在自己的系统上测出膝点:固定变异性,逐级加负载,画出「利用率—p99」曲线,把 p99 开始非线性上升的点当作告警线,把更低的点当作规划线。11.3 的梯度压测就是干这个的。
CPU 利用率是「CPU 忙的时间比例」, 是「瓶颈资源的利用率」。两者只有在 CPU 恰好是瓶颈时才接近。一个服务 CPU 60%、数据库连接池 100%, 是 1(连接池),系统已经在无界排队;此时看 CPU 会得出「还有 40% 余量」的错误结论。先找瓶颈,再算 。
11.3 压测:基线、梯度、开放/闭环
压测的目的不是「打出一个好看的 QPS」,而是回答四个问题:
- 在目标 SLO 下,单副本/单集群能承载多少 goodput?
- 瓶颈资源是什么,它在什么负载下先饱和?
- 过载时系统怎么坏:快速失败、排队、雪崩,还是抖动?
- 扩容/降级/限流是否按预期生效?
四个问题对应五类压测,而不是一次「打满」。
| 类型 | 目的 | 负载形状 | 时长 | 主要产出 |
|---|---|---|---|---|
| 基线 | 单请求成本、依赖 fan-out | 1–10 rps | 5–10 min | p50/p99、CPU/请求、调用链 |
| 梯度 | 找膝点与最大 goodput | 25/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):固定 个并发客户端,每个客户端发完一个请求、收到响应后再发下一个。
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 压测器本身也会成为瓶颈
开放压测要维持恒定速率,压测器必须有足够的并发和精度:
目标 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 令牌桶:允许突发,限制均值
令牌桶有两个参数:速率 (每秒补充多少 token)和桶容量 (最多攒多少)。请求消耗 token,不够就拒绝或排队。空闲时桶攒满,允许一次 的突发;持续打满时,长期速率被限制在 。
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 次。对下游来说,这和没限流差不多。
滑动窗口计数用前一窗口的加权值修正边界:
例:限制 100/min,上一窗口用了 80,当前窗口已过 15 秒(25%),本窗口已用 30,则估计值为 ,本窗口最多再放 10 个。它只需要存两个计数,精度足够大多数 API 限流。
| 算法 | 突发 | 是否整形 | 内存 | 分布式实现 | 适用 |
|---|---|---|---|---|---|
| 固定窗口 | 边界可 2× | 否 | O(1) | INCR + EXPIRE | 粗粒度配额 |
| 滑动窗口日志 | 精确 | 否 | O(请求数) | ZSET | 低频、强精确 |
| 滑动窗口计数 | 近似 | 否 | O(1) | Lua | 高频 API |
| 令牌桶 | 允许 | 否 | O(1) | Lua | 通用限流 |
| 漏桶 | 不允许 | 是 | O(队列) | 较难 | 出口整形、保护下游 |
| 并发限制 | 不适用 | 否 | O(1) | 信号量 | 保护连接池/线程池 |
最后一行值得单独说:并发限制常常比 QPS 限制更可靠。QPS 限制假设每个请求的成本固定;一旦下游变慢,同样的 QPS 会占用更多并发,QPS 限制就挡不住了。并发限制直接约束 ;由 Little’s law ,它会自动随 调整:延迟翻倍时,同样的并发只允许一半的吞吐。第 2.6 节的 Gate 就是并发限制。
11.4.3 分布式限流:本地、全局与热点
单机令牌桶在 N 个副本上会变成 N 倍限额:每个副本都以为自己有 1000/s,实际总量是 N×1000。分布式限流有三种常见结构:
| 结构 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 全局限流 | 所有请求查同一个 Redis/限流服务 | 精确 | 每次请求多一次 RTT;限流器成为单点 |
| 本地分片 | 每个副本分到 ,本地桶 | 无额外 RTT | 负载不均时浪费配额 |
| 两层 | 本地桶(快路径)+ 全局桶(总量) | 兼顾延迟与总量 | 实现复杂;两层参数要配合 |
两层结构的常用参数:本地桶速率 、突发 ;全局桶速率 、突发 。请求先过本地桶,再过全局桶;本地桶挡住大部分突发,只有接近配额时才查全局。分区时,本地桶仍能工作,但总量可能超出 ;这是一个显式的取舍:要可用性就接受短时超发,要精确就接受限流器不可用时拒绝。
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 分片上,把该分片打满。解决办法是把配额拆成 个子桶(例如 16 个),按 hash(request_id) % S 选一个,每个子桶速率 ;总量近似 ,误差最多 。代价是每个请求仍只查一个子桶,不会增加 RTT。
11.4.4 限流的响应:429、503 与 Retry-After
限流命中后返回什么,决定了上游会不会立刻再打回来:
| 场景 | 状态码 | 响应头 | 上游动作 |
|---|---|---|---|
| 租户配额耗尽 | 429 | Retry-After、X-RateLimit-Scope: tenant | 退避到指定时间,不要换 key 重试 |
| 全局限流 | 429 | Retry-After、X-RateLimit-Scope: global | 降速或排队 |
| 服务自身过载 | 503 | Retry-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()
四个实现细节比状态机本身更容易出错:
- 失败要分类:400、404、422 是业务结果,不是下游故障;把它们算进失败率,会在流量结构变化时误熔断。只统计超时、连接错误、5xx。
- 窗口要有最小请求数:低流量时 1 个失败就是 100% 失败率,会反复跳闸。
min_requests=20挡住这种噪声。 - HALF_OPEN 只放探测:如果半开状态放开全部流量,下游还没恢复就被再次打满,等于没有熔断。
- 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 | 自身过载 | 需要上游配合退避 |
降级要满足四个条件,否则它会把「可用性问题」变成「正确性问题」:
- 可观测:响应里带
degraded=true或X-Degraded: cache,指标里单独计数; - 有界:缓存兜底必须有 TTL 上限,不能把一小时前的库存当成现在;
- 可逆:降级开关要能一键恢复,不需要重新发布;
- 演练过:没演练过的降级路径,大概率在真正需要时抛异常。
大促期间库存服务超时,网关触发降级:读库存返回本地缓存的旧值。缓存 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 扩缩容的边界条件
自动扩缩容有三个必须手算的边界:
- 最小副本数由故障域决定,不由流量决定。三可用区、允许丢一个区,最小副本至少 3(每区 1),且要保证剩余两区能接住峰值。
- 最大副本数由下游容量决定。100 个副本、每个 20 条数据库连接,就是 2000 条连接;数据库上限 500 时,第 26 个副本开始就会连接失败。max 副本必须满足 。
- 缩容速度由在途请求和连接 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 依赖 | 熔断/重试/降级逻辑 | 无 |
| L1 | staging 全量 | 配置、部署、告警链路 | 无真实用户 |
| 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
脚本里有四个安全设计,值得抄到任何注入工具里:
- 白名单:只允许预声明的网卡,避免
IFACE=eth1打错到管理网; - 拒绝 loopback:本机所有服务共用
lo,注入等于自杀; - 自动清理:
trap cleanup EXIT INT TERM,即使脚本被 Ctrl-C 也能恢复; - dry-run:先打印将执行的
tc命令,确认参数再真跑。
11.7.5 Game day:角色、时间线、停止条件
Game day 是一次有组织的演练,不是一个人的即兴操作。最小配置四个角色:
| 角色 | 职责 | 不能兼任 |
|---|---|---|
| 指挥(IC) | 决定开始/停止、对外沟通 | 注入操作者 |
| 注入者 | 执行脚本、记录时间点 | 指挥 |
| 观察者 | 看指标、判断假设是否成立 | 注入者 |
| 记录者 | 时间线、决策、异常 | — |
时间线通常分四段:
- 准备(T−7 天):写假设、评审爆炸半径、准备回滚、通知相关团队、冻结变更;
- 基线(T−10 min):记录当前 SLO、错误率、容量,确认没有正在进行的故障;
- 注入(T0):按计划注入,观察者盯住预设指标,指挥有权随时叫停;
- 恢复与复盘(T+):清理注入、确认指标回到基线、24 小时内出复盘。
停止条件必须提前写死,至少包括:
- SLO 燃烧率超过阈值(例如 1 小时烧掉 10% 错误预算);
- 真实用户错误率超过 1%,且持续 2 分钟;
- 注入脚本无法清理,或清理后指标不恢复;
- 出现数据损坏/重复扣款迹象;
- 关键依赖(数据库、DNS、认证)出现非预期错误。
演练的第一原则是「可停止」。如果一次注入无法在 1 分钟内撤销,它就不该在生产做。第二原则是「可观测」:如果连「假设是否成立」都判断不了,演练只会产生争论,不会产生知识。
- 把注入期间的时间线、指标截图、告警记录归档;
- 逐条对照假设,标记「成立/不成立/无法判断」;
- 对每个「不成立」写出根因、修复项、负责人和复验日期;
- 把这次演练的脚本和参数收进仓库,下一次从同一基线复跑;
- 如果没有任何发现,先怀疑「故障太轻」或「观测太粗」,而不是庆祝系统完美。
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 | 队列 |
|---|---|---|---|---|---|---|---|---|
| 1 | 1500 | 120 ms | 1250 | 2% | 30 | 1530 | 1230 | 小 |
| 2 | 1500 | 250 ms | 690 | 18% | 270 | 1770 | 680 | 增长 |
| 3 | 1500 | 400 ms | 455 | 42% | 630 | 2130 | 450 | 接近满 |
| 4 | 1500 | 600 ms | 312 | 62% | 930 | 2430 | 310 | 溢出 |
| 5 | 1500 | 600 ms | 312 | 65% | 975 | 2475 | 305 | 丢弃 |
| 6(熔断+降载) | 1500 | 600 ms | 312 | 65% | 0 | 320 | 312 | 下降 |
看第二列和第七列:原始流量一直是 1500,涨的是重试。系统不是被更多用户压垮的,是被自己对失败的补偿压垮的。到第 5 轮,总到达是原始的 1.65 倍,goodput 只剩 305——多出来的 975 个请求几乎全在浪费线程和带宽。
11.8.2 正反馈环
① 下游变慢
│
▼
② 完成时间变长 ─────▶ ③ 超时率上升
▲ │
│ ▼
⑥ 有效容量下降 ◀── ⑤ 队列/并发上升 ◀── ④ 客户端重试
│
└────────────────▶ 回到 ①(下一轮更慢)
这个环的增益可以写成(不限制重试次数时):
其中 是失败率, 是每个失败请求平均再发的次数。 时没有有限的不动点,系统只能靠外部干预(限流、熔断、关重试)退出。 时也有放大: 时 , 时 。 真实系统的 不是常数,它随负载上升。把 代进去,稳定性条件是在不动点处 ;接近饱和时 急剧变大,这个乘积很容易越过 1。这就是为什么 70% 时一次抖动可能自愈,90% 时同样的抖动会直接雪崩。 代入数字:失败率 42% 时无限重试的放大倍数是 ,65% 时是 ;重试放大不是线性的,失败率越高,每一次重试越可能触发下一次重试。
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):触发因素已经消失,系统却停在坏状态里。原因有三:队列积压还没排空;故障期间排下的重试定时器在几十秒后同时到达;缓存大量失效,恢复期每次未命中都要回源。退出亚稳态不能只靠「等」,必须主动把入口压到系统能处理的水平,等队列排空、缓存预热、重试定时器消化完,再逐步放量。恢复期的容量不是稳态容量。
某服务 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 | 当前注入参数 | 与演练计划一致 | 与计划不符,说明规则被覆盖 |
dropped | qdisc 丢弃的包 | 接近配置的 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),说明已接近膝点,应该扩容或限流,而不是继续加负载。
11.10 真实案例:2021-12-07 AWS us-east-1 故障
2021 年 12 月 7 日,AWS 位于北弗吉尼亚的 us-east-1 区域发生了一次影响面很大的故障。AWS 官方的复盘把原因写得很克制:
| 项 | 公开事实 |
|---|---|
| 时间 | 2021-12-07 |
| 触发 | 内部网络中的一次自动化活动触发了非预期行为 |
| 直接影响 | 连接主 AWS 网络的网络设备出现拥塞 |
| 影响范围 | 控制面与多项服务受到影响 |
| 恢复 | 停止自动化、恢复网络设备、逐步恢复流量 |
| 复盘 | 官方 post-event summary |
这场故障不是「某台机器坏了」,而是自动化触发了网络拥塞。它和本章的关系,不是「网络细节」,而是四个容量与治理问题:
- 控制面在数据面的恢复路径上。控制面受损时,自动扩缩容、健康检查、服务发现、甚至控制台本身都可能不可用;依赖「出问题后自动扩容」的系统,恰好失去了扩容能力。
- 自动化的爆炸半径没有上限。一条自动化动作可以在几秒内影响大量设备;如果没有速率限制、分批执行、人工确认和回滚开关,自动化就是最高效的故障放大器。
- 重试与健康检查会放大恢复期负载。服务不可达时,客户端重试、负载均衡健康检查、内部依赖的探活会同时增加;恢复期系统最脆弱,却要面对高于稳态的请求量。
- 恢复容量不等于稳态容量。设备恢复后,路由收敛、连接重建、缓存预热都需要时间;按稳态流量一次性放量,会把刚恢复的系统再次推倒。
| 本章机制 | 在这次故障中的角色 | 缺失时的后果 |
|---|---|---|
| 容量余量 | 故障期间可用容量下降,需要 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 | 影响范围失控 |
某支付团队把生产流量回放到 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 非线性上升的利用率 | 梯度数据 |
| 5 | headroom | 稳态 ≤ 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 本章陷阱清单
- 用 CPU 利用率当容量口径:CPU 60% 但连接池 100% 时,系统已经在无界排队;先找瓶颈,再算 。
- 只压平均不压峰值:按日均流量规划,晚高峰的 2 倍峰值会直接越过膝点;容量按峰值和故障域算。
- 用闭环压测报开放延迟:系统变慢时闭环自动减压,漏掉 coordinated omission;固定到达率要用开放压测。
- 把压测 mock 当真实依赖:mock 1 ms、生产 300 ms,容量差一个数量级;基线必须接真实依赖或等价延迟。
- 把 headroom 当浪费:稳态跑到 80%,丢一个可用区后剩余节点直接过载;余量是故障预算。
- 限流按请求数不按成本:一个重查询和一次主键查询都算 1 QPS,瓶颈资源仍会被打穿;按 cost 计费。
- 单机限流当全局限流:N 个副本各限 1000/s,总量是 N×1000;要有全局桶或按副本分摊。
- 429 不给
Retry-After或给 0:客户端立刻重试,限流器变成随机数生成器;给估计恢复时间并加抖动。 - 熔断统计业务错误:400/404/422 被算成失败,流量结构一变就误熔断;只统计超时、连接错误、5xx。
- 熔断没有半开或没有退避:恢复瞬间放开全部流量,下游二次崩溃;半开只放探测,OPEN 时间指数退避。
- 舱壁做成全局线程池:一个慢依赖吃光所有线程,其他依赖陪葬;按依赖/租户隔离资源池。
- 回放工具默认有生产写权限:配置缺失、复用新键、无白名单,压测流量变成真实订单;默认 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 |
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 第一种 QPS;第二种 QPS。目标 1500 QPS 时,第二种情况的需求是容量的 3.75 倍,队列会无界增长,超时和重试会把有效容量进一步压低。
A2 Kingman: ms; 时 , ms。均值没变,只是变异性变大,排队时间翻了 2.5 倍。
A3 闭环压测在服务端卡住时只发出 100 个请求,另外本应在 2 秒内发出的约 10,000 个请求没有被记录;真实用户会排队,等待时间累积。要用开放压测(固定到达率),并从「计划发送时刻」计算延迟。
A4 前 200 个通过,后 50 个被拒;被拒请求最短等约 0.5 秒(50/100)。如果客户端立刻重试,这 50 个请求会在令牌还没补充时再次被拒,形成无效循环;应返回 Retry-After 并退避。
A5 失败率 ,低于 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.11 | 11.7、11.9 | 交叉引用 | 明确不重复 TCP、qdisc 内核路径、容器网络 |
| Google SRE 工作手册 | SLO、错误预算、过载保护 | 11.2、11.5、11.6 | 引用 | 只引用概念与公式,不复制案例 |
| 本书自编 + AWS 公开复盘 | us-east-1 2021-12-07 | 11.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.py | 7 个测试全过 |
| 2 | 丢响应后同键收敛 | bash fault_inject.sh --drop-response | effects == 1 |
| 3 | 提交后崩溃可恢复 | bash fault_inject.sh --crash | 重启后 status=succeeded,effects == 1 |
| 4 | 并发同键只一次 | bash fault_inject.sh --concurrent | 20 并发后 effects == 1 |
| 5 | 同键不同参拒绝 | 手工 curl 两次不同 amount | 第二次 409 |
这五条对应五个不变量。项目后面所有的代码、测试、脚本,都是为了让这五条成立。
读这张图要抓住三件事:同键请求走「返回原结果」而不是「再执行一次」;执行中靠租约防并发;租约过期后靠 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 不变量
- 键唯一:
calls.idem_key唯一,一个键只对应一条逻辑请求。 - 副作用唯一:
effects.call_id唯一,一次逻辑请求最多一条副作用。 - 指纹一致:同键不同指纹返回 409,不覆盖原结果。
- 租约互斥:同一时刻最多一个 worker 持有未过期租约。
- 恢复不重放终态:重启只重放
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 在客户端侧,不在服务端状态表里:服务端总是知道自己的记录状态,客户端不知道。
| 当前 | 事件 | 下一个 | 条件 |
|---|---|---|---|
| pending | worker 领取 | running | 租约可用 |
| running | 工具成功 | succeeded | effects 唯一 |
| running | 工具失败 | failed | error.retryable |
| running | 租约过期 | pending | 恢复线程 |
| failed | 同键重试 | pending | retryable=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"}}
三个细节:
key在循环外生成,循环内不变;- 只有超时/连接错误/429/503 才重试;400/409 立即返回;
- 退避加随机抖动,避免同步重试。
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/空 body | 400 |
create_or_get | 同键不同指纹 | 409 |
claim | 已被别人领取/终态 | 直接返回,不执行 |
_invoke | ToolError | fail 落状态 |
_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_effect | 12 并发同键只有一条 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 |
--concurrent | 20 个并发同键请求 | 只有一个 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 header | Idempotency-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 本章陷阱清单
- 重试时重新生成 UUID:服务端看到新请求,去重表失效;key 必须在第一次发送前生成并保存。
- 把
call_id当幂等键:call_id 是服务端生成的,第二次请求拿不到同一个;幂等键必须客户端生成。 - 只对成功响应做去重:服务端在
pending/running时也要能识别重复,返回原 call 而不是新建。 - 同键不同参静默返回旧结果:用户以为改了金额,实际没改;必须 409。
- 用
SELECT再INSERT而没有唯一约束:并发下会插两条;唯一约束是底线。 - 用
BEGIN DEFERRED做「查 + 插」:两个事务都可能读到不存在;用BEGIN IMMEDIATE或唯一约束兜底。 - 把租约当成绝对互斥:GC/暂停会让租约过期;
effects唯一约束和下游幂等才是最终保障。 - 租约短于工具执行时间:正常执行被接管,
attempt暴涨;租约要覆盖 p99 执行时间。 - 恢复时重放
succeeded:会重复副作用;恢复只处理pending和租约过期的running。 - 以为本地事务能覆盖外部副作用:外部调用不在 SQLite 事务里;必须把
effect_id传给下游做幂等。 - 压测用同一个键就宣称高吞吐:大部分请求只是读已提交记录;要用不同键测真实写入路径。
- 没有 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 + rowcount | 12.5 |
| 解释崩溃恢复 | 只重放 pending/过期 running | 12.7 |
| 用代理丢响应 | drop-first + 同键重试 | 12.11 |
用 kill 制造崩溃 | 提交后退出,重启查询 | 12.11 |
| 读懂 stats | calls/effects 的关系 | 12.11 |
| 做一次压测 | 不同键/同键各一组,报告 p50/p99 | 12.12 |
| 说出 Stripe 的对应关系 | key、结果保存、冲突 | 12.14 |
| 解释 K1/K2 两种结局 | 复用 vs 换键 | 12.15 |
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.11 | 12.6、12.8 | 交叉引用 | 明确不重复 TCP/socket/epoll/HTTP |
| 本书自编 + Stripe 文档 | Idempotent requests | 12.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.py | 6 个测试全过 |
| 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 不重复 |
这六条对应六个可观察结果。项目后面所有代码、测试和脚本,都是为了让这六条成立;如果某条不成立,先回到对应小节,不要靠「再跑一次」掩盖时序问题。
读这张图要抓住三件事:提交不是 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/2PC | Percolator、Spanner |
| 鉴权与 TLS | 本机回环,默认信任边界内 | mTLS、token、审计 |
这些非目标不是「以后再说」的借口,而是本章的边界。读完后你应该能判断:换到生产,哪些地方必须补,哪些不变量可以原样保留。
13.2 不变量与故障模型
Raft 的论文有五条安全性,但落到这个项目,最重要的是下面五条。它们不是口号,每一条都能在代码里找到对应位置。
- 选举安全:同一 term 最多一个 leader;每个节点每个 term 最多投一票,且投票持久化。
- 日志匹配:如果两个日志在相同 index 有相同 term,则该 index 及之前的日志完全相同;
prevLogIndex/prevLogTerm是它的检查点。 - 提交规则:leader 只直接提交当前 term 的日志项;旧 term 的日志项通过提交当前 term 的项间接提交。
- 去重:状态机对同一
(client_id, seq)最多应用一次;重复写返回第一次的结果。 - 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/STW | lease 可能过期;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(三节点中的任意两台)
这张图里有三个容易忽略的点:
- RPC 是双向的:leader 向 follower 发
AppendEntries,follower 也会向 leader 发RequestVote(选举时)。不存在单独的「从节点只读」通道。 - 每个节点都有完整日志和状态机:不是分片,不是主从只读副本;三个节点都能在选举后成为 leader。
- 客户端不参与 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-entries | leader | 复制日志/心跳 |
POST | /admin/partition | 故障注入 | 阻断与指定 peer 的 RPC |
POST | /admin/heal | 故障注入 | 清除阻断 |
/admin/* 没有鉴权,只适合本机教学;生产环境暴露它等于把集群交给任何人。
13.3.2 三个线程与一把锁
| 线程 | 周期 | 做什么 | 共享状态 |
|---|---|---|---|
_tick_loop | 20 ms | follower 检查选举超时;leader 发心跳、检查 quorum ack | role、election_deadline、last_quorum_ack |
_apply_loop | 20 ms | 把 commit_index 之前的日志应用到 SQLite | last_applied、kv、dedup |
| HTTP handler | 每请求 | 处理投票、复制、客户端读写 | 全部,靠 Node.lock(RLock)保护 |
Node.lock 是粗粒度锁:选举、复制状态、日志数组、lease 都在它下面。这样做的好处是不会出现「日志和 commitIndex 各改各的」;代价是吞吐受限,且持锁时不能做慢 I/O。项目里 _post 和 SQLite 写入都在锁外或短临界区,读者可以对照代码看边界。
13.3.3 运行参数
| 参数 | 默认值 | 含义 | 调大/调小的后果 |
|---|---|---|---|
--election-ms | 500 | 选举超时基准;实际随机 0.5–1.5 倍 | 太小易误选;太大故障恢复慢 |
--heartbeat-ms | 100 | leader 心跳间隔 | 太大 follower 易超时;太小 RPC 多 |
--lease-ms | 800 | leader lease 时长 | 太长分区期间旧主读窗口大;太短读容易 lease_expired |
quorum | 2 | (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、选举超时后变 candidate | term 更小的 RPC |
| candidate | term+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。
三节点的两个多数派必然相交({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 的五个关键字段
| 字段 | 作用 | 如果错了 |
|---|---|---|
term | leader 的任期 | follower 可能接受旧 leader |
prev_log_index | 新 entry 前一条的 index | 无法定位前缀 |
prev_log_term | 前一条的 term | 只看 index 会接受冲突日志 |
entries | 从 prev+1 开始的日志 | 空洞、重复、顺序错 |
leader_commit | leader 已知的提交位置 | follower 提交滞后或越界 |
follower 的处理顺序是:
- 如果
term < current_term,拒绝; - 如果
term > current_term,更新 term、退回 follower; - 如果
prev_log_index > last_log_index,拒绝,并返回自己的last_log_index; - 如果
prev_log_index > 0且log[prev-1].term != prev_log_term,拒绝,并返回prev-1; - 对
entries逐条:如果 index 已存在且 term 相同,跳过;如果 term 不同,从该 index 截断;如果 index 不连续,拒绝; - 追加新 entry;
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),返回 503no_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:返回 503leadership_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 留下的旧值? 项目给了三条路径,代价和安全性各不相同。
13.7.1 三条路径
consistency | 谁可以服务 | 机制 | 失败时 | 安全性 |
|---|---|---|---|---|
linearizable | 仅 leader | 记录 commit_index 作为 readIndex;向多数派发 AppendEntries 确认 leadership;等 applied >= readIndex;读本地 kv | 503 no_quorum / apply_lag | 分区下安全;多一次 RTT |
lease | 仅 leader | lease_until > now 时直接读 commit_index 对应的状态机;等 applied | 503 lease_expired / apply_lag | 依赖 lease 与时钟;分区窗口内可能旧读 |
stale | 任意节点 | 直接读本地 last_applied 的 kv | 200,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
两步缺一不可:
- 确认 leadership:向多数派发一次 AppendEntries,收到同 term 的成功响应。如果旧 leader 被分区,它拿不到多数派,返回 503;如果它已经看到更高 term,
_step_down会让role != leader,也返回 503。 - 等 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 什么时候写盘
| 事件 | 写入 | 是否在回复前 |
|---|---|---|
| 收到更高 term | current_term、清空 voted_for | 是 |
| 投票给候选人 | voted_for | 是 |
| 追加日志 | log 一行 | 是(leader 复制前) |
| 推进 commit | commit_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__ 的顺序是:
_init_db():建表(IF NOT EXISTS);_load():从meta读 term/vote/commit/applied,从log读日志;_reset_election_deadline():给一个随机选举超时;- 启动
_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_leader | term、voted_for、votes | 拿不到 quorum,等下一轮 |
| 写 | handle_put → _append_log → _replicate_peer → handle_append → _advance_commit → _apply_entry | next_index、match_index、commit_index | 202/503;客户端同 seq 重试 |
| 读 | handle_get → _read_index → _replicate_peer → _wait_applied → SELECT kv | read_index、last_applied | 503 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_put | commit 超时且仍 leader | 返回 pending | 202 |
handle_put | commit 超时且失去 leadership | 返回 leadership_lost | 503 |
handle_get | 非 leader 且 linearizable/lease | _redirect() | 307 或 503 |
handle_get | ReadIndex 拿不到 quorum | no_quorum | 503 |
handle_get | applied 追不上 | apply_lag | 503 |
handle_append | prev 不匹配 | 返回 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_linearizable | put k1,立刻 linearizable get | 200,值 v1,stale=false | 基本写读闭环 |
test_2_stale_read_from_follower | 向 follower 轮询 stale get | 最终读到 v2,stale=true | follower 能追上,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 | 三节点指向同一个 id | follower 指向不存在的 id;leader 指向别人 |
commit_index | 单调不减;leader 与多数派一致 | 倒退(除非重启加载旧库);落后很多 |
last_applied | 接近 commit_index | 长期小于 commit,说明 apply 卡住 |
log_len | >= commit_index | 大于 commit 是未提交尾巴;异常增长要查选举/重复写 |
last_log_term | leader 通常等于 term(no-op 之后) | 远小于 term,可能还没提交当前 term |
lease_remaining_ms | leader > 0;follower 0 | leader 长期 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 检查哪两件事
- 读后写:如果一条写 W 在时间上已经返回,之后才开始的一条非 stale 读 R 读同一个 key,那么 R 看到的版本不能早于 W。
- 重复身份:同一个
(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 拿不到多数派 ack | 503 no_quorum,或 step down 后 307/503 | 安全;拒绝比返回旧值好 |
lease | lease 未过期时直接读本地 | 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 做的正是这条时间线:
- 向 leader 写
kp=vp,确认 200; - 隔离 leader 与另外两个节点;
- 等 1.2 秒,向旧 leader 发 linearizable get,断言 503;
- 等新 leader,向新 leader 发 linearizable get,断言 200 且值仍为
vp; - 解除隔离,再读一次,断言值仍是
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 做过公开分析,把选举、线性一致读和故障注入放在一起检验。这里不引用具体数字,只引用结论层面的教训:
- 默认路径要安全:强一致读不能靠调用方记得加参数;默认拒绝旧读,显式选择才降级。
- 读路径也要故障注入:只测「leader 崩溃后能选新 leader」不够,还要测「旧 leader 还能不能服务读」。
- lease 是性能优化,不是安全证明:它把正确性押在时钟和租约上;分区/暂停时要有退路。
| etcd | 项目二 | 差异 |
|---|---|---|
| 默认线性一致读 | 默认 consistency=linearizable | 两者默认都安全;项目允许显式降级 |
| ReadIndex + 等 apply | _read_index + _wait_applied | 项目复用 AppendEntries 当心跳 |
| serializable read | consistency=stale | 项目明确标 stale=true |
| lease read | consistency=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
三次调用的结果(节选):
| 调用 | duplicate | version | 日志 | 状态机 |
|---|---|---|---|---|
第一次 (c1,7) | false | 2 | 新增 idx=2 | 应用一次 |
第二次 (c1,7) | true | 2 | 不新增 | 不应用 |
第三次 (c1,8) | false | 3 | 新增 idx=3 | 再应用一次 |
对这个项目的 put 来说,第三次只是把同一个 value 又写了一遍,kv 的值不变;如果命令是 incr、append 或 transfer,第三次就是第二次副作用。去重键保护的是逻辑请求,不是 key;换 seq 等于告诉服务端「这是一个新请求」。
13.15.2 为什么 apply 时还要去重
考虑这个时序:
- 旧 leader 追加了
(c1,7),已提交但响应丢失; - 新 leader 当选,但还没 apply 到这条 entry;
- 客户端重试
(c1,7)到新 leader; - 新 leader 的
handle_put查 dedup,查不到(还没 apply),于是又追加一条(c1,7); - 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 本章陷阱清单
- 把 307 当失败,换节点后换新 seq 重发:新 seq 是新逻辑请求,去重救不了;307 应跟随,重试应复用原 seq。
- 不传
client_id/seq:handle_put不强制这两个字段,缺省时 dedup 静默关闭,重复写会变成多条日志。 - 同 seq 不同 value 以为会覆盖:项目直接返回第一次结果,第二次 value 被忽略;生产应加指纹并返回 409。
- 只在
handle_put查 dedup:新 leader 还没 apply 时查不到,仍会追加重复 entry;正确性靠_apply_entry的唯一约束。 - 把
lease读当线性一致读:默认lease_ms=800大于election_ms的上界 750,分区时有旧读窗口;强一致用linearizable。 - 以为
stale读会失败:它返回 200 和stale=true,可能found=false;调用方必须自己判断。 - 把
commit_index当last_applied:commit 只表示多数派持久化,状态机可能还没 apply;读之前要等 applied。 - 直接提交旧 term 的日志:多数派复制不等于可提交;必须当前 term 的 entry 先提交,旧 entry 才能间接提交。
- 只看
prev_log_index不看prev_log_term:同 index 不同 term 是冲突日志,必须截断;只比 index 会接受错误历史。 - 以为 no-op 只是占位:no-op 让新 leader 建立当前 term 的提交点,并间接提交旧 term 已提交项;没有它,commitIndex 可能长期不动。
- 把
/admin/*暴露到生产:/admin/partition和/admin/heal没有鉴权,任何人都能切断集群。 - 把三节点本机测试当跨机房验证:没有真实延迟、丢包、时钟跳变、磁盘故障;而且
/admin/partition是应用层拒绝而非丢包,隔离节点的高 term 仍可能通过错误响应泄漏,heal 后出现选举抖动;参数要按生产重测。
13.17 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出五条不变量 | 选举安全、日志匹配、提交规则、去重、ReadIndex | 13.2 |
| 画出三节点架构 | 三个进程、两种 RPC、一个状态机、quorum=2 | 13.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_peers | 13.11 |
| 复现分区与杀主 | fault_inject.sh --partition、--kill-leader、--heal | 13.11 |
| 解释旧主读 | 为什么 linearizable 503、lease 可能旧、stale 明确旧 | 13.13 |
| 做一次一致性检查 | 能写出读后写与重复 seq 的检查器,并说明它没证明什么 | 13.12 |
| 说出 etcd 的对应 | ReadIndex、serializable、lease 的取舍 | 13.14 |
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 read | 13.4–13.7、13.14 | 完整 | 只引用公开设计事实,不复制论文或 etcd 源码 |
| 本书项目代码 | projects/replicated-kv/ | 13.1–13.12 | 完整 | 所有代码为本书编写,6 个测试实测 |
下一站:项目二证明了「三节点可以选主、复制、线性一致读」,但还没有回答「集群怎么在线扩容、日志怎么压缩、跨机房延迟怎么影响选举」。进入 A.4 交付自检与回归清单,把本章的验收命令、故障注入和检查器固化成交付前的回归清单。
A.1 母本与主题覆盖矩阵
1 怎么读
这张表有两个用途:
- 主题覆盖:任务书要求的数据面主题,每一项都必须能在正文找到一个可跳转的节;
tools/check_coverage.py会逐行核对锚点是否存在。 - 母本对应:四本母本(谢希仁、张彦飞、《Python 网络编程》、《Linux 系统与内核》)的哪些章节被本书引用、哪些明确不重复。母本不覆盖的分布式协议,标为「本书自编 + 论文/规范」。
「深度」列的含义:
- 完整:概念、机制、失败路径、观测、例子都在正文;
- 精选:核心机制在正文,冷门细节指路;
- 背景:只借用事实/视角,不展开;
- 交叉引用:本书不写,指向 Linux 书对应篇章。
2 主题覆盖(55 项,缺口 0)
| # | 主题 | 本书落点 | 锚点 | 深度 | 说明 |
|---|---|---|---|---|---|
| 1 | 请求路径与延迟预算 | 1.1、1.4 | sec-distributed.1.1.1 | 完整 | 12 个 hop、逐跳预算、p99 放大 |
| 2 | 部分失败与 unknown 状态 | 1.3、1.5、1.6 | sec-distributed.1.1.5 | 完整 | 七类失败、状态机、超时三种真相 |
| 3 | 超时与 deadline 传播 | 2.2 | sec-distributed.1.2.2 | 完整 | 绝对 deadline、逐跳换算、取消语义 |
| 4 | 重试、退避与抖动 | 2.3 | sec-distributed.1.2.3 | 完整 | full jitter、错误分类、hedged request |
| 5 | 重试预算与放大倍数 | 2.5 | sec-distributed.1.2.5 | 完整 | 正反馈、同步重试、止损顺序 |
| 6 | 幂等键与请求指纹 | 2.4 | sec-distributed.1.2.4 | 完整 | 键粒度、规范化、唯一约束 |
| 7 | 去重表与状态机 | 12.3、12.5 | sec-distributed.5.12.5 | 完整 | calls/effects/audit、claim、succeed |
| 8 | 背压与并发上限 | 2.6 | sec-distributed.1.2.6 | 完整 | Little’s law、有界队列、拒绝策略 |
| 9 | 限流与过载保护 | 2.7 | sec-distributed.1.2.7 | 完整 | 令牌桶、429/503、Retry-After |
| 10 | 单主复制 | 3.1、3.2 | sec-distributed.2.3.2 | 完整 | 同步/异步/半同步、RPO/RTO |
| 11 | 多主复制与冲突解决 | 3.5、3.6 | sec-distributed.2.3.5 | 完整 | LWW、版本向量、CRDT |
| 12 | 无主复制与 quorum | 3.7 | sec-distributed.2.3.7 | 完整 | R+W>N、读修复、hinted handoff、Merkle |
| 13 | 复制延迟与读己之写 | 3.8 | sec-distributed.2.3.8 | 完整 | lag 来源、会话保证 |
| 14 | 线性一致性 | 4.2 | sec-distributed.2.4.2 | 完整 | 实时序、全序、判定与反例 |
| 15 | 顺序一致性 | 4.3 | sec-distributed.2.4.3 | 完整 | 与线性一致的差异 |
| 16 | 因果一致性与会话保证 | 4.4 | sec-distributed.2.4.4 | 完整 | happens-before、RYW、单调读 |
| 17 | 最终一致性与收敛 | 4.5 | sec-distributed.2.4.5 | 完整 | 收敛条件、反熵、读修复 |
| 18 | CAP 与 PACELC 的精确表述 | 4.6 | sec-distributed.2.4.6 | 完整 | 分区时选什么、无分区时的 L/C |
| 19 | Paxos | 5.3 | sec-distributed.2.5.3 | 完整 | prepare/promise/accept/accepted |
| 20 | Multi-Paxos 与活锁 | 5.4 | sec-distributed.2.5.4 | 完整 | 双提案者互锁、稳定 leader |
| 21 | Raft 选举 | 5.5 | sec-distributed.2.5.5 | 完整 | term、投票、随机超时、选举限制 |
| 22 | Raft 日志复制与提交 | 5.6 | sec-distributed.2.5.6 | 完整 | prevLogIndex/term、当前 term 提交 |
| 23 | 成员变更与快照 | 5.7 | sec-distributed.2.5.7 | 精选 | 单步/联合共识、快照截断 |
| 24 | 线性一致读(ReadIndex/lease) | 5.8、13.7 | sec-distributed.2.5.8 | 完整 | ReadIndex、lease read、follower read |
| 25 | ACID 与事务边界 | 6.1 | sec-distributed.3.6.1 | 完整 | C 是应用不变量、短事务 |
| 26 | 隔离级别与异常 | 6.2 | sec-distributed.3.6.2 | 完整 | 脏读/不可重复/幻读/写偏斜 |
| 27 | MVCC 可见性 | 6.3、6.4 | sec-distributed.3.6.3 | 完整 | 版本链、read view、xmin/xmax |
| 28 | 分布式事务 2PC/TCC/Saga | 6.6 | sec-distributed.3.6.6 | 完整 | 阻塞、空回滚、补偿、选型 |
| 29 | Outbox 与幂等消费 | 6.7 | sec-distributed.3.6.7 | 完整 | 双写、CDC、去重 |
| 30 | 队列与日志的区别 | 7.1 | sec-distributed.3.7.1 | 完整 | 删除 vs 保留、重放 |
| 31 | 投递语义 | 7.2 | sec-distributed.3.7.2 | 完整 | 最多/至少/有效一次 |
| 32 | 分区、顺序与消费者组 | 7.3 | sec-distributed.3.7.3 | 完整 | 分区键、顺序范围、rebalance |
| 33 | offset 提交与再平衡 | 7.4 | sec-distributed.3.7.4 | 完整 | 提交窗口、重复投递 |
| 34 | Kafka ISR/acks/事务 | 7.5 | sec-distributed.3.7.5 | 完整 | acks=all、min.insync、幂等生产者 |
| 35 | CDC 与事件溯源 | 7.6 | sec-distributed.3.7.6 | 完整 | outbox、binlog、重放 |
| 36 | 对象存储语义 | 8.1、8.2 | sec-distributed.3.8.1 | 完整 | ETag、条件写、多段上传、强一致 |
| 37 | 缓存模式与一致性 | 8.3、8.4 | sec-distributed.3.8.3 | 完整 | cache-aside、双写、延迟双删 |
| 38 | 缓存击穿/穿透/雪崩 | 8.5 | sec-distributed.3.8.5 | 完整 | singleflight、空值、随机 TTL |
| 39 | CDN 与边缘缓存 | 8.6 | sec-distributed.3.8.6 | 完整 | anycast、cache key、purge |
| 40 | 分布式锁 | 9.1、9.2 | sec-distributed.4.9.2 | 完整 | SET NX PX、Lua、续期、主从丢锁 |
| 41 | 租约与 fencing token | 9.6、9.7 | sec-distributed.4.9.6 | 完整 | 单调 token、资源端校验、GC 停顿 |
| 42 | 选主与分裂脑 | 9.5 | sec-distributed.4.9.5 | 完整 | bully、Raft、lease、旧主自停 |
| 43 | trace 与上下文传播 | 10.2 | sec-distributed.4.10.2 | 完整 | traceparent、span、采样 |
| 44 | 指标与分位数 | 10.3 | sec-distributed.4.10.3 | 完整 | RED/USE、histogram、exemplar |
| 45 | 结构化日志 | 10.4 | sec-distributed.4.10.4 | 完整 | 字段、trace id、成本 |
| 46 | SLO 与错误预算 | 10.6 | sec-distributed.4.10.6 | 完整 | SLI/SLO、burn rate |
| 47 | 回放与影子流量 | 10.7 | sec-distributed.4.10.7 | 完整 | 确定性重放、副作用隔离 |
| 48 | 容量与 Little’s law | 11.1 | sec-distributed.4.11.1 | 完整 | L=λW、headroom |
| 49 | 排队与利用率曲线 | 11.2 | sec-distributed.4.11.2 | 完整 | 1/(1-ρ)、变异性 |
| 50 | 压测方法 | 11.3 | sec-distributed.4.11.3 | 完整 | 基线、梯度、开放/闭环 |
| 51 | 降级、熔断与舱壁 | 11.5 | sec-distributed.4.11.5 | 完整 | 熔断状态机、隔离、优先级 |
| 52 | 故障演练与爆炸半径 | 11.7 | sec-distributed.4.11.7 | 完整 | 假设、注入、回滚、game day |
| 53 | 项目一:幂等工具调用后端 | 12.1–12.17 | sec-distributed.5.12.1 | 完整 | 可运行服务、7 测试、3 类故障 |
| 54 | 项目二:小型复制 KV | 13.1–13.17 | sec-distributed.5.13.1 | 完整 | 三节点、选举、复制、一致性读 |
| 55 | 项目二的一致性读(ReadIndex/lease/stale) | 13.7、13.13 | sec-distributed.5.13.7 | 完整 | 三条读路径、分区旧主、测试证据 |
校验:
python3 tools/check_coverage.py。任何一行锚点不存在、或 REQUIRED 主题缺失,都会退出非 0。
3 母本对应
3.1 谢希仁《计算机网络》第 8 版
| 母本 | 章节 | 本书落点 | 锚点 | 处理 |
|---|---|---|---|---|
| 谢希仁 | 第 1 章 概述(分层、协议) | 1.1、1.11 | sec-distributed.1.1.1 | 背景:只借用分层视角;不重写体系结构 |
| 谢希仁 | 第 5 章 运输层(TCP/UDP) | 1.1、1.8、2.2 | sec-distributed.1.2.2 | 交叉引用:TCP 状态机、拥塞控制见 Linux 书 4.4–4.6 |
| 谢希仁 | 第 6 章 应用层(HTTP/DNS) | 8.6、12.4 | sec-distributed.3.8.6 | 背景:HTTP 语义只用于缓存/重试;报文细节不重写 |
| 谢希仁 | 第 7 章 网络安全 | 2.2、8.1 | sec-distributed.1.2.2 | 背景:只提 mTLS/鉴权边界,不写 TLS 握手 |
| 谢希仁 | 第 8–10 章(音视频、无线、下一代互联网) | — | — | 淘汰:与数据面主线无关 |
3.2 张彦飞《深入理解 Linux 网络》
| 母本 | 章节 | 本书落点 | 锚点 | 处理 |
|---|---|---|---|---|
| 张彦飞 | 第 4–5 章(TCP 连接、收发) | 1.1、2.2、12.6 | sec-distributed.5.12.6 | 背景:连接复用/超时只引用;不重写内核路径 |
| 张彦飞 | 第 6 章(连接队列、握手) | 1.1、1.8 | sec-distributed.1.1.1 | 交叉引用:队列/握手细节见 Linux 书 4.5、4.11 |
| 张彦飞 | 第 7–8 章(socket、epoll) | 12.6、13.9 | sec-distributed.5.13.1 | 背景:只讲用户态服务模型;epoll 实现不重写 |
| 张彦飞 | 第 9–10 章(性能、容器网络) | 11.1、11.9 | sec-distributed.4.11.1 | 精选:容量/观测借用指标;容器网络见 Linux 书第 5 篇 |
3.3 《Python 网络编程》
| 母本 | 章节 | 本书落点 | 锚点 | 处理 |
|---|---|---|---|---|
| Python 网络编程 | socket/HTTP 客户端与服务端 | 12.4、12.8、13.6 | sec-distributed.5.12.8 | 精选:工程风格;代码全部重写 |
| Python 网络编程 | 并发服务器/线程 | 12.6、12.9 | sec-distributed.5.12.9 | 精选:只讲与本项目相关的线程/队列 |
| Python 网络编程 | 协议解析/超时 | 2.2、2.3 | sec-distributed.1.2.3 | 背景:不复制示例,不写 socket API 手册 |
3.4 《Linux 系统与内核》
| 母本 | 章节 | 本书落点 | 锚点 | 处理 |
|---|---|---|---|---|
| Linux 书 | 第 4 篇 4.1–4.12(网络) | 1.1、2.2、8.6、12.6 | sec-distributed.1.1.1 | 交叉引用:TCP/IP、socket、epoll、HTTP/DNS/TLS 不重写 |
| Linux 书 | 第 5 篇 5.1–5.6(容器/K8s) | 11.6、11.7 | sec-distributed.4.11.7 | 交叉引用:容器/编排基础不重写 |
| Linux 书 | 第 2/3 篇(系统编程/内核) | 12.6、13.8 | sec-distributed.5.13.8 | 背景:线程/文件/持久化只借用概念 |
3.5 本书自编 + 论文/规范
| 材料 | 本书落点 | 锚点 | 处理 |
|---|---|---|---|
| Raft 论文(Ongaro & Ousterhout, 2014) | 5.5–5.8、13.4–13.7 | sec-distributed.2.5.5 | 自编讲解 + 代码;不复制论文原文/图 |
| Paxos 论文(Lamport, 1998/2001) | 5.3–5.4 | sec-distributed.2.5.3 | 自编简化模拟器;不复制原文 |
| Dynamo 论文(DeCandia et al., 2007) | 3.7 | sec-distributed.2.3.7 | 只引用 quorum/vector clock 思想 |
| Spanner 论文(Corbett et al., 2012) | 6.10 | sec-distributed.3.6.10 | 只引用 TrueTime/2PC 公开结论 |
| Jepsen: MongoDB 3.4(2017) | 4.10 | sec-distributed.2.4.10 | 只引用公开结论 + 链接 |
| Jepsen: etcd 3.4.3(2020) | 5.11、9.9 | sec-distributed.2.5.11 | 只引用公开结论 + 链接 |
| Stripe Idempotent requests | 12.14 | sec-distributed.5.12.14 | 只引用公开接口语义 |
| AWS S3 2017 / GitHub 2018 / Cloudflare 2019 / AWS 2021 / GitLab 2017 | 2.9、3.11、8.9、11.10、10.10 | sec-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 handoff | https://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) | 大规模系统为什么避免 2PC | https://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: Design | ISR、acks、幂等生产者、事务 | https://kafka.apache.org/documentation/#design |
| Amazon S3 Consistency Model | S3 的强一致读后写语义 | 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-call | https://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 Context | traceparent / tracestate 传播格式 |
| OpenTelemetry Specification | trace/metrics/logs 的 API 与语义约定 |
本书不复制论文原文、不搬运事故复盘的长段文字;只提取可核对的事实(时间、组件、失败链条、官方结论)并给出链接。若你发现链接失效,按标题搜索原始来源,不要依赖二手转述。
A.4 交付自检与回归清单
这份清单给两类人用:写完一章的作者,和准备把书交付出去的维护者。它只列可机械检查或可复现的项,不列「文笔是否优美」。
1 单章交付
| # | 检查项 | 命令/证据 | 通过标准 |
|---|---|---|---|
| 1 | 行数 | wc -l src/partN/chXX.md | 1000–1400 |
| 2 | 章首两栏 | 搜索 chapter-index 与 本章回答 | 两项都在 |
| 3 | 编号连续 | 检查 H2 编号 X.Y.Z | 无跳号、无重复 |
| 4 | 锚点唯一 | python3 tools/check_book.py | 0 重复 |
| 5 | 盒子配平 | 同上 | 0 未闭合 |
| 6 | 只用允许盒子 | 同上 | 仅 def/thm/ex/intuition/method/errata/note/proof/sol |
| 7 | 代码围栏有语言 | 同上 | 0 个无语言围栏 |
| 8 | 失败模式 | 搜索 失败模式 | 至少 1 个,且属于分区/超时/重复/乱序 |
| 9 | 观测命令 | 搜索 观测 | 至少 1 个可复制命令 |
| 10 | 真实案例 | 搜索 真实案例 | 至少 1 个,含可核对链接或时间线 |
| 11 | 重试重复副作用 | 搜索 重复副作用 | 至少 1 个案例 |
| 12 | 陷阱清单 | 末尾 ::: errata | 8–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.py | 0 问题、0 断链、0 重复锚点 |
| 3 | 覆盖矩阵 | python3 tools/check_coverage.py | 0 缺口 |
| 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 | 图 id | grep -o 'id="[^"]*"' src/assets/figures/*.md | 跨文件唯一 |
| 12 | 覆盖矩阵与正文一致 | 抽查 10 个锚点 | 全部能跳转 |
3 回归清单(每次改正文后)
- 改了某章的节号 → 同步改该章锚点、目录、覆盖矩阵、其他章的链接;
- 改了代码 → 重跑项目测试与
fault_inject.sh --dry-run,更新正文里的行号/输出; - 改了图 → 重跑
python3 tools/build_figures.py,检查 SVG id 是否仍唯一; - 改了 SUMMARY → 重跑
check_book.py,确认新增页面存在且非空; - 改了协议事实 → 在
CHECK-distributed.md里记一条,写清依据(论文/规范/版本)。
4 常见「假通过」
| 现象 | 为什么是假通过 | 正确做法 |
|---|---|---|
check_book.py 过了,但章只有 600 行 | 脚本只查 ≥150 行 | 用单章清单第 1 项单独卡 1000–1400 |
所有代码块都标了 text | 规避了语言检查 | 可运行代码必须标真实语言;伪代码才用 text |
链接指向 # 或空页面 | 没触发断链 | 链接必须有真实目标;跨书用绝对站点路径 |
| 覆盖矩阵每行都写「第 X 章」 | 没有锚点,无法核对 | 每行必须有可跳转的 sec-... 锚点 |
| 测试只跑 happy path | 重复/超时/崩溃没覆盖 | 项目测试至少包含丢响应、并发重复、崩溃恢复 |
故障脚本只 echo | 没注入任何故障 | --dry-run 可只打印;真实模式必须改变系统状态 |
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,先修再谈内容。