它是什么
时间序列是某一个序列(series) 的 (timestamp, value) 样本序列,序列由一个指标名和一组 label 标识(request_latency{service="api", region="eu"})。指标系统、监控和 IoT 都存储数百万个序列,每个都在持续写入、几乎从不更新。这种工作负载(只追加、按时间有序、按 label 和时间范围查询)适合专门构建的布局,而不是通用的表。
Important
核心要点: 把样本存成按序列追加的流,放在按时间分区的不可变块里,用一个内存中的 head 加 write-ahead log 保存最新的数据。这种布局让写入便宜、压缩比约 10×,并让查询只读取它所需要的块和序列。系统真正的容量上限是基数(cardinality),即不同序列的数量。
何时需要它
“设计一个指标系统”要求给出存储布局和写路径,追问总是”最先坏的是什么?“答案是基数,而不是数据量。
Note
面试信号:
说”决定性的问题是写入和查询各自碰什么”,再解释为什么在通用数据库里每个样本一行会和你作对,说明你能推导出布局,而不是背诵它。点出像 user_id 这样的 label 就是会杀死系统的东西,是实用的细节。
三种布局
| 选项 | 做法 | 评价 |
|---|---|---|
| 通用存储里每个样本一行 | (metric, labels, ts, value) 的行 | 写入可以扩展,但每个查询都要把行重新分组成序列,label 索引仍是你自己的问题,而一个 16 字节的样本要带几十字节的行开销。你是在一个和你作对的数据库里重造一个时序数据库 |
| 专门构建的时序布局 | 内存 head 里的按序列的 chunk,由日志保护;定期封存为不可变的块 | 正确的答案:它匹配工作负载 |
| 只做预聚合 | 在摄入时计算固定的 rollup;不存原始序列 | 便宜又快,但你没有预聚合过的问题(“上周二事故期间按客户的 p99”)就无法回答 |
预聚合应选择性地使用,作为已知开销大的看板查询的录制规则(recording rule),叠加在原始存储之上。
写路径
- Agent 按抓取间隔批量发送样本。路由器对序列标识(名称加排好序的 label)做 hash,所以一个序列的每个样本都落到同一个 ingester 分片上。
- Ingester 把这一批追加到它的 write-ahead log(顺序写,热路径上唯一的持久化成本),并应用到 head:查找这个序列(如果是新的就创建;基数就是在这里诞生的),追加到它当前的 chunk。
- 压缩发生在追加过程中。 时间戳存成 delta-of-delta(对 10 秒的抓取间隔,几乎总是几个 bit),值则与前一个浮点数做 XOR(变化缓慢的 gauge 几乎压缩为零)。这种 Gorilla 风格的压缩能达到约 每个样本 1.5 字节,比 16 字节的原始大小节省大约 10 倍,而且它只在按序列存储样本时才有效。
- 每两小时,head 封存成一个不可变的块(chunk 加上那个窗口的 label 索引),写入对象存储;该窗口的日志段被丢弃,head 内存被释放。
- 每个序列写入两到三个 ingester,所以丢失一个不会有损失,重启只需重放日志。
算例。 每秒 500 万个样本、抓取间隔 10 秒,意味着约 5,000 万个活跃序列。这是 5,000,000 × 86,400 ≈ 4.3 × 10¹¹ 个样本一天:按每个样本 1.5 字节约 650 GB 一天,而按 16 字节则约 6.9 TB 一天。高写入速率从不碰数据库;它是一次哈希查找加一次内存追加,每个分片一条顺序的日志流。昂贵的步骤(构建不可变的、带索引的、压缩的块)每两小时批量发生一次,不在热路径上。
读路径
- 解析序列。 label 索引是一个倒排索引:label 是 term,序列是文档。查询的选择器(
service="api"和__name__="request_latency")是对 posting list 的集合交集,返回大概 12,000 个序列 id。这个交集才是查询真正的成本驱动因素,那时还一个样本都没读。 - 取回 chunk,针对这些序列和时间范围:最近的数据来自 head(RAM),较旧的来自块,带块级缓存(不可变性让缓存天然正确)。
- 聚合,带下推:每个分片对它那份序列做预聚合,查询节点合并部分结果,所以汇聚的是 分片数 × 分组数,而不是 序列数 × 样本数。
降采样。 一个 compaction 作业以更低的分辨率重写已封存的块:10 秒的原始数据变成 5 分钟和 1 小时的聚合,存储每个窗口的 min、max、sum 和 count,使平均值和速率仍可计算。一次 13 个月的查询读 1 小时的块,样本比原始数据少约 360 倍,查询引擎按范围选择分辨率。于是保留策略变得字面化:原始块在 30 天后删除,降采样的块保留。
基数:故障的维度
每一个不同的 label 组合都对应一个序列。当这个数量爆炸时,系统会突然失效:一次部署给一个热门指标加了一个 user_id label,1,000 个序列一夜之间变成 5,000 万个,head 内存、索引大小和查询扇出同时成倍增加。这就是指标系统版本的名人问题:每个指标的序列数服从幂律分布,一个糟糕的 label 就是整条长尾。
算例。 一个按服务(5)、端点(20)、状态类别(5)和客户(100)加 label 的请求计数器,有 5 × 20 × 5 × 100 = 50,000 个序列。去掉客户这个 label(高基数的细节应该放在日志和 trace 里),就只有 500 个。
要按这个顺序部署的防御:
- 每个租户、每个指标的序列数限制,在摄入时强制执行,超出上限的新序列会被明确地拒绝。
- 基数看板,按序列数显示排名靠前的指标,让一个坏 label 在变成故障之前就看得见。
- Label 白名单,以及对新 label 的评审。
- 录制规则,预聚合人们实际会查询的形状。
Warning
生产实践: 要监控监控系统本身。如果指标存储在它本该诊断的事故期间宕机了,它就没完成它唯一的任务。运行一条小的、独立的”元监控”路径(心跳、dead-man’s switch),在主路径停止上报时报警。
如何选择
| 需求 | 选择 | 原因 |
|---|---|---|
| 数百万个序列、只追加的样本 | 时序数据库的布局 | 压缩与按序列的局部性 |
| 对近期数据的快速查询 | 内存 head 加结果缓存 | RAM 速度的读 |
| 廉价的长范围查询 | 降采样的块,按范围选择 | 样本少约 360 倍 |
| 防止 label 爆炸 | 摄入时的序列数限制 | 尽早拒绝坏的来源 |
| 按用户或按请求的细节 | 日志或 trace,而不是指标 label | 基数无界 |
| 已知很慢的看板查询 | 录制规则 | 计算一次,多次读取 |
Caution
常见错误: 把一个无界的标识符(用户 id、请求 id、带 id 的 URL 路径)放进指标 label。系统的容量就是序列数,每一个唯一值都是一个新序列;指标是用来做聚合的。
相关
- 演练:设计指标与监控系统 — 这种存储布局所服务的完整指标系统设计。
- 参考:倒排索引与分段索引 — label 索引就是倒排索引:label 是词项,序列是文档。
- 参考:流处理——窗口、事件时间、水位线与 Exactly-Once — 窗口化聚合,监控管道的另一半。