ClickHouse 集群实战:分片、副本、本地表与分布式表的完整梳理
一次把 ClickHouse 集群里最让人晕的几个概念讲清楚:分片到底是逻辑概念还是物理存在?分片键什么时候生效?写本地表和写分布式表到底选哪个?"全分片聚合"的查询真的会慢吗?
一、从一次困惑说起
项目里用到 ClickHouse 集群,架构约定是:
- 写入:应用直连本地表(经 LB 端口)
- 读取:应用查分布式表
- 规范:
INSERT全部打本地表,SELECT全部走dis_前缀的分布式表
这套规范本身没问题,但围绕着它,冒出了一连串让人绕不清的问题:
- 我写入本地表时,落到了哪个分片?分片数量是多少?
- 分布式表 DDL 里那个"分片键"(
rand()/cityHash64(product_id))是什么?是不是在"创建 64 个分片"? - 既然分片键不参与写入,写本地表会不会导致数据倾斜?
- 查询走分布式表"全分片聚合",3 个分片岂不是要扫 3 倍数据,不会慢吗?
这篇博客把这些问题一次梳理清楚,也把踩过的认知坑记下来。
二、概念基础:节点、分片、副本
这三个词最容易混,先把关系钉死:
| 概念 | 是什么 | 关系 |
|---|---|---|
| 节点(Node) | 一台物理机 / 一个 clickhouse-server 进程 | 集群的物理组成 |
| 分片(Shard) | 数据的逻辑分片单位 | 分片数 ≤ 节点数 |
| 副本(Replica) | 同一分片数据的完整拷贝,驻留在不同节点 | 分片数 × 副本数 = 节点数 |
核心公式:
节点数 = 分片数 × 每分片副本数
一个具体的例子——3 分片 × 2 副本 = 6 节点:
集群 (cluster_xxx)
┌──────────────┬──────────────┬──────────────┐
│ 分片 1 │ 分片 2 │ 分片 3 │
│ │ │ │
│ 节点 A(副1) │ 节点 C(副1) │ 节点 E(副1) │ ← 分片主副本
│ 节点 B(副2) │ 节点 D(副2) │ 节点 F(副2) │ ← 分片备副本
│ │ │ │
│ 数据 0-1/3 │ 数据 1/3-2/3 │ 数据 2/3-1 │
└──────────────┴──────────────┴──────────────┘
关键理解:
- 分片是逻辑切分:数据按某种规则分成 3 份,每份进一个分片
- 副本是物理冗余:同一个分片的数据,在不同节点上各存一份(通过 ReplicatedMergeTree 自动同步)
- 分片之间的数据不互通(除非经分布式表),副本之间的数据完全一致
三、一条 SQL 看清集群全貌
不用登服务器、不用翻配置文件,DBeaver 直连任意节点就能查到完整拓扑:
SELECT shard_num,
replica_num,
host_address,
port,
host_name
FROM system.clusters
WHERE cluster = 'cluster_xxx';
返回示例:
| shard_num | replica_num | host_address | host_name |
|---|---|---|---|
| 1 | 1 | 10.x.x.27 | 10.x.x.27 |
| 1 | 2 | 10.x.x.13 | 10.x.x.13 |
| 2 | 1 | 10.x.x.29 | 10.x.x.29 |
| 2 | 2 | 10.x.x.15 | 10.x.x.15 |
| 3 | 1 | 10.x.x.23 | 10.x.x.23 |
| 3 | 2 | 10.x.x.24 | 10.x.x.24 |
怎么读:
shard_num的最大值 = 分片总数(这里是 3)- 同一个
shard_num下的多行 = 该分片的多个副本(每行一个节点) replica_num区分同一分片内的不同副本
这个查询返回的是"集群配置",跟任何数据写入无关——它是运维在部署时配好的静态拓扑,全集群同步,任何节点查到的结果都一样。
四、分片键:被误解最深的参数
分片键是分布式表 DDL 的第四个参数:
ENGINE = Distributed(cluster_xxx, gateway, yi_chen_report_flow, cityHash64(product_id))
↑
这就是分片键
4.1 分片键不是"分片数量"
看到 cityHash64(product_id) 有人会问:“是不是要建 64 个分片?” —— 完全不是。
cityHash64是一个哈希函数:输入字符串,输出一个 64 位整数- 名字里的 “64” 指的是输出位数,跟分片数无关
- 分片数是
system.clusters里查出来的(比如 3),由运维配置固定
4.2 分片键真正做的事:哈希取模
当有人向分布式表插入数据时,CK 拿分片键计算结果对分片数取模:
分片号 = hash(分片键值) % 分片数
例(3 分片):
cityHash64("AAA") % 3 = 0 → 分片 1
cityHash64("BBB") % 3 = 1 → 分片 2
cityHash64("CCC") % 3 = 2 → 分片 3
cityHash64("AAA") % 3 = 0 → 分片 1(同一个值每次都落同一分片)
取模的直觉:
- 3 个分片 → 结果落在
[0, 1, 2]三个桶里 - 同一个 key 每次算出来都一样 → 稳定落同一分片
4.3 分片键只在一条路径上生效
这是最容易被忽略的一点:
| 写入路径 | 分片键是否生效 |
|---|---|
INSERT INTO dis_xxx(分布式表) | ✅ 生效,CK 按分片键路由每行 |
INSERT INTO xxx_local(本地表) | ❌ 完全不参与 |
为什么:本地表 DDL 里压根没有"分片键"这个参数——分片键是分布式表的属性。你写本地表时,这行的落点由当前连接挂在哪个节点决定,跟分片键毫无关系。
4.4 rand() vs cityHash64(key) 的真实差异
| 分片键 | 经 dis_ 表写入时 | 经 dis_ 表查询时 |
|---|---|---|
rand() | 每行随机撒到各分片 | WHERE 没法推分片 → 全分片扇出 |
cityHash64(product_id) | 同 key 固定落同一分片 | 配合 optimize_skip_unused_shards 可只打 1 个分片 |
但:在"本地写、分布式读"架构下,两者日常都不生效——写入不走 dis_ 表,分片键就像个摆设。真正的影响只体现在将来需要数据本地性时的可选优化。
五、写入路径对比:本地表 vs 分布式表
业务侧写入有两种方式,各有取舍:
5.1 方式 A:写本地表(本地写 + 连接层均衡)
INSERT INTO gateway.yi_chen_report_flow VALUES (...);
-- ↑ 本地表(无 dis_ 前缀)
路由机制:不经过 CK 的分片逻辑;连接建立时由 LB / 驱动决定落哪台节点,该连接后续所有 INSERT 都写到那个节点的分片。
优点:
- ✅ 写入路径最短:直写磁盘,无转发
- ✅ 不产生转发碎 part,后台 merge 压力小
- ✅ 应用只需知道 LB 地址,不知道拓扑
缺点:
- ⚠️ 均衡精度取决于 LB 策略 + 连接池大小,不精确
- ⚠️ 同一个逻辑 key 的数据不保证同分片
- ⚠️ 依赖 LB(LB 挂了写入全挂)
5.2 方式 B:写分布式表
INSERT INTO gateway.dis_yi_chen_report_flow VALUES (...);
-- ↑ 分布式表
路由机制:CK 按分片键算每行的落点,从接收节点异步转发到目标分片。
优点:
- ✅ 数据均匀分布(哈希算法保证)
- ✅ 同 key 固定同分片(如果分片键选 key)
- ✅ 应用不需要知道拓扑
缺点:
- ⚠️ 多一跳转发:数据先落在接收节点,再异步转发到目标分片
- ⚠️ 转发产生碎 part,merge 压力大
- ⚠️ 转发失败会重试(Replacing 幂等所以安全,但会增加抖动)
- ⚠️ 接收节点有转发压力(写高峰时可能变瓶颈)
5.3 决策框架
| 场景 | 推荐 |
|---|---|
| 中小规模、追求开发效率 | 写分布式表 |
| 大规模、写入吞吐瓶颈 | 写本地表 + 连接层路由 |
| ETL 批量导入 | 写本地表(直写各分片) |
| 有 LB 做连接级均衡 | 写本地表(避免冗余转发) |
| 需要同 key 同分片 | 写分布式表(分片键生效) |
判断原则:如果写入 QPS × 单条大小达到几百 MB/秒的量级,分布式表的转发开销会成瓶颈,就得写本地表让连接层分担路由。
六、负载均衡:LB 的"隐式路由"
"写本地表"看起来像没做路由,但如果 LB 端口插在中间,路由其实已经发生了——只是发生在连接层而非应用层。
6.1 架构与数据流
应用 (HikariCP 连接池,比如 10 条连接)
│ 每条连接建立时被 LB 分发到某个后端节点
▼
LB (10.x.x.172:9000) ← native 端口做了负载均衡
├──► 节点 A:分片 1 副本 1 ── 写本地表
├──► 节点 B:分片 1 副本 2 ──(副本同步,不直接写)
├──► 节点 C:分片 2 副本 1 ── 写本地表
└──► ...
连接池里多条连接被 LB 摊到各节点,每条连接的 INSERT 落在它绑定的节点 = 连接级随机分片,数据是分散的。
6.2 三个关键细节
-
路由粒度是连接级不是语句级。Hikari 池内的连接建立后固定绑定某个后端节点,期间该连接所有 INSERT 都走同一节点。均衡精度取决于连接数 × LB 策略,不会精确到行。
-
节点宕机时自愈。池内坏连接报错、重建后 LB 重新分发——不需要人工干预。
-
写入不区分副本。LB 分发到某节点的连接,写入该节点上驻留的分片副本;如果是备副本,数据会通过
ReplicatedMergeTree+ ZK 同步到主副本,反之亦然。应用写入不需要关心是主还是备。
6.3 为什么这是"更优"的一条路
有 LB 做连接级均衡时,写本地表反而绕开了分布式表的转发开销:
- 无转发 → 无碎 part → 无接收节点压力
- 数据仍均匀(LB 摊开连接池)
- 应用零改动
这就是"本地写 + LB"在你们架构里比"写分布式表"更合适的原因——两边都对,取决于基础设施。
七、分布式读取:为什么不会慢
最后一个核心疑问:查 dis_ 表"全分片聚合",不会慢吗?
7.1 不是串行扫全量,而是并行分治
应用查询 dis_yi_chen_report_flow (product_id=? AND report_date=?)
│
▼
发起节点自动成为"协调者"(initiator),把子查询同时发给全部分片
├──► 分片 1:本地表执行(排序键命中 + 分区裁剪,只扫几个 granule)──┐
├──► 分片 2:本地表执行(并行,同样毫秒级)──────────────────────┤→ 汇聚 → 返回
└──► 分片 3:本地表执行(并行)──────────────────────────────────┘
耗时 = max(各分片本地耗时) + 一次内网汇聚,不是三个分片时间相加。
7.2 用数字理解
假设 3 个分片、共 30 亿行数据:
| 方式 | 每个分片扫多少 | 相对耗时 |
|---|---|---|
| 不分片(单机扛 30 亿行) | 30 亿 | 100% |
| 3 分片并行(各 10 亿行) | 10 亿 | ≈ 33% |
总扫描量没变,但时间是并行的。
7.3 真正的开销与优化时机
| 开销项 | 量级 | 什么时候需要优化 |
|---|---|---|
| 扇出(分发子查询到各分片) | 内网一次 RTT,几 ms | 分片数 ≥ 几十、或 QPS 极高时 |
| 汇聚中间结果 | 点查场景几乎为 0 | 大范围聚合查询时 |
| 分片本地执行 | 主成本,本来必须有 | 永远 |
优化手段:
- 分片键改
cityHash64(product_id)+ 开启optimize_skip_unused_shards - 让点查(
WHERE product_id = ?)只打 1 个分片 - 分片数少(个位数)时,扇出的几 ms 完全无感,不用管
八、实战:如何查询分片数量、写入落点、数据分布
应用侧不需要也不能感知落点——真相只能问 CK 集群本身。三组 SQL 由粗到细:
8.1 分片数量与拓扑
SELECT shard_num, replica_num, host_address, port, host_name
FROM system.clusters
WHERE cluster = 'cluster_xxx';
8.2 各分片实际数据量与写入活跃度
SELECT hostName() AS host,
partition AS pt,
sum(rows) AS rows,
max(modification_time) AS last_write
FROM clusterAllReplicas('cluster_xxx', system.parts)
WHERE database = 'gateway'
AND table = 'yi_chen_report_flow'
AND active
GROUP BY host, pt
ORDER BY host, pt;
看两点:
- 各分片行数:同一量级 → LB 均衡性正常
- 最近写入时间:持续刷新 → 该分片在正常接收写入
8.3 某条数据的实际分布
SELECT hostName() AS host, count() AS cnt
FROM clusterAllReplicas('cluster_xxx', gateway.yi_chen_report_flow)
WHERE product_id = '实际值'
GROUP BY host;
跑几组不同的 product_id 会发现:同一个家庭的数据出现在多个节点——这正是"连接级路由"的直接证据(数据在不同连接的生存期内被写入,落到了不同节点)。
也再次说明:查询必须走 dis_ 表全分片聚合,不能假设数据在本地。
九、决策框架
把所有讨论收敛成一张决策表:
| 决策点 | 你的场景 | 选择 |
|---|---|---|
| 写入路径 | 有 LB、数据量中等 | 本地表 |
| 读取路径 | 需要全集群数据 | 分布式表(无例外) |
| 分片键 | 本地写架构下不生效 | rand() 够用,不用改 |
| 优化时机 | 分片少、QPS 可控 | 无需优化,留证据即可 |
| 未来触发条件 | 分片扩到 10+ / QPS 翻倍 / 需同 key 同分片 | 再评估切分布式表 + cityHash64 |
核心结论:
你们的场景(数据量中等 + 有 LB + 查询走分布式表)下,"本地写 + LB 均衡 + 分布式读"是最优解。
分片键
rand()是摆设,不影响任何功能;等将来分片数或 QPS 上一个量级、或出现数据本地性需求时,再切分布式写 +cityHash64分片键。
十、常见误区纠正
| 误区 | 事实 |
|---|---|
| 分片是写入时创建的 | 分片是运维预先配置的拓扑,写入不会改变它 |
cityHash64 = 建 64 个分片 | cityHash64 是哈希函数,“64” 指输出位数;分片数由 system.clusters 决定 |
| 分片键随便选无所谓 | 本地写架构下无所谓;写分布式表时,选低基数列会导致严重倾斜 |
| 写本地表 = 没做路由 | 有 LB 时,路由在连接层发生(连接级随机分片) |
| 写本地表要应用做 hash 路由 | 应用按行 hash 需要维护分片映射,业界几乎没人这么做;LB 或客户端多 host 轮询才是主流 |
| 分布式查询会慢 | 是并行分治,不是串行全扫;分片让查询更快,不是更慢 |
rand() 分片键是缺陷 | 在本地写架构下它不参与路由;将来需要数据本地性时再改 |
十一、总结
把整篇博客压缩成三句话:
-
拓扑是静态的:分片数、副本数由运维预先配置,任何时候查
system.clusters都能看到;写入不会创建分片。 -
分片键只在特定路径生效:只作用于"经分布式表写入"这一条路径;本地写架构下它是摆设,不影响功能和性能。
-
读写策略看基础设施:
- 无 LB、应用直连单节点 → 写分布式表(让 CK 帮你路由)
- 有 LB、数据量中等 → 写本地表(避免转发开销)
- 读取无论如何都走分布式表(需要全集群数据)
最终架构判断:
数据量不大 + 有 LB + 查询走分布式表
→ 本地写 + 分布式读 = 标准解
分片数扩张 / QPS 上量 / 需同 key 同分片
→ 切分布式写 + cityHash64 分片键
十二、后记
这套概念不是靠背文档能真正搞清的——它需要先接受"三个层次分开看":
- 层次 1:集群拓扑(运维配置,静态)
- 层次 2:本地表 / 分布式表(DDL 层,定义语义)
- 层次 3:写入 / 读取路径(运行时行为,看基础设施)
把这三层混在一起看,就会觉得"分片数量"和"分片键"矛盾、"写本地表没路由"等等一堆困惑。一旦分开,所有问题都自洽。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/J080624/article/details/168140462



