《游戏服务端编程实践》15.3 ELK / Loki / Prometheus 集成

从日志、指标、追踪三类信号的职责与成本讲起,分析 Prometheus 拉模型下标签基数爆炸的成因、直方图桶的取舍、Loki 与 ELK 的索引成本差异,以及 Sidecar、DaemonSet、Agent 三种采集架构的适用边界,并用 Go 实现一个可校验、可转义、可被文本解析器读取的导出器。

一、先把三类信号分工

日志、指标、追踪不是三套可以互相替代的工具,它们回答的问题不同,成本结构也不同。选型的第一步不是比较产品,而是先确定“这个问题应该由哪类信号回答”。

信号回答的问题典型数据成本大头
日志这一次请求发生了什么结构化事件、错误堆栈、审计记录存储量、索引、保留周期
指标整体趋势和阈值是否越界计数、瞬时值、直方图标签基数、抓取频率、查询
追踪一次调用在哪些环节变慢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 只索引少量标签,日志内容压缩后按块存储,查询时再扫描。

维度LokiELK
索引对象标签字段级倒排索引
查询能力按标签缩小范围后全文扫描任意字段组合检索
存储成本较低较高
写入放大较小较大
适合场景已知来源的排障、告警上下文复杂检索、审计、跨字段分析

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+10minPrometheus 内存持续上涨抓取开始变慢
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,把标签索引变成高基数存储。
  • 日志与指标的时间窗口、过滤条件不一致,故障时得到两个互相矛盾的数字。
  • 导出器不转义引号和换行,抓取端解析失败却只报“格式错误”。
  • 认为指标越多越好,忽略了每条序列都要占用内存、磁盘和查询时间。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist