第六章 复制(Replication)
“一个可能出错的事物与一个不可能出错的事物之间的主要区别在于:当那个不可能出错的事物出错时,通常会发现它无法触及或无法修复。”
—— 道格拉斯·亚当斯,《基本无害》(1992)
本章说明(早期版本)
早期版本电子书让您能够在作者写作过程中获取原始未编辑的内容,从而在这些技术正式发布之前就能加以利用。
本章将是最终书籍的第六章。本书的 GitHub 仓库地址为:https://github.com/ept/ddia2-feedback
如果您希望积极参与本草案的审阅和评论,请在 GitHub 上联系我们。
什么是复制?
复制是指通过网络在多台机器上保存相同数据的副本。如”分布式系统与单节点系统”一节所述,复制数据有以下几个原因:
| 目的 | 说明 |
|---|---|
| 降低访问延迟 | 将数据保存在地理位置靠近用户的地方 |
| 提高可用性 | 即使部分组件故障,系统仍能继续运行 |
| 扩展读吞吐量 | 增加可处理读查询的机器数量 |
本章假设:您的数据集足够小,每台机器都能保存完整的数据集副本。第七章将讨论分片(sharding)——处理单台机器无法容纳的大型数据集。后续章节将讨论复制数据系统中可能出现的各种故障及处理方法。
如果复制的数据不随时间变化,复制就很容易:只需将数据复制到每个节点一次即可。复制的所有难点都在于处理复制数据的变化,这正是本章的主题。
我们将讨论三类在节点间复制变更的算法:
- 单主复制(Single-leader)
- 多主复制(Multi-leader)
- 无主复制(Leaderless)
几乎所有分布式数据库都使用这三种方法之一。它们各有优缺点,我们将在下文详细讨论。
备份与复制的区别
您可能会想:如果已经有了复制,是否还需要备份?答案是肯定的,因为它们的目的不同:
- 副本(Replicas):快速将一个节点的写入反映到其他节点
- 备份(Backups):存储数据的旧快照,以便回溯时间
⚠️ 如果您意外删除了某些数据,复制无法帮助您,因为删除操作也会传播到所有副本。因此,如果您想恢复已删除的数据,需要备份。
实际上,复制和备份通常是互补的:
- 备份有时是设置复制过程的一部分(见”设置新从节点”)
- 归档复制日志可以是备份过程的一部分
一些数据库在内部维护过去状态的不可变快照,作为某种内部备份。然而,这意味着将旧版本数据与当前状态保存在同一存储介质上。如果数据量很大,将旧数据备份保存在专为不常访问数据优化的对象存储中可能更经济,而只在主存储中保存数据库的当前状态。
单主复制(Single-Leader Replication)
存储数据库副本的每个节点称为副本(replica)。当有多个副本时,一个问题不可避免地出现:如何确保所有数据都到达所有副本?
数据库的每次写入都需要被每个副本处理;否则,副本将不再包含相同的数据。最常见的解决方案称为基于领导的复制、主备复制或主动/被动复制。
工作原理
┌─────────────┐ ┌─────────────┐
│ 客户端 │────────▶│ 主节点 │
│ (写入请求) │ │ (Leader) │
└─────────────┘ └──────┬──────┘
│
▼
┌─────────────────────┐
│ 复制日志/变更流 │
│ (Replication Log) │
└──────────┬──────────┘
│
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 从节点1 │ │ 从节点2 │ │ 从节点3 │
│(Follower)│ │(Follower)│ │(Follower)│
└──────────┘ └──────────┘ └──────────┘
-
主节点(Leader):也称为主(primary)或源(source)。客户端想要写入数据库时,必须将请求发送给主节点,主节点首先将新数据写入其本地存储。
-
从节点(Followers):也称为读副本(read replicas)、次级节点(secondaries)或热备(hot standbys)。每当主节点将新数据写入本地存储时,它还会将数据变更作为复制日志或变更流的一部分发送给所有从节点。每个从节点从主节点获取日志,并按与主节点处理相同的顺序应用所有写入,从而更新其本地数据库副本。
-
读取:客户端可以从主节点或任何从节点读取数据。但是,写入只在主节点上接受(从客户端角度看,从节点是只读的)。
注意:如果数据库是分片的(见第七章),每个分片有一个主节点。不同分片的主节点可能在不同节点上,但每个分片必须有一个主节点。
应用场景
单主复制应用非常广泛:
- 关系型数据库:PostgreSQL、MySQL、Oracle Data Guard、SQL Server Always On
- 文档数据库:MongoDB、DynamoDB
- 消息代理:Kafka
- 复制块设备:DRBD
- 网络文件系统
- 共识算法:Raft(用于 CockroachDB、TiDB、etcd、RabbitMQ 仲裁队列等)
⚠️ 术语更新:在旧文档中您可能看到**主从复制(master-slave replication)**这一术语。它与基于领导的复制含义相同,但该术语应避免使用,因为它被广泛认为具有冒犯性。
同步复制与异步复制
复制系统的一个重要细节是复制是同步还是异步进行的。(在关系型数据库中,这通常是一个可配置选项;其他系统通常硬编码为其中一种。)
同步复制
- 优点:保证从节点拥有与主节点一致的最新数据副本。如果主节点突然故障,可以确保数据在从节点上仍然可用。
- 缺点:如果同步从节点无响应(崩溃、网络故障等),写入就无法处理。主节点必须阻塞所有写入,直到同步副本再次可用。
异步复制
- 优点:即使所有从节点都落后,主节点也能继续处理写入。
- 缺点:如果主节点故障且无法恢复,尚未复制到从节点的任何写入都会丢失。这意味着即使已向客户端确认,写入也不能保证持久。
半同步复制
实践中,如果数据库提供同步复制,通常意味着一个从节点是同步的,其他是异步的。如果同步从节点不可用或变慢,其中一个异步从节点会变为同步。这保证至少在两个节点上有最新数据副本:主节点和一个同步从节点。
完全异步配置
有时,基于领导的复制被配置为完全异步。这种情况下,如果主节点故障且无法恢复,尚未复制到从节点的写入会丢失。然而,完全异步配置的优势在于主节点可以继续处理写入,即使所有从节点都已落后。
权衡:削弱持久性听起来像是一个糟糕的权衡,但异步复制仍然被广泛使用,特别是当有很多从节点或它们地理分布时。
设置新从节点
有时需要设置新从节点——可能是为了增加副本数量,或替换故障节点。如何确保新从节点拥有主节点数据的准确副本?
简单地将数据文件从一个节点复制到另一个节点通常不够:客户端不断在写入数据库,数据始终在变化,因此标准文件复制会看到数据库在不同时间点的不同部分,结果可能毫无意义。
概念上,该过程如下:
步骤1: 获取一致性快照
↓
步骤2: 将快照复制到新从节点
↓
步骤3: 从节点连接主节点,请求快照后的所有变更
↓
步骤4: 从节点处理积压变更后"追上"主节点
- 获取一致性快照:在某个时间点获取主节点数据库的一致性快照——如果可能,不锁定整个数据库。
- 复制快照:将快照复制到新从节点。
- 请求变更:从节点连接主节点,请求自快照以来发生的所有数据变更。这要求快照与主节点复制日志中的确切位置相关联。
- 追上主节点:当从节点处理了自快照以来的积压变更后,我们说它已追上。现在它可以继续像之前一样处理来自主节点的数据变更。
实用工具:WAL-G(用于 PostgreSQL、MySQL、SQL Server)、Litestream(用于 SQLite)
对象存储支持的数据库
对象存储可用于比归档数据更多的用途。许多数据库开始使用对象存储(如 AWS S3、Google Cloud Storage、Azure Blob Storage)为实时查询提供数据。
优势
- 成本低:对象存储比其他云存储选项便宜
- 高持久性:提供多区域复制
- 条件写入:可实现事务和领导选举
- 简化数据集成:使用开放格式(如 Apache Parquet、Apache Iceberg)
权衡
- 延迟高:读写延迟远高于本地磁盘
- API 调用费用:需要批量读写以降低成本
- 缺乏标准文件系统接口
零磁盘架构(ZDA)
最近,一些系统采用了零磁盘架构:
- 所有数据持久化到对象存储
- 磁盘和内存仅用于缓存
- 节点无持久状态,极大简化运维
采用 ZDA 的系统:WarpStream、Confluent Freight、Buf’s Bufstream、Redpanda Serverless、现代云数据仓库、Turbopuffer、SlateDB
处理节点故障
任何节点都可能宕机——可能是由于故障意外发生,也可能是计划维护(例如重启机器安装内核安全补丁)。能够在不停机的情况下重启单个节点是运维的一大优势。
从节点故障:追赶恢复
每个从节点在本地磁盘上保存从主节点接收的数据变更日志。如果从节点崩溃后重启,或主从节点间网络暂时中断,从节点可以轻松恢复:
从节点日志: [已处理事务: 1, 2, 3, ..., N]
│
▼
连接主节点,请求事务 N+1 及之后的变更
│
▼
应用变更,追上主节点,继续接收变更流
性能挑战:如果数据库写入吞吐量高或从节点离线时间长,可能需要追赶大量写入。追赶过程中,恢复中的从节点和主节点(需要发送积压写入)都会有高负载。
主节点故障:故障转移
处理主节点故障更棘手:
- 确定主节点故障:通常使用超时机制(如 30 秒无响应)
- 选择新主节点:通过选举过程或预先建立的控制器节点指定
- 重新配置系统:客户端需要向新主节点发送写入请求
故障转移可能出现的问题:
| 问题 | 说明 |
|---|---|
| 数据丢失 | 如果使用异步复制,新主节点可能未收到旧主节点的所有写入 |
| 主键冲突 | 如 GitHub 事件:自增计数器落后导致重用主键 |
| 脑裂(Split Brain) | 两个节点都认为自己是主节点 |
| 超时设置 | 太长则恢复时间长,太短则可能不必要的故障转移 |
防护机制(Fencing/STONITH):通过限制或关闭旧主节点来防止脑裂。
复制日志的实现
1. 基于语句的复制
最简单的方案:主节点记录执行的每个写入请求(语句),将语句日志发送给从节点。
问题:
- 非确定性函数(如
NOW()、RAND())可能在每个副本上生成不同值 - 自动递增列或依赖现有数据的语句必须按完全相同顺序执行
- 有副作用的语句(触发器、存储过程)可能导致不同副作用
解决方案:主节点在记录语句时将非确定性函数调用替换为固定返回值。
2. 预写日志(WAL)传输
存储引擎使用的预写日志也可用于构建副本:主节点除将日志写入磁盘外,还通过网络发送给从节点。
缺点:日志描述数据级别很低(哪个磁盘块的哪些字节变更),使复制与存储引擎紧密耦合。数据库版本升级时,通常无法在主从节点上运行不同版本。
3. 逻辑(基于行)日志复制
使用不同日志格式进行复制和存储引擎内部,使复制日志与存储引擎内部解耦。
关系型数据库的逻辑日志通常是描述表行级写入的记录序列:
- 插入行:日志包含所有列的新值
- 删除行:日志包含足够信息唯一标识被删除的行(通常是主键)
- 更新行:日志包含足够信息唯一标识更新的行,以及所有列的新值(或至少变更列的新值)
优势:
- 更容易保持向后兼容,允许主从节点运行不同版本
- 更容易被外部应用程序解析(变更数据捕获)
复制延迟问题
能够容忍节点故障只是需要复制的原因之一。其他原因包括可扩展性(处理比单台机器更多的请求)和延迟(将副本放置在地理上靠近用户的地方)。
基于领导的复制要求所有写入都经过单个节点,但只读查询可以发送到任何副本。对于读多写少的工作负载(在线服务通常如此),有一个有吸引力的选择:创建许多从节点,并在这些从节点间分配读请求。这减轻了主节点的负载,并允许读请求由附近的副本处理。
最终一致性
在这种读扩展架构中,如果应用从异步从节点读取,如果从节点落后,可能会看到过时的信息。这导致数据库中出现明显的不一致:如果在主节点和从节点上同时运行相同查询,可能会得到不同结果,因为并非所有写入都已反映到从节点。
这种不一致只是暂时状态——如果停止写入数据库并等待一段时间,从节点最终会追上并与主节点一致。因此,这种效应被称为最终一致性(eventual consistency)。
“最终”一词故意模糊:通常没有限制副本可以落后多远。正常操作中,主节点上发生写入到反映在从节点上的延迟——复制延迟——可能只有几分之一秒,实践中不易察觉。然而,如果系统接近容量运行或网络有问题,延迟很容易增加到几秒甚至几分钟。
1. 读己之写(Read-Your-Writes)
问题场景:用户提交数据后立即查看,新数据可能尚未到达副本,看起来数据丢失了。
解决方案:读己之写一致性(read-after-write consistency),保证如果用户刷新页面,他们总能看到自己提交的任何更新。
实现方法:
- 读取用户可能修改的内容时,从主节点或同步更新的从节点读取
- 跟踪最后更新时间,在最后一次更新后的一分钟内所有读取都从主节点进行
- 客户端记住最近写入的时间戳,系统确保为该用户服务的任何读取副本至少反映到该时间戳的更新
2. 单调读(Monotonic Reads)
问题场景:用户从不同副本进行多次读取,可能看到时间倒退。
示例:用户 2345 两次查询,第一次从延迟小的从节点读取看到新评论,第二次从延迟大的从节点读取看不到该评论。
解决方案:单调读保证如果一个用户顺序进行多次读取,他们不会看到时间倒退——即在已读取较新数据后,不会再读取到更旧的数据。
实现:确保每个用户总是从同一副本读取(不同用户可从不同副本读取),例如基于用户 ID 的哈希选择副本。
3. 一致前缀读(Consistent Prefix Reads)
问题场景:违反因果关系。观察者先看到答案,后看到问题。
解决方案:一致前缀读保证如果一系列写入按特定顺序发生,那么任何人读取这些写入时都会看到它们以相同顺序出现。
多主复制(Multi-Leader Replication)
单主复制的主要缺点:所有写入必须经过唯一主节点。如果由于任何原因无法连接到主节点(例如您与主节点之间的网络中断),就无法写入数据库。
多主复制模型的自然扩展:允许多个节点接受写入。
架构
区域 A 区域 B 区域 C
┌──────┐ ┌──────┐ ┌──────┐
│主节点A│◄─────────►│主节点B│◄─────────►│主节点C│
└──┬───┘ └──┬───┘ └──┬───┘
│ │ │
┌──┴───┐ ┌──┴───┐ ┌──┴───┐
│从节点 │ │从节点 │ │从节点 │
└──────┘ └──────┘ └──────┘
每个区域内:常规主从复制
区域之间:各区域主节点相互复制变更
适用场景
| 场景 | 说明 |
|---|---|
| 地理分布式部署 | 每个区域一个主节点,避免跨互联网写入 |
| 容忍区域故障 | 每个区域可独立于其他区域运行 |
| 容忍网络问题 | 异步复制可更好容忍网络中断 |
同步引擎与本地优先软件
多主复制适用的另一种情况:应用需要在断开互联网连接时继续工作。
示例:手机、笔记本电脑等设备上的日历应用。需要随时查看会议(读请求)和输入新会议(写请求),无论设备当前是否有互联网连接。
架构:每个设备都有一个本地数据库副本作为主节点(接受写请求),所有设备上的日历副本之间有异步多主复制过程(同步)。
相关概念:
- 同步引擎(Sync Engine):支持此过程的软件库
- 离线优先(Offline-first):允许用户在离线时继续编辑文件的应用
- 本地优先软件(Local-first software):不仅离线优先,而且即使软件开发商关闭所有在线服务也能继续工作的协作应用
复制拓扑
| 拓扑 | 描述 | 特点 |
|---|---|---|
| 全连接(All-to-all) | 每个主节点将写入发送给其他每个主节点 | 容错性好,无单点故障 |
| 环形(Circular) | 每个节点从一个节点接收写入,转发给另一个节点 | 一个节点故障会中断复制流 |
| 星形(Star) | 指定根节点转发写入给所有其他节点 | 根节点是单点故障 |
处理冲突写入
多主复制的最大问题:不同主节点上的并发写入可能导致需要解决的冲突。
冲突避免策略:
- 确保特定记录的所有写入都经过同一主节点
- 为不同主节点分配不同的 ID 生成策略(如一个生成奇数,一个生成偶数)
冲突解决策略:
| 策略 | 说明 | 缺点 |
|---|---|---|
| 最后写入获胜(LWW) | 使用时间戳,保留最大时间戳的值 | 随机丢弃数据,可能丢失更新 |
| 手动解决 | 数据库返回所有并发值,由应用或用户解决 | API 变化,用户体验差 |
| 自动解决 | 使用算法自动合并并发写入 | 需要特定数据类型支持 |
CRDTs 与操作转换(OT):
两种常用的自动冲突解决算法家族:
- CRDT(无冲突复制数据类型):为每个字符分配唯一不可变 ID,使用这些 ID 确定插入/删除位置
- 操作转换(OT):记录字符插入/删除的索引,交换操作时转换索引以考虑已应用的并发操作
无主复制(Leaderless Replication)
之前讨论的复制方法——单主和多主复制——基于客户端将写请求发送给一个节点(主节点),数据库系统负责将该写入复制到其他副本。主节点确定写入的处理顺序,从节点按相同顺序应用主节点的写入。
一些数据存储系统采用不同方法,放弃主节点概念,允许任何副本直接接受客户端的写入。
工作原理
客户端写入:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 副本 1 │ │ 副本 2 │ │ 副本 3 │
│ (可用) │◄────┤ (可用) │◄────┤ (不可用) │
│ ✓确认 │ │ ✓确认 │ │ ✗无响应 │
└─────────┘ └─────────┘ └─────────┘
▲ ▲
└──────────────┘
│
收到 2 个确认
写入成功(忽略副本 3)
节点故障时的写入:客户端并行发送写入到所有副本,可用副本接受写入,不可用副本错过写入。如果收到足够数量的确认(如 3 个中的 2 个),就认为写入成功。
读取修复:客户端从多个节点并行读取时,可以检测到过时的响应,并将较新的值写回持有旧值的副本。
法定人数(Quorum)
如果有 n 个副本,每次写入必须由 w 个节点确认才视为成功,每次读取必须查询至少 r 个节点。只要 w + r > n,我们期望读取时返回最新写入的值。
常见配置:
- n = 3, w = 2, r = 2:可容忍 1 个不可用节点
- n = 5, w = 3, r = 3:可容忍 2 个不可用节点
松弛法定人数(Sloppy Quorum):如果客户端与大量副本断开连接,无法形成法定人数,某些无主数据库允许任何可达副本接受写入,即使它不是该键的通常副本。
检测并发写入
与多主复制一样,无主数据库允许对同一键的并发写入,导致需要解决的冲突。
版本向量(Version Vectors):每个副本维护每个键的版本号,处理写入时递增自己的版本号,并跟踪从其他副本看到的版本号。这指示要覆盖哪些值以及保留哪些值作为兄弟(siblings)。
总结
本章讨论了复制问题。复制可以服务于多个目的:
| 目的 | 说明 |
|---|---|
| 高可用性 | 即使一台机器(或多台机器、一个可用区、甚至整个区域)宕机,系统仍保持运行 |
| 离线操作 | 网络中断时应用仍能继续工作 |
| 低延迟 | 将数据放置在地理上靠近用户的地方 |
| 可扩展性 | 通过在副本上执行读取,处理比单台机器更高的读取量 |
尽管目标简单——在多台机器上保存相同数据的副本——复制却是一个相当棘手的问题。它需要仔细考虑并发性以及所有可能出错的事情,并处理这些故障的后果。
三种主要复制方法
| 方法 | 描述 | 特点 |
|---|---|---|
| 单主复制 | 客户端将所有写入发送到单个节点(主节点),主节点将数据变更事件流发送给其他副本(从节点) | 易于理解,提供强一致性 |
| 多主复制 | 客户端将每次写入发送到多个主节点之一,任何主节点都可接受写入 | 对故障节点、网络中断和延迟峰值更健壮,需要冲突解决 |
| 无主复制 | 客户端将每次写入发送到多个节点,并行从多个节点读取以检测和纠正过时数据 | 高可用性,最终一致性 |
一致性模型
- 读己之写一致性:用户应始终看到自己提交的数据
- 单调读:用户看到某时间点的数据后,不应稍后看到更早时间点的数据
- 一致前缀读:用户应看到因果合理的数据状态
冲突解决
多主和无主复制通过以下方式确保所有副本最终收敛到一致状态:
- 使用版本向量或类似算法检测哪些写入是并发的
- 使用 CRDT 等冲突解决算法合并并发写入的值
- 最后写入获胜和手动冲突解决也是可能的
下一章:本章假设每个副本存储整个数据库的完整副本,这对大型数据集不现实。下一章将讨论分片(Sharding),允许每台机器只存储数据子集。