把 MySQL 的建表习惯原样搬到 ClickHouse,第一周大概率会撞两件事:查询慢得不像"列存分析引擎",以及日志里躺着一条 Too many parts。
这两件事看着不相干,根子是同一个:ClickHouse 的"主键"和"分区"跟 OLTP 里名字一样的那两个东西,机制完全不同。下面把排序键、分区键、稀疏索引拆开讲,最后落到一张日志表的完整建表语句上。
一、先把三个名字拆开
MergeTree 家族建表时会碰到三个键,作用完全不重叠:
| 名称 | 写在 DDL 的位置 | 决定什么 | 不写会怎样 |
|---|---|---|---|
| 排序键 | ORDER BY (a, b, c) |
每个 data part 内部数据的物理排列顺序,同时也是稀疏索引的生成依据 | MergeTree 必填,没有默认值 |
| 主键 | PRIMARY KEY (...) |
键值的稀疏索引;必须是排序键的前缀 | 不指定时默认等于排序键 |
| 分区键 | PARTITION BY toYYYYMM(t) |
把数据切成一堆互相独立的 part 集合 | 不写就是单分区,全部数据挤在一起 |
两个高频误解,官方文档里专门点名过:
误解一:主键唯一。 主键不强制唯一,重复键值的行可以共存,不报错;要唯一性只能自己做去重(ReplacingMergeTree 也只是"最终"去重,不是约束)。
误解二:主键等于索引。 主键只负责"按这个顺序落盘"。在 ORDER BY (service_name, event_time) 的表上只按 event_time 过滤,首列没命中,稀疏索引基本帮不上忙,照样得扫大量数据块。
二、稀疏索引:为什么它敢扛 PB 级
OLTP 的 B+Tree 是稠密索引,每一行都有一条索引项。ClickHouse 反过来:它按 ORDER BY 把数据排好序落盘,然后每 8192 行(一个 granule,默认粒度)只记一条键值,形成一份极轻量的稀疏索引。
这份索引小到可以整个装进内存。查询时的路径是这样:
- 用稀疏索引在内存里二分,定位可能命中的 granule;
- 只在这些 granule 里做顺序扫描 + 表达式过滤,其余 granule 直接跳过。
这个设计是明确的取舍:牺牲单行点查,换高吞吐的范围扫描和列级压缩。拿它当 KV 用、一条一条 WHERE id = ? 地查,性能会很难看;反过来,扫几亿行做聚合,它能把 MySQL 甩开两三个数量级。
三条直接推论,后面所有设计原则都由它们推出来:
- 过滤列不在排序键里 → 索引裁剪失效 → 全表扫;
- 列序不按基数升序 → 相同值分散在不同 granule → 压缩率掉下来;
- 排序键列太多 → 索引体积变大、排序开销上升 → 写入变慢。
三、排序键怎么选:三条硬规则
官方主键设计文档给的建议可以压成三句话:
规则一:只放高频过滤列,通常不超过三列。 判断标准很实在——这一列会不会出现在 WHERE、GROUP BY、JOIN 条件里?不会就别放。曾见过把十个业务字段一股脑塞进 ORDER BY 的表,索引膨胀、写入变慢,查询一点没快。
规则二:首列要"高频 + 高筛选率"。 首列决定能裁掉多少 granule,影响力最大。日志场景几乎总是时间字段:绝大多数查询都带时间范围,把时间放第一位,范围外的 granule 直接被跳过。
规则三:后面的列按基数升序,低基数在前。 这样相同值连续落盘,压缩算法才能有效去重。反例是 ORDER BY (user_id, country)——user_id 基数极高,把 country 打散到几乎每个 granule,压缩率白白损失。
同一个日志需求,两种写法:
-- 正例:首列时间做裁剪,service_name 基数中等放第二,level 基数最低放最后
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, service_name, log_level)
-- 反例:首列是低基数的 level,裁剪能力极差;user_id 前置又把压缩率打散
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (log_level, user_id, event_time)
反例不是"跑不起来",是"跑得起来但一直很慢",这种问题最难被发现——它不报错,只是每次查询都多扫十倍数据。
四、分区键:它是数据管理工具,不是加速开关
这是最容易搞反的一点。官方文档的原话是:分区主要用于数据过期、冷热分层、批量删除,而不是首选的查询优化工具。跨全部分区查询甚至可能比不分区更慢——part 更碎,要扫的 part 更多。
选分区键的四条:
① 基数要低,分区总数控制在 100~1000。 这是硬区间。分区过多会让元数据、文件句柄、内存一起吃不消,更致命的是合并只在分区内部进行——分区一多,part 合不上,小 part 越堆越多。
② 按时间分区,按月或按天。 用 toYYYYMM(event_time) 或 toDate(event_time),天然对齐"按月归档、按月清理"的运维动作。
③ 别拿高基数字段分区,也别切太细。 user_id、order_id、device_id 一个值一个分区,分区数直接爆炸;toYYYYMMDDhhmm(event_time) 一天 1440 个分区,一年五十多万个,等于自己造事故。典型报错就是:
DB::Exception: Too many parts (N). Merges are processing significantly slower than inserts
④ 用原始字段或简单表达式。 别套复杂函数,否则分区裁剪用不上。
分区真正的价值在删除路径上:
| 删除方式 | 扫描量 | 耗时量级 | 空间回收 |
|---|---|---|---|
ALTER TABLE ... DELETE WHERE |
扫全表定位行,生成 mutation | 分钟到小时级,随数据量线性增长 | 后台异步,需等 merge |
DROP PARTITION 202609 |
直接删目录,不扫数据 | 秒级 | 立即释放 |
按月分区之后,清理三个月前的数据就一句 ALTER TABLE logs DROP PARTITION 的事;不分区的话,只能上 ALTER TABLE DELETE,几亿行扫下来,磁盘 IO 先撑不住。
五、合起来:一张日志表的完整设计
需求很普通:按天查询、按服务名过滤、保留 6 个月、要能快速删历史。
CREATE TABLE logs
(
event_time DateTime,
service_name LowCardinality(String),
log_level LowCardinality(String),
trace_id String,
message String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time) -- 按月分区:对齐 6 个月保留策略
ORDER BY (event_time, service_name) -- 排序键:时间先裁 granule,服务名在块内过滤
TTL event_time + INTERVAL 6 MONTH DELETE -- 结合分区,过期数据自动清
SETTINGS index_granularity = 8192;
查一条典型语句:
SELECT log_level, count()
FROM logs
WHERE service_name = 'order'
AND event_time >= now() - INTERVAL 1 DAY
GROUP BY log_level;
验证裁剪有没有生效,不要凭感觉,直接看执行计划:
EXPLAIN indexes = 1
SELECT count() FROM logs
WHERE service_name = 'order'
AND event_time >= now() - INTERVAL 1 DAY;
输出里重点看两处:Partitions 的选中比例(是不是只扫了 1 个月而不是 6 个)、PrimaryKey 的 Granules 比例(示意形如 12/1440)。两个都是个位数百分比说明设计到位;Granules 接近全选,说明排序键没覆盖住这个查询。
排序键永远是取舍:如果业务里"只按服务名、不带时间"的查询占了大头,就把 service_name 提到首位,代价是时间范围裁剪和压缩率。分区键则尽量只服务生命周期,别跟着查询跑。
六、跳数索引:排在最后才考虑
排序键覆盖不到的过滤列,可以用跳数索引(data skipping index)补,比如 bloom_filter、minmax、set。但它有明确的优先级:
- 先改排序键(覆盖高频过滤列);
- 再想预计算——投影(Projection)或物化视图,把高频聚合先算好;
- 最后才是跳数索引。
原因是跳数索引有双重开销:写入时要构建索引(拖慢写入),查询时要先评估索引再去扫 part(索引没筛掉东西就是纯亏)。官方给的判定标准很硬——索引要能跳过 90% 以上的数据块才有意义。所以别一上来给五个列建五个索引,那通常只是把表结构搞复杂、把写入搞慢。
七、写入侧:part 数量是另一条命门
Too many parts 不只来自分区设计,也来自写入方式。每次 INSERT 都生成新的 part,合并是后台异步做的。如果每秒来一次几百行的小批量写,part 产生速度就会超过合并速度,最终把表卡住。
实践上就三条:攒批写(零散记录在应用侧或消息队列里聚合,单批做到几千到十万行量级,按单行大小和内存算)、控制写入并发(多客户端狂写同一张表,等于人为制造小 part)、别频繁小批量 mutation(ALTER UPDATE/DELETE 每次都会重写 part)。分区键基数一高,每次插入要写的 part 也跟着涨——这两个问题同一个源头。
八、版本与账单:先想清楚要哪一种
| 维度 | 社区兼容版 | 企业版 |
|---|---|---|
| 架构 | 计算存储耦合,节点对等,跑在 ECS + 云盘上 | 存算分离,OSS 共享存储,Serverless 弹性 |
| 弹性 | 手动变配、平滑水平扩容 | 按负载秒级自动弹升弹降,扩缩容不迁数据 |
| 内核 | 22.8 / 21.8 / 20.8 等 | 23.8(含 Lightweight Update/Delete、ClickHouse Keeper) |
| 计费 | 计算 + 存储,包年包月或按量 | 计算按 CCU 小时、存储按 GB 小时,全按量 |
社区兼容版有两个计费细节要提前知道:存储按"购买容量"收费,不按实际占用——买 100GB 只用了 20GB,照样按 100GB 计费,别为"以后可能要用"一次买大;备份免费额度 = 100% × 集群存储空间,超出部分才按量,冷热分层需先手动开通冷存储。
规格上,社区兼容版标准型 8 核 32GB 单副本约 ¥1473.40/月、双副本约 ¥2946.80/月(官网折扣价,价格与活动以官方页面实时展示为准)。产品能力与计费入口都在 云数据库 ClickHouse 页面上。
九、上线前按这张表过一遍
| 检查项 | 合格标准 |
|---|---|
| 排序键列数 | ≤3 列,且都是高频过滤列 |
| 排序键首列 | 高频 + 高筛选率(日志场景通常是时间) |
| 排序键列序 | 首列之后按基数升序 |
| 分区键基数 | 分区总数落在 100~1000 |
| 分区粒度 | 月或天,不出现小时/分钟级 |
| 过期数据删除方式 | DROP PARTITION,不用全表 ALTER DELETE |
| 跳数索引数量 | 每个都有"跳过量 >90%"的实测依据 |
六个高频坑:
- 以为主键唯一,拿它当唯一约束用,重复数据没人报警;
- 拿
ORDER BY当"随便写写",首列放低基数字段,查询永远全扫; - 分区键用
user_id,或切到分钟级,上线两周就开始报Too many parts; - 存储按容量买,一次买大,钱花在空盘上;
- 跳数索引建了一堆,写入慢了、查询没快;
- 高频小批量写入,part 合并追不上生产速度。
排序键和分区键定对了,慢查询、写入堆积、存储成本这三类问题会自动小一号。成本只是建表时多想十分钟。

