3.2 指标与 Prometheus 打点
3.1 的日志回答的是「这一次请求发生了什么」。但运维值班时,你更常问的是另一个问题:「现在整个系统健康吗?」——在线人数掉了没有、错误率涨了没有、命令耗时 p99 是多少。这些问题日志也能答,但要先把几十万条日志扫一遍再聚合,代价高得离谱。指标(metrics)就是为这类问题生的:它把「聚合」这件事提前到采集时做掉,留下的是预聚合的数值时间序列,查询时一条语句就能出曲线。
aegis 选 Prometheus 做指标系统,理由和选 Go 一样朴素:它是拉模型(pull),服务只负责暴露一个 /metrics 的 HTTP 端点,Prometheus 定时来拉。这带来两个好处——服务端不需要知道 Prometheus 在哪(无侵入),以及「服务暴露了什么」是可离线验证的(curl 一下就行)。这一节就沿着这条线走:先把三种指标类型的语义讲清,再讲最容易把系统拖垮的标签基数问题,最后真起一个 HTTP server 暴露 /metrics,并用 HTTP 真拉取来断言指标存在。
三种指标类型:Counter / Gauge / Histogram
Prometheus 的指标类型不止三种,但日常 90% 的场景只用这三样。它们的区别不是「长得不一样」,而是语义契约不同——用错了,查询结果就是错的,而且错得很难发现。
| 类型 | 语义 | 只能怎么变 | 典型用途 | 典型误用 |
|---|---|---|---|---|
| Counter | 累计计数 | 只增(重启才归零) | 请求总数、错误总数、字节数 | 拿它记「当前在线人数」(会掉,Counter 不能掉) |
| Gauge | 瞬时值 | 可增可减 | 在线人数、队列长度、内存占用 | 拿它记「累计请求数」(重启就归零,历史全丢) |
| Histogram | 分桶分布 | 只增(各桶计数) | 命令耗时、请求体大小 | 拿它记「当前值」(应观察一次就上报一次) |
三条最容易踩的线,展开说:
- Counter 绝不能减。Counter 的语义是「自进程启动以来一共发生了多少次」。如果你需要「当前有多少」,那是 Gauge。用 Counter 记在线人数,会在玩家下线时露馅——你只能
Inc()不能Dec(),最后曲线只会单调上升。 - Gauge 会丢历史。Gauge 是瞬时快照,Prometheus 每次拉取只看到「此刻的值」。用 Gauge 记累计错误数,进程一重启就归零,之前的累计全部丢失,Counter 才扛得住重启。
- Histogram 是用来算分位数的。它把观测值扔进一组预定义的桶(bucket),Prometheus 靠
histogram_quantile()从桶计数反推 p50/p95/p99。所以桶的边界要按业务量级选:战斗命令耗时在毫秒级,桶就该密集布在 1ms–100ms,而不是 1s–10s。
一个关键认知:Prometheus 不存单个观测值,只存聚合后的时间序列。Histogram 上报的是「落入每个桶的数量」而不是原始耗时,这正是它比「自己记一个数组再算分位」便宜的原因。
标签基数:打点最容易踩的坑
指标名 + 一组标签,唯一确定一条时间序列。标签值的每一种组合,都是一条独立的时间序列,Prometheus 要为它维护一个内存中的 series。于是就有了基数(cardinality)问题:
基数 = 所有标签取值组合的数量。基数爆炸,Prometheus 的内存就爆炸。
最经典的爆炸方式,是把 player_id 当标签。假设十万在线玩家,你给 aegis_requests_total{player_id="10086"} 打点,那么光这一条指标就有十万条时间序列,几分钟就把 Prometheus 拉垮。更隐蔽的是把 trace_id、session_id、时间戳、错误详情文本当标签——它们每一个取值几乎都不重复,等价于「每个请求一条序列」。
正确的做法是把标签限制在低基数维度:命令号(几十个)、结果(3 个)、节点名(几十个)。高基数的东西——具体是哪个玩家、哪次请求——应该放到日志(3.1)或链路(3.3)里,那里本来就是按单次事件存的。所以 aegis 的请求计数指标只有两个标签 {cmd, result},cmd 还是经过 CmdName() 映射后的稳定枚举值,而不是原始的 uint16 数值。
aegis 里还有一条约定:histogram 的桶要显式声明,不用默认桶。下面这段是 L2,会真起 HTTP server 并用 HTTP 真拉取来断言:
// file: internal/telemetry/metrics.go
// tier: L2
// verify: go test ./internal/telemetry/
package telemetry
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// CmdName 把命令号映射成稳定、低基数的标签值。
// 绝不把 cmd 的原始数值直接当标签(数值散乱),也绝不把 player_id 当标签(基数爆炸)。
func CmdName(cmd uint16) string {
switch cmd {
case 0x0010:
return "login"
case 0x0101:
return "world_enter"
case 0x0401:
return "battle_cast"
case 0x0B01:
return "trade_create"
default:
return "other"
}
}
// Metrics 聚合三类指标:Counter(只增)、Gauge(可增可减)、Histogram(分桶分布)。
type Metrics struct {
reg *prometheus.Registry
// Counter:累计请求数。语义是「只增不减」,重启才归零。
RequestsTotal *prometheus.CounterVec
// Gauge:当前在线人数。语义是「瞬时值」,可上可下。
OnlinePlayers prometheus.Gauge
// Histogram:命令耗时分布。分桶给出 p50/p95/p99 的近似。
FrameCost *prometheus.HistogramVec
}
// NewMetrics 在给定 registry 上注册三类指标。传 nil 则新建一个私有 registry,
// 避免污染全局默认 registry(测试里尤其重要)。
func NewMetrics(reg *prometheus.Registry) *Metrics {
if reg == nil {
reg = prometheus.NewRegistry()
}
m := &Metrics{reg: reg}
m.RequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "aegis",
Name: "requests_total",
Help: "Total handled commands, by cmd and result.",
}, []string{"cmd", "result"})
m.OnlinePlayers = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: "aegis",
Name: "online_players",
Help: "Current online player count.",
})
m.FrameCost = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "aegis",
Name: "frame_cost_seconds",
Help: "Command handling cost in seconds.",
Buckets: []float64{0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1},
}, []string{"cmd"})
reg.MustRegister(m.RequestsTotal, m.OnlinePlayers, m.FrameCost)
return m
}
// Observe 是「一次命令处理完」的统一打点入口:计数 + 计时一起做。
func (m *Metrics) Observe(cmd uint16, costSeconds float64, result string) {
name := CmdName(cmd)
m.RequestsTotal.WithLabelValues(name, result).Inc()
m.FrameCost.WithLabelValues(name).Observe(costSeconds)
}
// Handler 返回暴露 /metrics 的 HTTP handler,供 Prometheus 真拉取。
func (m *Metrics) Handler() http.Handler {
return promhttp.HandlerFor(m.reg, promhttp.HandlerOpts{})
}
真起 HTTP server 并拉取断言
指标系统最容易骗自己的地方,是「代码写完了就以为能用了」。真正要验证的是:/metrics 端点能不能被拉起来、暴露的文本里有没有你要的指标、标签对不对。所以 aegis 的指标测试是 L2——用 httptest 起一个真 HTTP server,再用 http.Get 真拉一次 /metrics,最后用 Prometheus 官方的 expfmt 解析器把文本解析成结构化指标来断言。这比「字符串里找一下子串」强得多,因为它会真的走一遍 Prometheus 的解析逻辑。
下面这段是 L2,go test 会真起进程、真发 HTTP 请求:
// file: internal/telemetry/metrics_test.go
// tier: L2
// verify: go test ./internal/telemetry/
package telemetry
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
dto "github.com/prometheus/client_model/go"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
)
func labelValue(m *dto.Metric, name string) string {
for _, lp := range m.GetLabel() {
if lp.GetName() == name {
return lp.GetValue()
}
}
return ""
}
// L2:真起 HTTP server 暴露 /metrics,再用 HTTP 真拉取并解析,断言关键指标存在。
func TestMetricsExposedOverHTTP(t *testing.T) {
m := NewMetrics(nil)
m.Observe(0x0401, 0.003, ResultOK)
m.Observe(0x0401, 0.020, ResultOK)
m.Observe(0x0010, 0.001, ResultErr)
m.OnlinePlayers.Set(1234)
srv := httptest.NewServer(m.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/metrics")
if err != nil {
t.Fatalf("GET /metrics: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
parser := expfmt.NewTextParser(model.UTF8Validation)
families, err := parser.TextToMetricFamilies(bytes.NewReader(body))
if err != nil {
t.Fatalf("parse exposition: %v", err)
}
// Counter:battle_cast / ok 至少出现过 2 次。
rt := families["aegis_requests_total"]
if rt == nil {
t.Fatal("aegis_requests_total not exposed")
}
var battleOK float64 = -1
for _, met := range rt.GetMetric() {
if labelValue(met, "cmd") == "battle_cast" && labelValue(met, "result") == ResultOK {
battleOK = met.GetCounter().GetValue()
}
}
if battleOK != 2 {
t.Fatalf("battle_cast/ok counter = %v, want 2", battleOK)
}
// Gauge:在线人数就是刚 Set 的值。
op := families["aegis_online_players"]
if op == nil || len(op.GetMetric()) == 0 {
t.Fatal("aegis_online_players not exposed")
}
if v := op.GetMetric()[0].GetGauge().GetValue(); v != 1234 {
t.Fatalf("online_players = %v, want 1234", v)
}
// Histogram:必须带 _bucket / _sum / _count,且 count=2。
fc := families["aegis_frame_cost_seconds"]
if fc == nil {
t.Fatal("aegis_frame_cost_seconds not exposed")
}
if fc.GetType() != dto.MetricType_HISTOGRAM {
t.Fatalf("frame_cost type = %v", fc.GetType())
}
for _, met := range fc.GetMetric() {
if labelValue(met, "cmd") == "battle_cast" {
if c := met.GetHistogram().GetSampleCount(); c != 2 {
t.Fatalf("histogram sample count = %d, want 2", c)
}
}
}
}
// 基数陷阱:请求指标只允许 cmd/result 两个标签,绝不能带 player_id。
func TestRequestCounterLabelCardinality(t *testing.T) {
m := NewMetrics(nil)
m.Observe(0x0401, 0.001, ResultOK)
srv := httptest.NewServer(m.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/metrics")
if err != nil {
t.Fatalf("GET /metrics: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
parser := expfmt.NewTextParser(model.UTF8Validation)
families, _ := parser.TextToMetricFamilies(bytes.NewReader(body))
rt := families["aegis_requests_total"]
if rt == nil || len(rt.GetMetric()) == 0 {
t.Fatal("no metric")
}
seen := map[string]bool{}
for _, lp := range rt.GetMetric()[0].GetLabel() {
seen[lp.GetName()] = true
}
if seen["player_id"] {
t.Fatal("player_id must never be a metric label")
}
if !seen["cmd"] || !seen["result"] {
t.Fatalf("expected cmd/result labels, got %v", seen)
}
}
这段测试里有个值得单说的细节:解析器用 expfmt.NewTextParser(model.UTF8Validation)。Prometheus 的文本格式解析器要求显式指定名字校验方案(legacy 或 UTF-8),不指定会 panic——这是新版 client_golang 的一个坑,很多照抄老教程的代码会在这里直接崩。
本节小结 / 实测结论
三种指标类型的选型,一句话总结:要「一共多少次」用 Counter,要「现在多少」用 Gauge,要「分布和分位数」用 Histogram。打点前先问自己两个问题:这个量会减吗?这个标签的取值会不会无限增长?前者决定用哪种类型,后者决定会不会把 Prometheus 拖垮。
本机实测(go test ./internal/telemetry/ -v,Go 1.26.0,client_golang v1.25.0)两条断言全绿:真起 httptest server 拉取 /metrics 后,Counter 值等于 2、Gauge 值等于 1234、Histogram 的 _count 等于 2 且类型被识别为 HISTOGRAM;标签基数断言确认 aegis_requests_total 只有 cmd 与 result 两个标签,没有 player_id。这里刻意没有去拉 prom/prometheus 镜像做端到端——暴露端点 + HTTP 真拉取已经覆盖了「指标能否被采集」这一核心风险,而 Prometheus 服务端本身不在 aegis 的责任范围内。
阅读导航:上一节:日志该记什么 · 下一节:链路追踪与告警 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。