DDIA 2-1 复制
在分布式系统里,复制(Replication)和分片(Sharding)几乎是绕不开的两大命题。
单机的 CPU、内存、磁盘、带宽都存在上限,当并发与数据量增长到一定程度,系统就必须扩展到多节点来分担压力。扩展的第一步通常是把读流量拆出去——读写分离。
然而,一旦引入多个副本,就会带来一个非常关键的问题:
写入节点到底是单个?多个?还是没有固定主节点?
不同答案对应三种经典复制架构:
- 单主复制(Single Leader / Primary-Replica)
- 多主复制(Multi-Leader / Multi-Master)
- 无主复制(Leaderless / Dynamo-style)
接下来我们围绕这三种架构,梳理它们的核心思想、实现方式与工程取舍。
1. 单主复制:最常见、最容易理解的复制模型
单主复制的核心思想非常直接:
- 只有一个主节点(Leader)负责写入
- 其他节点都是从节点(Follower / Replica),从主节点同步数据
- 读取可以落到任意副本上(如果允许读到“可能陈旧”的数据)
这种模式广泛用于关系型数据库与多数传统分布式系统中,是最主流也最“符合直觉”的复制方案。
1.1 同步复制 vs 异步复制(以及半同步)
单主复制最重要的配置之一是:主节点写入后,是同步等待副本确认还是直接返回并异步复制?
- 同步复制:主节点必须等待副本确认后才算写成功
✅ 更强的一致性与更高的可靠性
❌ 副本慢或网络抖动会拖垮写入延迟,甚至导致写不可用 - 异步复制:主节点写成功就返回,副本稍后追赶
✅ 写入延迟更低,吞吐更高
❌ 可能出现读到旧数据、甚至主节点故障时数据丢失 - 半同步复制:通常只要求 至少一个 副本同步确认,其余异步
✅ 在一致性、可用性之间做折中
✅ 工程上比较常见
1.2 常见的复制实现方案
复制的本质是:主节点把“发生了什么变化”传给从节点。
因此复制日志的表达方式非常关键,主流有三类:
- 基于语句的复制(Statement-based)
主节点把 SQL 语句发给从节点执行
✅ 日志体积小
❌ 非确定性函数、触发器、自增主键等容易产生副本不一致 - WAL / 物理日志复制(Write-Ahead Log Shipping)
把存储引擎级别的物理变更传给副本
✅ 还原快,复制效率高
❌ 与存储格式强绑定,跨版本升级与兼容性会比较麻烦 - 基于行的逻辑日志复制(Logical / Row-based)
把“插入/更新/删除了哪一行”的逻辑事件传给副本
✅ 与底层存储解耦,更利于 CDC、跨版本、外部消费
❌ 日志体积与实现复杂度通常更高
1.3 节点故障:追赶恢复 & 故障转移
复制系统一定会遇到故障,单主复制主要分两类:
- 从节点故障:恢复后通过“追赶恢复”(catch-up)补齐错过的日志即可
- 主节点故障:需要进行故障转移(failover),提升某个从节点为新主节点,并让客户端改写入它
这里的挑战在于:- 异步复制可能导致“已确认写入”的数据还没来得及复制就丢失
- 脑裂(两个节点都认为自己是主)会造成数据损坏或丢失
1.4 复制延迟:最终一致性下的三类“读异常”
单主复制通过异步读扩展可以非常高效,但代价就是复制延迟带来的“读到陈旧数据”。
典型的三个用户体验问题:
- 读己之写(Read-your-writes / 写后读)
用户刚写入的数据,立刻刷新却看不到 → 以为写丢了 - 单调读(Monotonic reads)
用户先看到新数据,再刷新反而看到旧数据 → “时间倒退” - 一致前缀读(Consistent prefix reads)
用户看到“回复”但没看到“问题” → 因果顺序错乱
这些问题不是理论问题,而是会直接影响产品体验。
解决它们通常需要在应用层增加更强的读策略(例如读主、粘滞会话、读版本门槛等),或者直接选用提供强一致的数据库。
2. 多主复制:跨地域写入与离线场景的自然选择
多主复制的核心思想是:
- 多个主节点都可以接受写入
- 每个主节点把自己的写入异步复制给其他主节点
- 系统不再有唯一写入入口
它最显而易见的价值是:跨地域写入延迟显著降低。
用户可以写入离自己最近的主节点,然后后台同步到其他地区。
2.1 多主复制的常见拓扑
多个主节点之间如何传播写入?这就是复制拓扑(topology):
- 环形拓扑(Ring)
每个节点只转发给下一个节点
✅ 简单、链路少
❌ 任意节点故障可能阻塞复制链路 - 星形拓扑(Star)
一个中心节点负责转发给其他节点
✅ 管理简单
❌ 中心节点成为单点与瓶颈 - 全对全拓扑(Full mesh)
每个主节点都向其他主节点发送写入
✅ 容错最好,传播最快
❌ 链路数量多,工程复杂度更高
2.2 多主复制的最大难题:写入冲突
多主复制“写得快”,但一定要付出的代价就是:
并发写入一定会产生冲突
冲突处理策略通常分三类:
- 避免冲突(Conflict Avoidance)
通过业务路由、写入归属地、分配规则等尽可能把同一数据的写入固定到同一个主节点
✅ 最理想
❌ 不是总能做到,尤其是离线与实时协作 - LWW:最后写入者胜(Last Write Wins)
用时间戳选一个写入为赢家,其他丢掉
✅ 最简单
❌ 本质上会丢数据,并且依赖时钟正确性 - 手动解决冲突
系统保留多个并发版本(siblings),交给应用或用户合并
✅ 不丢信息
❌ 产品体验与工程成本都高 - 自动解决冲突(CRDT / OT)
通过可合并的数据结构或可转换的操作序列,让不同副本最终自动收敛到一致状态
✅ 最适合协作、离线优先、本地优先
❌ 算法与实现复杂,需要正确建模数据类型与语义
3. 无主复制:更高故障容忍度的 Dynamo 风格系统
无主复制常被误解为“随便写一个节点”。
实际上它的核心是:
- 写入会并行发送到多个副本
- 读取也会并行读取多个副本
- 通过“读写仲裁”来判断一次读/写是否成功
这就是 Dynamo 风格系统(Riak / Cassandra / ScyllaDB 等)的思路。
3.1 读写仲裁(Quorum)与仲裁一致性
设:
n:副本总数w:一次写成功所需的确认数r:一次读成功所需的响应数
当满足:
w + r > n
读取与写入的副本集合必然有交集,理论上提高读到最新值的概率。
这就是仲裁一致性。
它带来的好处是:
- 没有主节点,不需要故障转移(failover)
- 任意节点故障对系统影响更小
- 可以用请求对冲(hedging)降低尾延迟
3.2 无主复制更容易出现“并发写入”
无主复制因为不强制全局顺序,很容易出现:
- 不同副本以不同顺序收到写入
- 并发写入互相覆盖导致副本永久不一致
因此无主复制必须回答一个关键问题:
两个写入是“覆盖关系”,还是“并发关系”?
要解决这个问题,需要捕获写入的因果依赖(happens-before),最经典的方案就是:
✅ 版本向量(Version Vector)
版本向量可以理解为“每个副本一个计数器”的因果上下文,用来区分:
- 覆盖写入:后一个写入包含前一个写入的历史 → 可以覆盖
- 并发写入:两个写入互不包含 → 必须保留并合并
版本向量也是实现“从一个副本读,再写回另一个副本仍然安全”的关键。
总结:三种复制架构的核心取舍
| 架构 | 写入入口 | 一致性 | 可用性/容错 | 工程复杂度 | 典型场景 |
|---|---|---|---|---|---|
| 单主复制 | 单点主节点 | 较强(可线性化) | 依赖故障转移 | 中等 | 传统 OLTP、读扩展 |
| 多主复制 | 多个主节点 | 较弱(冲突不可避免) | 跨地域更强 | 高 | 跨地域、离线、协作 |
| 无主复制 | 没有主节点 | 最终一致为主 | 很强(无 failover) | 高 | 高可用 KV、抗抖动 |
复制与分片是分布式系统设计的基本功。
一旦你理解了三种复制架构的本质差异,就能更清晰地做出“架构选型”和“工程取舍”:
- 你究竟愿意把复杂度放在数据库层,还是应用层?
- 你到底需要强一致,还是更高可用与更低延迟?
- 你能不能接受冲突?能不能自动合并?
这些答案将决定你最终选择哪一种复制策略,并影响系统的长期演化路线。