Chapter 04

复制:把同一份数据放到多台机器

第 03 章建立了一致性目标的谱系——从线性一致到最终一致。这一章看第一种把数据分散到多台机器的方式:复制(同一份数据的多个副本)。每种复制策略落在一致性谱系的哪一档、各自在什么条件下失效,构成本章的核心线索。

本章你将建立的 schema

  • 单主复制中同步与异步是持久性与延迟的取舍;异步 failover 可能丢失已向客户端确认的写
  • 多主复制的核心难题是写冲突;LWW 用物理时间戳排序会静默丢已确认的写,版本向量保留并发 siblings 但代价是把合并责任推给应用
  • 无主 quorum R+W>N 只是集合交集论证,不是线性一致;sloppy quorum 是持久性手段而非一致性手段
  • 任意两个多数派必交叠至少一个节点——这个几何事实防止脑裂;奇数节点比偶数节点容更多故障

4.1单主复制(single-leader)

所有写走唯一 leader,follower 重放 leader 的日志并分担读。

为什么需要它

没有指定写入点,任意两个节点可以并发接受写,冲突立刻出现。单主把写序列化在一个节点上,把冲突问题推迟到多主或无主场景才需要面对。

leader 将每次写操作追加到复制日志(replication log / WAL),follower 按日志顺序重放,最终追上 leader 的状态。关键分叉在于 leader 何时向客户端返回"写入成功":

同步复制(synchronous):leader 等至少一个 follower 写入并确认,再提交并回复客户端。这个 follower 提供了第二份持久副本——即使 leader 在确认后立刻崩溃,该 follower 持有完整写记录。代价是:那个同步 follower 一旦变慢,整个写路径都被阻塞。实务中通常只让一个 follower 同步(semi-synchronous),其余走异步,兼顾持久性与吞吐。

异步复制(asynchronous):leader 写入自身日志后立即回复客户端,不等任何 follower 确认。吞吐和延迟最优,但 leader 在复制前崩溃,已经告知客户端"成功"的写永久丢失——follower 没有收到那批日志,新选出的 leader 也不会有。

follower 落后 leader 的时间窗口称为复制延迟(replication lag)。在这个窗口内,读 follower 的客户端看到的是旧版本,触发第 03 章的两类 anomaly:

  • read-after-write:客户端刚写入 leader,转头读 follower,读到写入前的旧值。
  • 非单调读(non-monotonic read):两次读分别命中不同 follower,第二次返回比第一次更旧的版本。
客户端 Leader Follower A (同步) Follower B (异步) 写 日志(同步) ACK 日志(异步) 成功 leader 崩溃 (异步下) 已确认写 Follower B 未收到 → 丢失 新 Leader Follower A 晋升 failover
图 4.1单主复制的同步与异步两条路径。注意:异步模式下,leader 在 Follower B 收到日志前崩溃,已向客户端确认的写永久消失——新 leader 由持有完整日志的 Follower A 晋升,但 Follower B 的那批写再无来源。

Failover 的两个经典风险

当 leader 不可达时,系统需要选出新 leader(failover)。两个风险与第 01 章的核心约束直接相关:

  • 丢写:若旧 leader 采用异步复制,新 leader 不含旧 leader 已确认的写。最简单的处理是丢弃这些写——但客户端已收到"成功",数据一致性被破坏。
  • 脑裂(split-brain):旧 leader 只是网络分区而非真正宕机,恢复后仍自认主,与新 leader 同时接受写。两个 leader 各自修改同一数据,系统产生不可协调的分歧(第 01 章已铺垫;第 06 章的 Raft 用 term 和 fencing token 系统性地解决它)。
陷阱

异步单主复制在正常路径下延迟极低,工程师容易误认为 failover 只是"重新选主"的运维操作。实际上每次 failover 都伴随一个不确定的写丢失窗口,大小等于旧 leader 最后一批未复制日志的量。这个窗口在负载高峰恰好最大。

想一想

某系统配置了 N=3(leader + 2 follower),其中一个 follower 同步、一个异步。leader 在同步 follower 确认后、异步 follower 收到日志前崩溃。此时执行 failover,新 leader 由同步 follower 晋升。异步 follower 上的数据状态如何?客户端能否感知到数据丢失?

展开答案(先停 10 秒)

异步 follower 缺少旧 leader 最后一批写,它的状态落后于新 leader(由同步 follower 晋升)。新系统运行后,异步 follower 会从新 leader 补全日志,最终追上——但那批"丢失"的写对任何客户端而言已经消失,因为新 leader 根本没有那批写的记录。客户端读到的将是那批写之前的版本,仿佛写操作从未发生。这正是"异步复制下 failover 可能丢写"的具体表现。

4.2多主复制(multi-leader)

多个节点同时接受写、互为对方的 leader,异步互相复制。

为什么需要它

单主要求所有写路由到同一节点。跨数据中心部署时,远端写的延迟由跨洲网络决定(50–200ms),本地延迟提交无法实现。多主让每个数据中心有本地 leader,本地写立即提交,跨 DC 复制在后台异步进行。离线客户端(如日历 App)同理:设备就是本地 leader,联网时与服务端同步。

多主的核心代价是写冲突(write conflict):两个 leader 在不同节点并发修改同一 key,本地都立即成功,复制时系统持有两个冲突版本且没有顺序信息说明哪个"更晚"。

LWW(Last Write Wins)的静默丢写

LWW 给每次写打一个时间戳,冲突时取时间戳更大的版本,另一个版本被丢弃。直觉上合理,但从第 02 章已知:物理时钟不可靠——NTP 偏差可达 500ms,闰秒调整更大。结果是:物理上更晚发生的写,时间戳反而更小,被 LWW 静默丢弃,而客户端已经收到了那次写的"成功"确认。

陷阱

LWW 不是"偶尔在极端情况下丢数据"。只要两次写并发(在对方确认前各自提交),LWW 必然丢掉其中一个——这是结构性的,不是概率性的。Cassandra 和 Riak 的 LWW 模式都有这个特征,生产中需要业务层保证写不并发(如单 key 只在一个客户端写),才能安全使用。

版本向量(version vector)保留并发写

版本向量(version vector)为每个副本维护一个计数器,格式为 [vA, vB, ...],表示"该节点已见过节点 A 的第 vA 次写、节点 B 的第 vB 次写……"。两个版本的偏序关系由向量分量逐一比较:

  • 若 V₁ 的每个分量均 ≥ V₂ 对应分量,则 V₁ happens-after V₂,可以安全覆盖。
  • 若 V₁ 和 V₂ 在某分量上各有大小,则两者并发(concurrent),系统保留两个 sibling 版本,交由应用合并(merge)。

版本向量的代价:应用必须处理 siblings——读操作会返回多个版本,写操作需要携带合并后的向量。这把冲突解决的复杂性从存储层推向应用层。从第 03 章的一致性谱系看,这本质是因果一致(causal consistency):保证了 happens-before 关系,但两个真正并发的写无全序,需要应用语义合并。

想一想

两个 DC 并发对同一 key 写入不同值,版本向量记录为并发(siblings)。此时一个客户端读到两个版本,选择"取较大值"合并后写回。这个合并操作本身是否会产生新的冲突?

展开答案(先停 10 秒)

合并写本身也是一次写操作,若另一个 DC 在合并写传播前又发生了新写,新的并发就出现了——这是版本向量的正常工作流程,不是异常。版本向量确保合并写的向量分量高于两个 sibling,后续写只要携带正确的向量就能判断因果关系。"取较大值"是业务层的合并策略选择,存储层的版本向量只负责保留并发、不负责决定如何合并。

4.3无主复制(leaderless / Dynamo 式)

没有指定 leader,客户端或协调者同时向多个副本读写,靠 quorum 判定成功。

为什么需要它

单主和多主都依赖 leader 节点的可用性。无主复制把可用性提升到极限:任意节点都可以接受写,任意节点都可以响应读,没有单点瓶颈。Amazon Dynamo(2007)和 Cassandra、Riak 采用这个模型。

Quorum 的集合交集论证

N 个副本中,写需要 W 个节点确认、读查询 R 个节点。当 R+W>N 时,任意写集合(W 个节点)与任意读集合(R 个节点)必有至少一个公共节点——该节点持有最新写。典型配置 N=3,W=2,R=2。

副本 A 写集合 副本 B 写集合 ∩ 读集合 副本 C 读集合 W=2(写 A、B) R=2(读 B、C) 交叠节点 B 持有最新写 N=3,W=2,R=2 → R+W=4 > N=3
图 4.3N=3 时写集合 {A,B} 与读集合 {B,C} 在节点 B 交叠,交叠节点持有最新写。注意:这个"至少一个交叠"只是集合论保证——它不能阻止网络延迟使旧副本比新副本先响应,也不能保证失败的部分写被回滚。
陷阱:R+W>N ≠ 线性一致

R+W>N 是集合交集论证:任意读写集合至少共享一个节点。但这不阻止以下情形:写操作已到达 A 和 B,读操作发给 B 和 C,B 的响应因网络延迟比 C 慢,客户端先收到 C 的旧值。读到"最新"取决于哪个副本最先响应,不是哪个副本最新。并发操作下,客户端可以看到非线性一致的结果。R+W>N 也不保证失败的部分写(写入 A 成功、写入 B 失败)被完整回滚——这是无主模型对应用层透明地牺牲原子性的地方。

读修复与反熵

副本在正常运行中会产生分歧(某节点短暂宕机、网络抖动)。两种机制补齐落后副本:

  • 读修复(read repair):客户端读时比较多个副本的版本,发现落后副本后主动将最新版本推送过去。代价:只修被读到的 key——冷数据(长期无读请求的 key)会永远 stale。
  • 反熵(anti-entropy):后台进程用 Merkle 树(Merkle tree)高效比对两个副本的数据摘要,找出差异后补齐。Merkle 树把数据分层哈希,比对时从根节点开始,只需 O(差异数 × log N) 次比对就能定位分歧 key,而不是全量扫描。反熵覆盖冷数据,但是异步的,实时性弱于读修复。

Sloppy quorum 与 hinted handoff

网络分区时,写操作凑不齐 W 个"home"节点(原本负责该 key 的节点)。sloppy quorum 允许写入任意 W 个可达节点(包括"非 home"的临时节点),写请求附带一个 hint 记录目标 home 节点。分区恢复后,临时节点通过 hinted handoff 把数据转交给真正的 home 节点。

陷阱:sloppy quorum 彻底打破 R+W>N 的交集保证

sloppy quorum 的写集合包含非 home 节点,而读操作只查 home 节点——两个集合不一定相交。读到的"最新值"这时还在某个临时节点上等待 handoff,home 节点上的版本是旧的。sloppy quorum 是持久性手段(防止写在分区期间完全失败),不是一致性手段。使用时必须接受:在 handoff 完成前,读会返回分区前的旧值。

想一想

系统配置 N=5,W=3,R=3(R+W=6>N=5)。网络分区导致客户端只能看到 2 个 home 节点,触发 sloppy quorum,写到 3 个节点(2 home + 1 临时)。读操作在分区期间查 3 个 home 节点,只能联系到 2 个。读到的最新版本是分区前的吗?为什么?

展开答案(先停 10 秒)

是的,读到的是分区前的版本。最新写已经在 2 个 home 节点 + 1 个临时节点上,但读操作只查 home 节点,而 3 个 home 节点中只有 2 个可达——凑不齐 R=3,读可能返回错误,或者系统降级为读 2 个节点(可用性优先模式下)。即使降级读成功,临时节点上的最新写不在读集合内,home 节点上仍是旧版本。这证明 sloppy quorum 期间 R+W>N 的集合交集论证失效。

4.4多数派直觉与脑裂防护

任意两个多数派必交叠至少一个节点,交叠点带权威值,防止两边各自独立决策。

为什么需要它

网络分区把集群切成两半。若每半都认为自己可以独立选 leader、接受写,两个"脑"各自修改同一数据,分区恢复后无法协调——这就是脑裂(split-brain)。多数派要求至少 ⌊N/2⌋+1 个节点同意,任意两个满足要求的多数派必然有交集,交集节点会拒绝同时参与两个多数派,从而保证只有一边能达到多数派。

奇数节点的容错优势

多数派(quorum)= ⌊N/2⌋+1。容许故障数 = N - quorum = ⌊(N-1)/2⌋。

  • N=3:quorum=2,容 1 故障。
  • N=4:quorum=3,容 1 故障。
  • N=5:quorum=3,容 2 故障。
  • N=6:quorum=4,容 2 故障。

偶数节点 N=4 和奇数节点 N=3 的容错能力相同(各容 1 故障),但 N=4 多一个节点、多一份硬件和维护成本。N=6 和 N=5 同理——偶数节点浪费了一个节点的容错收益。生产集群推荐 3、5、7 这种奇数。

容错对比 N1 N2 N3 N=3 quorum=2 容1故障 N1 N2 N3 N4 N=4 quorum=3 容1故障(同 N=3) N1 N2 N3 N4 N5 N=5 quorum=3 容2故障 Fencing Token 防脑裂 旧主 · token=1 存储节点 新主 · token=2 写 token=1 ✗ 拒绝 写 token=2 ✓ 接受
图 4.4奇数节点(N=3,5)比相邻偶数节点(N=4,6)用更少资源获得相同或更多容错;fencing token 让存储节点用单调递增的 token 拒绝旧 leader 的写。注意:fencing token 需要存储节点主动检查——客户端和旧 leader 无法自证无效,必须由存储层执行拒绝。

Fencing Token 与脑裂防护

脑裂的根因是旧 leader 无法感知自己已被替代(网络分区使它收不到新选主的通知)。fencing token 是一个由锁服务或选主服务颁发的单调递增整数,每次选出新 leader 时 token 加 1。存储节点记录当前见过的最大 token,拒绝携带更小 token 的写请求。即使旧 leader 分区恢复后仍然尝试写入,存储节点会以"token 过期"拒绝——旧 leader 的写在到达存储层时就被拦截,不会污染数据。

这里的"多数派"直觉是充分条件,真正可靠的 leader 选举与任期管理由第 06 章的共识算法(Raft)用 term + 多数派投票系统性地解决。fencing token 是防止脑裂的最后一道存储层防线,而不是选主协议本身。

类比 · 带边界

fencing token 像银行账户的世代编号:每次重新开户,旧卡自动失效。存储节点扮演银行,只认最新世代的写操作。但这个类比的边界在于:银行有中央记录,而分布式系统里"最大 token"本身就存在一致性问题——若 token 颁发服务自身也有分区,问题递归到第 06 章。

章末自测

  1. 异步单主复制下,failover 为什么会丢失已经向客户端确认的写?
  2. R+W>N 能保证每次读都能读到最新写吗?
  3. R+W>N 能保证线性一致吗?
  4. 为什么说 sloppy quorum 是"持久性手段"而非"一致性手段"?
  5. 为什么生产集群推荐 3/5/7 这种奇数节点,而不是 4/6/8?
展开参考答案

1. 异步复制下,leader 在把日志发送给任何 follower 之前就已向客户端回复"成功"。若 leader 在这个窗口内崩溃,follower 没有收到那批写;新 leader 由某个 follower 晋升,自然也没有那批写的记录。客户端认为写已成功,但数据事实上不存在于任何存活节点——写永久丢失。

2. 不一定。集合交集只保证读集合与写集合至少有一个共同节点存在于系统中,不保证那个节点是读操作中第一个响应的节点。网络延迟下,旧副本可能比最新副本更快响应,客户端先读到旧值。

3. 不能。线性一致要求操作表现得好像整个系统只有一个最新副本,所有操作有全局唯一顺序。R+W>N 只是集合论保证:读写集合有交叠节点。并发操作、网络延迟、部分写失败等情况下,客户端仍然可以看到非线性一致的结果。

4. sloppy quorum 期间,写操作写入了非 home 的临时节点。读操作只查 home 节点——写集合与读集合不一定相交。在 hinted handoff 完成前,home 节点上的数据是旧的,读出来的值不是最新写。sloppy quorum 确保写在分区期间不被完全丢弃(持久性),但无法保证分区期间读到最新值(一致性)。

5. N 节点多数派 = ⌊N/2⌋+1,容故障数 = ⌊(N-1)/2⌋。N=4 和 N=3 都只容 1 故障;N=6 和 N=5 都只容 2 故障。偶数节点比相邻奇数节点多消耗一个节点,容错能力却不变,白白浪费资源。奇数节点是最经济的多数派配置。

进阶挑战 · 刚好够不着

多主冲突解决的一致性代价

多主复制中两个 DC 并发改同一 key:DC-A 把值改为 "X",DC-B 把值改为 "Y",双方都立即向本地客户端确认成功。

(a)用 LWW 解决冲突会发生什么?DC-A 的客户端和 DC-B 的客户端各自会看到什么结果?

(b)改用第 02 章的版本向量,系统保留两个 sibling 版本。你能保住哪些写、代价是什么?

(c)版本向量方案让应用合并 siblings,应用选择"取两者都保留"(如购物车合并)。这把你的系统推向第 03 章谱系中的哪种一致性?为什么不是线性一致?

提示(卡住再展开)

LWW 的静默丢写是结构性的:两次写都已确认,但只有一个能存活,另一个不可恢复地消失——客户端无法感知。版本向量保留两个版本,代价是读操作可能返回多个 siblings,应用必须实现 merge 函数。"两者都保留"的语义保证了 happens-before 关系:如果 A happens-before B,B 肯定覆盖 A;只有真正并发的写才产生 siblings。这本质上是因果一致(causal consistency)——happens-before 有序,但并发写无全序,不满足线性一致"所有操作有全局唯一顺序"的要求。

参考资料

  • Kleppmann, M. Designing Data-Intensive Applications, Ch. 5 "Replication". O'Reilly, 2017.
  • DeCandia, G. et al. "Dynamo: Amazon's Highly Available Key-value Store." SOSP 2007. — 无主复制与 sloppy quorum 的工程来源。
  • Kingsbury, K. "The Trouble with Timestamps." aphyr.com, 2013. — LWW 静默丢写的实证分析。
  • Riak 文档:"Vector Clocks";Cassandra 文档:"Replication"。— 两种无主系统对版本向量与 LWW 的实际权衡。