Learn
Prometheus/19-instrumentation-best-practices

埋点最佳实践

前十八章里,我们一直在「消费」指标——写 PromQL、配告警、做容量规划。但你有没有想过:那些 http_requests_total、node_cpu_seconds_total 到底是怎么来的?当你坐在键盘前,要为一个新服务动手埋点时,真正困难的部分才刚开始。

埋点(instrumentation)是把「程序运行时的状态」变成「可被 Prometheus 抓取的指标」的过程。这一步做得好坏,直接决定了后面所有监控的上限:指标设计错了,再花哨的查询也救不回来。一个把 le 桶划分得不合理 Histogram,会让你永远算不出真实的 P99;一个用 Gauge 表达「累计请求数」的指标,会让 rate() 计算直接崩掉。

本章不教你某个具体的客户端库怎么装(文档到处都是),而是教你决策——什么时候该用 Counter,什么时候该用 Histogram,指标该叫什么名字,标签该放什么不该放什么,以及当你需要自己写一个 Exporter 时,那几行 HELP/TYPE 到底意味着什么。

读完本章你会掌握:

  • 四种指标类型(Counter、Gauge、Histogram、Summary)的本质区别,以及「看到需求 → 选对类型」的判断框架
  • Counter 只能增、重置归零、rate() 才是它的正确读法;Gauge 可任意增减、不要用它表达累计量
  • Histogram 与 Summary 的根本权衡:客户端算分位数 vs 服务端算分位数、可聚合性、以及桶(le)怎么选
  • 命名规范:单位后缀(_total/_seconds/_bytes)、snake_case、标签里不放单位、避免保留标签名
  • RED 方法(Rate/Errors/Duration)与 USE 方法(Utilization/Saturation/Errors)两套落地框架,分别适合服务与资源
  • 用官方客户端库(Go / Java / Python)写出结构正确的埋点骨架
  • 自定义 Exporter 的 OpenMetrics 文本格式:HELP 行、TYPE 行、以及一行样本的正确写法

四种指标类型的取舍:先问「它是什么」

很多人埋点的第一反应是「先写个 counter 再说」,结果所有东西都是 Counter,等到想算 P99 时发现数据根本不支持。选类型的正确起点是:问自己这个量在物理上是什么。

Counter:只增不减的累计量

Counter 代表一个单调递增的计数器,从进程启动开始,只会增加,天然会在进程重启时归零(这是它的特性,不是 bug)。典型的 Counter 包括:

  • 收到的 HTTP 请求总数
  • 发生的错误总数
  • 处理的字节总数

Counter 的核心规律是:你几乎永远不应该直接读它的瞬时值,而应该用 rate() 或 irate() 读它的「变化速度」。因为归零、因为你想看的是「速率」而非「总数」。

# 错误:直接读 Counter 瞬时值,重启归零后曲线会断崖
http_requests_total
 
# 正确:读每秒速率
rate(http_requests_total[5m])
 
# 想知道「累计了多少」时用 increase()
increase(http_requests_total[1h])

一个常见的反模式:把「当前在线用户数」用 Counter 记。在线用户数会减少,它不是累计量,应该用 Gauge。

Gauge:可以任意增减的瞬时值

Gauge 代表一个可以上下波动的瞬时测量值,没有「累计」语义。典型的 Gauge 包括:

  • 当前内存使用量(字节)
  • 当前在线连接数
  • 当前温度、队列长度、goroutine 数
  • 当前 Gauge 的设定值(如 HPA 的目标副本数)

Gauge 的特征是:你读它的瞬时值就是它当下的真实状态,不需要 rate()。你可以用 max、min、avg 这类聚合,但千万不要对 Gauge 用 rate()——Gauge 的增减不是「事件计数」,算速率没有物理意义。

# 当前内存使用量(直接读就好)
go_memstats_heap_inuse_bytes
 
# 看它随时间的峰值
max_over_time(go_memstats_heap_inuse_bytes[1h])
 
# 错误:对 Gauge 用 rate 是概念错误
rate(go_memstats_heap_inuse_bytes[5m])
💡小技巧

一个快速判断法:如果这个量「之前发生过的事的总和」,用 Counter;如果这个量「此刻快照到的状态」,用 Gauge。请求数总和 → Counter;内存占用 → Gauge。

Histogram:把分布切成桶

Histogram 在客户端把观测值(如请求耗时)按一组预设的边界(bucket)分桶计数。它的本质其实是一组 Counter:_bucket{le="0.1"}、_bucket{le="0.5"}、_bucket{le="+Inf"},再加上 _sum(所有观测值之和)和 _count(观测总次数)。

关键点在于:分位数(P99 等)是在服务端用 histogram_quantile() 算出来的,客户端只负责数每个桶里落了多少个值。这让 Histogram 具备一个巨大优势——可以跨实例聚合。你可以把十台机器的 _bucket 加起来,再算一次整体的 P99,结果依然正确。

# 整体 P99 延迟(跨所有实例聚合,结果仍正确)
histogram_quantile(
  0.99,
  sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)

Histogram 的代价是:桶边界在埋点时就要定好,而且一旦定好,落在不合理区间分布的耗时就无法精确估计(会塌缩到最近的桶)。桶多了存储成本高,桶少了精度差,这是埋点阶段最重要的一个权衡。

Summary:客户端直接算分位数

Summary 也在客户端,但它不像 Histogram 那样只数桶,而是直接在客户端用流式算法算出分位数(如 φ=0.5、0.9、0.99),并把结果作为 Gauge 暴露出来(典型后缀 _quantile{quantile="0.99"})。它还记录 _sum 和 _count。

Summary 的优势是:分位数精度不依赖桶边界,计算在客户端完成,服务端查询极轻。但它的致命弱点是:分位数不可聚合——你不能把两台机器的 _quantile 求平均来得到整体 P99,那样在数学上是错的。

一张表看清 Histogram vs Summary

维度HistogramSummary
分位数在哪算服务端(histogram_quantile)客户端(流式算法)
能否跨实例聚合分位数能(先 sum by (le) 再算)不能(聚合后的分位数无意义)
桶边界影响精度是(埋点时要设计好)否(但只暴露预设的 φ 值)
查询开销较高(要算 quantile)极低(直接读 Gauge)
存储成本随桶数线性增长固定几个 φ 值
适合场景多实例服务、需要灵活切分位数、SLO 计算单实例、固定几个分位数、查询要快
⚠️注意

绝大多数现代服务场景,默认选 Histogram。 原因只有一个:可聚合。微服务里一个「订单服务」至少有 8 个实例,你需要的是「整体 P99」而不是「某台机器的 P99」,而 Summary 做不到这一点。除非你明确只有单实例、且只关心固定的几个分位数、且查询压力极大,才考虑 Summary。

命名规范:指标名字是第一印象

指标名会伴随这个服务整个生命周期,改一次的成本极高(所有 Grafana 面板、告警规则、记录规则都要跟着改)。埋点时就定好规范,比事后返工便宜一百倍。

基本格式:(<namespace>_)<subsystem>_<name>_<unit>

Prometheus 官方推荐的命名结构是分层、带单位后缀的 snake_case:

  • namespace:应用名或团队名,避免和别的系统撞名。例如 http_server、process、myapp。
  • subsystem:子系统,可选。例如 requests、cache、db。
  • name:具体要测什么。例如 duration、count。
  • unit:单位后缀,必须带,用复数基本单位。
# 好:带命名空间、带单位后缀、snake_case
http_server_request_duration_seconds
myapp_cache_evictions_total
node_filesystem_avail_bytes
 
# 坏:没有单位、没有命名空间、含义模糊
http_request_time
requestCount
myMetric1

单位后缀清单

Prometheus 约定用基本单位的复数形式作为后缀,而不是任意单位:

物理含义正确后缀错误写法
请求数(累计)_total_count、_num
秒_seconds_ms、_second(单数)
字节_bytes_byte、_kb
米_meters_m
比率(0–1)_ratio_percent、无后缀
摄氏度_celsius_temperature

注意几个高频错误:

  1. _total 只给 Counter 用。它不仅是命名习惯,更是语义信号——任何看到 _total 的人都知道「这是 Counter,请用 rate()」。一个 Gauge 叫 memory_usage_total 是错的。
  2. 单位用基本单位,不要写 _ms。延迟用 _seconds,如果你想看毫秒,在查询或面板里换算,而不是埋点时就用毫秒。理由是:不同人写的毫秒桶和秒桶无法聚合,而且 _seconds 是生态共识。
  3. 标签里不要放单位。标签 http_request_duration_ms 这种「把维度当单位」的做法是错的——单位应固定在指标名里。

标签命名与保留字

标签名同样用 snake_case。有几条红线:

  • 不要用保留标签:job、instance、__name__、le、quantile、bucket 这些由 Prometheus 或指标类型自己使用,自定义标签不要占用。
  • 不要放「单位」进标签值:latency="100ms" 这种把单位塞进值里的写法会让聚合失效。值应是纯数字,单位在指标名里。
  • 标签取值要低基数:这是第 18 章讲过的基数问题。像 user_id、request_id 这种高基数维度,绝不能做标签。
ℹ️提示

一个实用的自查:把指标名读给同事听。如果「http_server_request_duration_seconds」能让人立刻明白「这是 http server 的请求耗时,单位是秒,是个 Histogram」,这个名字就合格了。如果还需要解释,说明它不合格。

RED 方法:给「服务」埋点的标准答案

RED 是 Tom Wilkie 提出的、专门给请求驱动型服务(HTTP API、RPC、消息消费)用的埋点框架。它只要求你埋三类指标,却覆盖了服务健康度 90% 的关注点:

  • Rate:每秒请求数(rate(requests_total[5m]))
  • Errors:每秒错误数,通常用一个带 status="error" 或 code=~"5.." 标签的 Counter
  • Duration:请求耗时分布,用 Histogram
# RED 三件套的标准查询
# Rate
sum by (service) (rate(http_requests_total[5m]))
 
# Errors(错误率 = 错误速率 / 总速率)
sum by (service) (rate(http_requests_total{status="500"}[5m]))
/
sum by (service) (rate(http_requests_total[5m]))
 
# Duration(P99)
histogram_quantile(0.99,
  sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)

RED 的精华在于:它把「一个服务要不要埋点」这个问题,压缩成了「R/E/D 三件事你都覆盖了吗」。如果你只埋了一件事,那就是 Duration——因为延迟往往是用户最先感知、也最难事后推断的指标。

💡小技巧

RED 适合「对请求负责」的角色:Web API、gRPC 服务、Kafka 消费者、数据库代理。如果你埋的是「机器」或「资源」,请看下一节的 USE。

USE 方法:给「资源」埋点的标准答案

USE 是 Brendan Gregg 提出的、专门给物理/虚拟资源(CPU、内存、磁盘、网络、文件描述符)用的框架。三类:

  • Utilization:资源被占用的百分比(时间维度或容量维度)
  • Saturation:资源排队/饱和程度(队列长度、等待时间)
  • Errors:资源层面的错误(I/O 错误、丢包、ECC 纠错)
# USE 在 node_exporter 上的典型映射
# Utilization:CPU 使用率
1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m]))
 
# Saturation:运行队列长度(CPU 供不应求的程度)
node_load1  # 或 node_procs_running
 
# Errors:磁盘 I/O 错误
increase(node_disk_io_errors_total[5m])

USE 的价值在于它逼你区分「忙」和「满」:Utilization 高只是「在用」,Saturation 高才是「不够用了、开始排队」。很多告警只盯 Utilization,结果 CPU 90% 就报警,但其实队列是空的、用户毫无感知——那是假告警。

⚠️注意

选 RED 还是 USE,取决于你埋的对象是「服务」还是「资源」。同一个系统里两者并存:订单服务用 RED,它跑在上面的那台机器用 USE,而 node_exporter 已经替你把 USE 埋好了。你自己的服务代码里,主要精力应放在 RED。

客户端库骨架:正确比花哨重要

不管用哪种语言,埋点的结构都高度一致:在包初始化时创建指标对象 → 在请求处理路径上调用 .Inc()/.Observe()/.Set() → 把注册表挂到 /metrics 端点。下面三个骨架只展示正确结构,省略业务细节。

Go(官方 prometheus/client_golang)

package metrics
 
import (
    "net/http"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)
 
// 1. 创建指标(用 MustRegister 注册到默认 Registry)
var (
    httpRequestsTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Namespace: "myapp",
            Subsystem: "http",
            Name:      "requests_total",
            Help:      "Total number of HTTP requests.",
        },
        []string{"method", "code"}, // 低基数标签
    )
    httpRequestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Namespace: "myapp",
            Subsystem: "http",
            Name:      "request_duration_seconds",
            Help:      "HTTP request latency in seconds.",
            Buckets:   []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
        },
        []string{"method"},
    )
)
 
func init() {
    prometheus.MustRegister(httpRequestsTotal, httpRequestDuration)
}
 
// 2. 在处理路径上记录
func Handle(w http.ResponseWriter, r *http.Request) {
    timer := prometheus.NewTimer(httpRequestDuration.WithLabelValues(r.Method))
    defer timer.ObserveDuration()
    defer func() {
        httpRequestsTotal.WithLabelValues(r.Method, "200").Inc()
    }()
    // ... 业务逻辑
}
 
// 3. 暴露 /metrics
func Handler() http.Handler {
    return promhttp.Handler()
}

注意 CounterVec/HistogramVec 里的标签列表——这就是「标签是低基数」原则在代码里的落地。code 取值只有几十种(HTTP 状态码),method 只有几种,都是安全的。

Java(官方 simpleclient)

import io.prometheus.client.Counter;
import io.prometheus.client.Histogram;
import io.prometheus.client.exporter.HTTPServer;
 
public class Metrics {
    // 1. 定义指标
    static final Counter httpRequests = Counter.build()
            .name("myapp_http_requests_total")
            .help("Total HTTP requests.")
            .labelNames("method", "code")
            .register();
 
    static final Histogram httpDuration = Histogram.build()
            .name("myapp_http_request_duration_seconds")
            .help("HTTP request latency in seconds.")
            .buckets(0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10)
            .labelNames("method")
            .register();
 
    public static void handle(String method) {
        // 2. 记录
        Histogram.Timer timer = httpDuration.labels(method).startTimer();
        try {
            httpRequests.labels(method, "200").inc();
        } finally {
            timer.observeDuration();
        }
    }
 
    // 3. 暴露 /metrics
    public static void main(String[] args) throws Exception {
        new HTTPServer(9100); // 默认暴露 /metrics
    }
}

Python(prometheus_client)

from prometheus_client import Counter, Histogram, start_http_server
 
# 1. 定义指标
http_requests_total = Counter(
    "myapp_http_requests_total",
    "Total HTTP requests.",
    ["method", "code"],
)
http_request_duration_seconds = Histogram(
    "myapp_http_request_duration_seconds",
    "HTTP request latency in seconds.",
    ["method"],
    buckets=(0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10),
)
 
def handle(method: str) -> None:
    # 2. 记录
    with http_request_duration_seconds.labels(method).time():
        http_requests_total.labels(method, "200").inc()
 
# 3. 暴露 /metrics(独立端口,或用 make_asgi_app 挂到现有 app)
if __name__ == "__main__":
    start_http_server(9100)

三个骨架的共同点值得记住:指标对象是全局单例、在程序启动时创建一次、在处理路径上只做 .Inc()/.Observe()/.Set() 这种极轻量的操作。埋点代码本身不应有分支、不应做 IO、不应抛异常——它必须比被埋的业务逻辑更可靠,否则它会反过来拖垮业务。

💡小技巧

如果你用的是框架(如 Spring Boot、FastAPI、Gin),优先用社区维护的 instrumentation 中间件,而不是手写。它们已经处理好了「请求进来时计时、出错时记错误码」这些细节,且命名符合生态习惯。手写只在中间件覆盖不到的自定义指标时才需要。

自定义 Exporter:当你要监控一个没有 SDK 的东西

绝大多数现成系统(MySQL、Redis、Nginx、操作系统)都有现成的 Exporter,直接用就好。但总有那么一些内部系统、硬件设备、或第三方 SaaS,只能通过一个 API 或命令拿到原始数据,这时候你就要自己写一个 Exporter。

Exporter 的本质是一个 HTTP 服务:它按 Prometheus 的文本格式(OpenMetrics 的前身,语法兼容)在 /metrics 输出指标,Prometheus 像抓普通 target 一样来抓它。核心就是那几行 HELP/TYPE/样本。

文本格式的三个要素

# HELP myapp_pending_jobs gauge of jobs waiting in queue
# TYPE myapp_pending_jobs gauge
myapp_pending_jobs{queue="orders"} 42
 
# HELP myapp_jobs_processed_total number of jobs processed
# TYPE myapp_jobs_processed_total counter
myapp_jobs_processed_total{queue="orders"} 1387
 
# HELP myapp_job_duration_seconds job processing latency
# TYPE myapp_job_duration_seconds histogram
myapp_job_duration_seconds_bucket{queue="orders",le="0.1"} 120
myapp_job_duration_seconds_bucket{queue="orders",le="0.5"} 350
myapp_job_duration_seconds_bucket{queue="orders",le="1"} 410
myapp_job_duration_seconds_bucket{queue="orders",le="+Inf"} 420
myapp_job_duration_seconds_sum{queue="orders"} 312.7
myapp_job_duration_seconds_count{queue="orders"} 420

每一行都不可少:

  • # HELP <name> <text>:指标的人类可读说明,抓一次 Prometheus 就把它存进 TSDB 的元数据。没有 HELP 不算错误,但等于没写注释的代码。
  • # TYPE <name> <type>:必须是 counter/gauge/histogram/summary/untyped 之一。它告诉 Prometheus 怎么解释后面的样本(le 桶、quantile 是 Histogram/Summary 的保留标签)。一个指标族只能有一个 TYPE,所有同名的 _bucket/_sum/_count 行都属于同一个 Histogram。
  • 样本行:metric_name{label="value"} number。标签顺序建议固定(方便人读),值必须是数字(可以是 NaN、Inf、-Inf,但不要用)。
⚠️注意

Histogram 的几行必须「自洽」:_count 应等于所有 _bucket(含 +Inf)的值之和,_sum 应等于所有观测值之和。如果你的 Exporter 算错了这些,下游 histogram_quantile() 会给出完全错误、甚至为负的分位数,而且极难排查。写 Exporter 时务必核对:最后一个桶(le="+Inf")的值必须等于 _count。

写一个最小 Exporter 的伪结构

from http.server import BaseHTTPRequestHandler, HTTPServer
from prometheus_client import generate_latest, REGISTRY
 
class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/metrics":
            self.send_response(404); self.end_headers(); return
        # generate_latest 会按规范输出 HELP/TYPE/样本
        data = generate_latest(REGISTRY)
        self.send_response(200)
        self.send_header("Content-Type", "text/plain; version=0.0.4")
        self.end_headers()
        self.wfile.write(data)
 
HTTPServer(("0.0.0.0", 9100), Handler).serve_forever()

实际项目里你几乎不会手写 HELP/TYPE 文本——用对应语言的客户端库(如上面 Python 的 prometheus_client)创建指标对象,再调用它的导出函数,库会替你生成完全合规的文本。自己拼字符串输出是反模式,因为很容易漏掉 le="+Inf" 桶、写错 TYPE、或在并发下产生不一致快照。

ℹ️提示

什么时候该写 Exporter,什么时候该直接推(Pushgateway)?规则很简单:短生命周期的任务(批处理、cron、CI 流水线)用 Pushgateway 主动推;长期运行、能被抓的服务用 Exporter 被动拉。Pushgateway 不是「抓不到就推」的兜底方案,它是专门为「活不过一次抓取间隔」的任务设计的。

小结:埋点是监控的地基

把这一章浓缩成几条可执行的原则:

  • 类型选错,后面全错:累计量用 Counter,瞬时态用 Gauge,要分位数且要多实例聚合用 Histogram,单实例固定分位数才考虑 Summary。
  • Counter 的正确读法是 rate(),Gauge 的正确读法是直接读瞬时值;对 Gauge 用 rate() 是概念错误。
  • 命名带单位后缀:_total/_seconds/_bytes,snake_case,带 namespace。这不只是美观,是语义信号。
  • 标签是低基数的维度,不是数据字段:user_id、完整 URL、错误消息绝不能进标签。
  • RED 给服务、USE 给资源:覆盖不了 R/E/D 或 U/S/E,就是埋点有缺口。
  • 埋点代码要比业务更可靠:只做 .Inc()/.Observe()/.Set(),不做 IO、不抛异常。
  • 写 Exporter 用客户端库导出,别手拼文本;Histogram 的 _count 必须等于所有桶之和。
  • 短任务用 Pushgateway 推,长服务用 Exporter 拉。

埋点这层做好了,第 20 章的「毕业项目」里你才能把 Nginx + 后端 API + Postgres trio 真正串成一套端到端可观测的系统——而前面十六章的所有 PromQL、告警、记录规则,才会落到一个坚实的数据基础上。

🎯练习 1:为四种场景选对指标类型

下面四个需求,请分别指出该用 Counter / Gauge / Histogram / Summary 中的哪一种,并说明理由(尤其要点明「为什么不是其他类型」):

  1. 统计一个消息队列里「当前」积压(pending)的消息条数。
  2. 统计某接口「累计」被调用的次数,后续要算 QPS。
  3. 统计某接口的请求耗时分布,该接口部署在 10 台机器上,你需要看整体的 P95。
  4. 统计单台机器的「磁盘平均写入延迟」,你只关心固定的 p50/p99,且这台机器是单机部署、不会横向扩展。
🎯练习 2:找出命名与标签的违规

下面这些指标定义里,每一行都有至少一个问题(命名或标签维度)。请逐行指出问题并给出修正后的名字:

RequestCount              # 总请求数
latency_ms{endpoint="/a"} # 某接口耗时(毫秒)
mem_usage_total{unit="MB"}# 当前内存使用(单位 MB)
errors_total{user_id="42"}# 按用户计的错误数
http_5xx{host="web01"}    # 5xx 错误数
🎯练习 3:用 RED 给一个 API 设计埋点

你要给一个「评论服务」的 REST API 设计埋点,它有 POST /comments、GET /comments/:id 两个接口,可能返回 2xx/4xx/5xx。请写出:

  1. 需要的指标(名字、类型、标签),套用 RED 框架。
  2. 计算该服务「整体错误率」和「整体 P99 延迟」的 PromQL(错误率定义为 5xx 占总请求的比例)。
🎯练习 4:修一个坏掉的 Exporter 输出

下面是一段自定义 Exporter 输出的文本,里面藏着至少 3 处会导致下游查询出错的硬伤。请找出来并给出修正:

# HELP myapp_job_duration_seconds job latency
# TYPE myapp_job_duration_seconds gauge
myapp_job_duration_seconds_bucket{le="0.1"} 10
myapp_job_duration_seconds_bucket{le="0.5"} 30
myapp_job_duration_seconds_bucket{le="1"} 40
myapp_job_duration_seconds_sum 28.5
myapp_job_duration_seconds_count 38