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.go 的 InsertCtx,约 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.go 的 Init() 函数(第 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.go、app/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。我们用代码引用来看真实结构:
关键源码引用 1:app/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 模式:
关键源码引用 2:app/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 生命周期:GetInsertCtx → Reset(rowsLen) → 处理 → FlushBufs → PutInsertCtx
无论是什么协议(Influx、Prometheus、OTel、OpenTSDB ...),写完数据最终都必须经过同一个数据结构 —— InsertCtx。我们看 app/vminsert/common/insert_ctx.go 第 52 行的 type InsertCtx struct 起:
关键源码引用 3:app/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):
关键源码引用 4:app/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.go 的 InsertHandler(第 23 行)和 insertRows(第 34 行):
关键源码引用 5:app/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 := ×eries[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 := ×eries[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 行起:
关键源码引用 6:app/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 流式解析器:
关键源码引用 7:app/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。我们看它的入口:
关键源码引用 8:app/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:
关键源码引用 9:lib/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。典型流程:
- 读前 4 字节 magic:gzip (0x1f 0x8b)、zstd (0x28 0xb5 0x2f 0xfd)、snappy (0xff ...)、lz4 (0x04 ...)
- 把 reader 包成对应解压 reader
- 继续传给 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 参数里提取附加标签,附加到每条样本。代码非常小:
关键源码引用 10:lib/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=acme、env=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. 如果新加一种协议,要做哪些事?
三件事:① 写协议解析层;② 写接入层;③ 注册路径。具体步骤:
- 在 lib/protoparser/newproto/ 新建目录,实现 stream.Parse(reader, args, callback)
- 在 app/vminsert/newproto/ 新建目录,仿 influx 包写一个 request_handler.go
- 在 app/vminsert/main.go 加一行 import 触发 init 注册
- 写 flag 让 listen addr 可配置
- 写 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 纳秒都在抠)有极致感受。

浙公网安备 33010602011771号