# 块与压实 · 为什么要合并，合并到多大

> block 每 2 小时生一个，一天 12 个，一个月 360 个——查询要打开几百个目录？这一章讲 LeveledCompactor 的指数分层压实、压实带来的三重收益，以及 tombstone 怎么在不可变的世界里实现删除。

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

# 块与压实 · 为什么要合并，合并到多大

[Head 每 2 小时切出一个 block](https://daiw.org/manual/observability/head-and-wal)。一天 12 个，一个月 360 个。**查询“过去 30 天”要打开 360 个目录、360 份索引、做 360 次归并？** 显然不行。这就是**压实（compaction）**的活。

## 压实的三重收益

<Callout type="info">
  **压实 = 把多个小 block 合并成一个大 block。它带来三重收益：**

  - **① 减少查询要打开的 block 数** —— 这是最直接的。查一个月的数据，面对 **12 个 6 小时块**远好于 **360 个 2 小时块**：少 30 倍的文件打开、索引加载、结果归并。
  - **② 索引更省** —— [符号表能跨更多 series 去重](https://daiw.org/manual/observability/index-format)。10 个块各自存一份 `job="api-server"`，合并后**只存一份**。block 越大，[符号表的去重效果越好](https://daiw.org/manual/observability/index-format)。
  - **③ 真正删掉该删的** —— [被 tombstone 标记的数据](https://daiw.org/manual/observability/blocks-compaction)、[超出保留期的数据](https://daiw.org/manual/observability/blocks-compaction)，在压实时被**物理丢弃**。这是[不可变世界里唯一的删除时机](https://daiw.org/manual/observability/tsdb-overview)。

  **代价是 IO 和 CPU**：压实要读出所有源 block、解压、归并、重新压缩、写出新 block。这是**后台的重活**，而且[会占用和查询相同的资源](https://daiw.org/manual/observability/blocks-compaction)——大 Prometheus 上，压实期间查询变慢是常见现象。
</Callout>

## 指数分层：借鉴 LSM

源码 `tsdb/compact.go` 的核心是 **`LeveledCompactor`**（`:80`），而它的分层策略藏在这个函数名里（`compact.go:40`）：

```go
// ExponentialBlockRanges returns the time ranges based on the stepSize.
func ExponentialBlockRanges(minSize int64, steps, stepSize int) []int64
```

<Callout type="info">
  **block 的大小按**指数**分层增长，默认是 3 倍：**

  ```
  Level 1:  2h      ← Head 刚切出来的
  Level 2:  6h      ← 3 个 2h 合并
  Level 3:  18h     ← 3 个 6h 合并
  Level 4:  54h     ← 3 个 18h 合并
  ...
  上限:通常是保留期的 10% 或 31 天(取小)
  ```

  **为什么用指数而不是“攒够 N 个就合并成一个大的”？** 这是 **LSM-tree 的经典智慧**——它平衡了两个相互冲突的目标：

  - **写放大** —— 每个数据点会被“重写”几次？指数分层下，一个点从 2h → 6h → 18h → 54h，大约**只被重写 log（总时长/2h） 次**（几次而已）。如果每次都合并成“当前所有数据”，那每次压实都要重写全部历史，**写放大爆炸**。
  - **读放大** —— 查询要打开几个 block？指数分层下，任何时间范围最多跨**每层几个**，总数是**对数级**的。

  **指数分层让写放大和读放大都是对数级**——这是 LSM 相对于“简单合并”的核心优势，Prometheus 直接继承了它。

  **`Plan()`（`compact.go:249`）就是决策者**：它扫描现有 block，按这套规则决定“这次该合并哪几个”。读这个函数能看到完整的分层策略。
</Callout>

## 为什么有上限：大 block 的坏处

<Callout type="warn">
  **block 不是越大越好——所以有个上限（默认是“保留期的 10%”或 31 天，取小者）。**

  大 block 的问题：

  - **压实本身太贵** —— 合并两个 15 天的 block，要读写几十 GB。**这期间的 CPU 和 IO 会明显影响查询**，而且一次压实可能跑几十分钟。
  - **删除粒度太粗** —— [数据过期是按整个 block 删的](https://daiw.org/manual/observability/tsdb-overview)。如果 block 是 31 天的，那么**保留期 15 天的配置根本没法生效**——这个块要等 31 天的数据全过期才能删，你会存下远超预期的数据。**这就是“上限 = 保留期的 10%”这条规则的由来**：保证任何时候都有足够细的粒度来执行保留策略。
  - **重新压实的浪费** —— block 越大，任何一次合并的成本越高，而收益（减少 block 数）越来越小。

  **所以上限的本质是：压实的边际收益递减，而边际成本递增——总有一个该停手的点。**
</Callout>

## Tombstone：不可变世界里的删除

[block 不可变](https://daiw.org/manual/observability/tsdb-overview)，那 `DELETE` API（删掉某些 series 的某段数据）怎么实现？

<Callout type="info">
  **答案：**不删，只做标记**。`tsdb/docs/format/tombstones.md` 定义了这个格式（magic `0x0130BA30`，文件放在 block 顶层目录）。**

  ```
  block/
  ├── chunks/
  ├── index
  ├── meta.json
  └── tombstones      ← "这些 series 的这些时间段,当作不存在"
  ```

  **工作方式：**
  1. **删除请求** → 只往 `tombstones` 文件追加一条记录（“series X 的 `[t1, t2]` 被删了”）。**原始数据一个字节没动。**
  2. **查询时** → 读到数据后，对照 tombstones **过滤掉**被标记的部分。用户看不到它们。
  3. **下次压实时** → 被标记的数据**真的被丢弃**，不写进新 block。**这才是物理删除的时刻。**

  **这是“延迟删除”的经典模式**（数据库里到处都是：MVCC 的旧版本、LSM 的墓碑、文件系统的 unlink）。代价是“删了但还占着空间”直到压实；收益是**保住了[不可变带来的全部好处](https://daiw.org/manual/observability/tsdb-overview)**（无锁读、位级打包、简单恢复）。

  **在监控场景这笔交易极其划算**——删数据本来就罕见（通常只在“误采了敏感信息”时才用），没必要为它牺牲整个架构的简洁。

  规范里还有个细节：“The stones section is **0 padded to a multiple of 4 for fast scans**”——**为了快速扫描而做 4 字节对齐填充**。查询热路径上每次都要读 tombstones，所以它也被优化过。
</Callout>

## 实战：压实相关的运维现象

<Callout type="warn">
  **几个你会真实遇到的现象：**

  - **磁盘用量周期性尖峰** —— 压实时**新旧 block 同时存在**（先写新的，成功后才删旧的）。所以磁盘至少要留出**最大 block 大小**的余量。**规划容量时别按“刚好够”来。**
  - **压实期间查询变慢** —— 压实吃 CPU 和 IO，和查询抢资源。大实例上这很明显。
  - **重启后立刻压实** —— 如果关闭时有 pending 的压实，重启后会补上，可能让“启动后一段时间性能不佳”。
  - **`prometheus_tsdb_compaction_duration_seconds`** —— 这个内置指标能告诉你压实要多久。**它持续变长是个信号**：数据量在增长，可能需要调整保留期或分片。

  **相关的自监控指标（Prometheus 监控自己）：**
  ```text
  # 压实耗时
  histogram_quantile(0.99, rate(prometheus_tsdb_compaction_duration_seconds_bucket[1h]))
  # Head 里的 series 数(基数监控的核心指标!)
  prometheus_tsdb_head_series
  # 压实失败
  rate(prometheus_tsdb_compactions_failed_total[1h]) > 0
  ```
  **`prometheus_tsdb_head_series` 是你最该盯的[基数指标](https://daiw.org/manual/observability/defining-metrics)之一**——它就是“当前活跃 series 数”，直接决定内存。
</Callout>

## 小结与下一章

**压实**把小 block 合成大 block，三重收益：[**减少查询要打开的 block 数**、**索引更省**（符号表跨更多 series 去重）、**真正物理删除**（不可变世界里唯一的删除时机）](https://daiw.org/manual/observability/blocks-compaction)；代价是[后台 IO/CPU 重活，会和查询抢资源](https://daiw.org/manual/observability/blocks-compaction)。策略是 **`ExponentialBlockRanges` 指数分层**（默认 3 倍：2h→6h→18h→54h，`LeveledCompactor.Plan` 决策）——**继承 LSM 的经典智慧：让写放大和读放大都是对数级**。有**上限**（保留期的 10% 或 31 天），因为[大 block 压实太贵、删除粒度太粗（会让保留期失效）、边际收益递减](https://daiw.org/manual/observability/blocks-compaction)。**Tombstone**（magic `0x0130BA30`）实现[不可变世界里的删除](https://daiw.org/manual/observability/tsdb-overview)：[只标记 → 查询时过滤 → 压实时才物理丢弃](https://daiw.org/manual/observability/blocks-compaction)——**延迟删除换取不可变的全部好处**，在监控场景极划算。运维上注意[磁盘尖峰（新旧 block 并存）、压实期查询变慢](https://daiw.org/manual/observability/blocks-compaction)，盯住 **`prometheus_tsdb_head_series`**（[最核心的基数指标](https://daiw.org/manual/observability/defining-metrics)）。

存储部分讲完了。数据怎么被**读出来**？下一章看查询路径，收尾第 3 部分。👉 [查询路径](https://daiw.org/manual/observability/query-path)。

## 参考资料

- [1] Prometheus 源码 `tsdb/compact.go`（`ExponentialBlockRanges:40`、`LeveledCompactor:80`、`Plan:249`、`Compact:485`）。
- [2] Prometheus 源码 `tsdb/docs/format/tombstones.md`（magic `0x0130BA30`、4 字节对齐）。
- [3] 关联[TSDB 不可变设计](https://daiw.org/manual/observability/tsdb-overview)、[索引符号表](https://daiw.org/manual/observability/index-format)。
