本节摘要:时序数据库(TSDB) 面向按时间排序的数据点流,在存储布局、索引与查询语言上优化 append 写入与时间范围聚合。相对 OLTP,它把
PRIMARY KEY (metric, tags, time)而非(id)当作组织主轴。本节用四大特征解释设计取舍,并对照四家主流产品。
阅读完本节,你应当能够:
2010 年前后,工程师在 MySQL 单表日增千万级时间点时发现:INSERT 延迟飙升,按时间范围扫描索引碎片化,应用层不得不手写 GROUP BY FLOOR(UNIX_TIMESTAMP(time)/300) 做 5 分钟聚合——时间在关系模型里只是普通列,在业务里却是不可切分的主轴。TSDB 把「过程-状态(Process-State)」而非「实体-关系(E-R)」当作建模起点:一个 CPU 读数孤立无意义,嵌入毫秒时间戳与 host、region 标签后才成为可聚合的信号。
把上面那句 SQL 展开看,会更清楚地意识到关系模型与时间轴的错位:
-- MySQL:用表达式拼 5 分钟桶,慢且难维护 SELECT CONCAT(DATE(ts),' ',HOUR(ts),':',FLOOR(MINUTE(ts)/5)*5) AS bucket, AVG(cpu_usage) FROM metrics WHERE host = 'web-01' AND ts >= NOW() - INTERVAL 1 DAY GROUP BY bucket;
这段查询的毛病不在写法本身,而在于:FLOOR(MINUTE(ts)/5) 这类表达式无法走常规索引,数据库只能先把一天的数据捞出来再在内存里分桶。当日写入量上到千万级时,这条查询会稳定吃掉几秒。TSDB 的替代做法是让存储层按时间组织数据,查询时直接定位「5 分钟块」再聚合,语义相同、执行路径完全不同。
Series(时间序列) = 唯一 metric + 标签组合下的全部样本;Sample = (timestamp, value)。
| 维度 | OLTP(MySQL) | TSDB |
|---|---|---|
| 写入模式 | 随机 UPDATE/INSERT | append 为主 |
| 典型查询 | 点查、JOIN | 时间窗口聚合 |
| 主键逻辑 | 实体 id | metric + tags + time |
| 保留策略 | 长期全量 | TTL、降采样 |
理解 series 概念后,最好再想一层:一条 series 的样本在物理上如何存放?几乎所有现代 TSDB 的做法都类似——同一 series 的样本在内存与磁盘上都相邻排列,形成一个「按时间有序的样本数组」。
series = cpu_usage{host=web-01, region=cn} samples = [(t0,0.72),(t1,0.73),(t2,0.71),(t3,0.74),...]
这样组织带来两个好处:查询某 series 的时间段,只需顺序扫它的样本数组,天然适合顺序 IO 与向量化;压缩时同一 series 的相邻样本时间间隔稳定、数值平滑,delta-of-delta 与 Gorilla 才有发挥空间。反观 MySQL,同一 host 的数据分散在 B+ 树各处,既要回表又要随机读。
高写入密度:Kubernetes 集群每 15s scrape 一次,单 Prometheus 实例可管理数百万 active series。
强时间局部性:最近 1 小时查询远多于 3 年前;TSM、hypertable chunk、QuestDB 分区都利用这一点。
低更新频率:点写入后极少改值;纠错常靠重写块而非行级 UPDATE。
高维标签化:cpu_usage{host,region,service} 构成多维立方体;标签组合爆炸即 cardinality 问题——InfluxData 2023 报告指出,标签基数超 50 万时 P99 查询延迟平均升高 3.7 倍。
| 产品 | 序列标识方式 | 时间精度 |
|---|---|---|
| Prometheus | metric name + labels | 毫秒 |
| InfluxDB | measurement + tag set | 纳秒 |
| TimescaleDB | 表行 + 时间列 | timestamptz |
| QuestDB | 表 + designated timestamp | 微秒/纳秒 |
# Prometheus exposition(文本格式) # metric{labels} value timestamp_ms cpu_usage{host="web-01",region="cn"} 0.73 1717023456789 # Influx line protocol # measurement,tag=v field=v timestamp_ns cpu,host=web-01,region=cn usage=0.73 1717023456789000000
cardinality 为什么是「第一杀手」而不是磁盘?因为 series 数决定三样东西同时膨胀:内存里的 series 表、标签倒排索引的 posting list、查询时需要展开的样本组数。举个例子:
host(10) × pod(20) × status(5) = 1000 series / metric 再乘 200 个 metric = 20 万 series
如果误把 pod_uid 当 label,pod 维度从 20 变成 20 万,series 数直接放大一万倍。所以治理基数永远排在扩容前面。
| 场景 | 推荐 | 避免 |
|---|---|---|
| 容器监控 | Prometheus + Remote Write | 把 pod_uid 作高基数 label |
| 多字段 IoT 批次 | Influx line protocol | user_id 作 tag |
| 已有 PG 生态 | TimescaleDB hypertable | 用 TSDB 做订单事务 |
| 低延迟分析 SQL | QuestDB | 在 TSDB 里跑复杂 JOIN |
假设你发现一个集群的 series 数一周内翻了三倍,如何定位元凶?可以这样排查:先用 topk(10, count by (__name__)({__name__=~".+"})) 之类查询找出 series 最多的 metric,再对它按各 label 分组统计基数,定位到是哪个 label 在爆炸。实战中这类问题多半来自把 request_id、trace_id、user_id 这类随请求变化的唯一值放进了 label——它们在语义上是「事件属性」,不是「可聚合维度」。
⚠️ 常见坑:把
user_id、request_id放进 tag——series 数爆炸,倒排索引与内存撑满。
💡 关键直觉:tag 是「你用什么 GROUP BY」;field 是「你测什么数」。Prometheus 无 field 概念,多值需拆 metric 或用不同 label。
cpu_usage{host="web-01",region="cn"} 是不是一条 series?再叠加 code="200" 呢?user_id 放进 tag 会带来哪三类成本?至少说出两类。下一节梳理从 OpenTSDB 到云原生四大产品的演进脉络。