一、先把三类信号分工
日志、指标、追踪不是三套可以互相替代的工具,它们回答的问题不同,成本结构也不同。选型的第一步不是比较产品,而是先确定“这个问题应该由哪类信号回答”。
| 信号 | 回答的问题 | 典型数据 | 成本大头 |
|---|---|---|---|
| 日志 | 这一次请求发生了什么 | 结构化事件、错误堆栈、审计记录 | 存储量、索引、保留周期 |
| 指标 | 整体趋势和阈值是否越界 | 计数、瞬时值、直方图 | 标签基数、抓取频率、查询 |
| 追踪 | 一次调用在哪些环节变慢 | Span、父子关系、耗时 | 采样率、Span 数量、后端存储 |
游戏服最常见的误区是“用日志算指标”:每次登录写一条日志,再用日志平台统计成功率。这样做在低峰期看不出问题,一旦峰值到来,日志量先压垮采集链路,指标反而迟到甚至丢失。指标应该由进程内的计数器直接暴露,日志只负责解释异常个案。
二、Prometheus 的拉模型与标签基数
Prometheus 默认主动拉取目标,而不是等待应用推送。拉模型的好处是目标是否存活、抓取是否成功本身就是指标;代价是应用必须暴露一个稳定的 HTTP 端点,并且抓取周期决定了指标的时间分辨率。
真正决定 Prometheus 成本的不是指标个数,而是时间序列条数。每个不同的标签组合都是一条独立序列。下面这些标签一旦放进指标,基数会随玩家或对局数量线性增长。
| 标签 | 基数来源 | 后果 |
|---|---|---|
player_id | 在线玩家数 | 每个玩家一条序列,内存和抓取体积暴涨 |
match_id | 同时进行的对局数 | 对局结束后序列仍会残留一段时间 |
session_id | 连接数 | 每次重连产生新序列,几乎无法聚合 |
realm | 区服数量 | 可控,通常几十到几百 |
status_code | 状态码枚举 | 可控,适合做错误分类 |
判断一个标签能不能用,可以问三个问题:它的取值是否有限、是否长期稳定、是否真的需要在下游按它聚合。玩家 ID 三个答案都是否,就不该进指标标签。需要按玩家排查时,用日志或追踪,或者用 exemplar 把指标样本关联到具体追踪。
三、直方图桶决定你能回答什么问题
直方图不是“记录了分布”就结束了,桶的边界决定了你能回答哪些问题。Prometheus 的直方图是累积桶:每个 le 桶表示“小于等于该上界的样本数”,最后隐含一个 +Inf 桶等于总样本数。
桶应该围绕 SLO 边界设置。如果登录耗时的目标是 P99 小于 300 毫秒,那么 100ms、200ms、300ms、500ms、1s 这些边界比均匀分布的 10ms 间隔更有用。桶太密会增加序列数和存储,桶太疏则无法判断是否达标。
| 桶设计 | 优点 | 风险 |
|---|---|---|
| 围绕 SLO 边界 | 能直接判断达标与否 | 边界外的分布不精确 |
| 指数间隔 | 覆盖范围广 | 关键边界可能落在两个桶之间 |
| 等宽桶 | 实现简单 | 长尾区间分辨率不足 |
| 只有平均值 | 存储最省 | 完全看不到尾延迟 |
_sum 和 _count 可以用来计算平均值,但平均值会掩盖长尾。游戏服的 tick 延迟、匹配耗时、登录耗时都应该看分位数,而不是只看均值。
四、Loki 与 ELK 的索引取舍
Loki 和 ELK 的核心差异是索引策略。ELK 通常为字段建立倒排索引,查询灵活,但索引本身可能比原始日志还大;Loki 只索引少量标签,日志内容压缩后按块存储,查询时再扫描。
| 维度 | Loki | ELK |
|---|---|---|
| 索引对象 | 标签 | 字段级倒排索引 |
| 查询能力 | 按标签缩小范围后全文扫描 | 任意字段组合检索 |
| 存储成本 | 较低 | 较高 |
| 写入放大 | 较小 | 较大 |
| 适合场景 | 已知来源的排障、告警上下文 | 复杂检索、审计、跨字段分析 |
Loki 的标签同样有基数问题。把 player_id 或 request_id 做成 Loki 标签,会把标签索引变成另一个高基数存储。正确做法是只保留 service、env、realm、level 这类低基数标签,把请求 ID 留在日志正文里。
ELK 的成本控制主要靠三件事:保留周期分层、冷热分离、写入前过滤。调试日志保留几天,审计日志保留更久;高频低价值的日志在采集端就丢弃,而不是写进索引后再删。
五、采集架构:Sidecar、DaemonSet 与 Agent
| 架构 | 部署方式 | 优点 | 代价 |
|---|---|---|---|
| Sidecar | 每个 Pod 一个采集容器 | 隔离好、配置随服务走 | 容器数量多、资源开销大 |
| DaemonSet | 每个节点一个采集进程 | 资源利用率高、集中管理 | 需要处理多租户和路径发现 |
| 中心 Agent | 应用直接推送到远端 | 接入简单 | 网络抖动时容易丢数据 |
采样和聚合应该尽量靠近数据源。计数器和直方图在进程内聚合,采集端只负责抓取;日志可以在 Agent 层做过滤和采样;追踪的头部采样通常在 SDK 或 Collector 完成。错误日志不要盲目采样,否则排障时最需要的那条可能刚好被丢掉。
一个常见的折中是:指标全量、日志按级别和来源采样、追踪按请求比例采样并对错误请求强制保留。这样既控制成本,又不会在故障时失去关键证据。
六、用 Go 写一个可校验的导出器
下面的导出器只做一件事:把进程内的指标安全地写成 Prometheus 文本格式。它校验指标名和标签名、拒绝重复标签、转义标签值中的反斜杠、双引号和换行,并按注册顺序输出,保证同样的输入得到稳定的文本。
// file: ch15/exporter/exporter.go
// tier: L1
// verify: go test ./ch15/exporter/
// Package exporter 用标准库输出 Prometheus 文本格式。
// 它只负责校验、转义和格式化,抓取与服务发现属于采集链路的其他环节。
package exporter
import (
"errors"
"fmt"
"io"
"math"
"sync"
)
var (
ErrDuplicateLabel = errors.New("exporter: duplicate label name")
ErrDuplicateMetric = errors.New("exporter: duplicate metric name")
ErrInvalidName = errors.New("exporter: invalid metric name")
ErrInvalidLabel = errors.New("exporter: invalid label name")
ErrLabelCount = errors.New("exporter: label value count mismatch")
ErrNonFinite = errors.New("exporter: non-finite sample value")
ErrBucketOrder = errors.New("exporter: histogram buckets must strictly increase")
ErrNegativeCounter = errors.New("exporter: counter cannot decrease")
)
type metricType string
const (
typeGauge metricType = "gauge"
typeCounter metricType = "counter"
typeHistogram metricType = "histogram"
)
// Registry 保存一组指标族,并按注册顺序输出。
type Registry struct {
mu sync.Mutex
order []string
families map[string]*family
}
func NewRegistry() *Registry { return &Registry{families: map[string]*family{}} }
type family struct {
name string
help string
typ metricType
labels []string
buckets []float64
mu sync.Mutex
order []string
samples map[string]*sample
}
type sample struct {
values []string
value float64
counts []uint64
sum float64
count uint64
}
func (r *Registry) newFamily(name, help string, typ metricType, labels []string, buckets []float64) (*family, error) {
if err := validateName(name, true); err != nil {
return nil, err
}
seen := make(map[string]bool, len(labels))
for _, label := range labels {
if err := validateName(label, false); err != nil {
return nil, err
}
if seen[label] {
return nil, fmt.Errorf("%w: %s", ErrDuplicateLabel, label)
}
seen[label] = true
}
if typ == typeHistogram {
if len(buckets) == 0 {
return nil, fmt.Errorf("%w: no buckets", ErrBucketOrder)
}
for i := 1; i < len(buckets); i++ {
if buckets[i] <= buckets[i-1] {
return nil, fmt.Errorf("%w: %g after %g", ErrBucketOrder, buckets[i], buckets[i-1])
}
}
}
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.families[name]; ok {
return nil, fmt.Errorf("%w: %s", ErrDuplicateMetric, name)
}
f := &family{
name: name, help: help, typ: typ,
labels: append([]string(nil), labels...),
buckets: append([]float64(nil), buckets...),
samples: make(map[string]*sample),
}
r.families[name] = f
r.order = append(r.order, name)
return f, nil
}
// Gauge 是可增可减的瞬时值,例如在线人数。
type Gauge struct{ fam *family }
func (r *Registry) Gauge(name, help string, labels ...string) (*Gauge, error) {
f, err := r.newFamily(name, help, typeGauge, labels, nil)
if err != nil {
return nil, err
}
return &Gauge{fam: f}, nil
}
func (g *Gauge) Set(value float64, labelValues ...string) error { return g.fam.set(value, labelValues) }
func (g *Gauge) Add(delta float64, labelValues ...string) error { return g.fam.add(delta, labelValues) }
func (f *family) sampleLocked(values []string) (*sample, error) {
if len(values) != len(f.labels) {
return nil, fmt.Errorf("%w: %s got %d, want %d", ErrLabelCount, f.name, len(values), len(f.labels))
}
key := encodeKey(values)
if s, ok := f.samples[key]; ok {
return s, nil
}
s := &sample{values: append([]string(nil), values...)}
if f.typ == typeHistogram {
s.counts = make([]uint64, len(f.buckets))
}
f.samples[key] = s
f.order = append(f.order, key)
return s, nil
}
func (f *family) set(value float64, labelValues []string) error {
if err := checkValue(value); err != nil {
return err
}
f.mu.Lock()
defer f.mu.Unlock()
s, err := f.sampleLocked(labelValues)
if err != nil {
return err
}
s.value = value
return nil
}
func (f *family) add(delta float64, labelValues []string) error {
if err := checkValue(delta); err != nil {
return err
}
f.mu.Lock()
defer f.mu.Unlock()
s, err := f.sampleLocked(labelValues)
if err != nil {
return err
}
next := s.value + delta
if math.IsInf(next, 0) || math.IsNaN(next) {
return fmt.Errorf("%w: %s overflow", ErrNonFinite, f.name)
}
s.value = next
return nil
}
// WriteText 按注册顺序输出文本格式。
func (r *Registry) WriteText(w io.Writer) error {
r.mu.Lock()
names := append([]string(nil), r.order...)
families := make([]*family, 0, len(names))
for _, name := range names {
families = append(families, r.families[name])
}
r.mu.Unlock()
for _, f := range families {
if err := f.writeText(w); err != nil {
return err
}
}
return nil
}
func (f *family) writeText(w io.Writer) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, err := fmt.Fprintf(w, "# HELP %s %s\n", f.name, escapeHelp(f.help)); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "# TYPE %s %s\n", f.name, f.typ); err != nil {
return err
}
for _, key := range f.order {
s := f.samples[key]
if f.typ == typeHistogram {
if err := f.writeHistogram(w, s); err != nil {
return err
}
continue
}
line := fmt.Sprintf("%s%s %s\n", f.name, renderLabels(f.labels, s.values, "", ""), formatValue(s.value))
if _, err := io.WriteString(w, line); err != nil {
return err
}
}
return nil
}
完整包还包含 histogram.go(直方图写入)和 text.go(标签渲染与转义),这里只展开注册、校验与文本输出的主路径。
这段代码有几个容易被忽略的细节。标签值里的双引号必须写成 \",反斜杠写成 \\,换行写成 \n,否则文本解析器会在引号处提前结束。HELP 文本只转义反斜杠和换行,不转义双引号。直方图的每个桶都要带上 le 标签,+Inf 桶的值必须等于总观测数,_sum 和 _count 则不带 le。
重复标签在注册阶段就被拒绝,而不是等到输出时才发现。标签值个数不匹配、样本值为 NaN 或无穷、直方图桶不是严格递增,都会返回明确的错误。这些校验看起来琐碎,但它们把“格式错误”挡在了抓取之前,而不是让 Prometheus 在解析失败后留下一段难以定位的日志。
七、失败场景:玩家 ID 进了标签
某竞技游戏把 player_id 加进了“匹配等待时长”直方图的标签。上线初期在线人数少,一切正常;大促活动期间同时在线从两万涨到二十万,匹配指标的时间序列从几十条涨到二十万条以上。
| 时间 | 事件 | 后果 |
|---|---|---|
| T+0 | 活动开启,在线人数上升 | 序列数随玩家数增长 |
| T+10min | Prometheus 内存持续上涨 | 抓取开始变慢 |
| T+25min | 单次抓取超过超时 | 目标被标记为不可用 |
| T+40min | 查询看板超时 | 值班无法判断匹配是否正常 |
| T+60min | 远程写入积压 | 监控链路本身成为故障源 |
问题不是“指标写错了”,而是把排查维度当成了聚合维度。匹配等待时长需要按区服、按匹配池、按时间段聚合;具体某个玩家等了多久,应该去日志或追踪里查。修复时把 player_id 从标签里移除,改为在追踪 Span 上记录玩家 ID,指标只保留 realm 和 pool。
八、日志与指标口径必须对齐
同一个故障,日志和指标经常给出不同的数字。原因通常不是采集丢失,而是口径不同:指标按请求结束时间计数,日志按写入时间计数;指标排除了健康检查,日志没有排除;指标按 1 分钟窗口聚合,日志按自然小时统计。
对齐口径需要把三件事写清楚:分子是什么、分母是什么、时间窗口和过滤条件是什么。比如“登录成功率”可以定义为“返回成功的登录请求数 / 全部登录请求数”,排除客户端主动取消和限流拒绝,窗口为一分钟。这个定义要同时出现在指标代码、看板说明和告警规则里,否则三个人会给出三个答案。
小结
日志、指标、追踪各自回答不同的问题,不能用日志代替指标。Prometheus 的成本主要由时间序列条数决定,玩家 ID、对局 ID 这类高基数标签不能进指标。直方图桶要围绕 SLO 边界设计,Loki 与 ELK 的取舍本质上是索引成本与查询灵活性的取舍。采集架构要把聚合和采样放在合适的层级,并用代码层面的校验保证导出的文本格式正确。
常见错误与边界
- 把
player_id、match_id、session_id放进指标标签,导致基数随在线人数增长。 - 用日志统计成功率,峰值时日志链路先过载,指标反而缺失。
- 直方图桶均匀分布,关键 SLO 边界落在两个桶之间,无法判断是否达标。
- 只输出
_sum和_count算平均值,看不到 tick 和登录的长尾。 - Loki 标签里放请求 ID,把标签索引变成高基数存储。
- 日志与指标的时间窗口、过滤条件不一致,故障时得到两个互相矛盾的数字。
- 导出器不转义引号和换行,抓取端解析失败却只报“格式错误”。
- 认为指标越多越好,忽略了每条序列都要占用内存、磁盘和查询时间。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。