Prometheus TSDB Tombstones 磁盘格式详解:Block 级删除标记文件的布局与读写实现
本文以 Prometheus 仓库中的 tombstones.md 文档为主体,完整讲解 TSDB Block 目录下 tombstones 文件的磁盘格式:文件头部魔数与版本字节、每条删除区间记录的 varint 编码方式、文件尾部校验结构,并结合 tsdb/tombstones/tombstones.go 源码剖析 WriteFile / ReadTombstones / MemTombstones 的实际实现。读完后,你将能够独立解读一个 Block 的 tombstones 文件布局,理解"软删除"在 TSDB 落盘与查询过滤链路中的完整位置,并在自行开发 TSDB 工具时正确复用其编解码约定。
Tombstones 在 TSDB 中的角色:删除不是物理移除
TSDB 的 Block 由若干文件组成(meta.json、index、chunks/ 目录,以及位于 Block 顶层目录的 tombstones 文件,见 TSDB 格式总览)。当用户请求删除某时间范围内的序列时,Prometheus 不会去改写不可变的 chunk 数据,而是把"哪个序列引用(series ref)在哪个时间区间被删除"写入 tombstones 文件——即文档开头所说:"tombstones 文件放置在 block 的顶层目录"。
从源码看,这个机制贯穿了删除、读取、压实三个阶段:
- 删除入口:HTTP API 的
/admin/tsdb/delete_series路由(web/api/v1/api.go)最终调用DB.Delete(tsdb/db.go),它对所有时间范围有重叠的 Block 并发执行Block.Delete,并对同样重叠的 Head 执行head.Delete。源码注释明确其原子性保证是"per-block basis"(逐块级别)。 - Block 级落盘:
Block.Delete(tsdb/block.go)通过索引找出与[mint, maxt]区间有 chunk 重叠的序列,构造新的MemTombstones,再调用tombstones.WriteFile整体重写该 Block 的tombstones文件,并同步更新meta.json中的NumTombstones统计。 - 读取过滤:
OpenBlock加载 Block 时即调用tombstones.ReadTombstones(dir)(tsdb/block.go)把删除区间读入内存,供查询器在扫描 chunk 时跳过被删除的时间范围。 - 最终清理:
Block.CleanTombstones(tsdb/block.go)在有 tombstone 的 Block 上触发压实重写;压实器写出新 Block 时会顺带生成一个空的 tombstones 文件(tsdb/compact.go)。
文件整体布局:魔数、版本、Stones 区与尾部字段
文档给出的整体结构如下(引自 tombstones.md):
┌────────────────────────────┬─────────────────────┐
│ magic(0x0130BA30) <4b> │ version(1) <1 byte> │
├────────────────────────────┴─────────────────────┤
│ ┌──────────────────────────────────────────────┐ │
│ │ Tombstone 1 │ │
│ ├──────────────────────────────────────────────┤ │
│ │ ... │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Tombstone N │ │
│ ├──────────────────────────────────────────────┤ │
│ │ CRC<4b> │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
文档中的两点关键说明:
- 文件头部是 4 字节大端魔数
0x0130BA30,紧接 1 字节版本号(当前为1)。 - 文档文字部分提到 "The last 8 bytes specifies the offset to the start of Stones section",并说明 Stones 区按 4 字节 0 填充以便快速扫描。这里需要注意:从当前源码实现看,
WriteFile写出的文件尾部字段是 4 字节 CRC(对应图中CRC<4b>),而文档文字描述的"尾部 8 字节偏移"字段在当前 tsdb/tombstones/tombstones.go 的写/读路径中并未体现——从源码结构看,该文字描述更可能对应历史版本或预留说明,理解格式时应以头部魔数、版本字节、变长 Stones 区、尾部校验这几个核心字段为准。
头部与校验相关的常量定义在 tsdb/tombstones/tombstones.go:
| 常量 | 值 | 含义 |
|---|---|---|
TombstonesFilename |
"tombstones" |
文件名,位于 Block 顶层目录 |
MagicTombstone |
0x0130BA30 |
4 字节魔数,文件头 |
tombstoneFormatV1 |
1 |
当前格式版本(1 字节) |
tombstoneFormatVersionSize |
1 |
版本字段长度 |
tombstonesHeaderSize |
5 |
头部长度(4 字节魔数 + 1 字节版本) |
tombstonesCRCSize |
4 |
尾部 CRC 长度 |
CRC 采用 CRC32-Castagnoli 多项式,表在包 init 阶段预构建(tsdb/tombstones/tombstones.go),以便将来需要更换多项式时只需改一处。
Tombstone 记录编码:uvarint 序列引用 + varint 时间区间
每条 Tombstone 记录的结构(引自 tombstones.md):
┌───────────────────────┬─────────────────┬────────────────┐
│series ref <uvarint64> │ mint <varint64> │ maxt <varint64>│
└───────────────────────┴─────────────────┴────────────────┘
三个字段全部采用 LEB128 风格的变长编码,序列引用用无符号 uvarint64,起止时间用(zigzag 后的)varint64 以支持负时间戳。对应实现见 Encode(tsdb/tombstones/tombstones.go):
func Encode(tr Reader) ([]byte, error) {
buf := encoding.Encbuf{}
buf.PutByte(tombstoneFormatV1) // 版本字节
err := tr.Iter(func(ref storage.SeriesRef, ivs Intervals) error {
for _, iv := range ivs {
buf.PutUvarint64(uint64(ref))
buf.PutVarint64(iv.Mint)
buf.PutVarint64(iv.Maxt)
}
return nil
})
return buf.Get(), err
}
即 Stones 区是"逐条区间"平铺的三元组序列:同一个序列引用可以有多个不重叠区间。反向解码在 Decode(tsdb/tombstones/tombstones.go):先校验首字节必须等于版本 1(否则报 invalid tombstone format),随后以 d.Len() > 0 为循环条件依次读出 ref / mint / maxt 三元组,装入 MemTombstones。这种"读到缓冲区耗尽即结束"的自描述结构意味着解析端无需单独的长度前缀,也解释了文档中 Stones 区按 4 字节对齐填充便于快速扫描的动机——尾部填充字节不携带语义。
写入流程:WriteFile 的校验和与原子替换
tombstones.WriteFile(tsdb/tombstones/tombstones.go)完整实现了文档布局的生成过程:
- 临时文件:先写
tombstones.tmp,任何失败路径下都会删除临时文件并关闭句柄(defer 兜底)。 - 写头部:
buf.PutBE32(MagicTombstone)写入 4 字节大端魔数。 - 编码 Stones 区:调用
Encode生成"版本字节 + 全部三元组"。 - 计算校验和:注意源码中的注释——计算 CRC 时跳过首字节(版本字节),只对 Stones 区内容做 hash;这一"忽略首字节"的兼容处理在写与读两端是对称的。
- 落盘与原子替换:依次写入编码结果和 4 字节 CRC 摘要,执行
f.Sync()刷盘,最后通过fileutil.Replace将 tmp 原子重命名为tombstones,保证读者永远看不到半写状态的文件。
返回值中的 int64(size) 是文件字节数,调用方(如 Block.Delete)用它更新 numBytesTombstone,从而纳入 Block.Size() 的统计(tsdb/block.go)。
读取流程:ReadTombstones 的三道校验
ReadTombstones(tsdb/tombstones/tombstones.go)是加载入口,其防御顺序值得逐条对照:
- 文件不存在不是错误:
os.IsNotExist时返回空的NewMemTombstones()。这与"新 Block 初始可以没有删除记录"的语义一致(尽管压实器会主动写一个空文件,见 tsdb/compact.go)。 - 最小长度:文件短于 5 字节(头部长度)直接报
invalid size。 - 魔数校验:读取前 4 字节大端值,不等于
0x0130BA30报invalid magic number。 - CRC 校验:对去掉尾部 4 字节 CRC 之后的内容(同样跳过首字节版本位)重新计算 CRC32-Castagnoli,与文件尾 4 字节大端比较,不一致报
checksum did not match。 - 解码:通过校验后交给
Decode解析成MemTombstones。
配套的往返测试 TestWriteAndReadbackTombstones(tsdb/tombstones/tombstones_test.go)随机生成 100 组、每组最多 5 个删除区间,写盘再读回后断言两个 Reader 内容完全相等,验证了编码-解码的对称性。
MemTombstones:内存态的区间集合语义
MemTombstones(tsdb/tombstones/tombstones.go)是磁盘格式与运行时之间的内存表示:map[storage.SeriesRef]Intervals 加一把 sync.RWMutex。几个实现细节与查询正确性直接相关:
Get返回副本:为避免并发写导致的数据竞争,Get会拷贝区间切片再返回(tsdb/tombstones/tombstones.go),测试TestTombstonesGetWithCopy专门验证了"对副本做Add会就地修改副本、而再次Get仍拿到原始内容"。- 区间为闭区间:
Interval.InBounds是t >= Mint && t <= Maxt(tsdb/tombstones/tombstones.go),即删除端点时刻的样本也会被覆盖。 Add自动合并重叠与相邻区间:Intervals.Add(tsdb/tombstones/tombstones.go)对有序区间做二分查找,把与新区间重叠的段合并;由于时间戳是离散整数,相邻仅差 1 的区间也可以合并成一段,并对MinInt64/MaxInt64边界做了溢出保护。这保证了落盘前区间集合始终"递增且不重叠",编码结果紧凑。TruncateBefore:删除Maxt < beforeT的历史区间,用于 Head 截断时同步清理过期的删除标记(行为用例见 tombstones_test.go 的TestTruncateBefore)。
一次端到端删除:从 API 到磁盘文件
把上述环节串起来,一次 delete_series 请求的落盘路径是:
- API 层收到 POST/PUT
/admin/tsdb/delete_series(web/api/v1/api.go),携带start、end时间参数与标签匹配器; DB.Delete持有 compaction 互斥锁,选出与[mint, maxt]重叠的 Block,并行执行Block.Delete(tsdb/db.go);- 每个
Block.Delete从索引取匹配序列,仅当序列确实存在落在删除窗口内的 chunk 时才加入删除区间(用clampInterval把窗口限制在该序列实际数据范围内,见 tsdb/block.go),再叠加该 Block 已有的 tombstones,整体写入新的tombstones文件并刷新meta.json; - 后续
CleanTombstones压实(tsdb/db.go)会重写含 tombstone 的 Block,真正从新 Block 中移除被删数据——这也是"删除只保证逐块原子"的原因:重写完成前,旧 Block 与 tombstones 文件共同承担过滤职责。
小结
tombstones 文件是 TSDB"软删除"策略的落盘载体:5 字节头(魔数 0x0130BA30 + 版本 1)、变长 Stones 区(uvarint64 ref + varint64 mint + varint64 maxt 三元组平铺)、尾部 4 字节 CRC32-Castagnoli 校验,配合 tmp 文件加原子重命名的写入方式,构成一个自描述、可校验、读者永远安全的最小化格式。若你要对 Block 做外部工具处理,建议直接复用 tsdb/tombstones/tombstones.go 中的 Encode / Decode 约定;相关行为约束也可以从 tombstones_test.go 的往返、删除、截断测试中获得参考。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00