1. 时序数据的特点
一句话总结: 时序数据是"随时间持续产生、只追加很少更新、按时间窗口查询"的数据,它与传统业务表最大的不同是写入模式与查询模式都围绕时间轴展开。
时序数据无处不在:服务器监控指标、IoT 传感器、订单流水、金融行情、用户行为日志。它的四个显著特征:
| 特征 | 说明 | 对存储的挑战 |
|---|---|---|
| 只追加 | 以 INSERT 为主,几乎不 UPDATE | 写入路径要极度高效 |
| 高吞吐 | 每秒百万级点位(每秒采集量) | 单机写入撑不住 |
| 按时间查 | 查询集中在最近窗口/时间范围 | 需要时间索引与分区裁剪 |
| 冷热分明 | 老数据只读、很少访问 | 需要降采样/归档回收成本 |
2. 关系型存时序数据的痛点
拿 MySQL 硬存时序数据,初期可行,规模一大就会处处碰壁:
| 痛点 | 表现 |
|---|---|
| 写入瓶颈 | 单行 INSERT 慢,批量又难兼顾实时 |
| 膨胀失控 | 一行一指标,表按天/小时急剧膨胀 |
| 查询慢 | 时间范围过滤 + 分组聚合 = 大量全表扫 |
| 索引昂贵 | 时间 + 标签多列索引,写入放大严重 |
| 压缩缺失 | 重复序列值浪费存储 |
典型场景对比(1 亿点位):
关系型:聚合一次 GROUP BY + WHERE 时间窗 → 秒级
时序库:同一查询(预聚合/列式扫描) → 毫秒级
关系型不是不能存时序,而是量级过了千万级后性价比急剧下降。本文后面的建模与选型,都在回答"什么时候该换,怎么换"。
2.1 何时该从关系型切换
| 信号 | 判断阈值 |
|---|---|
| 每秒写入点位 | 超过单库 5000 点且持续增长 |
| 表行数 | 超过 5 亿行且持续膨胀 |
| 聚合查询 P99 | 超过 1 秒且无法靠索引改善 |
| 存储成本 | 冷数据占比超过 70% |
出现三条以上,就该认真评估时序数据库。切换不是否定关系型,而是让合适的工具干合适的活。
3. 时序建模:时间戳、指标、标签
3.1 核心三元组
无论用什么存储,时序数据都可抽象成三个维度:
| 维度 | 含义 | 例子 |
|---|---|---|
| 时间戳 | 数据产生时刻 | ts=2026-09-30T14:00:00Z |
| 指标(Metric) | 被测量的量 | cpu_usage、request_count |
| 标签(Tag) | 描述维度的键值 | host=web-01、region=cn-east |
一条时序数据 = 时间戳 + 一组标签 + 指标值
cpu_usage{host="web-01", region="cn-east"} = 47.5 @ 14:00:00
标签是查询的分组/过滤键,设计标签时优先考虑"会被怎么 WHERE/GROUP BY"。标签集合相同的时间戳与指标构成一条时间序列(series)。
3.2 标签设计三原则
□ 标签用"高基数且查询频繁"的维度(host、region、app)
□ 低基数属性合并进值,不单独拆标签(避免序列爆炸)
□ 指标名保持稳定,慎用带版本号的名字(cpu_usage_v2 是坏味道)
序列爆炸示例:
labels={a:10}×{b:10}×{c:10}×{d:10} → 1 万条序列 × 每秒采样 = 写入灾难
3.3 采样频率与序列基数控制
写入量 = 采样频率 × 序列数,两者相乘就是成本:
公式:
每秒写入点位 = 采样间隔的倒数 × 序列总数
序列总数 = 指标数 × 标签组合数
例:
30 个指标 × 2000 台主机 × 5 个 region = 30 万条序列
10 秒采样一次 → 每秒写入 = 30 万 / 10 = 3 万点位
调整到 30 秒采样 → 每秒写入降到 1 万点位
| 手段 | 效果 |
|---|---|
| 拉长采样间隔 | 直降写入量,但精度下降 |
| 减少指标维度 | 砍掉低价值指标 |
| 标签值合并 | 降低组合爆炸 |
| 动态采样 | 高活跃期加密、低活跃期稀疏 |
时序系统的第一课是控制序列基数。序列膨胀是比存储空间更致命的杀手——它会拖垮内存索引与倒排,写入性能随序列数急剧劣化。
4. 降采样与聚合:让历史数据变"便宜"
4.1 原始数据 vs 降采样数据
时序数据随时间增长,原始精度不可能无限保留。降采样(Downsampling)把高频原始数据压缩成低频统计值:
原始:每 10 秒一个点(1 年 315 万点/序列)
降采样第 1 级:每分钟 MAX/AVG(约 52 万点) → 保留 30 天
降采样第 2 级:每小时 AVG/P95(约 8.7 万点) → 保留 365 天
原始数据:保留最近 7 天
存储成本大约下降 90%+,查询依然有统计意义。
4.2 降采样策略设计
| 时间窗口 | 保留粒度 | 保留时长 | 典型用途 |
|---|---|---|---|
| 最近 1 小时 | 原始 10s | 实时排障 | 监控看板 |
| 最近 7 天 | 5min AVG/MAX | 排障回溯 | 趋势图 |
| 最近 90 天 | 1h AVG/P95 | 容量规划 | 报表 |
| 一年以上 | 1d AVG | 合规归档 | 审计 |
-- 以 TimescaleDB 为例:连续聚合(continuous aggregate)
CREATE MATERIALIZED VIEW cpu_usage_1h
WITH (timescaledb.continuous) AS
SELECT time_bucket('1 hour', ts) AS bucket,
host,
avg(value) AS avg_val,
percentile_cont(0.95) WITHIN GROUP (ORDER BY value) AS p95
FROM cpu_usage
GROUP BY bucket, host;
一句话总结: 降采样的本质是按保留期给数据分档:越老的数据粒度越粗、成本越低。它把"无限增长"变成"有限、可控的成本"。
5. 时序数据库选型
5.1 主流方案对比
| 方案 | 类型 | 写入模型 | 亮点 | 适用 |
|---|---|---|---|---|
| InfluxDB | 专用 TSDB | 行式写入 + 倒排索引 | 类 SQL Flux/InfluxQL、生态成熟 | 监控、IoT 通用 |
| TimescaleDB | PG 扩展 | 分区超表 | SQL 兼容、与 PG 生态无缝 | 已有 PG 团队 |
| TDengine | 专用 TSDB | 超级表/标签建模 | 国产、高压缩比、硬件要求低 | IoT 大规模 |
| Prometheus | 拉模型监控 | 时序 + 指标标签 | 与 K8s/监控生态深度绑定 | 云原生监控 |
| ClickHouse | 列式 OLAP | 批量列式写入 | 极快聚合、高压缩 | 日志/分析型时序 |
5.2 选型决策树
是否已有强 PG 生态?
├─ 是 → TimescaleDB(SQL 兼容,平滑迁移)
└─ 否 ↓
是云原生监控指标?
├─ 是 → Prometheus + 长期存储(Thanos/Mimir)
└─ 否 ↓
写入量每秒百万级 / 大规模 IoT?
├─ 是 → TDengine / ClickHouse
└─ 否 → InfluxDB(通用、易上手)
一句话: 选型先问"SQL 兼容性、接入生态、写入规模、团队技能"四个问题,没有万能方案,只有适合你场景的方案。
5.2 从关系型迁移到时序库的路径
存量关系型数据迁入时序库,不要一刀切,按时间窗分批:
迁移五步:
1. 双写期:关系型照常写,同时写时序库(1~2 周)
2. 对账:两边抽样对比,指标值、时间戳、标签一致
3. 切读:新查询走时序库,老代码保留读关系型
4. 追补:用导出工具回填历史数据(按天分批)
5. 下线:确认无误后停关系型写入
回填注意:老数据时间戳要按「采集时间」而非「导入时间」写,
否则时间序列会被冲到错误的位置。
时序迁移与业务表迁移最大的不同:时序数据有「时间轴」约束,回填必须保序、保时间戳。任何乱序回填都会污染聚合结果。
6. 写入优化:吞吐是第一要务
6.1 批量写入与去重
时序写入的黄金法则是合并请求、减少往返:
❌ 逐条写入:每次一条 SQL,网络开销 + 写放大
✅ 批量写入:一次写 1000~10000 个点位
✅ 相同时间戳重复值:取最新覆盖(upsert 语义)
# InfluxDB 行协议批量写入(一次性发 5000 行)
lines = []
for point in points[:5000]:
lines.append(
"cpu_usage,host=%s,region=%s value=%f %d"
% (point.host, point.region, point.value, point.ts_ms)
)
influx.write(lines, precision="ms")
6.2 乱序与延迟容忍
时序写入常遇到迟到的数据(网络重试、设备补传)。合理设计写入路径:
| 手段 | 说明 |
|---|---|
| 允许乱序 | 存储层按时间戳排序合并,而非按到达序 |
| 延迟窗口 | 只对最近 N 小时的数据做乱序处理 |
| 缓存写入 | 客户端缓冲 1~5s 再批量提交,摊薄开销 |
| 不重复建索引 | 时间戳 + 标签做唯一性去重 |
6.3 分区与 TTL
几乎所有时序库都用按时间分区 + 自动过期控制成本:
-- TimescaleDB 示例:按时间分片 + 自动删除
SELECT create_hypertable('cpu_usage', 'ts', chunk_interval => INTERVAL '1 day');
-- 保留 30 天,老数据自动删除
SELECT add_retention_policy('cpu_usage', INTERVAL '30 days');
一句话总结: 写入优化的核心是批量、去重、乱序合并、时间分区 + TTL——让写入路径像流水线一样,吞吐稳定且不堆积。
6.4 压缩与编码:时序库的存储魔术
时序值高度重复(相邻时间戳的 CPU 值往往相近),时序库用专门编码压缩:
| 编码手段 | 原理 | 典型收益 |
|---|---|---|
| 时间戳差量编码 | 相邻时间戳只存差值 | 时间列压缩至极小 |
| 数值异或编码 | 相邻值变化小,存异或增量 | 数值列可压缩 10~100 倍 |
| 标签字典编码 | 重复标签转整数 ID | 标签列大幅瘦身 |
| 列式存储 | 同列连续存储、整体压缩 | 聚合扫描极快 |
对比:同样 1 亿个点位
关系型行式(裸存) :约 800MB~2GB
时序库列式+编码压缩 :约 80~300MB
这也是为什么「关系型存时序」与「时序库存时序」存储差异如此巨大的核心原因——不是引擎更聪明,而是数据按时间序编码了。
7. 查询优化与物化视图
7.1 查询两大件:时间范围 + 标签过滤
查询优化核心:
1. 时间范围下推:WHERE ts >= ... AND ts < ... → 只扫相关分区
2. 标签过滤下推:WHERE host='web-01' → 利用倒排/索引
3. 避免 SELECT *:按需要的指标/字段查询
4. 聚合下推:GROUP BY 让存储层预聚合,而不是应用层
7.2 物化视图做预聚合
高频聚合查询(如"每 5 分钟全网 CPU 平均")每次都实时算太贵,应预聚合(物化视图):
原始写入 → 后台连续聚合 → 预聚合结果表
查询"全网平均" → 命中预聚合表 → 毫秒返回
数据延迟容忍:聚合滞后 1~2 分钟即可覆盖绝大多数看板
7.3 时序查询避坑清单
| 坑 | 表现 | 对策 |
|---|---|---|
| 没下推时间范围 | 全表扫再过滤 | WHERE 里写裸时间范围 |
| 标签过滤函数化 | 倒排索引失效 | 标签列裸等值/正则 |
| 聚合放在应用层 | 拉回海量原始数据 | 存储层 GROUP BY |
| 时区不统一 | 跨时区数据错位 | 统一存 UTC + 展示时转换 |
| 采样率不一致 | 聚合口径混乱 | 固定采样网格(aligned) |
| 乱序数据不处理 | 聚合结果错位 | 延迟窗口内排序合并 |
时序查询优化的终局:让存储层「越少干活越好」——时间与标签下推、聚合下推、预聚合兜底,应用层只做展示。
8. 总结
| 环节 | 要点 |
|---|---|
| 特点 | 只追加、高吞吐、按时间查、冷热分明 |
| 建模 | 时间戳 + 指标 + 标签,警惕序列爆炸 |
| 降采样 | 按保留期分级,老数据越粗越便宜 |
| 选型 | PG 生态选 TimescaleDB,云原生选 Prometheus,大规模选 TDengine/ClickHouse |
| 写入 | 批量 + 去重 + 乱序合并 + 时间分区 TTL |
| 查询 | 时间范围与标签过滤下推 + 物化视图预聚合 |
时序数据的核心矛盾是**“数据无限增长 vs 成本有限”**。解法是一套组合拳:用正确的建模控制序列数量,用降采样分级控制存储,用预聚合控制查询成本,用 TTL 控制总体规模。把这几件事做好,千万级时序数据也能被稳稳托住。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。