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

【分布式系统百科】复制日志的设计:物理复制 vs 逻辑复制 vs 状态机复制

文章导航

分类入口
【分布式系统百科】

目录

分布式系统中的复制机制离不开日志。无论是数据库的主从复制、消息队列的分区副本,还是共识算法中的状态同步,背后都依赖某种形式的复制日志(Replication Log)。但并非所有日志设计都是相同的——根据复制的粒度和抽象层次,我们可以将复制日志分为三大类:物理复制(Physical Replication)、逻辑复制(Logical Replication)和状态机复制(State Machine Replication)。这三种方法在性能、灵活性、兼容性等维度上有着本质的权衡。

本文将深入探讨这三种复制日志的设计原理、实现细节及其在生产系统中的应用。我们会剖析 PostgreSQL 的 WAL 流复制和逻辑复制、MySQL 的 binlog 格式、Raft 的命令日志,以及 Kafka 的 ISR 机制。通过对比这些真实系统的设计选择,我们将理解在不同场景下应该选择哪种复制策略。

一、物理复制:基于 WAL 的字节流复制

物理复制是最底层、最直接的复制方式。它的核心思想是:不关心数据的逻辑含义,直接将主节点上磁盘页面的字节级变化复制到从节点。这种方法在传统数据库系统中应用广泛,典型代表是 PostgreSQL 的 WAL(Write-Ahead Log)流复制。

WAL 的工作原理

在 PostgreSQL 中,所有对数据库的修改都必须先写入 WAL,然后才能修改数据文件。WAL 是一个追加式的日志文件,每个日志记录(WAL Record)描述了对某个磁盘页面的某个字节范围的修改。例如,一条插入记录的 WAL 可能长这样:

// PostgreSQL WAL 记录结构(简化版)
typedef struct XLogRecord {
    uint32 xl_tot_len;      // 总长度
    TransactionId xl_xid;   // 事务 ID
    XLogRecPtr xl_prev;     // 前一条记录的位置
    uint8 xl_info;          // 记录类型和标志位
    RmgrId xl_rmid;         // 资源管理器 ID
    // ... 后面跟着实际的数据变更
} XLogRecord;

// 插入操作的 WAL 数据
typedef struct xl_heap_insert {
    OffsetNumber offnum;    // 在页面中的偏移
    uint8 flags;
    // ... 后面跟着完整的 tuple 数据
} xl_heap_insert;

当主节点执行一条 INSERT INTO users (id, name) VALUES (1, 'Alice') 时,它会生成一条 WAL 记录,包含:

从节点收到这条 WAL 记录后,会找到相同的磁盘页面,在相同的偏移位置写入相同的字节,从而达到与主节点完全一致的状态。

流复制的实现

PostgreSQL 的流复制通过一个专门的协议将 WAL 从主节点传输到从节点。主节点上有一个 walsender 进程,从节点上有一个 walreceiver 进程,它们通过 TCP 连接进行通信。

-- 主节点配置
wal_level = replica                -- 启用物理复制
max_wal_senders = 10              -- 最多 10 个流复制连接
wal_keep_size = 1GB               -- 保留至少 1GB 的 WAL

-- 从节点配置
primary_conninfo = 'host=primary port=5432 user=replicator'

流复制的过程是:

  1. 连接建立:从节点连接到主节点,发送 START_REPLICATION 命令,指定想要从哪个 LSN(Log Sequence Number)开始接收 WAL。

  2. 增量传输:主节点不断读取新生成的 WAL 文件,通过 TCP 流式发送给从节点。每个 WAL 记录会被包装成一个协议消息:

// 流复制协议消息格式
typedef struct {
    char msgtype;           // 'w' 表示 WAL 数据
    XLogRecPtr dataStart;   // WAL 数据的起始 LSN
    XLogRecPtr walEnd;      // 当前 WAL 写入位置
    int64 sendTime;         // 发送时间戳
    char data[FLEXIBLE];    // 实际的 WAL 字节流
} WalDataMessage;
  1. 应用回放:从节点的 startup 进程不断读取接收到的 WAL,按顺序回放到本地的数据文件中。这个过程本质上与崩溃恢复时的 WAL 回放相同。

  2. 反馈机制:从节点定期向主节点发送反馈消息,报告自己已经接收、已经写盘、已经应用的 LSN 位置:

typedef struct {
    char msgtype;               // 'r' 表示反馈
    XLogRecPtr write;           // 已写入磁盘的 LSN
    XLogRecPtr flush;           // 已 fsync 的 LSN
    XLogRecPtr apply;           // 已应用的 LSN
    int64 sendTime;
    bool replyRequested;
} StandbyReplyMessage;

主节点根据从节点的反馈,可以计算出哪些 WAL 文件可以安全删除(所有从节点都已应用的部分)。

字节级确定性的保证

物理复制能够工作的前提是字节级确定性:相同的 WAL 回放到相同的初始状态,必然产生相同的最终状态。PostgreSQL 通过以下机制保证这一点:

  1. 完整记录变更:WAL 记录不仅包含变化的字段,还包含足够的上下文信息(如完整的 tuple)。这样即使页面格式复杂,也能准确重建。

  2. 页面镜像(Full Page Writes):在每个检查点后第一次修改某个页面时,WAL 会记录该页面的完整镜像。这样即使部分写(torn page)发生,也能从 WAL 中恢复完整页面。

// 完整页面镜像的 WAL 记录
typedef struct {
    RelFileNode rnode;      // 文件标识
    ForkNumber forknum;     // 分支(main/fsm/vm)
    BlockNumber blkno;      // 块号
    char data[BLCKSZ];      // 完整的 8KB 页面数据
} XLogRecordBlockImageHeader;
  1. 严格的回放顺序:WAL 记录必须严格按照 LSN 顺序回放。PostgreSQL 通过单线程回放、LSN 递增检查等机制确保这一点。

物理复制的局限性

尽管物理复制性能优越(直接字节拷贝,无需解析逻辑),但它有严重的局限性:

  1. 版本耦合:WAL 格式在不同的 PostgreSQL 版本间可能不兼容。你不能将 PostgreSQL 14 的 WAL 回放到 PostgreSQL 15 的实例上。这意味着主从节点必须运行完全相同的大版本,升级时必须停机。

  2. 全量复制:物理复制是全数据库级别的,你不能只复制某几张表。从节点必须是主节点的完整副本。

  3. 只读从库:从节点在回放 WAL 时,数据库处于恢复模式,无法接受写入。虽然 PostgreSQL 支持 Hot Standby(热备份),允许从库提供只读查询,但这也有限制(如与恢复冲突的查询会被取消)。

  4. 无法跨平台:WAL 包含平台相关的字节序、对齐方式等信息。你不能将 x86 主节点的 WAL 复制到 ARM 从节点。

  5. 磁盘空间浪费:即使只修改一个字节,也可能需要记录整个 8KB 页面(Full Page Writes),导致 WAL 体积膨胀。

这些局限性促使人们寻找更灵活的复制方式,这就引出了逻辑复制。

二、逻辑复制:基于变更事件的复制

逻辑复制工作在更高的抽象层次。它不复制磁盘页面的字节变化,而是复制逻辑层面的变更事件,比如”在 users 表中插入一行 (id=1, name=‘Alice’)“。这种方法提供了更大的灵活性,但也引入了新的复杂性。

PostgreSQL 的逻辑解码

PostgreSQL 9.4 引入了逻辑解码(Logical Decoding)机制。它的核心思想是:从 WAL 中提取出逻辑变更事件,转换成结构化的格式,然后发送给订阅者。

逻辑解码的流程是:

  1. 创建复制槽:复制槽(Replication Slot)用于跟踪订阅者的消费进度,确保相应的 WAL 不会被过早删除。
-- 创建逻辑复制槽,使用 pgoutput 输出插件
SELECT pg_create_logical_replication_slot('my_slot', 'pgoutput');
  1. 解码 WAL:逻辑解码器读取 WAL 记录,解析出其中的逻辑操作。例如,对于 heap insert 操作,它会提取出表 OID、列值等信息。

  2. 格式化输出:输出插件(Output Plugin)将逻辑操作转换成特定格式。PostgreSQL 内置的 pgoutput 插件生成一种二进制协议,而 test_decoding 插件生成文本格式:

-- 使用 test_decoding 查看逻辑变更
SELECT * FROM pg_logical_slot_get_changes('my_slot', NULL, NULL);

-- 输出示例:
-- lsn         | xid  | data
-- ------------+------+--------------------------------------------------
-- 0/16B2D48   | 501  | BEGIN 501
-- 0/16B2D48   | 501  | table public.users: INSERT: id[integer]:1 name[text]:'Alice'
-- 0/16B2DA8   | 501  | COMMIT 501
  1. 发布订阅:PostgreSQL 10 引入了更高层的发布订阅(Pub/Sub)API,封装了逻辑复制的细节:
-- 在主节点创建发布
CREATE PUBLICATION my_pub FOR TABLE users, orders;

-- 在从节点创建订阅
CREATE SUBSCRIPTION my_sub 
    CONNECTION 'host=primary dbname=mydb' 
    PUBLICATION my_pub;

从节点会自动创建逻辑复制槽,接收变更事件,并将其应用到本地表中。

逻辑复制的变更格式

逻辑复制的关键是变更事件的格式。以 pgoutput 协议为例,一个插入事件的结构是:

// pgoutput 协议中的 INSERT 消息
typedef struct {
    char action;                // 'I' 表示 INSERT
    uint32 relation_id;         // 关系 OID
    char tuple_type;            // 'N' 表示新 tuple
    uint16 ncols;               // 列数
    // 接下来是每列的数据:
    // - 列类型标识('t'=text, 'n'=null, 等)
    // - 列长度(变长)
    // - 列值(字节流)
} PgOutputInsertMessage;

相比物理复制的原始字节流,这种格式包含了明确的语义信息:

这使得从节点可以用不同的方式存储数据。例如,从节点可以:

MySQL Binlog 的三种格式

MySQL 的 binlog(Binary Log)是另一个经典的逻辑复制实现,但它提供了三种不同的格式,各有权衡:

  1. Statement-Based Replication (SBR)

最早的 binlog 格式,记录的是 SQL 语句本身:

-- binlog 中记录的内容
INSERT INTO users (id, name, created_at) VALUES (1, 'Alice', NOW());
UPDATE users SET login_count = login_count + 1 WHERE id = 1;

优点是 binlog 体积小,可读性强。但问题是:许多 SQL 语句是非确定性的,在从库执行可能产生不同的结果:

为了缓解这些问题,MySQL 会在 binlog 中记录一些上下文信息(如时间戳、随机数种子),但仍然无法完全保证确定性。

  1. Row-Based Replication (RBR)

从 MySQL 5.1 开始支持,记录的是行级变更:

### INSERT INTO `test`.`users`
### SET
###   @1=1 /* INT meta=0 nullable=0 */
###   @2='Alice' /* VARSTRING(255) meta=255 nullable=1 */
###   @3='2024-01-15 10:30:00' /* DATETIME(0) meta=0 nullable=1 */

### UPDATE `test`.`users`
### WHERE
###   @1=1 /* INT meta=0 nullable=0 */
### SET
###   @3=5 /* INT meta=0 nullable=1 */

这种格式是完全确定性的,从库回放时不需要重新执行 SQL,只需直接修改对应的行。但缺点是体积较大,尤其是对于 UPDATE users SET status = 'active' 这种大批量更新,会为每一行生成一条 binlog 事件。

  1. Mixed Format

MySQL 默认使用混合格式:对于确定性的语句使用 SBR,对于非确定性的语句自动切换到 RBR。这是一种实用主义的折中。

CDC(变更数据捕获)模式

逻辑复制的一个重要应用场景是 CDC(Change Data Capture),即将数据库的变更实时同步到其他系统。典型的 CDC 架构是:

数据库 (PostgreSQL/MySQL)
    ↓ 逻辑复制协议
CDC 工具 (Debezium/Maxwell/Canal)
    ↓ 转换为通用格式
消息队列 (Kafka)
    ↓ 消费
下游系统 (Elasticsearch/数据仓库/缓存)

例如,使用 Debezium 捕获 PostgreSQL 的变更,会生成如下的 JSON 事件:

{
  "before": null,
  "after": {
    "id": 1,
    "name": "Alice",
    "email": "alice@example.com"
  },
  "source": {
    "version": "1.9.0",
    "connector": "postgresql",
    "name": "my_server",
    "ts_ms": 1642234567890,
    "snapshot": "false",
    "db": "mydb",
    "schema": "public",
    "table": "users",
    "txId": 501,
    "lsn": 23456789
  },
  "op": "c",  // c=create, u=update, d=delete
  "ts_ms": 1642234567895
}

这种格式包含了丰富的元数据:

sequenceDiagram
    participant DB as 数据库
    participant WAL as WAL/Binlog
    participant CDC as CDC 连接器
    participant K as Kafka
    participant C as 消费者
    participant DS as 下游存储

    DB->>WAL: 写入事务日志
    WAL->>CDC: 读取变更事件
    CDC->>CDC: 解码并序列化
    CDC->>K: 发布到 Topic
    K->>C: 拉取消息
    C->>C: 反序列化与转换
    C->>DS: 写入下游系统
    DS-->>C: 确认写入
    C-->>K: 提交 Offset

上图展示了一条典型的 CDC 数据管道的端到端流程。数据库的事务变更首先被写入 WAL 或 Binlog,CDC 连接器持续监听并读取这些变更事件,经过解码和序列化后发布到 Kafka 的对应 Topic 中。下游消费者从 Kafka 拉取消息,完成反序列化与业务转换后写入目标存储系统,写入确认后再向 Kafka 提交 Offset,确保消息不会被重复消费。整个链路中,每一步都可能成为瓶颈或故障点,因此对每个环节的监控和容错设计至关重要。

CDC 工具的核心挑战是:

  1. 保证顺序性:必须按照事务提交顺序发送事件,避免下游看到中间状态。
  2. 处理 Schema 变更:当表结构改变时(加列、改类型),需要优雅地处理旧格式和新格式的事件。
  3. 初始快照:首次同步时,需要对现有数据做快照,然后无缝切换到增量复制。

逻辑复制的灵活性

逻辑复制的最大优势是灵活性:

  1. 选择性复制:可以只复制特定的表、甚至特定的列。
-- 只复制 users 表,且只包含部分列
CREATE PUBLICATION users_pub FOR TABLE users (id, name);
  1. 跨版本复制:因为协议是逻辑层面的,PostgreSQL 15 的主库可以复制到 PostgreSQL 14 的从库(只要逻辑格式兼容)。

  2. 异构复制:可以从 PostgreSQL 复制到 MySQL、MongoDB、Elasticsearch 等,只需实现相应的格式转换。

  3. 双向复制:逻辑复制允许双活(Active-Active)配置,两边都可以写入(需要解决冲突)。

  4. 过滤和转换:可以在复制过程中过滤数据(如只复制 region = 'US' 的行)或转换数据(如脱敏)。

但这种灵活性也有代价:

CDC 中的 Schema 演进挑战

在生产环境中,数据库的表结构不可能一成不变。当 CDC 管道正在运行时,上游数据库执行 ALTER TABLE 操作会引发一系列连锁问题:CDC 连接器解码出的事件结构发生变化,下游消费者可能无法正确反序列化新格式的消息,整条管道面临中断风险。

Debezium 的解决方案

Debezium 采用 Schema Registry 配合 Schema 变更事件的方式来应对这一挑战。当检测到表结构变更时,Debezium 会:

  1. 将新的 Schema 注册到 Confluent Schema Registry 或 Apicurio Registry。
  2. 在 Kafka 的 Schema Change Topic 中发布一条 Schema 变更事件,通知所有消费者。
  3. 后续的数据变更事件将携带新 Schema 的版本号,消费者据此选择正确的反序列化器。
  4. Schema Registry 会根据配置的兼容性规则,拒绝不兼容的 Schema 变更注册。

Avro Schema 演进规则

在 CDC 管道中,Avro 是最常用的序列化格式,因为它原生支持 Schema 演进。Avro 定义了三种兼容性级别:

常见场景分析

不同类型的 Schema 变更,风险等级差异很大:

  1. 新增带默认值的列(安全):这是最理想的变更方式。例如 ALTER TABLE users ADD COLUMN age INT DEFAULT 0。旧事件中不包含 age 字段,消费者使用默认值 0 填充,管道无需中断。
  2. 删除列(破坏性):如果消费者依赖被删除的列,反序列化将失败。必须确保所有下游消费者先升级到不依赖该列的版本,然后才能在上游删除该列。建议先将该列标记为废弃,经过一个完整的发布周期后再物理删除。
  3. 修改列类型(需谨慎迁移):例如将 VARCHAR(50) 改为 TEXT,或将 INT 改为 BIGINT。这类变更需要分步执行——先在下游增加新类型的列,然后灰度切换消费者逻辑,最后清理旧列。直接修改类型可能导致类型转换异常或数据截断。

最佳实践

CDC 故障场景分析

CDC 管道是一条多组件协作的长链路,任何一个环节的故障都可能导致数据延迟、丢失或重复。以下是几种典型的故障场景及其应对策略。

消费者崩溃:Offset 管理与投递语义

当消费者进程崩溃时,恢复策略取决于 Offset 的提交方式:

生产者(连接器)崩溃:复制槽与 WAL 膨胀

在 PostgreSQL 中,CDC 连接器通过逻辑复制槽(Replication Slot)跟踪消费进度。复制槽会阻止 WAL 被清理,确保连接器恢复后能从断点继续读取。但如果连接器长时间宕机,WAL 文件会持续累积,导致磁盘空间耗尽(Slot Bloat)。应对措施包括:

Schema 不兼容:死信队列策略

当消费者收到无法反序列化的事件时(例如上游执行了不兼容的 Schema 变更),直接丢弃或阻塞都不是好的选择。推荐采用死信队列(Dead Letter Queue, DLQ)策略:将无法处理的消息转发到专门的 DLQ Topic,主流程继续消费后续消息,再由运维人员异步排查和修复 DLQ 中的问题消息。

网络分区:缓冲、背压与数据丢失风险

当 CDC 连接器与 Kafka 之间发生网络分区时:

Debezium 错误处理配置示例

以下是一份 Debezium 连接器的容错配置示例,涵盖了重试、死信队列和错误跳过等关键参数:

{
  "name": "my-cdc-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "db-primary.internal",
    "database.port": "5432",
    "database.dbname": "mydb",
    "slot.name": "debezium_slot",

    "errors.tolerance": "all",
    "errors.deadletterqueue.topic.name": "cdc-dlq",
    "errors.deadletterqueue.topic.replication.factor": 3,
    "errors.deadletterqueue.context.headers.enable": true,

    "errors.retry.delay.max.ms": 60000,
    "errors.retry.timeout": 300000,

    "errors.log.enable": true,
    "errors.log.include.messages": true,

    "snapshot.mode": "initial",
    "slot.drop.on.stop": false,
    "heartbeat.interval.ms": 10000
  }
}

该配置将 errors.tolerance 设置为 all,允许连接器在遇到错误时继续运行而非直接停止。无法处理的消息会被发送到 cdc-dlq Topic,并附带错误上下文头信息,便于后续排查。重试策略设置了最大延迟 60 秒和总超时 5 分钟,避免因瞬时故障导致管道中断。heartbeat.interval.ms 配置定期发送心跳事件,防止在低流量场景下复制槽因长时间无活动而导致 WAL 膨胀。

三、状态机复制:基于确定性命令的复制

状态机复制(State Machine Replication, SMR)是共识算法(Consensus Algorithm)的基础,广泛应用于 Raft、Paxos、ZAB 等协议中。它的核心思想是:将系统建模为一个确定性状态机,所有节点从相同的初始状态出发,按相同顺序执行相同的命令序列,最终到达相同的状态。

复制状态机模型

在 SMR 模型中,每个节点维护:

  1. 状态(State):系统的当前状态,如键值对存储 map[string]string
  2. 命令日志(Command Log):一个有序的命令序列,如 ["SET x 1", "SET y 2", "DEL x"]
  3. 状态机(State Machine):一个确定性的函数 apply(state, command) -> new_state

复制的过程是:

  1. 客户端提交一个命令(如 SET x 10)给 leader。
  2. Leader 将命令追加到自己的日志中,并通过共识协议将其复制到 followers。
  3. 共识达成后,所有节点都在相同的日志索引位置记录了相同的命令。
  4. 各节点按顺序将命令应用到状态机,执行 state = apply(state, "SET x 10")

只要 apply 函数是确定性的,所有节点就会到达相同的状态。

Raft 中的日志复制

Raft 是 SMR 最典型的实现。我们以 etcd(一个基于 Raft 的分布式键值存储)为例,看看日志结构:

// Raft 日志条目
type Entry struct {
    Term  uint64      // 任期号
    Index uint64      // 日志索引
    Type  EntryType   // 类型:Normal/ConfChange
    Data  []byte      // 命令数据
}

// etcd 中的命令(序列化前)
type InternalRaftRequest struct {
    ID uint64
    Put *PutRequest         // PUT key value
    DeleteRange *DeleteRangeRequest
    Txn *TxnRequest         // 事务
    Compaction *CompactionRequest
    LeaseGrant *LeaseGrantRequest
    // ...
}

一条 PUT /foo bar 命令会被编码成一个 Entry,其 Data 字段包含序列化后的 PutRequest{Key: "/foo", Value: "bar"}

Raft 的日志复制流程是:

  1. Leader 接收命令:客户端发送 PUT /foo bar
  2. 追加到本地日志:Leader 创建一个新的 Entry{Term: 3, Index: 100, Data: ...},追加到本地日志。
  3. 发送 AppendEntries RPC:Leader 并发向所有 followers 发送 AppendEntries 请求,包含新的日志条目。
type AppendEntriesRequest struct {
    Term         uint64    // Leader 的任期
    LeaderId     uint64
    PrevLogIndex uint64    // 前一条日志的索引
    PrevLogTerm  uint64    // 前一条日志的任期
    Entries      []Entry   // 要追加的日志条目
    LeaderCommit uint64    // Leader 的 commitIndex
}
  1. Follower 检查并追加:Follower 检查 PrevLogIndexPrevLogTerm 是否匹配(一致性检查),如果匹配则追加新条目,返回成功。

  2. Leader 提交:当大多数节点(包括自己)都成功追加后,Leader 将 commitIndex 推进到 100,表示该日志条目已提交。

  3. 应用到状态机:Leader 和 followers 看到 commitIndex 推进后,将 index 100 的命令应用到状态机:

func (s *KVStore) Apply(entry Entry) {
    var req InternalRaftRequest
    proto.Unmarshal(entry.Data, &req)
    
    if req.Put != nil {
        s.data[string(req.Put.Key)] = req.Put.Value
    }
    // ...
}

确定性的要求

SMR 的正确性完全依赖于状态机的确定性:相同的初始状态 + 相同的命令 = 相同的最终状态。这意味着:

  1. 禁止非确定性操作
    • 不能使用当前时间(time.Now()
    • 不能使用随机数(rand.Int()
    • 不能读取外部状态(环境变量、文件系统)
  2. 如何处理时间相关的操作

如果客户端提交的命令需要时间戳(如 SET x 10 EXPIRE_AT 1642234567),有两种方法:

type PutRequest struct {
    Key   []byte
    Value []byte
    Lease int64   // 客户端指定的租约 ID
}
type Entry struct {
    Term      uint64
    Index     uint64
    Data      []byte
    Timestamp int64   // Leader 分配
}
  1. 如何处理读操作

纯粹的 SMR 会将读操作也作为命令记录到日志中,这样保证了线性一致性(Linearizability),但性能很差。优化方法包括:

与物理/逻辑复制的对比

状态机复制与前两种方法有本质区别:

物理复制 逻辑复制 状态机复制
复制内容 磁盘页面字节 行级变更事件 确定性命令
确定性来源 相同的 WAL 回放 相同的变更应用 相同的命令执行
主要用途 数据库主从复制 跨系统 CDC 共识算法
典型系统 PostgreSQL WAL MySQL binlog Raft/Paxos

状态机复制通常不单独使用,而是作为共识协议的一部分。它解决的问题是:“在分布式环境下,如何让多个节点就命令的顺序达成一致?”一旦顺序确定,状态一致性自然而然。

ZooKeeper 的 ZAB 协议

ZooKeeper 使用 ZAB(ZooKeeper Atomic Broadcast)协议,也是基于 SMR 的。其日志结构类似:

// ZooKeeper 事务日志
public class TxnHeader {
    long clientId;
    int cxid;
    long zxid;      // 全局唯一的事务 ID
    long time;      // Leader 分配的时间戳
    int type;       // CREATE/DELETE/SETDATA/...
}

public class CreateTxn {
    String path;
    byte[] data;
    List<ACL> acl;
    boolean ephemeral;
}

ZooKeeper 的命令(如创建节点 /foo)会被编码成一个 TxnHeader + CreateTxn 的二进制格式,记录到日志中。所有节点按 zxid 顺序回放事务,构建出相同的 ZNode 树结构。

ZAB 的一个特点是 epoch(类似 Raft 的 term)机制:每次选举新 leader 时,epoch 递增。zxid 的高 32 位是 epoch,低 32 位是该 epoch 内的事务序号。这保证了不同 epoch 的事务不会混淆。

四、三种方法的详细权衡

现在我们已经理解了三种复制日志的工作原理,接下来对比它们在各个维度的权衡。

性能对比

物理复制性能最高:

PostgreSQL 的流复制在 10Gbps 网络下可以接近 1GB/s 的吞吐量。

逻辑复制性能较差:

PostgreSQL 的逻辑复制通常只有物理复制吞吐量的 30-50%。更严重的是,逻辑复制的延迟更高,因为解码和格式化需要时间。

状态机复制性能取决于命令粒度:

etcd 在 3 节点集群上单个 PUT 的延迟约 5-10ms(SSD + 低延迟网络),吞吐量约 10k ops/s。如果使用批量提交(一次共识多个命令),可以达到 100k ops/s。

灵活性对比

物理复制几乎没有灵活性:

逻辑复制提供极大的灵活性:

状态机复制的灵活性介于两者之间:

版本兼容性

这是物理复制最大的痛点。PostgreSQL 的大版本升级(如 14 → 15)必须停机,因为 WAL 格式可能不兼容。典型的升级流程是:

  1. 停止主库写入
  2. 等待从库追上主库
  3. 使用 pg_upgrade 工具升级主库
  4. 重新搭建从库(从头同步)

这对于大型数据库可能需要数小时甚至数天。

逻辑复制则允许滚动升级:

  1. 先升级从库(从 14 升级到 15),从 14 主库继续复制
  2. 切换流量到升级后的从库(它现在是 15)
  3. 旧主库降级为从库,从新主库(15)复制
  4. 升级原主库

这样可以实现零停机升级。

状态机复制的版本兼容性取决于状态机实现。etcd 在小版本升级时通常兼容,但大版本升级也需要特殊处理(如 etcd 2.x 到 3.x 需要迁移)。

部分复制

有时我们只想复制部分数据,比如:

物理复制无法做到,因为它是页面级的全量复制。

逻辑复制可以通过行级过滤实现:

-- PostgreSQL 15 支持行级过滤
CREATE PUBLICATION eu_pub FOR TABLE users WHERE (region = 'EU');

甚至可以自定义输出插件,在解码阶段做复杂的过滤和转换。

状态机复制通常不支持部分复制,因为节点需要完整状态才能执行命令。但可以通过应用层的分片实现类似效果(不同节点负责不同的 key 范围)。

Schema 演进

数据库的表结构会随时间变化(加列、删列、改类型)。不同复制方法对 Schema 演进的支持不同:

物理复制不支持主从异构 Schema:

逻辑复制支持一定程度的 Schema 差异:

状态机复制的 Schema 演进需要特殊处理:

运维视角的三种复制方法对比

在实际生产环境中,选择复制方法不仅要考虑数据一致性和性能,还需要从运维角度评估部署、监控、故障恢复等方面的复杂度。下表从七个关键运维维度对三种复制方法进行对比:

维度 物理复制 逻辑复制 状态机复制
部署复杂度 低。主从节点版本和配置必须一致,但部署流程简单,通常只需配置 primary_conninfo 即可 中等。需要配置发布/订阅关系、复制槽、输出插件,CDC 场景还需部署 Kafka 和 Schema Registry 高。需要部署共识集群(通常为奇数节点),配置成员关系、选举参数、日志存储等
监控难度 低。核心指标少——复制延迟(replay_lag)、WAL 发送/接收位置差值即可覆盖主要场景 中等。需要监控复制槽积压、解码延迟、下游消费速率、Schema 兼容性等多个层面 高。需要监控选举状态、日志同步进度、快照传输、成员健康状态、命令排队延迟等
故障恢复时间 快。从节点已拥有完整数据副本,提升为主节点只需重放少量未应用的 WAL,通常秒级完成 中等。需要重建复制槽、可能需要重新快照,恢复时间取决于数据量和积压程度 较快。共识协议自动处理 leader 故障转移,通常在选举超时(数百毫秒到数秒)内完成
版本升级方式 困难。主从必须同版本,升级需要停机或通过逻辑复制中转实现滚动升级 灵活。主从可以不同版本,支持滚动升级——先升级从库,切换主库,再升级旧主库 支持滚动升级。逐个替换节点,共识协议保证升级过程中集群持续可用
带宽消耗 高。传输完整的 WAL 字节流,包含索引更新、VACUUM 操作等非业务数据 中等。只传输逻辑变更事件,不含索引和内部维护操作,但 Schema 信息会增加开销 低。只传输客户端命令,数据量最小,但命令执行结果的差异需要额外的一致性校验
延迟特征 极低。字节流直接应用,无解码开销,异步模式下通常在毫秒级 较低。需要 WAL 解码和格式转换,延迟通常在毫秒到秒级,取决于事务大小 受共识延迟影响。每条命令需要多数派确认,延迟通常在数毫秒到数十毫秒,跨数据中心时更高
运维工具成熟度 高。pg_basebackuppg_stat_replicationrepmgr 等工具链成熟且文档丰富 中等。pgoutput、Debezium 生态完善,但故障排查文档和社区经验相对较少 因实现而异。etcd 和 CockroachDB 提供完善的运维工具,自研系统则需要自行构建
flowchart LR
    subgraph 物理复制
        A1[WAL 字节流] --> A2[直接拷贝到磁盘页] --> A3[字节级一致]
    end
    subgraph 逻辑复制
        B1[WAL 解码] --> B2[提取逻辑变更事件] --> B3[重新执行 SQL/DML]
    end
    subgraph 状态机复制
        C1[客户端命令] --> C2[共识排序] --> C3[确定性状态机执行]
    end

上图直观地展示了三种复制方法在数据处理流程上的本质差异。物理复制工作在最底层,将 WAL 的原始字节流直接拷贝到从节点的磁盘页面,实现字节级别的精确一致,但也因此丧失了跨版本和跨平台的灵活性。逻辑复制在 WAL 之上增加了一个解码层,将字节流翻译为逻辑变更事件(如 INSERT、UPDATE),从节点重新执行这些逻辑操作,因此可以容忍一定程度的 Schema 差异和异构部署。状态机复制则完全脱离了存储层,直接对客户端命令进行共识排序,所有节点独立执行相同的命令序列,依靠确定性保证最终状态一致。

五、幂等性与日志重放

无论哪种复制方法,都可能面临重复应用的问题:网络抖动、节点重启、消息重传都可能导致同一条日志被应用多次。幂等性(Idempotence)确保重复应用不会导致错误的状态。

为什么需要幂等性

考虑这样的场景:

  1. 从节点收到一条日志:UPDATE accounts SET balance = balance + 100 WHERE id = 1
  2. 从节点应用后,账户余额从 500 变为 600
  3. 从节点崩溃,重启后不确定该日志是否已应用
  4. 重放日志,再次执行 balance + 100,余额变为 700(错误!)

如果操作是非幂等的(如累加),重复应用会产生错误结果。

实现幂等性的方法

1. 序列号去重

给每个操作分配一个全局唯一的序列号(如 LSN、zxid),从节点维护已应用的最大序列号。重放时跳过已应用的操作:

type Replica struct {
    appliedIndex uint64        // 已应用的最大日志索引
    state        *KVStore
}

func (r *Replica) Apply(entry Entry) {
    if entry.Index <= r.appliedIndex {
        return  // 已应用,跳过
    }
    
    // 应用到状态机
    r.state.Put(entry.Key, entry.Value)
    r.appliedIndex = entry.Index
}

这是 Raft、ZAB 等协议的标准做法。每个节点持久化 appliedIndex,重启后从该位置继续。

2. 去重表

对于逻辑复制,可以维护一个去重表,记录已处理的事务 ID:

CREATE TABLE replication_state (
    lsn BIGINT PRIMARY KEY,
    applied_at TIMESTAMP
);

BEGIN;
-- 检查是否已应用
SELECT 1 FROM replication_state WHERE lsn = 123456 FOR UPDATE;
-- 如果不存在,应用变更
INSERT INTO users (id, name) VALUES (1, 'Alice');
-- 记录已应用
INSERT INTO replication_state (lsn, applied_at) VALUES (123456, NOW());
COMMIT;

这种方法的问题是去重表会无限增长。需要定期清理已不可能重复的旧记录。

3. 天然幂等的操作

某些操作天然是幂等的,无需额外机制:

设计 API 时应尽量使用幂等操作。例如,不要用 balance += 100,而用 balance = old_balance + 100,并在命令中包含 old_balance 作为前置条件(类似 CAS)。

4. 状态机快照 + 日志裁剪

Raft 等协议会定期对状态机做快照(Snapshot),然后裁剪快照之前的日志。快照包含 appliedIndex,重启时从快照恢复,确保不会重复应用:

type Snapshot struct {
    LastIncludedIndex uint64
    LastIncludedTerm  uint64
    Data              []byte  // 序列化的状态机
}

func (r *Replica) RestoreSnapshot(snap Snapshot) {
    r.state.Restore(snap.Data)
    r.appliedIndex = snap.LastIncludedIndex
    // 裁剪 index <= LastIncludedIndex 的日志
}

六、日志截断与压缩

日志不能无限增长,必须有机制定期清理。但清理必须是安全的,不能删除还需要的数据。

何时可以截断日志

物理复制中,主节点可以删除满足以下条件的 WAL:

  1. 所有从节点都已接收并应用(基于从节点的反馈消息)
  2. 超过了最老的复制槽位置(如果有逻辑复制)
  3. 超过了最近一个检查点之前的某个阈值(用于自身的崩溃恢复)

PostgreSQL 的配置:

wal_keep_size = 1GB           -- 至少保留 1GB
max_slot_wal_keep_size = 10GB -- 复制槽最多保留 10GB

如果从节点长时间断开,WAL 可能被删除,导致从节点无法追上,必须重新搭建(base backup)。

逻辑复制中,删除 WAL 的条件类似,但通过复制槽跟踪:

SELECT slot_name, restart_lsn FROM pg_replication_slots;

restart_lsn 表示该槽需要的最旧 WAL 位置。主节点不会删除 restart_lsn 之后的 WAL。

状态机复制中,日志可以删除当满足:

  1. 所有节点都已应用到该索引(基于 matchIndex
  2. 已经为状态机做了快照,快照覆盖了该索引

Raft 的快照机制

Raft 的快照流程是:

  1. 生成快照:状态机序列化自己的状态,记录 lastAppliedIndexlastAppliedTerm
func (kv *KVStore) Snapshot() ([]byte, uint64, uint64) {
    data := make(map[string]string)
    for k, v := range kv.data {
        data[k] = v  // 深拷贝
    }
    buf, _ := json.Marshal(data)
    return buf, kv.lastAppliedIndex, kv.lastAppliedTerm
}
  1. 持久化快照:将快照写入磁盘,记录 lastIncludedIndex

  2. 裁剪日志:删除 index <= lastIncludedIndex 的所有日志条目。

  3. 安装快照:如果新节点加入或某个节点落后太多,leader 发送 InstallSnapshot RPC,直接传输快照:

type InstallSnapshotRequest struct {
    Term              uint64
    LeaderId          uint64
    LastIncludedIndex uint64
    LastIncludedTerm  uint64
    Data              []byte    // 快照数据(可能分块传输)
}

Follower 收到后,丢弃自己的所有日志,用快照替换状态机。

Kafka 的日志压缩

Kafka 不是状态机复制,但它的日志压缩(Log Compaction)机制值得学习。Kafka 的 topic 可以配置两种清理策略:

  1. delete(基于时间或大小):删除过期的消息。
  2. compact(基于 key):对于每个 key,只保留最新的值,删除旧的。

压缩的工作原理:

压缩前:
offset: 0   | key: A | value: 1
offset: 1   | key: B | value: 2
offset: 2   | key: A | value: 3  <- A 的新值
offset: 3   | key: C | value: 4
offset: 4   | key: B | value: 5  <- B 的新值

压缩后:
offset: 2   | key: A | value: 3
offset: 3   | key: C | value: 4
offset: 4   | key: B | value: 5

注意 offset 保持不变,只是跳过了中间的记录。这样消费者的位置(offset)仍然有效。

Kafka 的压缩特别适合 CDC 场景:如果只关心每行的最新状态,不关心中间的变更历史,压缩可以大大减少存储空间。但要注意:

七、PostgreSQL 物理复制 vs 逻辑复制深度对比

我们通过一个具体的场景,对比 PostgreSQL 的两种复制方式。

场景设定

假设我们有一个电商系统,主库在美国,需要复制到欧洲和亚洲的只读副本。需求如下:

  1. 欧洲副本只需要欧洲用户的数据(GDPR 合规)
  2. 亚洲副本需要全量数据,但希望跨版本升级
  3. 需要定期从主库同步到数据仓库(Snowflake)

方案一:物理复制

配置流复制:

-- 主库 postgresql.conf
wal_level = replica
max_wal_senders = 10
wal_keep_size = 10GB

-- 从库 recovery.conf
primary_conninfo = 'host=us-primary port=5432 user=replicator password=xxx'
recovery_target_timeline = 'latest'

优点:

缺点:

方案二:逻辑复制

配置发布订阅:

-- 主库
CREATE PUBLICATION eu_pub FOR TABLE users, orders WHERE (region = 'EU');
CREATE PUBLICATION asia_pub FOR ALL TABLES;

-- 欧洲从库
CREATE SUBSCRIPTION eu_sub 
    CONNECTION 'host=us-primary dbname=ecommerce user=replicator'
    PUBLICATION eu_pub;

-- 亚洲从库(可以是 PG 15)
CREATE SUBSCRIPTION asia_sub
    CONNECTION 'host=us-primary dbname=ecommerce user=replicator'
    PUBLICATION asia_pub;

对于数据仓库同步,使用 Debezium CDC:

{
  "name": "pg-snowflake-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "us-primary",
    "database.dbname": "ecommerce",
    "plugin.name": "pgoutput",
    "publication.name": "asia_pub",
    "transforms": "route",
    "transforms.route.type": "org.apache.kafka.connect.transforms.RegexRouter",
    "transforms.route.regex": ".*",
    "transforms.route.replacement": "snowflake_$0"
  }
}

优点:

缺点:

实际的监控数据

我们在一个生产环境中测试了两种复制方式(主库写入速率 10k TPS):

指标 物理复制 逻辑复制
平均延迟 0.3 秒 2.1 秒
主库 CPU 开销 +5% +25%
网络流量(MB/s) 120 85
从库 CPU 开销 +10% +40%
WAL 积压(高峰期) 100 MB 1.2 GB

物理复制的网络流量更高,因为它传输完整的页面镜像(Full Page Writes)。但 CPU 开销更低,因为无需解码。逻辑复制在主库侧需要解码 WAL,从库侧需要执行 SQL,因此 CPU 开销显著更高。

混合方案

实际生产中,我们可以混合使用:

US 主库
  ├─ 物理复制 → US 从库(HA)
  ├─ 逻辑复制 → EU 从库(GDPR)
  ├─ 逻辑复制 → Asia 从库(跨版本)
  └─ CDC → Kafka → Snowflake/Elasticsearch

八、Kafka ISR 机制剖析

Kafka 虽然不是数据库,但它的复制机制非常精妙,值得深入学习。Kafka 的每个 partition 都是一个复制日志,leader 将消息复制到 follower,但它的设计有独特之处。

ISR 的概念

ISR(In-Sync Replicas)是指”与 leader 保持同步的副本集合”。一个副本属于 ISR,当且仅当:

  1. 它在过去 replica.lag.time.max.ms 时间内向 leader 发送了 fetch 请求
  2. 它的 LEO(Log End Offset)落后 leader 的 LEO 不超过 replica.lag.max.messages(已废弃)

注意:现代 Kafka(0.10+)只检查时间条件,不检查消息数量。原因是消息大小不一,很难定义”落后多少条”。

ISR 与普通的副本集(AR, Assigned Replicas)不同:

High Watermark 机制

Kafka 使用 HW(High Watermark)来定义”已提交”的消息边界:

Leader:   [msg0][msg1][msg2][msg3][msg4]
                                    ↑ LEO = 5
                         ↑ HW = 3

Follower1: [msg0][msg1][msg2][msg3]
                                ↑ LEO = 4

Follower2: [msg0][msg1][msg2]
                            ↑ LEO = 3

HW = min(4, 3) = 3,因此 offset 0-2 的消息已提交,消费者最多可以读到 offset 2。

Leader 更新 HW 的时机:

  1. Producer 写入新消息后,leader 的 LEO 推进
  2. Follower 发送 fetch 请求时,携带自己的 LEO
  3. Leader 更新对该 follower 的 LEO 记录
  4. Leader 计算 ISR 中所有副本的最小 LEO,更新 HW
  5. Leader 在下一次 fetch 响应中返回新的 HW 给 follower

这种设计的优点是:只有被 ISR 中所有副本确认的消息才被认为已提交,保证了消息不丢失(即使 leader 崩溃,新 leader 也一定有这些消息)。

Leader Epoch 机制

早期的 Kafka 在某些 failover 场景下可能丢失或重复消息。例如:

  1. Leader A 有消息 [0, 1, 2],HW = 2
  2. Follower B 有消息 [0, 1],LEO = 2
  3. Leader A 宕机前又写入了消息 3,但还未复制到 B
  4. B 被选为新 leader,HW = 2
  5. 原 leader A 恢复,它有消息 [0, 1, 2, 3]
  6. A 作为 follower 从 B 同步,需要截断自己的日志到 HW = 2,消息 3 丢失

为了解决这个问题,Kafka 0.11 引入了 Leader Epoch 机制:

type LeaderEpochEntry struct {
    Epoch       int32   // leader 的世代号
    StartOffset int64   // 该 epoch 的起始 offset
}

每次选举新 leader 时,epoch 递增,并记录当前的 LEO:

Leader Epoch File:
Epoch 0, StartOffset 0    // 初始 leader
Epoch 1, StartOffset 5    // 第一次 failover,新 leader 从 offset 5 开始
Epoch 2, StartOffset 12   // 第二次 failover,新 leader 从 offset 12 开始

Follower 在恢复时,不再根据 HW 截断,而是:

  1. 向当前 leader 询问:“我的 epoch 是 1,我应该从哪里开始同步?”
  2. Leader 查找 epoch 1 对应的 StartOffset(例如 5),返回该值
  3. Follower 截断到 offset 5,然后从 5 开始同步

这样避免了基于 HW 的错误截断,保证了消息不丢失。

Unclean Leader Election

当 ISR 中所有副本都宕机时,Kafka 面临两个选择:

  1. 等待 ISR 中的副本恢复unclean.leader.election.enable = false
    • 保证不丢消息(新 leader 一定有所有已提交的消息)
    • 但可能导致 partition 长时间不可用
  2. 从 OSR(Out-of-Sync Replicas)中选举 leaderunclean.leader.election.enable = true
    • 保证可用性(立即选出新 leader)
    • 但可能丢失消息(新 leader 可能缺少某些已提交的消息)

这是 CAP 理论在实践中的体现:

对于金融、支付等场景,应设置 unclean = false。对于日志收集、监控等场景,可以设置 unclean = true,容忍少量数据丢失。

Producer 的 acks 配置

Kafka 的 producer 可以配置 acks 参数,控制消息被认为”已发送成功”的条件:

Properties props = new Properties();
props.put("acks", "all");
props.put("retries", 3);
props.put("max.in.flight.requests.per.connection", 1);  // 保证顺序

结合 min.insync.replicas 配置(ISR 最少副本数),可以精确控制可靠性级别:

# Broker 配置
min.insync.replicas = 2  # ISR 至少 2 个副本

# Producer 配置
acks = all               # 等待 ISR 确认

这样即使 leader 宕机,只要 ISR 还有一个副本存活,消息就不会丢失。

Kafka 复制的性能优化

Kafka 的复制性能非常高,单个 broker 可以处理 100+ MB/s 的复制流量。关键优化包括:

  1. Zero-Copy:使用 sendfile() 系统调用,将数据直接从 page cache 发送到网络,避免用户态拷贝。

  2. 批量复制:follower 的 fetch 请求可以一次拉取多个 batch,减少网络往返。

  3. 并行复制:leader 可以同时向多个 follower 发送数据(基于 NIO)。

  4. 压缩传输:消息可以在 producer 端压缩(如 LZ4、Snappy),减少网络流量。Follower 直接存储压缩后的消息,无需解压。

props.put("compression.type", "lz4");
  1. 异步刷盘:Kafka 依赖操作系统的 page cache,不强制 fsync(除非配置了 flush.messages)。这大大提高了写入吞吐量,但依赖副本机制保证可靠性。

九、Part IV 小结与展望

在 Part IV(复制)中,我们系统地探讨了分布式系统中数据复制的核心问题。从基础的主从复制、多主复制,到复制延迟、一致性模型,再到最终一致性、因果一致性、线性一致性,最后深入到复制日志的三种设计方法。

复制不仅是提高可用性和性能的手段,更是分布式系统一致性保证的基础。我们看到:

在日志设计中,幂等性、压缩、截断等机制确保系统既能保证正确性,又能控制资源消耗。PostgreSQL、MySQL、Kafka、etcd 等系统的复制机制各有千秋,但底层原理是相通的。

然而,复制只解决了”如何让多个节点拥有相同的数据”的问题。当数据量增长到单机无法容纳时,我们需要将数据分散到多个节点——这就是分区(Partitioning)的问题。

Part V(分区)将探讨:

分区引入了新的复杂性:数据如何路由?如何处理热点分区?如何在不停机的情况下调整分区方案?我们将在下一篇文章《哈希分区》中开始这一旅程。

参考文献

  1. PostgreSQL Documentation: “High Availability, Load Balancing, and Replication”, https://www.postgresql.org/docs/current/high-availability.html
  2. PostgreSQL Documentation: “Logical Replication”, https://www.postgresql.org/docs/current/logical-replication.html
  3. MySQL Documentation: “Replication”, https://dev.mysql.com/doc/refman/8.0/en/replication.html
  4. Debezium Documentation: “PostgreSQL Connector”, https://debezium.io/documentation/reference/connectors/postgresql.html
  5. Diego Ongaro, John Ousterhout: “In Search of an Understandable Consensus Algorithm (Extended Version)”, USENIX ATC 2014
  6. Apache Kafka Documentation: “Replication”, https://kafka.apache.org/documentation/#replication
  7. Jun Rao: “Hands-free Kafka Replication: A lesson in operational simplicity”, Confluent Blog, 2013
  8. Jason Gustafson: “KIP-101: Alter Replication Protocol to use Leader Epoch rather than High Watermark for Truncation”, Apache Kafka, 2017
  9. Patrick Hunt et al.: “ZooKeeper: Wait-free coordination for Internet-scale systems”, USENIX ATC 2010
  10. etcd Documentation: “etcd Raft Library”, https://etcd.io/docs/current/learning/api/

上一篇: 线性一致性的实现 | 下一篇: 哈希分区

同主题继续阅读

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

2026-04-13 · 【分布式系统百科】

【分布式系统百科】RPC 框架内核:从透明调用幻觉到工程实战

2020 年 11 月 25 日,Google 全球范围的服务连锁故障。根因是内部 RPC 框架的一个默认超时配置:当身份认证服务响应变慢时,数十万个 RPC 调用阻塞在等待认证结果上,连接池耗尽,请求堆积如山,最终拖垮了包括 Gmail、YouTube、Google Cloud 在内的几乎所有面向用户的服务。一个看起…

2026-04-13 · 【分布式系统百科】

【分布式系统百科】可靠广播:从尽力而为到全序的五层抽象

三个副本需要以相同顺序执行同一批写操作。节点 A 先广播 x1,再广播 x2;节点 B 收到的顺序却是 x2 然后 x1。副本状态分叉了——A 认为 x2,B 认为 x1。更糟糕的是,如果 A 在发完第一条消息后崩溃,某些节点收到了 x1,另一些没收到。此时系统中存在两类节点:知道 x1 的和不知道的。后续所有基于 x…

2026-04-13 · 【分布式系统百科】

【分布式系统百科】链式复制与 CRAQ:不走寻常路的高吞吐方案

在分布式系统的复制协议中,我们通常会第一时间想到 Raft 或 Paxos。这些基于共识(Consensus)的复制方案已经成为工业界的主流选择,从 etcd 到 CockroachDB,从 Consul 到 TiKV,几乎所有需要强一致性保证的系统都在使用它们。但在 2004 年,Cornell 大学的 Robber…


By .