块与压实 · 为什么要合并,合并到多大
block 每 2 小时生一个,一天 12 个,一个月 360 个——查询要打开几百个目录?这一章讲 LeveledCompactor 的指数分层压实、压实带来的三重收益,以及 tombstone 怎么在不可变的世界里实现删除。
块与压实 · 为什么要合并,合并到多大
Head 每 2 小时切出一个 block。一天 12 个,一个月 360 个。查询“过去 30 天”要打开 360 个目录、360 份索引、做 360 次归并? 显然不行。这就是**压实(compaction)**的活。
压实的三重收益
压实 = 把多个小 block 合并成一个大 block。它带来三重收益:
- ① 减少查询要打开的 block 数 —— 这是最直接的。查一个月的数据,面对 12 个 6 小时块远好于 360 个 2 小时块:少 30 倍的文件打开、索引加载、结果归并。
- ② 索引更省 —— 符号表能跨更多 series 去重。10 个块各自存一份
job="api-server",合并后只存一份。block 越大,符号表的去重效果越好。 - ③ 真正删掉该删的 —— 被 tombstone 标记的数据、超出保留期的数据,在压实时被物理丢弃。这是不可变世界里唯一的删除时机。
代价是 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) []int64block 的大小按指数分层增长,默认是 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 的这些时间段,当作不存在"工作方式:
- 删除请求 → 只往
tombstones文件追加一条记录(“series X 的[t1, t2]被删了”)。原始数据一个字节没动。 - 查询时 → 读到数据后,对照 tombstones 过滤掉被标记的部分。用户看不到它们。
- 下次压实时 → 被标记的数据真的被丢弃,不写进新 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]) > 0prometheus_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(magic0x0130BA30、4 字节对齐)。 - [3] 关联TSDB 不可变设计、索引符号表。