Skip to main content

参考:时序存储与基数

一份关于时序存储的参考——按序列压缩的 chunk、head 与 write-ahead log、不可变块与降采样,以及为什么基数才是真正的容量上限。

它是什么

时间序列是某一个序列(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),叠加在原始存储之上。

写路径

  1. Agent 按抓取间隔批量发送样本。路由器对序列标识(名称加排好序的 label)做 hash,所以一个序列的每个样本都落到同一个 ingester 分片上。
  2. Ingester 把这一批追加到它的 write-ahead log(顺序写,热路径上唯一的持久化成本),并应用到 head:查找这个序列(如果是新的就创建;基数就是在这里诞生的),追加到它当前的 chunk。
  3. 压缩发生在追加过程中。 时间戳存成 delta-of-delta(对 10 秒的抓取间隔,几乎总是几个 bit),值则与前一个浮点数做 XOR(变化缓慢的 gauge 几乎压缩为零)。这种 Gorilla 风格的压缩能达到约 每个样本 1.5 字节,比 16 字节的原始大小节省大约 10 倍,而且它只在按序列存储样本时才有效。
  4. 每两小时,head 封存成一个不可变的块(chunk 加上那个窗口的 label 索引),写入对象存储;该窗口的日志段被丢弃,head 内存被释放。
  5. 每个序列写入两到三个 ingester,所以丢失一个不会有损失,重启只需重放日志。

算例。 每秒 500 万个样本、抓取间隔 10 秒,意味着约 5,000 万个活跃序列。这是 5,000,000 × 86,400 ≈ 4.3 × 10¹¹ 个样本一天:按每个样本 1.5 字节约 650 GB 一天,而按 16 字节则约 6.9 TB 一天。高写入速率从不碰数据库;它是一次哈希查找加一次内存追加,每个分片一条顺序的日志流。昂贵的步骤(构建不可变的、带索引的、压缩的块)每两小时批量发生一次,不在热路径上。

读路径

  1. 解析序列。 label 索引是一个倒排索引:label 是 term,序列是文档。查询的选择器(service="api" 和 __name__="request_latency")是对 posting list 的集合交集,返回大概 12,000 个序列 id。这个交集才是查询真正的成本驱动因素,那时还一个样本都没读。
  2. 取回 chunk,针对这些序列和时间范围:最近的数据来自 head(RAM),较旧的来自块,带块级缓存(不可变性让缓存天然正确)。
  3. 聚合,带下推:每个分片对它那份序列做预聚合,查询节点合并部分结果,所以汇聚的是 分片数 × 分组数,而不是 序列数 × 样本数。

降采样。 一个 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 个。

要按这个顺序部署的防御:

  1. 每个租户、每个指标的序列数限制,在摄入时强制执行,超出上限的新序列会被明确地拒绝。
  2. 基数看板,按序列数显示排名靠前的指标,让一个坏 label 在变成故障之前就看得见。
  3. Label 白名单,以及对新 label 的评审。
  4. 录制规则,预聚合人们实际会查询的形状。

Warning

生产实践: 要监控监控系统本身。如果指标存储在它本该诊断的事故期间宕机了,它就没完成它唯一的任务。运行一条小的、独立的”元监控”路径(心跳、dead-man’s switch),在主路径停止上报时报警。

如何选择

需求选择原因
数百万个序列、只追加的样本时序数据库的布局压缩与按序列的局部性
对近期数据的快速查询内存 head 加结果缓存RAM 速度的读
廉价的长范围查询降采样的块,按范围选择样本少约 360 倍
防止 label 爆炸摄入时的序列数限制尽早拒绝坏的来源
按用户或按请求的细节日志或 trace,而不是指标 label基数无界
已知很慢的看板查询录制规则计算一次,多次读取

Caution

常见错误: 把一个无界的标识符(用户 id、请求 id、带 id 的 URL 路径)放进指标 label。系统的容量就是序列数,每一个唯一值都是一个新序列;指标是用来做聚合的。

相关