复制策略

本页目录

复制让同一份逻辑数据存在于多个位置。副本可以承担故障恢复、就近读取和负载分担,但多保存几份数据不会自动得到强一致性:必须定义写入由谁排序、成功要等到什么程度、读取允许看到什么,以及故障后谁有权继续写。

先拆开四个独立维度

维度要回答的问题
拓扑与入口一个 leader、多位写入者,还是由协调者联系多个副本?
确认条件等本地持久化、远端收到、远端落盘,还是应用完成?
读取语义允许旧值,要求读己之写,还是要求线性一致性?
失败与切换哪些故障可容忍,如何保留已确认写入并隔离旧主?

“同步复制”主要描述确认等待条件,“单主”主要描述写入组织方式。不能只根据其中一个词推断整个系统的保证。

单 leader:在一个复制组内集中排序

客户端把写入交给 leader,leader 形成日志顺序并将变化复制给 followers。副本再按协议应用日志,形成可查询状态。这里的 leader 通常是一个分片或复制组的 leader,不一定是整个集群唯一的写入节点;不同分片可以由不同机器担任 leader。

收到、持久化、应用:副本的三个不同进度 收到日志 可能仍在内存 持久保存 重启后能够恢复 应用到状态 查询可见性另受快照影响 等待哪个阶段、等待哪些副本,决定写入成功的含义。 这是一条典型日志复制路径;提交边界与事务可见性仍须由具体协议定义。

同步与异步:成功响应发生在哪个边界

异步复制允许 leader 在无需等待远端副本的条件下返回成功。如果 leader 随后永久丢失,而接替者还没收到这些更新,⁠客户端已经收到成功的写入也可能丢失⁠。若只是短暂宕机且本地持久日志仍在,数据可能随后恢复,需区分故障模型。

同步复制要求成功前满足指定远端确认条件,但“同步”并不统一等于“至少一个副本落盘”。协议可能等待指定数量、指定成员或 quorum,也可能等待不同进度阶段。写延迟包括日志处理、网络往返、排队与存储开销,不能仅用两个 fsync 时间的最大值概括。

例如 PostgreSQL 区分远端接收、持久化和应用等等待语义;选择 remote_apply 才把等待推进到备用端应用阶段。具体行为应结合配置,而不是看到“同步”就假设任意备用端都已可读。PostgreSQL 复制文档

对同一条提交记录,三个副本可能分别达到不同阶段

用简化整数表示日志位置,待确认提交记录在 108。当前备用端进度如下,write 表示已写到操作系统文件层,flush 表示持久化,apply 表示已重放:

副本writeflushapply对位置 108 的状态
A110108104已持久化,尚未应用
B110106106已写文件层,尚未持久化到 108
C107107107尚未写到 108
progress = {
    "A": {"write": 110, "flush": 108, "apply": 104},
    "B": {"write": 110, "flush": 106, "apply": 106},
    "C": {"write": 107, "flush": 107, "apply": 107},
}

def reached(stage, position):
    return {node for node, stages in progress.items() if stages[stage] >= position}

assert reached("write", 108) == {"A", "B"}
assert reached("flush", 108) == {"A"}
assert reached("apply", 108) == set()
progress["A"]["apply"] = 108
assert reached("apply", 108) == {"A"}
assert "B" not in reached("apply", 108)

在 PostgreSQL 中,配置了有效同步备用端时,remote_write 等待的是远端写入文件系统,不能只理解为网络包已经收到;on 等待相应备用端持久化,remote_apply 再推进到应用阶段。remote_write 对备用数据库进程崩溃与备用端操作系统崩溃的保护不同,后者仍可能丢失尚未持久化的数据。synchronous_commit 定义

模型只列出每个阶段已经到达哪些副本,没有实现 FIRST/ANY 备用端选择或完整提交条件。真正能否返回,还取决于哪些备用端有资格计数、需要多少个确认,以及本地持久化是否完成。即使 A 的应用确认足以满足当前配置,也不能据此把立即读取路由到仍落后的 B。

故障切换不是把任意 follower 改名为 leader

安全切换要保留协议要求的已提交历史,选择有资格的候选者,并阻止旧 leader 恢复后继续成功写入。若确认过数据的副本存在,却选择一个落后的副本接管,单凭“之前用了同步复制”仍不能证明不丢数据。

共识协议将选举资格、日志匹配和提交规则结合起来。普通主备复制则需要检查其故障切换机制是否提供相应保证。怀疑旧主失联和获得新写入权限是两个问题,详见故障检测。

副本读取为什么可能破坏读己之写

用户修改昵称后立即刷新页面,若新读取被分配给尚未应用该日志的副本,可能看到旧昵称。可让相关读取经过有足够新鲜度保证的入口,或者在写入响应中返回版本 token,读取端等待自身进度至少达到该 token。

token 必须有明确的作用域与顺序关系。一个分片的日志位置不能随意与另一个分片比较;跨 leader 任期也需要有效的历史标识。固定“写后五秒读主”只能作为经验策略,不能证明所有延迟情况下的读己之写。

新鲜度 token 必须同时约束位置、历史和读取快照

假设一次写返回 (orders, history-1, 108):它属于订单分片的特定日志历史,读取至少要包含位置 108。库存分片已到 999,不能因此承接订单读取;另一个不兼容历史的位置 200,也不能直接与 108 比较。

from dataclasses import dataclass

@dataclass(frozen=True)
class Token:
    shard: str
    history: str
    position: int

def can_read(applied, required, snapshot_position=None):
    if (applied.shard, applied.history) != (required.shard, required.history):
        return False
    visible = applied.position
    if snapshot_position is not None:
        visible = min(visible, snapshot_position)
    return visible >= required.position

required = Token("orders", "history-1", 108)
assert not can_read(Token("orders", "history-1", 106), required)
assert can_read(Token("orders", "history-1", 108), required)
assert not can_read(Token("inventory", "history-1", 999), required)
assert not can_read(Token("orders", "other-history", 200), required)
assert not can_read(Token("orders", "history-1", 110), required, 100)
读己之写:副本进度够新,读取视图也必须够新 写 token:108 orders / history-1 副本 applied:110 同一分片、同一历史 旧快照:100 仍看不到 108 位置可比较 ≠ 旧事务快照自动刷新 模型仅判断指定依赖的新鲜度,不是完整的强一致读取协议。

最后一个断言是容易遗漏的边界:副本已应用到 110,但当前事务固定在 100 的历史快照,等待 apply 也不会自动刷新这个快照。若读取必须体现刚才的写入,需要在允许的隔离语义下建立足够新的读取视图。

这是把“读取视图对应一个日志前缀”的抽象模型,不是 PostgreSQL 的实际 MVCC 快照格式。真实系统需要实现 token 到读取条件的可靠映射;不同历史若能由协议证明继承关系,可以转换验证,而不是像示例一样全部拒绝。客户端也不能自己编造 token 作为授权凭据。

等待某个已知写入被应用,只提供这个依赖所要求的新鲜度,不等于读取到所有客户端的最新写入。要承诺线性一致读取,还需要当前领导权或相应读协议;读己之写与全局实时顺序是不同保证。

收到、持久化、应用,是三条不同的线

比较等待策略时,先固定“到底在等哪一个副本”,再比较它的阶段。下面只选一个同步备用端,假设本地条件已满足;write、flush、apply 三条边界保持有序。把等待条件从 flush 改成 apply,改变的是本次成功承诺,不会主动替副本执行重放。

正在呈现知识画面
收到、持久化、应用,是三条不同的线

apply≤flush≤write。远端文件层写入、持久化和应用确认提供不同保证;成功条件不等于任意副本立即可读。

多 leader:允许多个位置独立产生变化

异地独立写入能降低本地写入延迟,并在某些网络中断时继续处理请求。代价是不同位置可能在尚未看到彼此更新时修改同一对象。例如 A 和 B 同时修改联系人地址,恢复同步后必须保留、选择或合并这两个版本。

这不是“两个节点都会写”就必然出现冲突:按租户固定写入站点或把操作限制为可合并类型,可以减少冲突。但跨站点迁移写入权、离线编辑和并发创建仍需要明确规则。若必须在成功前跨站点协调唯一性,就不能同时把所有写入都当作完全独立的本地操作。

无 leader:协调多个副本,而不是没有协议

Dynamo 风格复制允许请求协调者联系 key 的多个副本。协调者不一定持有该 key 的副本,只有真正满足确认条件的副本才计入 W;不能机械地“收到 W−1 个回复再加自己”。

设固定副本集合大小为 N,一次写入得到 W 个副本确认,一次读取等待 R 个副本。当 R+W>N 时,两组集合必相交。但相交只说明读取可能接触到某次已确认写入留下的信息;如何选择版本、处理并发、未完成写入和读修复,仍然属于协议的一部分。

例如 N=3、W=2、R=2,写入 1 只到达 A 后写请求超时;第一次读取 A、B 看见 1,第二次读取 B、C 却看见 0。如果协议只“选本次最大版本后返回”,没有完成必要的传播或读回写,读值就可能倒退。单独的 quorum 数量不能证明线性一致性。

某些无固定 leader 的寄存器算法确实可以用 quorum 加版本与读回写实现线性一致性。因此,无主拓扑也不意味着只能提供弱一致性;应判断完整算法。

故障期间的暂存与修复

Hinted handoff 用于暂存原本应发往某个副本的更新,待其恢复后补送。它依赖提示持久性、保留期限和后续修复,不是永久丢失任意副本后仍必然可恢复的保证。

Sloppy quorum 允许临时替代节点参与接收,可能提高可写性。但一旦读写使用的候选集合不同,就不能直接套用同一个 N 上的交集证明。详细路径见分布式读写路径。

选择时先写出业务要求

要求需要进一步检查
成功后的写入不能因允许范围内故障而丢失持久化、确认集合、切换资格与故障域
写后立即读到自己的修改路由、应用进度和会话 token
多地断网时仍能修改并发冲突语义及无法本地验证的约束
扩展总写吞吐分片与 leader 分布,不只是副本数量

继续阅读:冲突解决与分布式事务。

参考

Keywords: replication, leader, follower, quorum, synchronous replication, read-your-writes, failover