前阵子帮一位朋友排查一个 ClickHouse 集群的故障现象是某个副本节点的磁盘满了写入直接报错但业务侧几乎无感知——因为流量打到其他副本上数据在后台慢慢补齐了。朋友问我数据到底是怎么在多个副本之间自动同步的为什么分布式表查起来像一个超级大表这两个问题恰好对应 ClickHouse 里最核心、也最容易混淆的两个概念Replicated*MergeTree 复制表以及 Distributed 分布式表。这篇内容会从原理到实操把复制表如何借助 ZooKeeper或 ClickHouse Keeper协调副本、分布式表如何作为“路由层”把查询分发给各个分片这两条主线讲透。同时结合我平时维护集群遇到的真实问题给出可落地的排查思路和配置建议。不管是刚接触 ClickHouse 的开发者还是正在设计集群架构的运维/数据工程师这篇文章都值得看完。1. 复制表 vs 分布式表两种完全不同的扩展思路1.1 先厘清“分片”和“副本”这两个基本概念很多初学者会把复制表和分布式表混为一谈觉得它们都是“多台机器一起工作”。实际上这俩解决的维度完全不同。“副本”解决的是高可用和冗余问题指的是同一份数据在多台机器上各存一份任何一台挂了其他机器上的数据还能顶上来。数据内容是相同的目的是防止单点故障。“分片”解决的是扩展性和容量问题指的是把一份大数据集按某种规则切分成多份分别存放在不同的机器上每台机器只保存其中一部分数据。数据内容是各不相同的目的是分摊存储和计算压力。这两个维度是正交的可以独立使用也可以组合。生产环境里最经典的做法就是每个分片内部用 Replicated 复制表保证多副本分片之间再用 Distributed 分布式表对外暴露统一访问入口。这也就是标题里两个概念总是同时出现的原因——它们天然是一对搭档。1.2 复制表家族加了个前缀复制机制就来了ClickHouse 的复制能力不是靠集群软件去镜像整个实例而是把“复制”这个能力内嵌在表引擎里。任何 MergeTree 家族的表引擎只要加上Replicated前缀就获得了多副本同步能力ReplicatedMergeTreeReplicatedReplacingMergeTreeReplicatedSummingMergeTreeReplicatedAggregatingMergeTreeReplicatedCollapsingMergeTree注意这些表之间复制的底层机制完全一样区别只在于合并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*MergeTreeZooKeeper 里的元数据布局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 AB 得到 AB而 node2 可能合并了 part BC 得到 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_quorum2表示 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副本是否只读0is_session_expiredZK 会话是否过期0queue_size待处理日志条数0log_max_index日志最大索引持续增长log_pointer当前处理到的日志位置接近 log_max_indexfuture_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 hostnode1/host port9000/port /replica replica hostnode2/host port9000/port /replica /shard shard replica hostnode3/host port9000/port /replica replica hostnode4/host port9000/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 ...)会导致子查询在每个分片分别执行一次如果子查询里读的是另一个分布式表就可能导致重复执行甚至结果错误。解决方案是在子查询前加GLOBALClickHouse 会把子查询结果物化到发起节点广播给各分片使用。多分片大集群上这是比较容易踩的坑查询变慢时优先检查这里。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_sync1。但注意这个参数会极大降低写入吞吐不要全局开启。小表不要用复制表直接在 Distributed 表的每个分片节点上建普通 MergeTree。复制表的 ZK 路径、日志、协调本身有开销对几百 MB 的小维度表来说完全不值得。一张复制表在 ZK 上的节点数量是几十上百个如果你建了几百张小复制表光 ZK 压力就够受的。分片键和分区键千万别图省事随手写。分片键错了还可以理解数据倾斜处理起来得重导分区键太细更是会导致 part 碎片化Merge 永远追不上一秒几万的写入速度。做了这么久 ClickHouse 运维我最大的体会是复制表和分布式表这两个机制本质上是为了把“分布式系统的复杂度”从业务代码里抽走——业务只需要看到一张逻辑大表而多副本一致性、分片路由、故障转移这些脏活累活全部交给 ClickHouse 内核去处理。但你仍然需要理解它的运行机制否则遇到故障只能对着日志发懵。一个小技巧作为收尾排查复制问题时把queue_size和future_parts这两个指标一起看。如果queue_size高但future_parts为 0说明日志积压在 ZK 侧还没开始下载优先排查 ZK 健康度或副本连接状态如果future_parts也高说明正在大量下载 part重点看网络和磁盘。这个细节在平时排障时非常有价值能让定位方向快很多。