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

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

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

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

Head 每 2 小时切出一个 block。一天 12 个,一个月 360 个。查询“过去 30 天”要打开 360 个目录、360 份索引、做 360 次归并? 显然不行。这就是**压实(compaction)**的活。

压实的三重收益

压实 = 把多个小 block 合并成一个大 block。它带来三重收益:

代价是 IO 和 CPU:压实要读出所有源 block、解压、归并、重新压缩、写出新 block。这是后台的重活,而且会占用和查询相同的资源——大 Prometheus 上,压实期间查询变慢是常见现象。

指数分层:借鉴 LSM

源码 tsdb/compact.go 的核心是 LeveledCompactor(:80),而它的分层策略藏在这个函数名里(compact.go:40):

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

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,按这套规则决定“这次该合并哪几个”。读这个函数能看到完整的分层策略。

为什么有上限:大 block 的坏处

block 不是越大越好——所以有个上限(默认是“保留期的 10%”或 31 天,取小者)。

大 block 的问题:

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

所以上限的本质是:压实的边际收益递减,而边际成本递增——总有一个该停手的点。

Tombstone:不可变世界里的删除

block 不可变,那 DELETE API(删掉某些 series 的某段数据)怎么实现?

答案:不删,只做标记。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)。代价是“删了但还占着空间”直到压实;收益是保住了不可变带来的全部好处(无锁读、位级打包、简单恢复)。

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

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

实战:压实相关的运维现象

几个你会真实遇到的现象:

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

相关的自监控指标(Prometheus 监控自己):

# 压实耗时
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 是你最该盯的基数指标之一——它就是“当前活跃 series 数”,直接决定内存。

小结与下一章

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

存储部分讲完了。数据怎么被读出来?下一章看查询路径,收尾第 3 部分。👉 查询路径。

参考资料

  • [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 不可变设计、索引符号表。

本页目录