做监控这么多年,我见过太多“裸奔”的系统了。
很多兄弟以为装个 Node Exporter,再把 mysqld_exporter 跑起来,Grafana 随便拖几个酷炫的仪表盘,就算是“懂监控”了。结果呢?业务接口延迟飙升,你看 CPU 正常;订单量腰斩,你看内存正常。老板在群里咆哮“为什么用户投诉了我们才知道?!”,你盯着那几个毫无波澜的基础指标,手心全是冷汗。
这就是监控的“盲区”。
真正的监控高手,从来不满足于社区提供的标准品。业务逻辑只有你最懂,核心指标必须自己通过 Exporter 暴露出来! 无论你是想监控一个老旧的 Java 遗留系统,还是一个自研的 Go 微服务,甚至是一个没有任何接口的物理设备,掌握 Prometheus Exporter 的开发能力,就是你从“运维民工”进阶到“SRE 专家”的分水岭。
今天这篇长文,我不讲虚头巴脑的理论,直接把自己压箱底的 10 年 Exporter 开发经验掏出来。从指标设计避坑,到 Go 语言实战,再到生产级的高性能优化,全部是真枪实弹的代码和血泪教训。
看完这篇,要是还不会写 Exporter,你来顺着网线打我!
第一部分:Prometheus生态快速入门 从Server到Exporter:理解监控的骨骼
很多新手一上来就扎进代码里,结果写出来的 Exporter 要么性能极差,要么根本连不上。我们得先搞清楚,Prometheus 到底是怎么“吸血”的。
1.1 Prometheus三大核心组件的协作关系
Prometheus 的架构其实非常简单粗暴,简单到只有三个核心角色,我把它们称为“监控铁三角”:
- Prometheus Server(大脑): 它是个不知疲倦的爬虫。它不被动接收数据,而是主动出击(Pull 模式),定期去各个目标地址“拉取”数据。
- Pushgateway(缓存站): 专门处理那些“短命”的任务。比如一个只运行 5 秒的批处理脚本,Server 还没来得及拉取它就挂了,这就需要它先把数据推给 Pushgateway。
- Exporter(翻译官): 这才是我们今天的主角! 绝大多数现成的软件(如 MySQL、Redis、Linux 内核)并不会天生吐出 Prometheus 能看懂的格式。Exporter 的作用就是充当“中间人”,连接到目标应用,拿数据,转换成 Prometheus 的文本格式,然后暴露一个 HTTP 接口等着 Server 来拉。
你要记住:Exporter 本质上就是一个暴露 HTTP 接口的 Web 程序,仅此而已。
1.2 Exporter的两种运行模式:独立 vs 集成
在开发前,你必须做一个战略选择:
- 独立模式 (Independent / Sidecar): 也就是如果你要监控 MySQL,你不可能去改 MySQL 的源码。所以你得写一个独立的程序(mysqld_exporter),它去连 MySQL 的库,查数据,然后自己起一个 HTTP 端口。
- 适用场景: 监控第三方中间件、黑盒系统、老旧遗留系统。
- 集成模式 (Inline / Instrumentation): 如果你在写一个 Go 语言开发的微服务,千万别傻乎乎地再写个独立 Exporter。直接在你的服务代码里引入 Prometheus SDK,开一个
/metrics接口。- 适用场景: 自研应用、微服务、内部 API。
1.3 社区Exporter的现状与局限
社区里有几百个现成的 Exporter,好用吗?好用。够用吗?绝对不够!
社区的 Exporter 只能告诉你“数据库还活没活着”、“QPS 是多少”。但是,它告诉不了你:
- “今天那个大客户的订单处理失败率是不是超标了?”
- “库存同步的逻辑是不是卡在第 3 步了?”
这些业务维度的洞察,只有你自己写的 Custom Exporter 能给。
真实 Scrape Config 配置示例:
如果你写好了一个 Exporter,跑在 192.168.1.100:8080,你只需要在 Prometheus 的 prometheus.yml 里加上这几行:
scrape_configs:
- job_name: 'my_custom_business_app'
scrape_interval: 15s # 哪怕是生产环境,15s也是个黄金标准
static_configs:
- targets: ['192.168.1.100:8080']
# 加上这个标签,以后排查问题能救命
labels:
env: 'production'
region: 'shanghai'
第二部分:指标设计论 三种指标类型的深度解析与应用
代码写得再溜,指标设计错了,整个监控系统就是垃圾。我见过太多人把 Gauge 当 Counter 用,最后画出来的图全是断崖式下跌,简直是灾难。
Prometheus 的指标类型,常用的就这四个,吃透它们,你就懂了一半。
2.1 Counter(计数器):只增不减的“守财奴”
定义: Counter 是最简单的类型,它代表一种累积的指标。就像你的汽车里程表,除非你把车(程序)砸了(重启),否则它永远只会增加,不会减少。
适用场景:
- HTTP 请求总数 (
http_requests_total) - 任务完成总数 (
tasks_completed_total) - 错误发生总次数 (
errors_total)
核心逻辑:
我们在 PromQL 里查询时,从来不直接看 Counter 的绝对值(因为它一直在涨,没意义),我们只看增长率。
也就是最常用的 rate() 函数:
rate(http_requests_total[5m]) -> 这里的含义是过去 5 分钟的每秒平均请求数(QPS)。
避坑指南: 永远不要用 Counter 来记录“当前在线人数”。因为人数会减少,而 Counter 只要一减少,Prometheus 就会认为发生了一次“重置”,算出来的 rate 会瞬间变成负数或巨大的异常值!
2.2 Gauge(仪表盘):情绪波动的“心电图”
定义: Gauge 是最直观的,它可增可减,就像你的车速表,或者你银行卡里的余额。
适用场景:
- 当前内存使用率 (
memory_usage_bytes) - 当前 Goroutine 数量 (
go_goroutines) - 任务队列中积压的任务数 (
job_queue_length)
核心逻辑:
Gauge 反映的是瞬时状态。它不需要 rate(),直接看值就行。
2.3 Histogram(直方图):拒绝平均数的“照妖镜”
这是最难理解,但也最有价值的指标。
老板问你:“我们的 API 慢不慢?” 你答:“平均响应时间 200ms。” 老板满意地点头。 结果背后可能有 10% 的用户请求耗时超过 5 秒,甚至超时!平均数把这些长尾问题给“抹平”了。
Histogram 就是为了解决这个问题。 它会把观测到的数据放入一个个“桶”(Bucket)里。比如:
- 小于 0.1s 的有 100 个
- 小于 0.5s 的有 500 个
- 小于 1s 的有 800 个
适用场景:
- 请求延迟(Latency)
- 响应大小(Response Size)
通过 Histogram,我们可以计算出 P99(99分位值)。
histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))
这句话的意思是:99% 的请求都在这个时间内完成了。这才是真实的性能指标!
2.4 Summary(摘要):客户端的“独角戏”
Summary 和 Histogram 很像,都是算分位数的。但区别在于:
- Histogram: 客户端只负责分桶,P99 是服务端(Prometheus)算出来的。消耗服务端 CPU,但支持聚合(比如算 10 台机器整体的 P99)。
- Summary: 客户端直接算好 P99 发给服务端。不消耗服务端 CPU,但不支持聚合(你不能把 10 台机器的 P99 加起来求平均)。
结论: 除非你明确知道自己在做什么,否则90% 的场景请使用 Histogram。
高危预警:高基数标签(High Cardinality) 这是新手把生产环境搞崩的第一原因! 比如你定义了一个指标
http_requests_total,然后你加了一个标签叫user_id。 假如你有 1000 万个用户。Prometheus 就要存储 1000 万条不同的时间序列。内存和磁盘会瞬间爆炸! 绝对禁止在 Label 中放入:用户 ID、订单 ID、随机 Token、邮件地址等无界数据!
2.5 指标命名规范
好的命名是成功的一半。Google SRE 推荐的命名法:
{namespace}_{subsystem}_{name}_{unit}
- Metric Name:
app_db_query_duration_seconds(清晰、带单位) - Bad Name:
db_query_time(到底是毫秒还是纳秒?是哪个 App 的?)
第三部分:Go语言实现 从理论到代码的蜕变
光说不练假把式。Go 语言是 Prometheus 的亲儿子(Prometheus 本身就是 Go 写的),所以 client_golang 库非常强大。
我们将通过 4 个步骤,手撸一个生产级的监控代码。
3.1 环境搭建
首先,初始化项目结构。别把所有代码都塞在 main.go 里,那太业余了。
mkdir my-exporter
cd my-exporter
go mod init my-exporter
go get github.com/prometheus/client_golang/prometheus
go get github.com/prometheus/client_golang/prometheus/promhttp
建议目录结构:
.
├── main.go # 入口
├── collectors/ # 自定义采集器逻辑
│ ├── system.go
│ └── business.go
├── metrics/ # 指标定义
└── go.mod
3.2 基础Exporter实现:监控你的API服务
这是最简单的场景:你在写一个 Web 服务,想记录 API 的请求量。我们使用“直接埋点”的方式。
代码模板 1:基础集成模式
package main
import (
"log"
"math/rand"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// 1. 定义指标
// 使用 promauto 可以自动注册,省去手动 Register 的步骤,适合简单场景
var (
opsProcessed = prometheus.NewCounter(prometheus.CounterOpts{
Name: "myapp_processed_ops_total",
Help: "The total number of processed events",
})
)
// 模拟业务处理函数
func recordMetrics() {
go func() {
for {
// 2. 埋点:在业务逻辑发生的地方调用 Inc()
opsProcessed.Inc()
time.Sleep(2 * time.Second)
}
}()
}
func main() {
// 启动模拟业务
recordMetrics()
// 3. 暴露端点
// 这是一个标准的 HTTP Handler,Prometheus Server 会来这就拉数据
http.Handle("/metrics", promhttp.Handler())
log.Println("Exporter started on :2112")
log.Fatal(http.ListenAndServe(":2112", nil))
}
这段代码发生了什么?
- 我们定义了一个
Counter叫myapp_processed_ops_total。 - 我们在后台协程里每隔 2 秒让它
Inc()(加一)。 - 我们在
:2112/metrics暴露了数据。
你运行起来,访问 http://localhost:2112/metrics,就能看到数据在跳动了。这就成功了一半!
3.3 自定义Collector实现:处理无法直接埋点的场景
现在难度升级。你需要监控 Redis 的连接池状态,或者监控 Linux 系统的负载。你没法在 Redis 的源码里插代码。这时候,你需要实现 Collector 接口。
这叫“收集模式”:Prometheus 来拉取时,我们才去现场查数据,查完赶紧返回。
代码模板 2:Redis 连接数监控(自定义 Collector)
package main
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// 定义你的收集器结构体
type RedisCollector struct {
// 这里通常放 Redis 的连接池对象
// redisPool *redis.Pool
// 预先定义描述符,提高性能
connectedCountDesc *prometheus.Desc
}
// 构造函数
func NewRedisCollector() *RedisCollector {
return &RedisCollector{
connectedCountDesc: prometheus.NewDesc(
"my_redis_connected_clients",
"Number of clients currently connected to Redis",
nil, nil, // 这里可以定义动态标签,暂且留空
),
}
}
// 核心方法 1: Describe
// 告诉 Prometheus 你有哪些指标
func (c *RedisCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.connectedCountDesc
}
// 核心方法 2: Collect
// 每次 Server 来拉取数据时,这个方法会被执行!
func (c *RedisCollector) Collect(ch chan<- prometheus.Metric) {
// 1. 执行真实的业务采集逻辑
// currentCount := c.redisPool.Stats().ActiveCount
// 这里为了演示,我们模拟一个值
currentCount := 100.0 // 假设当前有100个连接
// 2. 生成 Metric 并塞入通道
// MustNewConstMetric 是创建即时指标的标准姿势
ch <- prometheus.MustNewConstMetric(
c.connectedCountDesc,
prometheus.GaugeValue, // 类型是 Gauge
currentCount,
)
}
func main() {
// 实例化收集器
collector := NewRedisCollector()
// 注册到全局 Registry
prometheus.MustRegister(collector)
http.Handle("/metrics", promhttp.Handler())
http.ListenAndServe(":2112", nil)
}
专家点拨:
Collect()方法必须非常快!因为 Prometheus 默认的拉取超时只有 10 秒。如果你在这里执行一个耗时 20 秒的 SQL 查询,监控就会断点(Timeout)。对于耗时操作,必须异步采集!
3.4 Registry管理:别让全局变量坑了你
新手喜欢用 prometheus.MustRegister,这会把指标注册到默认的 DefaultRegisterer。
在生产级代码,尤其是你如果你在写一个库给别人用,千万别用全局 Registry。
最佳实践: 创建一个独立的 Registry。
reg := prometheus.NewRegistry()
reg.MustRegister(NewRedisCollector())
// 暴露 handler 时指定 registry
handler := promhttp.HandlerFor(reg, promhttp.HandlerOpts{})
http.Handle("/metrics", handler)
这样做的好处是,默认的 Go 运行时指标(比如 GC 耗时、Goroutine 数量)不会污染你的业务指标,除非你显式添加进去。
第四部分:生产级最佳实践 从能用到专业的飞跃
代码能跑通只是第一步,能在每秒几万 QPS 的冲击下不崩,才是真本事。
4.1 性能优化:高基数标签的生死线
再强调一次:High Cardinality 是万恶之源。
真实案例: 某大厂工程师在 Metric Label 里加了一个 url 标签。
原本 http_requests_total{method="GET"} 只有几个序列。
结果因为 RESTful API 的 URL 带有 ID,变成了:
http_requests_total{url="/api/users/123"}
http_requests_total{url="/api/users/124"}
...
一夜之间,Prometheus 内存溢出(OOM),整个集群瘫痪。
规避方法:
在中间件层面做 URL 归一化。把 /api/users/123 转换成 /api/users/:id 这种模板后再作为 Label 值。
4.2 错误处理:当数据源挂了怎么办?
如果你的 Exporter 是连 MySQL,但 MySQL 挂了,Collect() 方法报错了,该怎么办?
- 错误做法: 直接 Panic 或者返回 0。返回 0 会误导监控(以为是连接数为 0,其实是连不上)。
- 正确做法:
Collect方法虽然不能返回 error,但你可以记录一个内部指标exporter_scrape_errors_total并 +1。- 或者利用
MustNewConstMetric时的 Error Log 机制。 - 优雅降级: 如果只是部分指标失败,尽可能返回成功的那些。
4.3 容量规划
Exporter 本身也是要吃资源的。
- 内存: 主要取决于 Metric 的数量(TimeSeries 数量)。Go 的 Map 结构如果不控制,内存会一直涨。
- CPU: 取决于
Collect()里的计算逻辑。如果涉及到复杂的正则匹配或大规模数据聚合,CPU 会飙升。
何时拆分? 如果你一个 Exporter 既监控 Redis,又监控 Nginx,还监控业务逻辑,那它就太重了。遵循单一职责原则。Redis Exporter 就只干 Redis 的事。
4.4 版本管理:指标的“契约精神”
千万别随意改名!
你今天叫 app_latency,明天觉得不好听改成了 app_latency_seconds。
负责看大屏的运维同学会想杀人,因为历史数据全断了,Grafana 面板全报错(No Data)。
优雅弃用流程:
- 新旧指标同时存在(
app_latency和app_latency_seconds都吐数据)。 - 通知所有下游消费者迁移。
- 一个月后,删除旧指标。
第五部分:实战案例 完整的业务监控系统设计
光说不做假把式,我们来个真实的:电商核心交易链路监控。
5.1 案例背景
我们需要监控一个“订单服务”。关注点包括:
- 业务层: 下单成功/失败数、订单金额分布。
- 依赖层: 数据库连接池状态。
- 系统层: CPU、内存(这部分通常由 Node Exporter 覆盖,这里不重复)。
5.2 代码模板 3:订单服务完整 Exporter
package main
import (
"math/rand"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// 定义全局指标
var (
// 1. Counter: 订单总量
orderTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "shop_order_created_total",
Help: "Total number of orders created",
},
[]string{"status", "payment_method"}, // 标签:状态、支付方式
)
// 2. Histogram: 订单处理耗时 (P99 监控)
orderDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "shop_order_process_duration_seconds",
Help: "Time taken to process order",
Buckets: []float64{0.1, 0.5, 1, 2, 5}, // 针对性的 Bucket 设置
},
[]string{"flow_step"}, // 标签:处理步骤
)
// 3. Gauge: 当前库存水位 (模拟连接业务数据)
inventoryLevel = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "shop_inventory_stock",
Help: "Current inventory level of items",
},
[]string{"product_category"},
)
)
func init() {
// 注册指标
prometheus.MustRegister(orderTotal)
prometheus.MustRegister(orderDuration)
prometheus.MustRegister(inventoryLevel)
}
// 模拟业务循环
func simulateTraffic() {
for {
// 模拟下单成功
orderTotal.WithLabelValues("success", "alipay").Inc()
// 模拟库存变化
inventoryLevel.WithLabelValues("electronics").Set(float64(rand.Intn(100)))
// 模拟耗时
timer := prometheus.NewTimer(orderDuration.WithLabelValues("validate_stock"))
time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)
timer.ObserveDuration()
time.Sleep(100 * time.Millisecond)
}
}
func main() {
// 启动业务模拟
go simulateTraffic()
// 启动 Exporter
server := &http.Server{Addr: ":8080"}
http.Handle("/metrics", promhttp.Handler())
println("Order Service Exporter running on :8080")
server.ListenAndServe()
}
5.3 交付物清单
做完这个,你应该产出三样东西:
- Binary: 编译好的 Go 程序。
- Scrape Config: 告诉 Prometheus 怎么拉取。
- Grafana Dashboard JSON: 这才是老板爱看的。你的 JSON 里应该包含:
- QPS 面板:
sum(rate(shop_order_created_total[1m])) by (status) - 延迟面板:
histogram_quantile(0.99, sum(rate(shop_order_process_duration_seconds_bucket[5m])) by (le)) - 库存水位:
shop_inventory_stock
- QPS 面板:
第六部分:常见问题与踩坑指南 从100个真实问题中提炼的经验
这些坑,都是我熬夜排查出来的血泪史,看完能帮你省下几百个小时的 Debug 时间。
6.1 高基数标签导致磁盘爆满
现象: Prometheus 启动极慢,内存占用极高,磁盘写入 IOPS 爆表。
原因: 你在 Label 里记录了 client_ip 或者 query_sql(完整 SQL 语句)。
修正:
// 错误写法
counter.WithLabelValues(r.RemoteAddr).Inc()
// 正确写法
// 甚至不需要在 metrics 里记录 IP,那是日志(Logs)该干的事!
// 监控(Metrics)只看聚合趋势。
6.2 Counter 无法重置的由来
现象: 服务重启后,Counter 从 0 开始,但 rate() 函数计算出的曲线突然出现一个巨大的负值尖刺(在旧版 Prometheus)或者断点。
解释: Prometheus 的 rate 函数能够自动处理 Counter 重置(Counter Reset)。它看到值变小了,会判定为重启,自动把它“拼接”起来。
注意: 千万不要手动去重置 Counter!让它一直涨,直到溢出(Go 的 float64 实际上几乎不可能溢出)或重启。
6.3 /metrics 接口变慢
现象: Prometheus 报 Context Deadline Exceeded。
原因: 你的 Collect() 方法里有慢查询。比如每次拉取数据,你都要去数据库 SELECT count(*) FROM big_table。
修正:
不要在 Collect() 里做重计算。
应该在后台起一个 Goroutine 定时(比如每 1 分钟)算好结果,存到内存变量里。Collect() 只是简单地把这个内存变量读出来返回。这叫读写分离。
结尾部分:行动指引与延展方向
兄弟们,监控不是一个插件,它是系统的内裤。没穿内裤出门,早晚要出丑。
通过今天的文章,我们从架构原理聊到了代码实战,从指标设计聊到了生产避坑。这套“组合拳”,足够你应付 99% 的监控需求了。
4周行动规划
别光收藏不练!给自己定个计划:
- 第一周(Hello World): 本地跑通 Prometheus + Grafana,运行上面那个最简单的 Go 代码。
- 第二周(植入业务): 挑一个你负责的非核心业务,按照“指标设计论”,加上业务维度的埋点。
- 第三周(压力测试): 用压测工具轰炸你的接口,观察 Exporter 的内存变化,优化
Collect性能。 - 第四周(大屏展示): 学习 PromQL,画出一套让产品经理和老板都看得懂的 Dashboard。
最后的一句心里话
如果你觉得这篇文章让你对监控有了新的认识,请关注我。你的每一个点赞和转发,都是我继续死磕技术、输出干货的最大动力。
让我们一起,拒绝“裸奔”,做一个对系统了如指掌的顶尖架构师!
声明: 本文内容 90% 为本人原创,少量素材经 AI 辅助生成,且所有内容均经本人严格复核;图片素材均源自真实素材或 AI 原创。文章旨在倡导正能量,无低俗不良引导,敬请读者知悉。