# 第 3 部分导论 · TSDB 总览

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

- 作者：David（道雾轩）
- 专栏：指标采集与观测（https://daiw.org/manual/observability.md）
- 最后更新：2026-10-01
- 原文：https://daiw.org/manual/observability/tsdb-overview
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 第 3 部分导论 · TSDB 总览

[第 1 部分](https://daiw.org/manual/observability/scrape-loop)把数据抓回来了，[第 2 部分](https://daiw.org/manual/observability/metric-ranges)想清楚了该抓什么。现在数据要落地——这是 **TSDB**（`tsdb/`），整个 Prometheus 硬核程度**数一数二**的部分，也是真能学到功夫的地方。

## 两个难题

<Callout type="info">
  **TSDB 只解决两件事，但都难：**

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

  TSDB 给出的答案分别是：**[极端的压缩](https://daiw.org/manual/observability/chunk-encoding)**（把 16 字节压到平均 **1~2 字节**，10 倍以上）和**[倒排索引](https://daiw.org/manual/observability/index-format)**（标签 → series 列表，和搜索引擎同源）。

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

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

<Callout type="info">
  **通用数据库不敢做的优化，TSDB 敢——因为时序数据有三个**极强的先验**：**

  - **时间戳几乎等间距** —— [抓取是按固定间隔的](https://daiw.org/manual/observability/scrape-loop)（15s 一次，而且[哈希打散保证了它很稳](https://daiw.org/manual/observability/scrape-loop)）。所以时间戳序列是 `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 压缩](https://daiw.org/manual/observability/chunk-encoding)**（Facebook 的论文，Prometheus 的实现基于它）：**delta-of-delta 存时间戳、XOR 存值**。[下下章](https://daiw.org/manual/observability/chunk-encoding)会看它的位级实现。

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

## 结构：Head + Block 两段式

TSDB 在磁盘上的样子：

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

<Callout type="info">
  **数据的一生分两段：**

  - **Head（头部）** —— **最近的数据在内存里**。新抓来的点先进 Head，同时**写一份 [WAL](https://daiw.org/manual/observability/head-and-wal)**（防止进程崩了内存数据丢失）。Head 是**可写**的。
  - **Block（块）** —— **每隔一段时间（默认 2 小时），Head 里的数据被切出来、压缩、写成一个不可变的 block**。block 一旦落盘就**永不修改**，只会被[压实（compaction）](https://daiw.org/manual/observability/blocks-compaction)合并成更大的 block。

  ```mermaid
  flowchart LR
      S["抓取"] --> H["Head(内存,可写)<br/>+ WAL(防丢)"]
      H -->|"每 2 小时切出"| B1["Block(不可变)<br/>2h"]
      B1 -->|"压实"| B2["Block<br/>2h+2h+2h → 6h"]
      B2 -->|"再压实"| B3["Block<br/>更大"]
      B3 -->|"超出保留期"| D["删除"]
      style H fill:#3b82f6,color:#fff
      style B1 fill:#f59e0b,color:#000
  ```

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

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

## 为什么 block 要不可变

<Callout type="info">
  **不可变（immutable）是这个设计里最关键、也最常被低估的决定，它带来一串连锁的简化：**

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

  **代价是：删除单个 series 变得困难**（不能原地改）。TSDB 的解法是 **tombstone（墓碑）**——[单独记一个“这段数据被删了”的标记](https://daiw.org/manual/observability/blocks-compaction)，查询时跳过，等到[下次压实](https://daiw.org/manual/observability/blocks-compaction)时才真正物理删除。这是**用“延迟删除”换“不可变的全部好处”**，非常划算——毕竟删数据在监控场景里极其罕见。
</Callout>

## 官方规范：这一部分的地图

TSDB 是本专栏**唯一能读到字节级**的部分，因为它有**官方磁盘格式规范**（`tsdb/docs/format/`）：

| 规范 | 讲什么 | 本部分 |
| --- | --- | --- |
| `index.md` | 倒排索引的磁盘布局（magic `0xBAAAD700`） | [索引格式](https://daiw.org/manual/observability/index-format) |
| `chunks.md` | 采样点文件布局（magic `0x85BD40DD`） | [块与压实](https://daiw.org/manual/observability/blocks-compaction) |
| `wal.md` | 预写日志格式 | [Head 与 WAL](https://daiw.org/manual/observability/head-and-wal) |
| `head_chunks.md` | Head 切出的 chunk | [Head 与 WAL](https://daiw.org/manual/observability/head-and-wal) |
| `tombstones.md` | 删除标记 | [块与压实](https://daiw.org/manual/observability/blocks-compaction) |
| `bstream.md` | 位流与 Gorilla 编码 | [chunk 编码](https://daiw.org/manual/observability/chunk-encoding) |

**这些规范和它旁边的代码是一起演进的**——[比任何二手博客都可靠](https://daiw.org/manual/observability/reading-the-source)。本部分基本就是围着它们 + 对应实现读的。

## 小结与下一章

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

先看数据的第一站：内存里的 Head，以及那个保证它不丢的 WAL。👉 [Head 与 WAL](https://daiw.org/manual/observability/head-and-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` 中链接）。
