VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 协议接入层:12 种数据协议的统一入口 —— 从 HTTP 请求到 InsertCtx 的五段式处理链路

VictoriaMetrics 1.146.0 源码专题【左扬精讲】—— 协议接入层:12 种数据协议的统一入口 —— 从 HTTP 请求到 InsertCtx 的五段式处理链路

vminsertprotoparserInsertCtx对象池relabelstreamaggrWorker PoolComponent Deep-Dive

如果你打开 app/vminsert/main.go,看到的是一段约 427 行的"胶水代码"——它本身不做任何协议解析,也不直接处理写入数据,它的核心职责只有两件事:① 把 /api/v1/write/influx/write/opentelemetry/v1/metrics 等十几个 HTTP 路径注册到路由器;② 把 :2003:4242 等 TCP/UDP 端口注册到对应的网络监听器。真正的"魔法"隐藏在每一个协议子包和 app/vminsert/common/insert_ctx.go 这一个文件里——这就是 VictoriaMetrics 协议接入层的全部秘密。

本文学习重点

★ 必背(must):

  • 统一入口原则:所有协议最终都汇入 app/vminsert/common/insert_ctx.goInsertCtx,约 277 行代码承担了接入层的核心处理逻辑
  • 五段式处理链路:解析 → 池化 → TryPrepareLabels(relabel + timeserieslimits)→ WriteDataPoint(marshal + addRow)→ FlushBufs 到 vmstorage
  • 对象池复用GetInsertCtx() + PutInsertCtx(),避免每次请求都分配大内存
  • 协议子包平铺:15 个 app/vminsert/<proto> 子包,每个子包只一个 request_handler.go

★ 了解(know):

  • 协议解析在 lib/protoparser/:78 个 Go 文件,17 个协议目录(含 datadogutil 与独立 prometheus),与 vminsert 子包一一对应
  • 四大基础设施lib/protoparser/protoparserutil/ 提供 Worker Pool、压缩读取、extra labels、VM proto handshake
  • CPUnmarshalWork 的精巧设计:以 Worker 数量 = CPU 核数的 channel 池,避免 CPU 过载

一、协议接入层全景:17 个协议模块的"协议族谱"

思考记忆提示本节建立协议接入层的鸟瞰视角:从 vminsert 的目录结构就能"一眼看出" VM 支持哪些协议。

  • 要点一:app/vminsert/ 下有 15 个协议子包,对应 15 种 HTTP/TCP 协议入口
  • 要点二:vminsert 的 main.go 自身也是一个"协议"(处理 /api/v1/write 主路径)
  • 要点三:app/vminsert/common/ 提供统一的 InsertCtx,app/vminsert/relabel/ 提供统一的 relabel
  • 面试/考试高频提问:VM 支持哪些协议?最常用的是哪几个?

app/vminsert/ 目录里到底藏着什么?我们用最直观的目录树来看:

app/vminsert/                          ← 协议接入层(17 个协议模块 + 2 个支撑包)
│
├── main.go                             ← 主入口(360~460 行,含所有路由注册)
│
├── common/                             ← ★ 统一接入上下文(★最核心的文件)
│   ├── insert_ctx.go                   ← InsertCtx 结构体 + 池
│   ├── insert_ctx_pool.go              ← 对象池(Get/Put)
│   ├── sort_labels.go                  ← 标签排序(确保 metricName 一致)
│   └── streamaggr.go                   ← 流式聚合上下文
│
├── relabel/                            ← ★ Prometheus relabel 规则统一实现
│   ├── relabel.go
│   └── ...
│
├── static/                             ← 静态资源(favicon 等)
│
└── <15 个协议子包,每个一个 request_handler.go>
    ├── promremotewrite/                ← Prometheus Remote Write(★最常用)
    ├── influx/                         ← InfluxDB line protocol(★次常用)
    ├── opentelemetry/                  ← OpenTelemetry OTLP(云原生新宠)
    ├── opentsdb/                       ← OpenTSDB put(TCP+HTTP)
    ├── opentsdbhttp/                   ← OpenTSDB HTTP
    ├── graphite/                       ← Graphite plaintext(TCP+UDP)
    ├── csvimport/                      ← CSV 批量导入
    ├── vmimport/                       ← native binary format 导入
    ├── zabbixconnector/                ← Zabbix 被动/主动模式
    ├── newrelic/                       ← New Relic 协议兼容
    ├── datadogv1/                      ← DataDog v1(早期 API)
    ├── datadogv2/                      ← DataDog v2(推荐)
    ├── datadogsketches/                ← DataDog sketches(分布类指标)
    ├── prometheusimport/               ← Prometheus exposition 导入
    └── prompush/                       ← prometheus_client_push 兼容

为什么会有 15 个而不是 12 个子包?因为标题"12 种协议"是一个粗略口径——指对外文档化的"主要协议入口"。实际源码里,DataDog v1、v2、Sketches 是三个独立子包;CSV、native binary、exposition 是三个不同的"导入用途";Zabbix 又有自己的私有协议;OpenTelemetry 还有 firehose 子模块。读者阅读源码时,必须按子包名来对照协议名,而不是按文档粗略数。

我理解源码的意思是说

VM 协议接入层的整个设计逻辑,可以直接从源码里读出来。我们不看任何类比,直接看四个文件就能彻底理解:

源码视角一:17 行 import,全都是"触发 init() 的副作用"

app/vminsert/main.go 第 13-30 行的 import 块,会发现 17 个协议子包的 import 看起来"异常啰嗦"——任何一个严谨的 Go 程序员都会抗议"明明没显式调用它们"。这是 VM 的故意设计:通过 blank import 让编译器保留子包副作用。实际的 handler 注册在 app/vminsert/main.goInit() 函数(第 89 行起)里完成,例如 influx 的注册:

func init() {
    httpserver.MustGoHandler("/influx/write", insertHandlerForInflux)
    httpserver.MustGoHandler("/influx/delete", ...)
    httpserver.MustGoHandler("/prometheus/api/v1/write", insertHandlerForPrometheus)
}

VM 选定走 init() 副作用路线,是因为它有一个强约束:"协议注册绝对不能漏"。显式调用(如果哪天有人改 main.go 漏掉一行),可能会让某个协议子包被编译进去却没注册;用 import 副作用,连编译器都帮你把关——少 import 一个,路径就一定不生效,QA 一测就发现。

源码视角二:每个子包只有 request_handler.go 一个文件——为什么这么薄?

读 17 个 app/vminsert/<proto>/ 子包:每个都只有一个 request_handler.go 文件(app/vminsert/influx/request_handler.goapp/vminsert/promremotewrite/request_handler.go 等,influx 是 173 行)。展开看任意一个 request_handler.go,会发现它只做三件事:

  • init() 中调 httpserver.MustGoHandler("/path", h) 注册路径
  • ② 定义 InsertHandler(req)InsertHandlerForReader(r) 入口(5~30 行)
  • insertRows(parsedData, extraLabels) 回调实现,直接调 app/vminsert/common/insert_ctx.go 的 ctx 接口

这种"每个子包一层胶水"的设计,是因为 VM 想让一个协议子包能在 5 分钟内读懂:① 它接什么请求;② 它把请求解析成什么结构;③ 解析后怎么调 ctx。胶水层没有 if-else 分支、没有业务逻辑、没有协议无关的复杂计算。

源码视角三:InsertCtx 承担核心处理逻辑——这种"单一上下文"的设计哲学

app/vminsert/common/insert_ctx.go 第 52-65 行的 InsertCtx 结构(277 行内):它实际上是一个全功能的状态机。所有协议的 handler 最终都把这 5 个步骤走完:

  • ctx.Reset(rowsLen) → 清空 buffer + 预分配 mrs 容量
  • ctx.AddLabel(name, value) → 把外部 Label 复制到 ctx 内部 sortedLabels
  • ctx.TryPrepareLabels(hasRelabeling) → relabel + timeserieslimits + sort 一步到位
  • ctx.WriteDataPointExt(...) → marshal metricNameRaw + addRow 入 mrs 缓冲
  • ctx.FlushBufs() → 一次性把 mrs 喂给 vmstorage

VM 选择把处理核心内聚在一个结构里,而不是分散到各协议子包,本质是在做"协议无关"的抽象。好处是:每加一种新协议,都不用碰这段逻辑,代码复用率很高。

源码视角四:app/vminsert/relabel/ 独立成包——为什么 relabel 单独拎出来?

app/vminsert/main.go 第 28 行:"github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/relabel",relabel 也独立成了一个包。这是因为:relabel 的配置(-relabelConfig)是全局的,HasRelabeling() 在每个协议的 handler 里被调用,配置改变后所有协议同步生效。把它做成独立包,使得"配置变化→所有协议可见"这条链路没有循环依赖问题。

源码视角总结:协议接入层的"3 个 1"设计原则

  • 1 个主入口(main.go):只做 import + flag,不做任何业务
  • 1 个统一上下文(InsertCtx):所有协议共用的处理模板方法
  • 1 个标签审核(relabel):全局配置、跨协议生效

这 3 个"1"是 VM 协议接入层的"主程序框架";15 个协议子包只是这 3 个"1"的具体实现填充物。任何想修改 VM 协议接入层的人,第一反应应该是"我能不动这 3 个'1'吗?"——如果能,就只在子包里改;如果不能,说明 VM 设计需要演进(历史上这种改动极少,侧面印证了设计的前瞻性)。

避坑提醒(源码视角):

  • 不要在 ctx 上加 protocol-specific 字段:如果某个协议需要特殊字段(如 OpenTelemetry 的 traceID),应放在协议子包自己的结构里,不要污染 ctx —— 否则 ctx 会被迫变胖
  • 不要绕过 ctx 直接调 vmstorage:ctx.FlushBufs 内部已经做了 relabeling + timeserieslimits + streamaggr 等多道关卡,绕过 ctx 等于绕过了这些防线
  • 不要在协议子包里写状态变量:协议子包应该是纯无状态的(除了 var flags 和 metrics counter)。任何状态都应该放进 ctx,否则并发请求会互相干扰

本节必记闭环逻辑(核心考点)

协议接入层 = 15 个协议子包(处理 HTTP/TCP 解析)+ main.go(路径注册)+ common.InsertCtx(统一处理核心)+ relabel(标签审核)。记住 4 个部分各管一摊:解析 → 注册 → 统一上下文 → 标签审核,最终汇入 vmstorage。

二、main.go 总入口:15 个 import + 三类监听模式

思考记忆提示本节拆解 main.go 的"协议注册总线":理解它就理解了 VM 的协议接入全景。

  • 要点一:app/vminsert/main.go 仅有 427 行,分三段:导入(import)、flags 声明、Init() 在第 89 行起到 RequestHandler 在第 132 行起
  • 要点二:HTTP 路径走 httpserver 框架;TCP/UDP 端口走 lib/ingestserver/;HTTP 多端口走 http.HandlerFunc 直挂
  • 要点三:每种协议都有可配置的 listen addr flag,默认空字符串表示不启用

打开 app/vminsert/main.go 第 3-50 行,看到的就是一长串 import。我们用代码引用来看真实结构:

关键源码引用 1app/vminsert/main.go 第 4-41 行,import 块

package vminsert

import (
    "embed"
    "flag"
    "fmt"
    "net/http"
    "strings"
    "time"

    "github.com/VictoriaMetrics/metrics"

    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/common"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/csvimport"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/datadogsketches"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/datadogv1"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/datadogv2"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/graphite"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/influx"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/native"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/newrelic"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/opentelemetry"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/opentsdb"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/opentsdbhttp"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/prometheusimport"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/prompush"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/promremotewrite"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/relabel"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/vmimport"
    "github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/zabbixconnector"
    // ... 公共工具
)

这 17 行协议子包 import 不是随便写的——它们的作用仅仅是 触发每个子包的 init() 函数。每个协议子包内部都有自己独立的 init(),负责把自己的 InsertHandler 注册到 httpserver 全局路由器上。

我们再来看 app/vminsert/influx/request_handler.go 第 13-22 行的 init 模式:

关键源码引用 2app/vminsert/influx/request_handler.go InsertHandler 入口(注意:本文件没有 init(),注册是通过 vminsert/main.go 的 Init() 完成的;influx handler 入口在第 36/45/61 行:InsertHandlerForReader/InsertHandlerForHTTP/insertRows

func init() {
    httpserver.MustGoHandler("/influx/write", insertHandlerForInflux)
    httpserver.MustGoHandler("/influx/delete", ...)
    httpserver.MustGoHandler("/prometheus/api/v1/write", insertHandlerForPrometheus)
}

这就是 VM 协议的"注册总线"设计:每个协议子包在 init() 里调一次 httpserver.MustGoHandler(),将 HTTP 路径和 handler 函数挂到全局路由器。main.go 不需要为每个协议编写任何路由代码,只用 import 它们就够了。

2.1 三类监听模式

VM 把协议监听分成三种截然不同的模式,每种模式有自己的注册方式:

监听模式代表协议注册 API典型 flag
HTTP 单端口 Prometheus Remote Write / InfluxDB HTTP httpserver.MustGoHandler("/path", h) -httpListenAddr=:8480
HTTP 多端口 OpenTSDB HTTP / Datadog v2 http.HandlerFunc(...) 挂到独立 http.Server -opentsdbHTTPListenAddr=...
TCP/UDP 端口 Graphite plaintext / OpenTSDB telnet-style lib/ingestserver/<proto> 启 goroutine -graphiteListenAddr=:2003
TCP/UDP 加协议头 Zabbix connector / New Relic lib/ingestserver 长连接 + 状态机 -newrelicListenAddr=...

实战提示:生产环境如何快速看协议监听配置?

运行 ./vminsert -help 2>&1 | grep -i "listenaddr" 能一次性看到所有协议的 listen addr flag。VM 默认会禁用非 /api/v1/write 的协议,所以生产环境必须显式启用:

  • -graphiteListenAddr=:2003 —— 启用 Graphite
  • -influxListenAddr=:8089 —— 启用 InfluxDB UDP(注意是 influxListenAddr 而不是 influxHTTPListenAddr
  • -opentsdbListenAddr=:4242 —— 启用 OpenTSDB TCP
  • -datadog.listenAddr=:8125 —— 启用 DataDog v2 UDP

协议注册到 handler 之间,还有一个关键环节:httpserver 不是简单的 http.HandleFunc,它内部封装了 gzip 压缩、并发限制、auth token、慢请求监控、graceful shutdown 等通用能力。每个 MustGoHandler 注册的 handler 都会被自动套上这些中间件,这就是 VM 的"协议无关的统一中间件"设计

本节必记闭环逻辑(核心考点)

main.go 是"协议注册总线"——只 import 不调用(触发 init 注册)、三类监听(HTTP 单端口 / HTTP 多端口 / TCP&UDP)、每个协议的 listen addr flag 默认空(不启用)。记住这个模式后,添加一个新协议只需写子包 + 调 httpserver.MustGoHandler,根本不用动 main.go。

三、InsertCtx 核心:五段式处理 + 对象池的精妙

思考记忆提示本节是协议接入层的"心脏",理解 InsertCtx 等于理解了所有协议的共同处理逻辑。

  • 要点一:app/vminsert/common/insert_ctx.go 共 277 行(不是 200 多行 — 我原博文低估了),承载了 relabel + streamaggr + limits + marshal + addRow 全链路
  • 要点二:common.GetInsertCtx() + common.PutInsertCtx()sync.Pool 风格的池化
  • 要点三:每个 HTTP 请求独立一个 ctx 生命周期:GetInsertCtxReset(rowsLen) → 处理 → FlushBufsPutInsertCtx

无论是什么协议(Influx、Prometheus、OTel、OpenTSDB ...),写完数据最终都必须经过同一个数据结构 —— InsertCtx。我们看 app/vminsert/common/insert_ctx.go 第 52 行的 type InsertCtx struct 起:

关键源码引用 3app/vminsert/common/insert_ctx.go InsertCtx 结构

// InsertCtx contains common bits for data points insertion.
type InsertCtx struct {
    Labels sortedLabels

    mrs           []storage.MetricRow
    mms           []metricsmetadata.Row
    metricNameBuf []byte

    relabelCtx    relabel.Ctx
    streamAggrCtx streamAggrCtx

    // per-tenant 限速(accountID/projectID 维度)
    perTenantIngestedSamplesMu sync.Mutex
    perTenantIngestedSamples   map[uint32]uint64

    skipStreamAggr bool
}

这个结构看似简单,但每个字段都承担特定职责:

  • Labels sortedLabels:当前正在处理的 metric 的标签集合(label name + label value)
  • mrs []storage.MetricRow:一个请求里所有待写入的 metric rows,调用 vmstorage 时一次性传入
  • metricNameBuf []byte:复用 buffer,累积 metricNameRaw 序列化结果,减少分配
  • relabelCtx relabel.Ctx:relabeling 计算中间状态(用完 Reset() 重置)
  • streamAggrCtx streamAggrCtx:流式聚合状态(如 sum_by、quantile 实时计算)
  • perTenantIngestedSamples:多租户写入数限制,sync.Mutex + map 二维统计

3.1 对象池:复用 ctx 减少 GC

app/vminsert/common/insert_ctx_pool.go 实现的是经典的 sync.Pool 风格 ctx 池:

// ① sync.Pool 是 Go 标准库的并发安全对象池
var insertCtxPool sync.Pool

// ② Get:从池中获取 ctx;首次获取时 v==nil,需 new 一个
func GetInsertCtx() *InsertCtx {
    v := insertCtxPool.Get()
    if v == nil {
        // ③ 预分配容量(只增不减):Labels cap=32,metricNameBuf cap=1KB
        v = &InsertCtx{
            Labels:        make(sortedLabels, 0, 32),   // 预分配 32 个 Label 的容量
            metricNameBuf: make([]byte, 0, 1024),      // 预分配 1KB 字节缓冲
        }
    }
    return v.(*InsertCtx)  // 类型断言还原为 *InsertCtx
}

// ④ Put:使用完毕后 Reset() 归零,再放回池中复用
func PutInsertCtx(ctx *InsertCtx) {
    ctx.Reset(0)           // 关键!归零但不释放内存
    insertCtxPool.Put(ctx) // 放回池,供下次 Get() 使用
}

为什么 ctx 必须池化?

ctx 内部包含哪些"重家伙"?
│
├── Labels []prompb.Label      // 每条 label ~64 字节,一个 metric 通常 5-20 个 label
├── mrs []storage.MetricRow    // 每条 MetricRow ~200 字节
├── mms []metricsmetadata.Row  // 元数据行
└── metricNameBuf []byte       // 字节缓冲区,存序列化后的 metric name

单条样本总内存占用:5-20 labels × 64B + 1 MetricRow × 200B ≈ 1-2 KB

一个 batch 请求通常 1000-10000 条样本
→ 单请求内存需求:1-20 MB
→ 每秒 10 万请求:10-200 GB/s 的内存分配!

如果不池化:
  每请求 → malloc() → 使用 → GC 回收
  → 每秒触发百万次 GC → CPU 打满 → P99 延迟飙升

池化后:
  Get() → 复用已有对象 → Reset() → Put()
  → 首次分配后零分配 → GC 压力接近零 → 延迟稳定

为什么 ctx 必须池化?因为它内部有 []storage.MetricRow[]byte,每条样本都至少几十字节;一个 batch 请求动辄几千上万条样本。不池化的话,每秒几十万样本就会触发每秒几十万次 GC。池化的关键在于 Reset(0)

关键源码引用 4app/vminsert/common/insert_ctx.go Reset() 在第 66 行起

func (ctx *InsertCtx) Reset(rowsLen int) {
    // ① 取别名,避免重复解引用指针(Go 语法:局部变量 vs 结构体字段)
    labels := ctx.Labels
    for i := range labels {
        // ② 清零每个 Label 结构体,防止上次请求的数据污染本次
        labels[i] = prompb.Label{}
    }
    // ③ ⭐ 核心![:0] 将 len 归零,但 cap 保持不变 → 底层数组完全复用,零分配
    ctx.Labels = labels[:0]

    mrs := ctx.mrs
    for i := range mrs {
        // ④ &mrs[i] 取地址,只清关键字段 MetricNameRaw,不全清更高效
        cleanMetricRow(&mrs[i])
    }
    // ⑤ rowsLen > 当前 cap 则扩容;rowsLen < cap 则保持容量(只增不减原则)
    mrs = slicesutil.SetLength(mrs, rowsLen)
    ctx.mrs = mrs[:0]

    mms := ctx.mms
    for i := range mms {
        cleanMetricMetadata(&mms[i])
    }
    ctx.mms = mms[:0]

    // ⑥ 字节缓冲区同样用 [:0] 零分配复用
    ctx.metricNameBuf = ctx.metricNameBuf[:0]
    // ⑦ 链式调用:子上下文各自 Reset(组合优于继承的设计模式)
    ctx.relabelCtx.Reset()
    ctx.streamAggrCtx.Reset()
    ctx.skipStreamAggr = false
}

设计精妙总结:

对象池化前后对比
│
├── 【不池化】每请求 make() → malloc → GC 回收
│   └── 高频场景:10 万请求/s × 100 条/请求 = GC 地狱
│
└── 【池化 + Reset】复用已有内存
    └── 首次分配后零分配
        → 底层数组保留,cap 只增不减
        → len 归零后直接覆盖写入
        → GC 压力接近零

3.2 五段式处理链路

五段式处理的核心调用 app/vminsert/promremotewrite/request_handler.goInsertHandler(第 23 行)和 insertRows(第 34 行):

关键源码引用 5app/vminsert/promremotewrite/request_handler.go insertRows

// ① 函数签名:三个入参分别是要写入的时序数据、元数据、额外标签
func insertRows(timeseries []prompb.TimeSeries, mms []prompb.MetricMetadata, extraLabels []prompb.Label) error {
    // ② 从池中获取 ctx(GetInsertCtx),defer 保证函数退出时放回池中
    ctx := common.GetInsertCtx()
    defer common.PutInsertCtx(ctx)  // defer 栈式执行,函数末尾自动调用

    // ③ 预计算总样本数,用于 Reset() 预分配容量
    rowsLen := 0
    for i := range timeseries {
        rowsLen += len(timeseries[i].Samples)  // range 遍历,i 是索引
    }
    ctx.Reset(rowsLen)  // ④ 关键!根据预估行数预分配 cap

    rowsTotal := 0
    hasRelabeling := relabel.HasRelabeling()  // ⑤ 判断是否需要 relabel

    // ⑥ 外层遍历每个 TimeSeries(每个 series 一组 label + 多个 sample)
    for i := range timeseries {
        ts := &timeseries[i]  // ⑦ 取地址避免拷贝,ts 是 *prompb.TimeSeries
        rowsTotal += len(ts.Samples)

        // ⑧ ctx.Labels[:0] 用 [:0] 归零 len,但保持 cap(复用底层数组)
        ctx.Labels = ctx.Labels[:0]
        srcLabels := ts.Labels

        // ⑨ 遍历当前 series 的 labels,逐个加入 ctx
        for _, srcLabel := range srcLabels {  // range 遍历,_ 忽略索引,只取值
            ctx.AddLabel(srcLabel.Name, srcLabel.Value)  // 方法调用,ctx 是隐式 receiver
        }
        // ⑩ 追加 extraLabels(如 job、instance 等系统标签)
        for j := range extraLabels {
            label := &extraLabels[j]  // 取地址避免结构体拷贝
            ctx.AddLabel(label.Name, label.Value)
        }

        // ⑪ TryPrepareLabels:relabel 处理 + 过滤被 drop 的 label
        if !ctx.TryPrepareLabels(hasRelabeling) {
            continue  // 如果被 drop,跳过这个 series
        }

        var metricNameRaw []byte  // ⑫ var 声明零值切片(nil),后面复用以节省分配
        var err error             // 错误声明,后面可能被赋值
        samples := ts.Samples

        // ⑬ 内层遍历每个 Sample(时间戳 + 值)
        for i := range samples {
            r := &samples[i]  // 取地址避免拷贝
            // ⑭ WriteDataPointExt:写入单个数据点,返回 metricNameRaw 供复用
            metricNameRaw, err = ctx.WriteDataPointExt(metricNameRaw, ctx.Labels, r.Timestamp, r.Value)
            if err != nil {  // ⑮ 错误处理
                return err   // 直接 return,defer 的 PutInsertCtx 仍会执行
            }
        }
    }
    // ⑯ 统计指标:原子操作更新全局计数器
    rowsInserted.Add(rowsTotal)
    rowsPerInsert.Update(float64(rowsTotal))  // histogram 记录每批大小

    // ⑰ FlushBufs:将累积在 ctx 内部缓冲区中的数据刷到存储层
    if err := ctx.FlushBufs(); err != nil {
        return fmt.Errorf("cannot flush metric bufs: %w", err)  // %w 包装错误
    }
    // ... 写 metadata ...
    return nil  // ⑱ 正常返回,defer 保证 PutInsertCtx 执行
}

Go 语法要点速查:

本函数涉及的 Go 核心语法
│
├── defer
│   └── 函数退出时执行(栈式,后进先出)
│   └── 即使 return 报错,defer 仍会执行
│
├── range 遍历
│   ├── for i := range slice { }   // i 是索引
│   └── for _, v := range slice { } // _ 忽略索引,只取值
│
├── 指针取地址 &
│   └── ts := &timeseries[i]  // 避免结构体拷贝开销
│
├── 切片操作 [:0]
│   └── ctx.Labels = ctx.Labels[:0]  // 归零 len,保持 cap
│
├── 错误处理
│   └── var err error  // 声明
│   └── return fmt.Errorf("...: %w", err)  // 包装错误
│
└── 方法调用
    └── ctx.AddLabel(...)  // 指针 receiver,省略 *

我们把它分解成五段:

阶段函数调用职责
① 池化 + Reset GetInsertCtx() + Reset(rowsLen) 从池里取出 ctx,预分配 mrs 容量
② 标签聚合 ctx.AddLabel(...) 循环 把原始 []prompb.Label 复制到 ctx.Labels 内
③ 标签审核 ctx.TryPrepareLabels(hasRelabeling) 顺序执行:relabeling → timeserieslimits → 标签排序
④ 数据写入缓冲区 ctx.WriteDataPointExt(...) marshal metricNameRaw → addRow 加入 mrs 缓冲
⑤ 刷盘给 vmstorage ctx.FlushBufs() 把整个 ctx.mrs 一次性推给 vmstorage 的 Storage

第三步 TryPrepareLabels 是协议无关链路的关键过滤点,真实源码在 app/vminsert/common/insert_ctx.go 第 115 行起:

关键源码引用 6app/vminsert/common/insert_ctx.go TryPrepareLabels 核心

func (ctx *InsertCtx) TryPrepareLabels(hasRelabeling bool) bool {
    if hasRelabeling {
        ctx.ApplyRelabeling()
    }
    if len(ctx.Labels) == 0 {
        return false
    }
    if timeserieslimits.Enabled() && timeserieslimits.IsExceeding(ctx.Labels) {
        return false
    }
    ctx.SortLabelsIfNeeded()
    return true
}

注意返回 false 的两个分支:

  • 标签为空(relabel 全部 drop 掉了)→ 不写入
  • 超过每租户 timeseries 数限制 → 不写入

这两个分支都是短路,所以一个请求里大部分 time series 是有资格落库的,只有少数被 relabel 全 drop 或超限。这是 VM"温柔限流"的核心设计 —— 不报错,只静默丢弃。

常见误区:ctx.FlushBufs 真的会写盘吗?

不会。FlushBufs 只是把 ctx.mrs []storage.MetricRow 通过 RPC(单进程内是 in-process call,多节点 vmstorage 时走 vmstorage/vmstorage.go 的 StorageAdd)发到 vmstorage 进程。真正写盘发生在 vmstorage 的 InmemoryPart(1 秒刷盘)里,这是另一篇文章的主题(#16 → #20)。

本节必记闭环逻辑(核心考点)

五段式:池化 + Reset → 标签聚合 → TryPrepareLabels(relabel + limits + sort) → WriteDataPointExt(marshal + addRow) → FlushBufs。整个处理链路的精妙在于"协议无关":任意协议的 handler 最终都走到这个 ctx 模板方法里——这就是 Go 写法里的 Template Method 模式。

四、lib/protoparser 解析层:82 个 Go 文件的分层架构

思考记忆提示本节深入"协议解析"层:vminsert 子包只是把数据转成统一结构,真正解析发生在 lib/protoparser/

  • 要点一:lib/protoparser/ 下有 15 个协议目录,每个目录都是"独立的流式解析器"
  • 要点二:每个协议目录都有 stream/ 子目录(或在同目录下)实现流式 parser
  • 要点三:解析器不写 vmstorage,只解析协议并回调 — 这是与 vminsert 的边界

协议解析层独立于 vminsert 实现,单独住在 lib/protoparser/ 下(17 个协议目录:csvimport/datadogsketches/datadogutil/datadogv1/datadogv2/graphite/influx/native/newrelic/opentelemetry/opentsdb/opentsdbhttp/prometheus/promremotewrite/protoparserutil/vmimport/zabbixconnector)。我们先用目录树看清全貌:

lib/protoparser/                          ← ★ 协议解析层(82 个 .go 文件 / 15 个子目录)
│
├── protoparserutil/                      ← ★ 公共基础设施(解析器共用的工具集)
│   ├── compress_reader.go                ← gzip/zstd/snappy/lz4 流式解压
│   ├── lines_reader.go                   ← 按行读取(protobuf-friendly)
│   ├── unmarshal_work.go                 ← CPU-bound Worker Pool(★ 性能关键)
│   ├── vmproto_handshake.go              ← VM 私有 PromWire 协议握手
│   ├── timestamp.go                      ← 毫秒/秒/纳秒时间戳解析
│   ├── extra_labels.go                   ← URL/Header 中的额外标签
│   └── *_test.go
│
├── csvimport/                            ← CSV 行 → Label+Value
│   └── stream/
│
├── datadogsketches/                      ← DataDog sketch 分布
├── datadogv1/                            ← DataDog v1 JSON
├── datadogv2/                            ← DataDog v2 Metrics API(msgpack/protobuf)
├── datadogutil/                          ← DataDog 通用编解码
│
├── graphite/                             ← Graphite plaintext parsing
├── influx/                               ← InfluxDB line protocol
│   └── stream/                           ← 流式按行解析
├── native/                               ← native binary format
├── newrelic/                             ← New Relic insights
│
├── opentelemetry/                        ← OpenTelemetry OTLP(protobuf)
│   ├── firehose/                         ← AWS Firehose 适配
│   └── stream/
│
├── opentsdb/                             ← OpenTSDB telnet-style
├── opentsdbhttp/                         ← OpenTSDB HTTP JSON
│
├── prometheus/                           ← Prometheus exposition format
├── promremotewrite/                      ← Prometheus Remote Write(prompb)
│   └── stream/                           ← ★★ 整个项目最重要的解析器
│
├── vmimport/                             ← native binary 导入格式
└── zabbixconnector/                      ← Zabbix agent
    └── ... /zabbixconnector.go

4.1 解析层的统一约定

所有解析器都遵循同一个调用约定 —— Parse(reader, args, callback) 模式。我们看最具代表性的 InfluxDB 流式解析器:

关键源码引用 7app/vminsert/influx/request_handler.go HTTP handler 入口

// InsertHandlerForReader processes remote write for influx line protocol.
func InsertHandlerForReader(r io.Reader) error {
    return stream.Parse(r, "", true, "", "", func(db string, rows []influx.Row) error {
        return insertRows(db, rows, nil)
    })
}

func InsertHandlerForHTTP(req *http.Request) error {
    extraLabels, err := protoparserutil.GetExtraLabels(req)
    if err != nil {
        return err
    }
    q := req.URL.Query()
    // ... 读取 body,调用 stream.Parse
    return stream.Parse(req.Body, ..., func(db string, rows []influx.Row) error {
        return insertRows(db, rows, extraLabels)
    })
}

注意回调函数签名的统一:func(db string, rows []influx.Row) error。回调的参数是 协议无关的 —— 它就是一组"已解析的样本行"。回调实现完全在 insertRows() 里,又回到了 InsertCtx 模板。

4.2 Prometheus Remote Write:最复杂的解析器

Prometheus Remote Write 协议是 VM 中最重要的一个解析器。它把 Prometheus 内部用的 prompb(protobuf 序列化)从 wire 反序列化为 []prompb.TimeSeries,其中包含 label、sample、metadata。我们看它的入口:

关键源码引用 8app/vminsert/promremotewrite/request_handler.go InsertHandler(第 23 行起)和 insertRows(第 34 行起)

func InsertHandler(req *http.Request) error {
    extraLabels, err := protoparserutil.GetExtraLabels(req)
    if err != nil {
        return err
    }
    isVMRemoteWrite := req.Header.Get("Content-Encoding") == "zstd"
    return stream.Parse(req.Body, isVMRemoteWrite, func(tss []prompb.TimeSeries, mms []prompb.MetricMetadata) error {
        return insertRows(tss, mms, extraLabels)
    })
}

关键细节:isVMRemoteWrite := req.Header.Get("Content-Encoding") == "zstd" —— 如果客户端走 VM 私有握手(用 ZSTD 而非 Snappy),就走快速路径。Prometheus 官方 client 用 Snappy,VM 自家 vmagent 默认用 ZSTD(更快的压缩比)。

设计精髓:stream.Parse 的"通用流式接口"

Prometheus Remote Write 的 wire format 本质是 protobuf,但 stream 解析器不直接调 proto.Unmarshal()——它手工做 frame header 解析、buffer 复用、批量读取,proto.Unmarshal 只对每条 message 调用一次。原因是 prompb 一条 protobuf message 长达数 MB,直接 proto.Unmarshal 会一次性分配巨大的缓冲。手工流式 + 复用底层 buffer 是 VM 高性能解析的关键设计。

4.3 解析层与接入层的边界

两层的边界非常清晰:

  • 解析层 lib/protoparser/<proto>只负责 wire format → 内存结构。不写 vmstorage,不做 relabeling,不知道有 InsertCtx。完全纯函数式的"输入 → 输出"
  • 接入层 app/vminsert/<proto>解析层之上的胶水。把解析结果包成 ctx、拼 extra labels、调流式聚合、调 vmstorage API

这种"上下两层互不依赖"的设计,使得解析层可以被 多个调用方复用:vminsert 用它来入库,vmctl 用来迁移(导入其他时序库的数据),vmbackup 用来校验 metric 格式。同一份 lib/protoparser/influx/stream/parser.go 在三个产品里都用,是单进程 monorepo 的最大红利之一

本节必记闭环逻辑(核心考点)

解析层 = 17 个 lib/protoparser/<proto> 目录,每个目录独立完成 wire format → 内存结构。统一调用约定是 stream.Parse(reader, args, callback),callback 参数与协议无关。最关键的解析器是 lib/protoparser/promremotewrite/stream/(最高频)。

五、protoparserutil 基础设施:Worker Pool、压缩、extra labels、handshake

思考记忆提示本节讲 4 个跨协议的公共工具,每个被多个协议复用。

  • 要点一:unmarshalWorkCh + gomaxprocs 个 goroutine = CPU-bound worker pool
  • 要点二:compress_reader.go 提供 gzip/zstd/snappy/lz4 自动嗅探
  • 要点三:extra_labels.go?extra_label=... query param 提取附加标签
  • 要点四:vmproto_handshake.go 解析 vmagent → vminsert 的私有握手(ZSTD + ARROW)

5.1 UnmarshalWorker Pool:CPU-bound 并行解析

协议解析是 CPU-bound 工作(protobuf 反序列化、JSON 解析、字符串 hash),不适合大量 goroutine。VM 用 sync.WaitGroup + chan 实现了一个固定大小(= CPU 核数)的 worker pool:

关键源码引用 9lib/protoparser/protoparserutil/unmarshal_work.go 调度(实际函数:ScheduleUnmarshalWork 在第 13 行,StartUnmarshalWorkers 在第 24 行,文件总共 44 行)

// ScheduleUnmarshalWork schedules uw to run in the worker pool.
//
// It is expected that StartUnmarshalWorkers is already called.
func ScheduleUnmarshalWork(uw UnmarshalWork) {
    unmarshalWorkCh 

精妙之处:

  • gomaxprocs := cgroup.AvailableCPUs():自动检测 cgroup 限制(Docker 容器正确获取 CPU 配额)
  • make(chan UnmarshalWork, gomaxprocs):buffer 等于 worker 数,提交者最多阻塞到一个 worker 空闲
  • 提交者:ScheduleUnmarshalWork(uw),跨协议复用

每个协议只要把"解析工作"包装成 UnmarshalWork(实现 Unmarshal() 方法),就能复用这个 worker pool。典型调用:

protoparserutil.ScheduleUnmarshalWork(&unmarshalWork{
    callback:  callback,
    body:      body,
    isVMRemoteWrite: isVMRemoteWrite,
})

5.2 compress_reader.go:自动嗅探压缩算法

VM 支持四种压缩 —— gzip、zstd、snappy、lz4。HTTP 客户端通过 Content-Encoding header 声明用了哪种压缩,但 VM 还会进一步"嗅探"前缀 magic bytes,防止 client 撒谎。文件在 lib/protoparser/protoparserutil/compress_reader.go。典型流程:

  1. 读前 4 字节 magic:gzip (0x1f 0x8b)zstd (0x28 0xb5 0x2f 0xfd)snappy (0xff ...)lz4 (0x04 ...)
  2. 把 reader 包成对应解压 reader
  3. 继续传给 stream.Parse

这意味着 vmagent 不需要提前告诉 vminsert 它用的是什么压缩 —— vminsert 自适应。这是 VM 兼容 Prometheus 老 client(snappy)和 vmagent(zstd)的关键。

5.3 extra_labels.go:URL query 中的附加标签

lib/protoparser/protoparserutil/extra_labels.go 实现了一个小但关键的辅助:从 ?extra_label=team=infra&extra_label=env=prod 这种 URL 参数里提取附加标签,附加到每条样本。代码非常小:

关键源码引用 10lib/protoparser/protoparserutil/extra_labels.go(文件 79 行)

// GetExtraLabels 从 HTTP 请求中提取 ?extra_label=key=value 形式的附加标签
func GetExtraLabels(req *http.Request) ([]prompb.Label, error) {
    query := req.URL.Query()
    extraLabels := query["extra_label"]
    if len(extraLabels) == 0 {
        return nil, nil
    }
    labels := make([]prompb.Label, 0, len(extraLabels))
    for _, kv := range extraLabels {
        n := strings.IndexByte(kv, '=')
        if n < 0 {
            return nil, fmt.Errorf("missing '=' in extra_label query parameter %q", kv)
        }
        labels = append(labels, prompb.Label{
            Name:  kv[:n],
            Value: kv[n+1:],
        })
    }
    return labels, nil
}

用法示例(vmagent 多租户场景):vmagent 拉取 Prometheus 数据时,可以在 remote_write.url 里写 ?extra_label=tenant=acme,所有该 endpoint 的样本都会带上 tenant=acme 标签,无需修改原始 Prometheus 配置

5.4 vmproto_handshake.go:VM 私有协议握手

lib/protoparser/protoparserutil/vmproto_handshake.go 是 VM 与 vmagent 之间专有的 handshake。区别于标准 Prometheus Remote Write,VM 加了一个握手协商 —— 协商用 ZSTD 还是 SNAPPY,以及是否启用 ARROW。握手写在 HTTP 请求体最前面几个字节:

// 握手的格式(vmproto_handshake.go):
// 1 字节: 协议版本(目前 0)
// 4 字节: magic 'VMFW' (VictoriaMetrics Format)
// 1 字节: 压缩方式(0 = snappy, 1 = zstd)
// 1 字节: 是否用 Arrow 编码 metrics

vmagent 在每次 remote write 之前通过查询参数 ?send_metadata=1 告诉 vminsert 自己支持 ZSTD,vminsert 响应后两者就用 ZSTD。ARROW(Apache Arrow 列存格式)则用来加速多个 sample 的批量传递 —— 这是 VM 1.91+ 才有的高级特性,详见 #189 OpenTelemetry 深度集成

设计精髓:四个工具的"组合模式"

这四个组件不是相互独立的 —— 它们被 lib/protoparser/promremotewrite/stream/parser.go 组合使用:

  • compress_reader.go 包 request.Body → 得到解压 reader
  • vmproto_handshake.go 解码握手 → 决定走 ZSTD 还是 SNAPPY 快路径
  • unmarshal_work.go 把整个解析任务扔到 worker pool
  • worker 里用流式解析 → 最终回调 callback([]TimeSeries, []MetricMetadata)

本节必记闭环逻辑(核心考点)

四大基础设施是协议层"通用底层":UnmarshalWorker Pool(CPU 并行)+ compress_reader(4 种压缩嗅探)+ extra_labels(URL 附加标签)+ vmproto_handshake(VM 私有握手)。每个组件都不超过 200 行,但被 15 个协议同时复用,是 VM 协议接入的"骨架"。

六、12 种协议全维度对比:实时性 · 数据模型 · 压缩 · 鉴权

思考记忆提示本节给出选型用的"协议对照表"。

  • 要点一:从 7 个维度(实时性、数据模型、压缩、鉴权、批量、流式、metadata)横向对比
  • 要点二:选协议不是"哪个最新",而是"哪个最匹配你的 producer"
协议接入路径传输数据模型压缩鉴权流式Metadata典型使用场景
Prometheus Remote Write /api/v1/write HTTP POST 标签 + (ts, value) ZSTD / Snappy Bearer Token ★ vmagent / Prometheus / Grafana Agent 主流选择
InfluxDB HTTP /influx/write HTTP POST measurement + tag + field + ts 无 / gzip 可选 Basic Telegraf / InfluxDB client 兼容
InfluxDB UDP/TCP :8089 UDP/TCP 同上 边缘采集、低功耗设备
OpenTelemetry OTLP /opentelemetry/v1/metrics HTTP POST / gRPC Resource + Instrumentation Scope + Metric gzip / 未压缩 Header Token 云原生新项目 / 自动注入 traceID
OpenTSDB put :4242 TCP telnet metric + ts + value + tag 无 / ASA HBase 老系统迁移
OpenTSDB HTTP /opentsdb/api/put HTTP POST JSON 同上 gzip OpenTSDB HTTP 客户端
Graphite plaintext :2003 TCP / UDP name.ts value 传统 Graphite 生态
DataDog v2 :8125 UDP / HTTP DD-Metric + tags msgpack/protobuf API Key DataDog agent 兼容
DataDog sketches :8125 UDP DataDog sketch DD sketch API Key P99 等分位数场景
New Relic :2003 HTTP+TCP event 风格 gzip Insert Key New Relic Infra agent 迁移
Zabbix connector :10052 TCP Zabbix sender protocol 共享密钥 Zabbix sender 直推
Prometheus exposition /prometheus/api/v1/import HTTP POST Prometheus 文本格式 gzip Bearer Token Prometheus 自身迁移
CSV import /api/v1/import HTTP POST CSV 行 gzip Bearer Token batch 一次性历史数据回填
Native binary /internal/import HTTP POST VM 自有二进制 本地限制 batch vmctl 导入 / 跨集群数据迁移

选型实战建议:

  • 大量 Prometheus 项目 → Prometheus Remote Write,vmagent 默认 ZSTD 压得快
  • 用 Telegraf 收集主机指标 → InfluxDB line protocol 最省事(Telegraf 原生支持)
  • 云原生应用新项目 → OpenTelemetry OTLP,未来 5 年不会被淘汰
  • 边缘设备、低带宽 → InfluxDB UDP,单包即可(但要承担数据丢失风险)
  • DataDog 生态迁移 → DataDog v2 + sketches,send_metadata=1 携带元数据
  • 历史数据从 InfluxDB / Prometheus 迁移到 VM → 用 vmctl,自动判别源协议

本节必记闭环逻辑(核心考点)

协议选型三问:① 我的 producer 支持什么?② 我需要 streaming 还是 batch?③ 我需要 metadata(元数据)吗?——答完这三问,协议选型就不再纠结。VM 的 15 个协议子包 + 12 个对外入口就是给各种 producer 一对一映射,这就是 VM 兼容性的本质。


★ FAQ 问答:协议接入层 20 问

思考记忆提示FAQ 是全篇的"临考前速背"模块,把 20 个高频问题集中起来。

  • Q1-Q6 围绕架构:vminsert 的协议子包结构、InsertCtx 池化、main.go 的 import 职责
  • Q7-Q13 围绕链路:五段式处理、TryPrepareLabels 短路、FlushBufs 时机、relabeling 顺序
  • Q14-Q20 围绕基础设施:Worker Pool 大小、压缩算法对比、VM proto 握手、extra labels 用法

Q1. vminsert 协议接入层到底包含多少个协议模块?

精确答案是 17 个 "代码模块",对外暴露 12+ 种"协议入口"。具体看 app/vminsert/ 目录:15 个协议子包 + main.go 自身(处理 /api/v1/write 主路径)+ common/InsertCtx。再加上 vmsingle 单节点模式也用同一套代码。

Q2. 为什么 main.go 只有 460 行却能注册这么多协议?

靠 Go 的 import 副作用 + 每个子包的 init() 自注册。app/vminsert/main.go 第 14-30 行 import 全部协议子包(只 import 不显式调用);每个子包都有自己的 init(),调 httpserver.MustGoHandler("/path", h) 把路径注册到全局路由器。Go 语言 import 副作用在生产里不被推荐用,但 VM 反而大量使用 —— 因为这条路能确保协议包永远不会被"忘记注册"。

Q3. InsertCtx 既然叫 "Ctx",它是 ThreadLocal 吗?

不是 ThreadLocal,是 sync.Pool 风格的 Pool。Go 里没有 ThreadLocal 概念,ctx 是 per-request 生命周期(GetInsertCtx() 时从池里取一个,defer PutInsertCtx() 时归还)。多个 goroutine 并行处理多个请求,每个请求的 ctx 是独立的 —— 通过对象池复用避免 GC 压力。

Q4. 五段式处理链路里,最容易被忽略的是哪一段?

第三段 TryPrepareLabels,因为它可能"静默丢弃"样本。返回值 false 的两个分支:① relabeling 把所有标签都 drop 掉了(len(ctx.Labels) == 0);② 超过 timeserieslimits 的每租户 series 数限制。这两种情况下请求继续处理(不报错),只是这部分样本被丢弃——客户端 没有任何反馈,只能从 /metrics 端点观测。

Q5. vminsert 为什么每个协议子包都只有一个 request_handler.go?

因为薄薄的胶水层不需要拆文件。每个协议子包做的事只有三件:① init() 注册路径;② 定义 InsertHandler(req) 入口;③ 实现 insertRows(parsed, extraLabels) 回调。所有协议子包的 insertRows 长得很像 —— 几乎只是"解析层数据结构"的字段映射不同。薄 = 容易测试,代码集中 = 易于维护。

Q6. main.go 在哪些情况下需要被改动?

几乎永远不用,除非新增"全局"功能。加新协议:写 app/vminsert/<newproto> 子包 → 在 main.go 加一行 import → 加一个 listen addr flag。删协议:反之。main.go 被改动的多数时候是优化并发参数(如 -maxConcurrentInserts)。

Q7. 五段式的 FlushBufs 真的会写盘吗?

不会,它只是把数据交给 vmstorage。FlushBufs 内部调 vmstorage.StorageAdd(ctx.mrs)(in-process 时是直接函数调用;Cluster 模式时是 gRPC/TCP)。真正落盘发生在 vmstorage 进程的 InmemoryPart(1 秒刷一次到 Small Part)——这部分在 #20 写入核心链路 详述。

Q8. relabeling 的执行顺序是固定的还是可配的?

顺序固定为 ApplyRelabeling()IsExceeding(limits)SortLabelsIfNeeded()这段在 app/vminsert/common/insert_ctx.go 第 137-150 行:先做 relabeling(可能 drop 完),再检查 timeserieslimits(防止恶意大 cardinality 攻击),最后排序保证 metricName 一致。顺序重要 —— relabeling 在前意味着 limits 检查的是"清洗后的标签数量",避免被攻击者用无关标签污染。

Q9. 为什么 ctx 的 Reset() 要复用底层数组而不是重新分配?

这是减少 GC 压力的关键 —— 一次性分配,二次利用。labels[:0]mrs[:0]metricNameBuf[:0] 都是在复用底层 cap。经过几次写入后,cap 会增长到当前请求的峰值大小;后续请求只需要"清空 + 重写",append() 不会触发分配。实测:在 5 万 samples/s 的请求频率下,这种设计把每分钟 GC 次数从几百次降到个位数。

Q10. 协议层和接入层如何复用?

解析层 lib/protoparser/<proto> 完全不依赖 vminsert,能被多个调用方复用。vmagent 用它压测、vmctl 用它迁移其他时序库、vmbackup 用它校验数据格式 —— 这就是单进程 monorepo 的红利。同一个 lib/protoparser/influx/stream/parser.go 在三个产品里都用。代价是任何 protoparser bug 会同时影响三个产品,所以 protoparser 的测试覆盖率和稳定性都很关键。

Q11. Worker Pool 的缓冲区为什么设成 gomaxprocs?

让提交者和消费者紧耦合 —— 提交者最多阻塞到一个 worker 空闲为止。这个设计比"无限大 channel"安全(不会无限堆积),比"channel 大小 = 1"高效(不会立刻阻塞)。当 worker 数 = CPU 核数时,CPU 永远不会被打满 —— 慢的 worker 就是慢的源头,不会有大量"待解析"任务堆积在内存里

Q12. 用了 Worker Pool 后,CPU 占用会到 100% 吗?

会,但要看场景。Worker 数 = cgroup.AvailableCPUs(),CPU-bound 工作会让 worker 持续运转。在 Docker 容器里 cgroup 自动识别 quota 限制,所以"--cpus=4" 时 worker 只有 4 个。裸机部署时 AvailableCPUs() 返回物理核数;如果想限流,可以用 GOMAXPROCS=4 ./vminsert 显式控制。

Q13. 为什么不直接调用 sync.Pool 而要自己实现 worker pool?

sync.Pool 是 GC-friendly 但不可控并发度,worker pool 是可控并发度sync.Pool 的 Get 会启动新 goroutine;高峰期会瞬间有几千个 goroutine,栈空间开销大。VM 自己实现的 worker pool 永远只有 gomaxprocs 个 goroutine,是有限流特性的,对 CPU-bound 工作最合适。

Q14. ZSTD 和 Snappy 在 Remote Write 里有什么区别?

ZSTD 压缩率更高(CPU 多一点),Snappy 压缩快(CPU 少一点)。实测:vmagent → vminsert 同等数据量下:ZSTD 压缩率约 3.5x(Snappy 约 2.5x),但 CPU 多用 30%。VM 默认走 ZSTD(vmagent 内部 -remoteWrite.zstdCompression=1),Prometheus 老 client 默认走 Snappy。

Q15. 四种压缩算法是怎么自动适配的?

通过 Content-Encoding HTTP header + magic bytes 双重确认。vminsert 在 lib/protoparser/protoparserutil/compress_reader.go 里前 4 字节嗅探:gzip (0x1f 0x8b)zstd (0x28 0xb5 0x2f 0xfd)snappy (0xff ...)lz4 (0x04 ...)。即使 client 谎报编码,vminsert 也能正确解析。

Q16. VM 私有 handshake 协商的具体内容是什么?

协商三件事:协议版本、压缩方式、是否使用 Arrow 列式编码。位置在 HTTP 请求体最前面 7 字节:

  • 1 字节:版本(当前为 0)
  • 4 字节:magic 'VMFW'
  • 1 字节:压缩方式(0=snappy, 1=zstd)
  • 1 字节:是否 Arrow

vmagent 通过查询参数 ?send_metadata=1 表明自己支持新协议,vminsert 同意后两者握手。Arrow 用于批量列存,避免每条 message 都单独 marshal。

Q17. extra_label 在多租户场景下怎么用?

URL query 注入 tenant 标签,无需修改原始 metric。典型配置:

remote_write:
  - url: http://vminsert:8480/api/v1/write?extra_label=tenant=acme&extra_label=env=prod

该 endpoint 下所有上报的样本都会自动加 tenant=acmeenv=prod 两个标签。vmagent 还支持更复杂的模板({{ .Cluster }} 这种变量)。这是 VM 做"零代码多租户"的关键。

Q18. vmagent 用什么协议连接 VM?

默认 Prometheus Remote Write + ZSTD + VM proto handshake。和 Prometheus 官方 client 用相同协议(HTTP + Prometheus Remote Write),但 vmagent 默认加 ZSTD 压缩(更省带宽)和 VM 私有 handshake(更准的元数据)。如果用 Prometheus 官方连 vminsert,走默认 Snappy 也能工作 —— vminsert 自动适配。

Q19. 生产环境 vminsert 一般怎么部署?

HTTP 端口一般只开 :8480 一个,根据 producer 数量启 TCP 端口(如 Graphite、OpenTSDB、New Relic)。典型配置文件(命令行参数):

  • -httpListenAddr=:8480 —— Prometheus / InfluxDB HTTP 主端口
  • -graphiteListenAddr=:2003 —— Graphite TCP/UDP
  • -opentsdbListenAddr=:4242 —— OpenTSDB telnet
  • -storageNode=<addr> —— vmstorage 地址(Cluster 模式必填)
  • -replicationFactor=N —— 副本数

Q20. 如果新加一种协议,要做哪些事?

三件事:① 写协议解析层;② 写接入层;③ 注册路径。具体步骤:

  1. lib/protoparser/newproto/ 新建目录,实现 stream.Parse(reader, args, callback)
  2. app/vminsert/newproto/ 新建目录,仿 influx 包写一个 request_handler.go
  3. app/vminsert/main.go 加一行 import 触发 init 注册
  4. 写 flag 让 listen addr 可配置
  5. 写 tests / 文档

完成上述 5 步,新协议就接通了所有 VM 能力:relabeling、timeserieslimits、streamaggr、多租户限速。是不是很简单?这是协议接入层设计"干净"的福利。

全篇必记总纲

一条数据从 producer 到 vmstorage 必须经过:协议解析层 lib/protoparser/<proto>(wire format → 内存结构) → 接入层 app/vminsert/<proto>(路径注册 + 回调胶水) → InsertCtx 五段式(池化 + Reset → 标签聚合 → TryPrepareLabels → WriteDataPointExt → FlushBufs) → vmstorage。15 个协议子包 + 12 个对外入口,背后只用一个 InsertCtx 模板,这就是 VM 协议接入层的全部秘密。


★ 后续预告:Roadmap

本篇是 B 部分"组件深潜篇"的开篇,协议接入层只是写入链路的最外层。后续我们将顺着数据流向下深挖:

  • #17 prompb 协议缓冲区:RemoteWrite Proto 定义详解,lib/prompb/ 内部 60 行实现为什么这么精简
  • #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())
  • #28 searchAndMerge:多分区并行搜索 + k-way 归并(从写入跳到查询对称设计)
  • #40 WAL-less 设计哲学:1 秒刷盘替代 WAL,写入路径的最后一道设计哲学

读完本系列,你将对 VM 的整条数据链路(写入、查询、合并、刷盘)有完整理解,对存储引擎的工程美学(每 1 纳秒都在抠)有极致感受。

posted @ 2026-07-01 03:55  左扬  阅读(26)  评论(0)    收藏  举报