Prometheus 源码专题【左扬精讲】—— Prometheus Exporter 定制化开发:编码器篇 —— 深入学习编码器工作机制
Prometheus 源码专题【左扬精讲】—— Prometheus Exporter 定制化开发:编码器篇 —— 深入学习编码器工作机制
在 Prometheus 监控体系中,Exporter 作为指标采集的核心组件,其最终输出的指标数据必须经过编码转换,才能通过 HTTP 协议返回给 Prometheus Server。而这一转换过程的核心引擎,正是编码器(Encoder)。本文将以 Prometheus 3.4.2 源码为基础,结合实际案例,深入解析 Exporter 编码器的工作机制、源码实现及定制化实践路径。
Prometheus Exporter 编码器是衔接指标数据与 HTTP 响应的关键枢纽。借助 expfmt(github.com/prometheus/common/expfmt) 包的抽象化设计,它实现了对多种数据格式的灵活支持。深入掌握编码器的工作原理,不仅能帮助开发者透彻理解 Exporter 的数据输出全流程,更为定制化需求(如扩展新型格式、优化编码性能等)提供了坚实的技术基础。
在实际开发场景中,需依据业务需求选择适配的编码格式,在可读性与传输性能间找到最佳平衡点,确保监控数据的高效采集与稳定传输。通过深耕源码实现逻辑,开发者能够更精准地利用并拓展 Prometheus 的编码能力,为构建高可靠的监控体系赋能。
一、核心功能:连接Metric与 HTTP 响应
在 Prometheus Exporter 的工作流程中,指标数据首先通过采集逻辑生成 MetricFamily 数组(包含多个指标组),随后需要通过编码器将其转换为客户端可识别的格式。Prometheus 3.4.2 支持 5 种主流编码格式,包括:
- 文本格式(Text Format):默认格式,人类可读,适用于调试和简单场景。
- Protocol Buffers(Protobuf):二进制格式,传输效率高,适用于大规模监控场景。
- OpenMetrics 文本格式:扩展的文本格式,支持更多元数据(如指标类型、单位)。
- Protobuf 压缩格式:基于 Protobuf 的压缩版本,进一步减少传输体积。
- OpenMetrics Protobuf 格式:结合 OpenMetrics 元数据与 Protobuf 效率的混合格式。
编码器的核心作用是实现 MetricFamily 到目标格式的序列化,并通过 HTTP 响应头的 Content-Type 标识输出格式。客户端(如 Prometheus Server)通过请求头的 Accept 字段指定期望的格式,Exporter 则根据该字段选择对应的编码器。
二、Prometheus 3.4.2 编码器源码架构学习
2.1、核心处理流程
编码器的核心实现在 https://github.com/prometheus/client_golang/blob/main/prometheus/promhttp/http.go#L193 中,主要处理流程如下:
// HandlerForTransactional 类似于 HandlerFor,但使用事务性采集器(TransactionalGatherer)。
// 事务性采集允许在调用 `Gather` 后且在 `done` 回调前安全地原地修改返回的 *dto.MetricFamily。
func HandlerForTransactional(reg prometheus.TransactionalGatherer, opts HandlerOpts) http.Handler {
var (
// 用于限制并发请求数的信号量通道
inFlightSem chan struct{}
// 记录处理过程中各类错误的计数器
errCnt = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "promhttp_metric_handler_errors_total",
Help: "Total number of internal errors encountered by the promhttp metric handler.",
},
[]string{"cause"}, // 错误原因标签:gathering 或 encoding
)
)
// 初始化并发请求限制
if opts.MaxRequestsInFlight > 0 {
inFlightSem = make(chan struct{}, opts.MaxRequestsInFlight)
}
// 注册错误计数器到指定注册表
if opts.Registry != nil {
// 预初始化所有可能的错误标签值
errCnt.WithLabelValues("gathering")
errCnt.WithLabelValues("encoding")
// 注册错误计数器,处理已注册的情况
if err := opts.Registry.Register(errCnt); err != nil {
are := &prometheus.AlreadyRegisteredError{}
if errors.As(err, are) {
errCnt = are.ExistingCollector.(*prometheus.CounterVec)
} else {
panic(err) // 非预期错误,直接 panic
}
}
}
// 确定支持的压缩格式(基于用户配置或默认值)
var compressions []string
if !opts.DisableCompression {
offers := defaultCompressionFormats()
if len(opts.OfferedCompressions) > 0 {
offers = opts.OfferedCompressions
}
for _, comp := range offers {
compressions = append(compressions, string(comp))
}
}
// 构建 HTTP 请求处理函数
h := http.HandlerFunc(func(rsp http.ResponseWriter, req *http.Request) {
// 设置进程启动时间头(如果已配置)
if !opts.ProcessStartTime.IsZero() {
rsp.Header().Set(processStartTimeHeader, strconv.FormatInt(opts.ProcessStartTime.Unix(), 10))
}
// 并发请求限制处理
if inFlightSem != nil {
select {
case inFlightSem <- struct{}{}: // 成功获取信号量,继续处理
defer func() { <-inFlightSem }() // 处理完成后释放信号量
default:
// 达到并发限制,返回 503 错误
http.Error(rsp, fmt.Sprintf(
"Limit of concurrent requests reached (%d), try again later.", opts.MaxRequestsInFlight,
), http.StatusServiceUnavailable)
return
}
}
// 采集指标(事务性)
mfs, done, err := reg.Gather()
defer done() // 确保资源释放
// 处理采集错误
if err != nil {
if opts.ErrorLog != nil {
opts.ErrorLog.Println("error gathering metrics:", err)
}
errCnt.WithLabelValues("gathering").Inc()
switch opts.ErrorHandling {
case PanicOnError:
panic(err) // 遇到错误直接 panic
case ContinueOnError:
if len(mfs) == 0 {
// 没有采集到任何指标,返回错误
httpError(rsp, err)
return
}
// 否则继续处理已采集的指标
case HTTPErrorOnError:
httpError(rsp, err) // 返回 HTTP 错误
return
}
}
// 注意这里:========> 在 HandlerForTransactional 函数中的核心编码逻辑 <=======
// 根据请求头协商内容类型(支持 OpenMetrics 或传统格式)
var contentType expfmt.Format
if opts.EnableOpenMetrics {
contentType = expfmt.NegotiateIncludingOpenMetrics(req.Header)
} else {
contentType = expfmt.Negotiate(req.Header)
}
rsp.Header().Set(contentTypeHeader, string(contentType))
// 协商并设置响应编码(压缩)
w, encodingHeader, closeWriter, err := negotiateEncodingWriter(req, rsp, compressions)
if err != nil {
if opts.ErrorLog != nil {
opts.ErrorLog.Println("error getting writer", err)
}
w = io.Writer(rsp) // 回退到未压缩写入
encodingHeader = string(Identity)
}
defer closeWriter() // 确保资源释放
// 设置 Content-Encoding 头(仅在启用压缩时)
if encodingHeader != string(Identity) {
rsp.Header().Set(contentEncodingHeader, encodingHeader)
}
// 注意这里:========> 创建编码器 <=======
// 创建指标编码器(支持 OpenMetrics 的 created 时间戳)
var enc expfmt.Encoder
if opts.EnableOpenMetricsTextCreatedSamples {
enc = expfmt.NewEncoder(w, contentType, expfmt.WithCreatedLines())
} else {
enc = expfmt.NewEncoder(w, contentType)
}
// 错误处理辅助函数(根据配置决定是否终止处理)
handleError := func(err error) bool {
if err == nil {
return false
}
if opts.ErrorLog != nil {
opts.ErrorLog.Println("error encoding and sending metric family:", err)
}
errCnt.WithLabelValues("encoding").Inc()
switch opts.ErrorHandling {
case PanicOnError:
panic(err) // 遇到错误直接 panic
case HTTPErrorOnError:
// 已开始响应,无法修改状态码,只能终止发送
return true
}
// ContinueOnError 模式下忽略错误,继续处理后续指标
return false
}
// 注意这里:========> 编码每个 MetricFamily <=======
for _, mf := range mfs {
if handleError(enc.Encode(mf)) {
return
}
}
// 关闭编码器(确保 OpenMetrics 格式的完整性)
if closer, ok := enc.(expfmt.Closer); ok {
// 特别是为 OpenMetrics 添加最后的 "# EOF\n" 行
if handleError(closer.Close()) {
return
}
}
})
// 应用超时处理(如果配置了超时时间)
if opts.Timeout <= 0 {
return h
}
return http.TimeoutHandler(h, opts.Timeout, fmt.Sprintf(
"Exceeded configured timeout of %v.\n",
opts.Timeout,
))
}
2.2、内容协商机制
编码器通过 expfmt.Negotiate(https://github.com/prometheus/client_golang/blob/main/prometheus/promhttp/http.go#L197) 函数根据客户端的 Accept 头选择最合适的编码格式:
// 声明 contentType 变量,用于存储协商后的指标格式类型
var contentType expfmt.Format
// 根据配置决定是否启用 OpenMetrics 格式支持
if opts.EnableOpenMetrics {
// 使用支持 OpenMetrics 的协商函数,处理 Accept 头中包含的 OpenMetrics 格式
// 例如:Accept: application/openmetrics-text; version=1.0.0,text/plain;version=0.0.4;q=0.9
contentType = expfmt.NegotiateIncludingOpenMetrics(req.Header)
} else {
// 仅支持传统 Prometheus 文本格式
// 例如:Accept: text/plain;version=0.0.4
contentType = expfmt.Negotiate(req.Header)
}
关键说明(此逻辑确保了 Prometheus 生态系统中不同版本客户端与服务端的互操作性):
OpenMetrics 支持:通过 opts.EnableOpenMetrics 配置项控制是否支持 OpenMetrics 格式。启用后:
-
-
-
- 支持 application/openmetrics-text; version=1.0.0 内容类型
- 支持 # EOF 结束标记
- 支持 _created 时间戳等扩展字段
-
-
内容协商逻辑:
-
-
-
- 客户端通过 HTTP 请求头 Accept 指定支持的格式
- 服务端根据自身能力选择最匹配的格式
- 返回优先级:OpenMetrics > Prometheus Text 0.0.4
-
-
典型 Accept 头示例:
Accept: application/openmetrics-text; version=1.0.0,text/plain;version=0.0.4;q=0.9
- 优先使用 OpenMetrics 格式(权重最高)
- 其次使用 Prometheus 文本格式 0.0.4
兼容性:
-
-
-
- 启用 OpenMetrics 支持后,仍能兼容传统 Prometheus 客户端
- 传统客户端会忽略 OpenMetrics 特有的元数据(如 # EOF)
- 此逻辑确保了 Prometheus 生态系统中不同版本客户端与服务端的互操作性。
-
-
2.3、压缩处理
Prometheus/client_golang 1.22.0 支持多种压缩格式,通过 negotiateEncodingWriter(https://github.com/prometheus/client_golang/blob/v1.22.0/prometheus/promhttp/http.go#L465) 函数处理:
// negotiateEncodingWriter reads the Accept-Encoding header from a request and
// selects the right compression based on an allow-list of supported
// compressions. It returns a writer implementing the compression and an the
// correct value that the caller can set in the response header.
// 从 HTTP 请求的 Accept-Encoding 头中读取客户端支持的压缩格式。
// 根据服务器支持的压缩格式列表(允许列表),选择最合适的压缩格式。
// 返回一个实现了所选压缩格式的写入器,以及一个可以设置在响应头中的编码值,供调用者使用。
func negotiateEncodingWriter(r *http.Request, rw io.Writer, compressions []string) (_ io.Writer, encodingHeaderValue string, closeWriter func(), _ error) {
// 检查支持的压缩格式列表是否为空
if len(compressions) == 0 {
// 如果为空,说明不支持任何压缩,直接返回原始的写入器
// 编码头值设置为 "identity",表示内容未压缩
// 关闭写入器的函数为空函数
// 错误为 nil
return rw, string(Identity), func() {}, nil
}
// TODO(mrueg): Replace internal/github.com/gddo once https://github.com/golang/go/issues/19307 is implemented.
// 调用 NegotiateContentEncoding 函数,根据请求的 Accept-Encoding 头和支持的压缩格式列表
// 选择最合适的压缩格式
selected := httputil.NegotiateContentEncoding(r, compressions)
// 根据选择的压缩格式进行不同的处理
switch selected {
case "zstd":
// 检查是否已经实现了 zstd 压缩
if internal.NewZstdWriter == nil {
// 如果未实现,返回错误信息,提示内容压缩格式未被识别
// 并列出有效的压缩格式
return nil, "", func() {}, fmt.Errorf("content compression format not recognized: %s. Valid formats are: %s", selected, defaultCompressionFormats())
}
// 调用 internal.NewZstdWriter 函数创建一个 zstd 写入器
// 返回写入器、关闭写入器的函数和可能的错误
writer, closeWriter, err := internal.NewZstdWriter(rw)
return writer, selected, closeWriter, err
case "gzip":
// 从 gzip 池(gzipPool)中获取一个 gzip 写入器
gz := gzipPool.Get().(*gzip.Writer)
// 重置 gzip 写入器,使其可以用于新的写入操作
gz.Reset(rw)
// 返回 gzip 写入器、编码头值为 "gzip"
// 关闭写入器的函数会先关闭 gzip 写入器,然后将其放回 gzip 池
return gz, selected, func() { _ = gz.Close(); gzipPool.Put(gz) }, nil
case "identity":
// 表示内容未压缩,直接返回原始的写入器
// 编码头值为 "identity"
// 关闭写入器的函数为空函数
return rw, selected, func() {}, nil
default:
// 如果选择的压缩格式不在支持的范围内,返回错误信息
// 提示内容压缩格式未被识别,并列出有效的压缩格式
return nil, "", func() {}, fmt.Errorf("content compression format not recognized: %s. Valid formats are: %s", selected, defaultCompressionFormats())
}
}
三、编码器实现细节
3.1、支持的编码方式
从源码分析,Prometheus 1.22.0 支持以下编码格式:
-
- 文本格式:text/plain; version=0.0.4; charset=utf-8
- Protobuf 格式:application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited
- OpenMetrics 格式:application/openmetrics-text; version=1.0.0; charset=utf-8
3.2、压缩支持
支持的压缩格式:
-
-
- Identity:无压缩
- Gzip:gzip 压缩
- Zstd:zstd 压缩(需要导入 _ "github.com/prometheus/client_golang/prometheus/promhttp/zstd")
-
3.3、错误处理机制
编码器实现了完善的错误处理机制:
// 在 promhttp/http.go 中的错误处理
handleError := func(err error) bool {
if err == nil {
return false
}
if opts.ErrorLog != nil {
opts.ErrorLog.Println("error encoding and sending metric family:", err)
}
errCnt.WithLabelValues("encoding").Inc()
switch opts.ErrorHandling {
case PanicOnError:
panic(err)
case HTTPErrorOnError:
return true
}
return false
}
四、实战:自定义 Exporter 中的编码器应用
4.1、基本 Exporter 实现
package main
import (
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func main() {
// 定义指标。创建一个名为 demo_counter 的计数器指标,这是 Prometheus 中最基本的指标类型,用于记录单调递增的数值。
demoCounter := prometheus.NewCounter(prometheus.CounterOpts{
Name: "demo_counter",
Help: "A simple demo counter",
})
// 注册指标。使用 MustRegister 函数将质保注册到 Prometheus 的默认注册表中。如果注册失败的哈,则触发 panic。
prometheus.MustRegister(demoCounter)
// 模拟指标增长。启动一个 goroutine 来每秒增加计数器的值。Inc()方法将计数器加1,Inc()方法是 Prometheus 客户端库中计数器(Counter)类型的一个方法,用于将计数器的值增加1。
go func() {
for {
demoCounter.Inc()
time.Sleep(time.Second)
}
}()
// 注册 HTTP handler
http.Handle("/metrics", promhttp.Handler()) // 使用 promhttp.Handler()创建一个处理 Prometheus 指标请求的 HTTP 处理器,并将其注册到 /metrics 路径。
http.ListenAndServe(":8080", nil)
}
4.2、编码器工作流程分析
当客户端请求 /metrics(https://github.com/prometheus/client_golang/blob/v1.22.0/examples/middleware/main.go#L41)端点时,编码器的工作流程如下:
- 请求解析:解析客户端的 Accept 头
- 格式协商:通过 expfmt.Negotiate 选择最佳编码格式
- 压缩协商:通过 negotiateEncodingWriter 选择压缩格式
- 指标收集:调用 reg.Gather() 收集所有指标
- 编码输出:使用选定的编码器将指标序列化并输出
4.3、自定义编码器实践
如果需要支持自定义格式,可以通过实现 expfmt.Encoder(https://github.com/prometheus/client_golang/blob/v1.22.0/prometheus/promhttp/http.go#L217) 接口:
// 自定义 JSON 编码器示例
type jsonEncoder struct {
w io.Writer
}
func (j *jsonEncoder) Encode(mf *dto.MetricFamily) error {
// 将 MetricFamily 转换为 JSON 格式
data, err := json.Marshal(mf)
if err != nil {
return err
}
_, err = j.w.Write(data)
return err
}
func (j *jsonEncoder) Close() error {
return nil
}
五、编码器选型与最佳实践
5.1 格式选择建议
-
- 调试场景:优先使用文本格式(text/plain),便于人工阅读
- 生产环境:推荐 Protobuf 格式,提升传输效率
- 兼容性需求:若需与支持 OpenMetrics 的系统集成,选择 OpenMetrics 格式
5.2 性能优化
-
- 高并发场景:避免频繁创建编码器实例,可通过单例模式复用
- 大规模指标:优先使用 Protobuf 压缩格式减少网络开销
- 压缩选择:zstd 压缩比 gzip 更高,但 CPU 消耗也更高
5.3 版本兼容性
-
- Prometheus 1.22.0 对文本格式的默认版本为 0.0.4
- OpenMetrics 支持需要显式启用 EnableOpenMetrics 选项
- 压缩格式需要相应的导入包支持
六、Prometheus Exporter 编码器源码架构
6.1 核心组件
-
- promhttp.HandlerFor:主要的 HTTP 处理器工厂函数
- expfmt.Negotiate:内容格式协商
- expfmt.NewEncoder:编码器创建
- negotiateEncodingWriter:压缩格式协商
6.2 设计模式
-
- 工厂模式:通过 HandlerFor 创建处理器
- 策略模式:根据请求头选择不同的编码策略
- 装饰器模式:通过中间件添加功能(如压缩、监控)
6.3 扩展点
-
- 自定义编码器:实现 expfmt.Encoder 接口
- 自定义压缩:实现 internal.NewZstdWriter 函数
- 自定义错误处理:通过 HandlerOpts 配置

浙公网安备 33010602011771号