Prometheus TSDB 引用类型深度解析:Series 与 Chunk 的 64 位引用如何定位时序数据
本文围绕 Prometheus 仓库中 tsdb/docs/refs.md 的设计说明展开,系统梳理 TSDB 中两类核心引用——SeriesRef(系列引用)与 ChunkRef(块引用)——在 Head(内存)与持久化 Block(磁盘)中的不同实现形态、位宽约束与设计动机。读完本文,你将理解 Prometheus 查询执行时如何通过一个 64 位整数高效找到系列元数据与压缩 chunk 数据,以及 HeadChunkRef 与 BlockChunkRef 在位布局上的差异从何而来。
1. 全局视角:三层引用体系的分工
TSDB 对外暴露的查询接口只依赖抽象的“引用”概念,而不同存储位置(Head 内存区、持久化 Block)各自实现了不同的引用编码。refs.md 给出的总览表如下:
| 位置 | Series 访问 | Chunk 访问 |
|---|---|---|
| 全局接口 | SeriesRef(位于 postings 索引列表中) |
chunks.ChunkRef(ChunkReader 接口、Meta.Ref) |
| Head | HeadSeriesRef(自增计数器) |
HeadChunkRef(可为内存 head chunk 或 mmap chunk,5/3 字节拆分) |
| 持久化 Block | BlockSeriesRef(16 字节对齐) |
BlockChunkRef(4/4 字节拆分) |
这三行对应了 Prometheus 时序存储的基本事实:查询一个序列,需要先通过 postings 索引拿到 Series 引用,再由该引用解析出该序列的所有 chunk 引用,最后按 chunk 引用读取压缩数据。引用类型的设计目标就是让这两步解析尽可能“一次到位”。全局接口的定义可见 tsdb/chunks/chunks.go:
// ChunkRef is a generic reference for reading chunk data. In prometheus it
// is either a HeadChunkRef or BlockChunkRef, though other implementations
// may have their own reference types.
type ChunkRef uint64
// HeadSeriesRef refers to in-memory series.
type HeadSeriesRef uint64
// HeadChunkRef packs a HeadSeriesRef and a ChunkID into a global 8 Byte ID.
// The HeadSeriesRef and ChunkID may not exceed 5 and 3 bytes respectively.
type HeadChunkRef uint64
// BlockChunkRef refers to a chunk within a persisted block.
// The upper 4 bytes are for the segment index and
// the lower 4 bytes are for the segment offset where the data starts for this chunk.
type BlockChunkRef uint64
需要强调(与原文档一致的边界说明):这里描述的是 Prometheus 自身的实现,其他基于 TSDB 库的项目可能采用不同的引用实现。
2. SeriesRef:如何定位一条时间序列
2.1 HeadSeriesRef:内存中的自增 ID
HeadSeriesRef 本质上是一个 64 位计数器,每当一条新序列写入 Head 时就递增分配一个。原文档指出:由于序列“生灭”(series churn)的存在,实际活跃使用的 HeadSeriesRef 集合往往远大于 0——例如 0~1000 万号段可能已经失效,而 1000 万~1100 万号段才是活跃序列。
在源码中,HeadSeriesRef 是 Head 内 stripeSeries 哈希表的分片键。tsdb/head.go 中 stripeSeries 的注释与结构说明了它的双重索引方式:
// stripeSeries holds series by HeadSeriesRef ("ID") and also by hash of their labels.
// ID-based lookups via getByID() are preferred over getByHash() for performance reasons.
type stripeSeries struct {
size int
series []map[chunks.HeadSeriesRef]*memSeries // Sharded by ref. ...
hashes []seriesHashmap // Sharded by label hash.
locks []stripeLock
mmapReady []paddedAtomicInt32
seriesLifecycleCallback SeriesLifecycleCallback
}
也就是说,调用方有两条路径拿到序列:已知 HeadSeriesRef 时按 ref 分片直查(性能优先);不知道 ref 时按标签集的哈希值查(getByHash),这正是 refs.md 中“可以通过系列标签的哈希来访问”的源码依据。默认分片数为 DefaultStripeSize = 1 << 14(见 tsdb/head.go)。
refs.md 列出了 HeadSeriesRef 的三类消费方,均可在源码中得到印证:
- stripeSeries 哈希表:即上文按 ref 分片的
map[chunks.HeadSeriesRef]*memSeries; - WAL:WAL 记录以 ref 标识序列,重启时通过回放 WAL 恢复“最后一个
HeadSeriesRef”并从此续接。Agent 模式中的对等逻辑可见 tsdb/agent/db.go:loadWAL以lastRef = chunks.HeadSeriesRef(db.nextRef.Load())作为回放起点; - HeadChunkRef 编码:因为 head chunk 归
memSeries所有,HeadChunkRef内必须内嵌HeadSeriesRef才能先寻址到序列,再取 chunk。
原文档还给出两条值得展开的工程细节:
- mmap 化的 Head chunk 不含独立索引,它依赖内存中的系列列表;但 mmap chunk 内部保存了对应的
HeadSeriesRef,配合 WAL 中标签与样本记录,理论上可从磁盘 chunk 重建索引。其工程意义在于:启动时利用 mmap chunk 可以避免完整重放整段 WAL,从而节省 CPU 与启动时间。 - 查询路径上
HeadSeriesRef被限制在 2^40 以内(原因见下文HeadChunkRef的位宽约束)。
2.2 BlockSeriesRef:16 字节对齐的索引偏移
持久化 Block 是完全独立、结构迥异于 Head 的实体。在 Block 中:
- 系列按标签字典序排列;
- 某条系列条目在
index文件中的字节偏移,除以 16(因为每个条目都对齐到 16 字节),就是它的BlockSeriesRef。
这解释了 BlockSeriesRef 的两个特性:不连续(索引条目长度可以是 16 字节的任意倍数,相邻条目间隔不等),不从 0 开始(偏移是绝对偏移,包含了魔数、符号表等前置内容)。
源码印证在 tsdb/index/index.go:写入 postings 偏移表时执行 offsets = append(offsets, uint32(startPos/seriesByteAlign)),即用起始偏移除以 16 字节对齐常量得到 ref;同文件 L839 处的错误信息 "series offset %d exceeds 4 bytes" 则直接揭示了位宽约束:
BlockSeriesRef目前只有 32 位,因为 64 位会拖慢 postings 列表的磁盘访问性能。副作用是 index 文件大小被限制在 2^32 × 16 = 64 GB。
这个 32 位折中是典型的“空间换性能”:postings 列表是查询热点路径,用更窄的 ref 可以让同一页内装入更多条目、减少磁盘 I/O 次数。
3. ChunkRef:如何定位压缩 chunk 数据
Chunk 引用用于查询执行阶段加载 chunk 数据。两种实现的位布局完全不同,根源在于数据存放位置的差异。
3.1 HeadChunkRef:5 字节序列号 + 3 字节块 ID
HeadChunkRef 是一个 8 字节整数,把两个字段打包在一起:
- 5 字节:
HeadSeriesRef; - 3 字节:
HeadChunkID(uint64 的低 3 字节)。
实现位于 tsdb/chunks/chunks.go,打包/解包用位运算一次完成:
func NewHeadChunkRef(hsr HeadSeriesRef, chunkID HeadChunkID) HeadChunkRef {
if hsr > (1<<40)-1 {
panic("series ID exceeds 5 bytes")
}
if chunkID > (1<<24)-1 {
panic("chunk ID exceeds 3 bytes")
}
return HeadChunkRef(uint64(hsr<<24) | uint64(chunkID))
}
func (p HeadChunkRef) Unpack() (HeadSeriesRef, HeadChunkID) {
return HeadSeriesRef(p >> 24), HeadChunkID(p<<40) >> 40
}
(注:当前版本中 HeadChunkID 的具体位段还承担了 out-of-order 标志位,源码注释说明 ID 占 HeadChunkRef 的 0~22 位、第 23 位为 OOO 标志,并按 2^23 回绕使用,见 tsdb/chunks/chunks.go 的 HeadChunkID 类型注释及 tsdb/head_read.go 的 unpackHeadChunkRef。理解这一点很重要:不要直接对 HeadChunkID 做大小比较或边界判断,应使用源码注释给出的 wrapChunkID 方式换算回活动 chunk 下标。)
这个 5/3 拆分带来两个后果(原文档明确列出):
HeadSeriesRef在写入(ingestion)路径上可以无限增长,但在查询路径上被限制为 2^40。查询时若遇到过大的编号会导致查询失败,但不影响写入——这正是 §2.1 第 2 条约束的实现来源。ChunkID随新 chunk 不断增长,直到 Prometheus 重启才归零。若长期不重启,理论上可能逼近 2^24。按原文档引用的估算:以每 15 秒 1 个样本的速度,耗尽 2^24 需要约 957 年,实践中不构成风险。此外,当ChunkID == len(mmappedChunks)时,该 ID 指向当前“打开”的 head chunk,而不是已 mmap 的 chunk。
3.2 BlockChunkRef:4 字节文件号 + 4 字节文件内偏移
BlockChunkRef 同样是 8 字节整数,但它是静态的,与 Prometheus 是否重启等运行因素完全无关。布局为:
- 高 4 字节:Block 内 chunk 段文件(segment file)的序号。文件名从 1 开始编号,而 ref 中的序号从 0 开始;
- 低 4 字节:数据在该文件内的字节偏移。
对应实现(tsdb/chunks/chunks.go):
func NewBlockChunkRef(fileIndex, fileOffset uint64) BlockChunkRef {
return BlockChunkRef(fileIndex<<32 | fileOffset)
}
func (b BlockChunkRef) Unpack() (int, int) {
sgmIndex := int(b >> 32)
chkStart := int((b << 32) >> 32)
return sgmIndex, chkStart
}
写入侧,Writer.writeChunks 在把每个 chunk(长度 varint + 编码字节 + 数据 + CRC32)落盘的同时,回填 chk.Ref = ChunkRef(NewBlockChunkRef(seq, uint64(w.n)))——即“文件序号 + 当前写指针偏移”。读取侧,Reader.ChunkOrIterable 先 Unpack 得到 sgmIndex, chkStart,定位到对应 segment 的 mmap 字节区,按 varint 读出长度,再校验 CRC32 后从对象池取 chunk 实例——整条链路没有任何“先查系列”的中间步骤,印证了 Block 中“索引与 chunk 文件分离且静态”的设计。
3.3 为什么 HeadChunkRef 携带序列引用,而 BlockChunkRef 不携带?
原文档对这一不对称性的解释值得完整保留,因为它揭示了两种存储布局的根本差异:
- Head 中,chunk 数据挂在系列结构体上。要取 chunk,必须先寻址到所属序列(
memSeries),所以HeadChunkRef必须把HeadSeriesRef打包进去,让调用方“一次解包直达序列”; - 持久化 Block 中,chunk 文件与索引文件分离且布局静态。取 chunk 只需要
chunks目录内的“二维坐标”(第几个段文件、文件内偏移),与具体哪条序列无关,因此无需携带BlockSeriesRef。
4. TSDB 内部使用的引用:HeadChunkID 与 ChunkDiskMapperRef
refs.md 最后区分了“TSDB 调用方使用的引用”与“TSDB 内部使用的引用”——后者不参与对外查询接口,但支撑着 mmap chunk 的落盘与回收:
HeadChunkID:定位memSeries内的某个 chunk(可能是mmappedChunk,也可能是内存headChunk)。关键语义是:如果调用方持有的HeadChunkID指向一个已被压缩进 Block 的旧 chunk,再用它查询memSeries不会返回任何数据。当前实现中它按序列独立编号、支持回绕,且对正序/乱序(OOO)chunk 分别维护,详见 tsdb/chunks/chunks.go 的类型注释;ChunkDiskMapperRef:8 字节整数,4 字节指向 chunk 文件号、4 字节作为字节偏移——布局与BlockChunkRef相同。mmappedChunk向外提供该值,使调用方可以直接从磁盘 mmap 区读取该 chunk,而无需回到内存结构。相关分配逻辑见 tsdb/chunks/head_chunks.go 的getNextChunkRef。
5. 小结:引用设计背后的性能权衡
把全文要点收敛为三条可验证的设计原则:
- 引用即坐标,坐标编码服务于数据布局。Head 的数据按“系列 → chunk 链”组织,所以
HeadChunkRef内嵌序列号;Block 的数据按“段文件 + 偏移”组织,所以BlockChunkRef就是纯粹的二维坐标。两种 8 字节整数各自以最少的一次解包直达数据。 - 位宽是显式的容量约束。
HeadSeriesRef查询侧 2^40、BlockSeriesRef32 位(对应 64 GB index 上限)、postings ref 的 16 字节对齐,都在 tsdb/index/index.go 与 tsdb/chunks/chunks.go 的边界检查/错误信息中得到代码级确认。 - 引用区分“对外”与“对内”两层:对外层(
SeriesRef/ChunkRef)保证跨 Head 与 Block 的统一查询语义;对内存层(HeadChunkID/ChunkDiskMapperRef)则专门处理 mmap、压缩回收等生命周期问题,且明确声明了过期引用的失效行为。
理解这套引用体系后,再阅读 tsdb/docs/format/ 下的 index.md、chunks.md 与 head_chunks.md,可以进一步对照 index 文件、chunks 段文件的字节级布局,形成从“引用 → 坐标 → 磁盘格式”的完整认知链路。
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 StartedRust0625
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