VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— prompb 协议缓冲区:RemoteWrite Proto 定义
VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— prompb 协议缓冲区:RemoteWrite Proto 定义
prompbprotobufWriteRequestTimeSeriesNative Histogrameasyproto对象池Component Deep-Dive
lib/prompb/prompb.go ← WriteRequest、TimeSeries、Sample、Label、MetricMetadata 定义
lib/prompb/write_request_unmarshaler.go ← WriteRequestUnmarshaler 池化 + protobuf 手工解析
lib/prompb/marshal.go ← protobuf 序列化(写入方向)
lib/prompb/fmt_buffer.go ← 格式化缓冲区(原生 histogram vmrange 命名)
上篇讲了协议接入层的宏观架构:从 HTTP 路径到 InsertCtx 的五段式处理。数据真正流入 vminsert 后,第一个要过的关卡就是 prompb(Prometheus Protocol Buffers)—— Prometheus Remote Write 协议的 wire format。vmagent、Prometheus、Grafana Agent 发来的请求体,全都是这种二进制 protobuf 编码。本篇把 lib/prompb/ 的源码翻到底:看看 VM 是怎么定义数据结构、如何手工解析(没用 protobuf 官方库)、以及 Native Histogram 是怎么被展开成普通 TimeSeries 的。
本文学习重点
★ 必背(must):
- WriteRequest 的三层嵌套:WriteRequest → TimeSeries[] → Label[] + Sample[](或 Histogram[]),共 5 个核心类型
- 手工解析不走 protobuf 官方库:VM 用 easyproto.FieldContext.NextField 自己写解析循环,完全避免 proto.Unmarshal 的内存分配
- 对象池两级复用:WriteRequestUnmarshaler 池化 + nativeHistogramContext 池化,避免 GC 压力
- Native Histogram 的展开逻辑:一个 Histogram message 展开成 _count + _sum + _bucket 三条 TimeSeries
★ 了解(know):
- MetricType 枚举:8 种 Prometheus metric 类型(UNKNOWN/COUNTER/GAUGE/HISTOGRAM/GAUGEHISTOGRAM/SUMMARY/INFO/STATESET)
- 多租户字段:AccountID + ProjectID 出现在 MetricMetadata 里,是 VM 的扩展而非 Prometheus 标准字段
- BucketSpan offset 机制:sint32 offset 是相对于前一个 bucket 的累积偏移,用 RLE 压缩桶序列
本文章节
一、prompb 目录结构:8 个 Go 文件各司其职
思考记忆提示 — 本节建立 prompb 包的全局认知:8 个文件各自负责什么。
- 要点一:prompb.go 放所有数据类型定义(无逻辑,只有 struct)
- 要点二:write_request_unmarshaler.go 是核心:652 行手工 protobuf 解析
- 要点三:VM 不生成 .pb.go 文件,用手工解析替代——这是性能优先的设计选择
先看 lib/prompb/ 的文件列表:
lib/prompb/
prompb.go ← ★ 核心数据定义(WriteRequest、TimeSeries、Sample、Label、MetricMetadata)
write_request_unmarshaler.go ← ★★ 核心解析逻辑(700 行,池化 + 手工 protobuf)
marshal.go ← 序列化(WriteRequest → protobuf bytes,写入方向)
fmt_buffer.go ← 格式化 buffer(Histogram → vmrange 标签名)
write_request_unmarshaler_test.go ← 测试
prompb_test.go ← 测试
prompb_timing_test.go ← 性能基准测试
write_request_unmarshaler_timing_test.go ← 解析性能基准测试
注意一个关键设计点:VM 没有用 protoc 生成的 .pb.go 文件。Prometheus 官方用 protoc 生成 types.pb.go,而 VM 从头手写了所有数据结构和解析逻辑。这样做有三个原因:
- 避免 proto.Unmarshal 的内存分配:官方库每次解析都会分配新 slice;VM 的池化方案复用底层数组
- 没有 protoc 依赖:Go 项目里少一个 codegen 步骤,CI 更快
- 完全掌控 wire format 解析:Native Histogram 的展开逻辑需要深度定制,官方库不支持
设计精髓:为什么叫 prompb 而不是 protobuf?
VM 的包名是 prompb 而非 protobuf,因为它只实现了 Prometheus Remote Write 协议里用到的字段子集——没有全覆盖 protobuf 规范。具体说,它只解析 WriteRequest 相关的 message(TimeSeries、Label、Sample、Histogram、Metadata),不解析其他 Prometheus proto message(如 QueryResult、LabelMatcher 等)。"pb"是 "protocol buffers" 的缩写,"prompb" = Prometheus protocol buffers(特定子集)。
本节必记闭环逻辑(核心考点)
prompb 包 = 8 个 Go 文件(4 个核心 + 4 个测试):定义(prompb.go)+ 解析(write_request_unmarshaler.go)+ 序列化(marshal.go)+ 格式化(fmt_buffer.go),外加 4 个测试文件(含 2 个 _timing_test.go 微基准)。VM 走手工路线是为了绕过官方库的内存分配 + 定制 Native Histogram 展开逻辑。
二、WriteRequest 三层嵌套结构:接口设计的精与简
思考记忆提示 — 本节拆解 WriteRequest 的嵌套结构。这是 Prometheus Remote Write 协议的核心 wire format。
- 要点一:WriteRequest 顶层只有两个字段——Timeseries 和 Metadata
- 要点二:TimeSeries 包含 Labels + Samples(或 Histograms,两者互斥)
- 要点三:Sample = Value (float64) + Timestamp (int64 ms)
先看 lib/prompb/prompb.go 的核心类型定义,用源码引用:
关键源码引用 1:lib/prompb/prompb.go 第 10-49 行,核心数据类型
// WriteRequest represents Prometheus remote write API request.
type WriteRequest struct {
// Timeseries is a list of time series in the given WriteRequest
Timeseries []TimeSeries
// Metadata is a list of metadata info in the given WriteRequest
Metadata []MetricMetadata
}
// Reset resets wr for subsequent reuse.
func (wr *WriteRequest) Reset() {
wr.Timeseries = ResetTimeSeries(wr.Timeseries)
wr.Metadata = ResetMetadata(wr.Metadata)
}
// TimeSeries is a timeseries.
type TimeSeries struct {
// Labels is a list of labels for the given TimeSeries
Labels []Label
// Samples is a list of samples for the given TimeSeries
Samples []Sample
}
// Sample is a timeseries sample.
type Sample struct {
// Value is sample value.
Value float64
// Timestamp is unix timestamp for the sample in milliseconds.
Timestamp int64
}
// Label is a timeseries label.
type Label struct {
// Name is label name.
Name string
// Value is label value.
Value string
}
三句话总结这个结构:一个 WriteRequest 包含多条 TimeSeries,每条 TimeSeries 包含一组 Label 和一组 Sample。这就是 Prometheus Remote Write 协议的 wire format 本质。
注意 TimeSeries 结构里的注释:samples or native histograms, but not both。在 protobuf 层面,TimeSeries 有三个 repeated 字段:
- repeated Label labels = 1
- repeated Sample samples = 2
- repeated Histogram histograms = 4
字段 1 和 2 搭配 = 经典样本;字段 1 和 4 搭配 = 原生直方图。字段 3 是 reserved(历史原因占位),字段 5 也是 reserved。这个设计在 write_request_unmarshaler.go 第 181-183 行有校验:
关键源码引用 2:lib/prompb/write_request_unmarshaler.go 第 181-183 行,samples/histograms 互斥校验
if len(samples) > 0 && len(histograms) > 0 {
return tss, labelsPool, samplesPool, fmt.Errorf(
"cannot have both samples and native histograms in the same TimeSeries")
}
VM 对"一个 TimeSeries 不能同时包含 samples 和 histograms"的校验,是协议级别的防护。这条约束来自 Prometheus 官方 spec,不符合规范的数据 VM 会直接报错而不是静默处理。
2.1 IsEmpty 与 Reset:WriteRequest 的生命周期管理
prompb.go 里还定义了两个实用方法:
// IsEmpty checks if the WriteRequest has data to push.
func (m *WriteRequest) IsEmpty() bool {
return m == nil || (len(m.Timeseries) == 0 && len(m.Metadata) == 0)
}
// Reset resets wr for subsequent reuse.
func (wr *WriteRequest) Reset() {
wr.Timeseries = ResetTimeSeries(wr.Timeseries)
wr.Metadata = ResetMetadata(wr.Metadata)
}
Reset() 复用底层数组(通过 ResetTimeSeries 和 ResetMetadata 实现)。IsEmpty() 则在调用侧做短路——空请求不需要走后续处理链路。
2.2 LabelsToString:调试友好的字符串化
prompb.go 第 51-73 行还定义了一个调试方法:
关键源码引用 3:lib/prompb/prompb.go 第 52-73 行,LabelsToString
// LabelsToString:[]Label → Prometheus 格式字符串(调试友好)
func LabelsToString(labels []Label) string {
labelsCopy := append([]Label{}, labels...) // ① 操作副本,不影响原始 pool
sort.Slice(labelsCopy, func(i, j int) bool { // ② 标签按 name 排序,保证输出一致性
return string(labelsCopy[i].Name) < string(labelsCopy[j].Name)
})
var b []byte
b = append(b, '{')
for i, label := range labelsCopy {
if len(label.Name) == 0 {
b = append(b, "__name__"...) // ③ 空 name 替换为 __name__
} else {
b = append(b, label.Name...)
}
b = append(b, '=')
b = strconv.AppendQuote(b, label.Value) // ④ 自动加双引号、转义特殊字符
if i < len(labels)-1 {
b = append(b, ',')
}
}
b = append(b, '}')
return string(b)
}
这个方法在调试时把 []Label 转成 Prometheus 格式字符串(如 {__name__="http_requests_total",method="GET"})。注意两个细节:
- 标签排序:先把标签按 name 排序(确保相同 metric 的字符串表示始终一致)
- 空 name 替换:如果 label.Name == "",输出 __name__(Prometheus 惯例:metric 名存成名为 __name__ 的标签)
- 值用引号包裹:strconv.AppendQuote 自动加双引号,转义特殊字符
本节必记闭环逻辑(核心考点)
WriteRequest 三层:WriteRequest → []TimeSeries → ([]Label, []Sample) 或 ([]Label, []Histogram)。每个类型的职责非常清晰:WriteRequest 是容器、TimeSeries 是一组同标签的样本、Label 是键值对、Sample 是 (ts, value)。注意 TimeSeries 里 samples 和 histograms 互斥,校验在解析阶段强制执行。
三、WriteRequestUnmarshaler:sync.Pool + 手工 protobuf 解析
思考记忆提示 — 本节是全篇最核心的源码解读:VM 如何用 700 行手工代码实现 protobuf 解析 + 池化。
- 要点一:WriteRequestUnmarshaler 是一个可复用的解析器,包含自己的 buffer 池
- 要点二:解析不调用 proto.Unmarshal,用 easyproto.FieldContext.NextField 自己遍历字段
- 要点三:UnmarshalProtobuf 返回的 *WriteRequest 生命周期很短(下次调用即失效)
3.1 为什么不用 protobuf 官方库?
Go 官方库 github.com/golang/protobuf/proto 的 Unmarshal 每次调用都会:
- 分配新的 []byte slice 作为 buffer
- 递归展开嵌套 message,每次 append() 都可能触发新分配
- 返回的 struct 完全独立于输入 []byte
在 100 万 samples/s 的写入压力下,每秒可能来几万次 WriteRequest,每秒触发几万次新分配 —— GC 压力巨大。VM 的解法是把整个 Unmarshaler 对象池化,复用内部所有 buffer。
3.2 对象池的两级结构
先看池的外部定义:
关键源码引用 4:lib/prompb/write_request_unmarshaler.go 第 14-28 行,池定义
// ① Get:从 pool 获取 Unmarshaler,nil 则新建
func GetWriteRequestUnmarshaler() *WriteRequestUnmarshaler {
v := wruPool.Get() // ② sync.Pool.Get() 可能返回 nil
if v == nil {
return &WriteRequestUnmarshaler{} // 首次使用,冷启动
}
return v.(*WriteRequestUnmarshaler)
}
// ③ Put:归还前必须 Reset(清空 pool 状态)
func PutWriteRequestUnmarshaler(wru *WriteRequestUnmarshaler) {
wru.Reset() // ④ 否则脏数据会泄露给下一个使用者
wruPool.Put(wru)
}
var wruPool sync.Pool // ⑤ 全局无锁池,GC 时清空
sync.Pool 是 Go 标准库的"无锁对象池",在 GC 时自动清空。关键点:Put 前必须 Reset——否则下次 Get 到的对象还保留着上次的数据。
再看 Unmarshaler 自身的结构:
关键源码引用 5:lib/prompb/write_request_unmarshaler.go 第 36-42 行,WriteRequestUnmarshaler 结构
// WriteRequestUnmarshaler is reusable unmarshaler for WriteRequest protobuf messages.
//
// It maintains internal pools for labels and samples to reduce memory allocations.
type WriteRequestUnmarshaler struct {
wr WriteRequest
labelsPool []Label
samplesPool []Sample
fb fmtBuffer
}
三级 buffer 各自独立池化:
- wr.WriteRequest:整个 WriteRequest 复用
- labelsPool []Label:Label slice 复用(每条 TimeSeries 的标签数不同)
- samplesPool []Sample:Sample slice 复用(每条 TimeSeries 的样本数不同)
- fb fmtBuffer:格式化 buffer(用于原生 histogram 的 vmrange 命名)
3.3 UnmarshalProtobuf:主解析循环
主解析函数只有 60 行,逻辑非常清晰:
关键源码引用 6:lib/prompb/write_request_unmarshaler.go 第 66-117 行,UnmarshalProtobuf(注意:函数从第 66 行起,到第 117 行 `}` 结束,不含 118 行)
// ① UnmarshalProtobuf:主解析入口,返回内部指针(下次调用前有效)
func (wru *WriteRequestUnmarshaler) UnmarshalProtobuf(src []byte) (*WriteRequest, error) {
wru.Reset() // Reset 清空上次的 pool 状态
var err error
// message WriteRequest {
// repeated TimeSeries timeseries = 1;
// reserved 2;
// repeated Metadata metadata = 3;
// }
// ② 提取 pool 局部变量,避免每次 append 访问结构体字段
tss := wru.wr.Timeseries
mds := wru.wr.Metadata
labelsPool := wru.labelsPool
samplesPool := wru.samplesPool
// ③ easyproto.FieldContext:字段上下文,保存当前解析状态
var fc easyproto.FieldContext
for len(src) > 0 { // ④ 循环直到 src 被消费完
src, err = fc.NextField(src) // 自动推进 src,返回下一字段的剩余数据
if err != nil {
return nil, fmt.Errorf("cannot read the next field: %w", err)
}
// ⑤ FieldNum = protobuf 字段编号(1=timeseries, 3=metadata)
switch fc.FieldNum {
case 1:
data, ok := fc.MessageData()
if !ok {
return nil, fmt.Errorf("cannot read timeseries data")
}
tss, labelsPool, samplesPool, err = unmarshalTimeSeries(
data, tss, labelsPool, samplesPool, &wru.fb)
if err != nil {
return nil, fmt.Errorf("cannot unmarshal timeseries: %w", err)
}
case 3:
data, ok := fc.MessageData()
if !ok {
return nil, fmt.Errorf("cannot read metricMetadata data")
}
if len(mds) < cap(mds) {
mds = mds[:len(mds)+1]
} else {
mds = append(mds, MetricMetadata{})
}
md := &mds[len(mds)-1]
if err := md.unmarshalProtobuf(data); err != nil { // 解析 Metadata
return nil, fmt.Errorf("cannot unmarshal metricMetadata: %w", err)
}
}
}
// ⑧ 最后写回结构体字段(非局部变量,下次调用时被 Reset)
wru.wr.Timeseries = tss
wru.wr.Metadata = mds
wru.labelsPool = labelsPool
wru.samplesPool = samplesPool
return &wru.wr, nil // ⑨ 返回内部指针,下次调用前有效
}
这段代码的核心思想:用 easyproto.FieldContext 遍历 protobuf wire format 的 tag-length-value 序列。每个字段通过 fc.FieldNum 识别字段编号(1=timeseries, 3=metadata),通过 fc.MessageData() 提取该字段的二进制内容,再递归解析。
注意池化的关键技巧:在 for 循环内用局部变量 tss、labelsPool、samplesPool 操作,最后统一写回结构体字段。这避免了每次 append 时都访问结构体字段(减少指针解引用),也让 Reset 逻辑更干净。
3.4 解析时的 cap/len 技巧:append 复用的核心
在 write_request_unmarshaler.go 的多处解析逻辑里,都能看到这个模式:
if len(labelsPool) < cap(labelsPool) {
labelsPool = labelsPool[:len(labelsPool)+1]
} else {
labelsPool = append(labelsPool, Label{})
}
label := &labelsPool[len(labelsPool)-1]
这句话翻译成白话:先用完已分配的容量(cap - len),容量不够了才 append 扩展。第一次请求过来,slice 可能是空的(cap=0),那就 append 一次。第二次请求来,cap 可能已经到 32 了,就直接复用 [:cap] 的空间。每次 Reset() 只是把 len 截断到 0,底层数组不变。
prompb 的手工解析 + 池化设计,本质上是用 Go 的 slice cap 机制替代了 protobuf 官方库的内存分配。我们直接读源码里的关键几行就能理解:
源码视角一:easyproto.FieldContext 是 protobuf 解析的"低层 API"
读 lib/prompb/write_request_unmarshaler.go 第 81-85 行,会发现整个解析循环只有一个模式:
var fc easyproto.FieldContext
for len(src) > 0 {
src, err = fc.NextField(src)
// ...
switch fc.FieldNum {
case 1: data, ok := fc.MessageData()
case 2: value, ok := fc.Double()
case 3: value, ok := fc.Int64()
case 4: value, ok := fc.String()
case 5: value, ok := fc.Uint64()
// ...
}
}
easyproto.FieldContext 是 VM 团队开源的轻量级 protobuf 解析库(独立 module github.com/VictoriaMetrics/easyproto,通过 go.mod 引入,不在本 monorepo 中)。它不生成 .pb.go,而是提供原始的 tag-wire 遍历能力。FieldNum 对应 protobuf 的字段编号,MessageData/Double/Int64/String 等方法对应 wire type。这是对 protobuf wire format 的直接操作,比 proto.Unmarshal 底层一层。
源码视角二:Reset 复用底层数组的关键——clear() + [:0] 而非 = nil
读 lib/prompb/write_request_unmarshaler.go 第 45-55 行的 Reset:
func (wru *WriteRequestUnmarshaler) Reset() {
wru.wr.Reset()
clear(wru.labelsPool)
wru.labelsPool = wru.labelsPool[:0]
clear(wru.samplesPool)
wru.samplesPool = wru.samplesPool[:0]
wru.fb.reset()
}
注意这里用的是 clear(slice) + slice[:0] 而不是 slice = nil。clear() 在 Go 1.21+ 会把每个元素清零(释放引用),[:0] 截断 len 但保留 cap。下次 append 时直接用已有的底层数组,零分配!
源码视角三:nativeHistogramContext 是第二个池(histogram 专用)
读 lib/prompb/write_request_unmarshaler.go 第 567-574 行:
func getNativeHistogramContext() *nativeHistogramContext {
v := nhctxPool.Get()
if v == nil {
return &nativeHistogramContext{}
}
return v.(*nativeHistogramContext)
}
func putNativeHistogramContext(nhctx *nativeHistogramContext) {
nhctx.reset()
nhctxPool.Put(nhctx)
}
var nhctxPool sync.Pool
这是第二个 sync.Pool,专门给 Native Histogram 的中间计算用。Native Histogram 的展开涉及大量 float/int 数组(positive_deltas、negative_deltas、positive_counts 等),每条 histogram 的桶数不同。如果每次都重新分配,GC 压力不小。池化复用让同规格的 histogram 复用同一批数组。
源码视角总结:prompb 的"零分配解析"三层设计
- 第一层:WriteRequestUnmarshaler 池:整个解析器对象复用,Get/Put 成对
- 第二层:内部 slice 池:labelsPool、samplesPool,用 cap/len 技巧避免分配
- 第三层:nativeHistogramContext 池:histogram 专用中间状态复用
三层都靠 sync.Pool + clear() + [:0] 实现。这是 VM 在高性能路径上的标准技巧:复用 > 分配 > GC。
避坑提醒(源码视角):
- 不要在 UnmarshalProtobuf 返回的 *WriteRequest 上做异步引用:返回值指向 Unmarshaler 内部的 &wru.wr,下次调用 UnmarshalProtobuf 时这个指针指向的数据会被覆盖(Reset 清空)。如果需要持久化,必须深拷贝
- 不要跳过 fc.MessageData() 的 ok 判断:如果 field 的 wire type 不匹配,MessageData() 返回 ok=false,跳过会导致数据错位
- 不要把 labelsPool 和 samplesPool 混用:它们的 cap 增长规律不同(标签数通常少,样本数通常多),分开池化才能各自达到最优复用率
本节必记闭环逻辑(核心考点)
Unmarshaler 池化 = 两层 sync.Pool:WriteRequestUnmarshaler 池 + nativeHistogramContext 池。手工解析走 easyproto.FieldContext,不用 proto.Unmarshal。核心技巧:cap/len 复用 + clear() + [:0]。注意返回的 *WriteRequest 在下次调用时失效。
四、Label 与 Sample:最底层的两个原子类型
思考记忆提示 — 本节拆解 Label 和 Sample 的 protobuf wire format 解析——这是整个 prompb 解析链的最底层。
- 要点一:Label = name (string) + value (string);Sample = value (double) + timestamp (int64)
- 要点二:Label 解析在 unmarshalTimeSeries 内聚合一组 label 到 baseLabels
- 要点三:Sample 解析后立刻加入 samplesPool
4.1 Label 的 protobuf wire format
从 protobuf 定义来看,Label message 很简单:
// message Label {
// string name = 1;
// string value = 2;
// }
再看 VM 的手工解析实现:
关键源码引用 7:lib/prompb/write_request_unmarshaler.go 第 582-610 行,Label unmarshal
// unmarshalProtobuf:逐字段解析 Label message(field 1=name, field 2=value)
func (lbl *Label) unmarshalProtobuf(src []byte) (err error) {
var fc easyproto.FieldContext // ① 字段上下文
for len(src) > 0 { // ② 循环直到 src 被消费完
src, err = fc.NextField(src) // ③ 推进 src,返回下一字段
if err != nil {
return fmt.Errorf("cannot read the next field: %w", err)
}
// ④ field 1 = name (string), field 2 = value (string)
switch fc.FieldNum {
case 1:
name, ok := fc.String() // ⑤ 读取 wire type=2 (Length-delimited) 的 string
if !ok {
return fmt.Errorf("cannot read label name")
}
lbl.Name = name
case 2:
value, ok := fc.String()
if !ok {
return fmt.Errorf("cannot read label value")
}
lbl.Value = value
}
// ⑥ field 缺失时保持零值(protobuf optional 行为)
}
return nil
}
逐字段解析:fc.String() 读取 wire type=2(Length-delimited)的 string 值。如果一个字段在 wire 上缺失(optional 行为),循环直接跳过,结构体字段保持零值——这是 protobuf 的正常行为。
4.2 Sample 的 protobuf wire format
Sample message 定义:
// message Sample {
// double value = 1;
// int64 timestamp = 2;
// }
手工解析:
关键源码引用 8:lib/prompb/write_request_unmarshaler.go 第 611-639 行,Sample unmarshal
// unmarshalProtobuf:逐字段解析 Sample message(field 1=double value, field 2=varint timestamp)
func (s *Sample) unmarshalProtobuf(src []byte) (err error) {
var fc easyproto.FieldContext // ① 字段上下文
for len(src) > 0 { // ② 循环直到 src 被消费完
src, err = fc.NextField(src) // ③ 推进 src
if err != nil {
return fmt.Errorf("cannot read the next field: %w", err)
}
// ④ field 1 = double (wire type=1), field 2 = int64 (wire type=0, varint)
switch fc.FieldNum {
case 1:
value, ok := fc.Double() // ⑤ 读取 wire type=1 (64-bit) 的 double
if !ok {
return fmt.Errorf("cannot read sample value")
}
s.Value = value
case 2:
timestamp, ok := fc.Int64() // ⑥ 读取 wire type=0 (varint) 的 int64
if !ok {
return fmt.Errorf("cannot read sample timestamp")
}
s.Timestamp = timestamp
}
}
return nil
}
fc.Double() 读取 wire type=1(64-bit)的 double 值;fc.Int64() 读取 wire type=0(Varint)的 int64 值。两者都是 easyproto 库提供的类型安全读取,ok 返回 false 表示 wire 数据不符合预期类型(数据损坏或格式不匹配)。
4.3 TimeSeries 解析:批量 label + 批量 sample
TimeSeries 的解析最复杂,因为它要同时处理 label 数组和 sample 数组:
关键源码引用 9:lib/prompb/write_request_unmarshaler.go 第 122-199 行,unmarshalTimeSeries 主体
func unmarshalTimeSeries(src []byte, tss []TimeSeries,
labelsPool []Label, samplesPool []Sample,
fb *fmtBuffer) ([]TimeSeries, []Label, []Sample, error) {
labelsPoolLen := len(labelsPool)
samplesPoolLen := len(samplesPool)
var histograms [][]byte
var fc easyproto.FieldContext
var err error
// message TimeSeries {
// repeated Label labels = 1;
// repeated Sample samples = 2;
// repeated Histogram histograms = 4
// }
for len(src) > 0 {
src, err = fc.NextField(src)
if err != nil {
return tss, labelsPool, samplesPool, fmt.Errorf("cannot read the next field: %w", err)
}
switch fc.FieldNum {
case 1: // repeated Label
data, ok := fc.MessageData()
// ... parse 1 label into labelsPool ...
label := &labelsPool[len(labelsPool)-1]
if err := label.unmarshalProtobuf(data); err != nil { ... }
case 2: // repeated Sample
data, ok := fc.MessageData()
// ... parse 1 sample into samplesPool ...
sample := &samplesPool[len(samplesPool)-1]
if err := sample.unmarshalProtobuf(data); err != nil { ... }
case 4: // repeated Histogram (deferred raw bytes)
data, ok := fc.MessageData()
histograms = append(histograms, data)
}
}
baseLabels := labelsPool[labelsPoolLen:len(labelsPool):len(labelsPool)]
samples := samplesPool[samplesPoolLen:len(samplesPool):len(samplesPool)]
// samples 和 histograms 互斥校验
if len(samples) > 0 && len(histograms) > 0 {
return tss, labelsPool, samplesPool, fmt.Errorf("cannot have both...")
}
if len(samples) > 0 {
tss = appendTimeSeries(tss, baseLabels, samples)
return tss, labelsPool, samplesPool, nil
}
for _, hdata := range histograms {
tss, labelsPool, samplesPool, err = unmarshalHistogram(
hdata, tss, labelsPool, samplesPool, baseLabels, fb)
// ...
}
return tss, labelsPool, samplesPool, nil
}
这里的关键技巧是用 slice 的 len 快照来切分池:
- labelsPoolLen := len(labelsPool):记录当前池长度
- 解析过程中不断 append label 到 labelsPool
- baseLabels := labelsPool[labelsPoolLen:len(labelsPool):len(labelsPool)]:用切片表达式提取本次 TimeSeries 的标签子集(从快照位置到当前长度,cap 等于 len 防止意外扩展)
这样,同一组 baseLabels 可以被多条展开后的 histogram TimeSeries 共享(appendHistogramSeries 里再 copy 一次标签)。这就是为什么 appendTimeSeries 接收的是 labels []Label 而不是指针——共享底层数组是安全的,因为后续 histogram 展开时会在 appendHistogramSeries 里重新 copy 一份。
本节必记闭环逻辑(核心考点)
Label/Sample 的解析是逐字段的 switch fc.FieldNum 循环,fc.String()/fc.Double()/fc.Int64() 分别对应 protobuf wire type。TimeSeries 解析的核心技巧是用 len 快照分割 labelsPool/samplesPool,使多 TimeSeries 共享同一个池而不冲突。
五、Native Histogram:wire format → 3 条 TimeSeries 的展开
思考记忆提示 — Native Histogram 是 Prometheus 的高级特性(1.146.0 已支持)。本节讲它如何从 1 条 protobuf message 变成 3 条 TimeSeries。
- 要点一:1 个 Histogram message → _count + _sum + N 条 _bucket
- 要点二:桶的边界由 schema + base 计算得出(base^(bucketIdx))
- 要点三:vmrange 标签是 VM 自己加的,对应 Prometheus 的 le 标签
5.1 Prometheus Native Histogram 的 wire format
Prometheus Native Histogram(原生直方图)是一种"稀疏直方图"——不需要预定义桶边界,可以记录任意精度的分布。它在 protobuf 里的 message 定义很长:
关键源码引用 10:lib/prompb/write_request_unmarshaler.go 第 213-340 行,unmarshalHistogram 函数体(注意:原博文误把这一段说成"Histogram proto 定义注释",实际上是 unmarshalHistogram(src []byte, tss []TimeSeries, ...) (...) 函数的完整实现,第 213 行起,到第 340 行附近接近 appendBucketSpan 的位置)
// message Histogram {
// oneof count { // Count of observations in the histogram.
// uint64 count_int = 1;
// double count_float = 2;
// }
// double sum = 3; // Sum of observations in the histogram.
// sint32 schema = 4;
// double zero_threshold = 5; // Breadth of the zero bucket.
// oneof zero_count { // Count in zero bucket.
// uint64 zero_count_int = 6;
// double zero_count_float = 7;
// }
// repeated BucketSpan negative_spans = 8;
// repeated sint64 negative_deltas = 9; // Count delta vs previous bucket
// repeated double negative_counts = 10; // Absolute count of each bucket
// repeated BucketSpan positive_spans = 11;
// repeated sint64 positive_deltas = 12;
// repeated double positive_counts = 13;
// ResetHint reset_hint = 14;
// int64 timestamp = 15;
// repeated double custom_values = 16;
// }
每个字段的含义:
| 字段 | 类型 | 含义 |
|---|---|---|
| count_int / count_float | uint64 / double | 总观测次数(计数) |
| sum | double | 所有观测值的总和(用于计算平均值) |
| schema | sint32 | 桶配置版本号,决定 base = 2^(2^-schema) |
| zero_threshold | double | 零桶的宽度 |
| zero_count_int / zero_count_float | uint64 / double | 落在零桶内的观测次数 |
| positive_spans / negative_spans | repeated BucketSpan | 正轴/负轴桶的稀疏范围(offset-length 对) |
| positive_deltas / negative_deltas | repeated sint64 | 每个桶相对于前一个桶的计数增量(累积差分编码) |
| positive_counts / negative_counts | repeated double | 每个桶的绝对计数(float histogram 用) |
| reset_hint | enum | 提示计数器是否重置过(用于查询端合并) |
| timestamp | int64 | 采样时间戳(毫秒) |
5.2 BucketSpan:RLE 压缩的桶序列
BucketSpan 是理解 Native Histogram 的关键。它的 protobuf 定义:
// message BucketSpan {
// sint32 offset = 1; // gap to previous span, or index of first bucket for the first span
// uint32 length = 2; // number of consecutive buckets in this span
// }
offset 是累积偏移(相对于前一个 span 的第一个桶的索引),不是绝对索引。举例:
- 第一个 span:offset=0, length=3 → 桶 0, 1, 2
- 第二个 span:offset=5, length=2 → 桶 7, 8(跳过 3,4,5,6:这些桶计数为 0)
这种 RLE(Run-Length Encoding)设计使得"有很多空桶"的稀疏直方图可以用很少的字节描述。VM 的 appendBucketSpan 函数负责解析:
关键源码引用 11:lib/prompb/write_request_unmarshaler.go 第 342-379 行,appendBucketSpan
// appendBucketSpan:解析 BucketSpan message(field 1=offset, field 2=length)
// ① cap/len 技巧:优先复用已有容量
func appendBucketSpan(spans []bucketSpan, src []byte) ([]bucketSpan, error) {
if len(spans) < cap(spans) {
spans = spans[:len(spans)+1]
} else {
spans = append(spans, bucketSpan{})
}
span := &spans[len(spans)-1]
var err error
var fc easyproto.FieldContext
for len(src) > 0 { // ② 循环直到 src 被消费完
src, err = fc.NextField(src)
if err != nil {
return spans, fmt.Errorf("cannot read next field: %w", err)
}
// ③ field 1 = sint32 offset (zigzag 编码), field 2 = uint32 length
switch fc.FieldNum {
case 1:
span.offset, ok = fc.Sint32() // ④ zigzag:-2→3, -1→1, 0→0, 1→2
case 2:
span.length, ok = fc.Uint32()
}
}
return spans, nil
}
注意 fc.Sint32() 读取的是 zigzag 编码——一种将负数映射到正数的 varint 编码方式(0→0, -1→1, 1→2, -2→3 ...)。
5.3 展开成 3+N 条 TimeSeries
这是最精彩的逻辑:nativeHistogramContext.appendTimeSeries 把 1 条 Histogram message 展开成多条 TimeSeries:
关键源码引用 12:lib/prompb/write_request_unmarshaler.go 第 380-428 行,appendTimeSeries 展开逻辑(方法名是 (*nativeHistogramContext).appendTimeSeries)
// appendTimeSeries:Histogram → 3+N 条 TimeSeries 的展开入口
// ① 提取 __name__ 标签值作为 baseName
// ② 用 defer 恢复原始名称,实现复用 baseLabels 底层数组
// ③ 依次生成 _count / _sum / 零桶 _bucket / 正轴桶 / 负轴桶
func (nhctx *nativeHistogramContext) appendTimeSeries(
tss []TimeSeries, baseLabels []Label,
labelsPool []Label, samplesPool []Sample,
fb *fmtBuffer) ([]TimeSeries, []Label, []Sample) {
tsMillis := nhctx.timestamp
count := float64(nhctx.countInt) // ④ int 和 float 互斥,二选一
if nhctx.isCountFloat {
count = nhctx.countFloat
}
// ⑤ 找 __name__ 标签,取出原始 metric 名
var baseName string
var nameValueP *string
for i := range baseLabels {
if baseLabels[i].Name == "__name__" {
baseName = baseLabels[i].Value
nameValueP = &baseLabels[i].Value
break
}
}
if baseName == "" {
return tss, labelsPool, samplesPool // 无名称,跳过
}
originName := *nameValueP
defer func() { *nameValueP = originName }() // ⑥ defer 恢复,不污染后续使用
// 生成 _count 序列
*nameValueP = fb.formatName(baseName, "_count") // ⑦ 修改 __name__ 值为 baseName_count
tss, labelsPool, samplesPool = appendHistogramSeries(
tss, labelsPool, samplesPool, baseLabels, "", tsMillis, count)
// 生成 _sum 序列
*nameValueP = fb.formatName(baseName, "_sum")
tss, labelsPool, samplesPool = appendHistogramSeries(
tss, labelsPool, samplesPool, baseLabels, "", tsMillis, nhctx.sum)
// 生成零桶 _bucket
*nameValueP = fb.formatName(baseName, "_bucket")
zeroCount := float64(nhctx.zeroCountInt)
if nhctx.isZeroCountFloat {
zeroCount = nhctx.zeroCountFloat
}
if zeroCount > 0 {
vmrange := fb.formatVmrange(-nhctx.zeroThreshold, nhctx.zeroThreshold) // ⑧ vmrange 标签
tss, labelsPool, samplesPool = appendHistogramSeries(
tss, labelsPool, samplesPool, baseLabels, vmrange, tsMillis, zeroCount)
}
// 计算 schema → base(桶边界公式)
ratio := math.Pow(2, -float64(nhctx.schema)) // ⑨ base = 2^(2^-schema)
base := math.Pow(2, ratio)
// 生成正轴桶(正无穷方向)
tss, labelsPool, samplesPool = appendSpanBuckets(
tss, labelsPool, samplesPool, baseLabels, fb,
nhctx.positiveSpans, nhctx.positiveDeltas, nhctx.positiveCounts,
base, false, tsMillis)
// 生成负轴桶(负无穷方向)
tss, labelsPool, samplesPool = appendSpanBuckets(
tss, labelsPool, samplesPool, baseLabels, fb,
nhctx.negativeSpans, nhctx.negativeDeltas, nhctx.negativeCounts,
base, true, tsMillis)
return tss, labelsPool, samplesPool
}
展开步骤分解:
- 提取 base metric 名:从 baseLabels 里找 __name__ 标签,取出原始 metric 名
- 替换生成 _count:把 __name__ 的值从 baseName 改成 baseName_count,append 一条 TimeSeries
- 替换生成 _sum:同样替换为 baseName_sum,append
- 生成零桶 _bucket:如果 zeroCount > 0,加一条 _bucket{vmrange="[lower,upper]"}
- 逐 span 生成普通桶:appendSpanBuckets 遍历 BucketSpan,计算每个桶的 upper = base^(bucketIdx),生成 _bucket{vmrange="(prevUpper,upper]"}
5.4 vmrange 标签:Prometheus le 的 VM 版本
Prometheus 查询原生直方图时用 le(less than or equal)标签表示桶上界。VM 为了兼容自己的查询引擎,引入了 vmrange 标签,定义方式相同但命名不同:
关键源码引用 13:lib/prompb/fmt_buffer.go 第 30 行,vmrange 生成(注意:formatVmrange 不是 fmt_buffer.go 里的顶级函数名,实际方法是 (*fmtBuffer).formatVmrange(start, end float64) string,定义在该文件第 30 行)
// ① 计算单个桶的 vmrange 标签值
if bucketCount > 0 {
upper := math.Pow(base, float64(bucketIdx)) // ② 桶上界:base^(bucketIdx)
lower := upper / base // ③ 桶下界:上一级桶的上界
if negative { // ④ 负轴桶:lower/upper 互换并取负
lower, upper = -upper, -lower
}
vmrange := fb.formatVmrange(lower, upper) // ⑤ 生成 "1e+03...2e+03" 格式字符串
tss, labelsPool, samplesPool = appendHistogramSeries(
tss, labelsPool, samplesPool, baseLabels, vmrange, tsMillis, bucketCount)
}
vmrange 的格式是 "[lower,upper]"(含上界)或 "(lower,upper]"(不含下界)。负轴桶的 lower/upper 互换为负数,这是 histogram 的标准定义。
本节必记闭环逻辑(核心考点)
1 条 Histogram message → N+2 条 TimeSeries:_count + _sum + N 条 _bucket(每条桶对应一个 vmrange 标签)。桶索引通过 BucketSpan offset 累积推进,桶上界通过 base^(idx) 计算。vmrange 标签是 VM 的 le 等价物。
六、MetricMetadata:多租户元数据与 MetricType 枚举
思考记忆提示 — MetricMetadata 携带 metric 的元信息(类型、帮助文本、单位)。VM 还在这里扩展了 AccountID/ProjectID 多租户字段。
- 要点一:MetricType 是 uint32 枚举,共 8 种
- 要点二:AccountID + ProjectID 是 VM 扩展字段,不在 Prometheus 标准里
- 要点三:Metadata 走独立于 Timeseries 的解析路径(case 3 in UnmarshalProtobuf)
6.1 MetricType 枚举
MetricType 定义:
关键源码引用 14:lib/prompb/prompb.go 第 95-114 行,MetricType 枚举(实际 MetricType 在第 95 行定义,8 个枚举常量在第 97-114 行)
type MetricType uint32
const (
MetricTypeUnknown MetricType = 0
MetricTypeCounter MetricType = 1
MetricTypeGauge MetricType = 2
MetricTypeHistogram MetricType = 3
MetricTypeGaugeHistogram MetricType = 4
MetricTypeSummary MetricType = 5
MetricTypeInfo MetricType = 6
MetricTypeStateset MetricType = 7
)
8 种类型,对应 Prometheus/OpenMetrics 规范。代码注释里引用了 Prometheus 源码链接(prompb/types.proto),说明 VM 严格对齐了 Prometheus 官方的 enum 定义。
String 方法用于日志和调试输出:
// String:MetricType → 人类可读字符串(用于日志/调试)
func (mt MetricType) String() string {
switch mt {
case 0: return "unknown" // ① 未知的 metric 类型
case 1: return "counter" // ② 计数器
case 2: return "gauge" // ③ 仪表(可增可减)
case 3: return "histogram" // ④ 传统直方图
case 4: return "gauge histogram" // ⑤ 原生直方图(可增可减)
case 5: return "summary" // ⑥ 分位数摘要
case 6: return "info" // ⑦ 信息类
case 7: return "stateset" // ⑧ 状态集
default: return fmt.Sprintf("unknown(%d)", mt) // ⑨ 防御性处理
}
}
6.2 MetricMetadata 结构
关键源码引用 15:lib/prompb/prompb.go 第 78-89 行,MetricMetadata(注意:第 78 行起结构体定义,第 87-88 行的 AccountID/ProjectID 是多租户字段)
type MetricMetadata struct {
Type MetricType
MetricFamilyName string
Help string
Unit string
// Additional fields to allow storing and querying metadata in multitenancy.
AccountID uint32
ProjectID uint32
}
字段 1-5 是 Prometheus 标准字段;AccountID 和 ProjectID 是 VM 的多租户扩展。这两个字段在 protobuf wire format 里编号是 11 和 12(跳过了 6-10,可能因为历史原因或预留)。
Metadata 的解析:
关键源码引用 16:lib/prompb/write_request_unmarshaler.go 第 640-651 行,MetricMetadata unmarshal(注意:文件总共 652 行,所以 unmarshal 函数末尾到文件结尾)
// unmarshalProtobuf:逐字段解析 MetricMetadata message
// ① field 1=MetricType, 2=MetricFamilyName, 4=Help, 5=Unit, 11=AccountID, 12=ProjectID
// ② field 3 和 6-10 被 reserved(历史原因)
func (mm *MetricMetadata) unmarshalProtobuf(src []byte) (err error) {
var fc easyproto.FieldContext // ③ 字段上下文
for len(src) > 0 { // ④ 循环直到 src 被消费完
src, err = fc.NextField(src)
if err != nil {
return fmt.Errorf("cannot read the next field: %w", err)
}
switch fc.FieldNum {
case 1: // MetricType type
value, ok := fc.Uint32()
mm.Type = MetricType(value) // ⑤ uint32 → MetricType 枚举
case 2: // string metric_family_name
value, ok := fc.String()
mm.MetricFamilyName = value
case 4: // string help(field 3 被 reserved)
value, ok := fc.String()
mm.Help = value
case 5: // string unit
value, ok := fc.String()
mm.Unit = value
case 11: // uint32 AccountID (VM 扩展)
value, ok := fc.Uint32()
mm.AccountID = value // ⑥ 多租户字段
case 12: // uint32 ProjectID (VM 扩展)
value, ok := fc.Uint32()
mm.ProjectID = value // ⑦ 多租户字段
}
}
return nil
}
注意 field 3 被跳过了(reserved 3;),field 6-10 也是 reserved —— 这是 Prometheus proto 历史演进的结果。
Metadata 在写入链路里的位置:
Metadata 不是写入 timeseries 的必要条件——没有 Metadata,WriteRequest 只含 Timeseries 也能正常写入。Metadata 的主要作用是:① 告诉查询端 metric 的类型(histogram / counter 等),影响查询行为;② 提供 help 和 unit 信息用于文档化展示。VM 的 AccountID/ProjectID 扩展让多租户场景下也能携带正确的 tenant 信息。
本节必记闭环逻辑(核心考点)
MetricMetadata = Type(1) + MetricFamilyName(2) + Help(4) + Unit(5) + AccountID(11) + ProjectID(12)。前 5 个是 Prometheus 标准,最后 2 个是 VM 多租户扩展。8 种 MetricType 枚举值固定。Metadata 解析与 Timeseries 解析并行(case 1/case 3 in UnmarshalProtobuf)。
★ FAQ 问答:prompb 协议缓冲区 20 问
思考记忆提示 — FAQ 是全篇的临考前速背模块。
- Q1-Q5 围绕架构:prompb vs protobuf 官方库、目录结构、设计动机
- Q6-Q12 围绕解析:WriteRequestUnmarshaler 池化、cap/len 技巧、Label/Sample 解析
- Q13-Q20 围绕 Native Histogram:展开逻辑、BucketSpan、vmrange、MetricType
Q1. 为什么 VM 不用 protobuf 官方库(protoc 生成的 .pb.go)?
三个原因:避免内存分配、去掉 codegen 依赖、定制 Native Histogram 展开。官方 proto.Unmarshal 每次调用都分配新的 slice 树,GC 压力大。VM 的手工解析走 easyproto.FieldContext + 池化,100 万 samples/s 场景下把 GC 次数降到可忽略水平。
Q2. prompb 包和 easyproto 是什么关系?
github.com/VictoriaMetrics/easyproto 是 VM 自研的轻量级 protobuf 解析库(通过 go.mod 引入的外部依赖,版本 v1.2.0),lib/prompb/ 是它的一个应用。easyproto.FieldContext 提供 NextField()、String()、Double()、Int64() 等底层 API,prompb 在此之上构建特定 message 的解析逻辑。
Q3. WriteRequestUnmarshaler 为什么需要 Reset 而不只是 Put 回池?
因为 sync.Pool 里的对象可能被另一个 goroutine Get 走,脏数据会导致不可预期的行为。Put 前必须 Reset(clear() + [:0]),确保下一个 Get 到的人拿到的是干净状态。这是 Go sync.Pool 的标准用法。
Q4. UnmarshalProtobuf 返回的 *WriteRequest 能安全地异步使用吗?
不能。返回值 &wru.wr 指向 Unmarshaler 内部的字段,下次调用 UnmarshalProtobuf 时会被 Reset 并覆写内容。如果需要持久化,必须深拷贝(append([]TimeSeries{}, wr.Timeseries...))。
Q5. 为什么 LabelsToString 需要先排序标签?
确保相同 metric 的字符串表示始终一致。Prometheus 规定标签必须按 name 排序后才是规范的 metric 表达。sort.Slice 在副本上操作,不影响原始 labelsPool 里的顺序。
Q6. TimeSeries 里 samples 和 histograms 为什么不能共存?
这是 Prometheus 协议规范的要求,在 write_request_unmarshaler.go 第 181-183 行有强制校验。一个 TimeSeries 要么是经典样本(Label + Sample),要么是原生直方图(Label + Histogram),不能混合。这是 wire format 的语义约束,不是 VM 自己加的限制。
Q7. cap/len 复用技巧在代码里出现了几次?
至少 6 次,分布在 4 个解析函数里。模式固定:if len(slice) < cap(slice) { slice = slice[:len(slice)+1] } else { slice = append(slice, T{}) }。这个模式在 unmarshalTimeSeries(label、sample)、appendTimeSeries(ts)、appendHistogramSeries(labels)里反复出现。
Q8. labelsPool 和 samplesPool 为什么分开池化而不是共用一个?
因为它们的增长规律不同——标签数通常少且固定,样本数随请求大小变化剧烈。共用水池会导致容量竞争:标签用不了大容量的样本池,样本用不了小容量的标签池。分开池化让每种资源都能独立达到最优复用率。
Q9. fc.MessageData() 的 ok 返回值什么时候为 false?
当 field 的 wire type 不是 Length-delimited(type=2)时。例如,field 1(repeated Label)期待的是 sub-message(Length-delimited),如果 wire 上这个位置是 varint(field 2 Sample 的 timestamp),MessageData() 会返回 ok=false,此时返回错误而不是跳过。
Q10. nativeHistogramContext 是第二个池,它和 WriteRequestUnmarshaler 是什么关系?
解耦生命周期。一个 WriteRequest 里可能有多条 Native Histogram,每条 histogram 的展开需要一个独立的 nativeHistogramContext。如果把它放在 WriteRequestUnmarshaler 内部,解析多个 histogram 时会互相覆盖。分开池化让每个 histogram 展开操作都有独立状态。
Q11. BucketSpan 的 offset 为什么用 sint32(zigzag 编码)而不是 uint32?
因为 offset 可以是负数(负轴桶的跨度)且范围不大,用 zigzag 编码比普通 uint32 更省字节。sint32 的 zigzag 编码将 [-2^31, 2^31-1] 映射到 [0, 2^32-1],对小负数(offset 通常是 -5 到 50 之间的整数)尤其高效。
Q12. appendBucketSpan 里的 pool 复用逻辑和主解析里的一样吗?
完全一样——cap/len 复用模式。if len(spans) < cap(spans) { spans = spans[:len(spans)+1] } else { spans = append(spans, bucketSpan{}) }。这是 VM 全包的编码规范,任何池化的 slice 操作用同一套模式。
Q13. Native Histogram 展开后,原始 Histogram 数据去哪了?
被丢弃了——展开后的 _count、_sum、_bucket TimeSeries 直接加入 tss,原始 histogram bytes 不再保留。这是不可逆的转换:wire format 的 Histogram message 在解析时就被展开成经典样本。如果要支持 histogram 原生查询,需要在存储层保留 Histogram 格式(VM 企业版有支持)。
Q14. vmrange 标签的格式具体是什么?
"[lower,upper]" 或 "(lower,upper]"。零桶用方括号(下界为 -zero_threshold);普通正轴桶用圆括号(下界开区间)到方括号(上界闭区间);负轴桶的 lower 和 upper 互换为负数。lower/upper 用科学计数法(formatVmrange 在 lib/prompb/fmt_buffer.go)。
Q15. 为什么 _count 和 _sum 要单独成 TimeSeries 而不是放在 Histogram 里?
为了与 Prometheus 的查询模型对齐。Prometheus 查询语言里的 rate(histogram_counter_sum[5m]) 和 rate(histogram_counter_count[5m]) 是分开查询的。把 _count 和 _sum 拆成独立 TimeSeries,查询层无需知道 Histogram wire format,代码复用率最大化。
Q16. MetricMetadata 的 field 编号为什么跳过了 3、6-10?
这些编号在 Prometheus proto 历史版本里用过,后来删除了(reserved)。protobuf 的 reserved 机制防止新旧版本字段编号冲突。VM 完全照搬了 Prometheus 的 proto 定义,所以同样的空洞出现在 VM 的解析代码里。
Q17. AccountID 和 ProjectID 在写入链路里是怎么用的?
在 lib/prompb/write_request_unmarshaler.go 解析后,Metadata 的 AccountID/ProjectID 随 WriteRequest 传递到后续处理链路,最终用于多租户隔离。具体路由逻辑在 app/vminsert/common/ 层,但 metadata 里的租户字段是写入时就已经携带的,比从 HTTP header 里取更精确。
Q18. Prometheus 发来的 WriteRequest 里,Timeseries 和 Metadata 的顺序有关系吗?
没有。protobuf 里 repeated 字段的顺序不保证保留。解析时用 field 编号(1=Timeseries, 3=Metadata)区分,不依赖顺序。Timeseries 和 Metadata 分别解析后存在不同的 slice 里,通过 field 编号对应关系关联。
Q19. 如果客户端发来损坏的 protobuf,VM 会崩溃吗?
不会,每个解析函数都返回 error。UnmarshalProtobuf、unmarshalTimeSeries、unmarshalHistogram、Label.unmarshalProtobuf、Sample.unmarshalProtobuf 都有 ok 检查和 error 返回。任何 wire 损坏都会在解析层被捕获并返回 HTTP 400。
Q20. prompb 包的性能基准测试在哪个文件里?
lib/prompb/prompb_timing_test.go 和 lib/prompb/write_request_unmarshaler_timing_test.go。这两个文件包含微基准测试,可以直接运行 go test -bench=Benchmark -benchmem lib/prompb/ 来测量每秒能解析多少条 WriteRequest 和 TimeSeries。
全篇必记总纲
prompb 包是 Prometheus Remote Write 协议的 Go 语言实现,VM 选择手工解析而非官方库来获得性能优势:WriteRequest → []TimeSeries → (Labels + Samples) 或 (Labels + Histograms) 展开。核心实现是 WriteRequestUnmarshaler 的池化 + easyproto.FieldContext 遍历 + cap/len 复用。Native Histogram 从 1 条展开成 N+2 条(_count + _sum + N 个 _bucket),BucketSpan 的 offset 累积推进决定桶索引,vmrange 标签是 VM 的 le 等价物。
★ 后续预告:Roadmap
本篇深入了 prompb 的 wire format 解析层。后续将继续沿着写入链路向下:
- #18 protoparser 框架:82 个 Go 文件的解析架构普适模式(流式 + buffer 复用 + worker pool)
- #19 vmstorage API:裸露的 Storage 层是如何暴露给 Cluster 调用的(gRPC wire format 详解)
- #20 写入核心链路:Storage.add() 的三段式处理(解析 → 缓存 → 落盘)
- #21 rawRowsShards 分片:CPU 核数分片写入(rawItemsShards := make([]rawRowsShard, cgroup.AvailableCPUs()))
- #22 MetricName 二进制编码:转义与黄金标签排序
- #23 TSID 四字段体系:20 字节定长设计
- #40 WAL-less 设计哲学:1 秒刷盘替代 WAL,写入路径的最后一道设计哲学
读完本系列,你将对 VM 的整条数据链路(写入、查询、合并、刷盘)有完整理解,对存储引擎的工程美学(每 1 纳秒都在抠)有极致感受。

浙公网安备 33010602011771号