第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 通信
- 下层: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 副本的「大脑」。没有它,副本之间就是瞎子。
它的核心职责有三个:
- 元数据协调:记录谁有哪些数据块(Part)
- 日志序列管理:分配全局递增的日志序号
- 故障检测:监控副本是否存活
具体流程是这样的:
| 步骤 | 操作 | 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 协调。理解了这一点,后面讲分片、讲集群部署,就顺理成章了。