mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5069 字
13 分钟
数据复制:主从、多主与无主复制
2024-09-17

当你的业务从一台数据库扩展到多台时,第一个要回答的问题就是:数据如何在多个节点间保持一致? 复制(Replication)是分布式数据库的基石——它通过在多个节点上维护数据的副本来实现高可用、读扩展和地理分布。但复制引入了一个根本性矛盾:数据被复制到多个节点后,如何保证它们彼此一致?

本章从”为什么需要复制”出发,逐层深入三种复制架构——单主复制、多主复制、无主复制——分析各自的适用场景与权衡,最后聚焦复制延迟带来的一致性问题及其应对策略。理解复制,是理解所有分布式数据系统的起点。

数据复制的历史与分布式系统本身一样悠久。1970 年代,IBM 的 IMS Hot Standby 实现了最早的主从复制——备机持续读取主机的日志并回放,故障时接管。1990 年代,MySQL 的主从复制成为互联网公司的标配,但异步复制带来的”数据丢失”问题困扰了无数工程师。2007 年,Amazon 的 Dynamo 论文引入了无主复制(Quorum 机制),启发了 Cassandra 和 Riak。2010 年代,多主复制(Multi-Master)在跨地域场景中流行,但冲突解决成为新的难题。理解这段历史,你就会明白为什么复制架构的选择没有”正确答案”——单主简单但有单点风险,多主可用但冲突复杂,无主弹性但一致性弱——每种方案都是对特定场景的权衡。

前置知识#

Note

复制是分布式数据库的基石。理解复制后,数据分区分布式事务 会更容易理解。

一、为什么需要复制#

1.1 复制的三大动机#

在单机数据库中,数据只存在一份。这在大多数场景下足够,但当业务规模增长时,单机成为瓶颈——不是性能不够,就是可用性不够,或者两者都不够。复制的动机可以归纳为三点:

动机问题描述复制的解决方案
高可用节点故障导致服务不可用多副本保证单点故障不影响整体服务
读扩展单机读 QPS 达到上限将读请求分散到多个副本,提升读吞吐
低延迟跨地域用户访问延迟高在不同地域部署副本,就近服务
graph TB subgraph 问题["单机的三大瓶颈"] HA[" 单点故障<br/>节点宕机 = 服务不可用"] READ[" 读瓶颈<br/>单机 QPS 上限"] LAT[" 跨地域延迟<br/>物理距离不可逾越"] end subgraph 方案["复制的应对策略"] HA_S["多副本冗余<br/>自动故障转移"] READ_S["读写分离<br/>读分散到从节点"] LAT_S["地理复制<br/>就近读取"] end HA --> HA_S READ --> READ_S LAT --> LAT_S style 问题 fill:#fff3e0,stroke:#e65100 style 方案 fill:#e8f5e9,stroke:#2e7d32

1.2 复制的基本约束#

复制看似简单——把数据从 A 复制到 B 就行了——但实际面临一个根本性约束:网络不是瞬时的,节点不是同时的。这意味着在任意时刻,不同副本上的数据可能不一致。这个约束衍生出三个核心问题:

  1. 数据变更如何传播? — 同步还是异步?还是半同步?
  2. 多个写入点如何协调? — 单主、多主还是无主?
  3. 不一致的窗口有多大? — 复制延迟如何影响一致性保证?

这三个问题贯穿整章,逐一展开。

二、单主复制#

单主复制(Single-Leader Replication)是最经典、应用最广泛的复制架构。其核心思想是:所有写请求必须经过主节点,从节点只接受读请求

2.1 架构概览#

sequenceDiagram participant Client as 客户端 participant Leader as 主节点 participant Follower1 as 从节点 1 participant Follower2 as 从节点 2 Client->>Leader: WRITE(x=1) Leader->>Leader: 写入本地 WAL Leader->>Follower1: 异步推送日志 Leader->>Follower2: 异步推送日志 Follower1->>Follower1: 应用日志 Follower2->>Follower2: 应用日志 Client->>Follower1: READ(x) Follower1-->>Client: x=1(如果已同步)<br/>或 x=0(如果未同步)

主节点将数据变更写入本地日志后,将日志推送到所有从节点。从节点按顺序应用日志,使本地数据与主节点保持一致。这个过程看似简单,但日志推送的时机决定了系统的一致性与可用性权衡。

2.2 同步 vs 异步 vs 半同步#

日志推送的时机是单主复制最关键的设计决策。三种策略各有取舍:

策略工作方式一致性可用性性能典型系统
同步复制主节点等待所有从节点确认后才返回客户端最强(无数据丢失)最弱(任一从节点故障则写阻塞)最差(延迟取决于最慢从节点)MySQL 半同步(ALL 模式)
异步复制主节点写入本地后立即返回,不等从节点最弱(主节点故障可能丢数据)最强(从节点故障不影响写入)最好(写延迟 = 本地写入)MySQL 默认、PostgreSQL 默认
半同步复制主节点等待至少一个从节点确认后返回中等(至多丢失一个节点的数据)中等(一个从节点故障仍可写入)中等(延迟 = 本地 + 一个 RTT)MySQL 半同步、PostgreSQL 同步提交
Warning

异步复制在主节点故障时可能丢失已确认的写入。MySQL 主从切换中著名的”脑裂”问题就源于此——主节点崩溃前已向客户端确认写入,但从节点尚未收到日志。新主节点没有这些数据,导致”已确认的写入消失”。生产环境中,必须评估你能容忍的数据丢失量(RPO),再选择复制策略。

下面通过 MySQL 的配置来对比三种模式:

-- MySQL 异步复制(默认)
-- 主节点写入后立即返回客户端,不等从节点
CHANGE MASTER TO MASTER_HOST='leader', MASTER_PORT=3306;
START SLAVE;
-- 优点:写入延迟低
-- 缺点:主节点故障可能丢失数据
-- MySQL 半同步复制(至少一个从节点确认)
-- 主节点安装半同步插件
INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
SET GLOBAL rpl_semi_sync_master_enabled = 1;
SET GLOBAL rpl_semi_sync_master_timeout = 1000; -- 等待超时 1s,超时后降级为异步
-- 从节点安装半同步插件
INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so';
SET GLOBAL rpl_semi_sync_slave_enabled = 1;
-- 优点:至少一个从节点有数据,故障转移更安全
-- 缺点:写入延迟增加一个 RTT
# 用 Python 模拟三种复制策略的行为差异
import time
import random
class ReplicationSimulator:
"""模拟单主复制中三种策略的延迟与数据安全差异"""
def __init__(self, follower_count=3, network_latency_ms=50):
self.follower_count = follower_count
self.network_latency = network_latency_ms
def sync_replication(self):
"""同步复制:等待所有从节点确认"""
start = time.time()
# 模拟等待所有从节点
max_latency = max(
random.gauss(self.network_latency, 10)
for _ in range(self.follower_count)
)
time.sleep(max_latency / 1000)
return {
"strategy": "同步",
"latency_ms": round(max_latency, 1),
"data_safety": "所有从节点已确认,零数据丢失",
}
def async_replication(self):
"""异步复制:主节点写入后立即返回"""
start = time.time()
time.sleep(0.001) # 仅本地写入延迟
return {
"strategy": "异步",
"latency_ms": 1.0,
"data_safety": "从节点可能未收到,主节点故障可丢数据",
}
def semi_sync_replication(self):
"""半同步复制:等待至少一个从节点确认"""
start = time.time()
min_latency = min(
random.gauss(self.network_latency, 10)
for _ in range(self.follower_count)
)
time.sleep(min_latency / 1000)
return {
"strategy": "半同步",
"latency_ms": round(min_latency, 1),
"data_safety": "至少一个从节点已确认,至多丢一个节点数据",
}

2.3 日志复制的实现方式#

主节点如何将变更传递给从节点?不同数据库有不同的实现,核心区别在于日志的抽象层级

方式原理优点缺点典型系统
基于语句记录 SQL 语句,从节点重新执行日志量小、可读非确定性函数(NOW()/RAND())、自增 ID 冲突MySQL Statement Binlog
基于行记录行的实际变更(前像/后像)确定性强、无歧义日志量大(批量 UPDATE 产生大量行记录)MySQL Row Binlog
基于 WAL传递底层 WAL 日志段与存储引擎一致、延迟最低耦合存储引擎格式,版本升级困难PostgreSQL、Cassandra
-- MySQL Binlog 格式对比
-- 基于语句的日志(Statement-based)
-- Binlog: UPDATE users SET last_login = NOW() WHERE city = 'Beijing'
-- 问题:NOW() 在从节点执行时值不同!
-- 基于行的日志(Row-based)
-- Binlog: Table users, Row id=1: last_login='2026-05-03 10:00:00'
-- Table users, Row id=5: last_login='2026-05-03 10:00:01'
-- 优点:确定性,从节点结果与主节点完全一致
-- 设置 MySQL 使用 Row-based binlog
SET GLOBAL binlog_format = ROW;

2.4 主节点故障转移#

单主复制的阿喀琉斯之踵是主节点故障。当主节点宕机时,必须选出一个从节点提升为新主节点,这个过程称为故障转移(Failover)。故障转移看似简单,实则暗藏陷阱:

flowchart TD A["主节点故障"] --> B["检测故障<br/>心跳超时"] B --> C["选举新主节点"] C --> D{"旧主节点数据<br/>是否完全同步?"} D -->|是| E["提升从节点为新主"] D -->|否| F[" 数据丢失风险"] F --> G["决策:接受丢失 or 手动修复"] E --> H["重定向客户端连接"] H --> I["旧主节点恢复后<br/>降级为从节点"] style F fill:#ffcdd2,stroke:#c62828 style G fill:#fff9c4,stroke:#f9a825

故障转移中的常见问题:

  1. 数据丢失:异步复制下,旧主节点已确认但未同步到从节点的写入会丢失
  2. 脑裂:旧主节点未真正宕机,继续接受写入,出现两个主节点
  3. 过期数据提升:选错了从节点——它不是数据最新的那个
Note

Redis Sentinel 的故障转移机制是生产级的典型实现。它通过哨兵集群监控主节点,在故障时自动执行选举和提升。具体实现细节参见 Redis 深入

三、多主复制#

单主复制的写瓶颈在于所有写入必须经过主节点。如果写入本身也需要扩展,或者需要在多个数据中心写入,单主架构就力不从心了。多主复制(Multi-Leader Replication)允许多个节点同时接受写入,每个主节点将变更异步传播给其他主节点。

3.1 架构与适用场景#

graph TB subgraph DC1["数据中心 1(北京)"] L1["主节点 1"] F1A["从节点 1A"] F1B["从节点 1B"] L1 --> F1A L1 --> F1B end subgraph DC2["数据中心 2(上海)"] L2["主节点 2"] F2A["从节点 2A"] F2B["从节点 2B"] L2 --> F2A L2 --> F2B end subgraph DC3["数据中心 3(广州)"] L3["主节点 3"] F3A["从节点 3A"] F3B["从节点 3B"] L3 --> F3A L3 --> F3B end L1 <-->|"异步复制"| L2 L2 <-->|"异步复制"| L3 L1 <-->|"异步复制"| L3 Client1["客户端(北京)"] --> L1 Client2["客户端(上海)"] --> L2 Client3["客户端(广州)"] --> L3 style DC1 fill:#e3f2fd,stroke:#1565c0 style DC2 fill:#e8f5e9,stroke:#2e7d32 style DC3 fill:#fff3e0,stroke:#e65100

多主复制的典型应用场景:

场景为什么需要多主示例
多数据中心跨数据中心写入延迟不可接受,每个数据中心需要本地写入MySQL 双主、Bucardo
离线客户端客户端离线时需要本地写入,上线后同步CouchDB、PouchDB
协同编辑多人同时编辑同一文档Google Docs、Figma

3.2 写冲突 — 多主复制的核心挑战#

多主复制的根本问题是写冲突(Write Conflict):两个主节点同时修改同一行数据,当变更异步传播到对方时,冲突无法避免。

# 模拟多主复制中的写冲突
# 场景:用户在两个数据中心同时修改同一用户的姓名
# 数据中心 1(北京):用户将姓名改为 "张三"
dc1_data = {"user_id": 1, "name": "张三", "version": 1}
# 数据中心 2(上海):用户将姓名改为 "张三丰"
dc2_data = {"user_id": 1, "name": "张三丰", "version": 1}
# 当两个数据中心的变更异步同步到对方时,冲突发生了
# 两个版本都基于 version=1,但值不同
# 这不是并发控制能解决的问题——因为两个写入发生在不同节点上
# 事务隔离也无法帮助——这里没有"事务"的概念
Caution

多主复制的写冲突与 事务与并发控制 中讨论的并发写问题本质不同。单机并发写可以通过锁或 MVCC 串行化,但多主复制中的冲突发生在不同节点上,无法通过本地锁解决。冲突必须在变更传播后异步解决。

3.3 冲突检测与解决策略#

冲突解决是多主复制最复杂的问题。策略可以分为两大类:避免冲突检测并解决冲突

避免冲突#

最简单的策略是确保同一行数据只在一个节点上被修改——所有对特定行的写入都路由到同一个主节点。这需要精心设计数据分片策略,但并非总是可行(如协同编辑场景)。

冲突解决策略对比#

策略原理优点缺点典型系统
最后写入胜(LWW)基于时间戳,最新写入覆盖旧值简单、确定性强时钟偏差导致数据丢失;丢弃并发写入Cassandra、DynamoDB
应用层合并应用代码决定如何合并冲突最灵活、语义最准确开发成本高、容易出错CouchDB、CRDT-based
自定义冲突解决数据库调用用户注册的回调函数平衡灵活性与自动化回调逻辑复杂时难以维护Bucardo、Litelong
CRDT使用数学上可交换的数据结构自动合并、无冲突数据类型受限、存储开销大Riak、Automerge

3.4 最后写入胜(LWW)详解#

LWW 是最常用的冲突解决策略,也是问题最多的。它的核心假设是:时间戳更晚的写入是”正确”的。但分布式系统中,时钟同步是一个未解决的问题。

# LWW 的时钟偏差问题
# 假设两个节点的时钟有 100ms 偏差
import time
# 节点 A(时钟快 100ms)
node_a_time = time.time() # 例如 1714723200.100
node_a_write = {"key": "x", "value": "A", "timestamp": node_a_time}
# 节点 B(时钟正常)
time.sleep(0.05) # B 的写入在 A 之后 50ms 发生
node_b_time = time.time() # 例如 1714723200.050
node_b_write = {"key": "x", "value": "B", "timestamp": node_b_time}
# LWW 判定:A 胜出(时间戳更大)
# 但实际上 B 的写入在 A 之后!
# 结果:后写入的值被先写入的值覆盖
winner = max([node_a_write, node_b_write], key=lambda x: x["timestamp"])
print(f"LWW winner: {winner['value']}") # 输出: A — 错误!
-- Cassandra 中的 LWW 实现
-- Cassandra 使用客户端提供的时间戳(微秒精度)解决冲突
-- 后插入的行(时间戳更大)覆盖先插入的行
-- 客户端 A 写入(时间戳较大)
INSERT INTO users (id, name, ts)
VALUES (1, '张三', 1714723200100000);
-- 客户端 B 写入(时间戳较小,但实际发生更晚)
INSERT INTO users (id, name, ts)
VALUES (1, '张三丰', 1714723200050000);
-- 查询结果:name='张三'(时间戳更大者胜出)
-- 即使 B 的写入在逻辑上更晚,LWW 也会选择 A
SELECT name FROM users WHERE id = 1;
Warning

LWW 可能导致数据静默丢失——后写入的值被覆盖,且没有任何错误提示。如果你的业务不允许丢失写入(如银行余额),不应使用 LWW。考虑使用 CRDT 或应用层合并策略。关于分布式环境下的更强一致性保证,参见 分布式事务

四、无主复制#

无主复制(Leaderless Replication)彻底抛弃了”主节点”的概念——任何节点都可以接受写入和读取。这种架构源自 Amazon Dynamo 论文,被 Cassandra、DynamoDB、Riak 等系统采用。

4.1 核心思想:Quorum 读写#

无主复制的关键创新是 Quorum(法定人数) 机制。它通过数学上的不等式,在一致性与可用性之间找到平衡点:

w+r>nw + r > n

其中:

  • nn = 副本总数
  • ww = 写入确认所需的副本数(写 Quorum)
  • rr = 读取所需的副本数(读 Quorum)

w+r>nw + r > n 时,读取的节点集合与写入的节点集合必然有交集,因此读取一定能看到至少一个最新值。

# Quorum 读写模拟
class QuorumSystem:
"""模拟无主复制的 Quorum 读写"""
def __init__(self, n=3):
self.n = n # 副本总数
self.nodes = [None] * n # 每个节点存储的值
self.timestamps = [0] * n # 每个节点的写入时间戳
def write(self, key, value, w=None):
"""写入:需要 w 个节点确认"""
if w is None:
w = self.n # 默认写入所有节点
ts = max(self.timestamps) + 1 # 递增时间戳
success_count = 0
for i in range(self.n):
self.nodes[i] = value
self.timestamps[i] = ts
success_count += 1
if success_count >= w:
break
return success_count >= w
def read(self, key, r=None):
"""读取:从 r 个节点中取最新值"""
if r is None:
r = self.n
# 收集 r 个节点的值
values = []
for i in range(min(r, self.n)):
values.append((self.timestamps[i], self.nodes[i]))
# 返回时间戳最大的值
if not values:
return None
latest = max(values, key=lambda x: x[0])
return latest[1]
# 常见 Quorum 配置对比
configs = [
{"n": 3, "w": 2, "r": 2, "name": "严格 Quorum", "desc": "w+r>n,保证读到最新"},
{"n": 3, "w": 1, "r": 1, "name": "宽松 Quorum", "desc": "w+r≤n,可能读到旧值"},
{"n": 5, "w": 3, "r": 3, "name": "高可用 Quorum", "desc": "容忍 2 节点故障"},
{"n": 5, "w": 3, "r": 2, "name": "读优化 Quorum", "desc": "写严格,读宽松"},
]

4.2 Quorum 配置的权衡#

不同的 wwrr 配置代表不同的一致性与可用性权衡:

配置nwrw+r>n?容忍故障一致性读延迟写延迟
严格3221 节点
宽松3112 节点
高可用5332 节点
读优化5322 节点
写优化5232 节点

4.3 读修复与反熵#

无主复制中没有主节点来推送日志,那么如何保证副本间最终一致?两种机制:

读修复(Read Repair):在读取时检测到不一致,立即修复。客户端从多个节点读取数据,发现某个节点返回了旧值,就向该节点写入最新值。

# 读修复机制模拟
def read_repair(quorum_system, key, r):
"""读取时检测并修复不一致"""
# 从 r 个节点收集值
responses = []
for i in range(r):
responses.append({
"node": i,
"value": quorum_system.nodes[i],
"timestamp": quorum_system.timestamps[i],
})
# 找到最新值
latest = max(responses, key=lambda x: x["timestamp"])
# 修复落后节点
repairs = []
for resp in responses:
if resp["timestamp"] < latest["timestamp"]:
# 将最新值写入落后节点
quorum_system.nodes[resp["node"]] = latest["value"]
quorum_system.timestamps[resp["node"]] = latest["timestamp"]
repairs.append(resp["node"])
return latest["value"], repairs

反熵(Anti-Entropy):后台进程定期比较副本间的差异并修复。通常使用 Merkle Tree 快速定位不一致的数据范围。

# Cassandra 中的反熵修复命令
# 手动触发全量修复
nodetool repair --full
# 增量修复(只修复未修复的数据)
nodetool repair
# 查看修复进度
nodetool netstats

4.4 无主复制的故障处理#

无主复制的一大优势是故障处理简单——不需要选举新主节点,不需要故障转移。当某个节点故障时,写入和读取只需 Quorum 数量的节点可用即可继续。

flowchart LR subgraph Normal["正常状态(n=3, w=2, r=2)"] N1["节点 A<br/>v=3"] N2["节点 B<br/>v=3"] N3["节点 C<br/>v=3"] end subgraph Failure["节点 C 故障"] N1F["节点 A<br/>v=4"] N2F["节点 B<br/>v=4"] N3F["节点 C<br/>故障"] H[" hinted handoff<br/>暂存写入"] end subgraph Recovery["节点 C 恢复"] N1R["节点 A<br/>v=4"] N2R["节点 B<br/>v=4"] N3R["节点 C<br/>v=4<br/>(从 handoff 恢复)"] end Normal --> Failure --> Recovery style N3F fill:#ffcdd2,stroke:#c62828 style N3R fill:#c8e6c9,stroke:#2e7d32 style H fill:#fff9c4,stroke:#f9a825

当节点故障时,其他节点会使用 Hinted Handoff 机制暂存本应写入故障节点的数据。当故障节点恢复后,暂存的数据会被推送过去。这保证了即使 w<nw < n,故障期间的数据也不会丢失。

五、复制延迟与一致性#

无论采用哪种复制架构,只要存在异步传播,就必然存在复制延迟(Replication Lag)——主节点上的数据变更需要时间才能到达从节点。在正常情况下,延迟可能只有毫秒级;但在网络抖动或负载高峰时,延迟可能达到秒级甚至分钟级。

5.1 复制延迟的三个一致性问题#

复制延迟不是简单的”读到旧数据”——它会导致三种特定的一致性异常,每种都需要不同的应对策略:

异常描述触发场景应对策略
读写不一致写入后立即读取,读到旧值用户修改个人资料后刷新页面,看到旧信息读己之写
单调读违反先读到新值,后读到旧值(时间倒退)读请求被路由到不同从节点单调读保证
前缀一致读违反因果关系的写入被乱序读取问答场景:先看到回答,后看到问题前缀一致读

5.2 读写一致性(Read-after-Write Consistency)#

这是用户最直观感受到的问题:我刚改了密码,刷新页面怎么还是旧的?

sequenceDiagram participant User as 用户 participant Leader as 主节点 participant F1 as 从节点 1(延迟 1s) participant F2 as 从节点 2(延迟 5s) User->>Leader: 修改密码为 "newPass123" Leader-->>User: 修改成功 Leader->>F1: 异步推送(1s 后到达) Leader->>F2: 异步推送(5s 后到达) Note over User: 立即刷新页面 User->>F2: 读取密码 F2-->>User: 返回旧密码 "oldPass456" Note over User: 用户困惑:我明明改了! Note over User: 等待 5 秒后再次刷新 User->>F2: 读取密码 F2-->>User: 返回新密码 "newPass123"

实现读写一致性的策略

# 策略 1:关键读走主节点
# 对于用户自己修改的数据,读请求路由到主节点
def read_user_profile(user_id, is_self):
"""is_self=True 表示读取自己的资料(需要读写一致性)"""
if is_self:
return query_leader(f"SELECT * FROM users WHERE id = {user_id}")
else:
return query_follower(f"SELECT * FROM users WHERE id = {user_id}")
# 策略 2:时间窗口内读主节点
# 记录用户最后一次写入的时间,在时间窗口内读主节点
import time
class ReadAfterWriteRouter:
def __init__(self, window_seconds=30):
self.last_write_time = {} # user_id -> timestamp
self.window = window_seconds
def after_write(self, user_id):
"""用户写入后,记录时间"""
self.last_write_time[user_id] = time.time()
def route_read(self, user_id):
"""决定读请求路由到主节点还是从节点"""
last_write = self.last_write_time.get(user_id, 0)
if time.time() - last_write < self.window:
return "leader" # 时间窗口内,走主节点
return "follower" # 超出窗口,走从节点
-- 策略 3:MySQL 中使用 GTID 追踪复制进度
-- 客户端写入后记录 GTID,读取时等待从节点追上
-- 主节点写入后获取 GTID
INSERT INTO users (name, email) VALUES ('张三', 'zhang@example.com');
SHOW MASTER STATUS; -- 获取当前 GTID: 3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5
-- 从节点等待 GTID
-- MySQL 5.6+ 支持 WAIT_FOR_EXECUTED_GTID_SET
SELECT WAIT_FOR_EXECUTED_GTID_SET(
'3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5',
5 -- 最多等待 5 秒
);
-- 返回 0 表示从节点已追上,可以安全读取
-- 返回 1 表示超时,从节点尚未追上

5.3 单调读(Monotonic Read)#

单调读保证:如果一个用户先后发出两次读请求,第二次不会看到比第一次更旧的数据。违反单调读的体验是”时间倒退”——先看到新数据,后看到旧数据。

# 单调读违反的场景模拟
# 用户连续两次查询,被路由到不同从节点
def query_with_monotonic_read(user_id, router, last_seen_timestamp):
"""保证单调读:只从时间戳 >= last_seen_timestamp 的从节点读取"""
# 获取所有从节点的复制进度
followers = router.get_follower_status()
# 筛选已追上 last_seen_timestamp 的从节点
eligible = [
f for f in followers
if f.replication_lag_ms <= last_seen_timestamp
]
if not eligible:
# 没有从节点追上,回退到主节点
return router.query_leader(f"SELECT * FROM posts WHERE user_id = {user_id}")
# 从合格的从节点中随机选择(负载均衡)
import random
target = random.choice(eligible)
result = target.query(f"SELECT * FROM posts WHERE user_id = {user_id}")
# 更新已见时间戳
last_seen_timestamp = max(last_seen_timestamp, target.current_timestamp)
return result, last_seen_timestamp

实现单调读的常见方法:

  1. 用户亲和性路由:同一用户的读请求始终路由到同一个从节点
  2. 时间戳比较:记录用户最后一次读取的时间戳,只从复制进度超过该时间戳的从节点读取
  3. 一致性哈希:基于用户 ID 做一致性哈希,将读请求固定到特定节点

5.4 前缀一致读(Consistent Prefix Read)#

前缀一致读保证:如果写入 A 因果先于写入 B,那么任何读取到 B 的用户也必须能读取到 A。这在多主复制中最容易违反——不同主节点的复制延迟不同,导致因果关系的写入被乱序到达。

# 前缀一致读违反的典型场景
# 两个用户在聊天室对话
# 事件序列(因果顺序):
# 1. Alice: "今天天气真好!"
# 2. Bob: "是啊,适合出去散步。"
# 在多主复制下,Bob 的回复可能先到达某个从节点
# 导致读者看到:
# Bob: "是啊,适合出去散步。" ← 先看到回复
# Alice: "今天天气真好!" ← 后看到问题
# 这就是前缀一致读违反
# 解决方案:使用因果依赖追踪
class CausalTracker:
"""追踪写入间的因果关系"""
def __init__(self):
self.version_vector = {} # node_id -> counter
def write(self, node_id, data, dependencies=None):
"""写入时记录因果依赖"""
if dependencies is None:
dependencies = dict(self.version_vector)
self.version_vector[node_id] = self.version_vector.get(node_id, 0) + 1
return {
"data": data,
"version": dict(self.version_vector),
"dependencies": dependencies,
}
def is_readable(self, write, current_version):
"""判断写入是否可读(所有依赖是否已满足)"""
for node, counter in write["dependencies"].items():
if current_version.get(node, 0) < counter:
return False # 依赖未满足,不能读
return True

5.5 一致性级别总览#

不同的系统提供不同的一致性保证。以下表格对比了三种复制架构下的一致性级别:

一致性级别保证单主复制多主复制无主复制
强一致性读总是返回最新写入值同步复制可实现无法实现w+r>n + 同步读修复
读写一致性自己的写入自己能读到关键读走主节点需额外机制w+r>n
单调读不会看到时间倒退用户亲和路由需额外机制r>n/2
前缀一致读因果关系不被乱序单主天然有序需因果追踪需额外机制
最终一致性一段时间后所有副本一致异步复制冲突解决后读修复 + 反熵

六、复制拓扑对比#

三种复制架构各有适用场景,没有银弹。以下从多个维度进行对比:

6.1 全维度对比#

维度单主复制多主复制无主复制
写入扩展受限于单主多点写入任意节点写入
读取扩展从节点分担读更多读节点Quorum 读
写入一致性串行化写入写冲突Quorum 保证
故障转移需要选举其他主节点继续无需转移
跨地域延迟写入跨地域本地写入本地写入
实现复杂度高(冲突解决)中(Quorum)
运维复杂度
典型系统MySQL、PostgreSQLMySQL 双主、CouchDBCassandra、DynamoDB、Riak

6.2 选择决策树#

flowchart TD START["选择复制架构"] --> Q1{"需要跨地域写入?"} Q1 -->|否| Q2{"写入 QPS 是否超过单机上限?"} Q1 -->|是| Q3{"能容忍写冲突吗?"} Q2 -->|否| SINGLE["单主复制<br/>简单可靠,首选"] Q2 -->|是| Q4{"写入是否可分片?"} Q4 -->|是| SINGLE_SHARD["单主 + 分片<br/>参见数据分区"] Q4 -->|否| MULTI[" 多主复制<br/>需要冲突解决"] Q3 -->|能容忍| LEADERLESS["无主复制<br/>Quorum + LWW"] Q3 -->|不能容忍| Q5{"需要强一致性吗?"} Q5 -->|是| MULTI_SYNC[" 多主 + 同步冲突检测<br/>复杂但可行"] Q5 -->|否| LEADERLESS2["无主复制<br/>最终一致性"] style SINGLE fill:#c8e6c9,stroke:#2e7d32 style SINGLE_SHARD fill:#c8e6c9,stroke:#2e7d32 style MULTI fill:#fff9c4,stroke:#f9a825 style LEADERLESS fill:#e3f2fd,stroke:#1565c0 style LEADERLESS2 fill:#e3f2fd,stroke:#1565c0 style MULTI_SYNC fill:#ffcdd2,stroke:#c62828

6.3 复制拓扑形状#

多主复制的拓扑形状决定了变更传播的路径,也影响了系统的可靠性与性能:

拓扑结构优点缺点
环形每个节点将变更传给下一个节点网络开销小单点故障断链
星形一个中心节点转发所有变更管理简单中心节点瓶颈
全连接每个节点直接与其他所有节点通信无单点、延迟低网络开销大、消息冗余
# 模拟不同拓扑下的消息传播延迟
import networkx as nx
def simulate_topology(topology_type, node_count=5):
"""模拟不同复制拓扑的消息传播"""
if topology_type == "circular":
# 环形拓扑:每个节点只向下一个节点传播
G = nx.cycle_graph(node_count)
elif topology_type == "star":
# 星形拓扑:中心节点转发
G = nx.star_graph(node_count - 1)
elif topology_type == "all_to_all":
# 全连接:每个节点直接连接所有其他节点
G = nx.complete_graph(node_count)
# 计算从节点 0 到所有其他节点的最短路径
# 代表变更传播的最少跳数
path_lengths = nx.single_source_shortest_path_length(G, 0)
max_hops = max(path_lengths.values())
avg_hops = sum(path_lengths.values()) / len(path_lengths)
return {
"topology": topology_type,
"max_hops": max_hops,
"avg_hops": round(avg_hops, 1),
"edge_count": G.number_of_edges(),
}
# 对比三种拓扑
for topo in ["circular", "star", "all_to_all"]:
result = simulate_topology(topo, node_count=5)
print(f"{result['topology']:12s} | "
f"最大跳数: {result['max_hops']} | "
f"平均跳数: {result['avg_hops']} | "
f"边数: {result['edge_count']}")

七、总结#

7.1 核心概念回顾#

数据复制的核心矛盾是一致性与可用性的权衡。三种架构从不同角度回应这一矛盾:

  • 单主复制简单性换取一致性——所有写入串行化,但主节点是瓶颈和单点
  • 多主复制复杂性换取可用性——多点写入提升扩展性,但写冲突难以处理
  • 无主复制数学(Quorum) 平衡两者——可调的一致性级别,但语义更难理解

7.2 关键决策矩阵#

你的需求推荐架构关键配置
单地域、写入量适中、强一致性优先单主 + 半同步rpl_semi_sync_master_timeout
多地域、写入量适中、低延迟优先多主 + LWW冲突解决策略
高可用、容忍最终一致、写入量大无主 + Quorumw=2, r=2, n=3
高可用 + 强一致、写入量大无主 + 严格 Quorumw=3, r=3, n=5
协同编辑、离线场景多主 + CRDTCRDT 数据类型选择

7.3 与后续章节的联系#

复制是分布式数据系统的起点,但远不是终点:

  • 复制 + 分区 = 分布式数据库:下一章 数据分区 将讨论如何将数据分散到多个节点
  • 复制 + 事务 = 分布式事务分布式事务 将解决跨节点事务的原子性问题
  • 复制 + 共识 = 强一致性一致性与共识 将讨论 Raft 等共识算法如何实现线性化
graph LR REPLAY["本章:数据复制<br/>单主/多主/无主"] --> PARTITION["Ch13:数据分区<br/>范围/哈希分区"] REPLAY --> DIST_TXN["Ch14:分布式事务<br/>2PC/Saga/TCC"] REPLAY --> CONSENSUS["Ch15:一致性与共识<br/>Raft/Paxos"] PARTITION --> NEWSQL["Ch16:分库分表与 NewSQL"] DIST_TXN --> CONSENSUS style REPLAY fill:#ffe0b2,stroke:#e65100 style PARTITION fill:#e3f2fd,stroke:#1565c0 style DIST_TXN fill:#fff9c4,stroke:#f9a825 style CONSENSUS fill:#e1bee7,stroke:#6a1b9a

理解复制,就是理解分布式数据系统最基础的权衡——没有完美的方案,只有适合场景的选择。选择复制架构时,先明确你的需求(一致性?可用性?延迟?),再根据需求选择架构和配置,最后在运行时持续监控复制延迟,确保系统行为符合预期。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

数据复制:主从、多主与无主复制
https://blog.souloss.com/posts/database/data-replication/
作者
Souloss
发布于
2024-09-17
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

相关文章 智能推荐
1
分库分表与 NewSQL:Sharding 策略与分布式数据库
数据库 从分库分表的工程实践出发,深入解析垂直/水平拆分、分片策略、分布式 ID 生成、跨分片查询与分布式事务,再对比 NewSQL 数据库(TiDB、CockroachDB、OceanBase)如何用分布式架构原生解决这些问题,帮你做出合理的选型决策。
2
数据分区:范围、哈希与再平衡
数据库 深入解析数据分区的核心策略——范围分区与哈希分区的原理与取舍、一致性哈希的演进、二级索引的本地与全局分区、再平衡策略与热点缓解,从概念到工程实践全面掌握数据分片设计。
3
存储引擎:从磁盘到 B 树与 LSM 树
数据库 深入解析数据库存储引擎的核心机制——B 树与 LSM 树的设计取舍、页结构、Buffer Pool、WAL 与检查点,对比 InnoDB 与 RocksDB 的实现差异。
4
数据库可靠性:备份、容灾与混沌工程
数据库 从故障分类到备份恢复策略,从容灾架构到混沌工程实践,系统梳理数据库可靠性的核心方法论——RPO/RTO 权衡、全量/增量/日志备份、同城双活/两地三中心、MHA/Patroni/Sentinel 高可用、数据一致性校验与多地域部署。
5
Redis 深入:数据结构、持久化与事件驱动
数据库 深入解析 Redis 内部实现——SDS/跳表/压缩列表/整数集合等底层数据结构、对象系统、RDB/AOF 持久化、主从复制与 Reactor 事件驱动模型。