Chapter 04

数据复制:把状态拷贝

上一章把状态切开(分区),换来了存储容量和写吞吐。但切完之后,每一份子集仍然只躺在一台机器上——那台机器一断电、磁盘一坏,这份数据就没了,而且在它重启之前谁也读不到。分区解决了"放不下",没解决"挂得起"。本章给每份数据再准备几个副本,于是引出复制特有的代价:一旦同一份数据有多个拷贝,它们之间就会不一致。

本章建立的 schema

  • 复制带来高可用与就近读,但凭空引入"多个拷贝彼此不同步"这个新问题——本章其余部分都在管理这个代价。
  • 三种拓扑:主从(单主)、多主、无主 quorum,区别在"谁能接收写"以及"冲突谁来解决"。
  • 同步 vs 异步是一个"持久性 ↔ 延迟"旋钮:等副本确认更安全但更慢,不等更快但崩溃会丢已确认的写。
  • 无主架构里 W + R > N 让写集合与读集合必然相交,从而读到最新版本——这是 quorum 的安全核心。
  • 复制延迟会在异步窗口里破坏 read-your-writes、monotonic-reads、consistent-prefix 三类读保证。

4.1为什么要复制:一份拷贝不够用

分区把一个大数据集切成若干子集,但每个子集默认还是单点。复制(replication)指的是:把同一份数据在多个节点上各存一份拷贝。它一次性买到三样东西,而这三样都是单份数据拿不到的。

高可用——任意一个副本宕机,其余副本继续提供服务,故障从"数据不可访问"降级为"少了一条腿但还能走"。就近读——把副本放到离用户近的机房,跨洲请求变成同城请求,省掉第 01 章那道被光速钉死的跨洲 RTT(约 150ms)。读扩展——读多写少的系统里,把读分摊到多个副本上,单点读瓶颈被打开。

复制 ≠ 备份

备份是某个时间点的离线快照,用来对抗"误删/逻辑错误";复制是持续在线的多副本,用来对抗"机器挂了"。一条误删的 DELETE 会被复制忠实地同步到每个副本上——复制救不了你删错的数据。两者解决的是不同的故障,生产系统都要有。

代价只有一句话,但它撑起了本章乃至随后三章:一旦同一份数据存在多个拷贝,写入就必须传播到所有拷贝,而传播需要时间、可能失败、可能乱序。在传播完成之前,不同副本上的同一个 key 会读出不同的值。如何接收写、如何传播、传播没跟上时还能保证什么——这就是三种复制拓扑要回答的问题。

4.2三种拓扑:谁能写,冲突谁解决

复制方案的第一个分叉点是:哪些副本被允许接收写入。答案只有三种,每种都在"写入无冲突"和"可用性/写延迟"之间站到了不同的位置。

主从(单主) 主 leader 从 1 从 2 日志流 写只走主 多主 主 A 主 B 互相复制 两边都收写 → 写写冲突 无主 quorum R1 R2 R3 客户端直写 W 个
图 4.1三种拓扑的差别只在"谁能接收写"。注意:主从把写集中到一点换来"没有写写冲突",多主和无主用"接受冲突、事后解决"换来更高的写可用性——冲突不是 bug,是它们的设计前提。

主从(single-leader)

一个副本被指定为主(leader),所有写都发给它;主把变更按顺序记成一份复制日志(replication log),流式发给各从(follower)。读可以走主,也可以走从。

它的优点直接来自"写只有一个入口":所有写在主上被排成一条单一的全序,结构上不可能出现写写冲突,因为根本没有第二个地方在并发地改同一份数据。代价有三个,都要记住:(1)主是写吞吐的天花板,写扩展不了,只能靠分区把不同 key 的主分散到不同节点;(2)主一旦宕机,需要故障切换(failover)选出新主,这段空窗里写不可用;(3)从是异步追主的,从读可能读到陈旧数据——这正是 4.4 节复制延迟的来源。

多主(multi-leader)

多个副本都能接收写,彼此异步复制各自的写。典型场景是多机房:每个机房一个主,本地写本地主(低延迟),机房间互相同步。它能扛住整个机房级故障,跨区写延迟低。但它买来了一个主从结构上不会有的问题:两个主可能在同一时刻各自接收了对同一个 key 的写——写写冲突。等这两个写互相复制过去,系统必须决定"到底谁赢"。

冲突解决有三条主流路线,各有代价:LWW(last-write-wins,最后写入者胜)——按时间戳取较晚的,实现简单,但默默丢掉另一个写,且依赖时钟,时钟偏移会让"晚的"反而被判输;版本向量(version vector)——记录每个副本看到的版本,能精确区分"真并发"和"有先后",但并发冲突仍要交给应用决定怎么合并;CRDT(conflict-free replicated data type,无冲突复制数据类型)——把数据结构设计成"无论合并顺序如何,结果都收敛到一致"(如计数器、集合),代价是只有特定数据类型能这么建模。

无主(leaderless / quorum)

没有主这个角色,客户端(或代表它的协调节点)直接把写发给多个副本,也从多个副本读。这是 Dynamo 论文确立的风格(Cassandra、Riak 沿用)。写不必等所有副本,只要够"多数"就算成功;读也读多个副本,靠版本号挑出最新的。它的可用性最高——没有"主挂了"这个单点,少数副本宕机不影响整体。代价是冲突和陈旧都得自己处理:用 read-repair(读到不一致时顺手把落后副本修正)和 anti-entropy(后台进程持续比对副本、补齐差异)来收敛。它到底凭什么能读到最新值,就是下一节 quorum 的核心。

备选方案对比 · 三种复制拓扑
拓扑优势代价何时选
主从(单主) 无写写冲突;读写模型简单;强一致最好做 主是写瓶颈;故障切换有写空窗;从读陈旧 读多写少、单机房或单主够用的多数业务系统(默认起点)
多主 扛机房级故障;多地就近写、写延迟低 必然有写写冲突,要 LWW / 版本向量 / CRDT 解决 多活机房、离线协作编辑等"必须本地可写"的场景
无主 quorum 可用性最高;无单点;读写可调权衡 要处理冲突与陈旧(read-repair + anti-entropy);运维心智重 极高可用、可容忍最终一致的大规模 KV 存储(Dynamo 式)
回扣主线

生产系统里复制几乎从不单独出现——它叠在第 03 章的分区之上。一份数据先按 key 切成多个分区,每个分区再各自复制 N 份。"主从"指的是每个分区有自己的主,不同分区的主可以落在不同节点上,这样写吞吐才能横向扩展。换句话说:分区扩容量,复制扛故障,两把尺子同时在用。

先想一下

多主架构里,离线编辑器(如多端记事本)和银行账户余额,哪个适合用 LWW 解决冲突?为什么?

展开思路

都不太适合,但银行余额尤其不行。LWW 会默默丢弃"输的那个写"。记事本里丢一段并发编辑只是体验问题,还能容忍;而余额是累加语义——"+100"和"-50"两个并发写,LWW 只保留一个,等于凭空吞掉一笔交易,这是正确性灾难。余额这类应当用能合并的结构(如把操作建模成可交换的增量,靠 CRDT 计数器收敛),或干脆退回单主、用事务保证(第 06 章)。LWW 只适合"丢了也无所谓、要的就是简单"的数据。

4.3同步 vs 异步:持久性与延迟的旋钮

选定拓扑只回答了"谁能写";还有第二个旋钮:主(或协调节点)在回应客户端"写成功"之前,要不要等副本确认收到。这个旋钮一端是同步,一端是异步,它直接决定了"崩溃会不会丢已经告诉客户端成功的写"。

同步复制:主把写发给从,等从确认后才回应客户端成功。好处是——客户端收到成功时,数据已经在至少两个节点上,主立刻宕机也不丢这条已确认的写(持久性强)。代价致命:只要有一个从变慢或卡住,所有写都得跟着等它,整个写路径被最慢的副本拖住。一个 GC 暂停的从,能让整库写入冻结。

异步复制:主写完本地立即回应成功,复制在后台慢慢追。好处是快、且不被慢从拖累、从挂了也不挡写(可用性好)。代价同样致命:主在"已回应成功"和"复制真正送达从"之间的窗口里宕机,这条已确认的写就永远丢了——客户端以为成功了,故障切换后新主上却没有它。

常见误解

多数所谓"同步复制"其实是半同步(semi-synchronous):只要求恰好一个从同步确认,其余从异步。这样保证"任意时刻至少有两份持久拷贝",又不至于被某一个慢从拖垮全部写。纯同步(等所有从)在生产里几乎没人用——一个慢副本就能让可用性归零。听到"我们是同步复制",先问一句:是等所有从,还是只等一个?

把这个旋钮和 4.2 的拓扑叠起来看就清楚了:旋钮调的是"愿意为持久性付出多少延迟"。同步 = 用写延迟和可用性,换"不丢已确认的写";异步 = 用"可能丢一小段已确认的写",换低延迟和高可用。没有哪一端是对的,只有"这份数据丢一条能不能接受"。

先想一下

有人说"我们用异步复制,所以可用性高"——这句话在主宕机做故障切换时,藏着什么风险?

展开思路

异步窗口里主积压的、还没复制出去的写会随主一起消失。故障切换选出的新主是某个从,它没有那段数据,于是已对客户端确认成功的写丢失了。更糟的是:如果旧主只是网络分区、并没真死,它带着那段"独有"的写回来,可能和新主产生分叉——这就埋下了脑裂的种子(完整防御见第 08 章 fencing)。"异步=高可用"是真的,但代价"切换可能丢已确认写"也是真的,必须一起讲。

4.4quorum:W + R > N 为何能读到最新

无主架构没有主来定义"最新",那它凭什么保证读到的不是陈旧值?答案是 quorum(法定多数):设总副本数为 N,每次写必须成功写入 W 个副本,每次读必须从 R 个副本收集回应。只要满足:

W + R > N

读取的 R 个副本与写入的 W 个副本就必然至少有一个重叠——这个重叠副本上有最新的写。这不是概率,是鸽巢原理(pigeonhole):两个集合大小之和超过全集,它们的交集不可能为空。读到这个重叠副本后,再靠版本号(每次写带一个递增版本/时间戳)从 R 个回应里挑出版本最高的那个,就是最新值。

N = 3,W = 2,R = 2 → W + R = 4 > 3 副本 1 v5 副本 2 v5 ← 重叠 副本 3 v4 旧 写 W=2(副本 1、2) 读 R=2(副本 2、3) 读集合 ∩ 写集合 ⊇ {副本 2} → 一定能读到 v5
图 4.2W=2 与 R=2 在 N=3 上无论怎么选,交集都不可能为空。注意:副本 3 还停在旧版本 v4 是正常的——quorum 不保证每个副本都最新,只保证读到的 R 个里至少有一个最新,再由版本号选出它。

这条不等式还让你能用同一组副本调出不同的偏向。N 固定,调 W 和 R 就是在挪"读快还是写快":

  • W=N, R=1:写要落到所有副本,写慢且写可用性差(一个副本挂就写不了),但读只问一个、极快。适合写极少、读极多。
  • W=1, R=N:写只要一个副本即成功,写快且写可用性高;但读要问所有副本,读慢、读可用性差。适合写多读少、能接受读慢。
  • W=R=⌈(N+1)/2⌉(如 N=3 时 W=R=2):读写都付出"多数"代价,但都能容忍一个副本宕机——这是最常用的平衡点。
先想一下

N=3 下,W=3, R=1 和 W=1, R=3 各偏向什么?两者都满足 W+R>N 吗?

展开思路

两者都满足:3+1=4>3,1+3=4>3,所以都能读到最新值。W=3,R=1 写慢(要等全部三个副本)但读快(只问一个),偏读密集;W=1,R=3 写快(一个就成)但读慢(要问全部),偏写密集。关键收获:在不等式约束下,W 和 R 是一个零和的偏向旋钮——把成本从读这边挪到写那边,反之亦然。

陈旧由两个机制兜底收敛:read-repair(读到 R 个回应中有副本落后时,顺手把最新值写回它)在热点数据上修得快;anti-entropy(后台进程持续比对副本、补齐差异,常用 Merkle 树高效定位差异)保证冷数据也最终一致。

陷阱 · sloppy quorum 的安全错觉

W+R>N 的相交保证有一个前提:W 和 R 都从同一组 N 个"本应负责"的副本里选。但为了在网络抖动、部分副本不可达时仍保持写可用,无主系统常用 sloppy quorum(宽松多数):负责的副本够不到 W 个时,就把写临时落到环上其它"兜底(hinted handoff)"节点。这时写其实没落在那 N 个负责副本里——后来的读从负责副本读,读集合和写集合不再相交,相交保证失效,照样能读到旧值。sloppy quorum 提高的是写可用性,不是读到最新的保证。把它当成"反正 W+R>N 所以安全"是典型的误判。

4.5复制延迟:异步窗口破坏了哪些读保证

无论哪种拓扑,只要复制是异步的,主(或最新副本)和落后副本之间就有一段时间差——复制延迟(replication lag)。健康时这个差只有毫秒级,但在写高峰、网络抖动、从重启追日志时,它能涨到几秒甚至几分钟。在这个窗口里读到落后副本,就读到了陈旧数据。陈旧本身不是 bug,问题是它会破坏三个用户能直接感知的读保证。

主 从 ① 写「评论」 异步复制(延迟) 数据才到从 ② 读 → 空! 复制延迟窗口
图 4.3用户在主上写了评论(①),随即读从(②)却读到旧状态——因为复制还没追上。注意:这条窗口里用户"看不到自己刚写的东西",体验上像写丢了,但数据其实在主上好好的,破坏的只是 read-your-writes 保证。

read-your-writes(读到自己刚写的)——用户发了一条评论,刷新页面却看不到。技术上数据没丢,只是读落到了还没追上的从。这是复制延迟最常见、最伤体验的表现。

monotonic-reads(单调读,时间不倒流)——用户连续刷两次:第一次读到较新的从(看到评论),第二次读到更落后的从(评论又没了)。数据像在时间上倒退。根因是两次读落到了进度不同的副本。

consistent-prefix(一致前缀,因果不乱序)——问题先出现,答案后出现,但复制到从时顺序被打乱,旁观者看到"答案"先于"问题",对话逻辑错乱。在分区+复制叠加时尤其容易出现。

缓解手段(按代价从小到大)

写后读主:用户写过的数据,一段时间内(覆盖复制延迟)强制把它的读路由到主或最新副本,专门修 read-your-writes。按用户粘连路由:同一用户的读始终打到同一个副本,保证 monotonic-reads(不会在副本间反复横跳)。因果追踪:给写带上版本/逻辑时钟,读时要求"不低于我已见过的版本",能修 consistent-prefix——这正是第 05 章一致性模型要系统化解决的问题。

陷阱 · 写主后立刻读从

"写都走主、读分摊到从"是教科书式的读扩展做法,但它会原汁原味地踩中 read-your-writes 破坏:用户写完立刻读,读落到还没追上的从,于是"写好像没生效"。这不是偶发,是异步复制的结构性后果,必须主动用上面的缓解手段处理,而不是指望复制延迟"通常很小"。在写高峰恰恰是延迟最大、用户最多的时候。

自测

先合上教程,把答案写下来或说出来,再展开对照——能复述出"机制 + 代价"才算过关,只认得名词不算。

  1. 同步复制真正的危险,是"延迟变大"还是"可用性下降"?给出你的判断和理由。
  2. W+R>N 为什么能保证读到最新值?在什么情况下这个保证会失效?
  3. 复制延迟会破坏哪三个读保证?各举一个用户能直接感知的现象。
展开参考答案

1. 两者都是,但主要危险是可用性下降。纯同步要等所有从确认,任意一个从变慢或卡住就阻塞全部写,一个慢副本即可让写可用性归零;延迟变大只是同步的固有成本。正因如此生产里几乎都用半同步(只等恰好一个从),既保住"至少两份持久拷贝",又不被单个慢从拖垮。

2. 因为读取的 R 个副本与写入的 W 个副本之和超过 N,由鸽巢原理两个集合必然相交,交集里那个副本持有最新写;读端再用版本号从 R 个回应里挑出版本最高的即得最新值。失效情形:sloppy quorum——写因部分副本不可达被临时落到兜底节点,没真正落进那 N 个负责副本,读写集合不再相交,相交保证失效。

3. read-your-writes(用户刷新看不到自己刚发的评论)、monotonic-reads(连刷两次,第二次反而读到更旧的数据、时间倒流)、consistent-prefix(复制乱序导致"答案"显示在"问题"之前,因果错乱)。

进阶挑战

让用户永远读得到自己刚发的评论

产品要求:"用户发完评论,无论刷新多少次都必须看到它。"系统是主从架构、读分摊到多个异步从。请给出两种实现,并各说清它的代价与失效边界。

展开提示(先自己写两种再看)

方案一 · 写后读主(时间窗):记录用户最近一次写的时间戳,在覆盖典型复制延迟的一段时间(如 1 分钟)内,把该用户的相关读强制路由到主或最新副本。代价:这段时间内主的读压力上升,削弱了"读分摊到从"的初衷;失效边界——延迟偶发性飙到超过窗口时仍会破。

方案二 · 版本水位(因果追踪):写成功时把该写的复制日志位点(版本号/LSN)返回给客户端,后续读带上这个位点,路由层只把读发给"已追上该位点"的副本,否则等待或回退到主。代价:客户端要携带并管理版本水位,路由层要知道各从的复制进度;好处是比固定时间窗更精确,不靠"猜延迟多久"。这条思路是第 05 章因果一致性的工程雏形。

(还可补充:仅对"该用户自己的内容"做这层保证、对别人的评论容忍最终一致,能把成本压到最低——这是按数据、按操作选一致性级别的典型权衡,正是全书主线。)

参考来源

  • Martin Kleppmann,《Designing Data-Intensive Applications》第 5 章 Replication(主从/多主/无主、同步异步、quorum、复制延迟与读保证的权威来源)。
  • DeCandia 等,Dynamo: Amazon's Highly Available Key-value Store(SOSP 2007,无主 quorum、sloppy quorum、hinted handoff、read-repair、anti-entropy 的源头)。
  • timilearning,DDIA Chapter 5 Notes(对复制延迟与 quorum 边界条件的逐条梳理)。