3. 时序数据建模

时序数据建模的核心目标只有一个:让写入足够快、让查询足够准、让存储足够省。ClickHouse 的 MergeTree 家族为此提供了极强的表达能力,但能力越强,越需要建模纪律。本节围绕四个关键决策展开:时间字段如何选、标签与指标如何分离、维度基数如何控制、分区键与排序键如何设计。

一、核心原理:时序模型的三层结构

一条时序记录本质上由三部分组成:时间戳(事件发生时刻)、标签(描述“谁/在哪/什么类型”的低频维度)、指标(随时间变化的数值)。ClickHouse 并不强制区分标签与指标,但物理存储会放大你的选择:标签决定排序与索引效率,指标决定压缩与聚合效率。

时间字段设计要区分三种语义:事件时间(event time)、采集时间(ingest time)、处理时间(process time)。绝大多数分析场景应以事件时间作为分区与排序的第一列,因为它决定了数据的时间局部性,也决定了分区裁剪能否生效。若用采集时间,迟到数据会打乱分区边界,导致查询扫描过多分区。

时序数据建模四层决策链 时间字段 event_time 优先 分区裁剪基础 标签/指标分离 标签进排序键 指标做聚合列 维度基数控制 高基数慎入键 LowCardinality 兜底 分区+排序键 月/日分区 标签前缀排序 物理落盘示意(ORDER BY 前缀决定数据局部性) event_time region device_type metric_value PARTITION BY toYYYYMM(event_time) 排序键前缀 = 时间 + 低基数标签 → 相同标签数据物理相邻,扫描块数最少 高基数标签(如 user_id、trace_id)放入排序键会导致索引膨胀与写入抖动

二、方法流程:从业务查询反推建模

建模不是先建表再想查询,而是先列出 Top 查询,再决定键的顺序。推荐流程如下:

  1. 列出查询模式:按时间范围过滤?按哪些标签过滤?是否需要按标签分组聚合?
  2. 确定时间粒度:秒级、毫秒级还是微秒级?ClickHouse 的 DateTime64 支持精度参数,但精度越高压缩率越低。
  3. 分离标签与指标:标签用 String 或 LowCardinality(String),指标用数值类型;避免把指标塞进标签列。
  4. 评估维度基数:对每个候选标签统计 distinct 值数量,超过百万级的谨慎放入排序键。
  5. 设计分区键:通常按月或按日分区,分区数量控制在几百到几千,避免过多小分区。
  6. 设计排序键:按“时间 + 高频过滤标签 + 中频标签”排列,让最常用的过滤条件走索引。
排序键(ORDER BY)是 ClickHouse 最核心的索引结构。它决定主键索引的稀疏粒度,也决定数据在磁盘上的物理顺序。排序键前缀匹配才能高效裁剪,因此顺序比数量更重要。

三、案例场景:设备监控表的建模对比

假设业务需要存储设备监控数据,字段包括:设备 ID、区域、设备类型、采集时间、温度、湿度、电压。查询主要是“某区域某类型设备在最近 7 天的温度趋势”。

不推荐的建表方式:把 device_id 放在排序键前面,因为设备 ID 基数极高,会导致索引条目爆炸,且按区域过滤时无法利用前缀。

-- 不推荐:高基数 device_id 前置
CREATE TABLE device_metrics_bad (
  event_time DateTime,
  device_id String,
  region String,
  device_type String,
  temperature Float64,
  humidity Float64,
  voltage Float64
) ENGINE = MergeTree
PARTITION BY toYYYYMMDD(event_time)
ORDER BY (device_id, event_time);

推荐的建表方式:时间在前,低基数标签居中,高基数设备 ID 放在排序键末尾或仅作为普通列。

-- 推荐:时间 + 低基数标签 + 指标
CREATE TABLE device_metrics (
  event_time DateTime,
  region LowCardinality(String),
  device_type LowCardinality(String),
  device_id String,
  temperature Float64,
  humidity Float64,
  voltage Float64
) ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (region, device_type, event_time, device_id)
TTL event_time + INTERVAL 90 DAY;
设计项不推荐做法推荐做法影响
时间字段使用采集时间分区使用事件时间分区分区裁剪准确,迟到数据可控
标签类型全部 String低基数用 LowCardinality字典编码,扫描更快
排序键高基数 ID 前置时间+低基数标签前置索引小,前缀匹配高效
分区粒度按小时分区按月或按日分区避免分区过多导致元数据压力
指标存储指标混入标签列指标独立数值列压缩率高,聚合快

四、误区与复盘

常见误区一:认为分区键越细越好。按小时分区在数据量大时会产生数万个分区,写入时频繁创建分区,查询时元数据开销显著。分区键应服务于数据生命周期管理(TTL、冷热分离),而不是查询过滤。

常见误区二:把排序键当成“索引越多越好”。排序键只有前缀有效,后面的列几乎不参与裁剪。把十几个标签全部塞进排序键,只会增加写入合并成本,不会提升查询性能。

常见误区三:忽略维度基数。一个 region 可能只有几十个值,而 device_id 可能有上千万个。前者适合 LowCardinality 和排序键,后者适合作为普通列或放入跳数索引(skip index)辅助过滤。

复盘时可以用以下清单快速检查模型是否合理:

  • 时间字段是否为事件时间,且作为分区与排序的第一决策依据?
  • 标签是否与指标分离,指标是否使用数值类型?
  • 排序键前缀是否覆盖最高频的过滤条件?
  • 分区数量是否在可控范围(通常几百到几千)?
  • 高基数维度是否被错误地放入了排序键前部?
  • 是否配置了 TTL 或冷热分层策略来管理存储成本?

建模的最终检验标准是:用真实查询跑一遍 EXPLAIN 和查询日志,观察扫描行数、读取字节数和分区裁剪情况。如果扫描行数远大于结果行数,说明排序键或分区键没有起到应有的裁剪作用,需要回到查询模式重新调整键的顺序。