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 排序键设计三步法

  1. 列出所有高频查询模式——比如 80% 的查询都带时间范围,20% 带用户 ID
  2. 找出公共前缀——如果大部分查询都带时间,那把时间放第一位
  3. 考虑字段基数——高基数字段放前面,能更快缩小范围

举个例子:

-- 假设查询模式:
-- 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 会按写入顺序存储,查询性能极差
  • 分区键和排序键不要重复——比如分区键用日期,排序键也用日期开头,浪费了排序键的能力

嗯,以上就是分区键与排序键的设计哲学。记住一句话:分区键负责「切」,排序键负责「排」。切得好,排得对,查询自然快。