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(如 QueryResultLabelMatcher 等)。"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 顶层只有两个字段——TimeseriesMetadata
  • 要点二:TimeSeries 包含 Labels + Samples(或 Histograms,两者互斥)
  • 要点三:Sample = Value (float64) + Timestamp (int64 ms)

先看 lib/prompb/prompb.go 的核心类型定义,用源码引用:

关键源码引用 1lib/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 行有校验:

关键源码引用 2lib/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() 复用底层数组(通过 ResetTimeSeriesResetMetadata 实现)。IsEmpty() 则在调用侧做短路——空请求不需要走后续处理链路。

2.2 LabelsToString:调试友好的字符串化

prompb.go 第 51-73 行还定义了一个调试方法:

关键源码引用 3lib/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/protoUnmarshal 每次调用都会:

  • 分配新的 []byte slice 作为 buffer
  • 递归展开嵌套 message,每次 append() 都可能触发新分配
  • 返回的 struct 完全独立于输入 []byte

在 100 万 samples/s 的写入压力下,每秒可能来几万次 WriteRequest,每秒触发几万次新分配 —— GC 压力巨大。VM 的解法是把整个 Unmarshaler 对象池化,复用内部所有 buffer。

3.2 对象池的两级结构

先看池的外部定义:

关键源码引用 4lib/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 自身的结构:

关键源码引用 5lib/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 行,逻辑非常清晰:

关键源码引用 6lib/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 循环内用局部变量 tsslabelsPoolsamplesPool 操作,最后统一写回结构体字段。这避免了每次 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 = nilclear() 在 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 池labelsPoolsamplesPool,用 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,跳过会导致数据错位
  • 不要把 labelsPoolsamplesPool 混用:它们的 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 的手工解析实现:

关键源码引用 7lib/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;
// }

手工解析:

关键源码引用 8lib/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 数组:

关键源码引用 9lib/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 定义很长:

关键源码引用 10lib/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 函数负责解析:

关键源码引用 11lib/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:

关键源码引用 12lib/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
}

展开步骤分解:

  1. 提取 base metric 名:从 baseLabels 里找 __name__ 标签,取出原始 metric 名
  2. 替换生成 _count:把 __name__ 的值从 baseName 改成 baseName_count,append 一条 TimeSeries
  3. 替换生成 _sum:同样替换为 baseName_sum,append
  4. 生成零桶 _bucket:如果 zeroCount > 0,加一条 _bucket{vmrange="[lower,upper]"}
  5. 逐 span 生成普通桶appendSpanBuckets 遍历 BucketSpan,计算每个桶的 upper = base^(bucketIdx),生成 _bucket{vmrange="(prevUpper,upper]"}

5.4 vmrange 标签:Prometheus le 的 VM 版本

Prometheus 查询原生直方图时用 le(less than or equal)标签表示桶上界。VM 为了兼容自己的查询引擎,引入了 vmrange 标签,定义方式相同但命名不同:

关键源码引用 13lib/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 定义:

关键源码引用 14lib/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 结构

关键源码引用 15lib/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 标准字段;AccountIDProjectIDVM 的多租户扩展。这两个字段在 protobuf wire format 里编号是 11 和 12(跳过了 6-10,可能因为历史原因或预留)。

Metadata 的解析:

关键源码引用 16lib/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 前必须 Resetclear() + [: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 用科学计数法(formatVmrangelib/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。UnmarshalProtobufunmarshalTimeSeriesunmarshalHistogramLabel.unmarshalProtobufSample.unmarshalProtobuf 都有 ok 检查和 error 返回。任何 wire 损坏都会在解析层被捕获并返回 HTTP 400。

Q20. prompb 包的性能基准测试在哪个文件里?

lib/prompb/prompb_timing_test.golib/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 纳秒都在抠)有极致感受。

posted @ 2026-07-01 04:25  左扬  阅读(16)  评论(0)    收藏  举报