ClickHouse 分布式表设计指南:分片键选错,查询会慢成什么样

ClickHouse 分布式表设计指南:分片键选错,查询会慢成什么样

ClickHouse 分布式表设计指南:分片键选错,查询会慢成什么样

一、一个分片键选择失误,集群从 3 秒退化到 30 秒

ClickHouse 分布式表的威力在于把一张逻辑表拆到多个物理节点上,查询时并行扫描、写入时分片路由。但这些都依赖一个核心决策:分片键怎么选

选错了分片键,最直接的后果是数据分布不均——某个分片 500GB,另一个分片 50GB。查询时,全表扫描的瓶颈会卡在那个最大的分片上。更隐蔽的问题是:业务上最常见的查询条件如果跟分片键不匹配,每次查询都会变成全分片扫描,集群的并行优势荡然无存。

有一回帮业务团队排查 ClickHouse 查询变慢的问题,发现他们用user_id做了分片键,但 90% 的分析查询按date过滤。结果就是:每个查询命中所有分片、每个分片都要扫日期索引,虽然没有数据倾斜,但计算倾斜严重——所有分片都参与了相同的工作量。

flowchart TD A[选择分片键] --> B{主要查询模式是什么?} B -->|按日期范围查询| C[用日期作为分片键] B -->|按用户ID查询| D[用用户ID Hash作为分片键] B -->|多维度混合查询| E{数据量有多大?} E -->|TB级| F[随机分片 + 查询时全分片并行] E -->|百TB级以上| G[Composite分片键: 日期+业务Hash] C --> H{日期分片的陷阱} H -->|写入集中在当天| I[最新分片写入热点, 老分片空闲] H -->|历史数据量大| J[老数据分片可能被遗忘, 存储不均] D --> K{Hash分片的代价} K -->|按日期查询| L[全分片扫描, 失去分片裁剪优势] K -->|扩容时| M[需要重新分布所有数据]

二、分片键的三种经典策略与各自代价

策略一:按日期分片ENGINE = Distributed(cluster, db, table, toYYYYMM(dt))。对时序分析场景来说,这是最自然的选择——查询通常带日期范围,分片裁剪能精准命中需要的分片。

但日期分片的代价也明显:写入热点全集中在最新日期对应的分片上(通常是最近一两个月的数据),其他分片几乎闲着。这个问题在数据量不大时无所谓,但日增 TB 级场景下,写入节点的 IO 会成为瓶颈。另外,扩容时老分片的数据不会自动迁移,新节点只能承接新日期的数据。

策略二:按业务 ID Hash 分片ENGINE = Distributed(cluster, db, table, cityHash64(user_id))。数据在分片间天然均匀,没有热点问题。但代价也很直接:按日期查询会命中所有分片,查询耗时 ≈ 最慢那个分片的扫描时间。数据量大时,这种"全分片扫描"的开销可接受——毕竟 ClickHouse 的并行能力本身就强。

策略三:复合分片键ENGINE = Distributed(cluster, db, table, concat(toString(toYYYYMM(dt)), '_', toString(cityHash64(user_id) % shard_count)))。看起来兼顾了两者,实际上会让分片裁剪的条件变得极其复杂——不是一个简单的日期范围就能定位到目标分片。实战中复合分片键带来的心智负担通常超过了它带来的收益。

三、分片键错了怎么补救:没那么绝望但很麻烦

如果集群已经跑了一段时间才发现分片键不合理,直接改Distributed表的定义是不够的——背后的物理表数据分布已经定了。

抢救路线分两步:

第一步:建一个新的Distributed表指向新的分片策略,旧表保留只读。新旧两套表并行运行,查询逐渐切换到新表。

第二步:用INSERT INTO ... SELECT FROM ...把旧数据迁移过去。这个过程INSERT SELECT会按新分片键重新路由数据,天然完成数据的重新分布。但注意迁移期间不能有写入,需要在业务低峰期做。

如果数据量太大没法一次性迁移,可以分批次迁移历史数据,并对迁移期间的数据做双写确保一致性。

-- 新建目标表(所有节点执行) CREATE TABLE db.metrics_new ON CLUSTER my_cluster AS db.metrics_old ENGINE = MergeTree() PARTITION BY toYYYYMM(dt) ORDER BY (dt, metric_name); -- 新建分布式表,使用新的分片键 CREATE TABLE db.metrics_dist_new ON CLUSTER my_cluster AS db.metrics_dist_old ENGINE = Distributed(my_cluster, db, metrics_new, cityHash64(metric_name)); -- 历史数据迁移(低峰期执行) INSERT INTO db.metrics_dist_new SELECT * FROM db.metrics_dist_old WHERE dt < '2026-07-01';

四、除了分片键,分布式表还有两个常被忽略的配置

internal_replication参数:当底层ReplicatedMergeTree已经做了副本复制,把internal_replication设为true,写入时Distributed表只会写一份数据到其中一个副本,由 ReplicatedMergeTree 自己完成副本同步。设成false会导致 Distributed 表把同一份数据写两次(每个副本各一次),浪费一倍写入带宽。

sharding_keylocal查询的配合:当你知道数据在哪个分片时,直接查对应节点的本地表,绕过 Distributed 层的分片路由和结果聚合。这不仅省了网络开销,还能避免 Distributed 层在全分片查询时对结果做二次排序和合并。

-- 直接查分片1的本地表,跳过Distributed层 SELECT * FROM db.metrics_local WHERE dt = '2026-07-06' AND metric_name = 'gmv' -- 而不是: SELECT * FROM db.metrics_dist WHERE dt = '2026-07-06' AND metric_name = 'gmv'

这种"直连本地表"的技巧在实时看板刷新场景中特别有用——看板知道渲染哪个时间段的哪个指标,直接命中目标分片。

五、总结

分片键的选择本质上是用一种代价换一种收益:

  • 日期分片:查询快(裁剪精准),写入有热点。
  • Hash 分片:写入均匀,查询变成全分片扫描。
  • 随机分片:最省心,但完全没有裁剪能力。

选择的标准不是"哪个最好",而是"你的主要查询模式最怕什么代价"。如果查询总是带日期条件,日期分片的裁剪收益远超写入热点的代价。如果查询模式本身就是全表扫描风格(如跑日报),那 Hash 或随机分片的均匀性更值钱。

最后一条铁律:**建表时就想清楚分片键。**后期改分片键的成本,比初期多花一小时做查询模式分析高得多。