1. 先搞清楚:这个“排序”问题到底卡在哪
1.1 典型场景:做一个“最近N条”查询,速度突然就崩了
如果你是用 ClickHouse 做用户行为、日志、订单这类带时间属性的数据,几乎一定会碰到“按时间降序取最近N条”的需求。比如拉取某个用户最近10条支付记录、某个设备最近100条上报日志、某个订单号最近5条状态变更。这类查询 SQL 写起来非常简单:
sql复制SELECT *
FROM events
WHERE uid = 12345
ORDER BY event_time DESC
LIMIT 10;
数据量小的时候完全没感觉,一张表几十万行,怎么查都挺快。可一旦表到了千万级、亿级,或者查询的 uid 范围很广,你会发现这个“看起来理所当然”的倒序排序,会变成全表扫描级别的大麻烦。跑一次几秒甚至几十秒,连接池被打满,同事开始问你“ClickHouse 是不是不行”。
我也经历过这个阶段。最开始以为只是机器不够、索引没建好,后来一查执行计划才发现,真正的问题不是机器,而是 MergeTree 的数据存储方式和查询排序方向之间的匹配关系。
1.2 根因:MergeTree 的索引是“正序的稀疏索引”
ClickHouse 里的 MergeTree 家族表,数据在每个数据片段(part)内部是按照 ORDER BY 子句指定的列做物理排序存储的。这个 ORDER BY 既是排序键,也是主键索引的基础。primary.idx 里存的不是每一行的主键值,而是每 8192 行(默认索引粒度)一个“最小键值”,所以它是一个稀疏索引。
稀疏索引意味着什么?它只适合用来快速定位“某个值在哪些索引区间里”,不适合用来按任意方向读取所有行。而且,ClickHouse 在生成这个索引时,天然按升序排列,从小到大。查询时如果你也是升序读取,那么索引二分定位非常自然:找到第一个大于等于查询条件的 granule,然后往后读。
但如果你按降序查,问题就出现了。比如排序键是 event_time,查询要 ORDER BY event_time DESC,索引文件里存的却是每个 granule 的最小 event_time。没有 WHERE 时间范围时,优化器并不知道“我要的结果一定在最后几个 granule”,它为了保证正确性,只能把所有 granule 都纳入扫描范围,然后再做一次内存排序,取前 N 条。
更麻烦的是,一旦排序键不是查询过滤条件的前缀,比如表以 ORDER BY (uid, event_time) 建立,但查询只按 event_time 排序,索引连 uid 那一层都走不上,直接退化成全表读。这种性能损耗不是简单加个索引能救回来的,本质上就是数据结构跟查询模式不匹配。
1.3 什么时候不用折腾,什么时候必须改
先别急着改表,因为并不是所有“时间倒序查询”都需要动排序键。
如果查询 SQL 里带了一个明确的时间过滤条件,比如:
sql复制SELECT *
FROM events
WHERE event_time > now() - INTERVAL 1 DAY
ORDER BY event_time DESC
LIMIT 10;
那么即使排序键是正序,分区裁剪或 minmax 索引也能帮你把范围缩小到最后一个分区,性能通常能接受。因为要扫描的数据量本身已经很少,内存里做一次小规模排序并不贵。
真正的痛点集中在两类场景:
- 查询条件里没有时间范围,或者时间范围非常宽,比如“查某个用户全部记录里最新一条”。
- 查询条件里的过滤字段不是排序键前缀,导致无法走索引,只能全表挨个看。
- 数据量基数大,比如每张表几亿行,即使只扫一个分区也扛不住排序开销。
如果你命中其中一条,那就得认真考虑下面的方案了。我强烈建议你先把“为什么升序存储导致降序查询尴尬”这件事理解透,再去套方案,不然换汤不换药,改了也白改。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案一:把时间戳“反转”进排序键
2.1 核心思路:负数时间戳
既然 ClickHouse 的排序键只能高效支持“升序定位”,那我们就想办法把“业务上的降序”翻译成“存储上的升序”。最常见的做法是:存一个“负时间戳”列,用 -toUnixTimestamp(event_time) 或 -toUnixTimestamp64Milli(event_time) 生成。
举个例子,原始时间:
- 2025-01-01 00:00:00 -> 时间戳 1735689600 -> 负数 -1735689600
- 2025-01-02 00:00:00 -> 时间戳 1735776000 -> 负数 -1735776000
数值越小,代表时间越晚。所以如果排序键里包含负数时间戳,升序排列就等于把最新时间放在最前面。查询时我们只需要按这个负数时间戳升序,就能拿到业务意义上的“倒序”。
这个方案的本质,是把查询端的排序方向和存储端的排序方向对齐,让稀疏索引能够正常地做范围定位,而不是全程扫描。
2.2 建表与写入改造
我推荐用物化列(MATERIALIZED)来做,这样不需要在业务写入端额外算一列,表达式由 ClickHouse 在插入时自动计算。
sql复制CREATE TABLE events_v2
(
event_time DateTime,
uid UInt64,
event String,
event_time_neg Int64 MATERIALIZED -toUnixTimestamp(event_time)
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(event_time)
ORDER BY (uid, event_time_neg);
注意这里:
PARTITION BY仍然是原始时间,这样按时间范围做分区裁剪时不受影响。ORDER BY使用(uid, event_time_neg),意味着同一 uid 的数据内部按“负数时间戳”升序存储,也就是真正的时间降序。- 物化列不占用写入端逻辑,但会占磁盘空间。一个 Int64 是 8 字节,相比原始 DateTime 的 4 字节,每行会多出 4 字节。
如果你不想用物化列,也可以写成普通列,写入时在 INSERT 语句里显式传一个负时间戳。但这样容易出错,尤其业务侧可能忘记传。物化列更稳妥。
2.3 查询端怎么写才能让索引真正生效
这是最关键的一步:改了表结构后,SQL 不能还按老样子写。
原来的查询:
sql复制SELECT *
FROM events
WHERE uid = 12345
ORDER BY event_time DESC
LIMIT 10;
改造后应该写成:
sql复制SELECT *
FROM events_v2
WHERE uid = 12345
ORDER BY event_time_neg ASC
LIMIT 10;
为什么必须从 DESC 改成 ASC?因为 event_time_neg 是按升序存储的,你期望的最新时间对应的是负数最小值,所以升序排列才是最新在前。如果你的查询确实需要最后返回 event_time,套一层子查询即可:
sql复制SELECT
event_time,
uid,
event
FROM
(
SELECT *
FROM events_v2
WHERE uid = 12345
ORDER BY event_time_neg ASC
LIMIT 10
)
ORDER BY event_time DESC;
不过说实话,如果只取 10 条,最后这个外层排序基本没有成本,纯粹是让结果顺序符合业务习惯。
如果查询条件是“所有用户最新 10 条”,那排序键前缀就不是 uid 了,排序键要换成以负数时间戳开头:
sql复制CREATE TABLE events_v3
(
event_time DateTime,
uid UInt64,
event String,
event_time_neg Int64 MATERIALIZED -toUnixTimestamp(event_time)
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(event_time)
ORDER BY event_time_neg;
但这意味着任何按 uid 的查询就不能用排序键索引了。所以排序键设计一定要围绕“最高频、最核心的查询模式”来定,没有银弹。
2.4 排序键设计上的一些补充建议
我见过很多朋友一上来就搞个超长排序键,甚至把 7、8 个列都塞进去。这里要提醒一句:排序键越长,每次合并、写入排序成本越高,索引文件也越大,而且你的查询必须精确命中前缀才能收益。
建议这么做:
- 排序键控制 2 到 4 列,把最高频的等值过滤字段放第一位,负数时间戳放第二位。
- 如果查询条件同时有多个等值字段,选区分度最高、基数适中的那个做前缀,而不是把全部字段都放进去。
- 如果主要场景就是“不分实体,直接看最新数据”,排序键第一位放负数时间戳,不要额外再加前缀。
- 字符串列尽量别放排序键前几位,宽字符串做索引比较开销很高。
另外有一个细节:如果你用的是 DateTime 类型,toUnixTimestamp 只到秒。如果业务在同一个秒内有多条记录,它们的负数时间戳相同,物理排序不保证内部顺序。要精确到毫秒,要么把列改成 DateTime64(3),要么在 ORDER BY 里再补一个字段(比如自增序列号)来保证确定性。
3. 方案二:不想动业务SQL,用Projection兜底
3.1 Projection能解决什么问题
如果表已经跑了很久,业务方一堆 SQL 都是 ORDER BY event_time DESC 的老写法,你不可能挨个改。这时候 ClickHouse 的投影(Projection)就是一个非常实用的兜底方案。
Projection 可以理解为在一张表内部额外维护一份“物化的子表”。这份子表可以定义不同的排序键、甚至不同的聚合,但数据会和主表保持同步。查询时优化器如果发现 SQL 的 ORDER BY 或聚合能和某个投影匹配,就会自动选择扫描投影而不是主表。
使用投影,我们就能做到:
- 表层查询 SQL 保持
ORDER BY event_time DESC不变。 - 优化器自动命中那个按负数时间戳排序的投影。
- 索引定位生效,性能接近方案一。
3.2 定义和物化投影的完整示例
先建一张普通表,并同时创建投影:
sql复制CREATE TABLE events_proj
(
event_time DateTime,
uid UInt64,
event String,
PROJECTION p_event_time_desc
(
SELECT uid, event_time, event
ORDER BY (uid, -toUnixTimestamp(event_time))
)
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(event_time)
ORDER BY (uid, event_time);
如果表已经存在,可以后面再补:
sql复制ALTER TABLE events_proj ADD PROJECTION p_event_time_desc
(
SELECT uid, event_time, event
ORDER BY (uid, -toUnixTimestamp(event_time))
);
ALTER TABLE events_proj MATERIALIZE PROJECTION p_event_time_desc;
注意,MATERIALIZE PROJECTION 会把已有数据都重算一遍,大表会很耗时,需要挑业务低峰期执行。投影物化完成后,再执行下面的查询:
sql复制SELECT *
FROM events_proj
WHERE uid = 12345
ORDER BY event_time DESC
LIMIT 10;
理论上优化器应该自动使用 p_event_time_desc。为了确认,可以在 SELECT 前加:
sql复制EXPLAIN indexes = 1
SELECT *
FROM events_proj
WHERE uid = 12345
ORDER BY event_time DESC
LIMIT 10;
如果执行计划里出现 Projection 和 ReadFromMergeTree 的字样,并且读到的 granules 少得离谱,说明投影命中了。
3.3 投影的代价和适用边界
投影不是免费的。先想清楚三笔账:
- 存储翻倍:投影会额外存一份数据,整体磁盘占用大约翻一倍,除非你只投影必要列。
- 写入放大:每次插入数据,主表写一份,投影数据也要落一份,合并开销会增加,写入吞吐会下降。
- 内存压力:
MATERIALIZE PROJECTION或查询命中投影时,会占用额外内存,合并期间尤其明显。
所以 Projection 更适合这样用:表结构不方便大改、历史原因导致 SQL 不好动、但查询性能和写入性能还能接受的项目。如果你可以从根本上改表结构,我建议优先考虑方案一,方案二作为无感优化手段保留。
另外,投影的优化器匹配比较挑剔。如果查询里的 WHERE 字段和投影的 ORDER BY 前缀不一致,或者查询带了投影里没有的列,优化器可能不会命中,得自己看执行计划确认。别加完投影就觉得万事大吉,上线前务必拿真实 SQL 测一遍。
4. 方案三:低成本救急,依赖分区和查询改写
4.1 如果业务天然带时间范围
很多读者的场景其实没有那么复杂:业务查询总会带个“最近 1 小时”“最近 7 天”“最近一个月”的条件,只是想从里面倒序拿最新的几条。这种场景下,最见效果的往往不是改排序键,而是保证分区键和查询条件能精确对得上。
ClickHouse 的分区裁剪是独立于排序键的。分区键用 toYYYYMMDD(event_time),那么 WHERE event_time > now() - INTERVAL 1 DAY 就能把扫描范围限制在昨天和今天的几个分区内。之后即使全是内存排序,数据量可控,查询也快。
所以如果你的表分区键设置合理,最值得做的是在应用层强制加时间过滤条件,尽量不要让用户裸查 ORDER BY event_time DESC LIMIT 10 这条不带时间的 SQL。
4.2 把分区键当成“时间倒序加速器”
还有一种比较取巧的思路:既然最新数据永远落在最后一个分区,那我们就利用分区来做快速定位。
假设分区键是 toYYYYMMDD(event_time),查询目标是“全表最新 50 条”。如果按老写法 ORDER BY event_time DESC LIMIT 50,它可能还是要去扫全表确认到底哪些是最新的,因为优化器不一定能推断出“最新数据只在最后一个分区”。
但如果我们手动加一个条件:
sql复制SELECT *
FROM events
WHERE event_time >= today()
ORDER BY event_time DESC
LIMIT 50;
那么分区裁剪直接就把范围切到最后一天,配合 LIMIT,实际扫描量非常小。
这种方案下限很低,上限也很低。它只适用于“最新数据一定分布在最近一两个分区内”的场景,而且要求最新分区数据量不要太大。如果一天有几亿条,靠扫一天分区仍然扛不住,那就必须回到排序键方案。
4.3 三种方案快速对比表
我把这三种常见思路放在一起,方便你决策:
| 方案 | 核心做法 | 查询SQL是否要改 | 磁盘开销 | 写入影响 | 适用场景 |
|---|---|---|---|---|---|
| 负数时间戳排序键 | 加物化列,排序键存负时间戳 | 需要改,ORDER BY 要写负数列 ASC | 每行多 4-8 字节 | 较低,排序键稍长 | 新表或可重建,查询模式固定 |
| Projection | 表内建一份倒序子表 | 不需要改 | 约翻倍 | 较高,写入放大 | 老表、SQL不好动,能接受存储翻倍 |
| 分区裁剪+SQL改写 | 依赖分区键,强制加时间范围 | 需要改,但业务层可接受 | 无 | 无 | 最新数据集中在最近分区,数据量可控 |
真实项目里,这三种方案也可以组合。比如核心大表用负数时间戳排序键,然后把投影加在真正需要兼容旧 SQL 的场景。分区裁剪是任何方案都需要的安全网,应该作为底线保留。
5. 实操全过程:从线上问题到改造完成
5.1 准备测试环境和数据
先说实验环境,我这边用的是 ClickHouse 单机版,版本为 23.8,配置 8C16G。测试表模拟一个用户事件表,数据结构如下:
sql复制CREATE TABLE events
(
event_time DateTime,
uid UInt64,
event String
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(event_time)
ORDER BY (uid, event_time);
往里面插入了 1000 万行模拟数据,uid 范围 1 到 10 万,时间跨度 30 天。event_time 在 30 天内随机分布。
插入完成后,我分别跑了几组查询。所有查询都执行三次,取中间值,避免抖动。
5.2 改造前基线和瓶颈分析
查询一,不带头部时间条件的最近 10 条:
sql复制SELECT *
FROM events
WHERE uid = 12345
ORDER BY event_time DESC
LIMIT 10;
执行计划里看到 ReadFromMergeTree 读取的行数是 10 万行。因为 uid=12345 这个用户总共就有 10 万条,物理存储已经按 uid 分好了,所以索引能定位到该 uid 的所有数据,但接下来因为要把这 10 万条倒序排,需要构建排序队列,耗时大约 400 毫秒。
查询二,不带 uid 的全局最新 10 条:
sql复制SELECT *
FROM events
ORDER BY event_time DESC
LIMIT 10;
这个最惨,因为没有 WHERE 条件,排序键前缀也用不上,优化器只能扫描全表 1000 万行。虽然 ClickHouse 用并行扫描,整条查询也要 1.2 秒左右。在线上如果是频繁调用,1.2 秒足以打挂很多业务接口。
查询三,带一天范围但 ORDER BY desc:
sql复制SELECT *
FROM events
WHERE event_time > now() - INTERVAL 1 DAY
ORDER BY event_time DESC
LIMIT 10;
由于分区裁剪生效,只扫描了约 33 万行,耗时 180 毫秒,明显比查全表好。
据此可以判断:当前瓶颈主要在“无时间范围 + 全局倒序”和“单 uid 数据量大 + 倒序”,这两个都需要动存储结构。
5.3 改造后验证与效果
我重建了一张表 events_v2,使用负数时间戳排序键:
sql复制CREATE TABLE events_v2
(
event_time DateTime,
uid UInt64,
event String,
event_time_neg Int64 MATERIALIZED -toUnixTimestamp(event_time)
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(event_time)
ORDER BY (uid, event_time_neg);
把同样的 1000 万行数据灌进去后,重新跑两个核心查询。
单 uid 最新 10 条,改成:
sql复制SELECT *
FROM events_v2
WHERE uid = 12345
ORDER BY event_time_neg ASC
LIMIT 10;
耗时从原来的 400 毫秒降到了 15 毫秒左右。原因很简单:排序键前缀仍然是 uid,但第二列 event_time_neg 是升序,索引可以快速定位到该 uid 对应的第一个 granule,读取几条 row 就能返回,不必把该 uid 的 10 万行全部读出来再排序。
全局最新 10 条,如果业务允许改成这样:
sql复制SELECT *
FROM events_v2
ORDER BY event_time_neg ASC
LIMIT 10;
只需要扫描排序键开头的第一个 granule,耗时稳定在 8 毫秒以内。这个提升非常直观,因为存储结构保证了“最新的数据在物理存储最前面”。
我特意看了下执行计划,ReadFromMergeTree 这一层的 granules 从原先的全表几十上百个,降到了个位数。这才叫真正用上了索引。
5.4 线上平滑迁移怎么做
已经上线的大表,不建议直接停机重建。哪怕是改造思路很清晰,也要考虑平滑迁移。
比较稳妥的流程:
- 新键一张 events_v2,结构如上,设置好分区和排序键。
- 用 INSERT INTO events_v2 SELECT * FROM events 做历史数据同步。如果数据量很大,可以分批按日期段迁移,避免单次大事务占用太多资源。
- 同步期间,应用双写:新数据同时写旧表和新表。
- 核对一段时间的数据一致性,确认两表行数一致、时间范围覆盖完整。
- 把读流量切到新表,观察内存、磁盘、查询耗时。
如果你用的是新版 ClickHouse,也可以对旧表执行:
sql复制ALTER TABLE events
MODIFY ORDER BY (uid, -toUnixTimestamp(event_time));
但注意这个操作会重写整个表的数据,需要足够的临时磁盘空间和 CPU。我建议先在一个测试副本上验证,确认版本支持再操作。多数情况下,新建表加迁移脚本是更可控的选择。
6. 常见问题与避坑清单
6.1 改了排序键,INSERT变慢怎么办
排序键不是白加的。数据写入时,ClickHouse 会对每个插入块按排序键排序,再落盘。排序键字段越多、越复杂,排序开销越大。负数时间戳这种表达式列,在写入时也要多计算一次。
如果你发现改造后写入吞吐下降明显,可以考虑:
- 控制批量插入大小,比如每批 5 万到 20 万行,减少排序小块数量。
- 尽量减少排序键列数,列一多,排序成本指数级上涨。
- 如果物化列的计算函数较重(比如 DateTime64 精度转换),可以提前在应用层算好,写入普通列而不是物化列,分担 ClickHouse 侧压力。
实际上,大多数业务写多读少,用一点写入开销换查询性能是划算的。但如果你的写入每秒几十万行,那就得仔细评估排序键的成本了。
6.2 EXPLAIN 看到哪些输出能确认索引生效
我经常看到有人改了表结构,但查询还是很慢,一查发现执行计划根本没走预期路径。用这个命令检查:
sql复制EXPLAIN indexes = 1
SELECT ...
重点看输出里的 ReadFromMergeTree 部分:
Parts:实际读取的分区数量,如果很大,说明分区裁剪没生效。Granules:实际读取的索引粒度数量,这个数越小越好。全表扫描时,Granules 会非常大。Indexes:会列出命中的索引类型,比如MinMax、PrimaryKey,以及每个索引选出的 granule 范围。- 如果是 Projection,则会有
Projection行,说明查询直接读了投影。
如果你看到 Granules 很大,但你已经按方案一改造了,那么多半是查询 SQL 仍然写的是 ORDER BY event_time DESC,而不是 ORDER BY event_time_neg ASC。排序键方向不对,索引等于没改。
6.3 时间是DateTime/DateTime64,单位别搞混
这是踩坑重灾区。DateTime 精确到秒,DateTime64 可以精确到毫秒、微秒。
如果你用 DateTime 存时间,-toUnixTimestamp(event_time) 返回的是秒级负整数,Int32 范围可能不够(2038 问题之前没问题,但负数后还是够的),一般用 Int64 比较稳。
如果你用 DateTime64(3),那对应的函数应该是:
sql复制-toUnixTimestamp64Milli(event_time) -- 毫秒
-toUnixTimestamp64Micro(event_time) -- 微秒
对应物化列类型用 Int64。千万别对 DateTime64 直接调 toUnixTimestamp,单位是秒,毫秒精度就丢了。时间精度丢失会导致降序排序在大并发下出现“同一秒内顺序不稳定”的结果。
另外要注意时区处理。ClickHouse 的 DateTime 本身不存时区,如果业务写入的是东八区时间,而查询时用 UTC 的 now(),分桶和排序都会错位。统一在应用层把时间转成 UTC 再入库,能少踩很多坑。
6.4 版本差异:新老版本的优化器行为不完全一样
ClickHouse 版本迭代很快,时间倒序相关优化也在不断变化。早期版本对 ORDER BY ts DESC 支持并不好,经常退化成全表扫描。但 21.x 之后,很多场景下即使不改排序键,如果查询条件和排序键能匹配,优化器也会尝试按正序或逆序读取,配合 LIMIT 做提前终止。
我在 23.8 上测试时,简单场景(排序键前缀字段等值过滤 + ORDER BY DESC)其实已经能利用索引,但性能打折,而且一旦 WHERE 条件不是排序键前缀,还是老老实实全扫。这依赖版本的具体规则,所以不要拿网上某一篇文章的结论直接套到你的版本上。最靠谱的方法是在测试环境用 EXPLAIN 看执行计划,自己心里有底。
6.5 一些容易忽略的小细节
再分享几个我在实际项目中踩过的细节:
- 如果表已经按天分区,而且每天数据量在千万级,那么“最近 N 条”查询建议在 SQL 里同时写
WHERE event_time >= today()这类条件。这样即使排序键没改,也能借助分区裁剪把压力降下来。 - 负数时间戳列别直接给业务用,业务看不懂负数,最好只在查询排序时使用,返回结果再用原始 event_time。
- 对于复合排序键,查询 WHERE 一定要带前缀等值条件。比如排序键是
(uid, event_time_neg),查询里没写 uid,索引基本废掉。 - 如果一张表有多个典型查询模式,不要试图用一个排序键满足所有排序需求。可以选择一个最高频的模式做主排序键,再用 Projection 覆盖第二高频模式,成本可控。
- 数据量小的时候,任何方案都看不出差别。但设计阶段还是要按未来半年到一年的数据量来考虑,否则等数据涨上去再改表,运维成本和风险都高很多。
我个人在实际操作中的体会是:ClickHouse 的时间降序排序问题,九成以上是“排序键和查询排序方向不对齐”造成的。遇到问题先打开 EXPLAIN,确认数据扫描范围,再决定是改排序键、加投影,还是靠分区裁剪救急。不要一上来就换机器、加缓存,那样治标不治本。希望这篇内容能帮你少走点弯路,如果你在改造过程中有其他奇怪的现象,也欢迎按这个思路先排查一遍,大概率能找到答案。
