ClickHouse 集群建表实战:从引擎选择到本地表 / 分布式表落地
一次把
MergeTree/ReplacingMergeTree/ReplicatedReplacingMergeTree三者讲清,从引擎参数到"本地表 + 分布式表"的完整建表流程,并给出一套"从环境 + 业务倒推参数"的决策链。
一、起因
给一张"设备每日流量"表写 DDL 时,冒出了一堆问题:
- 引擎到底该写
MergeTree、ReplacingMergeTree,还是ReplicatedReplacingMergeTree? - 引擎括号里那三个参数分别是什么?
- 为什么版本字段要"由写入方显式传入",不能
DEFAULT now()? {shard}/{replica}这些占位符,我怎么知道该填什么?- 本地表和分布式表是什么?都要建吗?写哪个、读哪个?
网上搜到的都是"某某参数是什么意思"的碎片答案,很少有人讲怎么倒推出这些参数。这篇博客把推导过程完整记录一下。
二、三种引擎的区别
ReplacingMergeTree 家族共用"后台合并"底层,区别在去重 和副本两个维度:
| 引擎 | 去重 | 副本 | 典型场景 |
|---|---|---|---|
MergeTree |
❌ | ❌ | 追加型日志、事件流 |
ReplacingMergeTree(ver) |
✅ 按 ORDER BY 去重 | ❌ | 单机、状态表 |
ReplicatedReplacingMergeTree(zk, replica, ver) |
✅ 同左 | ✅ | 集群、生产 |
关键认知 :Replicated* 版本只多一个副本同步能力,去重语义和单机版完全一致。同一个 DDL 只要把引擎名换掉,其余可以不变。
MergeTree 与 ReplacingMergeTree 的差异
MergeTree 是最基础的引擎,写入多次就存多份,不做任何去重:
sql
INSERT INTO t VALUES ('A', '2026-10-15', '1.1.1.1', 100);
INSERT INTO t VALUES ('A', '2026-10-15', '1.1.1.1', 200);
SELECT * FROM t; -- 返回两条
ReplacingMergeTree(update_time) 则会在后台合并时,按 ORDER BY 键分组,每组只保留版本字段最大的那条。
选择原则:
- 事件流、埋点 ------ MergeTree
- 每键保留最新(可能重复上报、需要修正) ------ ReplacingMergeTree
- 生产集群 ------ 加
Replicated前缀
三、ReplicatedReplacingMergeTree 三个参数逐字段拆解
sql
ENGINE = ReplicatedReplacingMergeTree(
'/clickhouse/tables/{shard}/device_daily_traffic', -- 参数 1:ZK 路径
'{replica}', -- 参数 2:副本名
update_time -- 参数 3:版本字段
)
参数 1:ZooKeeper/Keeper 路径
告诉 ClickHouse "副本同步信息记在 ZK 的哪个节点下"。同一分片的副本必须写完全相同的路径,这是它们能互相发现、同步数据的前提。
模板:
/clickhouse/tables/{shard}/<表名>
/clickhouse/tables/是官方约定前缀{shard}是宏,展开成当前节点的分片 ID(如01)- 表名做后缀,保证每张表独立
展开后示例(同分片的两个副本):
| 节点 | macros 里 shard | 展开后路径 |
|---|---|---|
| node-1 | 01 | /clickhouse/tables/01/device_daily_traffic |
| node-2 | 01 | /clickhouse/tables/01/device_daily_traffic |
路径相同 → 相互发现 → 数据同步 ✅
不同分片路径不同 → 不同步 → 这是正确的,分片间本来就不该同步。
参数 2:副本名 {replica}
当前副本在分片内 的唯一标识。同分片内不同副本的 {replica} 必须不同。
在 ZK 里的实际结构:
/clickhouse/tables/01/device_daily_traffic
├── replicas
│ ├── replica-01 ← 副本 1
│ └── replica-02 ← 副本 2
└── log ← 同步事件日志
注意 :{replica} 是逻辑副本 ID,不是主机名。同一个副本名在 ZK 里必须全局唯一。踩过的坑:用 ch-{hostname} 做副本名,然后换机器忘了改 macros,两个物理节点用了同一个副本名 → ZK 里前一个被踢掉,副本状态错乱。
参数 3:版本字段
ReplacingMergeTree 家族特有的参数------同一去重键下有多条记录时,保留版本字段值最大的那条。
sql
-- 去重键来自 ORDER BY
GROUP BY (account_id, report_date, mac)
-- 每组保留
ORDER BY update_time DESC LIMIT 1
三条铁律:
- 必须严格单调递增 。写入方要保证"新记录"的
update_time一定大于"旧记录"。用now()生成的话,并发写入、时钟回拨、重放历史数据都可能出问题。 - 去重是异步的。合并触发前,查询会返回多条。
- 不参与 ORDER BY。它是版本字段,不是排序键。
| 写法 | 保留哪条 |
|---|---|
ReplacingMergeTree() |
保留最后插入的(不可靠) |
ReplacingMergeTree(update_time) |
保留 update_time 最大的(推荐) |
新版本还支持:
sql
-- 多字段版本,按 (v1, v2) 字典序比较
ReplacingMergeTree(v1, v2)
-- 加 is_deleted(UInt8),=1 表示软删除
ReplacingMergeTree(update_time, is_deleted)
四、其他子句
sql
PARTITION BY toYYYYMMDD(report_date) -- 按天分区
ORDER BY (account_id, report_date, mac) -- 排序键 + 去重键
SETTINGS index_granularity = 8192 -- 稀疏索引粒度
PARTITION BY
按查询维度切分数据块 。命中分区键时会做分区裁剪,只扫相关分区;同时支持按分区快速 DROP PARTITION 清理历史。
| 场景 | 分区键 |
|---|---|
| 时间序列、日报、日志 | toYYYYMMDD(date) |
| 数据量大、按天分区太多 | toYYYYMM(date) |
| 特殊业务维度 | 按业务字段 |
注意:分区数会持续增长,如果不配 TTL 也没有外部清理,几年后元数据会膨胀。
ORDER BY
同时承担排序键和去重键两个职责------这是最容易搞混的地方。
问:什么组合唯一标识一条逻辑记录?
sql
WHERE account_id = ? AND report_date = ? AND mac = ?
三个字段合起来唯一确定 → ORDER BY (account_id, report_date, mac)。
它是业务语义,不是技术选择。放多了会把本应去重的行拆开,放少了会误合并。
SETTINGS index_granularity
每个数据块多少行建一个稀疏索引条目。默认 8192 够用。
五、本地表 vs 分布式表
这是 ClickHouse 集群建表最重要的概念。
两者的角色
| 表类型 | 引擎 | 是否存数据 | 用途 |
|---|---|---|---|
| 本地表(Local) | Replicated* |
存 | 真正的数据,副本同步 |
| 分布式表(Distributed) | Distributed |
不存 | 查询入口,路由到各分片 |
核心理解:
- 本地表是数据真正的家。每个节点各存一份,同分片的副本之间同步。
- 分布式表 是一个"视图"------它本身不存数据,只是把查询/写入分发到各分片的本地表,然后把结果汇总返回。
- 写入时:可以写本地表,也可以写分布式表。
- 读取时 :查分布式表,否则只能看到当前分片的数据。
建表结构(标准姿势)
以"2 分片 × 2 副本"的集群为例:
sql
-- 1. 本地表:每台机器各建一份,副本间同步。用 _local 后缀区分。
CREATE TABLE analytics.device_daily_traffic_local ON CLUSTER my_cluster
(
`account_id` String COMMENT '账号',
`report_date` Date COMMENT '统计日期',
`mac` String COMMENT '设备 mac;"__gateway__" 表示主网关昨日总流量',
`flow_bytes` UInt64 COMMENT '昨日流量(字节)',
`online_time_sec` UInt32 COMMENT '昨日在线时长(秒)',
`create_time` DateTime DEFAULT now() COMMENT '记录创建时间',
`update_time` DateTime COMMENT '版本字段,由写入方显式传入'
)
ENGINE = ReplicatedReplacingMergeTree(
'/clickhouse/tables/{shard}/device_daily_traffic',
'{replica}',
update_time
)
PARTITION BY toYYYYMMDD(report_date)
ORDER BY (account_id, report_date, mac)
SETTINGS index_granularity = 8192
COMMENT '每日流量表 - 本地表(写路径入口)';
-- 2. 分布式表:查询入口,不存数据。名字不带后缀。
CREATE TABLE analytics.device_daily_traffic ON CLUSTER my_cluster
AS analytics.device_daily_traffic_local
ENGINE = Distributed(
my_cluster, -- 集群名
analytics, -- 库名
device_daily_traffic_local, -- 本地表名
cityHash64(account_id) -- 分片键
);
命名约定
- 本地表 :
<表名>_local(或_all、_replica等,看团队约定) - 分布式表 :
<表名>,作为业务查询入口
这个约定很重要:业务层永远只认识不带后缀的分布式表;本地表是"内部实现"。
ON CLUSTER 语法
sql
CREATE TABLE ... ON CLUSTER my_cluster
作用 :一条 DDL 在集群所有节点上同时执行。
- 不需要登到每台机器上手动建表
my_cluster是集群名,从system.clusters里查ON CLUSTER语法适用于 CREATE / ALTER / DROP 等 DDL
不带 ON CLUSTER 的后果:只在你连的那个节点建表,其他节点上不存在 → 副本同步报错、写入失败。
Distributed 引擎四个参数
sql
ENGINE = Distributed(cluster, database, table, sharding_key)
| 参数 | 说明 |
|---|---|
cluster |
集群名,对应 system.clusters.cluster |
database |
目标本地表所在的库 |
table |
目标本地表名 |
sharding_key |
分片键:决定写入时数据落到哪个分片 |
分片键怎么选?
目标 :让数据均匀分布 到各分片,同时同一业务键的数据尽量落到同一分片(让本地查询更快)。
| 场景 | 分片键 |
|---|---|
| 按用户均匀分 | cityHash64(user_id) |
| 按设备均匀分 | cityHash64(mac) |
| 按天均匀分 | toYYYYMMDD(report_date) |
| 随机分布 | rand() |
不要直接用自增整数字段 (如 user_id)------自增 ID 会导致数据倾斜,老分片一直在写、新分片空闲。用 cityHash64() 打散。
六、写入路径的实战选择
前面说过"写入时,可以写本地表,也可以写分布式表"。这句话背后其实有个容易踩坑的决策------默认推荐写分布式表,但并非绝对。我们来拆开看。
方式 A:写分布式表
sql
INSERT INTO analytics.device_daily_traffic VALUES (...);
优点:
- 应用无需知道集群拓扑,一个入口搞定
- ClickHouse 按分片键自动路由每行
- 应用代码干净,加节点不用改代码
缺点:
- 多一次转发:数据先落到接收节点,再异步转发到目标分片
- 写入高峰时接收节点有转发压力
- 分片不可用时可能部分成功
- 转发产生碎 part,后台 merge 压力大
方式 B:写本地表(性能优化场景)
sql
INSERT INTO analytics.device_daily_traffic_local VALUES (...);
优点:
- 无转发开销,直写本地磁盘
- 大批量写入时性能更好
- 不产生转发碎 part
缺点:
- 应用需要连接到某个节点(或经 LB),落点由连接决定
- 数据是否均匀分布取决于连接层均衡策略
决策框架
| 场景 | 推荐 |
|---|---|
| 中小规模、追求开发效率 | 写分布式表 |
| 无 LB、应用直连单节点 | 写分布式表(让 CK 帮路由) |
| 有 LB 做连接层均衡 | 写本地表(避免转发开销) |
| 大规模、写入吞吐瓶颈 | 写本地表(直写各分片) |
| ETL 批量导入 | 写本地表 |
判断原则:如果写入 QPS × 单条大小达到几百 MB/秒级别,分布式表的转发开销会成瓶颈,需要让连接层分担路由。
LB 的"隐式路由"
"写本地表"看起来像没做路由------但如果应用连接的 URL 指向 LB(负载均衡端口),路由其实已经发生了,只是发生在连接层而不是应用层:
应用 (HikariCP 连接池,比如 10 条连接)
│ 每条连接建立时被 LB 分发到某个后端节点
▼
LB (10.x.x.172:9000)
├──► 节点 A:分片 1 副本 1 ── 写本地表
├──► 节点 C:分片 2 副本 1 ── 写本地表
└──► ...
连接池里多条连接被 LB 摊到各节点,每条连接的 INSERT 落在它绑定的节点 = 连接级随机分片,数据是分散的。
三个关键细节:
- 路由粒度是连接级不是语句级。连接池内的连接建立后固定绑定某节点,期间所有 INSERT 都走同一节点。均衡精度取决于连接数 × LB 策略,不会精确到行。
- 节点宕机时自愈。池内坏连接报错、重建后 LB 重新分发,无需人工干预。
- 写入不区分主备副本。LB 分发的目标节点是哪个副本,数据由 ReplicatedMergeTree + ZK 自动同步到其他副本。
这类架构下,写本地表比写分布式表更优:省去了转发开销,且数据仍然均匀(LB 摊开连接池)。
七、读取路径:为什么"全分片聚合"不会慢
业务查询只查分布式表:
sql
-- ✅ 返回全集群数据
SELECT * FROM analytics.device_daily_traffic WHERE report_date = today();
-- ❌ 只返回当前节点数据(调试用)
SELECT * FROM analytics.device_daily_traffic_local;
很多人会问:分布式表要"全分片聚合",不会慢吗?不会,恰恰相反:分片的价值就是让查询更快 。关键在于"全分片聚合"不是串行扫全部数据,而是并行分治。
分布式查询的真实执行模型
应用查询 dis_device_daily_traffic (account_id=? AND report_date=?)
│
▼
发起节点成为"协调者"(initiator),把子查询同时发给全部分片
├──► 分片 1:本地表执行(排序键命中 + 分区裁剪,只扫几个 granule)─┐
├──► 分片 2:本地表执行(并行,同样毫秒级)───────────────────┤→ 汇聚 → 返回
└──► 分片 3:本地表执行(并行)───────────────────────────────┘
耗时 = max(各分片本地耗时) + 一次内网汇聚,不是三个分片相加。
用数字理解
假设 3 个分片、共 30 亿行数据:
| 方式 | 每个分片扫多少 | 相对耗时 |
|---|---|---|
| 不分片(单机扛 30 亿行) | 30 亿 | 100% |
| 3 分片并行(各 10 亿行) | 10 亿 | ≈ 33% |
总扫描量没变,但时间是并行的。
真正的开销与优化时机
| 开销项 | 量级 | 什么时候需要优化 |
|---|---|---|
| 扇出(分发子查询到各分片) | 内网一次 RTT,几 ms | 分片数 ≥ 几十、或 QPS 极高时 |
| 汇聚中间结果 | 点查场景几乎为 0 | 大范围聚合查询时 |
| 分片本地执行 | 主成本,本来必须有 | 永远 |
优化手段:
- 分片键改
cityHash64(account_id)+ 开启optimize_skip_unused_shards - 让点查(
WHERE account_id = ?)只打 1 个分片 - 分片数少(个位数)时,扇出的几 ms 完全无感,不用管
八、倒推决策链:怎么知道该写什么
参数不是背下来的,是从环境 + 业务推出来的。
决策流程
问题:给"设备每日流量"建表,引擎怎么写?
1. 集群是多副本吗? 是 → 用 Replicated 前缀
2. 同一条会重复写吗? 会 → 用 ReplacingMergeTree
3. 什么唯一标识一条记录? (account_id, report_date, mac) → ORDER BY
4. 哪个字段是最新版本? update_time → 引擎第三个参数
5. 按什么维度切分/清理? 按天 → PARTITION BY toYYYYMMDD(report_date)
6. ZK 路径模板? 抄现有表 → /clickhouse/tables/{shard}/<表名>
7. {replica} 填什么? 照抄集群约定
8. 要不要分布式表? 集群 → 要;单机 → 不要
9. 分片键? 看写入路径:写 dis_ → 选业务键;写本地表 → 不重要
第一步:先看集群环境
这一步不是你选的,是运维早就配好的。
sql
SELECT * FROM system.clusters; -- 有几个集群、几个分片、几个副本
SELECT * FROM system.macros; -- 本节点有没有配 {shard} / {replica}
- 查出多个副本 → 用 Replicated
- 只有一台机器、没有 macros → 用普通版
第二步:看业务会不会重复写
| 场景 | 引擎 |
|---|---|
| 追加型、重复写也无所谓 | MergeTree |
| 每键一条、可能重复上报或修正 | ReplacingMergeTree |
| 按维度累加 | SummingMergeTree |
第三步起:由业务语义决定
ORDER BY是业务唯一键- 版本字段是业务上代表"最新"的时间戳
PARTITION BY是查询和维护的维度
ZK 路径和 {replica}:抄现有表
sql
SELECT engine_full FROM system.tables
WHERE engine LIKE '%Replicated%' LIMIT 5;
新表照抄现有表 ,只改表名。这就是"我怎么知道"的标准答案------和旁边那张表一样。
九、怎么查现有表的真实参数
只用 SQL 客户端(DBeaver 等),不需要登录服务器。
1. 看建表时的原始定义
sql
SELECT engine_full
FROM system.tables
WHERE database = 'analytics' AND name = 'device_daily_traffic_local';
2. 看当前节点的宏值
sql
SELECT * FROM system.macros;
返回:
| macro | substitution |
|---|---|
| shard | 01 |
| replica | replica-01 |
3. 看这张表在 ZK 里的实际路径和副本名(最关键)
sql
SELECT database, table, zookeeper_path, replica_name, replica_path
FROM system.replicas
WHERE table = 'device_daily_traffic_local';
4. 集群里查所有节点
sql
-- 先看集群名
SELECT DISTINCT cluster FROM system.clusters;
-- 让每个节点各自跑一遍,结果汇总
SELECT hostName() AS host, macro, substitution
FROM clusterAllReplicas('my_cluster', system.macros)
ORDER BY host, macro;
5. 查分布式表的定义
sql
SELECT engine_full
FROM system.tables
WHERE database = 'analytics' AND name = 'device_daily_traffic';
-- 返回 Distributed(my_cluster, analytics, device_daily_traffic_local, cityHash64(account_id))
6. 查集群全貌
sql
-- 看分片数量与拓扑
SELECT shard_num, replica_num, host_address, port, host_name
FROM system.clusters
WHERE cluster = 'my_cluster';
-- 看各分片实际数据量与最近写入时间
SELECT hostName() AS host,
sum(rows) AS rows,
max(modification_time) AS last_write
FROM clusterAllReplicas('my_cluster', system.parts)
WHERE database = 'analytics' AND table = 'device_daily_traffic' AND active
GROUP BY host
ORDER BY host;
-- 看某条数据的实际分布
SELECT hostName() AS host, count() AS cnt
FROM clusterAllReplicas('my_cluster', analytics.device_daily_traffic)
WHERE account_id = '实际值'
GROUP BY host;
十、版本字段的深入理解
版本字段 vs ORDER BY 里的字段
两者是独立的:
| 概念 | 作用 | 来自 |
|---|---|---|
| 去重键 | 决定"哪些行算同一条" | ORDER BY 里的字段 |
| 版本字段 | 决定"同一条里保留哪条" | 引擎第三个参数 |
为什么不能 DEFAULT now()
sql
`update_time` DateTime DEFAULT now() -- ❌ 危险
now()是服务器时间,写入方无法控制- 补数据时用了旧时间戳 → 版本比现有数据小 → 去重保错
- 多写入源时钟不同步 → 版本比较失去意义
正确做法:写入方生成单调递增的时间戳显式传入。
去重是异步的
sql
-- insert 完立刻查,可能返回多条(还没 merge)
SELECT * FROM device_daily_traffic;
-- 想要强一致,用 FINAL(有性能损耗)
SELECT * FROM device_daily_traffic FINAL;
-- 或强制触发合并(生产慎用,会打满 IO)
OPTIMIZE TABLE device_daily_traffic FINAL;
十一、常见坑
| 坑 | 表现 |
|---|---|
同分片副本的 {shard} 配得不一样 |
各自为政,数据不同步 |
不同机器 {replica} 配成同一个值 |
ZK 里互相顶替,副本状态混乱 |
update_time 时钟回拨或写入顺序错乱 |
去重保留的不是"最新",而是"版本值最大" |
| 忘配 macros | 建表直接报错,找不到 {shard} / {replica} |
| 只在单节点建表 | 其他副本节点上该表不存在,写入报错 |
忘了 ON CLUSTER |
只在当前节点建表,其他节点同步失败 |
本地表名不带 _local 后缀 |
和分布式表重名冲突 |
| 分片键用了自增 ID | 数据倾斜,老分片忙死新分片闲死 |
| 业务查了本地表而不是分布式表 | 只看到当前分片的数据 |
版本字段用 DEFAULT now() |
补数、多源写入时版本乱序 |
| 两条记录版本相同 | 保留哪条不确定,结果不幂等 |
只查询不 FINAL |
合并前看到重复行 |
| 表无 TTL、无清理 | 分区无限增长,元数据膨胀 |
十二、一句话记忆
引擎决定"怎么存 + 怎么去重 + 怎么副本",PARTITION BY 决定"按什么切分数据块",ORDER BY 决定"按什么顺序存 + 按什么去重",SETTINGS 决定"底层索引细节"。
本地表存数据,分布式表做路由------业务读取只认分布式表,本地表是内部实现。
写入则有两条路径:没有 LB 时写分布式表 ,让 CK 帮你路由;有 LB 时写本地表,避免冗余转发,让连接层分担路由。
三者中最关键的是 ORDER BY------它既是排序键,也是去重键,直接决定"什么数据算同一条"。它是业务语义,不是技术选择。
十三、结语
最初我以为建表时需要"知道"每个参数该填什么,后来发现答案其实是:
参数不是背下来的,是倒推出来的。
- 引擎类型:由"集群 + 业务语义"倒推
- ZK 路径 /
{replica}:由集群运维约定固定(抄现有表)ORDER BY/ 版本字段 / 分区键:由"业务唯一键 + 更新语义 + 查询维度"倒推- 本地表 / 分布式表:集群必建两者,本地表存数据、分布式表做入口
- 写入路径(本地 / 分布式):由"是否有 LB + 写入量级"决定
下次建表,先看同库已有的同类表怎么写,改表名和业务字段就行。这才是"知道"的真正来源。