3. 分区键与排序键的设计哲学:如何根据查询模式设计分区键与排序键
分区键和排序键,是 ClickHouse 表设计的灵魂。很多新手上来就建表,随便选个字段当分区键,结果查询慢得离谱。我见过太多这样的案例了。
说白了,这两个东西决定了你的数据怎么存、怎么读。设计得好,查询快如闪电;设计得不好,全表扫描等着你。
3.1 分区键:数据物理隔离的第一道关卡
分区键的作用,是把数据按某个维度切分成物理上的独立目录。ClickHouse 在查询时,能直接跳过不相关的分区目录——这就是所谓的「分区裁剪」。
核心原则:分区键要能大幅缩小查询范围,但分区粒度不能太细。
我习惯用这个标准来判断:一个分区内的数据量,应该在 1-5 亿行之间。太细了,比如按小时分区,会导致大量小文件,合并压力大;太粗了,比如按年分区,分区裁剪效果就没了。
3.1.1 常见分区键选择
| 业务场景 | 推荐分区键 | 原因 |
|---|---|---|
| 日志分析 | toYYYYMM(event_time) | 按天或按月查询,天然适合 |
| 用户行为 | toYYYYMMDD(event_date) | 通常查最近几天数据 |
| 订单系统 | order_date | 按日期范围查询频繁 |
| 监控指标 | toStartOfHour(timestamp) | 实时监控按小时聚合 |
我曾经踩过的坑:有个项目把 user_id 的哈希值当分区键,结果每个分区数据量极不均匀。大用户一个分区几亿行,小用户几千行。查询时热点分区压力巨大,数据倾斜严重。后来改成按日期分区,问题才解决。
3.1.2 避免数据倾斜
数据倾斜,说白了就是某些分区数据量特别大,某些特别小。这会导致查询时,大分区成为瓶颈。
我建议你这样做:
- 不要用高基数字段直接分区——比如 user_id、order_id,每个分区可能只有几行
- 不要用低基数但分布不均的字段——比如 status 字段,可能 99% 都是「成功」
- 优先用时间字段——天然均匀分布,且查询模式匹配
- 如果必须用非时间字段——考虑用哈希取模,比如 intHash64(user_id) % 10
3.2 排序键:数据局部性的核心
排序键决定了数据在磁盘上的物理顺序。ClickHouse 的 MergeTree 引擎,数据是按排序键有序存储的。查询时,如果条件匹配排序键的前缀,就能做二分查找,大幅减少扫描数据量。
你想想看,如果排序键是 (event_date, city_id, user_id),那么查询 WHERE event_date = '2024-01-01' AND city_id = 100 时,ClickHouse 能直接定位到目标数据块。但如果查询 WHERE city_id = 100 不带日期,那就只能全表扫描了。
我的经验:排序键的设计,要遵循「最左前缀原则」。把查询频率最高的字段放在最左边,其次是区分度高的字段。
3.2.1 排序键设计三步法
- 列出所有高频查询模式——比如 80% 的查询都带时间范围,20% 带用户 ID
- 找出公共前缀——如果大部分查询都带时间,那把时间放第一位
- 考虑字段基数——高基数字段放前面,能更快缩小范围
举个例子:
-- 假设查询模式:
-- 1. 按日期 + 城市查询订单量(高频)
-- 2. 按日期 + 用户查询详情(中频)
-- 3. 按城市查询汇总(低频)
-- 推荐排序键:
ORDER BY (event_date, city_id, user_id)
-- 不推荐:
ORDER BY (user_id, event_date, city_id)
-- 因为 user_id 在第一位,但大部分查询不带 user_id
3.3 分区键与排序键的协同设计
分区键和排序键不是孤立的,它们需要配合。我见过有人分区键用日期,排序键也用日期开头——这其实浪费了排序键的能力。
为什么?因为分区键已经按日期把数据切开了,每个分区内的数据日期范围是固定的。排序键再以日期开头,意义不大。
最佳实践:分区键负责「粗粒度裁剪」,排序键负责「细粒度定位」。
比如:
- 分区键:
toYYYYMM(event_date)——按月切分 - 排序键:
(city_id, user_id, event_time)——在月内按城市和用户排序
这样,查询某月某城市的数据时,先通过分区键跳过其他月份,再通过排序键快速定位到目标城市的数据块。
3.4 核心知识体系
下面这张图,是我总结的分区键与排序键设计逻辑:
3.5 实战案例:日志系统优化
我记得有个日志分析项目,每天 10 亿行数据。最初的设计是这样的:
-- 错误设计
CREATE TABLE logs (
event_time DateTime,
level String,
service String,
message String
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(event_time)
ORDER BY (service, level, event_time);
问题在哪?查询 WHERE event_time BETWEEN '2024-01-01 10:00:00' AND '2024-01-01 11:00:00' 时,虽然分区裁剪到了当天,但排序键以 service 开头,导致需要扫描整个分区的数据。
优化后:
-- 优化设计
CREATE TABLE logs (
event_time DateTime,
level String,
service String,
message String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time) -- 按月分区,减少分区数
ORDER BY (event_time, service, level); -- 时间放第一位
这样,按时间范围查询时,能直接定位到目标数据块。查询速度提升了 10 倍以上。
小技巧:如果查询经常带 service 条件,可以考虑在排序键中把 service 放在第二位。这样既能利用时间裁剪,又能快速过滤 service。
3.6 避坑指南
最后,分享几个我踩过的坑:
- 分区键不要用表达式——比如
PARTITION BY toYYYYMM(event_time) + 1,会导致分区无法裁剪 - 排序键不要超过 3-4 个字段——太多字段会降低写入性能,且查询收益递减
- 不要忽略默认排序键——如果不指定 ORDER BY,ClickHouse 会按写入顺序存储,查询性能极差
- 分区键和排序键不要重复——比如分区键用日期,排序键也用日期开头,浪费了排序键的能力
嗯,以上就是分区键与排序键的设计哲学。记住一句话:分区键负责「切」,排序键负责「排」。切得好,排得对,查询自然快。