ClickHouse 集群建表实战:从引擎选择到本地表 / 分布式表落地

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

三条铁律:

  1. 必须严格单调递增 。写入方要保证"新记录"的 update_time 一定大于"旧记录"。用 now() 生成的话,并发写入、时钟回拨、重放历史数据都可能出问题。
  2. 去重是异步的。合并触发前,查询会返回多条。
  3. 不参与 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 落在它绑定的节点 = 连接级随机分片,数据是分散的。

三个关键细节:

  1. 路由粒度是连接级不是语句级。连接池内的连接建立后固定绑定某节点,期间所有 INSERT 都走同一节点。均衡精度取决于连接数 × LB 策略,不会精确到行。
  2. 节点宕机时自愈。池内坏连接报错、重建后 LB 重新分发,无需人工干预。
  3. 写入不区分主备副本。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 + 写入量级"决定

下次建表,先看同库已有的同类表怎么写,改表名和业务字段就行。这才是"知道"的真正来源。

相关推荐
donk131416 小时前
React Native for OpenHarmony 实战:三方库 react-native-step-indicator 的鸿蒙化适配指南
分布式·react native·react.js·wpf·harmonyos
梅菲塔亚18 小时前
Ceph分布式存储
分布式·ceph
acrel720 小时前
Acrel-1000系列分布式光伏监控系统在湖北荆门一马光彩大市场屋顶光伏发电项目中应用
分布式·光伏并网
程序 代码狂人20 小时前
金融数仓体系与核心业务知识详解
大数据·分布式·spark
深蓝电商API21 小时前
亿级数据的存储选型:MongoDB/ES/ClickHouse横评
爬虫·clickhouse·mongodb·elasticsearch
java_nnnn1 天前
Redis 初识:从单体架构到分布式架构的演进
redis·分布式·架构
Sayai1 天前
Kafka 2.8→3.9.1 跨大版本升级实战:ZooKeeper 同步升级、数据目录迁移与回滚预案全流程
大数据·运维·分布式·zookeeper·kafka
敲上瘾1 天前
分布式系统——Redis哨兵:自动故障转移与选举原理
linux·数据库·redis·分布式·缓存·微服务