【左扬精讲】| kafka_exporter v1.9.0 源码专题 | 架构设计与实现原理全解析
【左扬精讲】| kafka_exporter v1.9.0 源码专题 | 架构设计与实现原理全解析
https://github.com/danielqsj/kafka_exporter/tree/v1.9.0
Kafka 作为分布式消息队列的事实标准,其可观测性是保障集群稳定运行的核心。Kafka Exporter 作为 Prometheus 生态中最主流的 Kafka 指标采集工具,能够全面采集集群、Topic、消费组等核心指标。本文将从源码角度深度剖析 Kafka Exporter 的架构设计与实现原理,帮助开发者理解其核心逻辑,快速上手二次开发或问题排查。
一、Kafka Exporter 核心定位
Kafka Exporter 的核心目标是遵循 Prometheus 规范采集 Kafka 集群指标,并通过 HTTP/HTTPS 暴露指标接口,核心能力包括:
- 集群层:Broker 数量、Broker 信息等基础指标
- Topic 层:分区数、Offset、副本数、ISR、副本不同步等指标
- 消费组层:Offset、消费延迟(Lag)、成员数等核心指标
- 多认证支持:SASL/PLAIN、SCRAM、Kerberos、AWS IAM、TLS
- 兼容适配:支持 ZooKeeper 模式(老版本 Kafka)和新版本消费组 API
二、代码整体架构设计
Kafka Exporter 的架构遵循“模块化、接口化、配置驱动”的设计原则,整体可分为 5 大核心模块,架构图如下:
┌─────────────────────────────────────────────────────────────┐
│ 命令行参数解析层 │
│ (kingpin/flag) 处理配置 → 生成kafkaOpts配置结构体 │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────────┐
│ Kafka客户端层 │
│ (sarama) 封装Kafka连接、认证、元数据管理 │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────────┐
│ 指标采集核心层 │
│ (Exporter结构体) 实现Prometheus Collector接口,采集所有指标 │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────────┐
│ 并发控制层 │
│ (协程池/互斥锁) 控制采集并发,避免集群压力 │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────────────▼─────────────────────────────────┐
│ HTTP服务层 │
│ (net/http) 暴露/metrics接口、健康检查、HTTPS支持 │
└─────────────────────────────────────────────────────────────┘
-
- 接口化:核心 Exporter 结构体实现 Prometheus 的Collector接口,天然兼容 Prometheus 生态
- 配置驱动:所有行为通过命令行参数控制,无硬编码,适配不同环境
- 性能优先:元数据缓存、协程池、非并发共享采集结果等优化,适配大集群
- 兼容适配:支持 Kafka 多版本协议、多种认证方式、ZooKeeper / 新版本消费组双模式
三、代码核心模块源码解读
3.1、包导入与常量定义
https://github.com/danielqsj/kafka_exporter/blob/master/kafka_exporter.go#L45
package main import ( "context" // 上下文管理,用于AWS IAM token生成 "crypto/tls" // TLS加密 "crypto/x509" // X.509证书解析 "flag" // 命令行参数解析(基础) "fmt" // 格式化输出 "log" // 基础日志 "net/http" // HTTP服务 "os" // 操作系统交互 "regexp" // 正则表达式(过滤topic/消费组) "strconv" // 字符串/数值转换 "strings" // 字符串处理 "sync" // 同步原语(锁、WaitGroup) "time" // 时间处理 "github.com/IBM/sarama" // Kafka Go客户端核心库 kingpin "github.com/alecthomas/kingpin/v2" // 增强型命令行参数解析 "github.com/aws/aws-msk-iam-sasl-signer-go/signer" // AWS MSK IAM认证 "github.com/krallistic/kazoo-go" // ZooKeeper客户端(兼容老版本消费组) "github.com/pkg/errors" // 错误包装(增强错误信息) "github.com/prometheus/client_golang/prometheus" // Prometheus指标核心库 "github.com/prometheus/client_golang/prometheus/promhttp" // Prometheus HTTP handler plog "github.com/prometheus/common/promlog" // Prometheus日志配置 plogflag "github.com/prometheus/common/promlog/flag" // Prometheus日志命令行参数 versionCollector "github.com/prometheus/client_golang/prometheus/collectors/version" // 版本指标采集器 "github.com/prometheus/common/version" // 版本信息 "github.com/rcrowley/go-metrics" // 指标库(此处禁用) "k8s.io/klog/v2" // K8s风格日志(支持分级日志) ) // 常量定义:指标命名空间、客户端ID const ( namespace = "kafka" // Prometheus指标前缀(如kafka_brokers) clientID = "kafka_exporter" // Kafka客户端标识 ) // 日志级别常量 const ( INFO = 0 // 普通日志 DEBUG = 1 // 调试日志 TRACE = 2 // 追踪日志(最详细) )
关键性讲解:
-
-
- sarama是 Kafka Go 客户端的事实标准,几乎所有 Kafka Go 项目都基于它
- kingpin比原生flag更强大,支持参数默认值、类型校验、子命令等
- klog支持分级日志(V (1)、V (2)),适合不同环境的日志输出控制
- namespace是 Prometheus 指标的命名规范,确保指标唯一性
-
3.2、Prometheus 指标描述符定义
https://github.com/danielqsj/kafka_exporter/blob/master/kafka_exporter.go#L45
var ( // 集群级别指标 clusterBrokers *prometheus.Desc // Kafka broker总数 clusterBrokerInfo *prometheus.Desc // Broker信息(ID、地址) // Topic级别指标 topicPartitions *prometheus.Desc // Topic分区数 topicCurrentOffset *prometheus.Desc // Topic分区最新offset topicOldestOffset *prometheus.Desc // Topic分区最旧offset topicPartitionLeader *prometheus.Desc // Topic分区leader broker ID topicPartitionReplicas *prometheus.Desc // Topic分区副本数 topicPartitionInSyncReplicas *prometheus.Desc // Topic分区ISR(同步副本)数 topicPartitionUsesPreferredReplica *prometheus.Desc // 是否使用首选副本(1=是,0=否) topicUnderReplicatedPartition *prometheus.Desc // 是否存在副本数不足(1=是,0=否) // 消费组级别指标 consumergroupCurrentOffset *prometheus.Desc // 消费组当前offset consumergroupCurrentOffsetSum *prometheus.Desc // 消费组某topic所有分区offset总和 consumergroupLag *prometheus.Desc // 消费组lag(消费延迟) consumergroupLagSum *prometheus.Desc // 消费组某topic所有分区lag总和 consumergroupLagZookeeper *prometheus.Desc // ZooKeeper模式下的消费组lag consumergroupMembers *prometheus.Desc // 消费组成员数 )
关键讲解:
-
-
- prometheus.Desc是 Prometheus 指标的身份说明元数据描述(里面会写清楚指标叫啥名、有啥用,还有带哪些标签)
- 指标标签设计遵循 Prometheus 最佳实践:
- 集群级别:无标签 / 基础标签
- Topic 级别:topic、partition标签
- 消费组级别:consumergroup、topic、partition标签
-
3.3、核心结构体定义
https://github.com/danielqsj/kafka_exporter/blob/master/kafka_exporter.go#L65
// Exporter:Kafka指标导出器核心结构体(实现prometheus.Collector接口)
type Exporter struct {
client sarama.Client // Kafka客户端(sarama核心)
topicFilter *regexp.Regexp // Topic包含过滤正则
topicExclude *regexp.Regexp // Topic排除过滤正则
groupFilter *regexp.Regexp // 消费组包含过滤正则
groupExclude *regexp.Regexp // 消费组排除过滤正则
mu sync.Mutex // 通用互斥锁(保护共享变量)
useZooKeeperLag bool // 是否使用ZooKeeper获取消费组lag
zookeeperClient *kazoo.Kazoo // ZooKeeper客户端
nextMetadataRefresh time.Time // 下次元数据刷新时间
metadataRefreshInterval time.Duration // 元数据刷新间隔
offsetShowAll bool // 是否显示所有消费组offset(包括未连接的)
topicWorkers int // 采集topic指标的协程数
allowConcurrent bool // 是否允许并发采集(大集群禁用)
sgMutex sync.Mutex // 并发控制专用锁
sgWaitCh chan struct{} // 并发等待通道
sgChans []chan<- prometheus.Metric // 并发采集的指标通道
consumerGroupFetchAll bool // 是否支持全量消费组抓取(Kafka 2.0+)
}
// kafkaOpts:Kafka连接配置结构体(整合所有命令行参数)
type kafkaOpts struct {
uri []string // Kafka broker地址列表
useSASL bool // 是否启用SASL认证
useSASLHandshake bool // SASL握手(非Kafka代理需关闭)
saslUsername string // SASL用户名
saslPassword string // SASL密码
saslMechanism string // SASL机制(scram-sha256/512、gssapi、awsiam、plain)
saslDisablePAFXFast bool // Kerberos禁用PA_FX_FAST
saslAwsRegion string // AWS MSK区域
useTLS bool // Kafka客户端启用TLS
tlsServerName string // TLS服务器名(证书校验)
tlsCAFile string // TLS CA文件
tlsCertFile string // TLS客户端证书
tlsKeyFile string // TLS客户端私钥
serverUseTLS bool // Exporter自身HTTP服务启用TLS
serverMutualAuthEnabled bool // Exporter启用TLS双向认证
serverTlsCAFile string // Exporter TLS CA文件
serverTlsCertFile string // Exporter TLS证书
serverTlsKeyFile string // Exporter TLS私钥
tlsInsecureSkipTLSVerify bool // 跳过TLS证书校验(测试用)
kafkaVersion string // Kafka版本(兼容不同协议)
useZooKeeperLag bool // 使用ZooKeeper获取lag
uriZookeeper []string // ZooKeeper地址列表
labels string // 自定义标签(如集群名)
metadataRefreshInterval string // 元数据刷新间隔(字符串格式)
serviceName string // Kerberos服务名
kerberosConfigPath string // Kerberos配置路径
realm string // Kerberos域
keyTabPath string // Kerberos keytab文件
kerberosAuthType string // Kerberos认证类型(keytab/user)
offsetShowAll bool // 显示所有消费组offset
topicWorkers int // Topic采集协程数
allowConcurrent bool // 允许并发采集
allowAutoTopicCreation bool // 允许自动创建topic
verbosityLogLevel int // 日志级别
}
// MSKAccessTokenProvider:AWS MSK IAM认证的Token提供者(实现sarama.AccessTokenProvider接口)
type MSKAccessTokenProvider struct {
region string // AWS区域
}
关键讲解:
-
-
- Exporter 实现了prometheus.Collector接口(Describe+Collect方法),是 Prometheus 采集器的核心规范
- kafkaOpts是典型的 "配置聚合" 模式,将所有命令行参数整合为结构体,避免函数参数爆炸
- MSKAccessTokenProvider适配 sarama 的AccessTokenProvider接口,实现 AWS IAM 动态生成认证 Token
-
3.4、证书校验
https://github.com/danielqsj/kafka_exporter/blob/master/kafka_exporter.go#L133
// CanReadCertAndKey:校验证书和私钥文件是否可读(成对存在)
func CanReadCertAndKey(certPath, keyPath string) (bool, error) {
certReadable := canReadFile(certPath) // 检查证书文件
keyReadable := canReadFile(keyPath) // 检查私钥文件
if !certReadable && !keyReadable {
return false, nil // 都不存在,返回false(无需报错)
}
if !certReadable {
return false, fmt.Errorf("error reading %s, certificate and key must be supplied as a pair", certPath)
}
if !keyReadable {
return false, fmt.Errorf("error reading %s, certificate and key must be supplied as a pair", keyPath)
}
return true, nil
}
// canReadFile:检查文件是否存在且可读
func canReadFile(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close() // 必须关闭文件句柄,避免泄漏
return true
}
关键讲解:
-
-
- 证书校验是 TLS 认证的基础,确保客户端 / 服务端证书成对存在
- defer f.Close()是 Go 的经典用法,确保资源释放(即使函数出错)
-
3.5、Exporter 初始化(核心)
https://github.com/danielqsj/kafka_exporter/blob/master/kafka_exporter.go#L166
// NewExporter:创建并初始化Exporter实例
func NewExporter(opts kafkaOpts, topicFilter string, topicExclude string, groupFilter string, groupExclude string) (*Exporter, error) {
var zookeeperClient *kazoo.Kazoo // ZooKeeper客户端(按需初始化)
config := sarama.NewConfig() // 创建sarama配置
config.ClientID = clientID // 设置Kafka客户端ID
// 解析Kafka版本(兼容不同协议)
kafkaVersion, err := sarama.ParseKafkaVersion(opts.kafkaVersion)
if err != nil {
return nil, err
}
config.Version = kafkaVersion
// ========== SASL认证配置 ==========
if opts.useSASL {
opts.saslMechanism = strings.ToLower(opts.saslMechanism) // 统一转为小写(兼容大小写输入)
saslPassword := opts.saslPassword
if saslPassword == "" {
saslPassword = os.Getenv("SASL_USER_PASSWORD") // 环境变量优先级更高(安全)
}
// 根据SASL机制配置
switch opts.saslMechanism {
case "scram-sha512": // SCRAM-SHA512
config.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient { return &XDGSCRAMClient{HashGeneratorFcn: SHA512} }
config.Net.SASL.Mechanism = sarama.SASLMechanism(sarama.SASLTypeSCRAMSHA512)
case "scram-sha256": // SCRAM-SHA256
config.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient { return &XDGSCRAMClient{HashGeneratorFcn: SHA256} }
config.Net.SASL.Mechanism = sarama.SASLMechanism(sarama.SASLTypeSCRAMSHA256)
case "gssapi": // Kerberos
config.Net.SASL.Mechanism = sarama.SASLMechanism(sarama.SASLTypeGSSAPI)
config.Net.SASL.GSSAPI.ServiceName = opts.serviceName
config.Net.SASL.GSSAPI.KerberosConfigPath = opts.kerberosConfigPath
config.Net.SASL.GSSAPI.Realm = opts.realm
config.Net.SASL.GSSAPI.Username = opts.saslUsername
// Kerberos认证类型(keytab/用户密码)
if opts.kerberosAuthType == "keytabAuth" {
config.Net.SASL.GSSAPI.AuthType = sarama.KRB5_KEYTAB_AUTH
config.Net.SASL.GSSAPI.KeyTabPath = opts.keyTabPath
} else {
config.Net.SASL.GSSAPI.AuthType = sarama.KRB5_USER_AUTH
config.Net.SASL.GSSAPI.Password = saslPassword
}
if opts.saslDisablePAFXFast {
config.Net.SASL.GSSAPI.DisablePAFXFAST = true
}
case "awsiam": // AWS MSK IAM认证
config.Net.SASL.Mechanism = sarama.SASLMechanism(sarama.SASLTypeOAuth)
config.Net.SASL.TokenProvider = &MSKAccessTokenProvider{region: opts.saslAwsRegion}
case "plain": // PLAIN(基础认证)
default:
return nil, fmt.Errorf(
`invalid sasl mechanism "%s": can only be "scram-sha256", "scram-sha512", "gssapi", "awsiam" or "plain"`,
opts.saslMechanism,
)
}
// 启用SASL基础配置
config.Net.SASL.Enable = true
config.Net.SASL.Handshake = opts.useSASLHandshake
if opts.saslUsername != "" {
config.Net.SASL.User = opts.saslUsername
}
if saslPassword != "" {
config.Net.SASL.Password = saslPassword
}
}
// ========== TLS配置 ==========
if opts.useTLS {
config.Net.TLS.Enable = true
config.Net.TLS.Config = &tls.Config{
ServerName: opts.tlsServerName, // 证书校验的服务器名
InsecureSkipVerify: opts.tlsInsecureSkipTLSVerify, // 跳过证书校验(测试用)
}
// 加载CA证书(自定义CA)
if opts.tlsCAFile != "" {
if ca, err := os.ReadFile(opts.tlsCAFile); err == nil {
config.Net.TLS.Config.RootCAs = x509.NewCertPool()
config.Net.TLS.Config.RootCAs.AppendCertsFromPEM(ca)
} else {
return nil, err
}
}
// 加载客户端证书(双向认证)
canReadCertAndKey, err := CanReadCertAndKey(opts.tlsCertFile, opts.tlsKeyFile)
if err != nil {
return nil, errors.Wrap(err, "error reading cert and key")
}
if canReadCertAndKey {
cert, err := tls.LoadX509KeyPair(opts.tlsCertFile, opts.tlsKeyFile)
if err == nil {
config.Net.TLS.Config.Certificates = []tls.Certificate{cert}
} else {
return nil, err
}
}
}
// ========== ZooKeeper配置(兼容老版本消费组) ==========
if opts.useZooKeeperLag {
klog.V(DEBUG).Infoln("Using zookeeper lag, so connecting to zookeeper")
zookeeperClient, err = kazoo.NewKazoo(opts.uriZookeeper, nil)
if err != nil {
return nil, errors.Wrap(err, "error connecting to zookeeper")
}
}
// ========== 元数据刷新配置 ==========
interval, err := time.ParseDuration(opts.metadataRefreshInterval)
if err != nil {
return nil, errors.Wrap(err, "Cannot parse metadata refresh interval")
}
config.Metadata.RefreshFrequency = interval // sarama元数据刷新频率
config.Metadata.AllowAutoTopicCreation = opts.allowAutoTopicCreation // 禁止自动创建topic(生产环境推荐)
// ========== 创建Kafka客户端 ==========
client, err := sarama.NewClient(opts.uri, config)
if err != nil {
return nil, errors.Wrap(err, "Error Init Kafka Client")
}
klog.V(TRACE).Infoln("Done Init Clients")
// ========== 初始化Exporter ==========
return &Exporter{
client: client,
topicFilter: regexp.MustCompile(topicFilter), // 编译正则(预编译提升性能)
topicExclude: regexp.MustCompile(topicExclude),
groupFilter: regexp.MustCompile(groupFilter),
groupExclude: regexp.MustCompile(groupExclude),
useZooKeeperLag: opts.useZooKeeperLag,
zookeeperClient: zookeeperClient,
nextMetadataRefresh: time.Now(), // 下次刷新时间(初始为当前时间)
metadataRefreshInterval: interval,
offsetShowAll: opts.offsetShowAll,
topicWorkers: opts.topicWorkers,
allowConcurrent: opts.allowConcurrent,
sgMutex: sync.Mutex{},
sgWaitCh: nil,
sgChans: []chan<- prometheus.Metric{},
consumerGroupFetchAll: config.Version.IsAtLeast(sarama.V2_0_0_0), // Kafka 2.0+支持全量消费组抓取
}, nil
}
// fetchOffsetVersion:根据Kafka版本选择OffsetFetchRequest版本(兼容不同协议)
func (e *Exporter) fetchOffsetVersion() int16 {
version := e.client.Config().Version
if e.client.Config().Version.IsAtLeast(sarama.V2_0_0_0) {
return 4 // Kafka 2.0+使用版本4
} else if version.IsAtLeast(sarama.V0_10_2_0) {
return 2 // Kafka 0.10.2+使用版本2
} else if version.IsAtLeast(sarama.V0_8_2_2) {
return 1 // Kafka 0.8.2+使用版本1
}
return 0 // 老版本使用默认
}
关键讲解:
-
-
- 配置优先级:环境变量(如SASL_USER_PASSWORD)> 命令行参数,符合 12Factor 应用规范
- 正则预编译:regexp.MustCompile预编译正则表达式,避免每次采集时重复编译(性能优化)
- 错误包装:errors.Wrap添加上下文信息,便于问题定位(如 "error connecting to zookeeper")
- 版本兼容:Kafka 不同版本的协议差异通过fetchOffsetVersion适配,确保跨版本兼容性
-
3.6、Prometheus Collector 接口实现
https://github.com/danielqsj/kafka_exporter/blob/master/kafka_exporter.go#L320
// Describe:暴露所有指标描述符(Prometheus规范)
func (e *Exporter) Describe(ch chan<- *prometheus.Desc) {
ch <- clusterBrokers
ch <- topicCurrentOffset
ch <- topicOldestOffset
ch <- topicPartitions
ch <- topicPartitionLeader
ch <- topicPartitionReplicas
ch <- topicPartitionInSyncReplicas
ch <- topicPartitionUsesPreferredReplica
ch <- topicUnderReplicatedPartition
ch <- consumergroupCurrentOffset
ch <- consumergroupCurrentOffsetSum
ch <- consumergroupLag
ch <- consumergroupLagZookeeper
ch <- consumergroupLagSum
}
// Collect:核心采集逻辑(Prometheus调用此方法抓取指标)
func (e *Exporter) Collect(ch chan<- prometheus.Metric) {
if e.allowConcurrent {
e.collect(ch) // 并发模式(大集群禁用)
return
}
// 非并发模式:所有采集请求共享结果(避免重复采集)
e.sgMutex.Lock()
e.sgChans = append(e.sgChans, ch)
if len(e.sgChans) == 1 {
e.sgWaitCh = make(chan struct{})
go e.collectChans(e.sgWaitCh) // 启动单例采集
} else {
klog.V(TRACE).Info("concurrent calls detected, waiting for first to finish")
}
waiter := e.sgWaitCh
e.sgMutex.Unlock()
<-waiter // 等待采集完成
}
// collectChans:非并发模式的采集结果分发
func (e *Exporter) collectChans(quit chan struct{}) {
original := make(chan prometheus.Metric)
container := make([]prometheus.Metric, 0, 100) // 缓存指标(避免重复采集)
// 异步接收指标
go func() {
for metric := range original {
container = append(container, metric)
}
}()
// 执行采集
e.collect(original)
close(original)
// 分发指标到所有等待的通道
e.sgMutex.Lock()
for _, ch := range e.sgChans {
for _, metric := range container {
ch <- metric
}
}
e.sgChans = e.sgChans[:0] // 重置通道列表
close(quit) // 通知所有等待的请求
e.sgMutex.Unlock()
}
// collect:核心采集逻辑(真正的指标采集)
func (e *Exporter) collect(ch chan<- prometheus.Metric) {
var wg = sync.WaitGroup{} // 协程等待组
// ========== 1. 采集Broker指标 ==========
ch <- prometheus.MustNewConstMetric(
clusterBrokers, prometheus.GaugeValue, float64(len(e.client.Brokers())),
)
// 采集每个Broker的信息
for _, b := range e.client.Brokers() {
ch <- prometheus.MustNewConstMetric(
clusterBrokerInfo, prometheus.GaugeValue, 1, strconv.Itoa(int(b.ID())), b.Addr(),
)
}
// ========== 2. 元数据刷新 ==========
offset := make(map[string]map[int32]int64) // 缓存topic-partition的最新offset
now := time.Now()
if now.After(e.nextMetadataRefresh) {
klog.V(DEBUG).Info("Refreshing client metadata")
if err := e.client.RefreshMetadata(); err != nil {
klog.Errorf("Cannot refresh topics, using cached data: %v", err)
}
e.nextMetadataRefresh = now.Add(e.metadataRefreshInterval) // 更新下次刷新时间
}
// ========== 3. 获取Topic列表 ==========
topics, err := e.client.Topics()
if err != nil {
klog.Errorf("Cannot get topics: %v", err)
return
}
// ========== 4. 并发采集Topic指标 ==========
topicChannel := make(chan string) // Topic分发通道
// getTopicMetrics:单个Topic的指标采集逻辑
getTopicMetrics := func(topic string) {
defer wg.Done() // 协程完成通知
// 过滤Topic(包含+排除)
if !e.topicFilter.MatchString(topic) || e.topicExclude.MatchString(topic) {
return
}
// 获取Topic分区列表
partitions, err := e.client.Partitions(topic)
if err != nil {
klog.Errorf("Cannot get partitions of topic %s: %v", topic, err)
return
}
// 采集Topic分区数
ch <- prometheus.MustNewConstMetric(
topicPartitions, prometheus.GaugeValue, float64(len(partitions)), topic,
)
// 初始化offset缓存
e.mu.Lock()
offset[topic] = make(map[int32]int64, len(partitions))
e.mu.Unlock()
// 遍历分区采集指标
for _, partition := range partitions {
// 分区Leader Broker
broker, err := e.client.Leader(topic, partition)
if err != nil {
klog.Errorf("Cannot get leader of topic %s partition %d: %v", topic, partition, err)
} else {
ch <- prometheus.MustNewConstMetric(
topicPartitionLeader, prometheus.GaugeValue, float64(broker.ID()), topic, strconv.FormatInt(int64(partition), 10),
)
}
// 分区最新Offset(OffsetNewest)
currentOffset, err := e.client.GetOffset(topic, partition, sarama.OffsetNewest)
if err != nil {
klog.Errorf("Cannot get current offset of topic %s partition %d: %v", topic, partition, err)
} else {
e.mu.Lock()
offset[topic][partition] = currentOffset // 缓存offset
e.mu.Unlock()
ch <- prometheus.MustNewConstMetric(
topicCurrentOffset, prometheus.GaugeValue, float64(currentOffset), topic, strconv.FormatInt(int64(partition), 10),
)
}
// 分区最旧Offset(OffsetOldest)
oldestOffset, err := e.client.GetOffset(topic, partition, sarama.OffsetOldest)
if err != nil {
klog.Errorf("Cannot get oldest offset of topic %s partition %d: %v", topic, partition, err)
} else {
ch <- prometheus.MustNewConstMetric(
topicOldestOffset, prometheus.GaugeValue, float64(oldestOffset), topic, strconv.FormatInt(int64(partition), 10),
)
}
// 分区副本数
replicas, err := e.client.Replicas(topic, partition)
if err != nil {
klog.Errorf("Cannot get replicas of topic %s partition %d: %v", topic, partition, err)
} else {
ch <- prometheus.MustNewConstMetric(
topicPartitionReplicas, prometheus.GaugeValue, float64(len(replicas)), topic, strconv.FormatInt(int64(partition), 10),
)
}
// 分区ISR(同步副本)数
inSyncReplicas, err := e.client.InSyncReplicas(topic, partition)
if err != nil {
klog.Errorf("Cannot get in-sync replicas of topic %s partition %d: %v", topic, partition, err)
} else {
ch <- prometheus.MustNewConstMetric(
topicPartitionInSyncReplicas, prometheus.GaugeValue, float64(len(inSyncReplicas)), topic, strconv.FormatInt(int64(partition), 10),
)
}
// 是否使用首选副本
if broker != nil && replicas != nil && len(replicas) > 0 && broker.ID() == replicas[0] {
ch <- prometheus.MustNewConstMetric(
topicPartitionUsesPreferredReplica, prometheus.GaugeValue, float64(1), topic, strconv.FormatInt(int64(partition), 10),
)
} else {
ch <- prometheus.MustNewConstMetric(
topicPartitionUsesPreferredReplica, prometheus.GaugeValue, float64(0), topic, strconv.FormatInt(int64(partition), 10),
)
}
// 是否副本未复制(ISR数 < 副本数)
if replicas != nil && inSyncReplicas != nil && len(inSyncReplicas) < len(replicas) {
ch <- prometheus.MustNewConstMetric(
topicUnderReplicatedPartition, prometheus.GaugeValue, float64(1), topic, strconv.FormatInt(int64(partition), 10),
)
} else {
ch <- prometheus.MustNewConstMetric(
topicUnderReplicatedPartition, prometheus.GaugeValue, float64(0), topic, strconv.FormatInt(int64(partition), 10),
)
}
// ZooKeeper模式下的消费组Lag
if e.useZooKeeperLag {
ConsumerGroups, err := e.zookeeperClient.Consumergroups()
if err != nil {
klog.Errorf("Cannot get consumer group %v", err)
}
for _, group := range ConsumerGroups {
offset, _ := group.FetchOffset(topic, partition)
if offset > 0 {
consumerGroupLag := currentOffset - offset
ch <- prometheus.MustNewConstMetric(
consumergroupLagZookeeper, prometheus.GaugeValue, float64(consumerGroupLag), group.Name, topic, strconv.FormatInt(int64(partition), 10),
)
}
}
}
}
}
// loopTopics:协程循环处理Topic通道
loopTopics := func() {
ok := true
for ok {
topic, open := <-topicChannel
ok = open
if open {
getTopicMetrics(topic)
}
}
}
// 协程数控制(避免协程爆炸)
minx := func(x int, y int) int {
if x < y {
return x
} else {
return y
}
}
N := len(topics)
if N > 1 {
N = minx(N/2, e.topicWorkers) // 协程数 = min(Topic数/2, 配置的worker数)
}
// 启动Topic采集协程
for w := 1; w <= N; w++ {
go loopTopics()
}
// 分发Topic到协程通道
for _, topic := range topics {
if e.topicFilter.MatchString(topic) && !e.topicExclude.MatchString(topic) {
wg.Add(1)
topicChannel <- topic
}
}
close(topicChannel) // 关闭通道,协程退出
wg.Wait() // 等待所有Topic采集完成
// ========== 5. 采集消费组指标 ==========
getConsumerGroupMetrics := func(broker *sarama.Broker) {
defer wg.Done()
// 连接Broker(采集消费组需要直接连接Broker)
if err := broker.Open(e.client.Config()); err != nil && err != sarama.ErrAlreadyConnected {
klog.Errorf("Cannot connect to broker %d: %v", broker.ID(), err)
return
}
defer broker.Close() // 确保关闭连接
// 列出所有消费组
groups, err := broker.ListGroups(&sarama.ListGroupsRequest{})
if err != nil {
klog.Errorf("Cannot get consumer group: %v", err)
return
}
// 过滤消费组
groupIds := make([]string, 0)
for groupId := range groups.Groups {
if e.groupFilter.MatchString(groupId) && !e.groupExclude.MatchString(groupId) {
groupIds = append(groupIds, groupId)
}
}
// 描述消费组(获取成员、分配的topic/分区)
describeGroups, err := broker.DescribeGroups(&sarama.DescribeGroupsRequest{Groups: groupIds})
if err != nil {
klog.Errorf("Cannot get describe groups: %v", err)
return
}
// 遍历消费组采集指标
for _, group := range describeGroups.Groups {
if group.Err != 0 {
klog.Errorf("Cannot describe for the group %s with error code %d", group.GroupId, group.Err)
continue
}
// 构建OffsetFetchRequest(获取消费组offset)
offsetFetchRequest := sarama.OffsetFetchRequest{ConsumerGroup: group.GroupId, Version: e.fetchOffsetVersion()}
if e.offsetShowAll {
// 显示所有Topic/分区的offset(包括未消费的)
for topic, partitions := range offset {
for partition := range partitions {
offsetFetchRequest.AddPartition(topic, partition)
}
}
} else {
// 仅显示消费组已分配的Topic/分区
for _, member := range group.Members {
if len(member.MemberAssignment) == 0 {
klog.Warningf("MemberAssignment is empty for group member: %v in group: %v", member.MemberId, group.GroupId)
continue
}
// 解析成员分配的Topic/分区
assignment, err := member.GetMemberAssignment()
if err != nil {
klog.Errorf("Cannot get GetMemberAssignment of group member %v : %v", member, err)
continue
}
for topic, partions := range assignment.Topics {
for _, partition := range partions {
offsetFetchRequest.AddPartition(topic, partition)
}
}
}
}
// 采集消费组成员数
ch <- prometheus.MustNewConstMetric(
consumergroupMembers, prometheus.GaugeValue, float64(len(group.Members)), group.GroupId,
)
// 获取消费组offset
offsetFetchResponse, err := broker.FetchOffset(&offsetFetchRequest)
if err != nil {
klog.Errorf("Cannot get offset of group %s: %v", group.GroupId, err)
continue
}
// 遍历消费组的Topic/分区
for topic, partitions := range offsetFetchResponse.Blocks {
// 过滤未消费的Topic
topicConsumed := false
for _, offsetFetchResponseBlock := range partitions {
if offsetFetchResponseBlock.Offset != -1 { // Kafka返回-1表示无offset
topicConsumed = true
break
}
}
if !topicConsumed {
continue
}
// 计算总和指标
var currentOffsetSum int64
var lagSum int64
for partition, offsetFetchResponseBlock := range partitions {
err := offsetFetchResponseBlock.Err
if err != sarama.ErrNoError {
klog.Errorf("Error for partition %d :%v", partition, err.Error())
continue
}
// 消费组当前offset
currentOffset := offsetFetchResponseBlock.Offset
currentOffsetSum += currentOffset
ch <- prometheus.MustNewConstMetric(
consumergroupCurrentOffset, prometheus.GaugeValue, float64(currentOffset), group.GroupId, topic, strconv.FormatInt(int64(partition), 10),
)
// 计算消费lag(最新offset - 消费组offset)
e.mu.Lock()
currentPartitionOffset, currentPartitionOffsetError := e.client.GetOffset(topic, partition, sarama.OffsetNewest)
if currentPartitionOffsetError != nil {
klog.Errorf("Cannot get current offset of topic %s partition %d: %v", topic, partition, currentPartitionOffsetError)
} else {
var lag int64
if offsetFetchResponseBlock.Offset == -1 {
lag = -1 // 无offset时lag为-1
} else {
if offset, ok := offset[topic][partition]; ok {
if currentPartitionOffset == -1 {
currentPartitionOffset = offset
}
}
lag = currentPartitionOffset - offsetFetchResponseBlock.Offset
lagSum += lag
}
// 采集lag指标
ch <- prometheus.MustNewConstMetric(
consumergroupLag, prometheus.GaugeValue, float64(lag), group.GroupId, topic, strconv.FormatInt(int64(partition), 10),
)
}
e.mu.Unlock()
}
// 采集总和指标
ch <- prometheus.MustNewConstMetric(
consumergroupCurrentOffsetSum, prometheus.GaugeValue, float64(currentOffsetSum), group.GroupId, topic,
)
ch <- prometheus.MustNewConstMetric(
consumergroupLagSum, prometheus.GaugeValue, float64(lagSum), group.GroupId, topic,
)
}
}
}
// 采集消费组指标(遍历Broker)
klog.V(DEBUG).Info("Fetching consumer group metrics")
if len(e.client.Brokers()) > 0 {
// 去重Broker地址(避免重复采集)
uniqueBrokerAddresses := make(map[string]bool)
var servers []string
for _, broker := range e.client.Brokers() {
normalizedAddress := strings.ToLower(broker.Addr())
if !uniqueBrokerAddresses[normalizedAddress] {
uniqueBrokerAddresses[normalizedAddress] = true
servers = append(servers, broker.Addr())
}
}
klog.Info(servers)
// 遍历Broker采集消费组
for _, broker := range e.client.Brokers() {
for _, server := range servers {
if server == broker.Addr() {
wg.Add(1)
go getConsumerGroupMetrics(broker)
}
}
}
wg.Wait()
} else {
klog.Errorln("No valid broker, cannot get consumer group metrics")
}
}
关键讲解:
-
-
- Collect/Describe:Prometheus Collector 接口的两个核心方法,Describe 暴露指标元数据,Collect 采集指标值
- 并发控制:
- 非并发模式:所有 Prometheus 抓取请求共享一次采集结果(避免重复请求 Kafka,减轻集群压力)
- 并发模式:每个请求独立采集(适合小集群)
- 协程池:Topic 采集使用协程池(topicWorkers),避免协程爆炸(大集群关键优化)
- 消费组 Lag 计算:lag = 最新offset - 消费组offset,是 Kafka 消费延迟的核心指标
- 去重 Broker:消费组采集时去重 Broker 地址,避免重复采集(Kafka 集群中多个 Broker 返回相同消费组信息)
-
3.7、初始化与 main 函数
https://github.com/danielqsj/kafka_exporter/blob/master/kafka_exporter.go#L716 init() 函数位置
https://github.com/danielqsj/kafka_exporter/blob/master/kafka_exporter.go#L757 main() 函数位置
// init:包初始化(优先于main执行)
func init() {
metrics.UseNilMetrics = true // 禁用go-metrics(避免资源占用)
prometheus.MustRegister(versionCollector.NewCollector("kafka_exporter")) // 注册版本指标
}
// 命令行参数工具函数(兼容flag和kingpin)
func toFlagString(name string, help string, value string) *string {
flag.CommandLine.String(name, value, help) // 兼容原生flag
return kingpin.Flag(name, help).Default(value).String()
}
func toFlagBool(name string, help string, value bool, valueString string) *bool {
flag.CommandLine.Bool(name, value, help)
return kingpin.Flag(name, help).Default(valueString).Bool()
}
func toFlagStringsVar(name string, help string, value string, target *[]string) {
flag.CommandLine.String(name, value, help)
kingpin.Flag(name, help).Default(value).StringsVar(target)
}
func toFlagStringVar(name string, help string, value string, target *string) {
flag.CommandLine.String(name, value, help)
kingpin.Flag(name, help).Default(value).StringVar(target)
}
func toFlagBoolVar(name string, help string, value bool, valueString string, target *bool) {
flag.CommandLine.Bool(name, value, help)
kingpin.Flag(name, help).Default(valueString).BoolVar(target)
}
func toFlagIntVar(name string, help string, value int, valueString string, target *int) {
flag.CommandLine.Int(name, value, help)
kingpin.Flag(name, help).Default(valueString).IntVar(target)
}
// main:程序入口
func main() {
var (
listenAddress = toFlagString("web.listen-address", "Address to listen on for web interface and telemetry.", ":9308")
metricsPath = toFlagString("web.telemetry-path", "Path under which to expose metrics.", "/metrics")
topicFilter = toFlagString("topic.filter", "Regex that determines which topics to collect.", ".*")
topicExclude = toFlagString("topic.exclude", "Regex that determines which topics to exclude.", "^$")
groupFilter = toFlagString("group.filter", "Regex that determines which consumer groups to collect.", ".*")
groupExclude = toFlagString("group.exclude", "Regex that determines which consumer groups to exclude.", "^$")
logSarama = toFlagBool("log.enable-sarama", "Turn on Sarama logging, default is false.", false, "false")
opts = kafkaOpts{} // 初始化Kafka配置
)
// 绑定命令行参数到opts结构体
toFlagStringsVar("kafka.server", "Address (host:port) of Kafka server.", "kafka:9092", &opts.uri)
toFlagBoolVar("sasl.enabled", "Connect using SASL/PLAIN, default is false.", false, "false", &opts.useSASL)
toFlagBoolVar("sasl.handshake", "Only set this to false if using a non-Kafka SASL proxy, default is true.", true, "true", &opts.useSASLHandshake)
toFlagStringVar("sasl.username", "SASL user name.", "", &opts.saslUsername)
toFlagStringVar("sasl.password", "SASL user password.", "", &opts.saslPassword)
toFlagStringVar("sasl.aws-region", "The AWS region for IAM SASL authentication", os.Getenv("AWS_REGION"), &opts.saslAwsRegion)
toFlagStringVar("sasl.mechanism", "SASL SCRAM SHA algorithm: sha256 or sha512 or SASL mechanism: gssapi or awsiam", "", &opts.saslMechanism)
toFlagStringVar("sasl.service-name", "Service name when using kerberos Auth", "", &opts.serviceName)
toFlagStringVar("sasl.kerberos-config-path", "Kerberos config path", "", &opts.kerberosConfigPath)
toFlagStringVar("sasl.realm", "Kerberos realm", "", &opts.realm)
toFlagStringVar("sasl.kerberos-auth-type", "Kerberos auth type. Either 'keytabAuth' or 'userAuth'", "", &opts.kerberosAuthType)
toFlagStringVar("sasl.keytab-path", "Kerberos keytab file path", "", &opts.keyTabPath)
toFlagBoolVar("sasl.disable-PA-FX-FAST", "Configure the Kerberos client to not use PA_FX_FAST, default is false.", false, "false", &opts.saslDisablePAFXFast)
toFlagBoolVar("tls.enabled", "Connect to Kafka using TLS, default is false.", false, "false", &opts.useTLS)
toFlagStringVar("tls.server-name", "Used to verify the hostname on the returned certificates unless tls.insecure-skip-tls-verify is given. The kafka server's name should be given.", "", &opts.tlsServerName)
toFlagStringVar("tls.ca-file", "The optional certificate authority file for Kafka TLS client authentication.", "", &opts.tlsCAFile)
toFlagStringVar("tls.cert-file", "The optional certificate file for Kafka client authentication.", "", &opts.tlsCertFile)
toFlagStringVar("tls.key-file", "The optional key file for Kafka client authentication.", "", &opts.tlsKeyFile)
toFlagBoolVar("server.tls.enabled", "Enable TLS for web server, default is false.", false, "false", &opts.serverUseTLS)
toFlagBoolVar("server.tls.mutual-auth-enabled", "Enable TLS client mutual authentication, default is false.", false, "false", &opts.serverMutualAuthEnabled)
toFlagStringVar("server.tls.ca-file", "The certificate authority file for the web server.", "", &opts.serverTlsCAFile)
toFlagStringVar("server.tls.cert-file", "The certificate file for the web server.", "", &opts.serverTlsCertFile)
toFlagStringVar("server.tls.key-file", "The key file for the web server.", "", &opts.serverTlsKeyFile)
toFlagBoolVar("tls.insecure-skip-tls-verify", "If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure. Default is false", false, "false", &opts.tlsInsecureSkipTLSVerify)
toFlagStringVar("kafka.version", "Kafka broker version", sarama.V2_0_0_0.String(), &opts.kafkaVersion)
toFlagBoolVar("use.consumelag.zookeeper", "if you need to use a group from zookeeper, default is false", false, "false", &opts.useZooKeeperLag)
toFlagStringsVar("zookeeper.server", "Address (hosts) of zookeeper server.", "localhost:2181", &opts.uriZookeeper)
toFlagStringVar("kafka.labels", "Kafka cluster name", "", &opts.labels)
toFlagStringVar("refresh.metadata", "Metadata refresh interval", "30s", &opts.metadataRefreshInterval)
toFlagBoolVar("offset.show-all", "Whether show the offset/lag for all consumer group, otherwise, only show connected consumer groups, default is true", true, "true", &opts.offsetShowAll)
toFlagBoolVar("concurrent.enable", "If true, all scrapes will trigger kafka operations otherwise, they will share results. WARN: This should be disabled on large clusters. Default is false", false, "false", &opts.allowConcurrent)
toFlagIntVar("topic.workers", "Number of topic workers", 100, "100", &opts.topicWorkers)
toFlagBoolVar("kafka.allow-auto-topic-creation", "If true, the broker may auto-create topics that we requested which do not already exist, default is false.", false, "false", &opts.allowAutoTopicCreation)
toFlagIntVar("verbosity", "Verbosity log level", 0, "0", &opts.verbosityLogLevel)
plConfig := plog.Config{}
plogflag.AddFlags(kingpin.CommandLine, &plConfig) // 绑定Prometheus日志配置
kingpin.Version(version.Print("kafka_exporter")) // 版本信息
kingpin.HelpFlag.Short('h') // 短参数-h
kingpin.Parse() // 解析命令行参数
// 解析自定义标签(如cluster=kafka-prod)
labels := make(map[string]string)
if opts.labels != "" {
for _, label := range strings.Split(opts.labels, ",") {
splitted := strings.Split(label, "=")
if len(splitted) >= 2 {
labels[splitted[0]] = splitted[1]
}
}
}
// 启动Exporter
setup(*listenAddress, *metricsPath, *topicFilter, *topicExclude, *groupFilter, *groupExclude, *logSarama, opts, labels)
}
// setup:初始化并启动HTTP服务
func setup(
listenAddress string,
metricsPath string,
topicFilter string,
topicExclude string,
groupFilter string,
groupExclude string,
logSarama bool,
opts kafkaOpts,
labels map[string]string,
) {
// 初始化klog
klog.InitFlags(flag.CommandLine)
if err := flag.Set("logtostderr", "true"); err != nil {
klog.Errorf("Error on setting logtostderr to true: %v", err)
}
err := flag.Set("v", strconv.Itoa(opts.verbosityLogLevel))
if err != nil {
klog.Errorf("Error on setting v to %v: %v", strconv.Itoa(opts.verbosityLogLevel), err)
}
defer klog.Flush() // 确保日志刷出
// 打印启动信息
klog.V(INFO).Infoln("Starting kafka_exporter", version.Info())
klog.V(DEBUG).Infoln("Build context", version.BuildContext())
// ========== 初始化Prometheus指标描述符 ==========
clusterBrokers = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "", "brokers"),
"Number of Brokers in the Kafka Cluster.",
nil, labels,
)
clusterBrokerInfo = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "", "broker_info"),
"Information about the Kafka Broker.",
[]string{"id", "address"}, labels,
)
topicPartitions = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "topic", "partitions"),
"Number of partitions for this Topic",
[]string{"topic"}, labels,
)
topicCurrentOffset = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "topic", "partition_current_offset"),
"Current Offset of a Broker at Topic/Partition",
[]string{"topic", "partition"}, labels,
)
topicOldestOffset = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "topic", "partition_oldest_offset"),
"Oldest Offset of a Broker at Topic/Partition",
[]string{"topic", "partition"}, labels,
)
topicPartitionLeader = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "topic", "partition_leader"),
"Leader Broker ID of this Topic/Partition",
[]string{"topic", "partition"}, labels,
)
topicPartitionReplicas = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "topic", "partition_replicas"),
"Number of Replicas for this Topic/Partition",
[]string{"topic", "partition"}, labels,
)
topicPartitionInSyncReplicas = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "topic", "partition_in_sync_replica"),
"Number of In-Sync Replicas for this Topic/Partition",
[]string{"topic", "partition"}, labels,
)
topicPartitionUsesPreferredReplica = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "topic", "partition_leader_is_preferred"),
"1 if Topic/Partition is using the Preferred Broker",
[]string{"topic", "partition"}, labels,
)
topicUnderReplicatedPartition = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "topic", "partition_under_replicated_partition"),
"1 if Topic/Partition is under Replicated",
[]string{"topic", "partition"}, labels,
)
consumergroupCurrentOffset = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "consumergroup", "current_offset"),
"Current Offset of a ConsumerGroup at Topic/Partition",
[]string{"consumergroup", "topic", "partition"}, labels,
)
consumergroupCurrentOffsetSum = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "consumergroup", "current_offset_sum"),
"Current Offset of a ConsumerGroup at Topic for all partitions",
[]string{"consumergroup", "topic"}, labels,
)
consumergroupLag = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "consumergroup", "lag"),
"Current Approximate Lag of a ConsumerGroup at Topic/Partition",
[]string{"consumergroup", "topic", "partition"}, labels,
)
consumergroupLagZookeeper = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "consumergroupzookeeper", "lag_zookeeper"),
"Current Approximate Lag(zookeeper) of a ConsumerGroup at Topic/Partition",
[]string{"consumergroup", "topic", "partition"}, nil,
)
consumergroupLagSum = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "consumergroup", "lag_sum"),
"Current Approximate Lag of a ConsumerGroup at Topic for all partitions",
[]string{"consumergroup", "topic"}, labels,
)
consumergroupMembers = prometheus.NewDesc(
prometheus.BuildFQName(namespace, "consumergroup", "members"),
"Amount of members in a consumer group",
[]string{"consumergroup"}, labels,
)
// 启用Sarama日志(调试用)
if logSarama {
sarama.Logger = log.New(os.Stdout, "[sarama] ", log.LstdFlags)
}
// 创建Exporter实例
exporter, err := NewExporter(opts, topicFilter, topicExclude, groupFilter, groupExclude)
if err != nil {
klog.Fatalln(err)
}
defer exporter.client.Close() // 确保关闭Kafka客户端
prometheus.MustRegister(exporter) // 注册Exporter到Prometheus
// ========== 配置HTTP路由 ==========
http.Handle(metricsPath, promhttp.Handler()) // 指标暴露接口
// 首页(引导到指标页面)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte(`<html>
<head><title>Kafka Exporter</title></head>
<body>
<h1>Kafka Exporter</h1>
<p><a href='` + metricsPath + `'>Metrics</a></p>
</body>
</html>`))
if err != nil {
klog.Error("Error handle / request", err)
}
})
// 健康检查接口
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte("ok"))
if err != nil {
klog.Error("Error handle /healthz request", err)
}
})
// ========== 启动HTTP/HTTPS服务 ==========
if opts.serverUseTLS {
klog.V(INFO).Infoln("Listening on HTTPS", listenAddress)
// 校验服务端证书
_, err := CanReadCertAndKey(opts.serverTlsCertFile, opts.serverTlsKeyFile)
if err != nil {
klog.Error("error reading server cert and key")
}
// 配置TLS双向认证
clientAuthType := tls.NoClientCert
if opts.serverMutualAuthEnabled {
clientAuthType = tls.RequireAndVerifyClientCert
}
// 加载CA证书
certPool := x509.NewCertPool()
if opts.serverTlsCAFile != "" {
if caCert, err := os.ReadFile(opts.serverTlsCAFile); err == nil {
certPool.AppendCertsFromPEM(caCert)
} else {
klog.Error("error reading server ca")
}
}
// TLS配置(安全最佳实践)
tlsConfig := &tls.Config{
ClientCAs: certPool,
ClientAuth: clientAuthType,
MinVersion: tls.VersionTLS12, // 禁用老版本TLS
CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256}, // 安全曲线
PreferServerCipherSuites: true,
CipherSuites: []uint16{ // 安全加密套件
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256,
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_RSA_WITH_AES_256_CBC_SHA,
tls.TLS_RSA_WITH_AES_128_CBC_SHA256,
},
}
// 启动HTTPS服务
server := &http.Server{
Addr: listenAddress,
TLSConfig: tlsConfig,
}
klog.Fatal(server.ListenAndServeTLS(opts.serverTlsCertFile, opts.serverTlsKeyFile))
} else {
// 启动HTTP服务
klog.V(INFO).Infoln("Listening on HTTP", listenAddress)
klog.Fatal(http.ListenAndServe(listenAddress, nil))
}
}
关键讲解:
-
-
- 命令行参数适配:兼容原生flag和kingpin,兼顾不同使用习惯
- 指标命名规范:prometheus.BuildFQName生成规范的指标名(如kafka_topic_partitions)
- 健康检查接口:/healthz是云原生应用的标准,用于 k8s 存活探针
- TLS 最佳实践:禁用老版本 TLS、使用安全加密套件、支持双向认证(生产环境必备)
-
四、核心设计思路总结
4.1、核心流程:指标采集实现
collect方法 是指标采集的核心,按“Broker → Topic → 消费组”的顺序采集所有指标:
4.1.1、Broker 指标采集
https://github.com/danielqsj/kafka_exporter/blob/master/kafka_exporter.go#L387
// 采集Broker总数
ch <- prometheus.MustNewConstMetric(
clusterBrokers, prometheus.GaugeValue, float64(len(e.client.Brokers())),
)
// 采集每个Broker的ID和地址
for _, b := range e.client.Brokers() {
ch <- prometheus.MustNewConstMetric(
clusterBrokerInfo, prometheus.GaugeValue, 1, strconv.Itoa(int(b.ID())), b.Addr(),
)
}
4.1.2、Topic 指标采集(协程池优化)
https://github.com/danielqsj/kafka_exporter/blob/master/kafka_exporter.go#L418
Topic 采集是性能敏感点,kafka Exporter 使用协程池控制并发数,避免协程爆炸:
// Topic分发通道
topicChannel := make(chan string)
// 单个Topic采集逻辑
getTopicMetrics := func(topic string) {
defer wg.Done()
// 过滤Topic(包含+排除正则)
if !e.topicFilter.MatchString(topic) || e.topicExclude.MatchString(topic) {
return
}
// 获取Topic分区列表
partitions, err := e.client.Partitions(topic)
// 采集分区数
ch <- prometheus.MustNewConstMetric(
topicPartitions, prometheus.GaugeValue, float64(len(partitions)), topic,
)
// 遍历分区采集Offset、副本数、ISR、副本复制不同步等指标
for _, partition := range partitions {
// 最新Offset
currentOffset, err := e.client.GetOffset(topic, partition, sarama.OffsetNewest)
// 最旧Offset
oldestOffset, err := e.client.GetOffset(topic, partition, sarama.OffsetOldest)
// 副本数
replicas, err := e.client.Replicas(topic, partition)
// ISR数
inSyncReplicas, err := e.client.InSyncReplicas(topic, partition)
// 副本复制不同步判断(ISR数 < 副本数)
if len(inSyncReplicas) < len(replicas) {
ch <- prometheus.MustNewConstMetric(
topicUnderReplicatedPartition, prometheus.GaugeValue, 1, topic, strconv.FormatInt(int64(partition), 10),
)
}
// 其他Topic指标采集...
}
}
// 协程池控制:启动N个协程处理Topic
N := minx(len(topics)/2, e.topicWorkers) // 协程数 = min(Topic数/2, 配置的worker数)
for w := 1; w <= N; w++ {
go loopTopics() // 循环处理Topic通道
}
// 分发Topic到协程
for _, topic := range topics {
wg.Add(1)
topicChannel <- topic
}
这里有个重要的优化点:
-
-
-
- 正则预编译:topicFilter/topicExclude在 Exporter 初始化时预编译,避免每次采集重复编译
- 协程数控制:通过topicWorkers参数限制协程数,默认 100,可根据集群大小调整
-
-
4.1.3、消费组指标采集(核心:Lag 计算)
https://github.com/danielqsj/kafka_exporter/blob/master/kafka_exporter.go#L565
消费组 Lag(消费延迟)是 kafka 监控的核心指标,计算公式为:Lag = Topic分区最新Offset - 消费组当前Offset
getConsumerGroupMetrics := func(broker *sarama.Broker) {
defer wg.Done()
// 连接Broker(消费组信息需要直接连接Broker)
if err := broker.Open(e.client.Config()); err != nil && err != sarama.ErrAlreadyConnected {
return
}
defer broker.Close()
// 列出所有消费组并过滤
groups, err := broker.ListGroups(&sarama.ListGroupsRequest{})
groupIds := make([]string, 0)
for groupId := range groups.Groups {
if e.groupFilter.MatchString(groupId) && !e.groupExclude.MatchString(groupId) {
groupIds = append(groupIds, groupId)
}
}
// 遍历消费组采集指标
for _, group := range describeGroups.Groups {
// 构建OffsetFetchRequest获取消费组Offset
offsetFetchRequest := sarama.OffsetFetchRequest{ConsumerGroup: group.GroupId, Version: e.fetchOffsetVersion()}
// 获取消费组Offset响应
offsetFetchResponse, err := broker.FetchOffset(&offsetFetchRequest)
// 遍历消费组的Topic/分区计算Lag
for topic, partitions := range offsetFetchResponse.Blocks {
var lagSum int64
for partition, offsetBlock := range partitions {
// 消费组当前Offset
currentOffset := offsetBlock.Offset
// Topic分区最新Offset
currentPartitionOffset, _ := e.client.GetOffset(topic, partition, sarama.OffsetNewest)
// 计算Lag
lag := currentPartitionOffset - currentOffset
lagSum += lag
// 暴露Lag指标
ch <- prometheus.MustNewConstMetric(
consumergroupLag, prometheus.GaugeValue, float64(lag), group.GroupId, topic, strconv.FormatInt(int64(partition), 10),
)
}
// 暴露Topic级别的Lag总和
ch <- prometheus.MustNewConstMetric(
consumergroupLagSum, prometheus.GaugeValue, float64(lagSum), group.GroupId, topic,
)
}
}
}
兼容适配:
-
-
-
- ZooKeeper 模式:兼容老版本 Kafka(0.8.x),从 ZooKeeper 读取消费组 Offset
- 新版本模式:使用 Kafka 2.0 + 的ListGroups/DescribeGroups/FetchOffset API,性能更好
-
-
4.1.4、认证模块:多场景适配
kafka Exporter 支持多种认证方式,核心通过 sarama.Config配置实现:
4.1.4.1、SASL/PLAIN 认证
https://github.com/danielqsj/kafka_exporter/blob/master/kafka_exporter.go#L219
config.Net.SASL.Enable = true config.Net.SASL.User = opts.saslUsername config.Net.SASL.Password = opts.saslPassword config.Net.SASL.Mechanism = sarama.SASLMechanism(sarama.SASLTypePlaintext)
4.1.4.2、AWS MSK IAM 认证
https://github.com/danielqsj/kafka_exporter/blob/master/kafka_exporter.go#L209
config.Net.SASL.Mechanism = sarama.SASLMechanism(sarama.SASLTypeOAuth)
config.Net.SASL.TokenProvider = &MSKAccessTokenProvider{region: opts.saslAwsRegion}
4.1.4.3、TLS 认证
https://github.com/danielqsj/kafka_exporter/blob/master/kafka_exporter.go#L232
config.Net.TLS.Enable = true
config.Net.TLS.Config = &tls.Config{
ServerName: opts.tlsServerName,
InsecureSkipVerify: opts.tlsInsecureSkipTLSVerify, // 生产环境禁用
}
// 加载CA证书
if opts.tlsCAFile != "" {
ca, _ := os.ReadFile(opts.tlsCAFile)
config.Net.TLS.Config.RootCAs = x509.NewCertPool()
config.Net.TLS.Config.RootCAs.AppendCertsFromPEM(ca)
}、
4.2、并发采集的核心机制(collectChans 方法)
https://github.com/danielqsj/kafka_exporter/blob/v1.9.0/kafka_exporter.go#L364
这个机制确保在大集群场景下,避免重复的 kafka 查询。当 allowConcurrent=false 时,多个并发请求会共享同一个采集结果,大幅降低集群压力。
// collectChans 是 Exporter 结构体的方法,用于收集指标并分发给所有注册的通道
// quit 参数是一个用于通知退出的通道,完成收集后会关闭该通道以通知等待的协程
func (e *Exporter) collectChans(quit chan struct{}) {
// 创建一个原始通道,用于接收 collect 方法产生的 prometheus.Metric 类型指标
original := make(chan prometheus.Metric)
// 初始化一个切片容器,用于缓存收集到的指标,初始容量为 100 以减少内存分配
container := make([]prometheus.Metric, 0, 100)
// 启动一个匿名协程,持续从 original 通道读取指标并缓存到 container 切片中
go func() {
// 遍历 original 通道,直到通道被关闭
for metric := range original {
// 将读取到的单个指标追加到容器中
container = append(container, metric)
}
}()
// 调用 Exporter 的 collect 方法,将收集到的指标写入 original 通道
e.collect(original)
// 关闭 original 通道,通知上面的匿名协程停止读取(遍历循环会退出)
close(original)
// 加锁保护 sgChans 切片,避免在遍历/修改时发生并发读写问题
e.sgMutex.Lock()
// 遍历 Exporter 中所有注册的指标分发通道(sgChans 是通道切片)
for _, ch := range e.sgChans {
// 遍历缓存的所有指标,将每个指标写入当前分发通道
for _, metric := range container {
ch <- metric
}
}
// 清空 sgChans 切片(保留底层数组,避免内存浪费),重置注册的分发通道列表
e.sgChans = e.sgChans[:0]
// 关闭 quit 通道,通知所有等待的 Collect 方法协程可以返回(完成本次收集)
close(quit)
// 解锁,允许后续 Collect 方法再次向 sgChans 切片追加新的分发通道
e.sgMutex.Unlock()
}
帮助 SRE 理解关键信息:
-
-
-
- 协程与通道设计:匿名协程负责异步接收 collect 方法产生的指标,避免 collect 因通道阻塞而卡顿;关闭 original 通道是为了让协程的 for range 循环正常退出,防止协程泄漏。
- 锁的作用:sgMutex 锁保护的是 e.sgChans 切片的遍历和修改操作,因为可能有其他协程(如 Collect 方法)同时向该切片追加通道,加锁可避免并发读写 panic。
- 切片重置:e.sgChans = e.sgChans[:0] 是高效的切片清空方式,只重置长度而保留底层数组,后续追加元素时无需重新分配内存。
- 核心功能:收集 Prometheus 指标并分发给所有注册的通道,完成后重置通道列表并通知退出。
- 关键设计:通过协程异步缓存指标、锁保护并发资源、通道关闭实现协程优雅退出。
- 性能优化:预分配切片容量、复用底层数组,减少内存分配和 GC 开销。
-
-
4.3、SCRAM 认证中自定义 XDGSCRAMClient 的实现逻辑
kafka exporter(通常指 danielqsj/kafka_exporter)是用于采集 kafka 集群监控指标的工具,它基于 Sarama(Go 的 kafka 客户端库)实现与 kafka 集群的通信。
当 kafka 集群启用 SCRAM (Salted Challenge Response Authentication Mechanism) 认证(如 SCRAM-SHA256/SHA512)时,Sarama 虽然提供了基础的 SCRAM 支持,但默认实现有局限性(比如不兼容某些 Kafka 部署的 XDGSCRAM 规范),因此需要自定义 SCRAMClient 来适配,这就是 XDGSCRAMClient 的核心作用。
4.3.1、自定义 XDGSCRAMClient 的完整实现
XDGSCRAMClient 是对 Sarama 内置 SCRAMClient 接口的实现,核心是适配 XDGSCRAM 规范(一种更通用的 SCRAM 变体),并支持可配置的哈希算法(SHA256/SHA512)。
在 kafka Exporter 的配置中,需要将自定义的 XDGSCRAMClient 关联到 Sarama 的 SCRAM 配置,完整示例如下:
package main
import (
"github.com/Shopify/sarama"
"github.com/danielqsj/kafka_exporter/exporter"
"log"
)
func main() {
// 1. 配置 Kafka 连接参数
config := sarama.NewConfig()
config.Net.SASL.Enable = true // 启用 SASL 认证
config.Net.SASL.Mechanism = "SCRAM-SHA512" // 指定 SCRAM 算法
config.Net.SASL.User = "kafka_exporter_user" // 认证用户名
config.Net.SASL.Password = "kafka_exporter_pwd" // 认证密码
// 2. 绑定自定义 SCRAM 客户端生成函数
switch config.Net.SASL.Mechanism {
case "SCRAM-SHA256":
config.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient {
return &XDGSCRAMClient{HashGeneratorFcn: SHA256}
}
case "SCRAM-SHA512":
config.Net.SASL.SCRAMClientGeneratorFunc = func() sarama.SCRAMClient {
return &XDGSCRAMClient{HashGeneratorFcn: SHA512}
}
default:
log.Fatalf("不支持的 SCRAM 算法: %s", config.Net.SASL.Mechanism)
}
// 3. 初始化 Kafka Exporter
exporterConfig := exporter.Config{
KafkaVersion: sarama.V2_8_0_0.String(),
TopicsFilter: ".*",
GroupsFilter: ".*",
}
kafkaExporter, err := exporter.NewExporter([]string{"kafka-broker:9092"}, exporterConfig, config)
if err != nil {
log.Fatalf("初始化 Kafka Exporter 失败: %v", err)
}
defer kafkaExporter.Close()
// 4. 启动 Exporter(暴露指标端口)
log.Println("Kafka Exporter 启动成功,监听端口 9308")
log.Fatal(exporter.StartHTTPServer(":9308", kafkaExporter))
}
4.4、kafka_exporter 中元数据自动刷新机制的实现逻辑
我们一起深入了解 kafka_exporter 中元数据自动刷新机制的实现逻辑,包括定时触发的核心逻辑、刷新时机、异常处理以及这个机制解决的核心问题。我会从设计背景、完整实现 2个 维度详细讲解。
4.4.1、元数据自动刷新机制的设计背景
kafka_exporter 的核心是采集 Topic、Partition、Consumer Group 等指标,而这些信息都依赖于 kafka 的元数据(Metadata)(包含 Topic 列表、分区分布、副本信息、broker 节点等)。
-
-
-
- 如果元数据长期不刷新,当 kafka 集群发生变化(如新增 / 删除 Topic、分区重分配、broker 上下线)时,exporter 会读取到过期数据,导致指标缺失、错误或不一致。
- 但过于频繁刷新元数据会增加 kafka 集群的负担,因此需要定时 + 条件触发的平衡策略(默认 30 秒)。
-
-
4.4.2、完整的元数据刷新实现代码
以下是集成了元数据刷新逻辑的核心代码(贴合 kafka_exporter 实际实现),包含初始化、定时判断、刷新执行、异常处理等完整流程:
package main
import (
"time"
"github.com/Shopify/sarama"
"k8s.io/klog/v2"
)
// Exporter 定义 Kafka Exporter 核心结构体
type Exporter struct {
client sarama.Client // Kafka 客户端实例
metadataRefreshInterval time.Duration // 元数据刷新间隔(默认30秒)
nextMetadataRefresh time.Time // 下次刷新的时间点
// 其他核心字段(省略)
// sgMutex sync.Mutex
// sgChans []chan prometheus.Metric
// ...
}
// NewExporter 创建 Exporter 实例,初始化元数据刷新参数
func NewExporter(brokers []string, config *sarama.Config) (*Exporter, error) {
// 初始化 Kafka 客户端
client, err := sarama.NewClient(brokers, config)
if err != nil {
return nil, err
}
// 默认 30 秒刷新一次元数据
refreshInterval := 30 * time.Second
return &Exporter{
client: client,
metadataRefreshInterval: refreshInterval,
// 初始化下次刷新时间:当前时间 + 刷新间隔(首次延迟执行)
nextMetadataRefresh: time.Now().Add(refreshInterval),
}, nil
}
// collectMetrics 采集指标的核心方法(元数据刷新逻辑嵌入在此)
func (e *Exporter) collectMetrics() error {
// 1. 检查是否到达元数据刷新时间
now := time.Now()
if now.After(e.nextMetadataRefresh) {
klog.V(4).Info("开始刷新 Kafka 元数据")
// 2. 执行元数据刷新
if err := e.client.RefreshMetadata(); err != nil {
// 刷新失败:打印错误,但继续使用缓存数据,避免采集中断
klog.Errorf("刷新 Topic 元数据失败,将使用缓存数据: %v", err)
} else {
// 刷新成功:更新下次刷新时间
e.nextMetadataRefresh = now.Add(e.metadataRefreshInterval)
klog.V(4).Info("Kafka 元数据刷新成功,下次刷新时间: %v", e.nextMetadataRefresh)
}
}
// 3. 基于(可能刷新后的)元数据采集指标(核心逻辑省略)
// 例如:获取 Topic 列表、分区信息、消费组偏移量等
// topics, _ := e.client.Topics()
// for _, topic := range topics {
// partitions, _ := e.client.Partitions(topic)
// // 采集该 Topic 的分区指标
// }
return nil
}
// Close 关闭 Exporter,释放资源
func (e *Exporter) Close() error {
return e.client.Close()
}
4.5、kafka_exporter 中 Offset Feth 协议版本的适配策略
这确保了与不同 Kafka 版本的兼容性。
在 kafka_exporter 1.9.0 中,Offset Fetch 协议版本适配是 纯静态版本匹配,核心依赖 fetchOffsetVersion() 方法根据配置的 Kafka 版本返回固定的协议版本号,无动态降级、无 Broker 版本探测,仅保证 "配置版本"与 "协议版本" 的基础兼容。
kafka 的 Offset Fetch API(用于获取消费组的偏移量)是一个随版本迭代的核心协议:
-
-
- kafka 不同版本对 Offset Fetch 协议的字段、功能支持不同(比如高版本支持分区级别的偏移量、消费者实例信息,低版本仅支持基础偏移量);
- Sarama(Go kafka 客户端)作为通用客户端,需要显式指定协议版本号,才能与不同版本的 kafka Broker 正确通信;
- 如果协议版本不匹配,会出现「请求被拒绝」「返回字段缺失」「偏移量获取失败」等问题,导致 kafka Exporter 采集不到消费组偏移量指标。
-
因此,fetchOffsetVersion() 方法的 核心作用 是:根据 kafka 集群的版本,自动匹配对应的 Offset Fetch 协议版本号,确保跨版本兼容性。
4.5.1、核心方法:fetchOffsetVersion()(1.9.0 源码原生实现)
func (e *Exporter) fetchOffsetVersion() int16 {
version := e.client.Config().Version
if e.client.Config().Version.IsAtLeast(sarama.V2_0_0_0) {
return 4 // Kafka 2.0.0+ → 协议版本 4
} else if version.IsAtLeast(sarama.V0_10_2_0) {
return 2 // Kafka 0.10.2.0 ~ 1.1.x → 协议版本 2
} else if version.IsAtLeast(sarama.V0_8_2_2) {
return 1 // Kafka 0.8.2.2 ~ 0.10.1.x → 协议版本 1
}
return 0 // Kafka 0.8.2.1 及以下 → 协议版本 0
}
帮助 SRE 理解关键信息:
-
-
-
- 依赖 sarama.Config.Version(即启动参数 --kafka.version 配置的值),而非 Broker 实际版本;
- 判断逻辑为"从高到低",优先匹配高版本协议(充分利用新功能);
- 无任何容错 / 降级逻辑,返回值直接作为 OffsetFetchRequest.Version。
-
-
4.5.2、协议版本与 Kafka 版本的严格映射
注:1.9.0 跳过了协议版本 3(Kafka 2.0 开发过渡版本,未正式支持)。
| Offset Fetch 协议版本 | 适配的 Kafka 版本范围 | 1.9.0 支持的核心能力 |
|---|---|---|
| 0 | 0.8.2.1 及以下 | 仅查询消费组基础偏移量,无 Topic 过滤 |
| 1 | 0.8.2.2 ~ 0.10.1.x | 支持消费组级偏移量,新增 ErrorCode |
| 2 | 0.10.2.0 ~ 1.1.x | 支持按 Topic / 分区过滤偏移量 |
| 4 | 2.0.0 及以上 | 支持消费者实例 ID、分区状态等扩展字段 |
4.5.3、1.9.0 中协议版本的实际调用场景
https://github.com/danielqsj/kafka_exporter/blob/v1.9.0/kafka_exporter.go#L593
在 1.9.0 源码中,fetchOffsetVersion() 仅在 collect() 方法的 getConsumerGroupMetrics 闭包中被调用,用于构建 OffsetFetchRequest,核心流程如下:
// 遍历所有消费者组的描述信息
for _, group := range describeGroups.Groups {
// 检查当前消费者组是否存在错误码,非0表示有异常
if group.Err != 0 {
// 记录错误日志:无法描述该消费者组,输出组ID和错误码
klog.Errorf("Cannot describe for the group %s with error code %d", group.GroupId, group.Err)
// 跳过当前异常的消费者组,继续处理下一个
continue
}
// 构建OffsetFetchRequest请求:用于获取消费者组的偏移量
// 参数说明:ConsumerGroup指定目标消费者组,Version指定请求的版本号(由e.fetchOffsetVersion()适配Kafka版本)
offsetFetchRequest := sarama.OffsetFetchRequest{ConsumerGroup: group.GroupId, Version: e.fetchOffsetVersion()}
// 判断是否需要获取所有Topic/Partition的偏移量(由offsetShowAll配置控制)
if e.offsetShowAll {
// 若开启offsetShowAll:遍历所有已缓存的offset的Topic和Partition,添加到请求中
for topic, partitions := range offset {
for partition := range partitions {
offsetFetchRequest.AddPartition(topic, partition)
}
}
} else {
// 若未开启offsetShowAll:仅获取该消费者组下成员实际订阅的Topic/Partition偏移量
// 遍历当前消费者组的所有成员
for _, member := range group.Members {
// 检查成员的分配信息是否为空,为空则记录警告日志并跳过
if len(member.MemberAssignment) == 0 {
klog.Warningf("MemberAssignment is empty for group member: %v in group: %v", member.MemberId, group.GroupId)
continue
}
// 解析成员的分配信息(Topic/Partition)
assignment, err := member.GetMemberAssignment()
if解析失败,记录错误日志并跳过该成员
if err != nil {
klog.Errorf("Cannot get GetMemberAssignment of group member %v : %v", member, err)
continue
}
// 遍历该成员分配到的所有Topic和Partition,添加到OffsetFetchRequest中
for topic, partions := range assignment.Topics {
for _, partition := range partions {
offsetFetchRequest.AddPartition(topic, partition)
}
}
}
}
// 构建并发送消费者组成员数的Prometheus指标
// 指标名:consumergroupMembers(kafka_consumergroup_members),值为组成员数量,标签为消费者组ID
ch <- prometheus.MustNewConstMetric(
consumergroupMembers, prometheus.GaugeValue, float64(len(group.Members)), group.GroupId,
)
// 向Kafka Broker发送OffsetFetchRequest请求,获取消费者组的偏移量响应
offsetFetchResponse, err := broker.FetchOffset(&offsetFetchRequest)
// 请求失败则记录错误日志,跳过当前消费者组的后续处理
if err != nil {
klog.Errorf("Cannot get offset of group %s: %v", group.GroupId, err)
continue
}
帮助 SRE 理解关键信息:
-
-
-
-
- 核心逻辑:这段代码是kafka_exporter采集消费者组(Consumer Group)指标的核心逻辑,主要做两件事:
- 构建偏移量获取请求(OffsetFetchRequest),根据配置决定是获取全量 Topic/Partition 还是仅消费者组订阅的部分;
- 采集并上报消费者组成员数指标,然后尝试获取偏移量用于后续计算消费延迟(Lag)。
- 关键配置:offsetShowAll 控制是否获取全量偏移量,默认关闭(仅获取订阅的 Topic/Partition),开启后会遍历所有已知的 Topic/Partition。
- Kafka 协议适配:fetchOffsetVersion() 会根据 Kafka 集群版本返回对应的 OffsetFetchRequest 版本号,保证不同 Kafka 版本的兼容性。
- Prometheus 指标:consumergroupMembers 对应暴露的kafka_consumergroup_members指标,用于监控消费者组的成员数量。
- 核心逻辑:这段代码是kafka_exporter采集消费者组(Consumer Group)指标的核心逻辑,主要做两件事:
-
-
-
4.6、Broker 连接去重优化
https://github.com/danielqsj/kafka_exporter/blob/v1.9.0/kafka_exporter.go#L694
// 打印调试级别的日志,提示正在获取消费者组指标
klog.V(DEBUG).Info("Fetching consumer group metrics")
// 检查客户端的 Broker 列表是否不为空(即存在可用的 Broker 节点)
if len(e.client.Brokers()) > 0 {
// 创建一个 map 用于去重 Broker 地址,key 是 Broker 地址字符串,value 仅用于标记是否已存在
uniqueBrokerAddresses := make(map[string]bool)
// 定义一个字符串切片,用于存储去重后的 Broker 地址列表
var servers []string
// 遍历客户端所有的 Broker 节点
for _, broker := range e.client.Brokers() {
// 将 Broker 地址转换为小写,统一格式避免因大小写导致的重复(例如 "127.0.0.1:9092" 和 "127.0.0.1:9092" 视为同一个)
normalizedAddress := strings.ToLower(broker.Addr())
// 检查该地址是否未被记录过(去重逻辑)
if !uniqueBrokerAddresses[normalizedAddress] {
// 标记该地址已存在,避免重复添加
uniqueBrokerAddresses[normalizedAddress] = true
// 将原始的 Broker 地址添加到去重后的列表中
servers = append(servers, broker.Addr())
}
}
// 打印去重后的 Broker 地址列表,用于日志排查
klog.Info(servers)
// 再次遍历所有 Broker 节点
for _, broker := range e.client.Brokers() {
// 遍历去重后的 Broker 地址列表
for _, server := range servers {
// 匹配当前 Broker 的地址和去重后的地址
if server == broker.Addr() {
// 增加等待组的计数,用于等待 goroutine 执行完成
wg.Add(1)
// 启动一个 goroutine 异步获取该 Broker 的消费者组指标
go getConsumerGroupMetrics(broker)
}
}
}
// 等待所有启动的 goroutine 执行完成后,再继续后续逻辑
wg.Wait()
} else {
// 如果没有可用的 Broker 节点,打印错误日志
klog.Errorln("No valid broker, cannot get consumer group metrics")
}
帮助 SRE 理解关键信息:
-
- Broker 地址去重:通过 map[string]bool 实现 Broker 地址的去重,同时将地址转为小写(strings.ToLower)避免因地址大小写不一致导致的重复。
- 异步并发获取指标:外层遍历所有 Broker、内层遍历去重后的地址列表,匹配成功后启动 goroutine 调用 getConsumerGroupMetrics,结合 sync.WaitGroup(wg)确保所有异步任务执行完成后再退出,避免程序提前结束导致指标获取不完整。
- 边界条件处理:先检查 len(e.client.Brokers()) > 0,确保有可用 Broker 时才执行后续逻辑,否则直接打印错误日志,避免空指针或无效操作。
4.6、Lag 计算值为 -1 的场景
4.6.1、先明确核心概念
在 kafka 中,Consumer Group Lag(消费组滞后)的核心计算公式是:
- Lag = Topic分区的最新偏移量(OffsetNewest) - 消费组已消费的偏移量(ConsumerOffset);
- 偏移量(Offset):是分区中消息的「位置标识」,并非消息计数(比如偏移量 1000 不代表第 1000 条消息);
- Lag=1 表示 "消费组提交的偏移量比分区最新偏移量小 1",不一定等于仅落后 1 条消息(极端情况下甚至可能无未消费消息)。
4.6.2、Lag 计算的核心代码逻辑
https://github.com/danielqsj/kafka_exporter/blob/v1.9.0/kafka_exporter.go#L646
// 声明变量:统计当前消费组在某个 Topic 下所有分区的已提交偏移量总和(偏移量标识,非消息数)
var currentOffsetSum int64
// 声明变量:统计当前消费组在某个 Topic 下所有分区的 Lag 总和(偏移量差值总和)
var lagSum int64
// 遍历当前消费组在该Topic下的所有分区的偏移量响应块
for partition, offsetFetchResponseBlock := range partitions {
// 获取当前分区偏移量请求的错误码
err := offsetFetchResponseBlock.Err
// 如果错误码不为「无错误」,打印错误日志并跳过该分区的计算
if err != sarama.ErrNoError {
klog.Errorf("Error for partition %d :%v", partition, err.Error())
continue
}
// 从响应块中获取消费组在该分区已提交的偏移量(偏移量标识,非已消费消息数)
currentOffset := offsetFetchResponseBlock.Offset
// 将该分区的已提交偏移量累加到Topic级别的偏移量总和中
currentOffsetSum += currentOffset
// 向 Prometheus 暴露指标:消费组-Topic-分区维度的已提交偏移量
// 指标类型:Gauge,值为已提交偏移量的浮点型转换(仅格式适配,无业务含义)
ch <- prometheus.MustNewConstMetric(
consumergroupCurrentOffset, prometheus.GaugeValue, float64(currentOffset), group.GroupId, topic, strconv.FormatInt(int64(partition), 10),
)
// 加锁:防止多 goroutine 同时操作 offset 缓存Map导致数据竞争
e.mu.Lock()
// 获取该Topic-分区的最新偏移量(OffsetNewest:最后一条消息的下一个位置标识)
currentPartitionOffset, currentPartitionOffsetError := e.client.GetOffset(topic, partition, sarama.OffsetNewest)
// 如果获取最新偏移量失败,打印错误日志(不中断,后续仍尝试计算Lag)
if currentPartitionOffsetError != nil {
klog.Errorf("Cannot get current offset of topic %s partition %d: %v", topic, partition, currentPartitionOffsetError)
} else {
// 声明变量:当前分区的 Lag(偏移量差值,非未消费消息数)
var lag int64
// 若消费组从未消费过该分区(已提交偏移量为-1),强制设置Lag为-1
if offsetFetchResponseBlock.Offset == -1 {
lag = -1
} else {
// 容错逻辑:如果缓存中存在该Topic-分区的偏移量
if offset, ok := offset[topic][partition]; ok {
// 若实时获取的最新偏移量失败(返回-1),使用缓存的偏移量替代
if currentPartitionOffset == -1 {
currentPartitionOffset = offset
}
}
// 核心计算:Lag = 分区最新偏移量 - 消费组已提交偏移量(偏移量差值)
lag = currentPartitionOffset - offsetFetchResponseBlock.Offset
// 将该分区的 Lag 累加到Topic级别的 Lag 总和中
lagSum += lag
}
// 向 Prometheus 暴露指标:消费组-Topic-分区维度的 Lag(偏移量差值)
ch <- prometheus.MustNewConstMetric(
consumergroupLag, prometheus.GaugeValue, float64(lag), group.GroupId, topic, strconv.FormatInt(int64(partition), 10),
)
}
// 解锁:释放对 offset 缓存Map的独占访问
e.mu.Unlock()
}
// 向 Prometheus 暴露指标:消费组-Topic维度的已提交偏移量总和(所有分区累加)
ch <- prometheus.MustNewConstMetric(
consumergroupCurrentOffsetSum, prometheus.GaugeValue, float64(currentOffsetSum), group.GroupId, topic,
)
// 向 Prometheus 暴露指标:消费组-Topic维度的 Lag 总和(所有分区Lag累加,仅偏移量差值和)
ch <- prometheus.MustNewConstMetric(
consumergroupLagSum, prometheus.GaugeValue, float64(lagSum), group.GroupId, topic,
)
4.6.2、Lag = -1 场景
https://github.com/danielqsj/kafka_exporter/blob/v1.9.0/kafka_exporter.go#L665
先明确 kafka_exporter 1.9.0 中,Lag 被强制设置为 -1 时的唯一显性逻辑:
// 检查获取当前分区偏移量的操作是否出错
if currentPartitionOffsetError != nil {
// 如果出错,打印错误日志,包含具体的Topic、分区和错误信息
klog.Errorf("Cannot get current offset of topic %s partition %d: %v", topic, partition, currentPartitionOffsetError)
} else {
// 定义 lag 变量,用于存储当前分区的消费延迟(单位:偏移量)
var lag int64
// 检查 OffsetFetchResponse 中获取的消费位点是否为 -1(-1 通常表示该分区无消费位点)
if offsetFetchResponseBlock.Offset == -1 {
// 无消费位点时,将延迟标记为 -1(表示无法计算延迟)
lag = -1
} else {
// 尝试从 offset 字典中获取该Topic-分区对应的偏移量
if offset, ok := offset[topic][partition]; ok {
// 如果能获取到,且当前分区的偏移量为 -1(未初始化)
if currentPartitionOffset == -1 {
// 则将从字典中获取的偏移量赋值给当前分区偏移量
currentPartitionOffset = offset
}
}
// 计算消费延迟:当前分区最新偏移量 - 消费者已消费的偏移量
lag = currentPartitionOffset - offsetFetchResponseBlock.Offset
// 将当前分区的延迟累加到总延迟 lagSum 中
lagSum += lag
}
帮助 SRE 理解关键信息:
-
-
-
-
- Offset == -1:表示该分区没有消费者提交的消费位点,无法计算延迟,故将 lag 设为 -1。
- currentPartitionOffset == -1:表示当前分区最新偏移量未初始化,用字典中缓存的偏移量兜底。
- 场景 1:消费组从未消费过目标Topic / 分区(最常见)
- 消费组刚创建,尚未启动任何消费者实例,或消费者实例从未订阅过该Topic;
- 消费组虽订阅了Topic,但从未分配到该分区(比如分区数 > 消费者数,部分分区无消费者处理);
- kafka Broker 中无该 "消费组-Topic-分区" 的偏移量提交记录,因此返回 Offset=-1,最终 Lag=-1。
- 比如:消费组 group-01 订阅了Topic topic-01(含 3 个分区),但仅启动了 2 个消费者实例,分区 2 未被分配,此时查询分区 2 的偏移量时,Broker 返回 -1,Lag=-1。
-
场景 2:消费组的偏移量被手动删除 / 清理
-
-
-
-
-
-
-
-
- 手动执行过 kafka 命令删除消费组的偏移量记录:kafka-consumer-groups.sh --bootstrap-server <broker:port> --delete-offsets --group <group-id> --topic <topic> [--partition <partition>]
- kafka 的 offset.retention.minutes(默认 1440 分钟,即 24小时)配置项,会清理 "超过保留时间未提交偏移量" 的消费记录
- 消费组长时间停止运行(超过保留时间),且未提交任何新偏移量;
- Broker 自动清理该消费组的偏移量记录,后续查询时返回 Offset=-1,Lag=-1。
- 场景 3:消费组提交偏移量时指定了 "无效偏移量"
- 消费组手动提交偏移量时,若提交了非法值(如 -1),会导致 Broker 记录的偏移量为 -1:
- 手动执行过 kafka 命令,修改了偏移量为 -1:kafka-consumer-groups.sh --bootstrap-server <broker:port> --reset-offsets --group <group-id> --topic <topic> --to-offset -1 --execute
- 场景 4:消费组的偏移量Topic(__consumer_offsets)数据损坏 / 丢失
- kafka 消费组的偏移量存储在内部Topic __consumer_offsets 中:
- 若该Topic的副本数据损坏、丢失,或分区副本均不可用;
- Broker 无法读取到「消费组 - Topic - 分区」的偏移量记录,会返回默认值 -1,最终 Lag=-1。
- 场景 5:消费组重新平衡后,分区未被重新分配
- 消费组发生 rebalance(如消费者实例增减、分区数变更)时:
- 某个分区在 rebalance 过程中暂时未被分配给任何消费者;
- 此时查询该分区的偏移量,Broker 因无当前有效消费者的提交记录,返回 Offset=-1,Lag=-1;
- 待 rebalance 完成、分区重新分配后,若消费者提交了新偏移量,Lag 会恢复为正常数值。
- 消费组发生 rebalance(如消费者实例增减、分区数变更)时:
- 场景 6:Topic 分区被删除后重建,偏移量关联失效
-
-
-
-
-
-
-
-
-
- 先删除Topic topic-01 的分区 1,该分区的偏移量记录仍保留在 __consumer_offsets 中;
- 重建 topic-01 的分区 1,新分区与旧偏移量记录失去关联;
- 查询消费组在该新分区的偏移量时,Broker 无有效记录,返回 Offset=-1,Lag=-1。
-
-
-
-
4.6.3、验证 Lag = -1 原因的方法
# 1. 查看消费组的偏移量详情 kafka-consumer-groups.sh --bootstrap-server <broker:port> --describe --group <group-id> --topic <topic> # 输出中若「CURRENT-OFFSET」列为 -1,说明是场景1/2/3/4; # 若「CURRENT-OFFSET」为空,说明是场景5/6。 # 2. 检查消费组是否存活 kafka-consumer-groups.sh --bootstrap-server <broker:port> --list # 若消费组不存在,说明是偏移量被清理(场景2)。 # 3. 检查 __consumer_offsets Topic状态 kafka-topics.sh --bootstrap-server <broker:port> --describe --topic __consumer_offsets # 若该Topic有副本不可用、ISR 异常,说明是场景4。

浙公网安备 33010602011771号