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 一致性模型:线性/顺序/最终,把「旧值」「新值」「并发」写成可判定的历史,再用线性一致、顺序一致和最终一致去约束复制系统的读路径。