
ClickHouse 集群扩容实战数据重分布过程中的查询可用性保障ClickHouse 集群扩容是件让人头疼的事——加节点容易但新节点上来之后数据要重分布resharding这个过程可能持续数小时甚至数天。期间如果查询打到还没迁移完的分片上要么返回不完整数据要么直接报错。业务方打开看板一看数据突然少了 30%电话就打过来了。今天我们来实战 ClickHouse 集群扩容重点讨论数据重分布过程中怎么保障查询可用性。一、ClickHouse 集群扩容的基本原理ClickHouse 用 ReplicatedMergeTree 引擎族做数据复制用分布式表Distributed 表做跨分片查询。扩容的本质是改变分片数量——比如从 2 分片扩到 4 分片数据需要从旧的 2 分片重分布到新的 4 分片。具体到节点层面扩容前集群由 ch-node-1 至 ch-node-4 组成 2 分片 2 副本结构扩容后新增 ch-node-5 至 ch-node-8形成 4 分片 2 副本结构。原有节点维持原有分片角色新节点加入后承担新增分片的副本职责数据随后在旧分片与新分片之间进行重分布。扩容流程关键步骤在配置文件中定义新的集群拓扑新增分片节点在新节点上创建本地表结构ReplicatedMergeTree创建新的分布式表指向新集群拓扑从旧分布式表迁移数据到新分布式表验证数据完整性后切换读写流量二、数据重分布的两种策略2.1 策略一INSERT SELECT 重分布最直接的方式——从旧分布式表读数据INSERT 到新分布式表ClickHouse 自动按新分片规则分配数据-- 第1步在新节点上创建本地表结构与旧表完全一致 CREATE TABLE IF NOT EXISTS user_events_local ON CLUSTER new_cluster_4shards ( event_date Date COMMENT 事件日期, event_type String COMMENT 事件类型, user_id UInt64 COMMENT 用户ID, event_time DateTime COMMENT 事件时间, duration UInt32 COMMENT 事件持续时间, channel LowCardinality(String) COMMENT 渠道 ) ENGINE ReplicatedMergeTree(/clickhouse/tables/{shard}/user_events_local, {replica}) PARTITION BY toYYYYMM(event_date) -- 按月分区 ORDER BY (event_date, event_type, user_id); -- 第2步创建新的分布式表 CREATE TABLE IF NOT EXISTS user_events_all ON CLUSTER new_cluster_4shards AS user_events_local ENGINE Distributed( new_cluster_4shards, -- 新集群名4分片 default, -- 数据库 user_events_local, -- 本地表名 rand() -- 分片键随机分配 ); -- 第3步数据重分布——从旧表读取INSERT到新分布式表 -- 按分区迁移避免一次性迁移全量数据导致内存爆炸 INSERT INTO user_events_all SELECT * FROM user_events_old_all WHERE event_date 2026-01-01 AND event_date 2026-02-01; -- 每次迁移一个月的数据循环执行直到全部迁移完优点逻辑简单ClickHouse 自动处理分片路由。缺点INSERT SELECT 期间新分布式表的数据不完整直接查询会丢失数据。2.2 策略二分区级无缝迁移更安全的策略——利用 ClickHouse 的分区 Attach 功能在迁移每个分区时做原子切换。具体流程如下选择待迁移分区确定当前需要迁移的分区例如 202601。旧表分离分区在旧节点上执行 DETACH 操作使分区变为离线状态不影响查询。数据文件复制将分区数据文件从旧节点复制到新节点。新表挂载分区在新节点上执行 ATTACH 操作恢复分区可用性。数据一致性验证检查新旧数据是否一致。若一致更新分布式表配置指向新集群。若不一致执行回滚将分区重新 ATTACH 回旧节点。循环执行完成当前分区后继续迁移下一个分区。-- 分区级迁移操作示例 -- 在旧节点上 DETACH 分区不影响查询分区变为离线状态 ALTER TABLE user_events_local ON CLUSTER old_cluster_2shards DETACH PARTITION 202601; -- 把分区数据文件从旧节点复制到新节点 -- ClickHouse 分区文件路径: /clickhouse/data/default/user_events_local/202601/ -- 使用 rsync 高效传输-- rsync -avz ch-node-1:/clickhouse/data/default/user_events_local/202601/-- ch-node-5:/clickhouse/data/default/user_events_local/-- 在新节点上 ATTACH 分区数据立即可查询ALTER TABLE user_events_local ON CLUSTER new_cluster_4shardsATTACH PARTITION 202601;## 三、查询可用性保障的三层防线 数据重分布过程中最大的挑战是查询怎么才能不中断、不丢数据 ### 3.1 第一层双分布式表并行查询 在迁移期间同时维护旧分布式表和新分布式表。查询走一个联合视图同时从两个表读数据然后取并集 sql -- 创建联合视图同时从新旧分布式表查询去重合并 CREATE VIEW user_events_union AS SELECT * FROM user_events_old_all -- 旧集群数据在逐步减少 UNION ALL SELECT * FROM user_events_new_all -- 新集群数据在逐步增加 -- 注意UNION ALL 可能产生重复数据正在迁移的分区同时出现在新旧两个表 -- 解决方案用 event_date user_id event_time 作为去重键更精确的去重方式-- 使用 ClickHouse 的 argMax 去重取最新版本的数据 CREATE VIEW user_events_safe AS SELECT event_date, event_type, user_id, event_time, duration, channel, argMax(event_type, _version) AS event_type_final, argMax(duration, _version) AS duration_final FROM ( SELECT *, 1 AS _version FROM user_events_old_all UNION ALL SELECT *, 2 AS _version FROM user_events_new_all ) GROUP BY event_date, user_id, event_time;3.2 第二层查询路由中间层在应用层实现查询路由——根据查询涉及的时间范围决定走旧表还是新表class ClickHouseQueryRouter: 查询路由器根据迁移进度决定查询走旧表还是新表 def __init__(self): # 记录已迁移完成的分区列表 self.migrated_partitions set() # 如 {202601, 202602, 202603} self.old_cluster_client ClickHouseClient(hostch-node-1, port9000) self.new_cluster_client ClickHouseClient(hostch-node-5, port9000) def route_query(self, sql, date_range): 根据查询的时间范围决定路由到旧集群还是新集群 date_range: {start: 2026-01-01, end: 2026-07-19} # 检查查询涉及的月份分区是否已迁移完 involved_months self._extract_months(date_range) if all(m in self.migrated_partitions for m in involved_months): # 所有涉及分区都已迁移 → 查询走新集群 return self.new_cluster_client.execute(sql) elif not any(m in self.migrated_partitions for m in involved_months): # 所有涉及分区都未迁移 → 查询走旧集群 return self.old_cluster_client.execute(sql) else: # 混合状态 → 需要走联合视图 union_sql sql.replace(user_events_all, user_events_safe) return self.new_cluster_client.execute(union_sql) def _extract_months(self, date_range): 从日期范围提取涉及的月份分区键 start date_range[start] end date_range[end] months set() current start while current end: months.add(current[:7].replace(-, )) # 2026-01 → 202601 # 下一个月 month int(current[5:7]) 1 year int(current[:4]) if month 12: year 1 month 1 current f{year}-{month:02d}-01 return months ### 3.3 第三层读写分离与流量切换 在迁移过程中写入流量会经过判断后同时写入新旧两个分布式表以确保新数据在两边都有读取流量则经由查询路由器根据迁移进度路由到旧、新或联合视图最终实现业务方无感知切换。 写入流量在迁移期间需要双写 sql -- 双写INSERT 同时写入新旧分布式表 -- 可以在应用层做也可以用 ClickHouse 的 materialized view CREATE MATERIALIZED VIEW user_events_dual_write ON CLUSTER old_cluster_2shards TO user_events_new_all -- 目标表新集群分布式表 AS SELECT * FROM user_events_old_all; -- 来源旧集群的数据写入自动同步到新集群-- 注意materialized view 只对新写入的数据生效-- 旧数据需要用 INSERT SELECT 迁移## 四、扩容后的验证与优化 ### 4.1 数据完整性验证 sql -- 对比新旧集群的数据量是否一致 SELECT old_cluster AS source, count() AS total_rows FROM user_events_old_all UNION ALL SELECT new_cluster AS source, count() AS total_rows FROM user_events_new_all; -- 对比关键指标的聚合结果是否一致 SELECT old AS source, sum(duration) AS total_duration, count(DISTINCT user_id) AS unique_users FROM user_events_old_all WHERE event_date BETWEEN 2026-01-01 AND 2026-06-30 UNION ALL SELECT new AS source, sum(duration) AS total_duration, count(DISTINCT user_id) AS unique_users FROM user_events_new_all WHERE event_date BETWEEN 2026-01-01 AND 2026-06-30;4.2 性能优化扩容后新节点可能有冷数据缓存问题-- 手动预热新节点的缓存 -- 先跑一遍全表扫描让数据进入 page cache SELECT count() FROM user_events_local WHERE event_date 2026-07-01; -- 调整分布式表的负载均衡策略 -- 从默认的随机分片改为按分片键分配减少跨分片查询 ALTER TABLE user_events_all ON CLUSTER new_cluster_4shards MODIFY ENGINE Distributed( new_cluster_4shards, default, user_events_local, userId -- 用 user_id 作为分片键同用户数据落在同一分片 );4.3 常见故障排查新节点 join 失败检查 ZooKeeper 连接是否正常/clickhouse/tables/{shard}/路径是否已创建分区 ATTACH 后查询报错检查分区文件格式是否兼容新旧节点的 ClickHouse 版本必须一致联合视图查询超时双分布式表查询开销翻倍调整max_execution_time和max_memory_usage建议在扩容期间将联合视图查询的超时时间设为正常值的 2-3 倍并提前和业务方沟通可能会有短暂的查询延迟。五、总结ClickHouse 集群扩容的核心挑战不是加节点本身而是数据重分布过程中的查询可用性保障。两种重分布策略各有适用场景INSERT SELECT 适合小规模或非生产集群分区级 ATTACH/DETACH 适合大规模生产集群的逐月迁移。查询可用性保障的三层防线是双分布式表联合视图保证数据不丢失、查询路由器根据迁移进度智能路由、双写保证新数据在两边都有。落地路径是先做小规模验证一个分区确认流程没问题后按分区循环迁移每迁移一个分区就更新路由器的已迁移列表直到全部完成再做最终流量切换。扩容后务必做数据完整性对比验证确保行数和聚合结果一致。最后提醒一点这个方案在上生产之前建议先用灰度流量验证一周确认资源消耗在预期范围内再全量推送。我们在实际项目中因为跳过了这步有一次把缓存集群打挂了教训深刻。