上周一个朋友找我吐槽,说他们ClickHouse集群某个分片的磁盘使用率已经冲到87%,按日增数据估算再跑两周就要写满。他们“加了一台机器进去”之后,数据却纹丝不动,新节点的磁盘还是空的。这大概是ClickHouse节点扩容时最经典的误解——以为像普通分布式数据库一样,填个节点数据就会自动平均过去。实际上,如果你用的是MergeTree加Distributed表这套架构,存量数据不会因为新节点加入而自动迁移,需要你自己设计完整的数据迁移方案。这篇就围绕ClickHouse节点扩容和数据迁移这件事,把从容量评估到实操落地、再到验证收尾的完整思路写出来,适合正在维护ClickHouse集群的运维、大数据工程师和DBA参考。
1. 扩容前先回答三个问题:加副本、加分片,还是重搭集群
很多人一看到“扩容”两个字,第一反应就是找一台新机器加进集群。但ClickHouse的扩容其实有三种完全不同的路线,选错方向会白忙一场。开干之前,必须先搞清楚你遇到的瓶颈到底是什么。
1.1 副本扩容和分片扩容是两回事
ClickHouse集群由分片(Shard)和副本(Replica)组成。分片解决“数据太多放不下、写入和扫描并行度不够”的问题;副本解决“节点宕了数据还在、查询可以多节点分摊压力”的问题。两者的扩容方式天差地别:
| 扩容类型 | 解决的核心问题 | 是否需要迁移存量数据 | 成本与复杂度 |
|---|---|---|---|
| 增加副本 | 可用性提升、查询并发提升 | 不需要,复制表会自动追平 | 低,但只增加副本不增加容量 |
| 增加分片 | 容量扩展、写入/扫描吞吐提升 | 必须手动迁移部分存量数据 | 高,需要重算数据归属 |
| 重搭集群 | 分片键变更、大版本升级、节点规模大幅调整 | 全量重新导入 | 最高,但能解决根因问题 |
举个直观的例子:你有一个3分片、每分片2副本的集群,单副本数据量为40TB,磁盘使用率85%。这时候给每个分片加一个副本,磁盘压力并不会下降——因为新副本等于把同样40TB数据完整复制一份,只会让总存储占用更多。真正的解法是增加分片,让数据分散到更多物理节点上。
1.2 容量评估不能只看当前使用率
判断要不要扩容,至少要估算四组数据:当前全集群单副本原始数据量、压缩后的实际占用、日增量、数据保留周期。压缩后的占用可以从system.parts里查:
SELECT sum(bytes_on_disk) AS compressed_size, sum(bytes) AS uncompressed_size, round(compressed_size / uncompressed_size, 3) AS compression_ratio FROM system.parts WHERE active = 1我之前碰到一个日志平台就是典型场景:每天原始日志约2TB,ClickHouse压缩后大约300GB,保留30天,单副本需要9TB存储。为了保证可用性用了2副本,总共需要18TB。当时集群3个分片,每个分片约6TB,磁盘余量只剩15%。这种情况下,不仅磁盘要扩容,合并和删除操作还需要临时空间,所以最终决定加一个分片,而不是单纯加副本。
这里有一个经验值:任何扩容决策都要预留30%左右的缓冲空间。MergeTree合并时要临时写新Part,ALTER TABLE DELETE会生成大量临时Part,分布式查询的中间结果也要吃内存和临时磁盘。磁盘塞到90%以上再扩容,往往已经处于危险区间。
1.3 新节点硬件别搞“木桶效应”
新增节点的CPU、内存、磁盘规格最好与现有节点保持一致,尤其是磁盘类型和吞吐能力。ClickHouse查询会把一个大查询广播到所有分片,如果新分片磁盘是机械盘而旧分片是SSD,查询速度会被最慢的那个节点拖死。实在规格不一致,至少要把新节点的weight调低,让它承接少量写入,等后续节点补齐。
2. 搞懂ClickHouse的数据分布机制:分布式表的路由与本地表的真实位置
想理解为什么“加节点数据不会自动迁移”,必须先把ClickHouse的存储模型拆开看。这是整篇文章的地基,也是很多人踩坑的根因。
2.1 分布式表只是一个“路由器”
ClickHouse里有两类表:本地表(Local Table)和分布式表(Distributed Table)。你真正INSERT数据时,数据会写入本地表;你在应用层查询时,通常会查分布式表。以常见的logs为例:
- 每个节点上有一个
logs_local表,存的就是实际数据,可能是MergeTree引擎,也可能是ReplicatedMergeTree引擎。 - 集群里有一个
logs分布式表,本身不存储任何数据,只负责把查询转发到各个分片,再把结果汇总回来。
分布式表的分片位置和路由规则写在config.xml里。比如下面这个配置,意味着用户查询logs时,请求会分发到shard1和shard2节点:
<clickhouse> <remote_servers> <log_cluster> <shard> <replica> <host>clickhouse-01</host> <port>9000</port> </replica> </shard> <shard> <replica> <host>clickhouse-02</host> <port>9000</port> </replica> </shard> </log_cluster> </remote_servers> </clickhouse>写入时,分布式表根据分片键(Sharding Key)计算哈希,决定这一行数据到底去哪个分片。例如分片键是user_id,路由规则是cityHash64(user_id) % 2,那么偶数结果去shard1,奇数结果去shard2。
2.2 为什么存量数据不会自动均衡
当你往集群里加了一个新分片,并把分布式表配置改成3个分片后,会发生什么?路由规则变成了cityHash64(user_id) % 3,新写入的数据会均匀分布到3个分片。但旧数据依然躺在原来的两个节点上,不会有任何进程去移动它们。
更麻烦的是,旧数据在旧规则下归属在shard1或shard2,但新规则下查询时,分布式表会按照新路由把请求发到3个分片中的一个。比如某条数据user_id=123,旧规则下123 % 2 = 1去了shard2,新规则下123 % 3 = 0会被路由到shard1。shard1上根本没有这条数据,查询就漏了。
ClickHouse官方其实提供过SYSTEM BALANCE的实验能力,但成熟度有限,生产环境很少有人直接依赖它做自动重分布。你可以把它当成一个方向,但不要指望加个节点数据就能自动跑平。这也是ClickHouse和许多自动分片数据库最不一样的地方——它把数据分布的控制权完全交给了运维,你需要自己完成“存量数据按新规则重新归属”这件事。
3. 扩容副本:利用复制表自动追平,无需手动搬数据
如果你的瓶颈是可用性或查询并发,而不是磁盘容量,那么优先考虑扩容副本。这个过程在有ReplicatedMergeTree表引擎的前提下,几乎不需要手动迁移数据。
3.1 新副本加入的完整步骤
假设现在集群里已经有一个分片,包含两个副本replica-01和replica-02,你要在replica-03上增加第三个副本。前提是表引擎必须是ReplicatedMergeTree系列,并且元数据协调依赖ZooKeeper或ClickHouse Keeper。
第一步,在新节点上安装ClickHouse,版本尽可能和集群保持一致。然后把新节点加到对应分片的<replica>列表中:
<shard> <replica> <host>replica-01</host> <port>9000</port> </replica> <replica> <host>replica-02</host> <port>9000</port> </replica> <replica> <host>replica-03</host> <port>9000</port> </replica> </shard>第二步,在新节点上创建和旧节点完全一样的数据库和表。这里的关键是,ZooKeeper路径必须一致,但replica_name必须唯一。比如:
CREATE TABLE served_logs_local ( event_date Date, user_id UInt64, event_type String, payload String ) ENGINE = ReplicatedMergeTree('/clickhouse/tables/served_logs', 'replica-03') ORDER BY (event_date, user_id) PARTITION BY toYYYYMM(event_date)第三步,启动ClickHouse,它会自动从已有的副本上拉取元数据和数据Part,不需要执行任何INSERT。查询同步进度:
SELECT replica_name, is_readonly, total_replicas, active_replicas, zookeeper_exception, absolute_delay FROM system.replicas如果absolute_delay稳定下降,说明新副本正在追赶。数据追平后,新副本就可以正常承接查询。
3.2 副本扩容的注意事项
新副本追平数据的过程,本质是跨网络复制Part文件。数据量大时,会产生很高的网络IO和磁盘IO,建议在业务低峰期操作,并且可以给新副本设置较低的priority值,让查询流量先持续打到旧副本,等新副本数据完全追平再把权重调上来。
我还遇到过replica_name重复导致副本无法启动的情况。多人协作时,最好在命名里带上机器角色或序号,避免随意取名。另一个坑是表引擎不一致:旧表是ReplicatedMergeTree,新节点上如果写成了MergeTree,ZooKeeper路径对不上,数据永远不会自动同步。创建表之前,用SHOW CREATE TABLE把旧表的DDL完整拿过来,只改replica_name即可。
需要明确一点:扩容副本并没有增加集群的存储容量,它的价值在于高可用和查询并发。如果你误把它当成容量扩容方案,新副本追平历史数据之后,磁盘占用反而会翻倍,到时你会看到一个“更满”的集群。
4. 扩容分片:存量数据迁移的完整流程(含分片键归属重算)
扩容分片才是真正解决“磁盘要满了”的方案,但也是复杂度最高、最容易出事故的方案。核心难点不在搬迁动作本身,而在于迁移的数据必须严格符合新路由规则。
4.1 第一步:先算清楚哪些数据应该属于新分片
假设集群原本有2个分片(shard0、shard1),分片键是user_id,路由规则为cityHash64(user_id) % 2。现在要扩容到3个分片,新增shard2,新规则变成cityHash64(user_id) % 3。
新增的shard2只应该接收那些cityHash64(user_id) % 3 = 2的数据。而旧规则下,这些数据原本分散在shard0和shard1,现在需要被抽出来,迁到shard2。抽样条件就是这样:
cityHash64(user_id) % 3 = 2在写迁移SQL之前,先在新节点上把目标表建好。注意,如果新分片暂时只想维持单副本,可以用普通的MergeTree引擎;如果后续还要加副本,直接用ReplicatedMergeTree也可以,但ZooKeeper路径不要和旧分片冲突。
4.2 第二步:用remote函数把数据从旧节点搬到新节点
数据迁移最常用的SQL方式是通过remote()表函数读取旧节点本地表,然后直接写入新节点的本地表。以某一张事件表为例:
INSERT INTO new_node_db.served_logs_local SELECT * FROM remote('old-shard-0-host:9000', 'old_node_db.served_logs_local', 'query_user', 'query_password') WHERE cityHash64(user_id) % 3 = 2这里有一个极其重要的细节:目标表一定要是新节点的本地表,而不是分布式表。如果对分布式表执行INSERT ... SELECT,ClickHouse会再次按照分片键把数据重新路由到所有分片,等于白折腾一趟,还可能造成数据重复。
数据量很大时,不要一次性全量SELECT,建议按某个时间维度分批执行。比如按天迁移:
INSERT INTO new_node_db.served_logs_local SELECT * FROM remote('old-shard-0-host:9000', 'old_node_db.served_logs_local', 'query_user', 'query_password') WHERE event_date = '2024-06-01' AND cityHash64(user_id) % 3 = 2每批完成后,在目标节点查一下行数和聚合校验值:
SELECT count(), uniqExact(user_id), sum(cityHash64(payload)) FROM new_node_db.served_logs_local WHERE event_date = '2024-06-01'同样地,在源节点执行相同条件的统计,两边数值一致再进入下一批。这个校验动作朴素但非常重要,它能在数据没清掉之前发现问题,给自己留出修复空间。
4.3 第三步:删除旧节点上已迁移的数据
数据迁移完成并校验无误后,才能清理旧节点上的源数据。清理有两种方式:
如果迁移条件恰好和分区粒度对齐,比如按事件日期迁移且表按日期分区,那么直接用DROP PARTITION最干净、最快:
ALTER TABLE old_node_db.served_logs_local DROP PARTITION '2024-06-01'如果迁移条件只是行级条件(比如user_id哈希取模),没法整分区删除,只能用DELETE。需要注意,ALTER TABLE DELETE是一个异步mutation操作,它会为每个受影响的分区生成新的Part,磁盘瞬时占用很高:
ALTER TABLE old_node_db.served_logs_local DELETE WHERE event_date = '2024-06-01' AND cityHash64(user_id) % 3 = 2执行之后要盯着system.mutations:
SELECT database, table, mutation_id, is_done, latest_failed_reason FROM system.mutations ORDER BY create_time DESC等到is_done = 1,才算真正删除完成。这里强烈建议分批处理,不要一次性提交覆盖全表的大范围DELETE,否则mutation风暴会把整个集群的磁盘IO打满,在线查询会明显抖动。
4.4 第四步:“先迁数据后改路由”的顺序不能乱
很多团队扩容翻车,是因为先改了集群配置,把新分片加入分布式表,然后又花很长时间迁移存量数据。在这个时间窗内,应用层通过分布式表查询旧数据时,按新规则路由到新分片,而新分片还没有数据,就会出现“间歇性丢数据”的假象。
正确顺序是:
- 新节点建表、配置独立本地表。
- 分批迁移存量数据到新节点。
- 校验新旧数据一致。
- 删除旧节点已迁移部分。
- 最后修改
config.xml,把新分片加进remote_servers。 - 执行
SYSTEM RELOAD CONFIG和SYSTEM RELOAD DISTRIBUTED TABLES,让集群感知新拓扑。
在完成第6步之前,新分片不参与任何分布式路由,旧系统完全按老规则运行。数据迁移期间业务可以正常读写,只是新分片暂时是一个“与外部隔离”的数据接收者。等到所有历史数据就位,再切换路由,影响面最小。
4.5 如果没法按分片键重算数据归属怎么办
上面整个流程成立的前提是:你能精确判断每一行数据应该归属哪个新分片。但如果你的表没有明确的分片键,或者分片键在写入时用了随机函数(比如随机分片),那么新节点的归属条件根本没法算出来。
这种情况下,原地扩容加数据迁移很容易把数据搞丢。更稳妥的做法是放弃原地增分片,直接重建一个新集群,把全部存量数据按目标分片规则重新导入。虽然成本高,但至少数据是安全、可预期的。这正是下一节要展开的内容。
5. 当原地扩容太麻烦:整集群迁移与clickhouse-copier的取舍
遇到分片键需要变更、节点数量从2个变成10个、或者ClickHouse要跨大版本升级时,原地扩容会非常痛苦。此时整集群迁移往往是更理性的选择。
5.1 什么时候选择重建集群
我在实际项目里总结出三类必须走重建集群的场景:
第一,分片键要改。比如原来按event_date做的随机分片,现在要改成按user_id分片,用来支撑单用户维度查询。这种变更没法靠“搬一部分数据”解决,只能全量数据按新键重算归属。
第二,节点数量变化幅度太大。比如2分片扩到10分片,迁移SQL要反复跑8个分片的归属条件,每一轮都是全表扫描,时间长、风险高。重搭集群反而更快。
第三,集群版本跨度太大。旧节点还在旧版本,新节点如果用了更新的版本,分布式查询协议可能不兼容。与其让新旧长期混跑,不如以新版本为基础重建一套干净集群。
5.2 离线迁移与在线双写
重建集群的迁移模式有两种。
离线迁移适合可以接受停机窗口的业务。操作流程是:在维护窗口内停写、ALTER TABLE ... FREEZE做数据备份、用clickhouse-client导出数据(或者直接用备份恢复工具),导入新集群,校验完成后切换流量。优点是流程简单、逻辑直观;缺点是窗口期长,数据量越大停机越久。
在线双写适合不能长时间停业务的核心链路。思路是:新集群先建好,历史数据全量导入新集群;同时应用层把增量写入复制一份发给新集群;等新集群追平后,把查询流量切换到新集群;稳定运行一段时间后,再停掉旧集群的写入链路,最后下线旧集群。这个方案对业务改动较大,需要有一个双写通道,但对用户无感知,比较适合日志、消息、订单这类连续性强的数据。
5.3 clickhouse-copier什么时候值得用
如果迁移数据量特别大,又不希望手动写大量SQL,可以试试官方工具clickhouse-copier。它通过ZooKeeper协调多个迁移任务,从一个集群拉取分区数据写入目标集群,支持断点续传和并发控制,适合大规模数据重分片场景。
但坦率说,clickhouse-copier的配置相当繁琐,需要写一份XML任务描述,指定源集群、目标集群、分片映射规则,然后提交任务到ZooKeeper再启动copier进程。数据量在几百GB到几TB这个量级时,直接用remote()加INSERT INTO的SQL方案反而更轻量、更可控。真碰到了几十TB以上的重分片需求,再考虑上copier也不迟。
我个人的偏好是:优先用SQL方案,因为每一步都可以被审查和校验;只有SQL方案因为耗时长到无法接受时,才会引入专职迁移工具。
6. 扩容后的验证清单与我在生产环境踩过的坑
扩容不是“数据搬完就结束”,后面要验证的东西非常多。这里列一份我每次扩容都会执行的清单,以及几个真实踩过的坑。
6.1 扩容后的验证清单
第一,检查集群拓扑是否生效:
SELECT cluster, shard_num, replica_num, host_name, port FROM system.clusters确认所有分片和副本都出现在预期位置。
第二,检查数据分布是否均衡:
SELECT hostName() AS host, table, sum(bytes_on_disk) AS disk_bytes, count() AS part_count FROM system.parts WHERE active = 1 GROUP BY host, table ORDER BY disk_bytes DESC正常情况下,扩容完成后各分片的磁盘占用应当基本接近。如果某个分片明显偏高,可能是数据迁移的归属条件算错了,或者新写入的权重配置有问题。
第三,对分布式表和本地表做抽样一致性校验。比如查询分布式表的某一天数据总量,再分别查各节点本地表对应数据量,加在一起应该完全一致。
第四,查看Replicated表的同步状态。扩展副本场景下尤其要检查新副本的absolute_delay是否为0。
6.2 坑一:误对分布式表执行了INSERT SELECT
这是数据迁移里最隐蔽的坑。我见过有人把INSERT INTO new_node_db.served_logs_local写成了对分布式表INSERT INTO new_node_db.served_logs操作,结果所有数据被重新按分片键打散,迁过去的和没迁过去的互相覆盖,最后数据重复和缺失同时出现。记住一句话:迁移存量数据只针对本地表,分布式表只做查询使用。
6.3 坑二:DELETE mutation把集群跑挂了
用ALTER TABLE DELETE清理大批量数据时,ClickHouse会对每个历史分区生成新的Part,数据量越大,Part数量增长越恐怖。曾经有一个集群在执行全表DELETE WHERE user_id % 3 = 2之后,Part数量短时间翻了几倍,合并线程全部处于等待状态,查询延迟从几十毫秒飙到几秒。
解决经验是:能按分区删就按分区删;必须用DELETE时,按天或者按小时分批执行,每次删除之后等system.mutations里的任务完成,再提交下一批。同时密切观察system.parts里的Part数量和system.metrics里的Merge线程状态。磁盘余量不足35%时不要轻易触发大范围mutation。
6.4 坑三:路由配置改了,分布式表缓存没刷新
修改config.xml之后,重新加载配置可能不会立刻让所有分布式表感知新的分片列表。我后来养成了习惯,改完配置顺手执行两行命令:
clickhouse-client --query="SYSTEM RELOAD CONFIG" clickhouse-client --query="SYSTEM RELOAD DISTRIBUTED TABLES"如果不执行,某些长连接或缓存状态会继续按旧路由发查询,结果就是明明配置已更新,查询却还是打到旧分片。
6.5 坑四:没有备份就急着删旧数据
无论SQL迁移还是工具迁移,删除源节点数据之前,一定要保证能回滚。最稳妥的操作是先对旧表执行ALTER TABLE ... FREEZE,把当前数据快照备份到/var/lib/clickhouse/shadow/目录,确认新集群稳定运行几天后再清理备份或执行删除。
回滚时最怕的就是“想反悔却发现旧数据已经删了”。只要源数据还在,大部分问题都可以靠反向迁移或调整路由解决;源数据一旦没了,就只能从备份恢复,涉及的成本和沟通会大得多。
这套扩容流程我在不同环境里反复跑过多次,最大的体会是:ClickHouse扩容本身不复杂,复杂的是数据归属的重新建模和对运维顺序的敬畏。如果你的分片键稳定、表结构不常变,建议把迁移SQL、校验SQL和清理SQL沉淀成一套模板脚本,每次只改批次时间和节点地址,比临时临急手工敲命令可靠得多。扩容完成后,顺手把当时的拓扑变化、迁移耗时、踩过的坑记下来,下次就能直接照走,省掉大量试错成本。