第4章:副本机制原理——什么是副本、ReplicatedMergeTree 架构、ZooKeeper 在副本中的作用

副本,说白了就是数据的「备份」。但 ClickHouse 的副本,不是简单的拷贝粘贴。它要解决一个核心问题:多台机器上的数据,怎么保持一致?

我刚开始接触 ClickHouse 时,以为副本就是主从复制那一套。后来才发现,它走的是另一条路——多主架构。每台机器都可以写,数据通过日志同步。嗯,这里面的门道不少。

4.1 什么是副本

副本,就是同一份数据,在多个节点上各存一份。目的很明确:

  • 高可用:一台挂了,另一台顶上
  • 读扩展:查询可以分散到多个副本上
  • 数据冗余:防止磁盘损坏丢数据

但 ClickHouse 的副本,和 MySQL、Kafka 的副本不太一样。我列个表,你一看就明白:

特性 ClickHouse 副本 MySQL 主从 Kafka 分区副本
写入方式 多主(所有副本都可写) 单主(仅主库可写) 单主(仅 Leader 可写)
一致性模型 最终一致性 最终一致性(异步) 强一致性(ISR)
数据同步 日志回放 + 合并树 Binlog 回放 日志复制
依赖组件 ZooKeeper 无(或半同步插件) 内置 Raft

核心要点:ClickHouse 的副本,不是「主从」,而是「对等」。每个副本都能独立接收写入,然后通过 ZooKeeper 协调,把数据同步给其他副本。

你可能会问:多主写入,不会冲突吗?

会的。但 ClickHouse 的设计很巧妙——它用 分片 + 分区键 来规避冲突。同一行数据,只会落到一个分片的一个分区里。副本之间同步的是 数据块(Part),不是单行记录。所以冲突概率极低。

我在项目中遇到过一个问题:两个副本同时写入同一张表,结果数据重复了。后来排查发现,是客户端没做幂等处理。嗯,这个坑后面会细讲。

4.2 ReplicatedMergeTree 架构

ReplicatedMergeTree,是 ClickHouse 最核心的副本引擎。它不是一个新的存储引擎,而是 MergeTree 的「副本包装器」。

说白了,你建表时指定 ENGINE = ReplicatedMergeTree,底层还是 MergeTree 在存数据,只是多了副本同步的逻辑。

它的架构,我画了一张图:

ReplicatedMergeTree 架构图 ZooKeeper 集群 副本 1 (clickhouse-01) ReplicatedMergeTree 逻辑 MergeTree 存储引擎 数据 Part 目录 副本 2 (clickhouse-02) ReplicatedMergeTree 逻辑 MergeTree 存储引擎 数据 Part 目录 日志同步 客户端写入 每个副本独立写入,通过 ZooKeeper 协调日志序列,异步同步数据 Part

从图上你能看到:

  • 上层:ReplicatedMergeTree 逻辑层,负责和 ZooKeeper 通信
  • 下层:MergeTree 存储引擎,真正管数据的读写合并
  • 中间:通过 ZooKeeper 交换日志,驱动数据同步

建表时,你需要指定两个参数:

CREATE TABLE my_table (
    id UInt32,
    name String,
    ts DateTime
) ENGINE = ReplicatedMergeTree(
    '/clickhouse/tables/{shard}/my_table',  -- ZooKeeper 路径
    '{replica}'                              -- 副本名称
)
PARTITION BY toYYYYMM(ts)
ORDER BY id;

这里要注意:

  • ZooKeeper 路径:所有副本必须用同一个路径,才能互相发现
  • 副本名称:每个副本必须唯一,通常用 {replica} 宏自动替换

我的习惯:ZooKeeper 路径里带上分片信息,比如 /clickhouse/tables/{shard}/table_name。这样不同分片用不同路径,避免冲突。我曾经见过有人所有分片用同一个路径,结果数据全混了……

4.3 ZooKeeper 在副本中的作用

ZooKeeper 是 ClickHouse 副本的「大脑」。没有它,副本之间就是瞎子。

它的核心职责有三个:

  1. 元数据协调:记录谁有哪些数据块(Part)
  2. 日志序列管理:分配全局递增的日志序号
  3. 故障检测:监控副本是否存活

具体流程是这样的:

步骤 操作 ZooKeeper 角色
1 客户端写入副本 A 记录日志条目
2 副本 A 生成数据 Part 记录 Part 元数据
3 副本 B 从 ZK 拉取日志 提供日志序列
4 副本 B 下载 Part 记录同步状态
5 两个副本 Part 一致 更新完成标记

你想想看,如果没有 ZooKeeper,副本 A 写了一条数据,副本 B 怎么知道?

它只能定期去扫 A 的磁盘。但这样效率低,还容易漏。ZooKeeper 提供的是一个 可靠的、有序的、实时的 通知通道。

关键点:ZooKeeper 不存数据本身,只存元数据和日志指针。数据 Part 还是通过 HTTP 或 TCP 在副本之间直接传输。

我遇到过一个问题:ZooKeeper 压力太大,导致副本同步延迟。排查后发现,是因为表太多,ZK 节点数暴涨。后来我做了两件事:

  • 合并小表,减少 ZK 路径数量
  • 给 ZK 单独部署,不和 ClickHouse 混部

效果立竿见影。

4.4 副本同步的详细过程

咱们把同步过程拆开来看。假设有两个副本:replica_1 和 replica_2。

第一步:写入触发

客户端向 replica_1 写入一批数据。ClickHouse 先把数据写入内存缓冲区,然后刷盘生成一个不可变的数据 Part。

第二步:ZK 注册

replica_1 在 ZooKeeper 上创建一个日志节点,内容大致是:

/clickhouse/tables/0/my_table/log/log-0000000042
内容:{"part_name": "202501_42_42_0", "replica": "replica_1"}

第三步:通知其他副本

ZooKeeper 的 Watcher 机制会通知 replica_2:有新的日志条目。

第四步:拉取并下载

replica_2 从 ZK 读到日志,知道 replica_1 生成了 Part 202501_42_42_0。然后它通过 HTTP 从 replica_1 下载这个 Part。

第五步:本地生效

replica_2 把 Part 放到自己的数据目录,更新元数据。至此,两个副本数据一致。

注意:如果 replica_2 下载失败,它会重试。重试多次仍失败,Part 会标记为「损坏」,需要人工介入。我曾经因为磁盘空间不足,导致 Part 下载一半失败,ZK 里留下一堆脏数据。清理起来很麻烦。

4.5 副本的容错与恢复

副本最大的价值,就是容错。咱们看看几种常见故障场景:

故障场景 影响 恢复方式
单副本宕机 查询切到其他副本 重启后自动同步缺失的 Part
ZK 短暂不可用 副本无法同步,但本地写入正常 ZK 恢复后自动续传
磁盘损坏 数据丢失 从其他副本全量同步
网络分区 部分副本孤立 网络恢复后自动合并

这里有个细节:副本宕机期间,本地写入的数据不会丢。因为写入是先写本地磁盘,再同步到 ZK。等副本恢复后,它会从 ZK 拉取自己缺失的日志,然后从其他副本下载对应的 Part。

但要注意一种情况:如果副本宕机时间太长,ZK 上的日志被清理了(默认保留 100 条),那恢复时就需要全量同步。全量同步很慢,尤其是大表。

我的建议:生产环境把 ZK 的日志保留时间调长一些。我一般设成 7 天,配合定时巡检,基本不会触发全量同步。

4.6 副本的局限性

副本不是万能的。有几个地方你要心里有数:

  • 不解决写入热点:如果所有写入都打到一个副本,其他副本还是空闲的。需要配合分片来分散写入压力。
  • 不解决查询延迟:副本同步是异步的,刚写入的数据可能在其他副本上查不到。对一致性要求高的场景,要用 insert_distributed_sync 或查询时指定 SETTINGS select_sequential_consistency=1。
  • 不替代备份:副本是冗余,不是备份。误删数据,所有副本都会删。定期备份还是得做。

我记得有一次,运维同学误执行了 DROP TABLE,所有副本瞬间全没了。还好有备份,不然就凉了。从那以后,我坚持「副本 + 备份」双保险。

4.7 小结

副本机制,是 ClickHouse 高可用的基石。它的核心思路是:

  • 多主写入,通过 ZooKeeper 协调
  • 异步同步,最终一致
  • 自动容错,故障恢复

你只要记住:ReplicatedMergeTree = MergeTree + ZooKeeper 协调。理解了这一点,后面讲分片、讲集群部署,就顺理成章了。