土法炼钢兴趣小组的算法知识备份

【存储工程】副本与复制策略

文章导航

分类入口
storage
标签入口
#replication#quorum#chain-replication#multi-master#conflict-resolution#raft#synchronous-replication

目录

分布式存储系统绕不开一个根本问题:一份数据只存一个节点,节点挂了数据就没了。副本复制(Replication)是应对这个问题最直接的手段——把同一份数据拷贝到多个节点上,任何一个节点故障都不会导致数据丢失。但”把数据拷贝到多个节点”这句话背后的工程复杂度,远超大多数人的想象。

复制策略的核心矛盾在于一致性、可用性和性能之间的权衡。同步复制(Synchronous Replication)能保证强一致性,但写延迟受最慢副本约束;异步复制(Asynchronous Replication)写延迟低,但主节点故障时可能丢失已确认的写入;半同步复制(Semi-Synchronous Replication)试图在两者之间找到平衡;链式复制(Chain Replication)通过串行传播降低主节点的网络扇出;Quorum 复制通过 W+R>N 提供灵活的一致性选择;多主复制(Multi-Master Replication)允许多个节点同时接受写入,但引入了冲突解决的难题。

本文从复制的工程动机出发,逐一拆解六种主要复制策略的机制、代价和适用场景,再讨论复制拓扑、复制延迟的监控与处理,最后给出一张选型对比表。目标是让读者在设计存储系统或选择数据库时,能够根据自己的需求准确判断该用哪种复制策略。

适用范围 本文讨论的复制策略聚焦于数据存储层面,不涉及计算状态的复制(如流处理的 checkpoint)。涉及的具体实现以 MySQL 8.0、PostgreSQL 16、etcd 3.5、MongoDB 7.0 为参考版本,不同版本的行为可能存在差异。Raft 和 Paxos 等共识协议只在复制上下文中讨论其复制语义,不展开协议本身的证明。


一、复制的工程动机

数据复制解决三类工程问题:持久性(Durability)、可用性(Availability)和读扩展(Read Scaling)。这三个目标的优先级不同,直接决定复制策略的选择。

1.1 持久性

持久性的含义是:一旦系统向客户端确认写入成功,这条数据就不会因为硬件故障而丢失。单机场景下,持久性依赖磁盘。但磁盘会坏——根据 Backblaze 2023 年的硬盘故障统计,年化故障率(AFR)在 1% 到 3% 之间。如果一个存储集群有 10000 块硬盘,每年预期坏 100 到 300 块。仅靠单副本存储,任何一块硬盘的故障都意味着不可恢复的数据丢失。

副本复制通过在多个物理位置保存相同数据来应对这个问题。三副本的含义是:同一份数据存在三个不同的节点上,只有三个节点同时故障才会丢数据。假设单节点年化故障率为 2%,且故障独立,三副本同时故障的概率约为 0.02^3 = 8 * 10^-6,远低于单副本的 2%。

1.2 可用性

可用性的含义是:即使部分节点故障,系统仍然能对外提供读写服务。对于在线业务来说,可用性往往比持久性更紧迫——数据没丢但读不到,对用户来说和丢了没区别。

副本复制使得系统可以在部分节点不可用时继续服务。具体能容忍几个节点故障,取决于复制策略和副本数量。例如,三副本加上 Raft 共识协议,可以容忍一个节点故障后继续提供读写服务(需要多数派存活)。

1.3 读扩展

对于读多写少的负载,副本复制还有一个直接收益:把读请求分摊到多个副本上,增加系统的读吞吐量。一个典型场景是 Web 应用的数据库:写入集中在主节点,读请求分散到多个只读副本。MySQL 的主从复制在很大程度上就是为了这个目的。

但读扩展有个前提:应用层能容忍从副本(Follower)读到的数据可能不是最新的。如果业务逻辑要求强一致性的读,所有读请求都必须走主节点(Leader),副本的读扩展就失去了意义。

读扩展的另一个实际用途是地理就近读取(Geo-Local Read)。在全球多地域部署中,用户的读请求可以路由到离自己最近的副本,减少网络延迟。例如,一个部署在美国的主节点可以配置位于欧洲和亚洲的只读副本,欧洲用户的读请求直接走欧洲副本,避免跨大西洋的网络往返。

1.4 三个动机之间的关系

         持久性                     可用性                    读扩展
    (数据不丢失)              (服务不中断)              (读吞吐量提升)
         │                         │                        │
         │    三者并非独立,         │                        │
         │    复制策略的选择         │                        │
         │    是三者的权衡           │                        │
         ▼                         ▼                        ▼
  ┌──────────────────────────────────────────────────────────────┐
  │                       复制策略选择                           │
  │                                                              │
  │  同步复制 ──→ 强持久性 + 强一致性,但写延迟高、可用性受限     │
  │  异步复制 ──→ 低写延迟 + 高可用性,但可能丢数据               │
  │  半同步   ──→ 折中方案,保证至少一个副本同步                  │
  │  Quorum  ──→ 灵活调节一致性与可用性的平衡                    │
  └──────────────────────────────────────────────────────────────┘

实际工程中,这三个动机的优先级因场景而异。金融交易系统把持久性放在第一位,宁可牺牲写延迟也不能丢一条记录;内容分发系统优先读扩展,能容忍短暂的数据不一致;在线服务更关心可用性,宁可读到稍旧的数据也不能让请求失败。

还有一点容易被忽略:复制的网络开销。三副本意味着每次写入的数据量需要在网络上传输三份(主节点本地写入 + 发往两个副本)。在高吞吐场景下,复制流量可能占据节点间网络带宽的主要部分。如果一个存储节点的写入吞吐为 500 MB/s,三副本复制就需要额外 1 GB/s 的出向网络带宽。10 Gbps 的网络在这种负载下已经接近饱和。这就是为什么很多大规模存储系统在温冷数据场景下更倾向于使用纠删码(Erasure Coding)而非多副本——纠删码在相同容错能力下,网络和存储开销都更低(详见纠删码原理与存储效率)。


二、同步复制

同步复制(Synchronous Replication)的语义是:主节点在向客户端返回写入成功之前,必须等待所有副本都确认收到并持久化了这次写入。

2.1 写入流程

客户端                 主节点                 副本 A                副本 B
  │                     │                     │                     │
  │── Write(x=1) ─────→│                     │                     │
  │                     │── Replicate(x=1) ──→│                     │
  │                     │── Replicate(x=1) ──→│─────────────────────→│
  │                     │                     │                     │
  │                     │←── ACK ─────────────│                     │
  │                     │←── ACK ─────────────│─────────────────────│
  │                     │                     │                     │
  │←── OK ─────────────│                     │                     │
  │                     │                     │                     │

主节点收到写请求后,将数据同时发送给所有副本,等待所有副本都返回确认(ACK)后,才向客户端返回成功。这个过程中,写延迟等于主节点本地处理时间加上最慢副本的网络往返时间和持久化时间。

2.2 一致性保证

同步复制提供的一致性语义非常强:在任何时刻,如果客户端收到了写入成功的确认,那么无论从哪个副本读取,都能读到这次写入的结果。这就是强一致性(Strong Consistency),或者更精确地说,是线性一致性(Linearizability)的基础。

但同步复制本身并不直接等同于线性一致性。线性一致性还要求读操作能看到最新的写入,这需要在读路径上也做相应的保证(例如只从主节点读,或者在读之前先和主节点同步)。

2.3 代价分析

同步复制的代价集中在两个方面:

写延迟的木桶效应。写延迟取决于最慢的那个副本。如果三个副本分布在三个数据中心,跨数据中心的网络延迟通常在 1-10 ms(同城)到 50-200 ms(跨地域)。一次同步写入的延迟至少包含一个跨地域往返的时间,这对延迟敏感的在线服务来说难以接受。

可用性的短板。如果要求所有副本都确认,那么任何一个副本不可用都会导致写入阻塞。三副本的情况下,可用性反而比单机更差——单机只需要一个节点正常就能写,全同步复制需要三个节点都正常。

这正是为什么纯粹的全同步复制在生产环境中很少使用。实际的”同步复制”系统通常会加一些折中,例如只等多数派确认(这就进入了 Quorum 复制的范畴),或者在副本超时后降级为异步复制。

2.4 工程实例:etcd 的 Raft 复制

etcd 使用 Raft 共识协议实现复制。Raft 要求写入被多数派节点确认后才算提交(Commit),这可以看作一种”多数同步”的策略。在一个三节点的 etcd 集群中,Leader 只需要等待一个 Follower 的确认(加上自身共两个,满足多数派),就可以向客户端返回成功。

// etcd v3.5, raft/raft.go (删减版,仅保留关键路径)
// Leader 在收到多数派的 MsgAppResp 后推进 commitIndex
func (r *raft) maybeCommit() bool {
    mis := r.prs.CommittedIndex()
    // mis 是所有节点 matchIndex 的中位数
    // 即多数派都已经复制到的位置
    return r.raftLog.maybeCommit(mis, r.Term)
}

这段代码的核心逻辑是:Leader 计算所有 Follower 已确认的日志索引(matchIndex)的中位数,如果这个中位数大于当前的 commitIndex,就推进提交。中位数的含义正是”多数派都已经复制到”。

etcd 的默认写延迟在同数据中心部署下通常在 2-10 ms。跨数据中心部署时,写延迟主要取决于 Leader 到最近的多数派节点的网络距离。

2.5 工程实例:PostgreSQL 同步复制

PostgreSQL 原生支持同步复制。通过设置 synchronous_standby_names 参数,主节点可以在提交事务时等待指定的备节点确认。

# postgresql.conf (主节点)
synchronous_commit = on
synchronous_standby_names = 'FIRST 1 (standby1, standby2)'
# FIRST 1 表示等待列表中第一个可用的备节点确认
# ANY 1 (standby1, standby2) 表示等待任意一个确认

PostgreSQL 的 synchronous_commit 有五个级别,从弱到强依次为:

级别 行为 持久性保证
off 不等待本地 WAL 刷盘 本地崩溃可能丢数据
local 等待本地 WAL 刷盘 本地持久化
remote_write 等待备节点写入操作系统缓存 备节点进程崩溃不丢,操作系统崩溃可能丢
on 等待备节点 WAL 刷盘 备节点持久化
remote_apply 等待备节点回放完成 备节点可查询到最新数据

remote_apply 是最强的级别——不仅保证数据在备节点持久化了,还保证备节点上的查询能看到最新的数据。这对于需要强一致性读负载均衡的场景很有用,但写延迟也最高。


三、异步复制

异步复制(Asynchronous Replication)的语义是:主节点在本地持久化写入后,立即向客户端返回成功,然后在后台将数据发送给副本。

3.1 写入流程

客户端                 主节点                 副本 A                副本 B
  │                     │                     │                     │
  │── Write(x=1) ─────→│                     │                     │
  │                     │  (本地持久化)        │                     │
  │←── OK ─────────────│                     │                     │
  │                     │                     │                     │
  │                     │── Replicate(x=1) ──→│         (后台异步)   │
  │                     │── Replicate(x=1) ──→│─────────────────────→│
  │                     │                     │                     │

客户端收到成功响应时,数据只保证在主节点上持久化了。副本可能还没收到这次写入。

3.2 复制延迟

异步复制必然存在复制延迟(Replication Lag),即副本的数据落后于主节点的时间差。复制延迟的大小取决于多个因素:

在正常负载下,MySQL 的主从复制延迟通常在毫秒到秒级别。但在以下场景下,延迟可能急剧增加:

  1. 主节点执行了大批量写入(如 ALTER TABLE 或大量 INSERT),副本需要逐条回放
  2. 副本的磁盘 I/O 成为瓶颈
  3. 网络出现拥塞或抖动
  4. 副本上运行了复杂的读查询,和回放线程竞争资源

3.3 数据丢失风险

异步复制的核心风险是:主节点在数据尚未复制到任何副本时发生故障,那些已经向客户端确认但尚未复制出去的写入就会丢失。这个丢失窗口的大小等于最后一次成功复制到主节点故障之间的时间。

用一个具体例子说明:

时间线:
  t0: 客户端写入 x=1,主节点持久化,返回 OK
  t1: 客户端写入 x=2,主节点持久化,返回 OK
  t2: 主节点将 x=1 复制到副本 A
  t3: 主节点故障
  ─────────────────────────────────────────
  结果:x=2 丢失(已确认但未复制)
         x=1 存活(已复制到副本 A)

在这个例子中,客户端在 t1 收到了 x=2 写入成功的确认,但这条数据在主节点故障后永远丢失了。如果副本 A 被提升为新的主节点,系统里只有 x=1。

3.4 复制延迟对读一致性的影响

异步复制的另一个问题是读一致性。如果客户端从副本读取数据,可能读到过时的结果。这在以下场景中会导致用户困惑:

读己之写(Read-Your-Writes)。用户写入一条评论,刷新页面后看不到自己的评论——因为刷新请求被路由到了一个复制延迟较大的副本。

单调读(Monotonic Read)。用户连续两次刷新页面,第一次看到了最新数据(请求路由到了延迟较小的副本),第二次却看到了旧数据(请求路由到了延迟较大的副本),出现”时间倒流”。

解决这些问题通常需要在应用层做额外处理:

# 读己之写的一种实现方式(伪代码)
def read_with_consistency(user_id, key):
    # 记录用户最后一次写入的时间戳
    last_write_ts = get_user_last_write_timestamp(user_id)

    if last_write_ts is not None:
        # 如果用户最近有写入,从主节点读
        # 或者等副本追上这个时间戳
        return read_from_leader(key)
    else:
        # 否则可以从副本读
        return read_from_follower(key)

3.5 工程实例:MySQL 异步复制

MySQL 的默认主从复制就是异步的。主节点将修改写入二进制日志(Binary Log),从节点的 I/O 线程拉取二进制日志事件,写入中继日志(Relay Log),然后 SQL 线程回放中继日志中的事件。

主节点                              从节点
┌─────────────┐                    ┌─────────────┐
│  客户端写入  │                    │  I/O 线程    │
│      │      │                    │      │      │
│      ▼      │                    │      ▼      │
│  写 Binlog  │───── 网络 ────────→│ 写 Relay Log │
│      │      │                    │      │      │
│      ▼      │                    │      ▼      │
│  返回客户端  │                    │  SQL 线程    │
│             │                    │  回放事件    │
└─────────────┘                    └─────────────┘

MySQL 8.0 引入了多线程回放(Multi-Threaded Applier, MTA),通过将不同数据库或不同事务组的事件分配给多个回放线程来加速副本的回放速度,缓解复制延迟。

3.6 基于 GTID 的复制

MySQL 5.6 引入了全局事务标识符(Global Transaction Identifier, GTID),给每个事务分配一个全局唯一的 ID,格式为 server_uuid:transaction_id。GTID 让复制的管理变得更简单:

-- 查看当前节点已执行的 GTID 集合
SELECT @@global.gtid_executed;
-- 输出示例: 3e11fa47-71ca-11e1-9e33-c80aa9429562:1-35

-- 配置基于 GTID 的复制(从节点)
CHANGE REPLICATION SOURCE TO
  SOURCE_HOST = 'primary.example.com',
  SOURCE_AUTO_POSITION = 1;
-- SOURCE_AUTO_POSITION=1 让从节点自动根据 GTID 定位复制起点

基于 GTID 的复制从根本上改善了运维体验。在没有 GTID 的时代,故障切换后重新配置从节点需要手动计算新主节点的 Binlog 位置,容易出错。GTID 消除了这个人工操作。


四、半同步复制

半同步复制(Semi-Synchronous Replication)是同步和异步之间的折中:主节点在向客户端返回成功之前,等待至少一个副本确认收到了这次写入,但不要求所有副本都确认。

4.1 MySQL 半同步实现

MySQL 5.5 引入了半同步复制插件(rpl_semi_sync_master),MySQL 5.7 增强为”无损半同步”(Lossless Semi-Synchronous Replication),也称为 AFTER_SYNC 模式。

两种模式的关键区别在于主节点等待副本确认的时机:

AFTER_COMMIT(MySQL 5.5 原始模式):主节点先在本地提交事务(数据对其他本地事务可见),然后等待副本确认。如果主节点在提交后、等待确认前崩溃,可能导致主从数据不一致——主节点上已经提交的事务,副本上可能没有。

AFTER_SYNC(MySQL 5.7 增强模式):主节点先将事务写入二进制日志,等待至少一个副本确认收到后,才在本地提交事务。这确保了:如果主节点崩溃,任何已经提交的事务至少在一个副本上有备份。

AFTER_COMMIT 模式(旧):
  1. 写 Binlog
  2. 提交引擎(InnoDB Commit)    ← 数据已对其他事务可见
  3. 等待副本 ACK                  ← 这里崩溃会导致不一致
  4. 返回客户端

AFTER_SYNC 模式(新):
  1. 写 Binlog
  2. 等待副本 ACK                  ← 先确认副本收到
  3. 提交引擎(InnoDB Commit)     ← 再提交
  4. 返回客户端

4.2 超时降级

MySQL 半同步复制有一个超时参数 rpl_semi_sync_master_timeout(默认 10000 毫秒)。如果主节点在超时时间内没有收到任何副本的确认,就自动降级为异步复制,不再阻塞写入。

这是一个实用但危险的设计。实用之处在于:它避免了副本全部不可用时主节点写入被永久阻塞。危险之处在于:降级到异步模式后,持久性保证就没有了,但业务层往往意识不到这个变化。

-- 查看半同步复制状态
SHOW STATUS LIKE 'Rpl_semi_sync_master_status';
-- ON 表示半同步模式活跃,OFF 表示已降级为异步

-- 查看超时降级次数
SHOW STATUS LIKE 'Rpl_semi_sync_master_no_tx';
-- 这个值在增长说明有事务在异步模式下提交

-- 配置半同步参数
SET GLOBAL rpl_semi_sync_master_timeout = 5000;  -- 超时 5 秒
SET GLOBAL rpl_semi_sync_master_wait_for_slave_count = 1;  -- 至少等 1 个副本

4.3 等待副本数的选择

MySQL 5.7 引入了 rpl_semi_sync_master_wait_for_slave_count 参数,允许配置需要等待多少个副本的确认。在两个副本的配置下:

实际部署中,绝大多数场景使用 wait_for_slave_count = 1。等待全部副本确认相当于全同步复制,前面已经讨论过其代价。

4.4 半同步复制的局限

半同步复制解决了异步复制的数据丢失问题(在不降级的前提下),但引入了新的问题:

  1. 写延迟增加。每次写入都要等一个网络往返。同机房部署下额外延迟约 0.5-2 ms,跨机房则取决于机房间的网络距离。
  2. 吞吐量受限。等待确认的过程是串行的(在单个事务内),并发写入的整体吞吐受限于网络往返的延迟。MySQL 通过组提交(Group Commit)部分缓解了这个问题。
  3. 降级风险。网络抖动或副本过载都可能触发降级,而降级到异步模式后的行为对业务是不透明的。需要监控 Rpl_semi_sync_master_status 指标来及时发现。

五、链式复制

链式复制(Chain Replication)是一种将副本组织成线性链的复制策略,最早由 van Renesse 和 Schneider 在 2004 年的论文 “Chain Replication for Supporting High Throughput and Availability” 中提出。

5.1 基本机制

在链式复制中,所有副本按顺序排列成一条链。写请求从链头(Head)进入,沿链逐个传播到链尾(Tail);读请求只能从链尾发出。

写入流程:
  客户端 ──→ Head ──→ Middle ──→ Tail ──→ 客户端(ACK)

读取流程:
  客户端 ──→ Tail ──→ 客户端(返回数据)

        ┌──────┐    ┌──────┐    ┌──────┐
写 ───→ │ Head │───→│Middle│───→│ Tail │───→ 写确认
        └──────┘    └──────┘    └──────┘
                                    │
读 ─────────────────────────────────┘───→ 读返回

这种设计有几个关键属性:

写入有序性。写请求沿链单向传播,每个节点按相同的顺序处理写入,天然保证了所有副本的一致性。

读取一致性。所有读请求都走链尾,而链尾是最后一个收到写入的节点,因此链尾的数据一定是所有副本中最”旧”的。如果链尾有某条数据,那么链上所有节点都有这条数据。读链尾就等于读”已提交”的数据。

主节点负载分散。和传统主从复制不同,链式复制的 Head 只需要把数据发给下一个节点,不需要同时发给所有副本。网络扇出(Fan-out)从 N-1 降为 1。

5.2 链式复制的延迟特性

链式复制的写入延迟是链上所有节点间网络延迟的总和,而非最大值。假设三个节点 A、B、C 组成一条链,A 到 B 延迟 1 ms,B 到 C 延迟 1 ms,那么一次写入的端到端延迟约为 2 ms(加上各节点的本地处理时间)。

与”主节点并行发送到所有副本”的扇出复制相比:

复制方式 写入延迟 Head 网络扇出
扇出复制(3 副本) max(rtt_A, rtt_B) 2
链式复制(3 节点链) rtt_AB + rtt_BC 1

在同机房部署下,链式复制的延迟总和通常大于扇出复制的最大值。但链式复制的优势在于 Head 节点的网络带宽压力更小,特别是在副本数较多或数据量较大时。

5.3 CRAQ:链上读优化

标准链式复制要求所有读请求都走 Tail,这限制了读吞吐量。CRAQ(Chain Replication with Apportioned Queries,2009 年由 Terrace 和 Freedman 提出)对此做了优化:允许从链中的任意节点读取数据。

CRAQ 的核心思路是:每个节点维护对象的多个版本,区分”已提交”和”未提交”状态。当一个中间节点收到读请求时:

  1. 如果该对象只有一个版本(即最新写入已经传播到 Tail 并确认),直接返回
  2. 如果该对象有多个版本(即有未提交的写入正在传播中),向 Tail 查询最新的已提交版本号,然后返回对应版本
CRAQ 读取流程(对象有未提交版本时):

  客户端 ──→ Middle ──→ Tail(查询最新已提交版本)
                ←────── Tail 返回版本号 v3
             Middle 返回 v3 版本的数据 ──→ 客户端

CRAQ 在读多写少的场景下显著提升了读吞吐量,因为读请求可以分散到链上的所有节点,而不是全部集中在 Tail。在没有并发写入的情况下(即所有对象都只有一个已提交版本),CRAQ 的读性能和普通的多副本读分散一样好。

5.4 故障处理

链式复制的故障处理依赖一个外部的配置服务(Configuration Service)来维护链的成员列表和顺序。当链中的某个节点故障时:

故障处理的复杂度低于基于选举的共识协议(如 Raft),因为链的顺序是确定的,不需要选举过程。但链式复制依赖外部配置服务的可用性——如果配置服务本身不可用,链的重构就无法进行。

5.5 工程实例:HDFS 的 Pipeline 写入

HDFS 的数据写入就是链式复制的一个实际应用。客户端写入一个数据块时,NameNode 分配三个 DataNode 组成一条写入管道(Pipeline)。数据从客户端发送到第一个 DataNode,第一个转发给第二个,第二个转发给第三个。确认(ACK)沿反方向传回。

HDFS Pipeline 写入流程:

  Client ──→ DN1 ──→ DN2 ──→ DN3
                              │
  Client ←── DN1 ←── DN2 ←── DN3  (ACK)

这种设计的好处是:客户端只需要和一个 DataNode 通信,不需要同时向三个 DataNode 发送数据,减少了客户端的网络带宽消耗。对于大文件写入场景(HDFS 的典型负载),这一点尤其重要——如果客户端需要同时向三个 DataNode 发送同样的数据,客户端的出向带宽需要是单副本的三倍。

Pipeline 中某个 DataNode 故障时,HDFS 会将故障节点从 Pipeline 中摘除,用新的 DataNode 替换,继续写入。已写入的部分在后台通过重复制(Re-replication)补齐到三副本。


六、Quorum 复制

Quorum 复制的核心思想很简单:不要求所有副本都确认写入,只要求写入被足够多的副本确认,使得后续的读操作一定能读到最新的数据。这个”足够多”由 Quorum 条件定义:W + R > N,其中 N 是总副本数,W 是写入需要确认的副本数,R 是读取需要查询的副本数。

6.1 Quorum 条件的含义

W + R > N 保证了写集合和读集合之间一定有交集——至少有一个副本既参与了写入确认,又会被读操作查询到。这个交集副本上一定有最新的数据。

N=3, W=2, R=2 的情况:

写入确认的副本集合:  {A, B}
读取查询的副本集合:  {B, C}
交集:                {B}     ← B 上一定有最新数据

写入确认的副本集合:  {A, C}
读取查询的副本集合:  {A, B}
交集:                {A}     ← A 上一定有最新数据

只要满足 W + R > N,无论写入和读取各选择哪些副本,都能保证交集非空。

6.2 灵活的一致性配置

Quorum 复制的灵活性在于,可以通过调整 W 和 R 的值来适应不同的工作负载:

配置 W R 特点 适用场景
N=3, W=3, R=1 3 1 写入要全部副本确认,读只需要一个 读多写少,读延迟敏感
N=3, W=1, R=3 1 3 写入只需一个副本确认,读需要查询全部 写多读少,写延迟敏感
N=3, W=2, R=2 2 2 均衡配置 读写均衡
N=5, W=3, R=3 3 3 容忍 2 个节点故障 高可用性要求

W=1 意味着写入只要一个副本确认就返回,写延迟最低,但需要 R=N 来保证一致性读(必须查询所有副本才能确保读到最新值),且只能容忍 0 个节点故障(写路径)。

W=N 意味着写入需要所有副本确认,写延迟最高,但 R=1 就能保证一致性读。这实际上就是全同步复制。

下面这张图展示了 Quorum 写入和读取的交互过程(以 N=3, W=2, R=2 为例):

sequenceDiagram
    participant C as 客户端
    participant A as 副本 A
    participant B as 副本 B
    participant D as 副本 C

    Note over C,D: 写入阶段 (W=2)
    C->>A: Write(x=42)
    C->>B: Write(x=42)
    C->>D: Write(x=42)
    A-->>C: ACK
    B-->>C: ACK
    Note over C: 收到 2 个 ACK, 写入成功

    Note over C,D: 读取阶段 (R=2)
    C->>B: Read(x)
    C->>D: Read(x)
    B-->>C: x=42 (v2)
    D-->>C: x=41 (v1, 旧版本)
    Note over C: 取版本最高的值, x=42

客户端在写入时向所有三个副本发送请求,收到两个确认后返回成功(W=2)。读取时查询两个副本(R=2),取版本号最大的值作为结果。因为 W+R=4 > N=3,读集合和写集合一定有交集,所以一定能读到最新值。

6.3 Sloppy Quorum 与 Hinted Handoff

标准 Quorum 在部分节点不可用时会拒绝写入(如果可用节点数不足 W)。Dynamo 风格的系统(如 Amazon DynamoDB、Apache Cassandra、Riak)引入了 Sloppy Quorum 来提高可用性:当某个副本的指定节点不可用时,暂时将数据写入其他可用节点,并记录一个提示(Hint),等原始节点恢复后再把数据传回去。

正常 Quorum (N=3, W=2):
  节点 A: 写入 [OK]
  节点 B: 写入 [OK]  ← 满足 W=2,返回成功
  节点 C: 不可用

Sloppy Quorum (N=3, W=2):
  节点 A: 写入 [OK]
  节点 B: 不可用
  节点 C: 不可用
  节点 D: 代写 [OK] (Hinted Handoff,数据暂存于 D)
  ← 满足 W=2,返回成功
  
  当节点 B 恢复后:
  节点 D ──→ 节点 B: 传回数据,删除 Hint

Sloppy Quorum 提高了可用性,但牺牲了一致性保证。因为代写的节点 D 不在原始的 N 个副本之中,标准的 W + R > N 条件不再成立。在节点 B 恢复并接收到 Hinted Handoff 的数据之前,读请求可能读不到最新的写入。

6.4 读修复与反熵

Quorum 系统中,副本之间的数据可能存在差异(例如某个副本在写入时暂时不可用,之后恢复但缺少部分数据)。两种机制用于修复这些差异:

读修复(Read Repair)。读操作查询 R 个副本时,如果发现某些副本的数据版本落后,立即将最新数据写回这些落后的副本。这是一种”顺便”修复的策略,只在数据被读到时才会触发。

反熵过程(Anti-Entropy Process)。后台进程定期扫描副本之间的差异,将缺失的数据同步到落后的副本。Cassandra 使用 Merkle 树(Merkle Tree)来高效检测副本之间的差异:每个副本维护数据的 Merkle 树,比较两个副本的 Merkle 树根节点就能快速判断数据是否一致;如果不一致,逐层下探找到具体不同的数据范围。

6.5 工程实例:Cassandra 的可调一致性

Cassandra 允许在每次读写操作时指定一致性级别(Consistency Level),提供了极高的灵活性:

-- 写入时指定一致性级别
INSERT INTO users (id, name) VALUES (1, 'Alice')
USING CONSISTENCY QUORUM;

-- 可选的一致性级别:
-- ONE:         W=1,只等一个副本确认
-- QUORUM:      W=⌊N/2⌋+1,等多数副本确认
-- ALL:         W=N,等所有副本确认
-- LOCAL_QUORUM: 只在本数据中心内做 Quorum
-- EACH_QUORUM: 每个数据中心各自满足 Quorum

Cassandra 默认的副本因子 N=3,QUORUM 对应 W=2 或 R=2。在这个配置下,写 QUORUM + 读 QUORUM 满足 W+R=4 > N=3,可以保证强一致性读。但如果写 ONE + 读 ONE,W+R=2 不大于 N=3,就可能读到旧数据。

Cassandra 的实际部署中,一个常见的陷阱是跨数据中心的 Quorum。如果副本分布在三个数据中心,QUORUM 级别需要两个数据中心的副本都确认,写延迟取决于到第二近的数据中心的网络往返。LOCAL_QUORUM 只要求本数据中心内的副本达到 Quorum,写延迟只取决于本地网络,但无法保证跨数据中心的强一致性读。


七、多主复制与冲突解决

多主复制(Multi-Master Replication)允许多个节点同时接受写入,每个主节点独立处理客户端请求,然后将写入异步复制到其他主节点。和单主复制相比,多主复制的优势是:每个写入只需要在本地主节点提交,不需要跨数据中心的同步等待,写延迟取决于本地网络而非跨地域网络。

7.1 多主复制的适用场景

多主复制主要用于以下两种场景:

多数据中心部署。在多个数据中心各部署一个主节点,每个数据中心的写请求由本地主节点处理,写延迟只取决于本地网络。主节点之间通过异步复制保持数据同步。CockroachDB、TiDB 等分布式数据库在跨地域部署时本质上使用了类似的方案(尽管它们通常基于 Raft/Paxos 做分区级别的单主复制,整体上表现为多主)。

离线操作。需要在网络断开时继续工作的应用(如移动应用、协同编辑文档)。每个客户端实际上充当一个”主节点”,在离线时独立写入本地数据库,恢复网络连接后再和服务端同步。CouchDB 的设计理念就源于此。

7.2 写冲突

多主复制的核心难题是写冲突(Write Conflict)。当两个主节点同时修改同一条数据时,它们各自的本地写入都能成功,冲突在复制阶段才被发现。

主节点 A                                主节点 B
  │                                       │
  │  用户 Alice 修改 title="Foo"           │  用户 Bob 修改 title="Bar"
  │  本地提交成功                          │  本地提交成功
  │                                       │
  │───── 复制 title="Foo" ───────────────→│  冲突!本地 title="Bar"
  │←──── 复制 title="Bar" ───────────────│  冲突!本地 title="Foo"
  │                                       │

如果两个主节点最终的 title 值不同,数据就永久地不一致了。因此,多主复制必须有明确的冲突解决策略。

7.3 冲突解决策略

Last-Writer-Wins(LWW)

最简单的策略:给每次写入附加一个时间戳,冲突时保留时间戳最大的写入,丢弃其余的。

主节点 A: title="Foo", timestamp=100
主节点 B: title="Bar", timestamp=102

解决结果: title="Bar"(timestamp 102 > 100)

LWW 的问题是:它会静默丢弃数据。在上面的例子中,Alice 的写入被丢弃了,但 Alice 的客户端已经收到了写入成功的确认。Cassandra 默认使用 LWW 策略,这是因为它的设计目标是高可用性而非强一致性。

LWW 依赖时间戳的全局排序,但分布式系统中的时钟不精确,两个节点的时钟可能存在偏差。如果节点 A 的时钟比节点 B 快 10 ms,即使 Bob 的写入实际上发生在 Alice 之后,Alice 的时间戳也可能更大。可以使用逻辑时钟(Lamport Timestamp)或混合逻辑时钟(Hybrid Logical Clock, HLC)来缓解这个问题,但 LWW 丢弃数据的根本问题无法解决。

合并策略

不丢弃任何写入,而是将冲突的值合并。例如,将冲突的值放入一个集合 ["Foo", "Bar"],交给应用层或用户来最终决定。

CRDT

无冲突复制数据类型(Conflict-free Replicated Data Type, CRDT)是一类特殊的数据结构,设计上保证了并发修改不会产生冲突,或者冲突可以自动解决,且最终所有副本收敛到相同的状态。

CRDT 分为两大类:

基于状态的 CRDT(State-based CRDT, CvRDT)。每个副本维护完整状态,副本之间定期交换状态,通过数学上的合并函数(merge)将两个状态合并为一个。合并函数必须是可交换、可结合、幂等的。

基于操作的 CRDT(Operation-based CRDT, CmRDT)。副本之间传播操作(而非状态),要求操作满足可交换性——即无论操作以什么顺序到达,最终结果都相同。

一个经典的 CRDT 例子是 G-Counter(只增计数器):

G-Counter 示例(3 个节点):

初始状态:
  节点 A: {A:0, B:0, C:0}
  节点 B: {A:0, B:0, C:0}
  节点 C: {A:0, B:0, C:0}

节点 A 执行 increment():
  节点 A: {A:1, B:0, C:0}

节点 B 执行 increment() 两次:
  节点 B: {A:0, B:2, C:0}

合并 A 和 B 的状态(对每个条目取 max):
  {A: max(1,0), B: max(0,2), C: max(0,0)} = {A:1, B:2, C:0}
  计数器值 = 1 + 2 + 0 = 3

G-Counter 的每个节点只增加自己对应的计数器条目,合并时对每个条目取最大值。无论合并的顺序和次数如何,最终结果都是一致的。

更复杂的 CRDT 包括 PN-Counter(支持增减的计数器)、OR-Set(支持增删的集合)、LWW-Register(支持覆盖写的寄存器)等。Redis 7.0 的 CRDB(Conflict-free Replicated Database)使用了 CRDT 来实现多主同步。

7.4 CRDT 的工程代价

CRDT 不是银弹,它的工程代价包括:

  1. 元数据开销。以 OR-Set 为例,每个元素都需要附加一个唯一标识符来追踪添加和删除操作,集合的实际内存占用远大于数据本身。
  2. 可表达的数据类型有限。并非所有数据结构都能设计成 CRDT。例如,带约束的数据(如”余额不能为负”)很难用 CRDT 表达,因为约束检查需要全局知识。
  3. 语义不直观。CRDT 的合并语义有时和用户预期不一致。例如,两个用户同时编辑同一个文本,CRDT 可以保证不冲突,但合并后的文本可能既不是 Alice 想要的版本,也不是 Bob 想要的。
  4. 垃圾回收复杂。某些 CRDT(如 OR-Set)需要保留已删除元素的标记(Tombstone),这些标记会持续累积,需要一个分布式垃圾回收机制来清理。

八、复制拓扑

复制拓扑(Replication Topology)描述的是多个节点之间的数据传播路径。不同的拓扑结构在延迟、带宽效率和故障容错方面有不同的权衡。

8.1 星型拓扑

星型拓扑(Star Topology)有一个中心节点(Hub),所有其他节点只和中心节点通信。写入先发送到中心节点,中心节点再分发给所有其他节点。

        ┌───┐
   ┌────│ B │
   │    └───┘
┌───┐         ┌───┐
│ A │─────────│Hub│
└───┘         └───┘
   │    ┌───┐   │
   └────│ C │   │
        └───┘   │
        ┌───┐   │
        │ D │───┘
        └───┘

优点:拓扑简单,每个节点只需要和 Hub 通信。故障检测和管理容易——Hub 掌握所有节点的状态。

缺点:Hub 是单点瓶颈和单点故障。Hub 的网络扇出为 N-1,数据量大时 Hub 的网络带宽成为瓶颈。Hub 故障时,整个复制系统停摆。

8.2 环型拓扑

环型拓扑(Ring Topology)中,每个节点只向下一个节点传播数据,形成一个环。MySQL 的多主复制(MySQL Circular Replication)就是典型的环型拓扑。

  ┌───┐    ┌───┐
  │ A │───→│ B │
  └───┘    └───┘
    ↑        │
    │        ▼
  ┌───┐    ┌───┐
  │ D │←───│ C │
  └───┘    └───┘

优点:每个节点的网络扇出为 1,网络带宽压力小。

缺点:一个节点故障会断开整个环,需要重构拓扑。数据从起始节点传播到最远的节点需要经过多次转发,延迟等于链路上所有节点间延迟的总和。必须在写入中附加节点标识来避免数据在环中无限循环。

8.3 全连接拓扑

全连接拓扑(All-to-All Topology)中,每个节点都直接向所有其他节点发送数据。

  ┌───┐←───→┌───┐
  │ A │     │ B │
  └───┘←┐ ┌→└───┘
    ↕   │ │   ↕
  ┌───┐←┘ └→┌───┐
  │ D │     │ C │
  └───┘←───→└───┘

优点:任何单个节点的故障不影响其他节点之间的复制。数据传播延迟最短——直接从源节点到目标节点,不需要中转。

缺点:每个节点的网络扇出为 N-1,总网络流量为 N*(N-1)。节点数增加时网络开销急剧上升。可能存在因果一致性(Causal Consistency)问题——不同节点可能以不同的顺序收到来自不同源的写入。

全连接拓扑的因果一致性问题值得展开。假设有三个节点 A、B、C。用户在 A 上插入一条记录,然后在 B 上更新这条记录。如果 C 先收到了 B 的更新,后收到 A 的插入,C 在回放 B 的更新时会因为找不到记录而失败。解决这个问题需要使用版本向量(Version Vector)或逻辑时钟来追踪事件的因果关系,确保回放顺序尊重因果序。

8.4 拓扑选型对比

维度 星型 环型 全连接
每节点网络扇出 1(普通节点),N-1(Hub) 1 N-1
总网络流量 N-1(单向) N(单向) N*(N-1)
最大传播延迟 2 跳(经 Hub) N-1 跳 1 跳
单节点故障影响 Hub 故障致全停;其他节点故障影响小 环断裂,需重构 仅影响该节点
因果顺序保证 Hub 可做全局排序 天然有序(沿环方向) 需额外机制保证
典型实现 MySQL 主从 MySQL 环形多主 PostgreSQL 逻辑复制

实际部署中,大多数系统使用星型拓扑(单主 + 多从),因为它的管理复杂度最低,且单主天然避免了写冲突。全连接拓扑主要用于小规模的多主集群(通常 2-4 个节点)。环型拓扑因为故障处理复杂,在新系统中已经很少使用。


九、复制延迟的监控与处理

9.1 监控指标

复制延迟是复制系统最关键的运行时指标。监控延迟需要关注以下几个维度:

传输延迟(Transport Lag)。主节点产生写入到副本收到写入之间的时间差。这主要取决于网络延迟和带宽。

回放延迟(Apply Lag)。副本收到写入到将其应用到本地存储之间的时间差。这取决于副本的处理能力和负载。

端到端延迟(End-to-End Lag)。传输延迟 + 回放延迟。这是从业务视角最重要的指标——它决定了从副本读到的数据有多旧。

在 MySQL 中,可以通过 SHOW SLAVE STATUS(MySQL 8.0.22 之后为 SHOW REPLICA STATUS)查看复制延迟:

-- MySQL 复制延迟监控
SHOW REPLICA STATUS\G

-- 关键字段:
-- Seconds_Behind_Source: 副本落后主节点的秒数
-- Relay_Log_Space:       中继日志占用的磁盘空间
-- Exec_Source_Log_Pos:   副本已回放到的 Binlog 位置
-- Read_Source_Log_Pos:   副本已读取到的 Binlog 位置

Seconds_Behind_Source 是最常用的延迟指标,但它有一个已知缺陷:这个值基于事件的时间戳计算,如果主节点和副本的系统时钟不同步,或者副本在回放一个大事务(时间戳是事务开始时的),这个值可能不准确。更准确的做法是使用 pt-heartbeat(Percona Toolkit)等外部工具:在主节点定期写入当前时间戳,在副本上读取这个时间戳并与当前时间比较。

# 使用 pt-heartbeat 监控复制延迟
# 在主节点上运行(每秒更新一次时间戳)
pt-heartbeat --update --database=heartbeat --create-table

# 在副本上运行(监控延迟)
pt-heartbeat --monitor --database=heartbeat
# 输出: 0.02s, 0.01s, 0.03s ...(延迟值)

9.2 延迟告警阈值

延迟告警的阈值设置取决于业务对数据新鲜度的要求。一些参考值:

场景 建议告警阈值 说明
金融交易读副本 100 ms 不能容忍过期数据影响决策
在线业务读副本 1-5 s 用户感知到明显的数据延迟
报表 / 分析副本 30-60 s 分钟级延迟通常可接受
灾备副本 5-10 min 关注 RPO(恢复点目标)

9.3 延迟过大时的处理策略

当复制延迟持续增大时,需要按以下步骤排查和处理:

排查瓶颈。延迟增长的根源通常是以下几类:

  1. 主节点写入速率突然增加(大批量导入、DDL 操作)
  2. 副本的 I/O 或 CPU 成为瓶颈
  3. 网络带宽不足或延迟增加
  4. 副本上运行了复杂查询,和回放线程竞争资源
  5. 大事务导致回放阻塞

应急措施

-- 检查副本上是否有长查询阻塞回放
SHOW PROCESSLIST;

-- 如果有长查询阻塞,可以考虑终止
KILL <thread_id>;

-- MySQL 8.0: 检查多线程回放配置
SHOW VARIABLES LIKE 'replica_parallel_workers';
-- 如果为 1,可以增加并行回放线程数
SET GLOBAL replica_parallel_workers = 8;
SET GLOBAL replica_parallel_type = 'LOGICAL_CLOCK';

根本措施

9.4 复制延迟与故障切换

复制延迟对故障切换(Failover)有直接影响。当主节点故障需要将一个副本提升为新主节点时,如果该副本存在复制延迟,那些尚未复制到该副本的写入就会丢失。丢失的数据量等于故障切换时的复制延迟对应的写入量。

这就是恢复点目标(Recovery Point Objective, RPO)。对于异步复制,RPO 等于复制延迟;对于半同步复制(未降级),RPO 理论上为零(至少一个副本有所有已确认的写入);对于全同步复制,RPO 为零。

9.5 自动故障切换的工程考量

自动故障切换(Automatic Failover)是高可用部署的标配,但实现中有几个常见陷阱:

脑裂(Split Brain)。如果网络分区导致主节点和副本无法通信,但两者各自都还在运行,自动切换可能导致两个节点同时以为自己是主节点,客户端向两个”主节点”写入不同的数据。避免脑裂需要引入仲裁机制——例如使用奇数节点的共识协议(Raft/Paxos),或者依赖外部的分布式锁服务(如 ZooKeeper、etcd)来选举唯一的主节点。

切换后的数据回补。如果旧主节点在故障前的最后几条写入没有复制到新主节点,旧主节点恢复后这些写入就成了”孤立写入”(Orphaned Writes)。处理方式有三种:丢弃这些写入(简单但有数据丢失风险)、将旧主节点的多余写入导出到人工审核(保守但安全)、或者尝试自动合并(复杂且可能引入冲突)。MySQL 的 MHA(Master High Availability)工具在故障切换后会尝试从旧主节点的 Binlog 中补回差异事务。

故障检测的灵敏度。检测主节点故障通常依赖心跳超时。超时设置太短会导致误切换(网络抖动被误判为故障),超时设置太长会导致故障恢复时间(RTO)变长。实际部署中通常设置 10-30 秒的超时,并结合多次连续失败才触发切换。


十、复制策略选型对比

下面这张表汇总了本文讨论的各种复制策略在关键维度上的特征。没有哪种策略在所有维度上都最优,选择取决于具体场景的优先级。

维度 同步复制 异步复制 半同步复制 链式复制 Quorum(W=多数) 多主复制
写入延迟 高(受最慢副本约束) 低(本地持久化即返回) 中(等一个副本 ACK) 中(链上延迟总和) 中(等多数派 ACK) 低(本地主节点即返回)
数据丢失风险 有(延迟窗口内的写入) 极低(降级后有风险) 无(Tail 确认后) 取决于 W 值 有(异步复制部分)
读一致性 强一致 最终一致 最终一致(从副本读) 强一致(读 Tail) 可调(W+R>N 时强一致) 最终一致
可用性 低(全部副本须在线) 较高(可降级) 中(依赖链完整) 高(容忍少数节点故障)
写冲突 无(单主) 无(单主) 无(单主) 无(写走 Head) 可能有(并发写同 key) 有(需冲突解决)
主节点网络扇出 N-1 N-1 N-1 1 N-1 N-1(每个主节点)
实现复杂度 高(冲突解决)
典型实现 PostgreSQL 同步复制 MySQL 默认主从 MySQL 半同步 HDFS Pipeline、Ceph Cassandra、DynamoDB CouchDB、Galera Cluster

10.1 选型建议

金融交易、元数据存储、配置管理:强一致性要求高,RPO=0 是硬性约束。使用基于共识协议(Raft/Paxos)的多数派同步复制,如 etcd、ZooKeeper、TiKV。写延迟可接受范围内的同步复制。

在线业务数据库(OLTP):延迟和可用性并重。使用半同步复制(如 MySQL 半同步 + AFTER_SYNC),在不降级的前提下保证 RPO=0,同时写延迟增加可控。搭配读副本做读扩展。

大规模存储系统:吞吐量和可扩展性优先。使用 Quorum 复制(如 Cassandra),通过调节 W 和 R 的值适应不同的读写负载。链式复制适用于需要减少主节点网络扇出的场景(如 HDFS 的 Pipeline 写入)。

多地域部署:跨地域的网络延迟使得同步复制的代价极高。使用异步复制或多主复制,接受短暂的数据不一致。如果业务逻辑可以用 CRDT 建模,多主复制 + CRDT 是一个可行方案。否则,考虑按地域分片(Geo-Partitioning),将同一分片的数据限制在一个地域内,分片内使用同步复制。

10.2 一个实际的选型案例

假设要设计一个全球用户的社交应用的存储层。用户数据包括个人资料、好友关系和动态消息。

个人资料:用户自己修改,不存在并发写入冲突。但用户修改后应立即看到自己的更新(读己之写)。使用半同步复制 + 读主节点(对修改者)或读本地副本(对其他用户)。

好友关系:双方都可能同时发起好友请求,存在并发写入。好友关系可以建模为一个 OR-Set(CRDT),两端同时添加对方为好友时自动合并,不需要冲突解决。

动态消息:写入量大、读取量更大,一致性要求不高(晚几秒看到新动态是可以接受的)。使用异步复制 + 多副本读扩展。写入走本地数据中心的主节点,读取从本地副本读。

这个例子说明,同一个应用的不同数据类型可能需要不同的复制策略。实际系统中常见的做法是对不同的数据分别使用不同策略,而非全系统统一一种。


十一、参考文献

论文

书籍

官方文档

工具


上一篇: 数据分片策略 下一篇: 元数据管理

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2025-10-06 · storage

【存储工程】数据持久性工程

深入分析数据持久性的工程计算——故障率模型、多副本与纠删码的持久性推导、相关故障的影响、实际数据丢失案例与持久性计算器实现

2026-04-22 · db / storage

数据库内核实验索引

汇总本站数据库内核与存储引擎实验文章,重点覆盖从零实现 LSM-Tree 及其工程权衡。

2026-04-22 · storage

存储工程索引

汇总本站存储工程系列文章,覆盖 HDD、SSD、NVMe、持久内存、索引结构、压缩、分布式存储与对象存储。

2025-10-18 · storage

【存储工程】云块存储架构

深入剖析云块存储——分布式块存储架构原理、AWS EBS与阿里云ESSD架构分析、云盘性能规格解读、性能测试方法与选型成本优化


By .