首页
/ Prometheus TSDB 引用类型深度解析:Series 与 Chunk 的 64 位引用如何定位时序数据

Prometheus TSDB 引用类型深度解析:Series 与 Chunk 的 64 位引用如何定位时序数据

2026-09-04 22:01:49作者:侯霆垣

本文围绕 Prometheus 仓库中 tsdb/docs/refs.md 的设计说明展开,系统梳理 TSDB 中两类核心引用——SeriesRef(系列引用)与 ChunkRef(块引用)——在 Head(内存)与持久化 Block(磁盘)中的不同实现形态、位宽约束与设计动机。读完本文,你将理解 Prometheus 查询执行时如何通过一个 64 位整数高效找到系列元数据与压缩 chunk 数据,以及 HeadChunkRefBlockChunkRef 在位布局上的差异从何而来。

1. 全局视角:三层引用体系的分工

TSDB 对外暴露的查询接口只依赖抽象的“引用”概念,而不同存储位置(Head 内存区、持久化 Block)各自实现了不同的引用编码。refs.md 给出的总览表如下:

位置 Series 访问 Chunk 访问
全局接口 SeriesRef(位于 postings 索引列表中) chunks.ChunkRefChunkReader 接口、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.gostripeSeries 的注释与结构说明了它的双重索引方式:

// 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.goloadWALlastRef = chunks.HeadSeriesRef(db.nextRef.Load()) 作为回放起点;
  • HeadChunkRef 编码:因为 head chunk 归 memSeries 所有,HeadChunkRef 内必须内嵌 HeadSeriesRef 才能先寻址到序列,再取 chunk。

原文档还给出两条值得展开的工程细节:

  1. mmap 化的 Head chunk 不含独立索引,它依赖内存中的系列列表;但 mmap chunk 内部保存了对应的 HeadSeriesRef,配合 WAL 中标签与样本记录,理论上可从磁盘 chunk 重建索引。其工程意义在于:启动时利用 mmap chunk 可以避免完整重放整段 WAL,从而节省 CPU 与启动时间。
  2. 查询路径上 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.goHeadChunkID 类型注释及 tsdb/head_read.gounpackHeadChunkRef。理解这一点很重要:不要直接对 HeadChunkID 做大小比较或边界判断,应使用源码注释给出的 wrapChunkID 方式换算回活动 chunk 下标。)

这个 5/3 拆分带来两个后果(原文档明确列出):

  1. HeadSeriesRef 在写入(ingestion)路径上可以无限增长,但在查询路径上被限制为 2^40。查询时若遇到过大的编号会导致查询失败,但不影响写入——这正是 §2.1 第 2 条约束的实现来源。
  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.ChunkOrIterableUnpack 得到 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.gogetNextChunkRef

5. 小结:引用设计背后的性能权衡

把全文要点收敛为三条可验证的设计原则:

  1. 引用即坐标,坐标编码服务于数据布局。Head 的数据按“系列 → chunk 链”组织,所以 HeadChunkRef 内嵌序列号;Block 的数据按“段文件 + 偏移”组织,所以 BlockChunkRef 就是纯粹的二维坐标。两种 8 字节整数各自以最少的一次解包直达数据。
  2. 位宽是显式的容量约束HeadSeriesRef 查询侧 2^40、BlockSeriesRef 32 位(对应 64 GB index 上限)、postings ref 的 16 字节对齐,都在 tsdb/index/index.gotsdb/chunks/chunks.go 的边界检查/错误信息中得到代码级确认。
  3. 引用区分“对外”与“对内”两层:对外层(SeriesRef/ChunkRef)保证跨 Head 与 Block 的统一查询语义;对内存层(HeadChunkID/ChunkDiskMapperRef)则专门处理 mmap、压缩回收等生命周期问题,且明确声明了过期引用的失效行为。

理解这套引用体系后,再阅读 tsdb/docs/format/ 下的 index.mdchunks.mdhead_chunks.md,可以进一步对照 index 文件、chunks 段文件的字节级布局,形成从“引用 → 坐标 → 磁盘格式”的完整认知链路。

登录后查看全文
热门项目推荐
相关项目推荐