Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

本章回答

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

读完你会

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

定义 3.1.1 · 复制、RPO 与 RTO

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

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

3.1.2 复制不是备份

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

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

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

三副本不等于三个故障域

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

3.1.3 同步与异步的延迟代价

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

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

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

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

异步复制则是:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

定义 3.2.1 · 提交点与可见性

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

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

3.2.2 半同步的 quorum 变体

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

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

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

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

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

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

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

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

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

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

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

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

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

3.3 复制日志与状态机

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

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

3.3.1 日志里有什么

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

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

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

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

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

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

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

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

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

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

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

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

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

3.3.4 快照:日志不能无限长

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

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

3.3.5 一个确定性的 apply 骨架

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

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

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

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

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

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

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

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

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

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

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

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

3.4.1 一次 failover 的时间线

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

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

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

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

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

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

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

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

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

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

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

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

3.4.3 fencing:把旧 leader 挡在资源外

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

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

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

常见的 fencing 手段:

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

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

3.4.4 节点角色状态机

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

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

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

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

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

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

3.5 多主复制:冲突从哪来

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

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

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

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

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

3.5.1 冲突的六个来源

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

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

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

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

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

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

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

定义 3.5.1 · 写冲突与读冲突

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

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

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

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

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

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

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

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

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

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

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

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

LWW 还有几个变体问题:

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

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

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

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

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

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

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

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

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

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

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

版本向量也有成本:

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

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

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

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

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

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

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

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

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

定义 3.6.1 · 因果序与并发

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

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

3.6.4 三种方案怎么选

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

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

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

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

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

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

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

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

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

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

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

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

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

定理 3.7.1 · quorum 相交

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

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

3.7.2 R + W > N 的八个边界

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

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

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

3.7.3 一个 3 副本 quorum 模拟器

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

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

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


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

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

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

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

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


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

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

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


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


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


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

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

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

运行结果:

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

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

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

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

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

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

3.7.4 为什么 quorum 不是线性一致

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

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

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

「强一致 quorum」的说法

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

3.7.5 读修复与反熵

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

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

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

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

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

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

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

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

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

3.7.8 无主复制的适用边界

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

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

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

3.8 复制延迟与读己之写

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

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

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

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

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

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

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

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

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

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

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

import time

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

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

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

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

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

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

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

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

单调读与负载均衡

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

3.8.4 lag 与 failover 的交叉点

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

定义 3.9.1 · 分裂脑

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

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

只读模式要落在存储层

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

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

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

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

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

3.10.1 PostgreSQL:把 WAL 位置拆成四段

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

示例输出:

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

怎么读:

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

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

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

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

示例输出:

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

怎么读:

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

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

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

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

示例输出:

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

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

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

3.10.4 正常与异常的分界

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

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

空闲数据库的 lag 会骗人

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

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

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

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

3.11.1 失败链

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

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

3.11.2 五条可执行教训

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

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

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

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

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

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

3.12.1 一条重复扣款的时间线

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

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

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

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

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

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

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

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

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

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

关键点:

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

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

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

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

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

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

3.13 本章陷阱清单

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

3.14 本章出口检查

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

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

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

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

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

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

答案

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

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

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

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

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

母本对应

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

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