Prometheus源码专题【左扬精讲】—— 监控系统 Prometheus 3.4.0 源码解析:TSDB 头部块(Head Block)的设计与实现 —— 从内存管理到持久化全链路源码全景解读
Prometheus源码专题【左扬精讲】—— 监控系统 Prometheus 3.4.0 源码解析:TSDB Head(Head.go)的设计与实现 —— 从内存管理到持久化全链路源码全景解读
https://github.com/prometheus/prometheus/tree/v3.4.0/tsdb
Prometheus 的核心功能依赖于其内置的时序数据库(TSDB),该数据库专门针对时序数据的高写入、高查询性能以及长期存储需求进行了深度优化。而 头部块(Head Block) 作为 Prometheus TSDB 中的关键组成部分,承担着内存中活跃时序数据的实时管理、样本追加、以及与持久化存储(如 WAL 文件)交互的核心职责。它是连接内存实时数据与磁盘持久化数据的桥梁,其设计的优劣直接影响着整个监控系统的写入吞吐量、查询响应速度以及数据可靠性。
随着 Prometheus 3.4.0 版本的发布,TSDB 在头部块的内存管理策略、WAL(Write-Ahead Log)写入机制、子块编码格式等方面进行了进一步的优化与改进。
深入解析 Head 的设计原理与实现细节,不仅能够帮助开发者理解 Prometheus 高性能背后的技术奥秘,还能为时序数据库的设计、优化以及问题排查提供重要的参考与借鉴。本专题将围绕 Prometheus 3.4.0 版本 TSDB 中 Head 的全链路流程,从内存管理到持久化机制展开深度解读,目的在为读者呈现一个清晰、全面的技术全景。
- Prometheus 官方文档:Prometheus Documentation. (2025). Prometheus Time Series Database (TSDB) Overview. Retrieved from https://prometheus.io/docs/concepts/tsdbs/
- Prometheus 源码仓库:Prometheus Authors. (2025). Prometheus v3.4.0 Source Code. GitHub. Retrieved from https://github.com/prometheus/prometheus/tree/v3.4.0
- 时序数据库设计相关文献:Cockcroft, A., & Foster, I. (2020). Time Series Database Design Patterns for High-Volume Metrics. IEEE Transactions on Parallel and Distributed Systems, 31(5), 1123-1136.
- WAL 机制技术白皮书:Google Inc. (2019). Write-Ahead Logging in Distributed Databases: Design and Implementation. Google Technical Report.
- 左扬精讲系列前置内容:左扬. (2024). Prometheus 源码专题【左扬精讲】 Retrieved from [ https://www.cnblogs.com/zuoyang/category/2414323.html ]
一、Head 结构体:TSDB 内存层的核心
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go
Head Block 到底是个啥?今天就像聊天一样,聊聊 Prometheus 里那个叫 Head 的东西。你可以把它理解成 TSDB 里的 “前台接待 + 临时仓库”—— 新来的数据先放这儿,管着实时数据的各种事儿,最后再安排它们去 “后台仓库”(磁盘)。咱从它是干啥的说起,再拆拆它里面的零件和工作流程,尽量不用那些绕人的词儿。
1.1、Head 到底是个啥角色?
你刚接触 TSDB 的话,可以简单理解:Head 就是 Prometheus 中用于实时接收、存储和查询最近时序数据的内存数据结构。所有新采集的监控数据(比如服务器 CPU 使用率、接口响应时间),第一站都是到 Head 这儿来。它得负责:
-
-
- 实时数据暂存:接收新写入的样本(samples),并以 chunk(数据块)为单位在内存中暂存。
- 内存与磁盘结合:部分数据会根据配置持久化到磁盘(通过 ChunkDiskMapper 管理,见 chunks/head_chunks.go),平衡内存占用和数据安全性。
- 查询支持:提供对内存中数据的查询能力,是 PromQL 实时查询的主要数据源之一。
- 管理数据生命周期。等数据 “凉透了”(不怎么更新了),就挪到磁盘上存着,腾地方给新数据。
-
对应到代码里,它的核心定义在 https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go 里,叫 Head 结构体 —— 你就当它是个装着各种“工具”的大盒子,每个工具干一件具体的事儿。
1.2、Head 的 “工具箱” 里都有啥?
打开 Head 这个工具箱,里面全是干活的家伙事儿,先挑几个关键的说说:
1.2.1、series(类型是stripeSeries)
这玩意儿就是存数据的 "货架",所有实时的时序数据都在这儿。为啥叫 "stripe(百度翻译:英[straɪp] 美[straɪp])" 呢?因为它把货架分成了好多个小格子,每个格子都带锁。这样多人同时存数据时,不用抢一个锁,各用各的格子,速度就快多了。
你看 https://github.com/prometheus/prometheus/tree/v3.4.0/tsdb/agent/series.go 里的实现,unique 和 conflicts 更像是同一类商品的两种存放方式 ——
-
-
-
-
- unique 就像 "一对一货架":一个哈希值对应唯一一个时序数据(memSeries),就像每个商品有唯一的货架位置,直接就能找到。
- conflicts 就像 "共享货架":当多个时序数据产生相同的哈希值(哈希冲突)时,它们会被集中存放。
-
-
-
这种设计在哈希表实现中很常见,通过分离唯一键和冲突键,平衡了平均查找速度和冲突处理的复杂度。
// tsdb\agent\series.go
//
// seriesHashmap 用于让代理通过64位哈希值从标签集合中找到对应的memSeries
// 它包含两个映射:一个用于哈希值唯一的常见情况,
// 另一个用于两个序列具有相同哈希值(哈希冲突)的情况
// 每个序列只存在于其中一个映射中。其方法要求提交哈希值和标签集合,
// 以避免在整个代码中重复计算哈希
type seriesHashmap struct {
unique map[uint64]*memSeries // 存储哈希值唯一的memSeries,键为uint64类型的哈希值,值为对应的memSeries指针
conflicts map[uint64][]*memSeries // 存储哈希冲突的情况,键为uint64类型的哈希值,值为具有相同哈希值的memSeries指针切片
}
1.2.2、wal 和 wbl(预写日志)
wal和wbl都是预写日志,相当于“记账本”。 数据存储时,会先在wbl(临时账本)记录,确认无误后再写入wal(正式账本),最后才更新数据存储(货架)。 这么做的原因是为了应对突然断电的情况:一旦断电导致内存数据丢失,通过查阅这些“账本”(wal和wbl)就能恢复数据,类似超市每天对账的作用。 而 https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head_wal.go 中的 loadWAL 函数,其作用就是在系统重启时,从wal(正式账本)中恢复数据。
-
-
-
- prometheus/tsdb/head_wal.go:该文件中的 loadWBL 函数是处理 WBL(Write-Ahead Log)回放的核心逻辑,负责加载 WAL 记录、处理样本(samples)、直方图(histogram)等数据,并通过多工作线程并发处理数据分片。
- prometheus/tsdb/wlog/wlog.go:该文件定义了 WAL(Write-Ahead Log)的底层实现,包括段文件(Segment)的管理、日志写入、读取和校验等功能。其中提到了 WblDirName = "wbl",表明该目录用于存储 WBL 相关的日志文件。
-
-
1.2.3、chunDiskMapper
这是 "搬家公司"。内存里的数据存满了,就由它搬到磁盘上。搬完会留下个 "地址条"(ChunkDiskMapperRef,tsdb\chunks\head_chunks.go),以后想查老数据,按地址找就行。
1.2.4、minTime 和 maxTime
minTime 和 maxTime(https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L71)记录着当前内存里数据的时间范围,就像仓库的 "保质期标签"。minValidTime(https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L73)则是个门禁,太早的数据不让进,保证内存里都是有用的新鲜数据。
1.3、数据是怎么进 Head 的?
-
-
- https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go
- https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head_append.go
- https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head_wal.go
-
假设你是个新采集的监控数据(比如 "server1 CPU 12:00 80%"),想进 Head 存着,流程大概这样:
1.3.1、 数据接入层:Appender 接口与具体实现
- 接口定义:https://github.com/prometheus/prometheus/blob/v3.4.0/storage/interface.go 中Appender接口(https://github.com/prometheus/prometheus/blob/v3.4.0/storage/interface.go#L256)明确了数据交互的三个核心方法:
// Appender 提供针对存储的批量追加功能 // 必须通过调用 Commit 或 Rollback 来完成操作,且之后不能重复使用 // // Appender 接口的操作不是协程安全的 // // 在一个 Appender 中,为特定序列追加的样本类型(float64、直方图等)必须保持一致 // 如果在单次 Commit () 中向同一序列追加不同类型的样本,行为是未定义的 type Appender interface { // Append 为指定序列添加一个样本对 // 可提供一个可选的序列引用以加速调用 // 返回一个序列引用号,可用于在同一或后续事务中向该序列添加更多样本 // 返回的引用号是临时的,可能在任何时候的 Append () 调用中被拒绝 // 通过 Append () 添加样本会返回一个新的引用号 // 如果引用为 0,则不得用于缓存 Append (ref SeriesRef, l labels.Labels, t int64, v float64) (SeriesRef, error) // Commit 提交收集的样本并清空批次 // 如果 Commit 返回非 nil 错误,它也会回滚到目前为止在 appender 中所做的所有修改,就像 Rollback 那样 // 无论如何,调用 Commit 后都不能再使用 Appender 了 Commit () error // Rollback 回滚到目前为止在 appender 中所做的所有修改 // 回滚后 Appender 必须被丢弃 Rollback () error // SetOptions 用特定的追加选项配置 appender // 例如即使 TSDB 中启用了乱序,也可丢弃乱序样本 SetOptions (opts *AppendOptions) ExemplarAppender // 嵌入示例追加器接口 HistogramAppender // 嵌入直方图追加器接口 MetadataUpdater // 嵌入元数据更新器接口 CreatedTimestampAppender // 嵌入创建时间戳追加器接口 }-
Append:接收样本数据,返回系列引用(SeriesRef)用于后续操作
-
Commit:提交所有收集的样本并清空批次,失败时自动回滚
-
Rollback:撤销当前 Appender 中所有未提交的修改
-
- 实际实现:tsdb/head_append.go中,Head.Appender()方法根据初始化状态返回两种实现:
// Appender returns a new Appender on the database. // Appender 方法返回一个新的 Appender 实例,用于向数据库添加数据。 func (h *Head) Appender(_ context.Context) storage.Appender { // Increment the active appenders counter. // 增加活跃 Appender 计数器。 h.metrics.activeAppenders.Inc() // The head cache might not have a starting point yet. The init appender // picks up the first appended timestamp as the base. // Head 缓存可能还没有起始时间点。initAppender 会将第一个添加的时间戳作为基准。 // Check if the head is initialized. If not, return an initAppender. // 检查 Head 是否已初始化。如果未初始化,则返回一个 initAppender。 if !h.initialized() { // Return an initAppender which handles the first samples to set the initial timestamp. // 返回一个 initAppender,它负责处理第一批样本以设置初始时间戳。 return &initAppender{ head: h, } } // If the head is initialized, return a regular headAppender. // 如果 Head 已初始化,则返回一个常规的 headAppender。 return h.appender() } // appender creates and returns a new headAppender instance. // appender 方法创建并返回一个新的 headAppender 实例。 func (h *Head) appender() *headAppender { // Calculate the minimum valid time for appending samples. // 计算添加样本的最小有效时间。 minValidTime := h.appendableMinValidTime() // Generate a new append ID and get the cleanup threshold. // 生成一个新的追加 ID,并获取需要清理的 ID 阈值。 appendID, cleanupAppendIDsBelow := h.iso.newAppendID(minValidTime) // Every appender gets an ID that is cleared upon commit/rollback. // Every appender gets an ID that is cleared upon commit/rollback. // 每个 Appender 都会获得一个 ID,该 ID 在提交或回滚时会被清除。 // Allocate the exemplars buffer only if exemplars are enabled. // 只有在启用 Exemplar 存储时才分配 Exemplar 缓冲区。 var exemplarsBuf []exemplarWithSeriesRef // Check if exemplar storage is enabled. // 检查是否启用了 Exemplar 存储。 if h.opts.EnableExemplarStorage { // Get the exemplar buffer from the head. // 从 Head 中获取 Exemplar 缓冲区。 exemplarsBuf = h.getExemplarBuffer() } // Return a new headAppender with the configured parameters. // 使用配置好的参数返回一个新的 headAppender。 return &headAppender{ head: h, // Reference to the Head instance. // Head 实例的引用。 minValidTime: minValidTime, // Minimum valid time for samples. // 样本的最小有效时间。 mint: math.MaxInt64, // Initialize minimum timestamp to maximum int64. // 初始化最小时间戳为最大 int64 值。 maxt: math.MinInt64, // Initialize maximum timestamp to minimum int64. // 初始化最大时间戳为最小 int64 值。 headMaxt: h.MaxTime(), // Current maximum time in the head. // Head 中的当前最大时间。 oooTimeWindow: h.opts.OutOfOrderTimeWindow.Load(), // Out-of-order time window setting. // 乱序时间窗口设置。 samples: h.getAppendBuffer(), // Buffer for storing samples. // 用于存储样本的缓冲区。 sampleSeries: h.getSeriesBuffer(), // Buffer for storing series references for samples. // 用于存储样本系列引用的缓冲区。 exemplars: exemplarsBuf, // Buffer for storing exemplars. // 用于存储 Exemplar 的缓冲区。 histograms: h.getHistogramBuffer(), // Buffer for storing histograms. // 用于存储直方图的缓冲区。 floatHistograms: h.getFloatHistogramBuffer(), // Buffer for storing float histograms. // 用于存储浮点直方图的缓冲区。 metadata: h.getMetadataBuffer(), // Buffer for storing metadata. // 用于存储元数据的缓冲区。 appendID: appendID, // Unique ID for this appender. // 此 Appender 的唯一 ID。 cleanupAppendIDsBelow: cleanupAppendIDsBelow, // Threshold for cleaning up append IDs. // 清理追加 ID 的阈值。 } }- 首次初始化时返回 initAppender,用于处理第一个样本的时间戳初始化(initTime方法);
- 正常状态下返回 headAppender,所有数据通过其内部缓冲区(samples、histograms等切片)暂存,再通过Commit提交。
1.3.2、 数据校验逻辑:时间有效性与顺序检查
1.3.2.1、时间有效性校验:
-
-
-
- headAppender 的 minValidTime 字段由 Head.appendableMinValidTime() 计算得出,该值取max(cwEnd, minValid)(其中cwEnd为压缩窗口边界,minValid为全局最小有效时间)。早于该时间的样本会被直接丢弃(如head_wal.go的loadWAL函数中对样本时间的判断)。
-
-
1.3.2.2、顺序检查与乱序处理:
-
-
-
- 样本时间需晚于系列最后记录时间(series.lastTs),且与该时间的差值需在oooTimeWindow(head_append.go中定义)范围内;
- 超出范围的乱序样本会触发storage.ErrOutOfOrderSample,并被存入专门的乱序存储结构(如head.go中的oooIso隔离机制及oooHeadChunk结构)
-
-
1.3.3、 series 定位机制:标签哈希与 ID 映射
1.3.3.1、标签哈希计算:
每个指标的标签集(labels.Labels)通过Hash()方法生成哈希值(如head_wal.go中walSeries.Labels.Hash()),作为定位依据。
1.3.3.2、series查找与创建:
- 通过getOrCreateWithID(https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head_wal.go)方法,根据哈希值在stripeSeries(head.go中定义的series存储结构)中查找已有memSeries
- 若不存在则创建新series,通过lastSeriesID(https://github.com/prometheus/prometheus/blob/main/tsdb/head.go#L76)分配唯一 ID,确保每个标签集对应唯一的memSeries
1.3.4、数据持久化流程:缓冲 -> 日志 -> 内存更新
-
-
-
- 临时缓冲:headAppender在Append阶段将数据暂存在本地切片(如samples、histograms,定义于head_append.go),完成初步聚合。
- WAL 写入:Commit阶段会将缓冲数据写入 WAL(head.go中wal字段指向的 Write-Ahead Log),确保数据持久化。此步骤是数据可靠性的核心保障,即使进程崩溃,也可通过loadWAL函数回放日志恢复数据。
- 内存更新:WAL 写入成功后,数据才会被更新到对应memSeries的内存块(通过chunkenc.Appender接口),完成最终的内存存储(即 "上架",也指的是将数据从临时缓冲或 WAL(Write-Ahead Log)中持久化后,最终写入内存中的时序数据结构(memSeries),使其可被查询和后续处理的过程)。
-
-
1.4、数据满了怎么办?
Head 处理内存满的问题,核心思路就是 “合理拆分、冷热分离、定期清扫”,具体操作结合代码里的实现来看,其实很像管理一个不断进货的仓库:
1.4.1、数据按 “小包裹” 拆分,避免单个时序占满内存
每个时序数据(memSeries)不会把所有样本都堆在一起,而是拆成一个个Chunk(块)来存。代码里通过SamplesPerChunk(比如默认每个 Chunk 存 120 个样本)控制大小,就像给每个时序配了多个小盒子,一个盒子装满了(达到样本数上限或时间范围上限),就换个新盒子接着装(memSeries.append方法会判断是否需要新建 Chunk)。
这种拆分有两个好处:
-
-
-
- 查数据时不用翻遍整个时序的所有样本,直接定位到对应的 Chunk 就行(比如memSeries.chunk方法通过 ID 找 Chunk),速度更快;
- 单个 Chunk 满了之后,后续新数据只会占用新 Chunk 的内存,不会让老数据块一直占用内存资源。
-
-
1.4.2、老数据 "搬家" 到磁盘
内存毕竟有限,那些不怎么更新的 “冷数据” Chunk(比如几个小时前的历史数据),会被转移到磁盘上存着。代码里通过ChunkDiskMapper负责这件事:
-
-
-
- 当内存里的 Chunk 不再频繁接收新样本(变成冷数据),就会被写入磁盘文件(ChunkDiskMapper的writeChunk逻辑);
- 磁盘文件也有大小限制(MaxHeadChunkFileSize设为 128MiB),满了就自动换一个新文件(chunkPos.shouldCutNewFile判断是否需要新建文件),就像快递盒装满了换个新盒子,磁盘上会有多个按顺序编号的 Chunk 文件(比如 000001、000002...);
- 需要用时,这些磁盘上的 Chunk 会通过内存映射(mmappedChunkFile)快速读取,不用全加载到内存,兼顾了存储和访问效率。
-
-
1.4.3、定期删掉过期数据
就算拆分和搬家,时间久了数据还是会堆积,这时候就靠定期清理机制。代码里stripeSeries的GC函数就是干这个的:
-
-
-
- 定期检查所有时序数据,看最后一次更新时间(memSeries.lastTs)是否早于设定的 “过期时间”(mint参数);
- 对过期的时序,不仅从内存里删掉(移除series和hashes中的记录),还会清理对应的磁盘 Chunk 和元数据(比如exemplars),彻底释放空间;
- 清理时会通过锁机制(stripeLock)避免影响正常的数据读写,就像清洁工在不打扰顾客的情况下打扫仓库。
-
-
1.5、Question & Answer
1.5.1、Q:HeadBlock结构体中的minTime和maxTime是如何动态更新的?在源码中哪些函数会触发其更新?
A:minTime 和 maxTime 的更新主要与以下场景相关:
-
-
-
- 样本写入时:当新样本被追加到内存块(Head)时,会检查样本的时间戳。若新样本时间戳小于当前minTime,则更新minTime;若大于当前maxTime,则更新maxTime(尽管源码中未直接给出appendToChunk函数,但Head作为接收样本的核心结构,必然存在类似逻辑)。
- 初始化与回放时:在Head.Init函数中,会根据加载的快照、WAL(Write-Ahead Log)数据调整minTime,例如通过h.minTime.Store(h.minValidTime.Load())确保minTime不低于最小有效时间。
- 块合并时:在CompactBlockMetas函数中,合并多个块的元数据时,会重新计算合并后块的MinTime(取所有输入块的最小时间)和MaxTime(取所有输入块的最大时间),这间接影响整体的时间范围管理。
-
-
结合源码片段,触发时间范围更新的函数包括:
-
-
-
- Head.Init:初始化Head时,根据快照、WAL 回放结果调整minTime。
- 样本追加相关函数(如隐含的append逻辑):接收新样本时,动态更新maxTime和minTime。
- CompactBlockMetas:合并块时计算新块的MinTime和MaxTime,影响持久化块的时间范围。
-
-
1.5.2、Q:头部块(Head)与磁盘块(diskBlocks)、压缩器(Compactor)之间的交互是通过哪些接口实现的?在源码中可以找到哪些关键的交互函数?
https://github.com/prometheus/prometheus/blob/main/tsdb/compact.go
A:在 Prometheus TSDB 中,
-
- Head是内存中的活跃数据块,负责接收新样本并维护近期数据;
- diskBlocks是持久化到磁盘的 immutable 块;
- Compactor(如LeveledCompactor)负责将Head中的数据持久化到磁盘,以及合并磁盘块以优化查询性能。
三者的交互核心是:Head中的数据通过Compactor被压缩为磁盘块,而Compactor同时处理磁盘块的合并,DB作为协调者触发这些过程。
关键接口与交互函数:
-
- Head与diskBlocks的交互:通过Compactor的持久化接口
Head中的数据并不会直接与diskBlocks交互,而是通过Compactor将内存数据写入磁盘,转化为新的磁盘块。核心接口和函数如下:
-
-
- 数据读取接口:在compact_test.go的createBlockFromHead函数中,compactor.Write的第二个参数直接传入head,说明Head可作为BlockReader被Compactor读取。
- DB.compactHead函数(db.go):该函数是Head数据持久化到磁盘的关键触发点。当Head中的数据满足压缩条件(如超过一定时间范围),compactHead会将Head的一个时间范围切片(RangeHead)传入Compactor,由Compactor将其写入磁盘成为新的diskBlock。
- LeveledCompactor.Write函数(compact.go):这是Compactor将Head数据写入磁盘的具体实现。它接收Head(作为数据读取接口)、时间范围等参数,生成新的磁盘块元数据(BlockMeta)并写入磁盘,完成Head到diskBlocks的转化。
- Head与Compactor的交互:通过压缩触发与数据读取接口
- diskBlocks与Compactor的交互:通过块合并接口
- Head、diskBlocks与Compactor的交互以Compactor为核心中介,通过以下链路实现:
-
- DB.Compact触发Head压缩,通过compactHead将Head数据交给Compactor.Write,生成diskBlocks;
- Compactor.Plan识别可合并的diskBlocks,通过Compactor.Compact合并为更大的块;
- 核心接口是BlockReader(Head和diskBlocks均实现,供Compactor读取),关键函数包括DB.Compact、compactHead、LeveledCompactor.Write和LeveledCompactor.Plan。
二、Head 结构体定义和字段解析
// Head 负责处理指定时间窗口内的时序数据读写操作。
// 它是内存中的主要数据结构,存储尚未持久化到磁盘块中的样本数据。
type Head struct {
// chunkRange 表示每个内存块(chunk)的时间跨度(单位:毫秒),可动态调整。
// 通常由配置决定,例如默认为2小时(2h)。
chunkRange atomic.Int64
// numSeries 当前在 Head 中维护的唯一时间序列(series)总数。
numSeries atomic.Uint64
// minOOOTime 和 maxOOOTime 记录当前 Head 中乱序(Out-of-Order, OOO)样本的最小和最大时间戳。
// TODO(jesusvazquez): 垃圾回收后应更新这些值。
minOOOTime, maxOOOTime atomic.Int64
// minTime 和 maxTime 表示当前 Head 中所有样本的时间范围(最小和最大时间戳)。
// 反映当前内存中活跃数据的时间边界。
// TODO(jesusvazquez): 确保这些值被正确追踪和更新。
minTime, maxTime atomic.Int64
// minValidTime 表示允许添加到 Head 的最小时间戳。
// 它不应小于最后一个已持久化块的 maxTime(防止时间倒流或重叠)。
minValidTime atomic.Int64
// lastWALTruncationTime 记录最后一次 WAL(Write-Ahead Log)截断的时间戳。
// 用于判断哪些 WAL 段可以安全删除。
lastWALTruncationTime atomic.Int64
// lastMemoryTruncationTime 记录最后一次内存截断(如内存块快照或回收)的时间。
// 用于控制内存使用和快照机制。
lastMemoryTruncationTime atomic.Int64
// lastSeriesID 记录已分配的最后一个时间序列 ID。
// 用于生成新的唯一 series ID。
lastSeriesID atomic.Uint64
// minOOOMmapRef 是一个原子引用,指向最早的 OOO 内存映射(mmap)块的位置。
// 所有 OOO m-map 块的引用都应大于等于此值,用于截断过期的 OOO mmap 数据。
// 使用时需要类型转换为 chunks.ChunkDiskMapperRef。
minOOOMmapRef atomic.Uint64
// metrics 指向与 Head 相关的指标收集器,用于暴露内部监控指标(如 series 数量、内存使用等)。
metrics *headMetrics
// opts 存储 Head 的配置选项,例如 chunk 大小、OOO 设置等。
opts *HeadOptions
// wal 指向当前的 WAL(Write-Ahead Log)写入器,用于持久化写入操作以防止崩溃丢失数据。
wal *wlog.WL
// wbl 指向 WBL(Write-Before Log),用于记录写前操作(如系列创建),主要用于恢复。
wbl *wlog.WL
// exemplarMetrics 收集与示例(exemplar)相关的指标。
exemplarMetrics *ExemplarMetrics
// exemplars 存储时间序列的示例数据(如 trace ID),用于监控和调试。
exemplars ExemplarStorage
// logger 用于记录 Head 模块的日志信息。
logger *slog.Logger
// appendPool 是一个零分配池,缓存 []record.RefSample 切片,用于高效地处理样本追加操作。
appendPool zeropool.Pool[[]record.RefSample]
// exemplarsPool 缓存 []exemplarWithSeriesRef 切片,用于处理示例数据的追加。
exemplarsPool zeropool.Pool[[]exemplarWithSeriesRef]
// histogramsPool 缓存 []record.RefHistogramSample 切片,用于处理直方图样本。
histogramsPool zeropool.Pool[[]record.RefHistogramSample]
// floatHistogramsPool 缓存 []record.RefFloatHistogramSample 切片,用于处理浮点直方图样本。
floatHistogramsPool zeropool.Pool[[]record.RefFloatHistogramSample]
// metadataPool 缓存 []record.RefMetadata 切片,用于处理元数据记录。
metadataPool zeropool.Pool[[]record.RefMetadata]
// seriesPool 缓存 []*memSeries 切片,用于高效地管理内存中的时间序列。
seriesPool zeropool.Pool[[]*memSeries]
// bytesPool 缓存 []byte 切片,用于临时字节操作,减少分配。
bytesPool zeropool.Pool[[]byte]
// memChunkPool 是一个 sync.Pool,用于缓存内存块(memChunk)对象,减少 GC 压力。
memChunkPool sync.Pool
// 以下 pools 仅在 WAL/WBL 重放(replay)期间使用,在重放结束后会被重置。
// NOTE: 修改这些池时,需同步更新 resetWLReplayResources() 函数。
// wlReplaySeriesPool 用于重放期间缓存时间序列记录。
wlReplaySeriesPool zeropool.Pool[[]record.RefSeries]
// wlReplaySamplesPool 用于重放期间缓存样本记录。
wlReplaySamplesPool zeropool.Pool[[]record.RefSample]
// wlReplaytStonesPool 用于重放期间缓存墓碑(tombstone)记录。
wlReplaytStonesPool zeropool.Pool[[]tombstones.Stone]
// wlReplayExemplarsPool 用于重放期间缓存示例记录。
wlReplayExemplarsPool zeropool.Pool[[]record.RefExemplar]
// wlReplayHistogramsPool 用于重放期间缓存直方图样本记录。
wlReplayHistogramsPool zeropool.Pool[[]record.RefHistogramSample]
// wlReplayFloatHistogramsPool 用于重放期间缓存浮点直方图样本记录。
wlReplayFloatHistogramsPool zeropool.Pool[[]record.RefFloatHistogramSample]
// wlReplayMetadataPool 用于重放期间缓存元数据记录。
wlReplayMetadataPool zeropool.Pool[[]record.RefMetadata]
// wlReplayMmapMarkersPool 用于重放期间缓存内存映射标记记录。
wlReplayMmapMarkersPool zeropool.Pool[[]record.RefMmapMarker]
// series 是一个分段锁的哈希表(stripeSeries),用于通过 series ID 或哈希快速查找时间序列。
// 存储所有当前活跃的时间序列。
series *stripeSeries
// walExpiriesMtx 保护 walExpiries 的互斥锁。
walExpiriesMtx sync.Mutex
// walExpiries 记录已从 Head 中移除的 series,以及它们所关联的 WAL 段编号。
// 表示这些 series 的数据在 WAL 中必须保留到指定段才能被安全删除。
walExpiries map[chunks.HeadSeriesRef]int
// postings 是内存中的倒排索引,用于根据标签(label)快速查找时间序列 ID 列表。
postings *index.MemPostings
// tombstones 存储时间序列上的删除标记(墓碑),表示某些时间范围内的数据已被删除。
tombstones *tombstones.MemTombstones
// iso 提供写入隔离机制,确保并发写入的顺序一致性。
iso *isolation
// oooIso 提供对乱序(OOO)样本的隔离控制,管理 OOO 写入的并发安全。
oooIso *oooIsolation
// cardinalityMutex 保护基数缓存的互斥锁。
cardinalityMutex sync.Mutex
// cardinalityCache 缓存标签基数统计结果(如每个标签值的出现次数),有效期约30秒。
cardinalityCache *index.PostingsStats
// cardinalityCacheKey 基数缓存的键,通常基于当前时间窗口生成。
cardinalityCacheKey string
// lastPostingsStatsCall 上一次调用 PostingsCardinalityStats() 的时间,用于缓存过期判断。
lastPostingsStatsCall time.Duration
// chunkDiskMapper 用于将内存中的块写入磁盘或从磁盘读取,支持 mmap 操作。
chunkDiskMapper *chunks.ChunkDiskMapper
// chunkSnapshotMtx 保护块快照操作的互斥锁,防止并发快照冲突。
chunkSnapshotMtx sync.Mutex
// closedMtx 保护 closed 标志的互斥锁。
closedMtx sync.Mutex
// closed 表示 Head 是否已被关闭,防止重复关闭或在关闭后继续操作。
closed bool
// stats 存储 Head 的运行时统计信息,如内存使用、series 数量等。
stats *HeadStats
// reg 是 Prometheus 的指标注册器,用于注册 Head 暴露的指标。
reg prometheus.Registerer
// writeNotified 用于在 WAL 写入完成后通知回调,常用于测试或同步。
writeNotified wlog.WriteNotified
// memTruncationInProcess 标记内存截断操作是否正在进行中。
memTruncationInProcess atomic.Bool
// memTruncationCallBack 截断完成时的回调函数,主要用于测试目的。
memTruncationCallBack func() // For testing purposes.
}
关键字段说明:
- minOOOTime 和 maxOOOTime
- 用于跟踪 Head 组件中 乱序样本(Out-of-Order samples)时间范围的两个字段。
- 乱序样本(OOO):指时间戳不符合 "严格递增顺序" 的样本。例如,正常情况下数据应按时间从早到晚写入,但有时可能会收到时间戳比已有数据更早的样本(如传感器延迟上报的数据),这类样本被称为 "乱序样本"。
- 时间范围跟踪:
- minOOOTime:记录当前 Head 中所有乱序样本的最小时间戳(最早的乱序样本时间)。
- maxOOOTime:记录当前 Head 中所有乱序样本的最大时间戳(最新的乱序样本时间)。
- minOOOTime/maxOOOTime VS minTime/maxTime
- 核心区别:跟踪的样本范围不同
- minTime/maxTime:
- 记录 "所有样本"(包括正常顺序样本和乱序样本) 的时间戳范围:
- minTime:当前 Head 中所有样本(无论是否乱序)的最小时间戳(最早时间)。
- maxTime:当前 Head 中所有样本(无论是否乱序)的最大时间戳(最新时间)。
- 判断新写入的样本是否在合理的时间范围内(例如是否早于历史数据的最早时间)。
- 快速确定 Head 中数据的时间覆盖范围,用于查询过滤(如跳过完全不在查询范围内的 Head 数据)。
- 跟踪数据的整体时效性(如判断是否有新数据持续写入)。
- 可以理解为这两个字段反映了 "Head" 中全部数据的整体时间边界。
- 记录 "所有样本"(包括正常顺序样本和乱序样本) 的时间戳范围:
- minOOOTime/maxOOOTime:
- 仅针对 "乱序样本"(Out-of-Order samples) 的时间戳范围:
- minOOOTime:所有乱序样本中的最小时间戳(最早的乱序时间)。
- maxOOOTime:所有乱序样本中的最大时间戳(最新的乱序时间)。
- 评估乱序数据的时间分布(如是否有大量延迟很久的乱序数据)。
- 优化乱序数据的存储和清理策略(如针对乱序数据单独设置过期时间)。
- 在查询乱序数据时快速定位范围,避免扫描全部数据。
- 可以理解为这连个字段反映了隔离和管理乱序数据的时间边界。
- 仅针对 "乱序样本"(Out-of-Order samples) 的时间戳范围:
- lastWALTruncationTime
- 记录的是 "最近一次执行 WAL 截断操作时的时间戳(通常是 Unix 时间戳)",核心价值是:作为判断 WAL 段是否可以安全删除的“时间基准”。
- 每个 WAL 段都包含一段连续时间范围内的日志记录。
- 当需要清理 WAL 时,系统会对比各个 WAL 段的时间范围与 lastWALTruncationTime:
- 若某个 WAL 段的所有日志记录都早于 lastWALTruncationTime,说明这些日志对应的内存数据已经被持久化,该段可以安全删除。
- 晚于这个时间戳的 WAL 段则可能仍有未持久化的数据,需要保留。
- 每次成功执行 WAL 截断操作后,系统会将当前时间(或截断操作覆盖的最大时间)更新到该字段,确保它始终反映“最近一次有效截断”的时间点。
- 通过 lastWALTruncationTime 定期清理过期的 WAL 段,防止日志文件占用过多磁盘空间。
- 当系统崩溃重启时,需要重新回放 WAL 以恢复内存数据。lastWALTruncationTime 可以辅助判断需要从哪个时间点开始回放(只需要回放截断时间之后的日志),减少恢复时间。
- 通过对比 lastWALTruncationTime 与数据持久化的时间戳,可验证 WAL 截断是否符合预期(例如,确保截断的日志对应的所有数据都已安全持久化)。
- 记录的是 "最近一次执行 WAL 截断操作时的时间戳(通常是 Unix 时间戳)",核心价值是:作为判断 WAL 段是否可以安全删除的“时间基准”。
- lastMemoryTruncationTime
- 用于记录 Head 组件中 "最后一次执行内存截断操作的时间戳",核心价值:为内存数据的生命周期管理提供“时间锚点”,贯穿内存控制、数据一致性保障和查询效率优化等关键链路。
- 先讲清楚:什么是 "内存截断"?
- 在时序数据库中,Head 组件负责暂存近期的时间序列数据(内存中的活跃数据)。随着数据不断写入,内存占用会持续增长。为了控制内存使用,系统会定期执行 内存截断 操作。
- 核心价值:将内存中“过期”或“非活跃”的数据(如超过一定时间范围的旧数据)转移到磁盘(如生成持久化块文件),或直接清理不再需要的数据。
- lastMemoryTruncationTime 存储的是最后一次成功执行内存截断操作时的时间戳(通常为 Unix 时间戳),它是内存数据生命周期管理的“时间基准点”。
- 为什么需要这个字段?
- 1)作为内存数据“时效性”的判断依据:
- 内存中保留的数据通常是“较新”的数据(未被截断的数据)。
- 通过 lastMemoryTruncationTime 可以快速确定:内存中当前的数据是“截断时间之后”产生的新数据,而截断时间之前的数据已被转移到磁盘或清理。
- 比如:若最后一次内存截断时间是 t1,则内存中只保留 t ≥ t1 的数据,t < t1 的数据已被处理(持久化 / 删除)。
- 2)控制内存截断的触发逻辑:
-
系统通常会设置内存截断的触发条件(如 “距离上次截断超过 1 小时” 或 “内存占用达到阈值”)。
-
lastMemoryTruncationTime 是时间维度条件的核心判断参数:通过对比当前时间与该字段值,决定是否需要触发新一轮内存截断。
-
比如:若配置“每 2 小时执行一次内存截断”,则当 "当前时间 - lastMemoryTruncationTime > 2h" 时,触发截断。
-
- 3)配合快照机制,保障数据一致性:
- 内存截断常与“内存快照”结合:截断前会对内存数据生成快照,再将快照持久化到磁盘。
- lastMemoryTruncationTime 可与快照的时间戳关联,用于后续校验:确保快照包含的是“截断前”的完整数据,且与磁盘中已持久化的数据无重叠。
- 例:快照时间若等于 lastMemoryTruncationTime,说明该快照是截断操作的产物,可用于数据恢复。
- 4)辅助数据查询单额范围定位:
- 当查询历史数据时,系统需要判断:数据是在内存中,还是在磁盘的持久化块中。
- 若查询时间 t < lastMemoryTruncationTime,则数据已被截断到磁盘,直接查询磁盘块;若 t ≥ lastMemoryTruncationTime,则数据可能在内存中,优先查询内存。
- 作用:减少不必要的磁盘 IO,提升查询效率。
- 5)lastMemoryTruncationTime → 记录最近一次内存截断的时间 → 作为内存数据时效性的基准 → 支撑内存截断触发逻辑、快照一致性校验、查询范围定位等功能 → 最终实现内存使用的可控性与数据操作的高效性。
- 1)作为内存数据“时效性”的判断依据:
- 在时序数据库中,Head 组件负责暂存近期的时间序列数据(内存中的活跃数据)。随着数据不断写入,内存占用会持续增长。为了控制内存使用,系统会定期执行 内存截断 操作。
- metrics *headMetrics
- 先讲清楚:headMetric 是什么?
- 该字段是 Head 组件中 用于监控和暴露内部运行状态指标 的核心结构,为 Head 组件提供 "可观测性",其封装了多种监控指标的结构体,包含与 Head 运行相关的各类量化指标,例如:
- 时间序列数量(numSeries 的实时值及变化率)
- 内存中块的数量、大小
- 数据写入 / 查询的吞吐量、延迟
- 乱序样本的占比
- WAL 操作的频率、耗时等
- 为什么需要这个字段?
- 1)实现 "系统可见性":让内部系统状态可以被观测:
- Head 作为时序数据库的核心组件(负责内存数据读写、WAL 管理等),其内部状态(如内存占用、序列增长速度)直接影响系统稳定性。
- metrics 字段通过 headMetrics 实例,将这些“黑盒”状态转化为可量化的指标(如 series_count、memory_usage_bytes),暴露给外部监控系统。
- 例:通过 metrics.seriesCount 可实时观测 numSeries 的变化,及时发现“标签爆炸”导致的序列数量异常增长。
- 2)支撑“问题诊断与优化”:
-
-
-
-
-
-
- 当系统出现性能下降、内存泄漏等问题时,metrics 收集的指标是排查根因的关键依据:
- 若查询延迟升高,可通过 metrics.queryLatency 指标定位是否为内存数据过多导致扫描变慢;
- 若磁盘 IO 突增,可结合 metrics.walWriteCount 判断是否为 WAL 写入过于频繁。
- metrics 记录指标 → 监控系统采集 → 运维 / 开发人员分析 → 定位问题并优化(如调整内存截断频率)。
- 当系统出现性能下降、内存泄漏等问题时,metrics 收集的指标是排查根因的关键依据:
-
3)触发告警与自动调优:
- metrics 收集的指标可与告警规则、自动扩缩容策略联动:
- 当 metrics.memoryUsage 超过阈值时,触发告警通知运维人员,或自动触发内存截断操作;
- 当 metrics.outOfOrderSamplesRatio(乱序样本占比)过高时,提示可能存在数据采集延迟问题,需调整客户端配置。
- metrics 实时更新指标 → 监控系统触发阈值规则 → 执行告警 / 自动调优 → 避免系统崩溃。
- metrics 收集的指标可与告警规则、自动扩缩容策略联动:
-
-
-
-
-
-
-
-
-
-
-
- Head 组件的指标种类繁多(涉及序列、内存、WAL、查询等多个维度),metrics *headMetrics 字段将这些指标的定义、更新、暴露逻辑封装在一个结构体中:
- 避免指标散落在代码各处,便于维护(如新增指标只需在 headMetrics 中定义,无需修改多处逻辑);
- 确保指标的一致性(如时间单位、命名规范统一)。
- 避免指标散落在代码各处,便于维护(如新增指标只需在 headMetrics 中定义,无需修改多处逻辑);
- Head 组件的指标种类繁多(涉及序列、内存、WAL、查询等多个维度),metrics *headMetrics 字段将这些指标的定义、更新、暴露逻辑封装在一个结构体中:
-
-
-
- wal, wbl *wlog.WL
- 先讲清楚:wal, wbl *wlog.WL 是什么?
- 该字段是 Head 组件中用于 日志管理 的核心结构,分别对应两种关键日志机制:WAL(Write-Ahead Log,预写日志)和 WBL(Write-Behind Log,后写日志)。它们的设计围绕“数据可靠性”与“性能平衡”展开。
- wlog.WL:是一个封装了日志操作(如写入、读取、截断、回放)的通用接口 / 结构体,提供了日志文件的底层管理能力(如分段存储、刷盘控制等)。
- wal:指向 WAL 日志的实例,核心特性是 “先写日志,再更新内存”—— 数据在写入 Head 内存之前,必须先写入 WAL,确保数据不丢失。
- wbl:指向 WBL 日志的实例,核心特性是 “先更新内存,再异步写日志”—— 数据先写入内存以保证低延迟,再由后台异步写入 WBL,平衡性能与可靠性。
- wal, wbl 核心区别
- wal(WAL 日志)和 wbl(WBL 日志)并非简单的“先后顺序”关系,而是针对不同数据场景的并行日志机制,各自承担不同职责。具体来说:
-
- wal(预写日志)的写入链路:
- 数据必须先写入 WAL,再更新内存。
-
这是 Prometheus 保障核心数据可靠性的 “强约束”—— 任何对内存中时间序列(series)、样本(samples)的修改(新增、更新),都必须先通过 wal.Log()(https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head_append.go#L901)写入磁盘日志,确保日志刷盘成功后,才会修改内存状态。
head.go → 数据结构定义和内存管理 head_append.go → 数据写入和WAL持久化 head_read.go → 数据查询和读取 head_wal.go → WAL回放和恢复
- 可以在代码中看到,这里有严格的写入顺序:
# https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head_append.go#L1441 func (a *headAppender) Commit() (err error) { // 1. 首先必须写入 WAL if err := a.log(); err != nil { _ = a.Rollback() // WAL 写入失败则回滚 return fmt.Errorf("write to WAL: %w", err) } // 2. WAL 写入成功后才进行内存更新 a.commitSamples(acc) a.commitHistograms(acc) a.commitFloatHistograms(acc) a.commitMetadata() // 3. 最后写入 WBL (乱序数据) if a.head.wbl != nil { if err := a.head.wbl.Log(acc.oooRecords...); err != nil { // 注意:这里即使失败也不回滚,因为内存已更新 a.head.logger.Error("Failed to log out of order samples into the WAL", "err", err) } } return nil } - 调用顺序(强约束,必须先写入成功,否则整个 Commit 失败):
1、headAppender.Commit() ↓ 2、headAppender.log() // https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go 中 ↓ 3、a.head.wal.Log(rec) // 调用 https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/wlog/wlog.go#L657 ↓ 4、w.log(r, final) // https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/wlog/wlog.go#L675 中的实际写入 ↓ 5、w.flushPage() // 刷新到磁盘,https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/wlog/wlog.go#L583
- 数据必须先写入 WAL,再更新内存。
- wal(预写日志)的写入链路:
-
- wal(WAL 日志)和 wbl(WBL 日志)并非简单的“先后顺序”关系,而是针对不同数据场景的并行日志机制,各自承担不同职责。具体来说:
-
-
-
-
-
-
-
-
-
- wbl(后写日志)的写入链路:
- 数据先更新内存,再异步写入 WBL。
- 它是为了优化性能设计的“弱约束”—— 内存修改可以立即完成,无需等待日志写入,日志写入由后台 goroutine 批量、异步处理。如:某些非核心元数据更新时,流程是 更新内存 → 后台异步写入 WBL。
- wbl(后写日志)的写入链路:
-
- Prometheus 会根据数据的 “重要性” 决定使用哪种日志:
- wal 处理核心数据:包括时间序列的创建、样本(metric samples)的写入、墓碑(tombstones,标记删除)等。这些数据是监控的核心,一旦丢失会导致指标不完整,因此必须通过 WAL 确保可靠性。
- wbl 处理非核心或辅助数据:例如某些低优先级的元数据、统计信息(如临时聚合结果)等。这些数据即使丢失,也不会影响核心监控指标的完整性,因此可以牺牲一点可靠性换取性能。
- Prometheus 会根据数据的 “重要性” 决定使用哪种日志:
- 3)简单总结
- wal 和 wbl 并非 “谁先谁后” 的串行关系,而是并行运行:
- 核心监控数据(样本、序列):走 wal 链路,严格遵循 “先日志后内存”,优先保障可靠性;
- 非核心监控数据:走 wbl 链路,遵循“先内存后日志”,优先保障写入性能
- wal 和 wbl 并非 “谁先谁后” 的串行关系,而是并行运行:
-
-
-
-
-
-
三、stripeSeries 的分片机制
stripeSeries 的设计解决了高并发环境下的锁竞争问题,核心思想如下:
-
- 分片存储:将时序数据分散到多个独立的存储单元
- 分片锁定:每个分片使用独立的锁,减少锁竞争
- 双重索引:同时支持 ID 和 Hash 两种查找方式
3.1、stripeSeries 核心结构体字段详细说明
3.1.1、stripeSeries 结构体字段说明
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L1891
type stripeSeries struct {
size int // 分片数量,决定了 series、hashes 和 locks 切片的长度
// 必须是 2 的幂(如 16384=2^14),确保通过位运算高效计算分片索引(i % size = i & (size-1))
// 影响并发性能:值过小会导致锁竞争加剧,值过大会浪费内存并增加缓存 misses
series []map[chunks.HeadSeriesRef]*memSeries // 按 HeadSeriesRef 分片的映射数组
// 每个元素是一个哈希表,存储 "ID -> 时间序列对象" 的映射
// 分片规则:通过 HeadSeriesRef 的哈希值与 size 取模确定分片索引
// 用途:提供高效的 ID 导向查询(O(1) 复杂度,加锁范围仅为单个分片)
hashes []seriesHashmap // unique 表处理无冲突情况,conflicts 表处理哈希冲突
// 分片规则:通过标签哈希值与 size 取模确定分片索引
// 用途:支持按标签快速查找(适用于标签匹配场景),但性能略低于 ID 查找
locks []stripeLock // 分片锁数组,与分片数量一一对应
// 对 series 操作时:通过 HeadSeriesRef 映射的分片索引获取锁
// 对 hashes 操作时:通过标签哈希映射的分片索引获取锁
// 作用:将全局锁拆分为细粒度锁,最大化并发吞吐量
seriesLifecycleCallback SeriesLifecycleCallback // 生命周期回调函数,在时间序列创建/销毁时触发
// 典型用途:资源清理、 metrics 统计(如活跃序列数)、外部索引同步
// 注意:回调函数执行时间会影响主流程性能,应尽量轻量化
}
3.1.1、stripeLock 结构体字段说明
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L1899
type stripeLock struct {
sync.RWMutex // 读写锁,支持并发读、独占写
// 读操作(如查询)使用 RLock()/RUnlock()
// 写操作(如新增/删除序列)使用 Lock()/Unlock()
_ [40]byte // 内存填充,避免缓存伪共享(False Sharing)
// 现代 CPU 缓存行通常为 64 字节,sync.RWMutex 占 24 字节
// 填充 40 字节后总大小为 64 字节,确保单个锁独占一个缓存行
// 若不填充,多个锁可能共享缓存行,导致 CPU 缓存失效频繁,性能下降
}
关键字段说明:
1、初始化配置
-
-
-
- size 必须设置为 2 的幂(如 1<<10=1024、1<<14=16384),否则位运算取模会出错
- 建议根据预期并发量调整 size:高并发场景用较大值(如 1<<16),内存受限场景用较小值(如 1<<12)
- 初始化时需确保 series、hashes、locks 三个切片的长度均等于 size
-
-
2、并发操作规范
-
-
-
- 操作 series 时,必须先通过 HeadSeriesRef 计算分片索引,再获取对应 stripeLock
- 操作 hashes 时,必须先通过标签哈希计算分片索引,再获取对应 stripeLock
- 避免跨分片加锁:同时持有多个分片锁可能导致死锁(如分片 A 锁等待分片 B 锁,同时分片 B 锁等待分片 A 锁)
-
-
3、性能优化建议
-
-
-
- 优先使用 getByID() 而非 getByHash():ID 查找直接定位分片,哈希查找可能需遍历多个候选序列
- 批量操作时尽量按分片分组处理:减少锁的获取 / 释放次数
- 避免在锁持有期间执行耗时操作(如复杂计算、IO):会阻塞其他并发请求
-
-
4、内存管理
-
-
-
- series 和 hashes 中的映射会动态扩容,需监控内存使用避免 OOM
- 删除序列时需同时清理 series 和 hashes 中的记录,否则会导致内存泄漏
- 长期运行场景下建议定期对空映射进行收缩,释放闲置内存
-
-
5、回调函数设计
-
-
-
- seriesLifecycleCallback 应避免修改 stripeSeries 内部状态,防止死锁
- 若回调需要异步执行,需自行处理并发安全(如使用 channel 异步转发事件)
-
-
3.2、分片锁策略
3.2.1、分片数量设置
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L1881
const ( // DefaultStripeSize is the default number of entries to allocate in the stripeSeries hash map. DefaultStripeSize = 1 << 14 // 16384 个分片 )
分片数量的权衡:
-
-
-
- 更大的分片数:减少锁竞争,提升并发性能,但占用更多内存
- 更小的分片数:节省内存,但可能增加锁竞争
-
-
源代码关键说明:
result = value << n
-
-
-
- value:要进行左移的数值
- n:左移的位数
- result:左移后的结果
- 以 DefaultStripeSize = 1 << 14 为例子
原始值 1 的二进制表示: 0000 0000 0000 0001 左移 14 位后: 0100 0000 0000 0000 转换为十进制: 16384
- 数据等价关系:
位左移 n 位 = 乘以 2^n
1 << 0 = 1 × 2^0 = 1 1 << 1 = 1 × 2^1 = 2 1 << 2 = 1 × 2^2 = 4 1 << 3 = 1 × 2^3 = 8 1 << 4 = 1 × 2^4 = 16 ... 1 << 14 = 1 × 2^14 = 16384
-
-
3.2.2、分片算法
3.2.2.1、ID 分片算法
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L2035
// getByID 根据 HeadSeriesRef 获取对应的 memSeries
// 参数 id 是要查找的内存序列引用
// 返回找到的 memSeries 指针,若不存在则返回 nil
func (s *stripeSeries) getByID(id chunks.HeadSeriesRef) *memSeries {
// 计算当前 id 对应的分区索引:通过 id 与 (分区数量-1) 按位与,实现哈希映射
// 目的是将不同的 id 分散到不同的分区中,减少锁竞争
i := uint64(id) & uint64(s.size-1)
// 对对应分区的读写锁进行读锁定,保证并发安全
s.locks[i].RLock()
// 从当前分区的 series 映射中根据 id 获取 memSeries
series := s.series[i][id]
// 释放读锁
s.locks[i].RUnlock()
// 返回找到的 memSeries(可能为 nil)
return series
}
在这段代码中,“分区” 指的是 stripeSeries 结构体中用于存储 memSeries 的分片(或称为 “条带”)。具体来说:
-
-
-
- 分区的物理载体:stripeSeries 包含一个 series 字段,其类型为 []map[chunks.HeadSeriesRef]*memSeries,即由多个映射(map)组成的数组。这里的每个数组元素(每个 map)就是一个“分区”,用于存储一部分 memSeries 实例,键为 HeadSeriesRef(series 的唯一标识),值为对应的 memSeries 指针。
- 分区的作用:分区的核心目的是分散并发访问压力。通过将所有 memSeries 分散到多个分区中,每个分区对应一个独立的读写锁(stripeLock),可以避免单锁竞争导致的性能瓶颈。例如,当多个 goroutine 同时访问不同分区的 memSeries 时,它们可以持有不同分区的锁,互不阻塞。
- 分区的索引计算:分区的索引通过 uint64(id) & uint64(s.size-1) 计算,其中 s.size 是分区总数(数组长度)。这本质上是一种哈希取模操作,确保不同的 HeadSeriesRef 能均匀分布到各个分区中,进一步降低锁竞争概率。
-
-
3.2.2.2、Hash 分片算法
// getByHash 根据给定的 hash 值和标签集 lset 获取对应的 memSeries
// 参数 hash 是标签集的哈希值,lset 是具体的标签集
// 返回找到的 memSeries 指针,若不存在则返回 nil
func (s *stripeSeries) getByHash(hash uint64, lset labels.Labels) *memSeries {
// 计算当前 hash 对应的分区索引:通过 hash 与 (分区数量-1) 按位与,实现哈希映射
// 目的是将不同的 hash 值分散到不同的分区中,减少锁竞争
i := hash & uint64(s.size-1)
// 对对应分区的读写锁进行读锁定,保证并发安全
s.locks[i].RLock()
// 从当前分区的 hashes 结构中根据 hash 和 lset 获取 memSeries
series := s.hashes[i].get(hash, lset)
// 释放读锁
s.locks[i].RUnlock()
// 返回找到的 memSeries(可能为 nil)
return series
}
关键点说明:
-
-
-
- 分区索引计算:通过 hash & uint64(s.size-1) 确定当前 hash 对应的分区索引,利用按位与操作实现高效的哈希映射,确保 hash 值均匀分布到不同分区(s.hashes 数组元素),降低锁竞争概率。
- 并发安全机制:使用 RLock() 和 RUnlock() 进行读锁定,允许多个 goroutine 同时读取同一分区的数据,提升读操作的并发性能,同时避免数据竞争。
- 基于 hash 和标签集的查找:通过 s.hashes[i].get(hash, lset) 完成实际查找,s.hashes 是 seriesHashmap 类型的数组,每个元素维护了该分区内 hash 与 memSeries 的映射关系(包括处理 hash 冲突的逻辑)。
- 与 seriesHashmap 的协作:seriesHashmap 内部通过 unique map 存储无冲突的 hash 映射,通过 conflicts map 存储有冲突的 hash 对应的 memSeries 列表,get 方法会先检查 unique 再遍历 conflicts,结合标签集精确匹配目标 memSeries。
-
-
3.3、锁机制
3.3.1、缓存行对齐优化
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L1899
type stripeLock struct {
sync.RWMutex
// Padding to avoid multiple locks being on the same cache line.
_ [40]byte // 40字节填充,避免伪共享
}
伪共享问题:
-
-
- CPU 缓存行通常是 64 字节
- 如果多个锁在同一缓存行,会导致缓存失效
- 40 字节填充确保每个锁独占一个缓存行
-
3.3.2、双重锁定策略
在 getOrSet 方法中体现了复杂的锁定策略:
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L2055
// getOrSet 尝试根据标签集的哈希和标签集本身在 stripeSeries 中查找或创建一个内存系列(memSeries)
// 参数:
// - hash: 标签集的哈希值,用于快速定位
// - lset: 标签集,用于精确匹配系列
// - createSeries: 用于创建新 memSeries 的函数,在需要时调用
// 返回值:
// - *memSeries: 找到或创建的内存系列
// - bool: 标识是否是新创建的系列(true 为新创建)
// - error: 操作过程中出现的错误(如预创建回调失败)
func (s *stripeSeries) getOrSet(hash uint64, lset labels.Labels, createSeries func() *memSeries) (*memSeries, bool, error) {
// 先调用 PreCreation 回调(在获取锁之外),用于提前判断是否允许创建系列
// 这一步是为了避免在持有锁的情况下执行可能耗时的回调逻辑,同时提供是否创建系列的"提示"
preCreationErr := s.seriesLifecycleCallback.PreCreation(lset) ===> 预创建检查(无锁)
// 仅当预创建回调成功时,才通过 createSeries 函数创建新系列
// 如果回调失败,后续不会允许创建新系列
var series *memSeries
if preCreationErr == nil {
series = createSeries() ===> 在锁外创建,减少锁持有时间
}
// 根据哈希值计算对应的锁索引(基于 stripe 大小取模),用于减少锁竞争 ===> 第一次枷锁:检查 hash 索引
i := hash & uint64(s.size-1)
// 获取该索引对应的写锁,保证操作哈希映射时的线程安全
s.locks[i].Lock()
// 尝试在当前哈希对应的哈希映射中查找已有系列
// 如果找到,释放锁并返回已有系列(未创建新系列)
if prev := s.hashes[i].get(hash, lset); prev != nil {
s.locks[i].Unlock()
return prev, false, nil ===> 已存在,直接返回
}
// 如果预创建回调成功,将新创建的系列存入哈希映射中
if preCreationErr == nil {
s.hashes[i].set(hash, series)
}
// 释放哈希映射对应的锁
s.locks[i].Unlock()
// 如果预创建回调失败,返回错误(此时未创建系列)
if preCreationErr != nil {
// 回调阻止了系列的创建
return nil, false, preCreationErr
}
// 根据系列的引用(ref)计算对应的锁索引(用于系列 ID 到系列的映射)
i = uint64(series.ref) & uint64(s.size-1) ===> 第二次加锁:更新 ID 索引
// 获取该索引对应的写锁,保证操作系列 ID 映射时的线程安全
s.locks[i].Lock()
// 将新系列存入系列 ID 到系列的映射中
s.series[i][series.ref] = series
// 释放系列 ID 映射对应的锁
s.locks[i].Unlock()
// 返回新创建的系列、创建成功标识(true)和无错误
return series, true, nil
}
3.4、seriesHashmap 哈希冲突处理
3.4.1、分离式冲突处理
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L1815
type seriesHashmap struct {
unique map[uint64]*memSeries // 无冲突的哈希值
conflicts map[uint64][]*memSeries // 有冲突的哈希值
}
关键点说明:
-
-
- 常见情况优化:大部分哈希值无冲突,直接查找 unique 表
- 冲突处理:少数冲突情况使用切片存储,按需初始化
- 内存效率:避免为每个哈希值预分配切片
-
3.4.2、哈希查找逻辑
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L1820
// get 方法根据给定的哈希值和标签集查找对应的 memSeries
// 参数:
// - hash: 标签集的哈希值,用于快速定位
// - lset: 标签集,用于精确匹配系列
// 返回值:找到的 memSeries 指针,若未找到则返回 nil
func (m *seriesHashmap) get(hash uint64, lset labels.Labels) *memSeries {
// 先在 unique 映射中查找哈希值对应的系列
// unique 存储哈希值唯一对应的系列,是快速查找的主要路径
if s, found := m.unique[hash]; found {
// 若找到,需验证标签集是否完全匹配(避免哈希冲突导致的误判)
if labels.Equal(s.labels(), lset) {
return s
}
}
// 若 unique 中未找到或标签不匹配,在 conflicts 中查找哈希冲突的系列
// conflicts 存储哈希值相同但标签集不同的系列列表
for _, s := range m.conflicts[hash] {
// 逐个验证冲突列表中的系列标签是否匹配
if labels.Equal(s.labels(), lset) {
return s
}
}
// 未找到匹配的系列,返回 nil
return nil
}
- 哈希查找机制:使用 unique 和 conflicts 两个映射处理哈希值,unique 优先存储哈希唯一的系列,conflicts 处理哈希冲突(同一哈希对应多个不同标签集的情况),平衡查找效率和冲突处理。
- 标签精确匹配:即使哈希值匹配,仍需通过 labels.Equal 验证标签集完全一致,因为不同标签集可能产生相同哈希(哈希冲突),确保查找结果的准确性。
- 性能优化:优先从 unique 查找(O (1) 时间复杂度),仅在存在哈希冲突时才遍历 conflicts 列表,减少不必要的比较操作,适合大多数哈希值唯一的场景。
- 线程安全性:该方法本身不包含锁机制,需由调用方(如 stripeSeries)通过外部锁保证并发安全,避免多线程读写冲突。
3.4.3、动态冲突转换
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L1834
// set 方法将 memSeries 实例存入 seriesHashmap 中,根据哈希值和标签集处理存储逻辑
// 参数:
// - hash: 标签集的哈希值,用于定位存储位置
// - s: 要存储的 memSeries 实例
func (m *seriesHashmap) set(hash uint64, s *memSeries) {
// 先检查 unique 映射中是否存在该哈希值对应的系列
// 如果不存在,或存在但标签集与当前系列完全匹配,则直接存入 unique
if existing, found := m.unique[hash]; !found || labels.Equal(existing.labels(), s.labels()) {
m.unique[hash] = s
return
}
// 如果 unique 中存在不同标签集的系列(哈希冲突),初始化 conflicts 映射(若未初始化)
if m.conflicts == nil {
m.conflicts = make(map[uint64][]*memSeries)
}
// 获取当前哈希值在 conflicts 中对应的系列列表
l := m.conflicts[hash]
// 遍历冲突列表,若存在标签集完全匹配的系列,则替换它
for i, prev := range l {
if labels.Equal(prev.labels(), s.labels()) {
l[i] = s
return
}
}
// 若冲突列表中无匹配项,则将当前系列追加到冲突列表
m.conflicts[hash] = append(l, s)
}
关键点说明:
- 哈希冲突处理机制:通过 unique 和 conflicts 两级结构处理哈希冲突。unique 优先存储哈希唯一的系列,conflicts 存储哈希相同但标签集不同的系列,避免哈希冲突导致的数据覆盖。
- 标签精确匹配校验:无论是更新 unique 还是 conflicts,都通过 labels.Equal 验证标签集完全一致,确保相同标签集的系列被正确覆盖,不同标签集的系列即使哈希相同也会被区分存储。
- 高效更新逻辑:
- 优先检查 unique,命中则直接更新(O (1) 时间复杂度)。
- 哈希冲突时才操作 conflicts,遍历列表查找匹配项(最坏 O (n),但哈希冲突概率低,实际效率较高)。
- 内存安全设计:conflicts 采用延迟初始化(首次冲突时才创建映射),减少不必要的内存占用,适合大多数哈希值唯一的场景。
- 与 get 方法的协同:set 方法的存储逻辑与 get 方法的查找逻辑严格对应(先查 unique 再查 conflicts),保证数据存取的一致性。
3.5、垃圾回收的分片并行处理
3.5.1、分片并行GC
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L1932
// gc 用于垃圾回收严格早于 mint 的旧块,并完全移除没有剩余块的序列
// 注意:返回 map[chunks.HeadSeriesRef]struct{} 会更精确,
// 但返回的映射会传入 postings.Delete(),该方法期望接收 map[storage.SeriesRef]struct{} 类型,
// 而映射类型无法直接转换
// minMmapFile 是垃圾回收后,序列(有序和无序)中出现的最小内存映射文件编号
func (s *stripeSeries) gc(mint int64, minOOOMmapRef chunks.ChunkDiskMapperRef) (_ map[storage.SeriesRef]struct{}, _ map[labels.Label]struct{}, _ int, _, _ int64, minMmapFile int) {
var (
deleted = map[storage.SeriesRef]struct{}{} // 存储被删除的序列ID
affected = map[labels.Label]struct{}{} // 存储受删除操作影响的标签
rmChunks = 0 // 记录被移除的块数量
actualMint int64 = math.MaxInt64 // 实际的最小时间戳(垃圾回收后剩余序列的最小时间)
minOOOTime int64 = math.MaxInt64 // 无序块的最小时间戳
)
minMmapFile = math.MaxInt32 // 初始化最小内存映射文件编号为最大值
// 处理单个序列:截断早于 mint 的旧块,检查是否有剩余块。如果没有,则标记为删除并收集其ID
check := func(hashShard int, hash uint64, series *memSeries, deletedForCallback map[chunks.HeadSeriesRef]labels.Labels) {
series.Lock() // 锁定序列,保证并发安全
defer series.Unlock() // 函数退出时解锁
// 截断早于 mint 的块,并累加被移除的块数量
rmChunks += series.truncateChunksBefore(mint, minOOOMmapRef)
// 更新最小内存映射文件编号(针对有序块)
if len(series.mmappedChunks) > 0 {
seq, _ := series.mmappedChunks[0].ref.Unpack() // 解析块引用获取序列编号
if seq < minMmapFile {
minMmapFile = seq
}
}
// 更新最小内存映射文件编号(针对无序块)和无序块的最小时间戳
if series.ooo != nil && len(series.ooo.oooMmappedChunks) > 0 {
seq, _ := series.ooo.oooMmappedChunks[0].ref.Unpack()
if seq < minMmapFile {
minMmapFile = seq
}
// 遍历无序块,更新最小时间戳
for _, ch := range series.ooo.oooMmappedChunks {
if ch.minTime < minOOOTime {
minOOOTime = ch.minTime
}
}
}
// 检查无序头块的最小时间戳
if series.ooo != nil && series.ooo.oooHeadChunk != nil {
if series.ooo.oooHeadChunk.minTime < minOOOTime {
minOOOTime = series.ooo.oooHeadChunk.minTime
}
}
// 如果序列仍有块(有序块、头块、待提交状态,或无序块/无序头块),则不删除
if len(series.mmappedChunks) > 0 || series.headChunks != nil || series.pendingCommit ||
(series.ooo != nil && (len(series.ooo.oooMmappedChunks) > 0 || series.ooo.oooHeadChunk != nil)) {
seriesMint := series.minTime() // 获取序列的最小时间戳
if seriesMint < actualMint {
actualMint = seriesMint // 更新实际最小时间戳
}
return
}
// 序列已无任何块,需要删除。计算序列ID对应的锁分片
refShard := int(series.ref) & (s.size - 1)
// 如果哈希分片与序列ID分片不同,需要额外锁定序列ID分片,避免并发冲突
if hashShard != refShard {
s.locks[refShard].Lock()
defer s.locks[refShard].Unlock()
}
// 记录被删除的序列ID
deleted[storage.SeriesRef(series.ref)] = struct{}{}
// 记录序列包含的所有标签(用于后续更新索引)
series.lset.Range(func(l labels.Label) { affected[l] = struct{}{} })
// 从哈希映射中删除序列
s.hashes[hashShard].del(hash, series.ref)
// 从序列ID映射中删除序列
delete(s.series[refShard], series.ref)
// 记录被删除的序列及其标签(用于回调)
deletedForCallback[series.ref] = series.lset // 安全访问lset,因为序列已锁定
}
// 遍历所有序列,执行check函数进行垃圾回收检查
s.iterForDeletion(check)
// 如果没有剩余序列,将实际最小时间戳设为mint
if actualMint == math.MaxInt64 {
actualMint = mint
}
return deleted, affected, rmChunks, actualMint, minOOOTime, minMmapFile
}
关键点说明:
- 垃圾回收目标:主要回收两类数据:
- 时间戳严格早于 mint 的旧块(包括有序块 mmappedChunks 和无序块 oooMmappedChunks)。
- 完全没有剩余块的序列(包括无有序块、无头块、无待提交状态、无无序块)。
- 并发安全机制:
- 对单个序列操作时使用 series.Lock() 保证序列级别的并发安全。
- 当哈希分片与序列 ID 分片不同时,额外锁定序列 ID 对应的分片锁,避免删除过程中出现并发写入冲突。
- 元数据跟踪:
- deleted 记录被完全删除的序列 ID,用于后续清理索引。
- affected 记录被删除序列包含的所有标签,用于更新标签索引。
- rmChunks 统计被移除的块数量,用于监控和调试。
- minMmapFile 跟踪最小内存映射文件编号,用于后续可能的文件清理。
- 边界处理:
- 若所有序列都被删除,将 actualMint 设为 mint,避免保留无效的最大值。
- 分别处理有序块和无序块的元数据(时间戳、内存映射编号),确保两类数据都被正确回收。
- 与其他模块的协同:
- 返回的 deleted 映射需适配 postings.Delete() 接口,因此使用 storage.SeriesRef 类型。
- 通过 deletedForCallback 传递被删除序列的标签信息,支持后续的回调处理(如索引更新)。
- 性能优化:
- 仅在序列确实需要删除时才获取额外的分片锁,减少锁竞争。
- 遍历过程中同步计算最小时间戳和内存映射编号,避免二次遍历。
- 垃圾回收目标:主要回收两类数据:
3.5.1、迭代删除的回调机制
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L2009
// iterForDeletion 函数遍历所有序列,为每个序列调用 checkDeletedFunc 函数
// checkDeletedFunc 接收一个映射作为输入,应向其中添加所有已删除且需要在调用 PostDeletion 钩子时包含的序列
func (s *stripeSeries) iterForDeletion(checkDeletedFunc func(int, uint64, *memSeries, map[chunks.HeadSeriesRef]labels.Labels)) int {
seriesSetFromPrevStripe := 0 // 上一个分片的删除序列集合大小,用于预分配当前分片的映射容量
totalDeletedSeries := 0 // 记录总删除序列数量
// 按分片逐个处理所有序列
for i := 0; i < s.size; i++ {
// 基于上一个分片的删除数量预分配当前分片的映射容量,优化内存分配
seriesSet := make(map[chunks.HeadSeriesRef]labels.Labels, seriesSetFromPrevStripe)
s.locks[i].Lock() // 锁定当前分片,确保并发安全
// 先遍历冲突列表(conflicts),避免在删除 unique 中的序列后,checkDeletedFunc 将序列移至 unique 字段
for hash, all := range s.hashes[i].conflicts {
for _, series := range all {
checkDeletedFunc(i, hash, series, seriesSet) // 调用检查函数,处理当前序列
}
}
// 再遍历唯一序列(unique)
for hash, series := range s.hashes[i].unique {
checkDeletedFunc(i, hash, series, seriesSet) // 调用检查函数,处理当前序列
}
s.locks[i].Unlock() // 解锁当前分片
// 调用序列生命周期回调的 PostDeletion 方法,通知删除事件
s.seriesLifecycleCallback.PostDeletion(seriesSet)
totalDeletedSeries += len(seriesSet) // 累加当前分片的删除数量
seriesSetFromPrevStripe = len(seriesSet) // 更新上一个分片的删除数量,用于下一轮预分配
}
return totalDeletedSeries // 返回总删除序列数量
}
关键点说明:
- 分片遍历策略:
- 按分片(s.size 个分片)逐个处理序列,通过分片锁(s.locks[i])保证每个分片内的操作原子性,避免并发修改冲突。
- 先遍历 conflicts 再遍历 unique,防止在处理 unique 时因序列删除导致 conflicts 中的序列被迁移到 unique,从而遗漏处理。
- 内存优化:
- 使用 seriesSetFromPrevStripe 记录上一个分片的删除序列数量,以此预分配当前分片的 seriesSet 映射容量,减少动态扩容带来的性能开销。
- 生命周期回调:
- 每个分片处理完成后,通过 s.seriesLifecycleCallback.PostDeletion(seriesSet) 触发删除后的回调逻辑(如索引清理、 metrics 更新等),保证删除操作的完整性。
- 函数职责边界:
- 本函数仅负责遍历序列并调用外部传入的 checkDeletedFunc,具体的删除判断逻辑(如是否符合删除条件)由 checkDeletedFunc 实现,实现了遍历与业务逻辑的解耦。
- 统计与返回值:
- 累计所有分片的删除序列数量并返回,便于上层跟踪垃圾回收或清理操作的效果(如日志记录、性能监控等)。
- 分片遍历策略:
3.6、Series 生命周期管理
3.6.1、一个 Series 的完整生命周期有哪些阶段
创建 → 活跃使用 → 空闲状态 → 垃圾收集 → 最终清理
3.6.2、Series 创建阶段
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head_append.go#L456
// getOrCreate 尝试根据标签集获取已有序列,若不存在则创建新序列
// 参数 lset 为标签集,返回值依次为:找到或创建的序列、是否为新创建的序列、可能的错误
func (a *headAppender) getOrCreate(lset labels.Labels) (s *memSeries, created bool, err error) {
// 移除标签集中的空值标签(如值为空字符串的标签)
lset = lset.WithoutEmpty()
// 检查处理后的标签集是否为空,若为空则返回错误
if lset.IsEmpty() {
return nil, false, fmt.Errorf("empty labelset: %w", ErrInvalidSample)
}
// 检查标签集中是否有重复的标签名,若有则返回错误
if l, dup := lset.HasDuplicateLabelNames(); dup {
return nil, false, fmt.Errorf(`label name "%s" is not unique: %w`, l, ErrInvalidSample)
}
// 调用 head 的 getOrCreate 方法,通过标签集的哈希值查找或创建序列
// 第三个参数 true 表示允许创建新序列
s, created, err = a.head.getOrCreate(lset.Hash(), lset, true)
if err != nil {
return nil, false, err
}
// 若序列是新创建的,记录其引用和标签集到当前 appender 的待提交列表中
if created {
a.seriesRefs = append(a.seriesRefs, record.RefSeries{
Ref: s.ref, // 序列的引用 ID
Labels: lset, // 序列的标签集
})
a.series = append(a.series, s) // 记录新创建的序列实例
}
return s, created, nil
}
- 标签集预处理与校验:
- 通过 lset.WithoutEmpty() 移除空值标签,避免无效数据进入存储。
- 校验标签集是否为空或包含重复标签名,提前拦截不符合规范的输入,确保数据合法性。
- 序列的查找与创建逻辑:
- 基于标签集的哈希值(lset.Hash())高效查找序列,哈希值用于快速定位可能的序列位置(减少比较成本)。
- 调用 a.head.getOrCreate 实现核心逻辑,该方法内部会处理并发安全(如分片锁)和冲突解决(如哈希冲突时的标签集精确匹配)。
- 新序列的跟踪:
- 若序列为新创建(created == true),将其引用(Ref)和标签集记录到 seriesRefs 和 series 中,用于后续提交(Commit)时的批量处理(如写入 WAL、更新索引等)。
- 这种批量跟踪机制避免了单次创建序列时的频繁 IO 操作,提升了写入性能。
- 与 Head 模块的协作:
- 本方法是 headAppender 与 Head 模块交互的关键接口,将序列管理的底层逻辑(如存储结构、并发控制)委托给 Head,自身专注于当前批次的追加事务管理。
- 错误处理:
- 所有错误均包装了 ErrInvalidSample 作为基础错误类型,便于上层统一处理数据无效类异常。
- 标签集预处理与校验:
3.6.3、Series 创建流程
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L1737
// getOrCreate 尝试根据哈希值和标签集获取已有序列,若不存在则创建新序列
// 参数 hash 为标签集的哈希值,lset 为标签集,pendingCommit 表示是否处于待提交状态
// 返回值依次为:找到或创建的内存序列、是否为新创建的序列、可能的错误
func (h *Head) getOrCreate(hash uint64, lset labels.Labels, pendingCommit bool) (*memSeries, bool, error) {
// 仅使用下面的 getOrCreateWithID 在语义上是足够的,但这样会在每次通过 Add() 插入样本时创建新序列,
// 这会导致内存分配,并且会使序列 ID 变得随机,难以在 postings 中压缩
s := h.series.getByHash(hash, lset)
if s != nil {
// 若找到对应序列,返回该序列,标记为未新创建
return s, false, nil
}
// 乐观地假设当前是第一个创建该序列的操作,生成新的序列 ID
// 通过自增 lastSeriesID 获取新的 HeadSeriesRef 作为序列 ID
id := chunks.HeadSeriesRef(h.lastSeriesID.Inc())
// 调用 getOrCreateWithID 方法,使用生成的 ID 创建或获取序列
return h.getOrCreateWithID(id, hash, lset, pendingCommit)
}
3.6.4、带 ID的创建流程
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L1752
// getOrCreateWithID 尝试根据指定的 ID、哈希值和标签集获取已有序列,若不存在则使用提供的函数创建新序列
// 参数 id 为指定的序列引用 ID,hash 为标签集的哈希值,lset 为标签集,pendingCommit 表示是否处于待提交状态
// 返回值依次为:找到或创建的内存序列、是否为新创建的序列、可能的错误
func (h *Head) getOrCreateWithID(id chunks.HeadSeriesRef, hash uint64, lset labels.Labels, pendingCommit bool) (*memSeries, bool, error) {
// 调用 series 的 getOrSet 方法,尝试获取已有序列;若不存在,则通过传入的匿名函数创建新序列
s, created, err := h.series.getOrSet(hash, lset, func() *memSeries {
shardHash := uint64(0)
// 若启用分片功能,计算标签集的稳定哈希值作为分片哈希
if h.opts.EnableSharding {
shardHash = labels.StableHash(lset)
}
// 创建新的内存序列,传入标签集、ID、分片哈希、隔离配置和待提交状态
return newMemSeries(lset, id, shardHash, h.opts.IsolationDisabled, pendingCommit)
})
if err != nil {
return nil, false, err
}
// 若序列已存在,直接返回该序列,标记为未新创建
if !created {
return s, false, nil
}
// 若序列是新创建的,更新相关指标和计数器
h.metrics.seriesCreated.Inc() // 增加序列创建计数指标
h.numSeries.Inc() // 增加总序列数计数器
// 将新序列的引用和标签集添加到 postings 索引中,用于查询时快速定位
h.postings.Add(storage.SeriesRef(id), lset)
// 通知序列创建完成,确保后续读写操作能正确识别该序列
h.series.postCreation(lset)
// 返回新创建的序列,标记为已创建
return s, true, nil
}
3.6.4、Series 生命周期回调机制
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L220
// SeriesLifecycleCallback 定义了一系列在序列生命周期中会被调用的回调方法
// 该接口在 Prometheus 中通常是一个空操作(no-op),主要用于那些导入了 TSDB 的外部用户
// 所有回调方法都应保证并发调用的安全性
// 由用户决定通过将回调实现为原子操作或非原子操作,来实现弱一致性或强一致性
// 原子回调可能会导致性能下降
type SeriesLifecycleCallback interface {
// PreCreation 在创建序列之前被调用,用于判断该序列是否可以被创建(创建前回调 - 可以组织 series 创建)
// 返回非 nil 错误表示该序列不应被创建
PreCreation(labels.Labels) error
// PostCreation 在序列创建之后被调用,用于通知序列已创建(创建后回调 - 通知 series 已创建)
PostCreation(labels.Labels)
// PostDeletion 在序列删除之后被调用,用于通知序列已删除(删除后回调 - 批量通知 series 已删除)
PostDeletion(map[chunks.HeadSeriesRef]labels.Labels)
}
四、memSeries 的结构设计
在 Prometheus 的 head.go 中,memSeries 的双层存储架构主要通过内存块(headChunks)和 磁盘映射块(mmappedChunks)实现,用于高效管理时序数据的读写和生命周期。以下是其设计细节:
4.1、双层存储架构
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L2138
type memSeries struct {
// 不可变元数据(构造后不变)
ref chunks.HeadSeriesRef
meta *metadata.Metadata
shardHash uint64
// 需要锁保护的存储层
sync.Mutex
lset labels.Labels
// 第一层:磁盘映射层 (Memory-Mapped Chunks)
mmappedChunks []*mmappedChunk // 已持久化的不可变 chunks
firstChunkID chunks.HeadChunkID // mmappedChunks[0] 的 ID
// 第二层:内存层 (Head Chunks)
headChunks *memChunk // 内存中的可变 chunks 链表
// 乱序数据处理
ooo *memSeriesOOOFields // 乱序数据存储
// 状态管理
mmMaxTime int64 // 最大 mmap chunk 时间
nextAt int64 // 下次切分 chunk 的时间点
pendingCommit bool // 是否有待提交的样本
app chunkenc.Appender // 当前 chunk 的追加器
}
4.1.1、内存层(headChunks)—— 实时写入与活跃数据
headChunks 是 memSeries 中用于存储最新、活跃的时序数据的内存结构,采用链表形式组织(headChunks 字段为链表头),特点如下:
-
-
- 实时写入:新样本首先写入 headChunks,保证数据追加的高效性(链表头部插入,避免移动大量数据)。
- 未持久化数据:headChunks 中的数据尚未写入磁盘持久化,仅存在于内存中,用于支持高频写入和查询。
- 动态拆分:当一个内存块(memChunk)达到配置的样本数量上限(如 SamplesPerChunk)或时间范围(ChunkRange)时,会自动创建新的内存块并添加到链表头部,旧内存块仍保留在链表中。
- 可被映射到磁盘:当内存块不再活跃(如数据时间范围超出内存保留窗口),会被转移到磁盘映射层(mmappedChunks),释放内存空间。
-
4.1.2、磁盘映射层(mmappedChunks)—— 历史数据与内存优化
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L2379
type mmappedChunk struct {
ref chunks.ChunkDiskMapperRef // 磁盘映射引用
numSamples uint16 // 样本数量
minTime, maxTime int64 // 时间范围
}
mmappedChunks 是 memSeries 中用于存储历史数据的磁盘映射结构,采用切片形式组织(mmappedChunks []mmappedChunkMeta),特点如下:
-
-
- 磁盘持久化:mmappedChunks 中的数据通过内存映射文件(ChunkDiskMapper)存储在磁盘上,避免占用过多内存。
- 按需加载:访问历史数据时,通过内存映射机制将磁盘数据临时映射到内存,无需全量加载,提升内存使用效率。
- 有序存储:切片中按时间顺序存储历史块(索引 0 为最旧数据,索引递增对应数据时间递增),与 headChunks 的链表顺序(头部为最新数据)形成互补。
- 元数据记录:每个 mmappedChunkMeta 包含磁盘块的引用(ref)、时间范围(minTime/maxTime)等元信息,用于快速定位和校验数据。
-
4.1.3、双层架构的协作机制
-
-
- 数据流转:新数据先写入 headChunks,当满足迁移条件(如时间范围过期、内存压力触发),headChunks 中的旧块会被转移到 mmappedChunks 并持久化到磁盘,链表中移除该块。
新样本写入流程: headChunks (内存) → mmappedChunks (磁盘映射) → Block (长期存储) ↑ ↑ ↑ 可读写 只读(mmap) 压缩归档 - 查询适配:查询时,memSeries.chunk() 方法根据 chunk ID 自动判断数据所在层:
- 若 ID 对应内存块(headChunks),直接返回内存中的 memChunk,支持快速读写。
- 若 ID 对应磁盘映射块(mmappedChunks),通过 ChunkDiskMapper 加载磁盘数据并映射到内存,返回临时内存块。
- 一致性保障:通过 firstChunkID 字段维护全局 chunk ID 连续性,确保内存层和磁盘映射层的块按时间顺序统一编号,避免查询时数据错乱。
- 数据流转:新数据先写入 headChunks,当满足迁移条件(如时间范围过期、内存压力触发),headChunks 中的旧块会被转移到 mmappedChunks 并持久化到磁盘,链表中移除该块。
-
4.1.4、设计优势
这种双层架构是 Prometheus 处理高基数时序数据的核心设计之一,兼顾了写入效率、内存控制和查询灵活性。
-
-
- 读写分离:内存层(headChunks)优化写入性能,磁盘映射层(mmappedChunks)优化历史数据存储,平衡实时性与资源占用。
- 内存高效:仅保留活跃数据在内存,历史数据通过磁盘映射按需加载,降低内存压力。
- 查询灵活:统一的 chunk ID 机制使查询无需关心数据存储位置,透明适配内存和磁盘数据。
-
4.2、Chunk 链表管理
4.2.1、memChunk 链表设计
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L2302
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L2309
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L2324
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L2336
type memChunk struct {
chunk chunkenc.Chunk // 实际的 chunk 数据
minTime, maxTime int64 // 时间范围
prev *memChunk // 指向更老的 chunk
}
// 链表操作方法
func (mc *memChunk) len() (count int) {
elem := mc
for elem != nil {
count++
elem = elem.prev
}
return count
}
func (mc *memChunk) oldest() (elem *memChunk) {
elem = mc
for elem.prev != nil {
elem = elem.prev
}
return elem
}
func (mc *memChunk) atOffset(offset int) (elem *memChunk) {
elem = mc
for i := 0; i < offset && elem != nil; i++ {
elem = elem.prev
}
return elem
}
4.2.2、链表结构示意
headChunks 链表组织(新 → 旧):
s.headChunks → {t5} → {t4} → {t3} → {t2} → nil
↑ ↑ ↑ ↑ ↑
最新chunk offset=1 offset=2 offset=3 最老chunk
mmappedChunks 数组组织(旧 → 新):
s.mmappedChunks = [t0, t1, t2, t3, t4]
↑ ↑
最老 最新
4.2.3、Chunk 切分策略
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head_append.go#L1692
// appendPreprocessor 负责切割新的 XOR 块并将旧块映射到内存(m-mapping)。
// XOR 块的切割基于其包含的样本数量,并以字节数作为软限制。
// 在不持有序列锁的情况下,与 s.iterator(...) 并发调用是不安全的。
// 此方法仅应在追加数据时调用。
func (s *memSeries) appendPreprocessor(t int64, e chunkenc.Encoding, o chunkOpts) (c *memChunk, sampleInOrder, chunkCreated bool) {
// 目标是将 chunkenc.MaxBytesPerXORChunk 作为 XOR 块大小的硬限制。
// 由于无法预知下一个样本的大小,这里假设下一个样本为最大尺寸(19字节),预留出空间。
const maxBytesPerXORChunk = chunkenc.MaxBytesPerXORChunk - 19
// 获取当前内存块(head chunk)
c = s.headChunks
// 若当前没有内存块(head chunk)
if c == nil {
// 检查最新的磁盘映射块(mmapped chunk)的最大时间是否大于等于当前样本时间
if len(s.mmappedChunks) > 0 && s.mmappedChunks[len(s.mmappedChunks)-1].maxTime >= t {
// 样本为乱序(时间戳已存在于磁盘映射块中),忽略该样本
return c, false, false
}
// 为该序列创建第一个内存块
c = s.cutNewHeadChunk(t, e, o.chunkRange)
chunkCreated = true // 标记新块已创建
}
// 检查当前样本是否乱序(当前内存块的最大时间 >= 样本时间)
if c.maxTime >= t {
return c, false, chunkCreated // 乱序样本,返回当前块但标记为非顺序
}
// 若未创建新块,且当前块大小超过阈值,切割新块
// 这里的判断避免了刚创建的块立即被切割(chunkCreated 为 true 时跳过)
if !chunkCreated && len(c.chunk.Bytes()) > maxBytesPerXORChunk {
c = s.cutNewHeadChunk(t, e, o.chunkRange)
chunkCreated = true
}
// 若当前块的编码格式与待追加样本的编码格式不匹配
if c.chunk.Encoding() != e {
// 切割新块以匹配样本编码
c = s.cutNewHeadChunk(t, e, o.chunkRange)
chunkCreated = true
}
// 获取当前块中的样本数量
numSamples := c.chunk.NumSamples()
if numSamples == 0 {
// 若块是新创建的(可能是读取快照后创建),修正块的最小时间
c.minTime = t
// 计算下一个块的预计结束时间(基于块的时间范围)
s.nextAt = rangeForTimestamp(c.minTime, o.chunkRange)
}
// 当样本数量达到块预期样本数的 25% 时,预测当前块的结束时间
// 目的是使剩余块在当前时间范围内均匀分布样本
if numSamples == o.samplesPerChunk/4 {
s.nextAt = computeChunkEndTime(c.minTime, c.maxTime, s.nextAt, 4)
}
// 若样本时间超过当前块的预计结束时间,或样本数超过预期的 2 倍
// 说明之前的预测无效(可能样本率变化),切割新块
// 注意:新块会根据新的样本率重新计算 nextAt
if t >= s.nextAt || numSamples >= o.samplesPerChunk*2 {
c = s.cutNewHeadChunk(t, e, o.chunkRange)
chunkCreated = true
}
// 返回当前块、是否为顺序样本、是否创建了新块
return c, true, chunkCreated
}
- 块大小控制
- 通过 maxBytesPerXORChunk 限制 XOR 块的最大字节数(预留 19 字节给下一个样本),避免单块过大影响读写效率。当当前块大小超过此阈值时,自动切割新块。
- 乱序样本处理
- 若当前无内存块,且样本时间戳已存在于磁盘映射块中,直接忽略样本。
- 若当前内存块的最大时间 ≥ 样本时间,标记为乱序样本(sampleInOrder=false)。
- 编码格式适配
- 当待追加样本的编码(如 XOR、Histogram)与当前块的编码不一致时,切割新块以匹配编码,保证单块内样本格式统一。
- 动态块切割策略
- 初始切割:新块创建时,根据时间范围计算 nextAt(预计结束时间)。
- 动态调整:当样本数达到预期的 25% 时,重新计算 nextAt,使样本在时间范围内均匀分布。
- 强制切割:若样本时间超过 nextAt 或样本数过多(超过预期 2 倍),强制切割新块,适应样本率变化。
- 并发安全
- 方法注释明确指出,需持有序列锁才能与迭代器并发调用,避免数据竞争。
- 块大小控制
4.2.4、内存映射过程
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head_append.go#L1925
// mmapChunks 将 s.headChunks 链表中除第一个第一个块之外的所有块映射到内存(mmap)
// 返回被映射的块数量
func (s *memSeries) mmapChunks(chunkDiskMapper *chunks.ChunkDiskMapper) (count int) {
// 如果没有头块(headChunks),或者头块没有前序块(即只有一个头块),则无需映射
if s.headChunks == nil || s.headChunks.prev == nil {
// 没有块或只有一个头块,无需执行映射操作
return
}
// 从最旧的块开始写入磁盘,直到当前 s.headChunks 之前的块(不包含当前头块)
// 例如:若链表为 s.headChunks{t4} -> t3 -> t2 -> t1 -> t0
// 则需要写入 t0 到 t3,跳过 s.headChunks(t4)
// 循环从链表长度-1开始(最旧的块),到 i > 0 结束(跳过第一个块,即当前头块)
for i := s.headChunks.len() - 1; i > 0; i-- {
// 获取当前索引位置的块(通过偏移量定位)
chk := s.headChunks.atOffset(i)
// 将块写入磁盘,并获取磁盘映射引用(chunkRef)
// 参数:系列引用、块的最小时间、最大时间、块数据、是否为乱序块、错误处理函数
chunkRef := chunkDiskMapper.WriteChunk(s.ref, chk.minTime, chk.maxTime, chk.chunk, false, handleChunkWriteError)
// 将新映射的块添加到 mmappedChunks 切片(存储磁盘映射块的元数据)
s.mmappedChunks = append(s.mmappedChunks, &mmappedChunk{
ref: chunkRef, // 磁盘映射引用
numSamples: uint16(chk.chunk.NumSamples()), // 块中的样本数量
minTime: chk.minTime, // 块的最小时间戳
maxTime: chk.maxTime, // 块的最大时间戳
})
// 递增映射计数
count++
}
// 完成所有非头块的映射后,将头块的前序指针置空(断开与已映射块的链接)
// 此时 headChunks 仅保留当前活跃的头块,旧块已迁移到磁盘映射层
s.headChunks.prev = nil
// 返回映射的块数量
return count
}
4.3、乱序数据处理机制
4.3.1、乱序数据存储结构
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L2194
// memSeriesOOOFields 包含 memSeries 处理乱序(out-of-order)数据所需的字段
type memSeriesOOOFields struct {
oooMmappedChunks []*mmappedChunk // 磁盘上的不可变块,包含乱序样本
oooHeadChunk *oooHeadChunk // 内存中最新的乱序样本块,仍在构建中(用于临时存储新的乱序样本)
firstOOOChunkID chunks.HeadChunkID // oooMmappedChunks[0] 对应的头部乱序块ID(用于标识和索引第一个磁盘映射的乱序块)
}
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head.go#L2359
// oooHeadChunk 表示内存中用于存储乱序(out-of-order)样本的头部块
type oooHeadChunk struct {
chunk *OOOChunk // 实际存储乱序样本的块,内部以升序排列样本
minTime int64 // 块中样本的最小时间戳
maxTime int64 // 块中样本的最大时间戳
}
4.3.2、OOOChunk 核心实现
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/ooo_head.go#L27
// OOOChunk 用于维护按时间升序排列的样本(OOO 即 out-of-order,乱序)
// 对于已存在的时间戳,插入操作会被丢弃(不允许重复时间戳的样本)
// 样本以未压缩的形式存储,以便于快速排序
// 未来可能会有更高效的实现方式
type OOOChunk struct {
samples []sample // 存储样本的切片,内部样本按时间戳升序排列
}
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/ooo_head.go#L37
// Insert 插入样本并保持时间升序排列
// 如果因时间戳已存在导致插入失败,返回 false
func (o *OOOChunk) Insert(t int64, v float64, h *histogram.Histogram, fh *histogram.FloatHistogram) bool {
// 尽管乱序样本之间间可能无序,但通常倾向于按顺序到达,因此先尝试直接追加到末尾(如果新时间戳大于最后一个已知时间戳)
if len(o.samples) == 0 || t > o.samples[len(o.samples)-1].t {
// 样本切片为空或新时间戳最大,直接追加到末尾
o.samples = append(o.samples, sample{t, v, h, fh})
return true
}
// 查找应插入的位置(第一个时间戳大于等于当前 t 的样本索引)
i := sort.Search(len(o.samples), func(i int) bool { return o.samples[i].t >= t })
if i >= len(o.samples) {
// 未找到符合条件的样本(所有样本时间戳都小于 t),追加到末尾
o.samples = append(o.samples, sample{t, v, h, fh})
return true
}
// 不允许插入时间戳重复的样本
if o.samples[i].t == t {
return false
}
// 扩展切片长度以腾出空间(先追加一个空样本,后续会覆盖)
o.samples = append(o.samples, sample{})
// 将索引 i 及之后的样本向后移动一位
copy(o.samples[i+1:], o.samples[i:])
// 在索引 i 处插入新样本
o.samples[i] = sample{t, v, h, fh}
return true
}
4.3.3、乱序数据插入流程
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head_append.go#L1520
// insert 类似于 append 方法,但用于插入操作,专门处理乱序(OOO)样本
// 返回值分别为:插入是否成功、是否创建了新块、内存映射引用列表
func (s *memSeries) insert(t int64, v float64, h *histogram.Histogram, fh *histogram.Histogram, chunkDiskMapper *chunks.ChunkDiskMapper, oooCapMax int64, logger *slog.Logger) (inserted, chunkCreated bool, mmapRefs []chunks.ChunkDiskMapperRef) {
// 如果乱序字段结构未初始化,则创建一个新的
if s.ooo == nil {
s.ooo = &memSeriesOOOFields{}
}
// 获取当前乱序头部块(用于存储新的乱序样本)
c := s.ooo.oooHeadChunk
// 如果头部块不存在,或当前头部块的样本数已达到最大容量,则创建新的乱序头部块
if c == nil || c.chunk.NumSamples() == int(oooCapMax) {
// 注意:如果没有新样本进入,我们依赖压缩过程来清理内存中过期的乱序块
c, mmapRefs = s.cutNewOOOHeadChunk(t, chunkDiskMapper, logger)
chunkCreated = true // 标记新块已创建
}
// 尝试将样本插入到当前乱序头部块中
ok := c.chunk.Insert(t, v, h, fh)
if ok {
// 如果插入成功,更新块的最小/最大时间戳
// 若为新创建的块,或当前时间戳小于块的最小时间,则更新最小时间
if chunkCreated || t < c.minTime {
c.minTime = t
}
// 若为新创建的块,或当前时间戳大于块的最大时间,则更新最大时间
if chunkCreated || t > c.maxTime {
c.maxTime = t
}
}
// 返回插入结果、是否创建新块以及内存映射引用列表
return ok, chunkCreated, mmapRefs
}
4.3.4、乱序 Chunk ID 管理
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/head_read.go#L248
// oooChunkIDMask 是用于标识乱序(OOO)块的掩码,值为 1 << 23(即 8388608)
const oooChunkIDMask = 1 << 23
// oooHeadChunkID 返回由给定位置引用的 HeadChunkID(头部块 ID)
// 仅使用低 24 位。对于乱序块,第 23 位始终为 1;其余规则如下:
// * 当 0 <= pos < len(s.oooMmappedChunks) 时,引用 s.oooMmappedChunks[pos](磁盘映射的乱序块)
// * 当 pos == len(s.oooMmappedChunks) 时,引用 s.oooHeadChunk(内存中活跃的乱序头部块)
// 调用者必须确保 s.ooo 不为 nil
func (s *memSeries) oooHeadChunkID(pos int) chunks.HeadChunkID {
// 计算方式:将位置 pos 与第一个乱序块 ID(firstOOOChunkID)相加,再与 oooChunkIDMask 按位或
// 作用是生成带有乱序标识的 HeadChunkID,同时通过 pos 定位具体的乱序块
return (chunks.HeadChunkID(pos) + s.ooo.firstOOOChunkID) | oooChunkIDMask
}
// ChunkID 解析:
// - 普通 chunk: 第23位为0
// - 乱序 chunk: 第23位为1
4.3.5、乱序数据转换为编码 Chunks
https://github.com/prometheus/prometheus/blob/v3.4.0/tsdb/ooo_head.go#L76
// ToEncodedChunks 将 OOOChunk 中的样本转换为编码后的块(chunks)
//
//nolint:revive
func (o *OOOChunk) ToEncodedChunks(mint, maxt int64) (chks []memChunk, err error) {
// 如果没有样本,返回空结果
if len(o.samples) == 0 {
return nil, nil
}
// 最常见的情况是只有一个块,且块中样本类型相同(浮点数样本一定满足此条件)
chks = make([]memChunk, 0, 1)
var (
cmint int64 // 当前块的最小时间戳
cmaxt int64 // 当前块的最大时间戳
chunk chunkenc.Chunk // 当前正在构建的编码块
app chunkenc.Appender // 当前块的追加器(用于写入样本)
)
// 记录上一个样本的编码类型(初始为 EncNone,比直接从块中获取更高效)
prevEncoding := chunkenc.EncNone
// 遍历所有样本,按时间范围过滤并构建编码块
for _, s := range o.samples {
// 跳过时间戳小于 mint 的样本
if s.t < mint {
continue
}
// 时间戳大于 maxt 时,后续样本时间戳更大(因 OOOChunk 样本按时间升序),可直接退出循环
if s.t > maxt {
break
}
// 确定当前样本的编码类型
encoding := chunkenc.EncXOR // 默认是浮点数编码(XOR)
if s.h != nil {
encoding = chunkenc.EncHistogram // 直方图样本
} else if s.fh != nil {
encoding = chunkenc.EncFloatHistogram // 浮点直方图样本
}
// 保存上一个样本的追加器(用于直方图类型的连续性检查)
prevApp := app
// 如果当前编码类型与上一个不同,需要创建新块
if encoding != prevEncoding {
// 若上一个块已存在(非初始状态),将其添加到结果列表
if prevEncoding != chunkenc.EncNone {
chks = append(chks, memChunk{chunk, cmint, cmaxt, nil})
}
// 初始化新块的最小时间戳为当前样本时间戳
cmint = s.t
// 根据编码类型创建对应的新块
switch encoding {
case chunkenc.EncXOR:
chunk = chunkenc.NewXORChunk()
case chunkenc.EncHistogram:
chunk = chunkenc.NewHistogramChunk()
case chunkenc.EncFloatHistogram:
chunk = chunkenc.NewFloatHistogramChunk()
default:
chunk = chunkenc.NewXORChunk() // 默认为 XOR 编码块
}
// 获取新块的追加器,用于写入样本
app, err = chunk.Appender()
if err != nil {
return // 若获取追加器失败,返回错误
}
}
// 根据编码类型将样本写入当前块
switch encoding {
case chunkenc.EncXOR:
// 浮点数样本直接追加
app.Append(s.t, s.f)
case chunkenc.EncHistogram:
// 直方图样本:转换上一个追加器为直方图追加器(忽略类型转换失败的情况)
prevHApp, _ := prevApp.(*chunkenc.HistogramAppender)
var (
newChunk chunkenc.Chunk // 可能创建的新块
recoded bool // 标记是否因格式兼容重新编码
)
// 追加直方图样本,可能返回新块(如因格式不兼容需要拆分)
newChunk, recoded, app, _ = app.AppendHistogram(prevHApp, s.t, s.h, false)
// 若创建了新块
if newChunk != nil {
// 若不是因重新编码导致的新块,需将当前块添加到结果列表,并更新当前块的最小时间戳
if !recoded {
chks = append(chks, memChunk{chunk, cmint, cmaxt, nil})
cmint = s.t
}
// 更新当前块为新创建的块
chunk = newChunk
}
case chunkenc.EncFloatHistogram:
// 浮点直方图样本:转换上一个追加器为浮点直方图追加器(忽略类型转换失败的情况)
prevHApp, _ := prevApp.(*chunkenc.FloatHistogramAppender)
var (
newChunk chunkenc.Chunk // 可能创建的新块
recoded bool // 标记是否因格式兼容重新编码
)
// 追加浮点直方图样本,可能返回新块(如因格式不兼容需要拆分)
newChunk, recoded, app, _ = app.AppendFloatHistogram(prevHApp, s.t, s.fh, false)
// 若创建了新块
if newChunk != nil {
// 若不是因重新编码导致的新块,需将当前块添加到结果列表,并更新当前块的最小时间戳
if !recoded {
chks = append(chks, memChunk{chunk, cmint, cmaxt, nil})
cmint = s.t
}
// 更新当前块为新创建的块
chunk = newChunk
}
}
// 更新当前块的最大时间戳为当前样本时间戳
cmaxt = s.t
// 更新上一个编码类型为当前类型
prevEncoding = encoding
}
// 处理最后一个未添加到结果列表的块
if prevEncoding != chunkenc.EncNone {
chks = append(chks, memChunk{chunk, cmint, cmaxt, nil})
}
return chks, nil
}

浙公网安备 33010602011771号