2. 时间戳索引设计:时间分区策略与排序键选择

时间序列数据,说白了就是带时间戳的数据流。你想想看,监控指标、交易流水、IoT 传感器数据,哪个不是按时间一路往下写的?我做了这么多年数据库调优,发现一个规律:时序索引设计得好不好,直接决定了查询是秒级还是分钟级。

今天咱们就聊两个核心问题:怎么分区,以及排序键怎么选。这两件事绑在一起,搞定了,你的时序查询就能飞起来。

2.1 时间分区策略:按小时、天、还是月?

分区,就是把大表切成小段。时序数据天然适合按时间范围切。但切多细?我见过不少新手一上来就按小时分区,结果小文件太多,反而拖慢了查询。

先看一张图,帮你快速理解分区粒度的选择逻辑:

时间分区策略选择决策树 数据总量 & 查询范围 日增量 > 100GB 按小时分区 日增量 10GB ~ 100GB 按天分区 日增量 < 10GB 按月分区 关键判断依据 • 分区粒度 = 查询最小时间窗口 × 2(经验法则) • 每个分区文件大小建议控制在 100MB ~ 1GB 之间 • 分区数不宜超过 10000,否则元数据管理会成为瓶颈

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 的场景,每个分区文件大小刚好
我的经验:如果你不确定选什么粒度,先按天。我在项目中遇到过,一开始按小时分区,结果每天产生 24 个小文件,查询反而变慢了。改成按天分区后,查询性能提升了 3 倍。

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);
注意:按小时分区不是越细越好。如果每小时数据量不到 1GB,会产生大量小文件,元数据开销反而拖垮性能。我曾经见过一个极端案例,每秒数据量很小却按分钟分区,结果分区数超过 10 万,查询计划生成就要好几秒。

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 分区 + 排序键的协同设计

分区和排序键不是孤立的,它们需要一起考虑。我总结了一个简单的设计流程:

  1. 先确定查询的时间窗口:大部分查询是查小时、天、还是月?
  2. 根据数据量选分区粒度:保证每个分区文件在 100MB ~ 1GB 之间
  3. 排序键首位放时间戳:和分区字段保持一致,但粒度可以更细
  4. 排序键第二位放最常见的过滤字段:比如设备 ID、用户 ID
  5. 测试验证:用真实数据跑几个典型查询,看是否触发了分区裁剪和索引

举个例子,假设你的数据日增量 50GB,查询模式是「查某台机器过去 1 小时的指标」:

  • 分区:按天分区(每天 50GB,文件大小适中)
  • 排序键:(timestamp, host)(时间戳精确到秒,host 用于精确过滤)

这样设计后,查询「2024-01-01 10:00~11:00 机器 A 的 CPU」时,数据库只需要:

  • 扫描 1 个分区(而不是全表)
  • 在分区内读取 1 小时的数据块(而不是全天)
  • 在数据块内精确匹配 host = 'A'

你想想看,如果分区和排序键没配合好,可能要扫描全表几百 GB 的数据。配合好了,可能只需要读几十 MB。这就是性能差距的来源。

一个小技巧:在 ClickHouse 中,可以用 EXPLAIN 查看查询是否触发了分区裁剪。如果看到 Selected 1 part 而不是 Selected 30 parts,说明分区设计是合理的。