2. 时间戳索引设计:时间分区策略与排序键选择
时间序列数据,说白了就是带时间戳的数据流。你想想看,监控指标、交易流水、IoT 传感器数据,哪个不是按时间一路往下写的?我做了这么多年数据库调优,发现一个规律:时序索引设计得好不好,直接决定了查询是秒级还是分钟级。
今天咱们就聊两个核心问题:怎么分区,以及排序键怎么选。这两件事绑在一起,搞定了,你的时序查询就能飞起来。
2.1 时间分区策略:按小时、天、还是月?
分区,就是把大表切成小段。时序数据天然适合按时间范围切。但切多细?我见过不少新手一上来就按小时分区,结果小文件太多,反而拖慢了查询。
先看一张图,帮你快速理解分区粒度的选择逻辑:
2.1.1 按天分区:最通用的选择
我个人习惯,80% 的场景先用按天分区。为什么?因为大多数业务查询天然就是查某一天的数据。比如看昨天的订单量、查前天的 CPU 峰值。
举个例子,在 ClickHouse 里建表:
CREATE TABLE metrics (
timestamp DateTime,
host String,
cpu_usage Float64,
memory_usage Float64
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (timestamp, host);
按天分区的好处很明显:
- 分区裁剪高效:查一天数据,只扫一个分区
- 数据管理方便:删除旧数据直接 drop 分区,比 delete 快几个数量级
- 文件大小适中:一般日增量在 10GB~100GB 的场景,每个分区文件大小刚好
2.1.2 按小时分区:高吞吐场景的利器
什么时候需要按小时分区?数据量大到每天几百 GB,或者查询经常精确到小时级别。比如金融交易流水、实时风控日志。
我曾经帮一家支付公司调优,他们每天产生 500GB 的交易日志。按天分区,一个分区文件 500GB,查询还是慢。改成按小时分区后,每个分区 20GB 左右,查询秒级返回。
按小时分区的写法:
CREATE TABLE trades (
trade_time DateTime,
trade_id String,
amount Float64,
status String
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(trade_time) * 24 + toHour(trade_time)
ORDER BY (trade_time, trade_id);
2.1.3 按月分区:归档类数据的首选
对于历史归档、审计日志这类查询频率低、数据量大的场景,按月分区就够了。你想想看,谁会天天查三个月前的数据?
按月分区还有一个好处:冷热数据分离方便。把最近一个月的数据放在 SSD 上,历史数据挪到 HDD 或者对象存储,成本能降不少。
CREATE TABLE audit_log (
event_time DateTime,
user_id String,
action String,
detail String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, user_id);
2.2 时间戳排序键的选择
分区解决了「扫哪些文件」的问题,排序键解决的是「文件内怎么快速定位」的问题。两者配合好了,查询才能快。
2.2.1 为什么时间戳必须放在排序键首位?
时序查询有个特点:大部分查询都带时间范围过滤。比如「查过去 1 小时的 CPU 数据」。如果排序键首位不是时间戳,数据库就得全表扫描。
我见过一个反面案例:有人把 host 放在排序键第一位,timestamp 放在第二位。结果查某个时间范围的数据时,即使只查 5 分钟的数据,也要扫描所有 host 的数据块。这就是典型的排序键选错了。
2.2.2 排序键的组合设计
时间戳放第一位之后,第二位放什么?这取决于你的查询模式。
| 查询模式 | 推荐排序键 | 说明 |
|---|---|---|
| 按时间 + 设备查 | (timestamp, device_id) |
先过滤时间,再按设备精确查找 |
| 按时间 + 指标类型查 | (timestamp, metric_name) |
适合多指标混合存储的场景 |
| 按时间 + 用户查 | (timestamp, user_id) |
用户行为分析常用 |
| 纯时间范围查 | (timestamp) |
最简单,但无法利用二级过滤 |
举个例子,假设你要查「设备 A 在 2024-01-01 10:00 到 10:05 之间的 CPU 使用率」:
-- 排序键为 (timestamp, device_id) 时,查询效率最高
SELECT timestamp, cpu_usage
FROM metrics
WHERE timestamp BETWEEN '2024-01-01 10:00:00' AND '2024-01-01 10:05:00'
AND device_id = 'A';
数据库会先通过分区裁剪定位到对应小时分区,然后利用排序键快速定位到时间范围,再在时间范围内精确找到 device_id = 'A' 的数据。整个过程就像翻书一样:先翻到正确的章节(分区),再翻到正确的页(时间范围),最后找到具体的段落(设备)。
2.2.3 排序键的避坑指南
我曾经踩过一个坑:把高基数的字段(比如 request_id)放在排序键第二位。结果排序键的压缩率极差,存储空间暴涨,写入性能也下降了。后来我改成 (timestamp, service_name, request_id),压缩率提升了 40%。
- 不要把 UUID、随机字符串等高基数字段放在排序键前几位
- 排序键的字段顺序 = 查询过滤条件的优先级顺序
- 排序键字段越多,写入时的排序开销越大,一般 3~4 个字段就够了
2.3 分区 + 排序键的协同设计
分区和排序键不是孤立的,它们需要一起考虑。我总结了一个简单的设计流程:
- 先确定查询的时间窗口:大部分查询是查小时、天、还是月?
- 根据数据量选分区粒度:保证每个分区文件在 100MB ~ 1GB 之间
- 排序键首位放时间戳:和分区字段保持一致,但粒度可以更细
- 排序键第二位放最常见的过滤字段:比如设备 ID、用户 ID
- 测试验证:用真实数据跑几个典型查询,看是否触发了分区裁剪和索引
举个例子,假设你的数据日增量 50GB,查询模式是「查某台机器过去 1 小时的指标」:
- 分区:按天分区(每天 50GB,文件大小适中)
- 排序键:
(timestamp, host)(时间戳精确到秒,host 用于精确过滤)
这样设计后,查询「2024-01-01 10:00~11:00 机器 A 的 CPU」时,数据库只需要:
- 扫描 1 个分区(而不是全表)
- 在分区内读取 1 小时的数据块(而不是全天)
- 在数据块内精确匹配 host = 'A'
你想想看,如果分区和排序键没配合好,可能要扫描全表几百 GB 的数据。配合好了,可能只需要读几十 MB。这就是性能差距的来源。
EXPLAIN 查看查询是否触发了分区裁剪。如果看到 Selected 1 part 而不是 Selected 30 parts,说明分区设计是合理的。