主节点返回写入成功后立刻故障,备节点接管却读不到这次写入。副本没有同时损坏,客户端也没有误读响应;只要成功响应早于复制完成,这条时间线就可能成立。复制协议要规定的,正是哪些事件必须发生在成功之前,以及换主之后哪些结果必须保留。

上一篇:一致性模型与分区边界给出了检查读写历史的标准。本篇把标准落到主备与链式复制的确认点上。课程结构参考 MIT 6.5840 2026 的 Chain Replication 讲义,协议以 OSDI 2004 原论文为依据;PostgreSQL 18.6 只用于核对真实产品的配置语义,配套程序是独立的有限事件模型。

成功、复制与可读是三个事件

设一个寄存器初值为 x=0,客户端提交 Put(x,1)。主节点可以先更新内存、记录日志,再把变化发送给备节点。备节点收到字节、写入本地日志、刷入稳定存储和应用到查询状态,各自需要不同步骤。“已经复制”若不指明其中哪一步,就不足以解释一次故障后的结果。

异步主备允许主节点在备节点达到所需状态之前返回成功。如下时间线中,主节点本地数据甚至可以已经落盘,切换仍会丢失这次已确认写入:

1
2
3
P: 写入 1 ── 返回 OK ── 故障、无法用于恢复
B: 保持 0 ────────────── 被提升为主 ── 读出 0
复制消息尚未送达

这里的丢失是指新服务所能提供的历史没有该写入,不能据此断言旧磁盘物理损坏。如果系统等待旧主恢复后再恢复数据,就改变了故障期间的服务承诺。自动选择落后的备节点继续服务,与等待包含最新写入的副本恢复,是两个不同策略。

同步确认把备节点的一项确认纳入客户端成功条件。若约定必须等 B 落盘,那么 B 不可达时,P 不能沿用原保证立即返回成功。切换为异步确认可以恢复部分写入进展,但也改变了后续写入的保证;一次配置修改不能同时保留互相冲突的确认条件。

这还没有解决读路径。备节点已经保存日志却尚未应用,直接查询可能仍返回旧值;只有复制确认明确包含应用可见性,或者读请求另行等待对应进度,才能用这个确认解释备节点上的后续读。PostgreSQL 文档的同步提交模式表正是按这些不同位置区分保证。

链式复制把确认放到尾部

原始 Chain Replication 针对单对象查询与更新。节点按 A → B → C 排列,A 是 head,C 是 tail。head 原子处理更新并确定顺序,把得到的状态变化传给下一节点;tail 串行处理到达的更新与查询,并向客户端发送响应。新值只在 head 计算一次,因此可容纳由 head 作出的非确定性选择,其他节点复制其结果。原论文 §2–§3

1
2
3
客户端 Put ──→ A(head) ──→ B ──→ C(tail) ──→ 客户端 OK
客户端 Get ──────────────────→ C(tail) ──→ 查询结果
← ACK ← 用于回收上游待确认记录

单对象限定很重要:这个协议描述本身没有提供跨多个对象的原子事务。把多个对象分在不同链上,也不会凭空产生跨链事务顺序。实验进一步收窄为三个节点、同一个寄存器、依次编号的写入;节点内状态用内存切片表示,不声称模拟了真实落盘。

论文的节点故障模型是 fail-stop:故障节点停止执行,停止可被环境检测;正常链路提供可靠 FIFO 传递。master 管理链成员、通知新的前驱后继并告知客户端 head/tail。论证为简化叙述假定 master 不失效,论文原型用 Paxos 复制 master。客户端与存储节点断连、master 自身失去多数派和副本全部丢失,不能被这项简化假设自动消除。

链上的数据写入经过当前配置的所有节点,不能称为多数派写协议。控制面可以用多数派协议维护配置,数据面仍然等待整条链传播完成。课程讲义强调这种分工:配置服务解决成员身份和切换,数据复制可以采用另一套协议。MIT 2026 讲义

前缀不变量解释了恢复方向

Hist_A 表示 A 已处理的更新序列。符号 U ⪯ V 表示 U 是 V 的前缀;正常传播始终要求:

1
2
Hist_C ⪯ Hist_B ⪯ Hist_A
例如 A=[1,2,3],B=[1,2],C=[1]

下游可以少收到若干末尾更新,但不能跳过中间更新,也不能改变它们的顺序。这个方向由 head 向 tail 的传播决定,与原论文 Update Propagation Invariant 公式一致。只有 C 处理写入 2 后才向客户端确认,意味着当时链中的 A、B 也已经处理过 2。

证明直觉可以分成状态与观察两部分。初始三个空历史满足前缀关系;head 追加一条更新,或后继接收下一条尚未收到的更新,都保留这个关系。tail 串行处理查询与更新,为单对象操作提供一个共同的顺序。写响应发出后,后续查询进入该顺序时不能退回到更早的状态。

故障之后还要保证这个已观察历史不会缩短。任何幸存节点都含有此前在 tail 完成的更新,故障修复若保留这些前缀,成功写入就不会因受支持的节点停止而消失。尚未完成的请求则不同:它可以只存在于部分上游,换角色时也可能变成新 tail 的可见状态。没有响应的请求既不能强制算作成功,也不能强制算作未执行。

所谓 N 个数据副本可在 N−1 个 fail-stop 故障后恢复服务,还依赖 master 能完成重配置、至少一个含有已确认历史的节点存活,以及模型假设成立。它并不表示任何时刻断掉 N−1 个网络连接仍可立即写入。链上一个节点停顿就能阻止更新到达 tail,恢复进展要等检测与重配置完成。MIT CR FAQ

三种故障需要三种处理

head 消失:丢弃的只能是未完成部分

A 故障后,master 令 B 成为新 head。假设写入 1 已经到达 C 并获得确认,写入 2 只到达 A。去掉 A 后,B、C 仍保留 1,2 则不在幸存历史里。这与此前未收到 2 的成功响应相容。客户端若重试,需要保持请求标识或采用幂等操作;复制角色切换不能取代去重协议。

若 2 已经到达 B,它还可能继续传向 C。于是同一个客户端超时现象对应至少两种系统内部状态。复制协议限制允许出现哪些状态,却不能让客户端通过一次超时获知确切状态。

tail 消失:前驱可能包含更多更新

C 故障后,B 成为新 tail。前缀关系保证 B 不比 C 落后,因而之前已经确认的更新不会因为选择 B 而缺失。若 A、B 都有 [1,2],C 只有 [1],新 tail 可以使 2 成为可见状态。

客户端可能仍没有收到写入 2 的成功响应。这里没有矛盾:一个悬而未决的操作可以已经生效。真正错误的是把“客户端只确认到 1”解释为“新 tail 必须删除 2”,或向重试客户端保证 2 从未执行。原论文把非幂等重试的防重责任留给客户端;产品实现通常还要补充请求序号与结果记录。

中间节点消失:先补后缀,再接新写入

B 故障时直接连起 A 与 C,只修好了连接图。若 A 的历史为 [1,2]、C 为 [1],立刻传新更新 3 会使 C 得到 [1,3],已经破坏历史前缀。后续读即使返回最新数值,也不能恢复缺失操作的副作用。

原论文 Figure 3 先让后继 C 得知新配置并报告已收序号,再让前驱 A 补传缺失后缀。上游维护 Sent,保留已经转发但尚未获 tail 确认的记录;tail 的 ACK 逐级向上游传播,用于安全回收。前驱必须先在同一 FIFO 链路上发送旧后缀,再处理和转发新请求;这里要求发送顺序,不额外要求每条旧更新都收到 tail ACK 才能发送新请求。

配套模型没有实现完整 Sent 回收协议,而是保留全部短历史,按后继已收长度取得缺失后缀。这种做法只适合有限演示。长期服务必须控制内存增长,并协调快照、日志截断与故障后的补传范围;直接删除历史可能删除恢复所需的最后一份记录。

超时误判旧 tail 时,原模型不够

真实网络可能把 C 与 master、B 隔开,却仍允许某个客户端连接 C。如果 master 把 B 设为新 tail,A、B 完成写入 2 后,旧 C 仍可能返回 1。只要这次读取发生在写入 2 完成之后,且没有中间写入,把它放进同一线性一致寄存器的历史就无法解释。

这个反例包含“被判故障但仍提供服务的旧节点”,超出了停止后不再执行的模型。MIT FAQ 明确指出,原论文没有解释这类旧 head、旧 tail 分裂脑问题。head 的补充措施可以让新 head 拒绝旧 head 的更新;tail 的补充措施可以是读 lease,并在旧 lease 到期后才启用新 tail。lease 又需要自己的时钟与有效期假设,不能仅加一个字段便宣称已经证明安全。

新配置中的节点都正确,也不足以约束仍被客户端访问的旧角色。身份切换协议必须覆盖这些旧路径,这一点也解释了为什么本地数组模型不能被称为可部署的复制服务。

PostgreSQL 18.6 把确认条件放进配置

PostgreSQL 18.6 发布于 2026-08-13。以下讨论按该版本文档及 REL_18_6 文档源码核对,没有运行 PostgreSQL 集群。复制连接已经建立,并不等于配置了同步提交;synchronous_standby_names 决定要等待哪些同步备库,synchronous_commit 决定等待到哪个阶段。版本发布说明

设置 客户端成功前等待的远端进度
local 不等待远端;等待主库本地 WAL 持久化
remote_write 当前同步备库写入操作系统,不保证备库 OS 崩溃后的持久性
on 当前同步备库把 WAL 刷入持久存储,不要求完成重放
remote_apply 当前同步备库完成重放,事务对其查询可见,且满足持久化要求

表中的远端等待以同步备库配置非空为前提;空配置下,这些非 off 模式只产生本地同步效果。持久性还依赖 fsync 等正常配置和存储兑现刷盘语义。remote_apply 的可见性属于参加本次同步确认的备库,不能扩大成任意备库读都最新。WAL 配置与模式表

FIRSTANY 可以指定等待备库的集合与数量。若仍有足够候选满足条件,单个备库断开不一定阻止提交;若不足,则提交可能继续等待。这项等待策略没有自动完成主库故障检测、提升选择或旧主隔离。PostgreSQL 的 failover 文档要求另外处理这些职责,以免旧主恢复后形成两个主节点。同步复制Failover

工程取舍也随位置变化。并行主备把发送压力集中在主节点;链把发送工作分散给各节点,却增加了更新逐跳传播的依赖。tail 能直接处理读,但单条链的读服务仍集中在 tail。多个分片可以分散负载,快照和补副本速度又会影响系统暴露在低冗余状态下的时间。仅凭节点数量或论文中的模拟吞吐量,不能判断一个具体部署的尾延迟与恢复时间。

运行五类有限时间线

累计代码位于 examples/distributed-systems/replication07/main.go,仅依赖 Go 标准库。在仓库根目录运行:

1
2
3
4
cd examples/distributed-systems
go run -race ./replication07
go vet ./replication07
go run ./replication07 -help

程序覆盖异步丢写、同步等待、中间节点修复、两端故障和旧 tail 读取五类情形。主备样本直接构造两个副本状态与确认门槛,不启动进程,也不实施真实故障注入。状态转移只追加历史或改变活动成员;独立的 audit 检查每个活动历史的连续序号、相邻前缀关系,以及客户端已确认前缀是否仍存在。另有提前确认与损坏历史的负例,避免检查器只对正确样本输出通过。

同步等待样本的结果只表示有限事件序列结束时尚未确认。代码没有无限调度,也就没有证明“永远不完成”。旧 tail 样本则保留被摘除 C 的历史,明确在新写成功后读取它,并以单寄存器、无中间写入的规格判定旧读非法。

删除补传的变异可以单独运行:

1
go run ./replication07 -scenario middle -skip-replay

该命令应以非零状态结束,检查器发现 C 的历史是 [1 3]。正常 all 模式也会调用这个错误路径,并要求检查器拒绝它。变异证明本次检查能发现缺失补传,不代表任何可能的重配置错误都已被覆盖。

程序保留完整短历史,没有网络、磁盘、崩溃恢复进程或真实计时器;race 检查也是对本地程序的检查,不是对生产复制协议并发安全性的证明。实际命令、输出与状态见 本地验证记录;资料及关键论断见 证据索引

两个推导练习

练习一: A、B、C 的历史分别为 [1,2,3][1,2][1],只有 1 获得客户端确认。B 停止后,A 应当给 C 发送哪些记录?如果只发送 3,会违反什么?

C 缺少有序后缀 [2,3]。只送 3 会留下序号缺口,且 C 不再是 A 的前缀。即使写 3 把寄存器设成与完整执行相同的数值,也不能据此丢弃写 2:操作可以含有不同副作用,协议保证针对操作历史而不只是最终数值。

练习二: PostgreSQL 使用非空同步备库配置与 synchronous_commit=on,客户端提交成功后立即查询同一同步备库,是否必然看到新值?

不能从该配置推出立即可见。on 要求 WAL 持久化,但重放仍可能落后。remote_apply 额外等待当前同步备库重放;查询还要遵守自身事务快照语义,已有旧快照不能因配置名称而自动更新。查询任意异步备库则又超出本次同步确认范围。

08 将转向共识问题:当配置服务也可能失效、网络延迟没有已知上界时,如何分别描述永不分叉和最终取得进展所需的假设。数据复制中的 master 假设会在那里成为需要解释的协议问题。

参考资料