> 一个从未被写入过的 Gauge,导出的不是"无数据",是"一切正常"

Systems & Infrastructure Intermediate 2026-10-12 04:23 2026-10-12
#prometheus #observability #monitoring #alerting #failure-modes #go

一个分布式存储项目有 53 KB 的运维手册,其中 §4.1「Prometheus 指标大全」 列了四十来个指标,§4.2 给它们配了告警规则,§5.1 的故障排查表里每一行的 "排查步骤"都是"查看某个指标"。

// TABLE_OF_CONTENTS

结论先行

一个分布式存储项目有 53 KB 的运维手册,其中 §4.1「Prometheus 指标大全」 列了四十来个指标,§4.2 给它们配了告警规则,§5.1 的故障排查表里每一行的 "排查步骤"都是"查看某个指标"。

指标全都声明了,在两个文件里,用 promauto 规规矩矩注册:

ClusterMode = promauto.NewGauge(prometheus.GaugeOpts{
    Name: "cluster_mode",
    Help: "Cluster mode: 0=read-write, 1=read-only",
})

数一遍谁在给它们赋值:

pkg/metrics/metrics.go          19 个声明,被写入 1 个(ErrorsTotal)
pkg/metrics/kvcache_metrics.go  21 个声明,被写入 0 个

40 个指标里 39 个在整个仓库中只出现在它自己的声明处。 真实数字是存在的 —— 它们从 internal/api/handlers/demo_handler.go 以 JSON 形式吐出来,名字还很像(kvcache_hits 对 kvcache_hits_total, kvcache_mem_used 对 kvcache_memory_used_bytes),只是没有走 Prometheus。

最要命的一条是脑裂告警:

- expr: cluster_mode == 1          # 手册 §4.2

ClusterMode 是普通 Gauge,不是 Vec。promauto 注册之后,哪怕 一次都没 Set(),/metrics 里也会有 cluster_mode 0 —— 而它自己的 Help 写着 0=read-write。

它不是缺数据,它是在肯定地报平安。 真脑裂的时候,告警不响, 而手册让你"查看 cluster_mode 指标是否为 1",你查到的是 0。

为什么

Prometheus 客户端库里,没被写过的指标有两种导出行为,两种都不会让告警响:

类型 从未写入时 /metrics 里 x == 1 rate(x[5m]) > 0
NewGauge / NewCounter x 0 false(看起来健康) false
NewGaugeVec / NewCounterVec 一条 series 都没有 no data(不触发) no data(不触发)

无标签的 Vec 在任何标签组合被用到之前不产生任何 series,而 Prometheus 的 告警规则在表达式求值为空时默认不触发(要靠 absent() 才能捕捉)。 所以 Vec 那半边静悄悄,plain 那半边直接报健康。

这不是 Prometheus 的问题,是声明和写入在两个不同的改动里。 声明指标是写监控时的第一步,接到业务路径上是第二步,而第一步就足以让 /metrics 看起来是满的、让运维手册写得出来、让 Grafana 画得出面板。 中间那一步漏掉之后,系统的每一个外部观测面都变得更自信,而不是更沉默。

同一个仓库里还有一条印证:internal/storage/replication.go:350, 副本写失败的处理是

logger.Error("Replication failed", ...)
// TODO: Add to retry queue

既不重试,也不计数。那个本该记它的 s3_replication_operations_total{status="failure"} 就在上面那 39 个里。 于是一条副本永久缺失,唯一痕迹是一行日志。

怎么用

上线监控前,先数一遍"声明了但没人写"的指标。 一行 shell 就够:

for v in $(grep -oE '^\s*[A-Z][A-Za-z]+ = promauto\.New[A-Za-z]+' pkg/metrics/*.go \
           | sed 's/.*://;s/ =.*//'); do
  printf '%-34s refs=%s\n' "$v" "$(grep -rn "metrics\.$v" --include='*.go' . | wc -l)"
done

refs=1(只有声明本身)就是一个永远绿的指标。这是静态的、零成本的、 可以进 CI 的 —— 我用它在这个仓库上一次抓到 39 个。

真正的验收不是"指标存在",是"能让它变成非零"。 每个告警规则都该有一个配套的测试或手动步骤,去造出它该响的那个状态, 看它响。做不到这一步的告警规则,和注释没有区别 —— 同 断言"它拒绝了"不能用非零退出码——任何东西都会非零退出: 断言"门禁是绿的"和断言"门禁在该红的时候红"完全是两件事。

普通 Gauge 的默认值要选在"异常"那一侧。 0=read-write, 1=read-only 把健康态放在了零值上,于是漏接线和健康无法区分。反过来定义 (0=unknown, 1=read-write, 2=read-only,告警 cluster_mode != 1) 就让"没人写过它"变成一个会响的状态。能让默认值报警的编码, 比能让默认值报平安的编码值钱。

运维手册不是证据。 这份手册写得很认真,它只是对着代码里的 声明写的,而不是对着 /metrics 的实际输出写的。 写运行手册时,每个指标名都该从活实例的 curl /metrics 里复制 —— 见 被引用的产物要有一个"任何一次运行都生成不出来"的文件名。

出处

~/Engineering/s3-like-storage:pkg/metrics/metrics.go、 pkg/metrics/kvcache_metrics.go:104-107(ClusterMode 的声明与 Help), internal/api/handlers/demo_handler.go:59-66(真实数值走的那条 JSON 路径), internal/storage/replication.go:343-351(失败只记日志的那段), docs/OPERATIONS_GUIDE.md §4.1 / §4.2(896 行的 cluster_mode == 1)/ §5.1 (脑裂行的排查步骤)。引用计数是我 2026-10-11 自己跑的,只读,未改仓库。