第 5 章开启 DDIA 第二部分"分布式数据"。复制意味着在多台经网络连接的机器上保留同一数据的副本。我们这么做有三个原因:让数据地理上靠近用户(更低延迟)、即便部分失败系统仍工作(可用性)、横向扩展服务读查询的机器数(吞吐)。若数据从不改变,复制就微不足道——拷一次就完事。全部困难、以及整章,都关于处理对复制数据的变更。Kleppmann 把一切围绕三种算法构建:单主、多主、无主复制。
- 三种方法——单主(一个节点接受写)、多主(几个接受)、无主(客户端直接写多个节点)。几乎每个分布式数据存储都是这些的变体。
- 同步 vs 异步是持久性/可用性取舍——同步复制保证一份最新副本但若 follower 挂了就阻塞;异步快但故障转移时可能丢失刚确认的写。
- 故障转移危险——提升新 leader 冒丢写、脑裂(两个 leader)、坏超时调优的风险。自动故障转移真的难做对。
- 复制滞后破坏直觉保证——你需要读己之写、单调读、一致前缀读来弥合一个落后的异步 follower。
- 多主的核心问题是写冲突——同一记录在两个 leader 上被编辑必须被调和(LWW、CRDT、自定义合并)。
- 无主用 quorum——若 w + r > n,读集和写集重叠,所以一次读看到最新写。读修复和反熵治愈陈旧副本。
单主复制简单且是默认(大多数关系 DB):写去一个 leader,它把变更日志流给 follower。多主跨数据中心增加写可用性,代价是冲突解决。无主(Dynamo 风格)完全去掉 leader,依赖 quorum 加修复。反复出现的主题是网络不可靠、复制异步时一致性与可用性间的张力。
单主复制(Single-Leader)
最常见的方法,被 PostgreSQL、MySQL、SQL Server、MongoDB 等许多系统使用,是基于 leader 的复制(也叫 active/passive 或 master/slave)。一个副本被指定为 leader。所有写必须去 leader,它先把新数据写到自己的本地存储。其他副本是 follower:每当 leader 写,它也把变更作为复制日志或变更流的一部分发给每个 follower。每个 follower 按 leader 处理的相同顺序应用变更。读能由 leader 或任何 follower 服务,但写仅 leader。
同步 vs 异步复制
一个关键配置旋钮是复制同步还是异步发生:
- 同步——leader 等到一个 follower 确认它收到写后才向客户端报告成功。该 follower 保证有一份最新副本,但若它不响应(崩溃、网络故障),写无法进行——leader 必须阻塞所有写。
- 异步——leader 发变更而不等。写快,且即便 follower 落后 leader 也继续工作,但若 leader 在一个写传播前失败,那个写丢失即便它已被向客户端确认。
让每个 follower 都同步不切实际——一个慢节点会让整个系统停摆。一个常见折中是半同步(semi-synchronous):一个 follower 同步,若它太慢,另一个 follower 被替换为同步的。这保证至少两个节点有最新数据。实践中,许多基于 leader 的系统全异步运行,接受故障转移时丢失近期写的风险以换性能和可用性。
设置新 Follower
要不停机加 follower,你不能在写发生时只是拷文件。标准流程:取 leader 数据库的一致快照(大多数 DB 支持这个而不锁),拷到新 follower,然后让 follower 连到 leader 并请求自快照以来发生的所有变更——由复制日志中的精确位置标识(PostgreSQL 叫它日志序列号,MySQL 叫 binlog 坐标)。follower 追上后,它实时处理变更。
处理节点故障
任何节点都可能宕,复制的一个目标是让系统挺过单个故障。如何恢复取决于哪个节点失败。
Follower 故障:追赶恢复
这是容易的情况。每个 follower 保留它收到的变更日志。崩溃或网络抖动后,它知道它处理的最后一个事务;它重连到 leader 并请求自那以来的每个变更、应用它们,就重新同步了。无需人工介入。
Leader 故障:故障转移
这是难的情况。若 leader 失败,一个 follower 必须被提升为新 leader,客户端必须被重新配置去把写发给它,其他 follower 必须开始从它消费变更。这个过程——叫故障转移(failover)——可手动或自动,且布满陷阱:
- 丢写。异步复制下,被提升的 follower 可能没收到 leader 最近的写。若旧 leader 重新加入,那些写与新的冲突。通常的解决是丢弃旧 leader 未复制的写——这违反客户端的持久性期望。
- 与其他系统的危险交互。Kleppmann 的警世故事:在 GitHub,一个过时的 MySQL follower 被提升;它重用了旧 leader 已分配的主键(自增),那些键也用在 Redis 里,导致私有数据泄露给错误用户。
- 脑裂(Split brain)。两个节点可能都相信自己是 leader 并都接受写,无法调和——数据被损坏或丢失。一些系统关掉一个,但粗心的机制可能把两个都关了。
- 超时调优。多久才宣告一个 leader 死了?太长意味更长的宕机;太短意味不必要的故障转移让事情更糟,尤其在负载峰值下。
这些故障转移问题没有简单答案——这正是为什么许多运维团队偏好手动故障转移,即便那意味更长的宕机。这些问题如此棘手的深层原因是网络、时钟的不可靠,以及就单一真相达成一致的困难——第 8、9 章的主题。
复制日志的实现
leader 实际如何把变更传达给 follower?有几种方法,各有取舍。
- 基于语句——leader 运送字面写语句(如每个
INSERT/UPDATE)。紧凑,但在非确定性上崩溃:NOW()、RAND()、自增列和带副作用的语句在每个副本上产生不同结果。因此大体被弃用。 - 预写日志(WAL)运送——复制存储引擎已写的精确字节级日志。非常精确,但把复制紧耦合到存储引擎的内部格式,所以 leader 和 follower 通常必须跑同一数据库版本——使零停机升级难。
- 逻辑(基于行)日志——一个在行级别描述变更(哪行、哪列、新值)的单独日志,与存储引擎内部解耦。这种解耦允许版本不匹配并让外部系统消费这个流——变更数据捕获(CDC)的基础。
- 基于触发器——用数据库触发器/存储过程的应用级复制。最灵活(你能转换或过滤数据)但开销更大、更易出 bug。
复制滞后的问题
从异步 follower 读让你便宜地扩展读,但 follower 可能落后 leader。若你从落后的 follower 读,你可能看到陈旧数据——系统是最终一致的,但"最终"刻意含糊,在负载下可能是数秒或数分钟。三个具体异常、以及修复它们的保证,在面试里不断出现:
读己之写一致性(Read-After-Write)
用户提交一个变更,然后重载——若他们的读命中一个还没收到该写的 follower,他们自己的更新看似消失了。读己之写(或 read-your-writes)一致性保证用户总看到自己的写。技术:从 leader 读用户可能修改过的东西;跟踪用户最后一次写的时间戳,只从追上它的 follower 读。
单调读(Monotonic Reads)
若用户从不同副本做几次读,他们可能看到一个值,片刻后看到一个更旧的值——时间看似倒流。单调读保证一旦你读了更新的数据,你之后不会读更旧的。一个简单实现:每个用户总从同一副本读(如按其用户 ID 的哈希选)。
一致前缀读(Consistent Prefix Reads)
若一连串写以某顺序发生,任何读它们的人都应以同样顺序看到。违反令人不安——你可能在问题本身之前看到一个答案。一致前缀读保证因果相关的写被按序读。这在分区(分片)数据库里尤其是问题,那里不同分区以不同速度复制。
"最终一致性"是一个弱保证,把大量复杂度推给应用开发者。与其希望滞后保持小、然后在它没保持时惊讶,不如用这些具体保证思考——并认识到提供更强保证正是事务和共识(后续章节)的用途。
多主复制(Multi-Leader)
单主有一个明显缺点:所有写漏斗式通过一个节点。多主复制允许多于一个节点接受写;每个 leader 同时充当其他 leader 的 follower。主要用例:
- 多数据中心运行——每个数据中心一个 leader,所以写是本地的(低延迟),且若另一个 DC 或它们之间的链路失败,每个 DC 继续工作。
- 带离线操作的客户端——如你手机上的日历应用实际是一个 leader(它离线接受写),重连时与服务器同步。
- 协作编辑——Google Docs 等实时工具是多主复制的一种形式。
写冲突
多主引入的大问题是写冲突:同一数据能在两个不同 leader 上并发被修改,而冲突直到变更被异步合并才被检测到。方法:
- 冲突避免——把给定记录的所有写路由到同一 leader,绕开冲突。在那个 leader 失败或用户移动时崩溃。
- 收敛到一致状态——每个副本必须到达同一最终值。选项包括最后写入胜(LWW)(选时间戳最高的写——简单但丢数据)、给每个写一个唯一 ID 并选最高的,或记录冲突稍后解决。
- 自定义解决逻辑——让应用代码在写时或读时合并冲突。
- 自动数据结构——CRDT(无冲突复制数据类型)和操作转换被设计来明智地合并并发编辑。
复制拓扑
多于两个 leader 时,你必须决定写在它们之间走的路径。全互联(all-to-all)(每个 leader 发给每个其他)最健壮但若某些链路比其他快可能有因果问题。环形和星形拓扑减少流量但引入单点故障,且若一个节点宕可能断。为防无限循环,每个写被标上它已经过的节点标识符。
无主复制(Leaderless)
第三种方法完全放弃 leader 概念:任何副本都能直接从客户端接受写。这种风格由 Amazon 的 Dynamo 推广,被 Cassandra、Riak、Voldemort 使用(常叫 Dynamo 风格)。客户端(或代表它的协调者节点)把每个写发给几个副本并从几个副本并行读。
Quorum 读写
要知道一次读尽管有些节点宕了也看到最新写,无主系统用 quorum。有 n 个副本,一个写必须被 w 个节点确认、一个读必须查询 r 个节点。只要 w + r > n,被写的节点集和被读的集保证至少重叠一个节点——所以一次读会看到至少一份最新副本。
n = 3 副本
w + r > n 保证写集和读集重叠
例: w = 2, r = 2, n = 3
2 + 2 = 4 > 3 ✓ 每次读看到最新写
为读调优: w = 3, r = 1 (读快,写慢/脆弱)
为写调优: w = 1, r = 3 (写快,必须广泛读)
保持副本同步
当一个宕掉的节点回来,它错过了写。两个机制治愈这个:
- 读修复(Read repair)——当客户端从几个节点读并注意到一个有陈旧值,它把更新的值写回那个节点。对频繁读的数据工作得好。
- 反熵(Anti-entropy)——一个后台进程持续找副本间差异并拷贝缺失数据。不像基于 WAL 的复制,它不保留写顺序且可能有显著延迟。
Quorum 的局限与检测并发写
Quorum 不是密不透风的。宽松 quorum(sloppy quorum)(带 hinted handoff)让写在"家"节点不可用时去其他可达节点,增加可用性但削弱重叠保证。且因为多个客户端能并发写一个无主存储,冲突像在多主里一样出现。检测它们需要推理 "happens-before" 关系:若两个操作都不知道对方,它们是并发的。系统用每 key 版本号,或跨副本的版本向量(version vector),来判断一个写是否取代另一个,还是它们真正并发(那种情况下冲突值,叫 sibling,必须由应用合并)。
| 维度 | 单主 | 多主 | 无主 |
|---|---|---|---|
| 谁接受写 | 一个节点 | 几个节点 | 任何副本 |
| 写冲突 | 无(串行化) | 有——必须解决 | 有——必须解决 |
| 处理节点丢失 | 故障转移(难) | 其他 leader 继续 | quorum 容忍丢失 |
| 一致性 | 较强(若读 leader) | 最终 | 最终(可调 quorum) |
| 示例 | PostgreSQL、MySQL | 多 DC、CouchDB | Cassandra、Riak、Dynamo |
复制是每个分布式数据存储的基础,三种策略是写可用性与解决冲突成本之间取舍的谱系。单主避免冲突但成瓶颈、失败别扭;多主和无主用可用性换取你必须调和并发写。知道一个给定数据库坐落在哪——以及它对滞后和冲突做什么——是强大的分布式系统面试答案的核心。
同步 vs 异步复制?同步保证一份持久的最新副本但在慢 follower 上阻塞;异步快但故障转移时可能丢已确认的写。半同步(一个同步 follower)是常见折中。
为什么故障转移难?丢失异步写、脑裂(两个 leader)、坏超时调优,以及与外部系统的危险交互——这就是为什么许多团队手动做它。
w + r > n 给你什么?读 quorum 和写 quorum 至少重叠一个节点,所以一次读保证看到最新写——无主一致性的核心旋钮。
怎么检测并发写?用版本号或版本向量跟踪 happens-before 关系;两个都没看到对方的写是需要合并的并发 sibling。