☰
ClickHouse复制表与分布式表:原理、实践与故障排查
2026/10/17 6:03:45 网站建设 项目流程

前阵子帮一位朋友排查一个 ClickHouse 集群的故障,现象是某个副本节点的磁盘满了,写入直接报错,但业务侧几乎无感知——因为流量打到其他副本上,数据在后台慢慢补齐了。朋友问我,数据到底是怎么在多个副本之间自动同步的?为什么分布式表查起来像一个超级大表?这两个问题,恰好对应 ClickHouse 里最核心、也最容易混淆的两个概念:Replicated*MergeTree 复制表,以及 Distributed 分布式表。

这篇内容会从原理到实操,把复制表如何借助 ZooKeeper(或 ClickHouse Keeper)协调副本、分布式表如何作为“路由层”把查询分发给各个分片这两条主线讲透。同时结合我平时维护集群遇到的真实问题,给出可落地的排查思路和配置建议。不管是刚接触 ClickHouse 的开发者,还是正在设计集群架构的运维/数据工程师,这篇文章都值得看完。

1. 复制表 vs 分布式表:两种完全不同的扩展思路

1.1 先厘清“分片”和“副本”这两个基本概念

很多初学者会把复制表和分布式表混为一谈,觉得它们都是“多台机器一起工作”。实际上这俩解决的维度完全不同。

“副本”解决的是高可用和冗余问题,指的是同一份数据在多台机器上各存一份,任何一台挂了,其他机器上的数据还能顶上来。数据内容是相同的,目的是防止单点故障。

“分片”解决的是扩展性和容量问题,指的是把一份大数据集按某种规则切分成多份,分别存放在不同的机器上,每台机器只保存其中一部分数据。数据内容是各不相同的,目的是分摊存储和计算压力。

这两个维度是正交的,可以独立使用,也可以组合。生产环境里最经典的做法就是:每个分片内部用 Replicated 复制表保证多副本,分片之间再用 Distributed 分布式表对外暴露统一访问入口。这也就是标题里两个概念总是同时出现的原因——它们天然是一对搭档。

1.2 复制表家族:加了个前缀,复制机制就来了

ClickHouse 的复制能力不是靠集群软件去镜像整个实例,而是把“复制”这个能力内嵌在表引擎里。任何 MergeTree 家族的表引擎,只要加上Replicated前缀,就获得了多副本同步能力:

  • ReplicatedMergeTree
  • ReplicatedReplacingMergeTree
  • ReplicatedSummingMergeTree
  • ReplicatedAggregatingMergeTree
  • ReplicatedCollapsingMergeTree

注意,这些表之间复制的底层机制完全一样,区别只在于合并(Merge)时数据怎么聚合。也就是说,只要你理解了 ReplicatedMergeTree 的同步原理,其他 Replicated 系列也就是顺势换一层合并逻辑的事。这也是为什么题目里写成“Replicated*MergeTree”——星号代表任意后缀。

1.3 复制表到底在复制什么?是“动作”而不是“数据流”

这是理解复制表最重要的一个认知转变。

ClickHouse 的复制不像 MySQL 那样传输 binlog 或者传输实际的数据变更流,它采用的是**“日志通知 + 数据拉取”**的模式。简单来说:

  • 某个副本先在自己本地写入数据,形成一个不可变的数据片段(part)。
  • 它把这个 part 的“元信息”(part 名称、校验值、所属分区等)写进 ZooKeeper 的日志节点。
  • 其他副本通过监听 ZooKeeper 上的日志,发现有新 part 出现,就主动从源副本那里把数据文件下载到本地,然后挂载到自己本地表上。

用生活化的类比:A 仓库收到了一批新货,它在公告栏贴了一张条子“东侧 3 号位有这批货”。B 仓库和 C 仓库看到条子后,各自开着自己的卡车去 A 仓库把那批货拉回自己仓库,然后放进自己的货架。

所以,ZK 上传输的不是数据本身,只是“通知”。真正的数据流发生在副本与副本之间的直接 HTTP 通信上。这带来的一个直接效果是:如果 ZK 偶发抖动,数据同步不会丢,最多是延迟;但如果 ZK 长时间不可用,副本会进入只读状态,因为新数据无法协调了。

2. 深入 Replicated*MergeTree:ZooKeeper 里的元数据布局

2.1 ZooKeeper(或 ClickHouse Keeper)扮演什么角色

复制表必须依赖一个外部协调组件来协调多个副本。ClickHouse 官方推荐的是 ZooKeeper,新版本也可以用内置的 ClickHouse Keeper(相当于一个回归在 C++ 里的兼容 ZooKeeper 协议的服务)。没有它,复制表连建表都建不了。

ZK 看起来是一个分布式的 key-value 树,ClickHouse 会在它上面为每张复制表维护一套固定的路径。

2.2 一张复制表在 ZK 上的路径结构

假设集群名是my_cluster,数据库是default,表名是test_local,那么这张表的 ZK 元数据路径大致长这样:

/clickhouse/tables/{shard_id}/{table_uuid}/ ├── metadata │ ├── log_pointer │ └── columns ├── replicas │ ├── replica1 │ ├── replica2 └── log ├── mutations └── blocks ├── quorum ├── leader_election

每个路径都有自己的职责:

  • metadata:记录表结构、列定义、复制配置。节点启动时读取这里,如果没有就会从其他副本拉取。
  • replicas:下面每个子节点代表一个活动副本。副本启动时在这里注册写入自己的 host、端口等连接信息。其他副本靠这里知道“从哪里拉数据”。
  • log:复制日志队列。每次有新的 part 或者分片操作,都会在这里追加一条日志。这是整个复制机制的核心。
  • blocks:用于去重。插入时,ClickHouse 会在这里记录已经插入过的数据块编号(block_id),防止重复插入。
  • mutations:记录 ALTER UPDATE 或 DELETE 操作。与普通日志分开,避免大量 mutations 影响正常 part 同步。
  • quorum:配合insert_quorum参数使用,用于强一致插入。
  • leader_election:用于选举“合并且协调者”的节点。合并任务的发起会在这里协调。

这一套路径直接决定了你排查问题时看什么。比如log_pointer表示当前副本已经处理到 log 的哪个位置,而queue_size表示待处理条目数,这两个值是判断副本是否落后的关键。

2.3 为什么不需要远程同步表结构

很多第一次用复制表的同学会疑惑:我创建了一张复制表,怎么另一个节点上就自动有了?原因就在于副本启动时会通过 ZK 上的metadata路径比对表结构,如果本地没有,它会从其他副本拉取并创建本地表。所以复制表本质上把“表结构同步”和“数据同步”统一管理了——这比手动在每台机器执行建表语句要省事得多,但前提是建表时ReplicatedMergeTree引擎参数中的 ZooKeeper 路径必须一致,否则会当成两张不同的表。

这里有一个常见的坑:如果你在两个节点上分别执行了同一张复制表的建表语句,只是 ZK 路径冲突,ClickHouse 会直接报错。如果是 UUID 方式建表(新版本默认),则用 UUID 作为路径,天然避免了这个冲突。

3. 复制表工作流程:从一次 INSERT 到多副本一致的完整链路

3.1 写入路径拆解:一个 INSERT 如何变成多个副本上的 part

我用一个具体例子来说明。假设集群里有node1和node2两个节点,表是ReplicatedMergeTree,现在往node1插入一批数据:

第一步,node1的写入线程把数据按分区规则拆成若干个数据块(每个分区生成一个 part),临时写入本地磁盘,格式是临时目录。

第二步,part 写入完成并 fsync 到磁盘后,node1把 part 的元信息(分区、part 名称、校验和)打包成一个“log 条目”,追加到 ZK 的/log节点下。同时把自己的log_pointer指针推进。

第三步,ZK 通知node2的复制后台线程:有新的日志条目了。node2的复制后台线程从/log读取该条目,解析出“数据在 node1 上”这个信息。

第四步,node2通过 HTTP 向node1发起数据拉取请求,校验 part 的 checksum,然后把下载的数据文件放到自己的临时目录,再挂载到本地表上,更新自己的log_pointer。

第五步,node1的写入操作此时可能已经返回给客户端了(如果没设置insert_quorum)。也就是说,写入返回并不代表所有副本都已经有了数据,这是一个异步最终一致过程。

这就是为什么你在一个副本上插入后立刻去另一个副本查询,有时会查不到最新数据——数据还在同步的路上。

3.2 合并(Merge)过程:每个副本独立执行但结果一致

MergeTree 家族有一个核心特性:后台会不断把小的 part 合并成大的 part。这个合并操作在复制表里是怎么协调的呢?

每个副本的后台合并线程会定期查看自己的“可合并 part 集合”,如果满足合并条件(比如相邻分区、大小合适),就会通过 ZK 的leader_election参与“合并选举”。实际上 ClickHouse 对合并任务做了去重,同一个 part 集合不会被两个副本同时合并。

合并完成后,产生的新 part 会被当作“新数据”一样,写入一条 log 条目广播给所有副本。其他副本收到后,下载这个合并后的新 part,然后把参与合并的旧 part 标记为“过期”,等待清理。

这里有个很多资料都没讲透的细节:不同副本上的合并历史可能是不一样的。比如 node1 合并了 part A+B 得到 AB,而 node2 可能合并了 part B+C 得到 BC。这没关系,因为每当一个副本产生新的合并结果 AB,它都会广播,其他副本会把 AB 下载下来;如果后面某个副本又基于 AB 产生了更大的合并结果 ABC,它同样会广播。最终,所有副本都会朝着“合并到大 part”的方向收敛,数据集合完全一致。

这背后的核心保障是:part 是不可变对象,一旦生成就不会再修改;所有变更都是“新 part 产生 + 旧 part 失效”。这就让多副本之间的同步变成了一个纯粹的新对象分发问题,而不是复杂的增量数据更新问题。这也是 ClickHouse 复制机制能做得这么简洁的根本原因。

3.3 插入去重:防止 INSERT 重试导致的数据重复

凡是做数据写入的,都遇到过“客户端请求超时重试”的情况。如果没有去重机制,一次 INSERT 被业务系统重试两次,数据就会翻倍。

ClickHouse 的去重其实是一种“块级幂等”:对同一次 INSERT 操作生成的一个数据块(block),记录它的唯一标识。这个标识由分区信息、块内最小 block number 和最大 block number 组合而来。

实际流程是:node1在把数据落盘后、写 log 之前,先去 ZK 的/blocks路径下检查该 block_id 是否已存在。如果不存在,就占坑写一个节点,然后继续;如果存在,说明之前已经插入过成功的数据了,直接丢弃这次的写入并返回成功。这样客户端重试时,数据不会重复。

要注意,这个去重粒度是“块”,不是“行”。如果你在同一个 INSERT 里插入的数据量大到被拆成多个 block,那么每个 block 都有不同的 block_id;如果是跨分区插入,也是每个分区一个 block_id。所以去重机制对“同一批次插入”的保护是有效的,但如果两次不同的 INSERT 插入完全相同的业务数据,去重机制是无法识别的。

3.4 quorum:当你需要同步等待多副本时

默认情况下,写入一个副本就返回成功,这在多数场景下足够。但如果你要求强一致——比如写入必须确保至少 N 个副本都拿到了数据,才能返回成功——可以设置insert_quorum参数。

比如insert_quorum=2,表示 INSERT 必须等到至少 2 个副本确认写入成功,客户端才会收到成功响应。其原理是:写入节点在写 log 后,会等 ZK 的quorum节点上记录到足够数量的副本确认,才返回成功。

这带来了一个好处:读多副本时能保证读到刚写入的数据(配合select_sequential_consistency参数读)。但代价是写入延迟显著增加,并加重 ZK 的压力。生产环境通常只在关键业务数据上使用,日志类等非关键数据不建议开。

3.5 实操验证:如何检查复制是否健康

建表语句示例(需要在每个副本节点上分别执行,或者通过集群 DDL 一次性执行):

CREATE TABLE default.test_local ON CLUSTER my_cluster ( id UInt64, name String, ts DateTime ) ENGINE = ReplicatedMergeTree('/clickhouse/tables/test_local', '{replica}') PARTITION BY toYYYYMM(ts) ORDER BY id;

这里的{replica}宏会在不同节点上自动替换成各自的副本名。

插入一点测试数据:

INSERT INTO default.test_local VALUES (1, 'a', now()), (2, 'b', now());

然后去任意副本节点执行:

SELECT database, table, is_readonly, is_session_expired, queue_size, log_max_index, log_pointer, future_parts, parts_to_check FROM system.replicas \G

关键字段含义如下:

字段含义正常表现
is_readonly副本是否只读0
is_session_expiredZK 会话是否过期0
queue_size待处理日志条数0
log_max_index日志最大索引持续增长
log_pointer当前处理到的日志位置接近 log_max_index
future_parts正在下载/等待合并的未来 part通常为 0 或很低
parts_to_check需要排查校验的 part0

如果queue_size长期大于 0,说明副本落后了,结合log_max_index和log_pointer能判断落后多少,再配合看future_parts判断是在下载数据还是卡在合并。

4. Distributed 分布式表:一个不存数据的逻辑路由层

4.1 分布式表的本质:一个只存在于逻辑层的“门面”

和复制表完全不同,Distributed 表自身不存储任何数据,它只是一个路由层、一个门面。查询 Distributed 表时,ClickHouse 会把查询拆分成多个子查询,下发给各个分片的本地表,然后把各分片返回的结果汇总起来;写入 Distributed 表时,数据会被暂存在本地的“插入队列”里,后台再异步发送到各个分片。

为了区分,通常会给表名加约定后缀:本地表叫test_local,分布式表叫test_all。这只是一种命名习惯,但强烈建议这样用,能避免在几十张表之间搞混。

4.2 集群配置与建表方式

分布式表依赖集群配置。在config.xml的remote_servers中定义一个集群:

<remote_servers> <my_cluster> <shard> <replica> <host>node1</host> <port>9000</port> </replica> <replica> <host>node2</host> <port>9000</port> </replica> </shard> <shard> <replica> <host>node3</host> <port>9000</port> </replica> <replica> <host>node4</host> <port>9000</port> </replica> </shard> </my_cluster> </remote_servers>

这是一个两分片、每分片两副本的经典结构。在这个集群上,每个节点创建自己的本地复制表:

CREATE TABLE default.test_local ON CLUSTER my_cluster ( id UInt64, name String, ts DateTime ) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/test_local', '{replica}') PARTITION BY toYYYYMM(ts) ORDER BY id;

注意这里的 ZK 路径用了{shard}宏,保证不同分片走不同的路径,避免冲突。然后创建分布表:

CREATE TABLE default.test_all ON CLUSTER my_cluster ( id UInt64, name String, ts DateTime ) ENGINE = Distributed('my_cluster', 'default', 'test_local', rand());

Distributed引擎四个参数分别是:集群名、库名、本地表名、分片键(sharding key)。

4.3 分片键选型:直接影响数据分布和查询性能

分片键决定了数据往哪个分片写,是分布式表最重要的一个设计决策。

  • rand():随机分片,写均匀但读无法裁剪。每个分片都有每类数据,适合日志、行为流水等无固定维度的场景。
  • hash(某列)或cityHash64(某列):同一维度的数据固定落在同一分片,比如按用户 ID 哈希。这样按用户 ID 的聚合查询可以只在一个分片执行,避免跨分片聚合。

选错分片键的代价是:要么数据不均匀,某分片膨胀;要么查询效率低,任何查询都要跨所有分片扫描。而且分片键一旦建表定了,改起来要重导数据,所以设计阶段多花点时间是值得的。

4.4 写入路径:异步发送与缓冲机制

往test_all插入数据时,实际上数据会先写到“发起节点”的本地,然后被拆分到不同分片的块,放进一个内部缓冲区(插入队列),后台线程再异步发送到目标分片的本地表。

这个机制有几个重要参数:

  • insert_distributed_sync:设为 1 时,INSERT 会同步等待数据发送完成才返回,适合需要立即读到的场景,但写性能会下降。
  • fsync_after_insert:插入队列在 fsync 后落盘,防止重启丢队列。
  • internal_replication:集群配置里每个 shard 可以设置。如果为true,则 Distributed 表写入时只把数据发送到其中一个副本,其他副本由 ReplicatedMergeTree 复制机制补齐;如果为false,则写入所有副本,配合复制表时可能产生重复(虽然复制表有 block 去重,但有一定条件限制)。生产上与 ReplicatedMergeTree 搭配时,强烈建议设为true。

这里有个实操提示:如果往 Distributed 表写入后立刻查询,偶尔会缺数据——那是异步队列还没发送完,不是丢了。可以通过system.distribution_queue表查看待发送数据量。如果积压过高,排查网络或目标节点负载。

4.5 查询路径:并行下发与结果聚合

查询test_all表时,发起节点会:

  • 根据load_balancing参数(默认random)选择一个副本作为每个分片的读取节点。
  • 把去掉了分布式表逻辑的底层查询发送到各分片的本地表上并发执行。
  • 各分片执行完返回结果,发起节点再做最终汇总。

如果查询条件里带有分片键上的等值条件,比如WHERE id = 100(而分片键是cityHash64(id)),ClickHouse 理论上可以只路由到对应分片执行,减少资源消耗。但要注意,这个裁剪是“尽力而为”的,不能 100% 依赖它来设计查询。

聚合场景下,分布式表会把聚合下推到各分片执行(比如GROUP BY先在每个分片做局部聚合),然后发起节点再做最终聚合。ClickHouse 有两阶段聚合机制,各分片返回的是中间状态,发起节点合并中间状态,内存压力和网络传输量都比“全量数据汇总”小得多。

关于GLOBAL JOIN:当子查询出现在 JOIN 或 IN 中时,普通IN (SELECT ...)会导致子查询在每个分片分别执行一次;如果子查询里读的是另一个分布式表,就可能导致重复执行甚至结果错误。解决方案是在子查询前加GLOBAL,ClickHouse 会把子查询结果物化到发起节点,广播给各分片使用。多分片大集群上这是比较容易踩的坑,查询变慢时优先检查这里。

4.6 本地优先与副本选择策略

Distributed 表查询时,每个分片可能对应多个副本。发起节点选择副本的核心逻辑是:

  • prefer_local_replica = 1时,优先选择本机上的副本,避免跨网络传输。
  • load_balancing控制策略:random随机选;nearest_hostname按主机名匹配最近节点;in_order按顺序轮询。

实际部署中,如果每个分片都有两个副本分布在两个机房,合理设置这些参数可以避免跨机房读放大。这些参数是全局配置,也可以在查询级别通过设置项覆盖。

5. 生产实战:分片+副本如何配合,常见故障怎么排查

5.1 一套可落地的标准架构

结合上面讲的东西,一个标准生产架构会是这样:

  • 物理节点按分片划分,每个分片有两个副本节点。
  • 每个分片内部用 Replicated 复制表建本地表,ZK 路径区分分片。
  • 每个分片建一个 Distributed 表指向本地表,对外提供统一查询入口。
  • 写入走 Distributed 表,或者由业务侧直接写本地表(配合负载均衡)。
  • 内部通过复制表的高可用能力保证单节点故障不影响读写。

建完这套结构后,建议做一次完整的故障演练:杀一个副本节点,观察查询是否正常、数据是否有丢失;再启动它,观察复制队列是否自动追平。这一步能帮你提前发现配置问题,而不是等到真正故障时手忙脚乱。

5.2 常见问题速查表

这里整理一份我从大量集群运维中总结出来的排查记录:

现象可能原因排查方向
复制表变只读,报 ZK 连接问题ZooKeeper 会话过期、网络分区查system.replicas的is_session_expired,检查 ZK 到 ClickHouse 的网络,必要时重启副本进程
副本数据长期落后网络带宽不足、磁盘慢、part 过多导致 Merge 跟不上查queue_size、future_parts,观察system.replication_queue
数据重复插入关闭了去重、客户端无限重试、block_id 冲突检查no_deduplication、insert_quorum、业务幂等设计
Distributed 表写入积压网络故障、目标节点负载过高、磁盘满查system.distribution_queue,必要时用SYSTEM FLUSH DISTRIBUTED手动刷
查询 Distributed 表特别慢子查询重复执行、未走 GLOBAL、分片键不可裁剪EXPLAIN 查看执行计划,检查GLOBAL JOIN/GLOBAL IN使用情况
Too many parts分区过于细粒度、Merge 跟不上写入速度优化分区键设计,调整 merge 线程数与并发力度
某个 part 校验失败磁盘损坏、下载中断查parts_to_check,整体重启副本或从其他副本强制拉取

5.3 监控与运维要点

  • 关注system.replicas里的queue_size、future_parts、is_readonly三个字段,做成监控指标。
  • 关注 ZK 自身的延迟和队列长度,复制表的所有协调都依赖它,ZK 抖动会直接表现为写入变慢、副本只读。
  • 关注system.metrics中DistributedFilesToInsert指标,长期上涨说明发送队列有积压。
  • part 数量是 ClickHouse 健康度的核心指标:单表 part 数超过 300 是很危险的信号,应该及时优化分区策略或调整 merge 参数。

5.4 一些运维上的心得和建议

写到这里,分享几条比较务实的经验:

副本数不是越多越好。两个副本已经覆盖绝大多数故障场景,三个副本通常只在跨机房容灾时才需要。副本越多,每个写入的日志通知和潜在的拉取耗时都会增加,存储成本也翻倍。我自己维护的集群大多数是两副本,少数核心业务三副本,足够了。

分布式表写入有延迟,业务侧接受不了“插入立即可查”的话,要么写本地表,要么设置insert_distributed_sync=1。但注意这个参数会极大降低写入吞吐,不要全局开启。

小表不要用复制表,直接在 Distributed 表的每个分片节点上建普通 MergeTree。复制表的 ZK 路径、日志、协调本身有开销,对几百 MB 的小维度表来说完全不值得。一张复制表在 ZK 上的节点数量是几十上百个,如果你建了几百张小复制表,光 ZK 压力就够受的。

分片键和分区键千万别图省事随手写。分片键错了还可以理解数据倾斜,处理起来得重导;分区键太细更是会导致 part 碎片化,Merge 永远追不上一秒几万的写入速度。

做了这么久 ClickHouse 运维,我最大的体会是:复制表和分布式表这两个机制,本质上是为了把“分布式系统的复杂度”从业务代码里抽走——业务只需要看到一张逻辑大表,而多副本一致性、分片路由、故障转移这些脏活累活,全部交给 ClickHouse 内核去处理。但你仍然需要理解它的运行机制,否则遇到故障只能对着日志发懵。

一个小技巧作为收尾:排查复制问题时,把queue_size和future_parts这两个指标一起看。如果queue_size高但future_parts为 0,说明日志积压在 ZK 侧,还没开始下载,优先排查 ZK 健康度或副本连接状态;如果future_parts也高,说明正在大量下载 part,重点看网络和磁盘。这个细节在平时排障时非常有价值,能让定位方向快很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询