原文发布于 quant67.com,转载请保留出处。
单节点 MergeTree 能撑住不少 OLAP 场景,但日志、指标、宽表事实一旦超过单机磁盘或 CPU 上限,就要把 同一张逻辑表 切到多个 shard 上。ClickHouse 的 Distributed 引擎不是「又一种存储格式」,而是 查询与写入的路由层 :本地表(MergeTree / ReplicatedMergeTree)才真正持有 Part;Distributed 表只保存集群拓扑与分片函数。
ReplicatedMergeTree(第 08 篇) 解决 副本一致性与 HA ;本文解决 水平分片与跨节点查询 。读完应能回答:分片键怎么选、INSERT 落到哪、SELECT 如何在 shard 上并行、何时必须 GLOBAL IN,以及和 PostgreSQL Citus 的本质差异。
版本锚定:ClickHouse 24.x LTS 。集群协调默认写法以 ClickHouse Keeper(兼容 ZooKeeper 协议)为准;仍使用 ZooKeeper 的集群概念相同,路径前缀不同。
一、三层对象:Cluster、Local、Distributed
典型生产拓扑:
| 对象 | 引擎 | 磁盘数据 | 典型命名 |
|---|---|---|---|
| 本地表 | ReplicatedMergeTree |
有 Part | events_local |
| 分布式表 | Distributed |
无 Part,仅元数据 | events |
| 集群定义 | 配置 | 无 | <cluster> in config.xml |
关键结论 :Distributed 表是 视图 + 路由器 ,不是第三套存储。所有列存文件格式仍遵循 MergeTree Part 格式(第 02 篇)。
1.1 最小 DDL 示例
sql
-- 在每个 shard 的每个 replica 上创建本地表(简化,省略 ON CLUSTER)
CREATE TABLE events_local ON CLUSTER '{cluster}'
(
event_date Date,
user_id UInt64,
event_type LowCardinality(String),
payload String
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events_local', '{replica}')
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, user_id);
-- 在「入口」库创建 Distributed 表(可在任意节点,常放在统一 query 节点)
CREATE TABLE events ON CLUSTER '{cluster}'
AS events_local
ENGINE = Distributed('{cluster}', currentDatabase(), events_local, cityHash64(user_id));
第三参数 events_local 是 各 shard 上的本地表名 ;第四参数 cityHash64(user_id) 是 分片键表达式。
1.2 宏 {cluster}、{shard}、{replica}
ON CLUSTER 与 ReplicatedMergeTree ZK 路径依赖 macros (通常在 /etc/clickhouse-server/config.d/macros.xml):
xml
<clickhouse>
<macros>
<cluster>prod</cluster>
<shard>01</shard>
<replica>replica_a</replica>
</macros>
</clickhouse>
每个物理节点 macros 不同;ON CLUSTER prod 会把 DDL 广播到 remote_servers 里 prod 的所有 host。工程判断 :macros 与 remote_servers 不一致是分布式集群最常见的「表存在但路由错 shard」根因。
二、集群配置:remote_servers 与副本权重
config.xml(或 config.d/cluster.xml)定义逻辑 cluster:
xml
<remote_servers>
<prod>
<shard>
<internal_replication>true</internal_replication>
<replica>
<host>ch1.example.com</host>
<port>9000</port>
</replica>
<replica>
<host>ch2.example.com</host>
<port>9000</port>
</replica>
</shard>
<shard>
<internal_replication>true</internal_replication>
<replica>
<host>ch3.example.com</host>
<port>9000</port>
</replica>
<replica>
<host>ch4.example.com</host>
<port>9000</port>
</replica>
</shard>
</prod>
</remote_servers>
| 字段 | 含义 |
|---|---|
shard |
水平分片;不同 shard 各持数据子集 |
replica |
同一 shard 内的副本;internal_replication=true 时写入走副本协议 |
internal_replication |
true:Distributed INSERT 只写一份到 shard 内一 replica,由副本链复制 |
weight |
可选;非均匀 shard 容量时调整查询/写入权重 |
与第 08 篇衔接 :internal_replication=true 时,Distributed 引擎 不向同一 shard 的所有 replica 各写一份 ------否则 ReplacingMergeTree 去重语义会被打破。写入目标由 insert_replica 或负载策略选择。
三、分片键:数据分布与查询局部性
分片键是 Distributed 表 DDL 的第四个参数(或 SHARDING_KEY 别名语法,视版本文档)。ClickHouse 计算:
shard_index=(hash(sharding_expr)modNshards)+1
常用 hash:cityHash64、sipHash64、jumpConsistentHash(扩容场景)。
3.1 选型原则
| 目标 | 推荐分片键 | 风险 |
|---|---|---|
| 用户级查询局部性 | cityHash64(user_id) |
超大用户热点 shard |
| 时间范围 + 实体 | (event_date, user_id) 组合 hash |
表达式过复杂难调试 |
| 近似均匀 | 高基数 UUID / 雪花 ID | 无法按业务键 colocate |
| 日志按天 | 不推荐 仅 toDate(ts) |
单日流量打满单 shard |
工程判断 :分片键首要保证 写入均匀 ;其次考虑 最常见 WHERE 能否只扫单 shard。ClickHouse 没有 Citus 的「distribution column = 主键必须」硬约束,但选错键的代价一样:跨 shard 聚合、GLOBAL JOIN 爆炸。
3.2 与 PARTITION BY 正交
PARTITION BY(本地表):Part 目录按月/日切 ------ 影响 merge、TTL、删分区。- 分片键:行落到哪个 shard ------ 影响分布式路由。
同一 event_date 的行可以分布在所有 shard(分片键含 user_id 时)。常见误区 :把 PARTITION BY toYYYYMM(d) 误当分片键,导致每月一个 shard 热点。
3.3 扩容与 resharding
增加 shard 后,旧数据 不会自动重分片。官方路径:
- 新 shard 建空本地表;
- 历史数据
INSERT SELECT或clickhouse-copier/ 第三方工具迁移; - 双写或读路径合并。
jumpConsistentHash 在扩 shard 时比 naive mod 更少迁移行------仍是工程级迁移,无在线透明 resharding(与 Citus rebalance 对比见 §九)。
四、INSERT 路由
INSERT INTO events ... 进入 Distributed 表时,协调节点:
- 按 block 计算每行的 sharding expr;
- 按 shard 分组;
- 向各 shard 发送 sub-insert(
internal_replication=true时每个 shard 选一个 replica)。
4.1 批量与 async_insert
小 batch 高频 INSERT 会在 每个 shard 各产生 small parts------放大 too many parts(第 15 篇) 风险。缓解:
- 客户端攒 batch(建议 ≥ 10k 行,视列宽调整);
- 表级
async_insert+wait_for_async_insert; - 入口
Buffer表(注意 Buffer 自身坑,见官方 Buffer table)。
不在此给出吞吐数字 ------须在本集群测 system.events 的 InsertedRows / InsertedBytes。
4.2 insert_distributed_sync
insert_distributed_sync = 1(session 或 profile)时,Distributed INSERT 等待所有 shard 子 insert 完成再返回。默认异步更快但客户端难以及时捕获单 shard 失败。
4.3 直接写本地表
ETL 若已知 target shard,可绕过 Distributed 直写 events_local------须保证 不会写错 shard。运维脚本常用;应用层一般仍写 Distributed。
五、SELECT:子查询下发与二次聚合
SELECT ... FROM events 的典型计划:
- 协调节点把查询改写成对 每个 shard 的本地表 的子查询(可能带相同 WHERE);
- 各 shard 并行执行(受
max_threads、副本选择影响); - 协调节点对 shard 结果 再聚合 (
SUM、COUNT等)。
这与 向量化执行(第 04 篇) 的 pipeline 在协调节点再串一层 Merge 算子。
5.1 副本读:load_balancing
remote_servers 里 replica 的读取策略由 settings 控制,例如:
load_balancing = random(默认类行为)load_balancing = nearest_hostname(同 rack 优先)load_balancing = in_order(故障转移)
只读副本 (readonly 用户 + 副本延迟监控)可把 analytics 流量从 leader 拉开------延迟见 system.replicas(第 14 篇)。
5.2 谓词下推与限制
Distributed 会尝试把 WHERE、PREWHERE(见 查询读取路径(第 05 篇))下推到 shard 子查询。但:
- 依赖分片键 无法 剪枝 shard(仍扫全 cluster);
- 某些函数/UDF 可能阻止下推;
LIMIT下推需optimize_skip_unused_shards等优化配合。
sql
-- 若 sharding key 含 user_id,且 WHERE user_id = 123 常数,可只打单 shard(视优化器版本)
SET optimize_skip_unused_shards = 1;
SELECT count() FROM events WHERE user_id = 123 AND event_date >= today() - 7;
是否生效以 EXPLAIN / EXPLAIN PLAN 为准------须本地执行,本文不粘贴计划文本。
5.3 GLOBAL 二次聚合边界
shard 本地执行 GROUP BY k 后,协调节点收到 部分聚合状态 ,可能再 GROUP BY k 合并。若聚合函数非 decomposable (如 quantileExact 中间态不能简单相加),会触发不同 plan 或报错------设计分布式 SQL 时要查函数是否 combinable。
六、GLOBAL IN 与 GLOBAL JOIN
6.1 普通 IN 的问题
sql
SELECT * FROM events
WHERE user_id IN (SELECT user_id FROM dim_vip_users);
若 dim_vip_users 是 仅存在于协调节点 的小表,或未 Distributed 复制,默认 IN 可能在 每个 shard 子查询里本地执行子查询------子表为空或不全,结果错误。
6.2 GLOBAL IN
sql
SELECT * FROM events
WHERE user_id GLOBAL IN (SELECT user_id FROM dim_vip_users);
语义:先在 协调节点 执行子查询,把结果 广播 到每个 shard,再各 shard 过滤。
| 模式 | 网络 | 内存 | 适用 |
|---|---|---|---|
| IN | 低 | 低 | 子表已复制到各 shard |
| GLOBAL IN | 高(广播) | 协调节点持全集 | 小子表、临时过滤集 |
| JOIN + 复制引擎 | 中 | 各 shard 本地 | 维表定期同步 |
代价 :GLOBAL IN 把协调节点变成 单点内存瓶颈 ------维表百万行级可能 OOM(max_memory_usage)。
6.3 GLOBAL JOIN
sql
SELECT e.*, d.name
FROM events AS e
GLOBAL INNER JOIN dim_users AS d ON e.user_id = d.id
WHERE e.event_date >= today() - 1;
协调节点先拉 dim_users(或子计划),广播到 shard 与 events_local join。宽维表 + 事实表大 scan 时 极慢。
替代架构(工程上更常见):
- 维表复制 :每个 shard 建相同
ReplicatedMergeTree维表,定时INSERT SELECT同步;查询用普通JOIN。 - Dictionary (
ENGINE = Dictionary+dictGet):适合小维表、键值查找。 - 预 join 宽表:ETL 阶段打平------牺牲存储换查询简单。
6.4 distributed_product_mode
global / local / allow 控制分布式 JOIN 行为(见官方 Distributed product mode )。误用 local 可能导致 shard 间笛卡尔积------经典事故模式,第 15 篇会列 symptom。
七、Distributed DDL 与 ON CLUSTER
sql
CREATE TABLE metrics_local ON CLUSTER prod (...);
CREATE TABLE metrics ON CLUSTER prod AS metrics_local
ENGINE = Distributed(prod, currentDatabase(), metrics_local, rand());
| 操作 | 注意 |
|---|---|
CREATE ... ON CLUSTER |
依赖 DDLWorker + Keeper/ZK 队列;失败会留 system.distributed_ddl_queue |
DROP TABLE |
先删 Distributed 再删 local,或反之需团队规范 |
RENAME |
24.x 支持有限;生产倾向建新表迁移 |
ALTER mutation |
各 shard 独立 mutation 队列------大表 ALTER 在 N shard 上放大为 N 倍后台压力 |
检查 DDL 队列:
sql
SELECT entry, query, initiator, status, exception_code, exception_text
FROM system.distributed_ddl_queue
ORDER BY entry DESC
LIMIT 20;
八、源码与实现锚点
ClickHouse 24.x 源码(A 级):
| 路径 | 职责 |
|---|---|
src/Storages/StorageDistributed.cpp |
Distributed 存储引擎入口 |
src/Storages/Distributed/DistributedSink.cpp |
INSERT 路由与发送 |
src/QueryPipeline/RemoteQueryExecutor.cpp |
远程 subquery 执行 |
src/Interpreters/Cluster.cpp |
cluster 拓扑解析 |
src/Interpreters/DDLWorker.cpp |
ON CLUSTER DDL |
阅读 StorageDistributed::read 与 DistributedSink::write 可串起 读写下推 全路径。
九、与 PostgreSQL Citus / 其它分布式边界
| 维度 | ClickHouse Distributed | Citus |
|---|---|---|
| 定位 | OLAP 分片 + 路由 | PG 扩展,OLTP+HTAP |
| 分片单位 | Part(列存) | 行 / 分片表 |
| 跨 shard JOIN | GLOBAL / 复制维表 | Colocated / repartition |
| 事务 | 无跨 shard 单事务 | 有限分布式事务 |
| Reshard | 离线迁移为主 | rebalance_table_shards |
| 协调节点 | 任意能访问 cluster 的 CH 节点 | Coordinator |
PostgreSQL 内核系列 的 B-Tree、MVCC 语义 不迁移 到 ClickHouse------用 Citus 做 PG 水平扩展,用 ClickHouse 做分析副本或独立 OLAP,是常见分工(第 13 篇 展开选型)。
不写 Spark/Flink 全链路------PLAN 边界外。
十、设计模式与反模式
10.1 推荐模式
星型 + 复制维表
- 事实表
Distributed+cityHash64(user_id); - 维表每 shard 完整副本(小表
ReplicatedMergeTree); - 查询普通
JOIN,避免 GLOBAL。
专用集群入口
- BI 只连 query 节点(无本地 heavy data);
- 写入走 load balancer 到多入口 Distributed;
- 减少业务直连 random shard host。
与 Kafka + MV 配合
- 见 物化视图(第 10 篇);
- Kafka Engine 表 不要 直接 Distributed------通常 Kafka → MV → 本地 MergeTree,Distributed 只包本地表。
10.2 反模式
| 反模式 | 后果 |
|---|---|
分片键 rand() 且无业务过滤 |
任何 GROUP BY 全 cluster 扫描 |
| 大维表 GLOBAL JOIN | 协调节点 OOM、查询超时 |
| 每行 INSERT | N shard × small parts → merge 崩溃 |
忽略 internal_replication 双写 replica |
重复数据、副本冲突 |
| Distributed 当唯一备份 | 无 Part 文件,恢复从 local 表 |
十一、排查清单
- 数据倾斜 :
system.parts按hostName()/ 表统计各 shard 行数(需集群级监控或逐 shard 查)。 - 查询慢 :
system.query_log看read_rows、ProfileEvents的DistributedConnection*。 - INSERT 失败单 shard :
insert_distributed_sync=1复现;查目标 shardsystem.errors。 - DDL 卡住 :
system.distributed_ddl_queue+ Keeper 连通。 - 结果不一致:检查 IN vs GLOBAL IN、维表是否全 shard 同步。
详细系统表见 监控(第 14 篇)。
十二、实验框架(须本地执行)
以下命令框架 未在本环境执行------读者在测试集群验证。
12.1 验证分片分布
sql
-- 在每个 shard 的 events_local 上分别执行,对比 count()
SELECT count() FROM events_local;
-- 或通过 Distributed 查 system 表(若配置允许)
SELECT hostName(), count()
FROM cluster('prod', currentDatabase(), events_local)
GROUP BY hostName();
12.2 对比 IN 与 GLOBAL IN 计划
sql
EXPLAIN PLAN
SELECT count() FROM events
WHERE user_id IN (SELECT user_id FROM dim WHERE tier = 'vip');
EXPLAIN PLAN
SELECT count() FROM events
WHERE user_id GLOBAL IN (SELECT user_id FROM dim WHERE tier = 'vip');
对比 Expression 节点是否含 GLOBAL 广播------以本地输出为准。
12.3 制造 skew 观察
sql
-- 测试库:故意用常量分片键观察单 shard 膨胀(勿在生产)
CREATE TABLE skew_test_local (...) ENGINE = MergeTree ...;
CREATE TABLE skew_test AS skew_test_local
ENGINE = Distributed('test_cluster', currentDatabase(), skew_test_local, 1); -- 常数键
十三、配置项速查
| Setting | 作用 |
|---|---|
optimize_skip_unused_shards |
常量分片键过滤时跳过无关 shard |
optimize_skip_unused_shards_limit |
跳过优化的 shard 数上限 |
insert_distributed_sync |
同步等待各 shard insert |
distributed_product_mode |
JOIN 分布式行为 |
max_query_size |
大 IN 列表解析 |
distributed_group_by_no_merge |
高级:控制二次 GROUP BY |
prefer_localhost_replica |
协调节点本地 replica 优先 |
完整容量相关见 配置与容量(第 16 篇)。
十四、学术谱系:MPP 路由 · Shuffle 代价 · GLOBAL IN
Distributed 引擎是 查询/写入路由器,不是第二套存储------与经典 MPP「coordinator + workers」同构,但数据仍在各 shard 的 MergeTree。
| 坐标 | 含义 | 本篇落点 |
|---|---|---|
| 分片键 | 决定写入目标与部分剪枝 | INSERT / 部分 SELECT |
| Shuffle / 广播 | 跨节点连接代价 | GLOBAL IN / JOIN |
| 与向量化 | 协调节点再串 Merge 算子 | 第 04 篇 pipeline |
14.1 争论与开放问题
- 争论:应用层双写各 shard vs 依赖 Distributed 路由。前者可控,后者省事但扩容难。
- 开放问题 :无透明 rebalance 时,业务是否应默认「按租户分集群」而非「大 Distributed」?可检验:resharding 窗口与查询正确性用例(入口:CH Distributed;MPP 教科书 shuffle 章节)。
十五、小结
| 问题 | 答案 |
|---|---|
| Distributed 存数据吗? | 否,只路由 |
| 分片键影响什么? | INSERT 目标 shard、部分查询剪枝 |
| GLOBAL IN 何时用? | 小子表未复制到各 shard |
| 与副本关系? | internal_replication=true 时 INSERT 不双写 replica |
| 扩容? | 离线 resharding,无透明 rebalance |
附录 A、GLOBAL JOIN 改写清单
| 原写法 | 问题 | 改写 |
|---|---|---|
GLOBAL JOIN 大维表 |
协调节点 OOM | 每 shard 复制维表 + 普通 JOIN |
IN (SELECT ...) 协调库小表 |
shard 子查询为空 | GLOBAL IN 或复制 |
| 两 fact 表 JOIN | 跨 shard _shuffle | 预聚合 / 同 sharding key colocate |
DISTINCT 子查询过滤 |
广播大 set | JOIN 复制表或 Dictionary |
附录 B、分片键评审问卷
- 写入 QPS 与 batch 大小?小 batch 则先优化 ingest 再谈分片数。
- Top 10 查询是否带 sharding key 等值过滤?
- 是否存在「大用户」skew?是否需要 salt(如
cityHash64(user_id, bucket_id))? - 扩容计划:是否接受 offline copy?
- 维表大小:是否适合每 shard 全量复制?
附录 C、网络与防火墙
Distributed 查询在 shard 间建立 大量 outbound 连接 (端口通常 9000/native 或 9440/TLS)。协调节点到各 shard、shard 间 interserver_http(复制)需放通。跨 AZ 部署时 RTT 增加会放大 scatter-gather 延迟------无量化延迟,但架构上应同 AZ 放 query 与 data。
附录 D、术语表
| 术语 | 含义 |
|---|---|
| Scatter-gather | 协调节点下发子查询再合并 |
| Sharding key | Distributed 第四参数表达式 |
| Global subquery | 协调执行后广播到 shard |
上一篇 :ReplicatedMergeTree
下一篇 :物化视图与增量管道
参考资料
核心论文 / 坐标
- 经典 MPP / shuffle 教科书坐标(跨节点连接代价)------与本篇
GLOBAL IN/JOIN工程化对应;具体教材不替代 CH 文档。
规范 / 源码 / 文档
- ClickHouse Documentation, Distributed table engine 、GLOBAL IN/JOIN(24.x;A 级)。
- ClickHouse Source, release-24.x,
StorageDistributed.cpp、DistributedSink.cpp(A 级)。 - Citus Documentation, Distributed Tables --- 对照边界(B 级)。