第 3 部分导论 · TSDB 总览

Prometheus 里硬核程度数一数二的部分。这一章讲清 TSDB 要解决的两个难题(存得下、查得快)、它的 Head + Block 两段式结构,以及为什么「时序数据的特殊性」允许它做常规数据库做不到的极端优化。

作者 David更新于 第 19 篇(共 52 篇)

第 3 部分导论 · TSDB 总览

第 1 部分把数据抓回来了,第 2 部分想清楚了该抓什么。现在数据要落地——这是 TSDB(tsdb/),整个 Prometheus 硬核程度数一数二的部分,也是真能学到功夫的地方。

两个难题

TSDB 只解决两件事,但都难:

  • 存得下 —— 一个中等规模的 Prometheus 每秒要吃几十万到几百万个采样点。按原始格式存(8 字节时间戳 + 8 字节 float64 = 16 字节),100 万点/秒 × 86400 秒 = 1.4 TB/天。这显然不可接受。
  • 查得快 —— {job="api", status="500"} 要在几百万条 series 里瞬间挑出匹配的那些,再从中读出指定时间段的点。

TSDB 给出的答案分别是:极端的压缩(把 16 字节压到平均 1~2 字节,10 倍以上)和**倒排索引**(标签 → series 列表,和搜索引擎同源)。

这两个答案都不是凭空来的,而是利用了时序数据的特殊性质——这是理解 TSDB 一切设计的钥匙。

为什么时序数据能被压到 1~2 字节

通用数据库不敢做的优化,TSDB 敢——因为时序数据有三个极强的先验**:**

  • 时间戳几乎等间距 —— 抓取是按固定间隔的(15s 一次,而且哈希打散保证了它很稳)。所以时间戳序列是 t, t+15, t+30, t+45...——相邻差值(delta)几乎恒定,而差值的差值(delta-of-delta)几乎总是 0。存一串 0 是极其便宜的。
  • 相邻的值极其相似 —— 内存占用从 4.01GB 变成 4.02GB,CPU 从 42.3% 变成 42.5%。相邻两个 float64 的二进制表示,高位绝大部分相同——异或(XOR)之后是一长串 0。
  • 只追加,不修改 —— 时序数据写入即历史,几乎从不更新已有的点。这允许用不可变的数据结构和紧密打包的编码(不用为“以后可能要改”留空间)。

这三条先验合起来,就是 Gorilla 压缩(Facebook 的论文,Prometheus 的实现基于它):delta-of-delta 存时间戳、XOR 存值。下下章会看它的位级实现。

这就是“专用数据库”的价值:通用数据库必须假设“数据可能长任何样、可能被任意修改”,所以不敢用这些技巧;TSDB 知道自己的数据长什么样,于是能做到 10 倍以上的压缩。约束越强,优化空间越大。

结构:Head + Block 两段式

TSDB 在磁盘上的样子:

data/
├── wal/                    ← 预写日志(防丢)
│   ├── 000001
│   └── 000002
├── chunks_head/            ← Head 已切出的 chunk(内存映射)
├── 01H8XY.../              ← 一个持久化 block(不可变)
│   ├── chunks/
│   │   └── 000001          ← 采样点数据(压缩后)
│   ├── index               ← 倒排索引
│   ├── meta.json           ← 元数据(时间范围等)
│   └── tombstones          ← 删除标记
├── 01H8XZ.../              ← 另一个 block
└── ...

数据的一生分两段:

  • Head(头部) —— 最近的数据在内存里。新抓来的点先进 Head,同时写一份 WAL(防止进程崩了内存数据丢失)。Head 是可写的。
  • Block(块) —— 每隔一段时间(默认 2 小时),Head 里的数据被切出来、压缩、写成一个不可变的 block。block 一旦落盘就永不修改,只会被压实(compaction)合并成更大的 block。
图表加载中…

这个“内存写入 + 定期落盘 + 后台合并”的结构,和 LSM-tree 一脉相承(LevelDB/RocksDB 那一套)。核心思想相同:写入走内存(快),落盘是顺序写(快),代价是读要查多个地方、且需要后台合并。

为什么 TSDB 适合这个结构?因为它的负载是写多读少、写入是纯追加、读取按时间范围——正是 LSM 的甜蜜区。

为什么 block 要不可变

不可变(immutable)是这个设计里最关键、也最常被低估的决定,它带来一串连锁的简化:

  • 无需锁 —— 已落盘的 block 永远不变,所以读它不需要任何同步。多个查询可以并发读同一个 block,零竞争。
  • 可以极端压缩 —— 因为不用考虑“以后要在中间插一个点”,数据可以紧密打包到位级(连字节对齐都不要)。可变的数据结构做不到这一点。
  • 删除超简单 —— 数据过期?直接删整个目录。不用像 B-tree 那样做复杂的页面回收。
  • 备份/迁移超简单 —— block 是自包含的目录,拷走就行。这正是 Thanos/Mimir 能把 block 传到对象存储的基础。
  • 崩溃恢复简单 —— block 要么完整要么不存在(靠原子重命名),不会有“写了一半”的中间状态。

代价是:删除单个 series 变得困难(不能原地改)。TSDB 的解法是 tombstone(墓碑)——单独记一个“这段数据被删了”的标记,查询时跳过,等到下次压实时才真正物理删除。这是用“延迟删除”换“不可变的全部好处”,非常划算——毕竟删数据在监控场景里极其罕见。

官方规范:这一部分的地图

TSDB 是本专栏唯一能读到字节级的部分,因为它有官方磁盘格式规范(tsdb/docs/format/):

规范讲什么本部分
index.md倒排索引的磁盘布局(magic 0xBAAAD700)索引格式
chunks.md采样点文件布局(magic 0x85BD40DD)块与压实
wal.md预写日志格式Head 与 WAL
head_chunks.mdHead 切出的 chunkHead 与 WAL
tombstones.md删除标记块与压实
bstream.md位流与 Gorilla 编码chunk 编码

这些规范和它旁边的代码是一起演进的——比任何二手博客都可靠。本部分基本就是围着它们 + 对应实现读的。

小结与下一章

TSDB 只解决两件事:存得下(原始格式 1.4TB/天不可接受 → 压到平均 1~2 字节/点)和查得快(几百万 series 里瞬间挑出匹配的 → 倒排索引)。它敢做极端优化,是因为时序数据有三个强先验:时间戳几乎等间距(delta-of-delta ≈ 0)、相邻值极其相似(XOR 后一串 0)、只追加不修改——约束越强,优化空间越大,这就是专用数据库的价值。结构是 Head(内存可写 + WAL 防丢)+ Block(每 2 小时切出、不可变、被压实合并),和 LSM-tree 一脉相承(写多读少、纯追加、按时间范围读——正是 LSM 甜蜜区)。block 不可变带来一串连锁简化:读无需锁、可位级紧密打包、删除即删目录、可直接拷去对象存储(Thanos 的基础)、崩溃恢复简单;代价是删单条 series 要靠 tombstone 延迟删除——很划算。

先看数据的第一站:内存里的 Head,以及那个保证它不丢的 WAL。👉 Head 与 WAL。

参考资料

  • [1] Prometheus 源码 tsdb/README.md、tsdb/docs/format/(index/chunks/wal/head_chunks/tombstones)、tsdb/docs/bstream.md。
  • [2] Facebook「Gorilla: A Fast, Scalable, In-Memory Time Series Database」(VLDB 2015)——压缩算法的来源。
  • [3] Fabian Reinartz 的原始 TSDB 设计文章(tsdb/README.md 中链接)。

本页目录