副本复制算法与架构——PacificA、Elasticsearch、Kafka、Pulsar 全面对比
本文将深入剖析 PacificA、Elasticsearch、Kafka、Pulsar 四大分布式系统的副本复制机制,揭示它们在一致性、可用性和性能之间的精妙权衡。这些系统虽然都通过副本复制来保证数据可靠性和高可用性,但在具体的实现策略上各有特色,反映了不同的设计哲学和适用场景。
引言:为什么需要副本复制
数据可靠性与高可用性
在分布式系统中,硬件故障是常态而非例外。Google 的统计数据显示,一个拥有 10,000 台服务器的数据中心,每天平均会有 2-3 台服务器发生故障。如果数据只存储在一台机器上,那么这台机器的故障就意味着数据的永久丢失。
副本复制(Replication) 是解决这个问题的核心手段:将数据复制到多台机器上,即使部分机器故障,数据仍然可用。
CAP 定理的实际影响
CAP 定理告诉我们,在网络分区(Partition)发生时,分布式系统只能在**一致性(Consistency)和可用性(Availability)**之间二选一:
| 选择 | 含义 | 代表系统 |
|---|---|---|
| CP | 网络分区时拒绝服务,保证一致性 | ZooKeeper、etcd、HBase |
| AP | 网络分区时继续服务,允许不一致 | Cassandra、DynamoDB |
实际系统通常不是简单的 CP 或 AP,而是在不同操作和配置下提供不同的一致性级别。
副本复制的核心挑战
副本复制需要解决三个核心问题:
- 一致性:所有副本看到的数据是否相同?
- 可用性:部分副本故障时,系统是否仍能提供服务?
- 性能:复制操作对写入延迟和吞吐量的影响有多大?
Part 1: 副本复制的基础理论
同步复制 vs 异步复制
| 模式 | 写入流程 | 一致性 | 延迟 | 可用性 |
|---|---|---|---|---|
| 同步复制 | 等待所有副本确认后才返回成功 | 强一致 | 高(受最慢副本影响) | 低(任一副本故障阻塞写入) |
| 异步复制 | 主副本写入成功即返回 | 最终一致 | 低 | 高 |
| 半同步复制 | 等待部分副本(Quorum)确认 | 可调一致性 | 中等 | 中等 |
主从复制的三种模型
单主复制(Single-Leader):
- 所有写入都经过一个主节点
- 从节点从主节点复制数据
- 代表:MySQL 主从、Kafka、Elasticsearch
多主复制(Multi-Leader):
- 多个节点都可以接受写入
- 需要解决写入冲突
- 代表:CockroachDB、TiDB(Raft Group 级别)
无主复制(Leaderless):
- 任何节点都可以接受读写
- 使用 Quorum 机制保证一致性
- 代表:Cassandra、DynamoDB、Riak
Quorum 机制
Quorum 机制通过 W + R > N 来保证读取到最新数据:
- N:副本总数
- W:写入时需要确认的副本数
- R:读取时需要查询的副本数
例如,N=3, W=2, R=2:写入需要 2 个副本确认,读取需要查询 2 个副本。由于 W + R = 4 > 3 = N,读取的 2 个副本中至少有 1 个包含最新数据。
脑裂问题
脑裂(Split-Brain) 是分布式系统中最危险的故障之一:网络分区导致两个节点都认为自己是主节点,同时接受写入,导致数据不一致。
解决方案:
- Fencing(隔离):通过 STONITH(Shoot The Other Node In The Head)强制关闭旧主节点
- Lease(租约):主节点持有一个有时效的租约,租约过期前其他节点不能成为主节点
- Epoch/Term:每次选举递增一个编号,旧编号的主节点的写入被拒绝
Part 2: PacificA 协议
论文背景
PacificA 是微软研究院在 2008 年发表的论文《PacificA: Replication in Log-Based Distributed Storage Systems》中提出的复制协议。它的设计目标是为日志结构的分布式存储系统提供强一致性的复制方案。
核心架构
graph TD
CM[Configuration Manager<br/>全局元数据管理]
P[Primary<br/>接受所有读写] --> S1[Secondary 1<br/>被动复制]
P --> S2[Secondary 2<br/>被动复制]
P --> S3[Secondary 3<br/>被动复制]
CM -.->|管理配置| P
CM -.->|管理配置| S1
CM -.->|管理配置| S2
CM -.->|管理配置| S3
PacificA 的架构由三个角色组成:
| 角色 | 职责 | 特点 |
|---|---|---|
| Primary | 接受所有读写请求 | 同一时刻只有一个 |
| Secondary | 被动复制 Primary 的数据 | 可以有多个 |
| Configuration Manager | 管理副本组的配置信息 | 全局唯一,类似 ZooKeeper |
Write-All 强一致性语义
PacificA 的写入流程:
- 客户端将写入请求发送给 Primary
- Primary 将操作追加到本地日志
- Primary 将操作并行发送给所有 Secondary
- 所有 Secondary 确认后,Primary 才向客户端返回成功
- Primary 推进 Committed Point(提交点)
这就是 Write-All 语义:所有副本都必须确认,才算写入成功。
故障检测与 Primary 切换
PacificA 使用 Lease(租约) 机制进行故障检测:
- Primary 定期向所有 Secondary 发送心跳(同时也是 Lease 续约)
- 如果 Secondary 在 Lease 超时内没有收到心跳,它会向 Configuration Manager 报告 Primary 故障
- Configuration Manager 选择一个数据最新的 Secondary 作为新 Primary
- 新 Primary 开始接受读写请求
Lease 防止脑裂:旧 Primary 在 Lease 过期前不会被替换,而新 Primary 只有在 Configuration Manager 确认后才开始服务。这保证了任何时刻最多只有一个 Primary。
日志复制与追赶机制
当一个 Secondary 落后于 Primary 时(例如刚从故障中恢复),它需要通过**追赶(Catch-up)**来同步数据:
- Secondary 向 Primary 报告自己的最新日志位置
- Primary 将缺失的日志条目发送给 Secondary
- Secondary 应用这些日志条目,直到追上 Primary
- Secondary 重新加入副本组
Reconfiguration 协议细节
PacificA 的 Reconfiguration 协议处理副本组的动态变更(添加/移除副本):
- 配置版本控制:每个副本组配置都有一个递增的版本号(Configuration Version)
- 两阶段提交:
- Prepare 阶段:Primary 向所有副本(包括新旧)发送 Prepare 请求,携带新配置
- Commit 阶段:所有副本确认后,Primary 广播 Commit,新配置生效
- 日志截断与恢复:
- 新加入的副本从 Primary 的最新日志位置开始复制
- 被移除的副本在完成已接收日志的复制后停止服务
- 原子性保证:通过 Configuration Manager 的全局锁确保同一时刻只有一个 Reconfiguration 在进行
Reconfiguration 过程中,系统仍能服务写入请求(Write-All 语义基于当前配置),但读取操作可能短暂阻塞以保证一致性。
优点与局限性
| 优点 | 局限性 |
|---|---|
| 强一致性,读写都在 Primary | 写入延迟受最慢副本影响 |
| 设计简单直观 | Configuration Manager 是单点(需要自身高可用) |
| 故障恢复流程清晰 | Write-All 语义在副本数多时延迟高 |
| Lease 机制有效防止脑裂 | 不适合跨数据中心部署(延迟太高) |
Part 3: Elasticsearch
分片与副本模型
Elasticsearch 将索引(Index)划分为多个分片(Shard),每个分片可以有零个或多个副本(Replica):
1 | |
每个分片本质上是一个独立的 Lucene 索引。分片的数量在索引创建时确定,之后不能更改(除非 Reindex)。
Master Node 的职责与选举
ES 集群中有一个 Master Node,负责:
- 管理集群状态(Cluster State):哪些节点在线、分片分配在哪个节点
- 创建/删除索引
- 分配和重新分配分片
Master 选举在 ES 7.x 后基于类 Raft 协议实现,取代了之前的 Zen Discovery。选举过程:
- 节点发现彼此(通过种子节点列表)
- 具有
master角色的节点参与选举 - 获得多数票的节点成为 Master
- Master 发布集群状态更新
写入流程
1 | |
详细步骤:
- 客户端将写入请求发送给任意节点(该节点成为协调节点)
- 协调节点根据文档 ID 的哈希值确定目标 Primary Shard
- 请求被转发到 Primary Shard 所在的节点
- Primary Shard 执行写入操作(写入 Translog + 内存 Buffer)
- Primary Shard 并行将操作转发给所有 In-Sync Replica
- 所有 In-Sync Replica 确认后,Primary 向协调节点返回成功
- 协调节点向客户端返回成功
Translog 的作用
Translog(Transaction Log)是 ES 的 WAL,保证数据持久性:
- 每次写入操作都会先追加到 Translog
- Translog 默认每 5 秒 fsync 一次(可配置为每次请求都 fsync)
- 当 Translog 达到一定大小时,触发 Flush 操作:将内存中的数据写入 Lucene Segment,并清空 Translog
Segment 合并与 Refresh
ES 的近实时搜索(NRT)依赖于 Refresh 机制:
- Refresh(默认每 1 秒):将内存 Buffer 中的数据写入一个新的 Lucene Segment(但不 fsync),使数据可被搜索
- Segment 合并:后台线程将多个小 Segment 合并为大 Segment,类似 LSM-Tree 的 Compaction
Sequence Number 和 Primary Term 机制
ES 7.x 引入了 Sequence Number 和 Primary Term 机制来保证数据一致性:
- Sequence Number:每个文档操作都有一个严格递增的序列号,用于标识操作的顺序
- Primary Term:每次 Primary 切换时递增,用于区分不同的 Primary 任期
- Checkpoints:每个副本维护本地 Checkpoint(已确认的最大 Sequence Number)
当 Primary 切换时:
- 新 Primary 从 Master 获取最新的 Allocation ID 和 Primary Term
- 新 Primary 接收写入时,会检查 Sequence Number 是否连续
- 如果发现空洞(缺失的序列号),会拒绝写入并请求副本同步
- 副本恢复时,基于 Sequence Number 进行增量同步,而非全量复制
这个机制解决了之前版本可能出现的"已删除文档复活"问题,确保操作的线性一致性。
ES 对 PacificA 的变体实现
ES 的复制模型是 PacificA 的变体:
| PacificA 原始 | ES 变体 |
|---|---|
| Configuration Manager | Master Node |
| Primary | Primary Shard |
| Secondary | Replica Shard |
| Write-All(所有副本) | Write to In-Sync Copies(同步副本集) |
| 单一 Lease | 基于 Cluster State 的分片分配 |
关键区别:ES 使用 In-Sync Copies 而非严格的 Write-All。如果某个 Replica 响应太慢或故障,Master 会将其从 In-Sync 集合中移除,写入不再等待它。
Part 4: Kafka
Partition 与 Replica
Kafka 的数据组织:
1 | |
每个 Partition 是一个有序的、不可变的消息序列(日志)。每个 Partition 有一个 Leader 和零个或多个 Follower。
ISR 机制详解
ISR(In-Sync Replicas) 是 Kafka 复制模型的核心概念:
- ISR 是一个动态维护的副本集合,包含与 Leader 保持同步的所有副本
- 判断标准:Follower 的复制延迟不超过
replica.lag.time.max.ms(默认 30 秒) - 如果 Follower 落后太多,会被从 ISR 中移除
- 当 Follower 追上 Leader 后,会被重新加入 ISR
ISR 的动态性使得 Kafka 在一致性和可用性之间取得了灵活的平衡。
replica.lag.time.max.ms 参数调优建议
replica.lag.time.max.ms 控制 Follower 被踢出 ISR 的超时时间,调优建议:
| 场景 | 推荐值 | 理由 |
|---|---|---|
| 标准生产环境 | 30000(默认) | 平衡一致性和可用性,容忍短暂的网络抖动 |
| 低延迟网络 | 10000-15000 | 更快检测故障副本,减少 ISR 收缩时间 |
| 高延迟或跨数据中心 | 60000-120000 | 容忍更高的网络延迟,避免频繁 ISR 抖动 |
| 批处理场景 | 300000+ | 允许更长的追赶时间,最大化可用性 |
注意事项:
- 设置过小(< 5000ms)会导致网络抖动时频繁 ISR 收缩,影响吞吐
- 设置过大(> 300000ms)会导致故障检测延迟,延长数据不一致窗口
- 应配合
min.insync.replicas使用,确保即使 ISR 收缩仍有足够副本保证持久性
acks 参数的三种模式
Producer 的 acks 参数控制写入的持久性保证:
| acks 值 | 含义 | 持久性 | 延迟 | 吞吐量 |
|---|---|---|---|---|
| 0 | 不等待任何确认 | 最低(可能丢数据) | 最低 | 最高 |
| 1 | 等待 Leader 确认 | 中等(Leader 故障可能丢数据) | 中等 | 中等 |
| all (-1) | 等待所有 ISR 副本确认 | 最高 | 最高 | 最低 |
acks=all 配合 min.insync.replicas=2(至少 2 个 ISR 副本)是生产环境推荐的配置。
Controller 的角色
Kafka 集群中有一个 Controller(由某个 Broker 担任),负责:
- 监控 Broker 的上下线
- 管理 Partition 的 Leader 选举
- 管理副本的 ISR 变更
- 处理 Topic 的创建和删除
Unclean Leader Election
当 ISR 中所有副本都故障时,Kafka 面临一个艰难的选择:
| 配置 | 行为 | 权衡 |
|---|---|---|
unclean.leader.election.enable=false(默认) |
Partition 不可用,等待 ISR 中的副本恢复 | 保证一致性,牺牲可用性 |
unclean.leader.election.enable=true |
允许非 ISR 副本成为 Leader | 保证可用性,可能丢数据 |
KRaft 模式:去 ZooKeeper 化
Kafka 3.x 引入了 KRaft(Kafka Raft) 模式,用内置的 Raft 协议替代 ZooKeeper:
| 维度 | ZooKeeper 模式 | KRaft 模式 |
|---|---|---|
| 元数据管理 | 外部 ZooKeeper 集群 | 内置 Raft 协议 |
| 运维复杂度 | 需要维护两套集群 | 只需维护 Kafka |
| 元数据延迟 | 通过 ZooKeeper 间接通信 | 直接在 Broker 间同步 |
| 扩展性 | 受 ZooKeeper 限制 | 更好的扩展性 |
高水位与 Leader Epoch
高水位(High Watermark, HW) 是 Kafka 保证一致性的关键机制:
- HW 表示所有 ISR 副本都已复制到的最大偏移量
- 消费者只能读取 HW 之前的消息
- Leader 故障切换时,新 Leader 会将日志截断到 HW
Leader Epoch 解决了 HW 机制的一个边界问题:
- 每次 Leader 切换时,Epoch 递增
- Follower 恢复时,先向 Leader 查询自己 Epoch 对应的结束偏移量
- 避免了基于 HW 截断可能导致的数据不一致
graph TD
Controller[Controller<br/>Partition 管理]
L[Leader Broker<br/>接受读写] --> F1[Follower 1<br/>ISR 成员]
L --> F2[Follower 2<br/>ISR 成员]
Controller -.->|Leader 选举| L
Controller -.->|ISR 管理| F1
Controller -.->|ISR 管理| F2
Part 5: Pulsar
存算分离架构
Pulsar 的最大特点是存算分离:计算层(Broker)和存储层(BookKeeper)完全解耦。
graph TD
ZK[ZooKeeper<br/>元数据管理]
B1[Broker 1<br/>无状态] --> BK1[Bookie 1]
B1 --> BK2[Bookie 2]
B1 --> BK3[Bookie 3]
B2[Broker 2<br/>无状态] --> BK1
B2 --> BK2
B2 --> BK3
ZK -.-> B1
ZK -.-> B2
ZK -.-> BK1
ZK -.-> BK2
ZK -.-> BK3
| 组件 | 角色 | 状态 |
|---|---|---|
| Broker | 接受客户端请求,处理消息路由 | 无状态——不存储任何消息数据 |
| Bookie(BookKeeper) | 存储消息数据 | 有状态——持久化存储 |
| ZooKeeper | 管理元数据、服务发现 | 有状态 |
Ledger 与 Fragment
Pulsar 的消息存储使用 BookKeeper 的 Ledger 概念:
- Ledger:一个有序的、不可变的日志段。一个 Topic 的消息被分成多个 Ledger
- Fragment:一个 Ledger 内部的分段。当 Bookie 故障时,新的 Fragment 会被分配到其他 Bookie
- Entry:Ledger 中的一条记录(对应一条或一批消息)
Bookie Ensemble 的写入机制
BookKeeper 使用 Ensemble 机制来分散写入负载:
- Ensemble Size (E):参与存储一个 Ledger 的 Bookie 总数
- Write Quorum (WQ):每条 Entry 写入的 Bookie 数量
- Ack Quorum (AQ):需要确认的 Bookie 数量
例如,E=5, WQ=3, AQ=2:
- 每条消息写入 5 个 Bookie 中的 3 个(通过 Round-Robin 选择)
- 其中 2 个确认即返回成功
这种设计比 Kafka 的 ISR 更灵活:
- 写入负载分散到更多节点
- 不需要所有副本都在线
- 单个 Bookie 故障不影响写入
Journal 与 Ledger Storage 分离设计
BookKeeper 采用 Journal 和 Ledger Storage 分离的存储架构,这是 Pulsar 高性能的关键:
Journal(预写日志)
- 作用:类似数据库的 WAL,提供持久性和崩溃恢复能力
- 写入路径:所有 Entry 先顺序写入 Journal(追加写),再异步写入 Ledger Storage
- 特点:
- 严格的顺序写入,充分利用磁盘带宽
- 默认配置为 fsync 每次写入(保证持久性)
- Journal 文件大小固定(默认 1GB),写满后滚动
- 恢复流程:Bookie 重启时,先从 Journal 恢复未刷盘的 Entry,再应用到 Ledger Storage
Ledger Storage(数据文件)
- 作用:实际存储 Entry 数据,支持随机读取
- 类型:
- Interleaved Ledger Storage:所有 Ledger 的 Entry 混存(默认,兼容性好)
- Separate Ledger Storage:每个 Ledger 独立文件(读写隔离更好)
- 写入策略:异步批量写入,合并多个 Entry 的 fsync 操作
- 读取优化:基于索引文件的快速定位,支持从任意位置读取
性能优化
分离设计带来的性能优势:
- 写入优化:Journal 顺序写 + Ledger Storage 批量写,最大化吞吐
- 读取优化:Ledger Storage 支持并发读取,不受 Journal 限制
- 恢复优化:仅从 Journal 恢复少量未刷盘数据,启动快速
- 存储分层:Journal 可放在 SSD,Ledger Storage 放在 HDD,成本最优
配置建议:
- Journal:使用 SSD,配置
journalSyncData=true(强一致) - Ledger Storage:使用 HDD,配置
ledgerStorageClass=interleaved(默认)或separate(多租户场景)
Broker 故障恢复
由于 Broker 是无状态的,故障恢复极其简单:
- Broker 1 故障
- 其负责的 Topic 被重新分配给 Broker 2
- Broker 2 从 BookKeeper 读取元数据,开始服务
- 无需数据迁移——数据始终在 BookKeeper 中
恢复时间:秒级(对比 Kafka 的分钟级甚至小时级)。
Bookie 故障恢复
当一个 Bookie 故障时:
- BookKeeper 检测到 Bookie 不可用
- 当前 Ledger 的 Fragment 被关闭
- 新的 Fragment 被分配到其他健康的 Bookie
- 后台启动 Auto Re-replication:将故障 Bookie 上的数据复制到其他 Bookie
分层存储
Pulsar 支持分层存储(Tiered Storage):将冷数据自动卸载到廉价存储(如 S3、HDFS):
1 | |
这使得 Pulsar 可以保留无限期的消息历史,而不会耗尽本地存储。
Part 6: 横向对比与选型指南
完整对比表
| 维度 | PacificA 原始 | Elasticsearch | Kafka | Pulsar |
|---|---|---|---|---|
| 计算节点 | Primary | Data Node | Broker | Broker |
| 存储节点 | Secondary | Data Node(本地分片) | Broker(本地日志) | Bookie |
| 元数据节点 | Configuration Manager | Master Node | Controller (+ZK/KRaft) | ZooKeeper |
| 副本粒度 | 任意对象 | Shard | Partition | Ledger Fragment |
| 一致性协议 | 强一致(Write-All) | PacificA 变体(In-Sync) | ISR 机制 | Quorum + Ensemble |
| 存算分离 | 否 | 否 | 否(Tiered Storage 部分支持) | 是 |
| 故障恢复时间 | 秒-分钟级 | 分钟级 | 分钟-小时级 | 秒级 |
| 写入延迟 | 高(Write-All) | 中等 | 可调(acks) | 中等 |
| 运维复杂度 | 低 | 中等 | 中等(KRaft 后降低) | 高(三套组件) |
选型建议
| 场景 | 推荐系统 | 理由 |
|---|---|---|
| 全文搜索 + 日志分析 | Elasticsearch | Lucene 生态,强大的搜索和聚合能力 |
| 高吞吐消息队列 | Kafka | 成熟稳定,生态丰富,社区庞大 |
| 多租户消息平台 | Pulsar | 存算分离,天然支持多租户和弹性伸缩 |
| 强一致性存储 | PacificA 变体 | 简单直观,适合自研存储系统 |
| 云原生消息系统 | Pulsar | Broker 无状态,天然适合 Kubernetes |
| 流处理 + 消息队列 | Kafka | Kafka Streams / ksqlDB 生态 |
附录:Raft 与 Paxos 简介
为什么 Raft 比 Paxos 更流行
Paxos 是 Leslie Lamport 在 1989 年提出的分布式共识算法,是理论上最早的共识协议之一。但 Paxos 以难以理解和实现著称。
Raft 是 Diego Ongaro 和 John Ousterhout 在 2014 年提出的,其设计目标就是可理解性。Raft 将共识问题分解为三个子问题:
- Leader 选举:通过随机超时和投票机制选出 Leader
- 日志复制:Leader 将日志条目复制到 Follower
- 安全性:保证已提交的日志不会被覆盖
Raft vs PacificA
| 维度 | Raft | PacificA |
|---|---|---|
| Leader 选举 | 内置(随机超时 + 投票) | 外部(Configuration Manager) |
| 日志复制 | Quorum(多数派确认) | Write-All(所有副本确认) |
| 读取 | 默认需要经过 Leader | 只从 Primary 读取 |
| 适用场景 | 元数据管理、配置存储 | 数据复制、日志存储 |
| 代表实现 | etcd、CockroachDB、TiKV | Elasticsearch、Kafka(ISR 变体) |
Raft 更适合元数据管理(少量数据、强一致性),而 PacificA 更适合数据复制(大量数据、高吞吐)。





