分布式系统中的复制机制离不开日志。无论是数据库的主从复制、消息队列的分区副本,还是共识算法中的状态同步,背后都依赖某种形式的复制日志(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 记录,包含:
- 要修改的页面 ID(比如表空间 1, 数据库 16384, 关系 16385, 块号 0)
- 在该页面的偏移位置
- 完整的 tuple 字节流(包括行头、NULL bitmap、实际数据等)
从节点收到这条 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'流复制的过程是:
连接建立:从节点连接到主节点,发送
START_REPLICATION命令,指定想要从哪个 LSN(Log Sequence Number)开始接收 WAL。增量传输:主节点不断读取新生成的 WAL 文件,通过 TCP 流式发送给从节点。每个 WAL 记录会被包装成一个协议消息:
// 流复制协议消息格式
typedef struct {
char msgtype; // 'w' 表示 WAL 数据
XLogRecPtr dataStart; // WAL 数据的起始 LSN
XLogRecPtr walEnd; // 当前 WAL 写入位置
int64 sendTime; // 发送时间戳
char data[FLEXIBLE]; // 实际的 WAL 字节流
} WalDataMessage;应用回放:从节点的
startup进程不断读取接收到的 WAL,按顺序回放到本地的数据文件中。这个过程本质上与崩溃恢复时的 WAL 回放相同。反馈机制:从节点定期向主节点发送反馈消息,报告自己已经接收、已经写盘、已经应用的 LSN 位置:
typedef struct {
char msgtype; // 'r' 表示反馈
XLogRecPtr write; // 已写入磁盘的 LSN
XLogRecPtr flush; // 已 fsync 的 LSN
XLogRecPtr apply; // 已应用的 LSN
int64 sendTime;
bool replyRequested;
} StandbyReplyMessage;主节点根据从节点的反馈,可以计算出哪些 WAL 文件可以安全删除(所有从节点都已应用的部分)。
字节级确定性的保证
物理复制能够工作的前提是字节级确定性:相同的 WAL 回放到相同的初始状态,必然产生相同的最终状态。PostgreSQL 通过以下机制保证这一点:
完整记录变更:WAL 记录不仅包含变化的字段,还包含足够的上下文信息(如完整的 tuple)。这样即使页面格式复杂,也能准确重建。
页面镜像(Full Page Writes):在每个检查点后第一次修改某个页面时,WAL 会记录该页面的完整镜像。这样即使部分写(torn page)发生,也能从 WAL 中恢复完整页面。
// 完整页面镜像的 WAL 记录
typedef struct {
RelFileNode rnode; // 文件标识
ForkNumber forknum; // 分支(main/fsm/vm)
BlockNumber blkno; // 块号
char data[BLCKSZ]; // 完整的 8KB 页面数据
} XLogRecordBlockImageHeader;- 严格的回放顺序:WAL 记录必须严格按照 LSN 顺序回放。PostgreSQL 通过单线程回放、LSN 递增检查等机制确保这一点。
物理复制的局限性
尽管物理复制性能优越(直接字节拷贝,无需解析逻辑),但它有严重的局限性:
版本耦合:WAL 格式在不同的 PostgreSQL 版本间可能不兼容。你不能将 PostgreSQL 14 的 WAL 回放到 PostgreSQL 15 的实例上。这意味着主从节点必须运行完全相同的大版本,升级时必须停机。
全量复制:物理复制是全数据库级别的,你不能只复制某几张表。从节点必须是主节点的完整副本。
只读从库:从节点在回放 WAL 时,数据库处于恢复模式,无法接受写入。虽然 PostgreSQL 支持 Hot Standby(热备份),允许从库提供只读查询,但这也有限制(如与恢复冲突的查询会被取消)。
无法跨平台:WAL 包含平台相关的字节序、对齐方式等信息。你不能将 x86 主节点的 WAL 复制到 ARM 从节点。
磁盘空间浪费:即使只修改一个字节,也可能需要记录整个 8KB 页面(Full Page Writes),导致 WAL 体积膨胀。
这些局限性促使人们寻找更灵活的复制方式,这就引出了逻辑复制。
二、逻辑复制:基于变更事件的复制
逻辑复制工作在更高的抽象层次。它不复制磁盘页面的字节变化,而是复制逻辑层面的变更事件,比如”在 users 表中插入一行 (id=1, name=‘Alice’)“。这种方法提供了更大的灵活性,但也引入了新的复杂性。
PostgreSQL 的逻辑解码
PostgreSQL 9.4 引入了逻辑解码(Logical Decoding)机制。它的核心思想是:从 WAL 中提取出逻辑变更事件,转换成结构化的格式,然后发送给订阅者。
逻辑解码的流程是:
- 创建复制槽:复制槽(Replication Slot)用于跟踪订阅者的消费进度,确保相应的 WAL 不会被过早删除。
-- 创建逻辑复制槽,使用 pgoutput 输出插件
SELECT pg_create_logical_replication_slot('my_slot', 'pgoutput');解码 WAL:逻辑解码器读取 WAL 记录,解析出其中的逻辑操作。例如,对于 heap insert 操作,它会提取出表 OID、列值等信息。
格式化输出:输出插件(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- 发布订阅: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;相比物理复制的原始字节流,这种格式包含了明确的语义信息:
- 知道这是对哪张表的操作
- 知道每列的名称和类型
- 值是逻辑层面的(如整数 1、字符串 ‘Alice’),而非磁盘页面的字节布局
这使得从节点可以用不同的方式存储数据。例如,从节点可以:
- 使用不同的索引
- 使用不同的列顺序
- 跳过某些列(如不需要的 BLOB 字段)
- 将数据写入不同类型的存储(如从 PostgreSQL 复制到 Elasticsearch)
MySQL Binlog 的三种格式
MySQL 的 binlog(Binary Log)是另一个经典的逻辑复制实现,但它提供了三种不同的格式,各有权衡:
- 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 语句是非确定性的,在从库执行可能产生不同的结果:
NOW()、RAND()、UUID()等函数在不同时间、不同节点上返回不同值LIMIT子句在没有ORDER BY时,结果顺序是不确定的- 触发器、存储过程内部的逻辑可能依赖于执行环境
为了缓解这些问题,MySQL 会在 binlog 中记录一些上下文信息(如时间戳、随机数种子),但仍然无法完全保证确定性。
- 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 事件。
- 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
}这种格式包含了丰富的元数据:
before/after字段提供变更前后的完整行数据source字段提供变更的来源信息(哪个数据库、哪张表、哪个事务)lsn提供全局顺序保证ts_ms提供时间戳
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 工具的核心挑战是:
- 保证顺序性:必须按照事务提交顺序发送事件,避免下游看到中间状态。
- 处理 Schema 变更:当表结构改变时(加列、改类型),需要优雅地处理旧格式和新格式的事件。
- 初始快照:首次同步时,需要对现有数据做快照,然后无缝切换到增量复制。
逻辑复制的灵活性
逻辑复制的最大优势是灵活性:
- 选择性复制:可以只复制特定的表、甚至特定的列。
-- 只复制 users 表,且只包含部分列
CREATE PUBLICATION users_pub FOR TABLE users (id, name);跨版本复制:因为协议是逻辑层面的,PostgreSQL 15 的主库可以复制到 PostgreSQL 14 的从库(只要逻辑格式兼容)。
异构复制:可以从 PostgreSQL 复制到 MySQL、MongoDB、Elasticsearch 等,只需实现相应的格式转换。
双向复制:逻辑复制允许双活(Active-Active)配置,两边都可以写入(需要解决冲突)。
过滤和转换:可以在复制过程中过滤数据(如只复制
region = 'US'的行)或转换数据(如脱敏)。
但这种灵活性也有代价:
- 性能开销:需要解码 WAL、格式化数据,比物理复制慢。
- 冲突处理:双向复制时,两边可能同时修改同一行,需要冲突解决策略(如 last-write-wins、自定义冲突函数)。
- 事务语义:大事务可能被拆分成多个小的变更事件,下游可能看到部分提交的状态。
CDC 中的 Schema 演进挑战
在生产环境中,数据库的表结构不可能一成不变。当 CDC
管道正在运行时,上游数据库执行 ALTER TABLE
操作会引发一系列连锁问题:CDC
连接器解码出的事件结构发生变化,下游消费者可能无法正确反序列化新格式的消息,整条管道面临中断风险。
Debezium 的解决方案
Debezium 采用 Schema Registry 配合 Schema 变更事件的方式来应对这一挑战。当检测到表结构变更时,Debezium 会:
- 将新的 Schema 注册到 Confluent Schema Registry 或 Apicurio Registry。
- 在 Kafka 的 Schema Change Topic 中发布一条 Schema 变更事件,通知所有消费者。
- 后续的数据变更事件将携带新 Schema 的版本号,消费者据此选择正确的反序列化器。
- Schema Registry 会根据配置的兼容性规则,拒绝不兼容的 Schema 变更注册。
Avro Schema 演进规则
在 CDC 管道中,Avro 是最常用的序列化格式,因为它原生支持 Schema 演进。Avro 定义了三种兼容性级别:
- 向后兼容(Backward Compatible):新 Schema 可以读取旧 Schema 写入的数据。例如新增一个带默认值的字段,旧数据中缺少该字段时使用默认值填充。这是最常用也最推荐的策略。
- 向前兼容(Forward Compatible):旧 Schema 可以读取新 Schema 写入的数据。例如删除一个字段,旧消费者忽略新数据中不认识的字段。
- 完全兼容(Full Compatible):同时满足向后兼容和向前兼容。这是最严格的级别,要求每次变更都既能被新消费者理解,也能被旧消费者容忍。
常见场景分析
不同类型的 Schema 变更,风险等级差异很大:
- 新增带默认值的列(安全):这是最理想的变更方式。例如
ALTER TABLE users ADD COLUMN age INT DEFAULT 0。旧事件中不包含age字段,消费者使用默认值0填充,管道无需中断。 - 删除列(破坏性):如果消费者依赖被删除的列,反序列化将失败。必须确保所有下游消费者先升级到不依赖该列的版本,然后才能在上游删除该列。建议先将该列标记为废弃,经过一个完整的发布周期后再物理删除。
- 修改列类型(需谨慎迁移):例如将
VARCHAR(50)改为TEXT,或将INT改为BIGINT。这类变更需要分步执行——先在下游增加新类型的列,然后灰度切换消费者逻辑,最后清理旧列。直接修改类型可能导致类型转换异常或数据截断。
最佳实践
- 始终采用向后兼容的变更策略,优先新增带默认值的字段。
- 为每个 Schema 版本分配语义化版本号,便于追溯和回滚。
- 在预发布环境中搭建完整的 CDC 管道副本,所有 Schema 变更先在预发布管道中验证通过后再上线生产。
- 配置 Schema Registry 的兼容性检查为
BACKWARD或FULL,让不兼容的变更在注册阶段就被拒绝。
CDC 故障场景分析
CDC 管道是一条多组件协作的长链路,任何一个环节的故障都可能导致数据延迟、丢失或重复。以下是几种典型的故障场景及其应对策略。
消费者崩溃:Offset 管理与投递语义
当消费者进程崩溃时,恢复策略取决于 Offset 的提交方式:
- 至少一次投递(At-Least-Once):消费者在处理完消息并写入下游存储后才提交 Offset。如果在写入后、提交前崩溃,重启后会重新消费已写入的消息,导致重复。下游系统需要具备幂等写入能力(如基于主键的 UPSERT)。
- 精确一次投递(Exactly-Once):通过事务性写入实现——将下游写入和 Offset 提交放在同一个事务中。例如,Kafka Connect 的 Sink Connector 支持将 Offset 存储在目标数据库的事务中,保证原子性。但这要求下游存储支持事务,且性能会有所下降。
生产者(连接器)崩溃:复制槽与 WAL 膨胀
在 PostgreSQL 中,CDC 连接器通过逻辑复制槽(Replication Slot)跟踪消费进度。复制槽会阻止 WAL 被清理,确保连接器恢复后能从断点继续读取。但如果连接器长时间宕机,WAL 文件会持续累积,导致磁盘空间耗尽(Slot Bloat)。应对措施包括:
- 设置
max_slot_wal_keep_size限制复制槽保留的 WAL 大小。 - 配置监控告警,当 WAL 积压超过阈值时通知运维人员。
- 在连接器长时间不可恢复时,主动删除复制槽并在恢复后执行全量快照重建。
Schema 不兼容:死信队列策略
当消费者收到无法反序列化的事件时(例如上游执行了不兼容的 Schema 变更),直接丢弃或阻塞都不是好的选择。推荐采用死信队列(Dead Letter Queue, DLQ)策略:将无法处理的消息转发到专门的 DLQ Topic,主流程继续消费后续消息,再由运维人员异步排查和修复 DLQ 中的问题消息。
网络分区:缓冲、背压与数据丢失风险
当 CDC 连接器与 Kafka 之间发生网络分区时:
- 连接器会在本地缓冲区中暂存已读取的变更事件。但缓冲区大小有限,超出后连接器将暂停从 WAL 读取,形成背压(Backpressure)。
- 如果分区持续时间过长,且连接器配置了超时放弃策略,可能导致部分事件丢失。
- 建议配置合理的重试策略和缓冲区大小,并在分区恢复后通过 LSN 对比验证数据完整性。
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 模型中,每个节点维护:
- 状态(State):系统的当前状态,如键值对存储
map[string]string。 - 命令日志(Command
Log):一个有序的命令序列,如
["SET x 1", "SET y 2", "DEL x"]。 - 状态机(State
Machine):一个确定性的函数
apply(state, command) -> new_state。
复制的过程是:
- 客户端提交一个命令(如
SET x 10)给 leader。 - Leader 将命令追加到自己的日志中,并通过共识协议将其复制到 followers。
- 共识达成后,所有节点都在相同的日志索引位置记录了相同的命令。
- 各节点按顺序将命令应用到状态机,执行
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 的日志复制流程是:
- Leader 接收命令:客户端发送
PUT /foo bar。 - 追加到本地日志:Leader 创建一个新的
Entry{Term: 3, Index: 100, Data: ...},追加到本地日志。 - 发送 AppendEntries RPC:Leader
并发向所有 followers 发送
AppendEntries请求,包含新的日志条目。
type AppendEntriesRequest struct {
Term uint64 // Leader 的任期
LeaderId uint64
PrevLogIndex uint64 // 前一条日志的索引
PrevLogTerm uint64 // 前一条日志的任期
Entries []Entry // 要追加的日志条目
LeaderCommit uint64 // Leader 的 commitIndex
}Follower 检查并追加:Follower 检查
PrevLogIndex和PrevLogTerm是否匹配(一致性检查),如果匹配则追加新条目,返回成功。Leader 提交:当大多数节点(包括自己)都成功追加后,Leader 将
commitIndex推进到 100,表示该日志条目已提交。应用到状态机: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 的正确性完全依赖于状态机的确定性:相同的初始状态 + 相同的命令 = 相同的最终状态。这意味着:
- 禁止非确定性操作:
- 不能使用当前时间(
time.Now()) - 不能使用随机数(
rand.Int()) - 不能读取外部状态(环境变量、文件系统)
- 不能使用当前时间(
- 如何处理时间相关的操作?
如果客户端提交的命令需要时间戳(如
SET x 10 EXPIRE_AT 1642234567),有两种方法:
- 客户端提供时间戳:客户端在命令中显式包含时间戳,Leader 不做修改。这样时间戳成为命令的一部分,是确定性的。
type PutRequest struct {
Key []byte
Value []byte
Lease int64 // 客户端指定的租约 ID
}- Leader 分配时间戳:Leader 在收到命令时分配时间戳,并将其作为命令的一部分复制。Followers 不重新生成时间戳,而是使用 Leader 提供的值。
type Entry struct {
Term uint64
Index uint64
Data []byte
Timestamp int64 // Leader 分配
}- 如何处理读操作?
纯粹的 SMR 会将读操作也作为命令记录到日志中,这样保证了线性一致性(Linearizability),但性能很差。优化方法包括:
- Lease Read:Leader 在租约有效期内直接返回本地状态,无需走共识(etcd 使用此方法)。
- ReadIndex:Leader 记录当前的
commitIndex,等待该 index 被应用后返回结果(Raft 论文建议的方法)。 - Follower Read:允许 follower 提供读服务,但需要先向 leader 确认自己的日志是最新的。
与物理/逻辑复制的对比
状态机复制与前两种方法有本质区别:
| 物理复制 | 逻辑复制 | 状态机复制 | |
|---|---|---|---|
| 复制内容 | 磁盘页面字节 | 行级变更事件 | 确定性命令 |
| 确定性来源 | 相同的 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 的事务不会混淆。
四、三种方法的详细权衡
现在我们已经理解了三种复制日志的工作原理,接下来对比它们在各个维度的权衡。
性能对比
物理复制性能最高:
- 直接字节拷贝,无需解析
- 可以批量传输(如一次发送 1MB 的 WAL)
- 从库回放时直接写磁盘页面,无需 SQL 层处理
PostgreSQL 的流复制在 10Gbps 网络下可以接近 1GB/s 的吞吐量。
逻辑复制性能较差:
- 需要解码 WAL,提取逻辑变更
- 格式化成结构化数据(如 JSON)
- 从库需要重新执行插入/更新逻辑(可能需要查找索引、检查约束)
PostgreSQL 的逻辑复制通常只有物理复制吞吐量的 30-50%。更严重的是,逻辑复制的延迟更高,因为解码和格式化需要时间。
状态机复制性能取决于命令粒度:
- 如果命令很小(如单次 PUT),性能类似逻辑复制
- 如果命令很大(如批量写入),一次共识可以应用多个操作,性能更好
- Raft 的瓶颈通常在共识开销(网络往返 + 磁盘 fsync),而非命令应用
etcd 在 3 节点集群上单个 PUT 的延迟约 5-10ms(SSD + 低延迟网络),吞吐量约 10k ops/s。如果使用批量提交(一次共识多个命令),可以达到 100k ops/s。
灵活性对比
物理复制几乎没有灵活性:
- 全库复制,无法选择表
- 从库必须与主库架构、版本完全一致
- 无法改变数据布局(如列顺序、索引)
逻辑复制提供极大的灵活性:
- 可以选择性复制(表级、列级)
- 可以跨版本、跨平台、跨数据库类型
- 可以在复制过程中转换数据(过滤、脱敏、聚合)
- 支持双向复制和多主复制
状态机复制的灵活性介于两者之间:
- 命令是应用定义的,可以设计得很灵活(如支持条件写入、事务)
- 但状态机实现必须在所有节点上一致(不能一个节点用 BTree,另一个用 HashMap)
- 通常不支持部分复制(所有节点需要完整状态以处理任意命令)
版本兼容性
这是物理复制最大的痛点。PostgreSQL 的大版本升级(如 14 → 15)必须停机,因为 WAL 格式可能不兼容。典型的升级流程是:
- 停止主库写入
- 等待从库追上主库
- 使用
pg_upgrade工具升级主库 - 重新搭建从库(从头同步)
这对于大型数据库可能需要数小时甚至数天。
逻辑复制则允许滚动升级:
- 先升级从库(从 14 升级到 15),从 14 主库继续复制
- 切换流量到升级后的从库(它现在是 15)
- 旧主库降级为从库,从新主库(15)复制
- 升级原主库
这样可以实现零停机升级。
状态机复制的版本兼容性取决于状态机实现。etcd 在小版本升级时通常兼容,但大版本升级也需要特殊处理(如 etcd 2.x 到 3.x 需要迁移)。
部分复制
有时我们只想复制部分数据,比如:
- 只复制活跃用户的数据(
WHERE last_login > NOW() - INTERVAL '30 days') - 只复制特定地区的数据(
WHERE region = 'EU',满足 GDPR 要求) - 只复制部分列(排除敏感字段)
物理复制无法做到,因为它是页面级的全量复制。
逻辑复制可以通过行级过滤实现:
-- PostgreSQL 15 支持行级过滤
CREATE PUBLICATION eu_pub FOR TABLE users WHERE (region = 'EU');甚至可以自定义输出插件,在解码阶段做复杂的过滤和转换。
状态机复制通常不支持部分复制,因为节点需要完整状态才能执行命令。但可以通过应用层的分片实现类似效果(不同节点负责不同的 key 范围)。
Schema 演进
数据库的表结构会随时间变化(加列、删列、改类型)。不同复制方法对 Schema 演进的支持不同:
物理复制不支持主从异构 Schema:
- 如果主库执行
ALTER TABLE users ADD COLUMN age INT,从库必须有完全相同的表结构 - WAL 中记录的字节布局假定了特定的 Schema
逻辑复制支持一定程度的 Schema 差异:
- 从库可以有额外的列(主库的变更只影响共同的列)
- 从库可以缺少某些列(如果这些列有默认值)
- 但列类型不兼容时会出错(如主库是 INT,从库是 VARCHAR)
状态机复制的 Schema 演进需要特殊处理:
- 通常通过配置变更(Configuration Change)机制来同步 Schema 变化
- 所有节点在相同的日志索引位置应用 Schema 变更,保证一致性
- etcd v3 就是在应用层定义 Schema(所有值都是
[]byte),避免了这个问题
运维视角的三种复制方法对比
在实际生产环境中,选择复制方法不仅要考虑数据一致性和性能,还需要从运维角度评估部署、监控、故障恢复等方面的复杂度。下表从七个关键运维维度对三种复制方法进行对比:
| 维度 | 物理复制 | 逻辑复制 | 状态机复制 |
|---|---|---|---|
| 部署复杂度 | 低。主从节点版本和配置必须一致,但部署流程简单,通常只需配置
primary_conninfo 即可 |
中等。需要配置发布/订阅关系、复制槽、输出插件,CDC 场景还需部署 Kafka 和 Schema Registry | 高。需要部署共识集群(通常为奇数节点),配置成员关系、选举参数、日志存储等 |
| 监控难度 | 低。核心指标少——复制延迟(replay_lag)、WAL
发送/接收位置差值即可覆盖主要场景 |
中等。需要监控复制槽积压、解码延迟、下游消费速率、Schema 兼容性等多个层面 | 高。需要监控选举状态、日志同步进度、快照传输、成员健康状态、命令排队延迟等 |
| 故障恢复时间 | 快。从节点已拥有完整数据副本,提升为主节点只需重放少量未应用的 WAL,通常秒级完成 | 中等。需要重建复制槽、可能需要重新快照,恢复时间取决于数据量和积压程度 | 较快。共识协议自动处理 leader 故障转移,通常在选举超时(数百毫秒到数秒)内完成 |
| 版本升级方式 | 困难。主从必须同版本,升级需要停机或通过逻辑复制中转实现滚动升级 | 灵活。主从可以不同版本,支持滚动升级——先升级从库,切换主库,再升级旧主库 | 支持滚动升级。逐个替换节点,共识协议保证升级过程中集群持续可用 |
| 带宽消耗 | 高。传输完整的 WAL 字节流,包含索引更新、VACUUM 操作等非业务数据 | 中等。只传输逻辑变更事件,不含索引和内部维护操作,但 Schema 信息会增加开销 | 低。只传输客户端命令,数据量最小,但命令执行结果的差异需要额外的一致性校验 |
| 延迟特征 | 极低。字节流直接应用,无解码开销,异步模式下通常在毫秒级 | 较低。需要 WAL 解码和格式转换,延迟通常在毫秒到秒级,取决于事务大小 | 受共识延迟影响。每条命令需要多数派确认,延迟通常在数毫秒到数十毫秒,跨数据中心时更高 |
| 运维工具成熟度 | 高。pg_basebackup、pg_stat_replication、repmgr
等工具链成熟且文档丰富 |
中等。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)确保重复应用不会导致错误的状态。
为什么需要幂等性
考虑这样的场景:
- 从节点收到一条日志:
UPDATE accounts SET balance = balance + 100 WHERE id = 1 - 从节点应用后,账户余额从 500 变为 600
- 从节点崩溃,重启后不确定该日志是否已应用
- 重放日志,再次执行
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. 天然幂等的操作
某些操作天然是幂等的,无需额外机制:
SET x = 10(赋值):无论执行多少次,结果都是x = 10DELETE FROM users WHERE id = 1:重复删除没有副作用INSERT ... ON CONFLICT DO NOTHING:主键冲突时跳过
设计 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:
- 所有从节点都已接收并应用(基于从节点的反馈消息)
- 超过了最老的复制槽位置(如果有逻辑复制)
- 超过了最近一个检查点之前的某个阈值(用于自身的崩溃恢复)
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。
状态机复制中,日志可以删除当满足:
- 所有节点都已应用到该索引(基于
matchIndex) - 已经为状态机做了快照,快照覆盖了该索引
Raft 的快照机制
Raft 的快照流程是:
- 生成快照:状态机序列化自己的状态,记录
lastAppliedIndex和lastAppliedTerm。
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
}持久化快照:将快照写入磁盘,记录
lastIncludedIndex。裁剪日志:删除 index <=
lastIncludedIndex的所有日志条目。安装快照:如果新节点加入或某个节点落后太多,leader 发送
InstallSnapshotRPC,直接传输快照:
type InstallSnapshotRequest struct {
Term uint64
LeaderId uint64
LastIncludedIndex uint64
LastIncludedTerm uint64
Data []byte // 快照数据(可能分块传输)
}Follower 收到后,丢弃自己的所有日志,用快照替换状态机。
Kafka 的日志压缩
Kafka 不是状态机复制,但它的日志压缩(Log Compaction)机制值得学习。Kafka 的 topic 可以配置两种清理策略:
- delete(基于时间或大小):删除过期的消息。
- compact(基于 key):对于每个 key,只保留最新的值,删除旧的。
压缩的工作原理:
- Kafka 将日志分为多个 segment(如每 1GB 一个)
- 压缩线程扫描旧 segment,构建一个 key → offset 的映射,记录每个 key 最新出现的位置
- 复制 segment,跳过被覆盖的旧记录,只保留每个 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 场景:如果只关心每行的最新状态,不关心中间的变更历史,压缩可以大大减少存储空间。但要注意:
- 删除操作需要记录为
null值(tombstone),并保留一段时间(delete.retention.ms) - 压缩是异步的,不保证立即生效
- 压缩后的日志不保证完整的历史顺序(因为跳过了中间值)
七、PostgreSQL 物理复制 vs 逻辑复制深度对比
我们通过一个具体的场景,对比 PostgreSQL 的两种复制方式。
场景设定
假设我们有一个电商系统,主库在美国,需要复制到欧洲和亚洲的只读副本。需求如下:
- 欧洲副本只需要欧洲用户的数据(GDPR 合规)
- 亚洲副本需要全量数据,但希望跨版本升级
- 需要定期从主库同步到数据仓库(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'优点:
- 性能极高,延迟通常 < 1 秒
- 配置简单,运维负担小
- 从库是完整的副本,可以立即接管(failover)
缺点:
- 无法满足需求 1:欧洲副本会包含全球所有数据,违反 GDPR
- 无法满足需求 2:如果主库是 PG 14,从库必须也是 PG 14,无法单独升级
- 无法满足需求 3:物理 WAL 无法直接导入 Snowflake
方案二:逻辑复制
配置发布订阅:
-- 主库
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"
}
}优点:
- 满足需求 1:通过行级过滤,欧洲副本只包含 EU 数据
- 满足需求 2:亚洲副本可以是 PG 15,独立升级
- 满足需求 3:通过 Debezium 转换成 JSON,导入 Snowflake
缺点:
- 性能较差:延迟可能达到数秒甚至更高(取决于负载)
- 配置复杂:需要管理发布、订阅、复制槽
- 冲突可能:如果欧洲副本手动修改数据,可能与复制冲突
实际的监控数据
我们在一个生产环境中测试了两种复制方式(主库写入速率 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 开销显著更高。
混合方案
实际生产中,我们可以混合使用:
- 对于同地域的 HA(High Availability)副本,使用物理复制(低延迟、快速 failover)
- 对于跨地域的只读副本,使用逻辑复制(灵活过滤、跨版本)
- 对于数据仓库、搜索引擎等异构系统,使用 CDC
US 主库
├─ 物理复制 → US 从库(HA)
├─ 逻辑复制 → EU 从库(GDPR)
├─ 逻辑复制 → Asia 从库(跨版本)
└─ CDC → Kafka → Snowflake/Elasticsearch
八、Kafka ISR 机制剖析
Kafka 虽然不是数据库,但它的复制机制非常精妙,值得深入学习。Kafka 的每个 partition 都是一个复制日志,leader 将消息复制到 follower,但它的设计有独特之处。
ISR 的概念
ISR(In-Sync Replicas)是指”与 leader 保持同步的副本集合”。一个副本属于 ISR,当且仅当:
- 它在过去
replica.lag.time.max.ms时间内向 leader 发送了 fetch 请求 - 它的 LEO(Log End Offset)落后 leader 的 LEO 不超过
replica.lag.max.messages(已废弃)
注意:现代 Kafka(0.10+)只检查时间条件,不检查消息数量。原因是消息大小不一,很难定义”落后多少条”。
ISR 与普通的副本集(AR, Assigned Replicas)不同:
- AR:分配给该 partition 的所有副本(如 3 个副本)
- ISR:AR 中与 leader 保持同步的副本(可能只有 2 个,如果一个副本宕机)
High Watermark 机制
Kafka 使用 HW(High Watermark)来定义”已提交”的消息边界:
- LEO(Log End Offset):日志的末尾位置,leader 和每个 follower 都有自己的 LEO
- HW:ISR 中所有副本的最小 LEO,表示所有 ISR 副本都已复制到该位置
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 的时机:
- Producer 写入新消息后,leader 的 LEO 推进
- Follower 发送 fetch 请求时,携带自己的 LEO
- Leader 更新对该 follower 的 LEO 记录
- Leader 计算 ISR 中所有副本的最小 LEO,更新 HW
- Leader 在下一次 fetch 响应中返回新的 HW 给 follower
这种设计的优点是:只有被 ISR 中所有副本确认的消息才被认为已提交,保证了消息不丢失(即使 leader 崩溃,新 leader 也一定有这些消息)。
Leader Epoch 机制
早期的 Kafka 在某些 failover 场景下可能丢失或重复消息。例如:
- Leader A 有消息 [0, 1, 2],HW = 2
- Follower B 有消息 [0, 1],LEO = 2
- Leader A 宕机前又写入了消息 3,但还未复制到 B
- B 被选为新 leader,HW = 2
- 原 leader A 恢复,它有消息 [0, 1, 2, 3]
- 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 截断,而是:
- 向当前 leader 询问:“我的 epoch 是 1,我应该从哪里开始同步?”
- Leader 查找 epoch 1 对应的 StartOffset(例如 5),返回该值
- Follower 截断到 offset 5,然后从 5 开始同步
这样避免了基于 HW 的错误截断,保证了消息不丢失。
Unclean Leader Election
当 ISR 中所有副本都宕机时,Kafka 面临两个选择:
- 等待 ISR
中的副本恢复(
unclean.leader.election.enable = false)- 保证不丢消息(新 leader 一定有所有已提交的消息)
- 但可能导致 partition 长时间不可用
- 从 OSR(Out-of-Sync Replicas)中选举
leader(
unclean.leader.election.enable = true)- 保证可用性(立即选出新 leader)
- 但可能丢失消息(新 leader 可能缺少某些已提交的消息)
这是 CAP 理论在实践中的体现:
unclean = false:选择一致性(Consistency)over 可用性(Availability)unclean = true:选择可用性 over 一致性
对于金融、支付等场景,应设置
unclean = false。对于日志收集、监控等场景,可以设置
unclean = true,容忍少量数据丢失。
Producer 的 acks 配置
Kafka 的 producer 可以配置 acks
参数,控制消息被认为”已发送成功”的条件:
- acks = 0:producer 不等待任何确认,发送即成功。性能最高,但可能丢消息。
- acks = 1:等待 leader 写入本地日志即返回。如果 leader 写入后立即宕机,消息可能丢失。
- acks = all(或
-1):等待 ISR 中所有副本都确认。保证消息不丢失(除非 ISR 全部宕机且 unclean=true)。
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 的复制流量。关键优化包括:
Zero-Copy:使用
sendfile()系统调用,将数据直接从 page cache 发送到网络,避免用户态拷贝。批量复制:follower 的 fetch 请求可以一次拉取多个 batch,减少网络往返。
并行复制:leader 可以同时向多个 follower 发送数据(基于 NIO)。
压缩传输:消息可以在 producer 端压缩(如 LZ4、Snappy),减少网络流量。Follower 直接存储压缩后的消息,无需解压。
props.put("compression.type", "lz4");- 异步刷盘:Kafka 依赖操作系统的 page
cache,不强制
fsync(除非配置了flush.messages)。这大大提高了写入吞吐量,但依赖副本机制保证可靠性。
九、Part IV 小结与展望
在 Part IV(复制)中,我们系统地探讨了分布式系统中数据复制的核心问题。从基础的主从复制、多主复制,到复制延迟、一致性模型,再到最终一致性、因果一致性、线性一致性,最后深入到复制日志的三种设计方法。
复制不仅是提高可用性和性能的手段,更是分布式系统一致性保证的基础。我们看到:
- 物理复制提供最高性能,但牺牲了灵活性,适合同构环境的强一致性复制
- 逻辑复制提供最大灵活性,支持异构系统、部分复制、跨版本升级,但性能和复杂度更高
- 状态机复制是共识算法的核心,通过命令序列的全局顺序实现强一致性,适合元数据存储和协调服务
在日志设计中,幂等性、压缩、截断等机制确保系统既能保证正确性,又能控制资源消耗。PostgreSQL、MySQL、Kafka、etcd 等系统的复制机制各有千秋,但底层原理是相通的。
然而,复制只解决了”如何让多个节点拥有相同的数据”的问题。当数据量增长到单机无法容纳时,我们需要将数据分散到多个节点——这就是分区(Partitioning)的问题。
Part V(分区)将探讨:
- 如何将大规模数据集划分到多个节点
- 哈希分区、范围分区、一致性哈希的原理和权衡
- 分区与复制的结合(每个分区又有多个副本)
- 分区再平衡(Rebalancing)的挑战
- 二级索引在分区系统中的实现
分区引入了新的复杂性:数据如何路由?如何处理热点分区?如何在不停机的情况下调整分区方案?我们将在下一篇文章《哈希分区》中开始这一旅程。
参考文献
- PostgreSQL Documentation: “High Availability, Load Balancing, and Replication”, https://www.postgresql.org/docs/current/high-availability.html
- PostgreSQL Documentation: “Logical Replication”, https://www.postgresql.org/docs/current/logical-replication.html
- MySQL Documentation: “Replication”, https://dev.mysql.com/doc/refman/8.0/en/replication.html
- Debezium Documentation: “PostgreSQL Connector”, https://debezium.io/documentation/reference/connectors/postgresql.html
- Diego Ongaro, John Ousterhout: “In Search of an Understandable Consensus Algorithm (Extended Version)”, USENIX ATC 2014
- Apache Kafka Documentation: “Replication”, https://kafka.apache.org/documentation/#replication
- Jun Rao: “Hands-free Kafka Replication: A lesson in operational simplicity”, Confluent Blog, 2013
- Jason Gustafson: “KIP-101: Alter Replication Protocol to use Leader Epoch rather than High Watermark for Truncation”, Apache Kafka, 2017
- Patrick Hunt et al.: “ZooKeeper: Wait-free coordination for Internet-scale systems”, USENIX ATC 2010
- etcd Documentation: “etcd Raft Library”, https://etcd.io/docs/current/learning/api/
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【分布式系统百科】RPC 框架内核:从透明调用幻觉到工程实战
2020 年 11 月 25 日,Google 全球范围的服务连锁故障。根因是内部 RPC 框架的一个默认超时配置:当身份认证服务响应变慢时,数十万个 RPC 调用阻塞在等待认证结果上,连接池耗尽,请求堆积如山,最终拖垮了包括 Gmail、YouTube、Google Cloud 在内的几乎所有面向用户的服务。一个看起…
【分布式系统百科】Gossip 协议:从流行病模型到大规模集群通信
一个 1000 节点的集群里,某台机器的磁盘满了。这个信息需要多久才能传遍整个集群?
【分布式系统百科】可靠广播:从尽力而为到全序的五层抽象
三个副本需要以相同顺序执行同一批写操作。节点 A 先广播 x1,再广播 x2;节点 B 收到的顺序却是 x2 然后 x1。副本状态分叉了——A 认为 x2,B 认为 x1。更糟糕的是,如果 A 在发完第一条消息后崩溃,某些节点收到了 x1,另一些没收到。此时系统中存在两类节点:知道 x1 的和不知道的。后续所有基于 x…
【分布式系统百科】链式复制与 CRAQ:不走寻常路的高吞吐方案
在分布式系统的复制协议中,我们通常会第一时间想到 Raft 或 Paxos。这些基于共识(Consensus)的复制方案已经成为工业界的主流选择,从 etcd 到 CockroachDB,从 Consul 到 TiKV,几乎所有需要强一致性保证的系统都在使用它们。但在 2004 年,Cornell 大学的 Robber…