ClickHouse MergeTree 引擎:排序键设计决定查询天花板

ClickHouse MergeTree 引擎:排序键设计决定查询天花板

ClickHouse MergeTree 引擎:排序键设计决定查询天花板

公司上了 ClickHouse 之后,一张 20 亿行的用户行为表,同样的数据量,有人查出结果只要 0.2 秒,有人等了 30 秒没出来。差距全在排序键上。今天聊聊排序键这个"沉默的性能杀手"。

一、MergeTree 的存储哲学:先排好,再用

MergeTree 最核心的设计理念跟菜场卖菜一个道理:把顾客常买的东西摆在最显眼的位置。你去菜场买葱姜蒜,它们一定在最近的地方 —- 因为卖菜大爷早就观察出来了,十个顾客九个要买葱。

MergeTree 也是这么干的。写入数据时,它按排序键(ORDER BY)把数据物理排序后写入磁盘。查询时,主键(PRIMARY KEY)就是索引进场券 —- ClickHouse 用主键快速定位到数据块(granule),然后在这个块内按排序键做二分查找。

graph TB subgraph 写入流程 A[INSERT 数据行] --> B[按排序键物理排序] B --> C[切分成 granule 粒块] C --> D[写入 .bin 数据文件] D --> E[更新稀疏索引 .mrk] end subgraph 查询流程 F[WHERE 条件] --> G[主键索引定位目标 granule] G --> H[二分查找定位具体行] H --> I[读取目标行返回结果] end style B fill:#f96,stroke:#333 style G fill:#69f,stroke:#333

关键区别来了:主键和排序键不是一回事。建表时这样写:

CREATE TABLE user_events ( event_date Date, event_time DateTime, user_id UInt64, event String, page_url String ) ENGINE = MergeTree() PARTITION BY toYYYYMM(event_date) ORDER BY (event_date, user_id, event_time) -- 排序键:物理排序依据 PRIMARY KEY (event_date, user_id) -- 主键:索引依据,必须是排序键的前缀

排序键(event_date, user_id, event_time)决定了数据在磁盘上的物理排布顺序。主键(event_date, user_id)只取前两列做索引。能享受索引加速的查询,WHERE 条件必须覆盖主键的前缀列

二、排序键设计三铁律

铁律一:高频过滤字段放最前面。想想菜场的例子 —- 葱在最前面,因为所有人买葱。同理,如果你的查询 90% 都带日期范围,那就把event_date放在排序键第一位。这样索引能直接跳过 90% 不相关日期的 granule。

-- 按 (event_date, user_id) 排序时 -- 查询 event_date='2026-07-01' AND user_id=1001: -- 索引先按 event_date 过滤 granule(大部分跳过), -- 在剩余 granule 中按 user_id 二分 SELECT count() FROM user_events WHERE event_date = '2026-07-01' AND user_id = 1001;

如果把顺序反过来ORDER BY (user_id, event_date),那查某一天的某个用户仍然快(因为两个字段都能走索引),但查某一天的全部用户就惨了 —- 数据按 user_id 排序而非日期,同一天的数据散落在磁盘各处,ClickHouse 被迫扫描大量 granule。

铁律二:基数从低到高排列。排序键不是主键,可以放超过 3 列。列的排列顺序原则是:低基数列在前,高基数列在后。比如(event_date, event, user_id)(user_id, event, event_date)压缩率更高。因为低基数列相邻行的值相同,ClickHouse 的列式压缩算法(LZ4/ZSTD)能大幅受益。

铁律三:1 granule 覆盖 8192 行勿动摇。默认index_granularity=8192,意味着索引每 8192 行才记录一个标记(mark)。排序键设计不好,一个 granule 内数据跨度太大,二分查找的效力就大打折扣。

-- 检查某个日期范围内有多少 granule 被扫描 -- 被扫描越多,说明排序键对这类查询越不利 EXPLAIN indexes = 1 SELECT count() FROM user_events WHERE event_date BETWEEN '2026-07-01' AND '2026-07-07';

三、实战推演:三种排序键方案对比

拿一张订单表来练练,字段:order_dateuser_idproduct_categoryamount,每天 500 万行。常见查询有两类:

  • 查询A:按用户查所有订单(BI 报表)
  • 查询B:按日期+品类查汇总(运营看板)
graph LR subgraph 方案1 A1["ORDER BY order_date,<br/>user_id, product_category"] end subgraph 方案2 A2["ORDER BY user_id,<br/>order_date, product_category"] end subgraph 方案3 A3["ORDER BY order_date,<br/>product_category, user_id"] end style A1 fill:#9f9,stroke:#333 style A2 fill:#ff9,stroke:#333 style A3 fill:#f99,stroke:#333
方案排序键查询A耗时查询B耗时压缩率
方案1(order_date, user_id)~2s~0.05s
方案2(user_id, order_date)~0.01s~5s
方案3(order_date, product_category)~10s~0.01s

方案1 是典型的高频字段优先 —- 大多数运营看板查询都带日期,所以order_date排第一。方案2 在按用户查时最快,但运营看板的按日期范围查询就慢了,因为同一天的订单分布在磁盘各处。方案3 则是为特定品类分析的极端优化,通用性最差。

没有完美方案,只有最常见的查询决定了排序键的设计。

四、排序键改不动时怎么办?

线上表建好才发现排序键不合理,想ALTER TABLE MODIFY ORDER BY?不行 —- MergeTree 不支持在线修改排序键。数据需要重排,只能通过重建表或物化视图来迂回:

-- 方案1:用物化视图换排序键,原始表不变 CREATE MATERIALIZED VIEW user_events_by_date ENGINE = MergeTree() ORDER BY (event_date, user_id) POPULATE -- 将已有数据回填 AS SELECT * FROM user_events;

物化视图的本质是一张新表,用不同的排序键存储同一份数据。写入原始表时,ClickHouse 自动同步到物化视图。这相当于给数据做了两种排布,不同查询走不同的表 —- 代价是占用双倍存储。

-- 方案2:创建投影(Projection),更轻量的方案 -- 语法:在 ALTER TABLE 时添加 ALTER TABLE user_events ADD PROJECTION proj_by_date (SELECT * ORDER BY event_date, user_id);

Projection 是 ClickHouse 21.6+ 引入的特性,比物化视图更轻量。它在数据写入时自动维护一份按不同排序键的"数据影子",查询优化器自动选择最优的投影。不需要手动在查询里切换表,对应用层完全透明。

记住一句:排序键是建表时就要慎重选择的设计决策。设计错了最多用物化视图兜底,但双份存储不是谁都能承受的。

五、总结

MergeTree 的排序键不是简单把常用字段丢给 ORDER BY 就完事了。它决定了数据的物理排布、索引扫描效率、列式压缩率,甚至会影响到分布式场景下分片数据的局部性。

落地三句话:最高频的过滤字段放第一位;排序键字段不要超过 3~4 个,多了写入会慢;建表后改不了,想好再动手。如果业务场景已经确定,花半个小时跑EXPLAIN indexes=1验证排序键效果,比上线后改排布划算得多。