《游戏服务端架构·平台化与架构治理》5.1 日志、指标与链路追踪

玩家说结算奖励没到账,客服拿不出 trace_id,日志、指标、追踪却各自显示正常。本文把三条腿的分工写成可执行的观测契约:五个关联字段、按阶段必填、一致的头部采样、保留成本估算与链路拼装,并用 Go 实现字段校验、采样决策、断链定位和慢段识别。

一次“三张图都正常”的丢单

《裂隙战场》赛季结算后的第二天,客服收到一条工单:玩家打满整个赛季,奖励没到账。值班同学打开三个系统:日志平台按玩家昵称搜不到,因为结算服务只打了订单号;指标看板显示结算成功率 99.98%(示例值),绿色;追踪系统采样率 1%(示例值),这笔请求没被采到。三张图都正常,问题却真实存在。

这不是工具不够多,而是三条腿之间没有契约。日志不知道追踪的 trace_id,指标不知道玩家的身份,追踪又只保留了极少数请求。每一件工具单独看都成立,合起来却回答不了“这个玩家的这一局发生了什么”。可观测性要建设的不是三个平台,而是一份 producer 愿意执行、consumer 能够 join 的字段契约。

三条腿各回答什么

腿回答的问题典型查询成本主导典型失效
日志发生了什么按 trace_id 或 player_id 取上下文存储与索引字段缺失、采样丢弃
指标是否异常速率、错误率、分位趋势标签基数口径错误、高基数
追踪慢在哪一次请求的跨服务时序采样与存储断链、时钟漂移

三条腿不能互相替代。用日志算指标,成本高且口径随文案漂移;用指标定位单玩家,指标里根本没有身份;用追踪做审计,采样决定了它必然漏。合理的分工是:指标负责发现异常,追踪负责缩小范围,日志负责解释细节。

观测契约:五个字段

契约的核心是一组稳定的关联字段。字段名一旦发布就是接口,改名等于让所有下游查询失效。

字段格式产生位置缺失后的后果
trace_id32 位小写十六进制网关入口无法跨服务拼接
span_id16 位小写十六进制每个服务无法表达父子关系
player_id业务 ID登录成功客服无法按玩家检索
server_id服或分片 ID会话建立无法判断故障域
room_id房间 ID进房成功对局成本无法归因
match_id对局 ID匹配成功匹配与结算无法关联

关键决策是“按阶段必填”,而不是“所有字段永远必填”。登录阶段还没有 room_id,强行要求只会让 producer 填 "unknown" 或 "0"。假值比空值更危险:空值能被校验拦住,假值会安静地污染聚合。

阶段必填字段
登录trace_id、span_id、player_id、server_id
匹配上述字段 + match_id
进房上述字段 + room_id
对局同进房
结算同进房

这张表就是校验器的输入。新增阶段时先改表,再改代码,最后才允许 producer 上线。

采样:一致比精确更重要

追踪必须采样,否则存储成本随调用量线性上涨。采样最容易犯的错是每个服务各自随机:网关采了、战斗服没采,链路从中间断掉,trace 看起来像“服务没被调用”。头部一致采样解决了这个问题:只对 trace_id 做哈希,所有服务用同一个桶和同一个阈值,决策天然一致,不需要额外协调。错误请求单独放行,因为排障时最需要的恰恰是失败样本。

策略决策点优点代价适用
按 trace 比例采样入口实现简单、链路完整低频故障可能漏常规流量
错误全留每个服务失败样本完整错误风暴时成本高所有环境
尾部采样链路结束后只留“有价值”的 trace需要缓冲全链路核心交易
按阶段加权入口关键阶段保留更多规则复杂、易漂移登录与结算

尾部采样的代价常被低估:它要求收集器先缓存整条链路,等结束条件满足再决定去留,延迟和内存都会上升。中小团队先用“比例 + 错误全留”,等成本真的成为问题时再上尾部采样。

成本要按“事件量 × 单条字节 × 保留天数”估算。示例值:每天 2 亿条日志、单条 600 字节、全量保留 7 天,约 840GB;采样到 10% 后约 84GB。这个数字不含副本和索引,真实账单通常是它的 2 到 3 倍(示例值)。示例值只用于比较方案,不用于报价。

游戏链路:五段埋点与断链症状

游戏请求不是一条均匀的调用链,它有明确的阶段。每个阶段只需要回答一个问题:下一段能不能接上。

阶段必须打的点常见断链现场症状
登录网关收包、鉴权结果、分配服只打昵称不打 player_id客服按玩家搜不到
匹配入队、匹配成功、取消匹配成功没回写 match_id匹配与对局对不上
进房分配房间、加载完成、首帧分配后客户端没连上有 match 无 room
对局tick 起止、结算触发客户端与服务端时钟混用出现负时长
结算订单创建、发放、确认只打“发放成功”不打订单号无法证明是否到账

以“匹配成功但进房失败”为例:trace 里有 match 段、没有 room 段,说明匹配器已经返回结果,断点在“客户端拿结果去连房间”这一段。它可能是房间分配失败,也可能是客户端网络问题,但排查范围已经从“整个游戏”缩到“分配与连接”。反过来,如果 room 段存在但结算段缺失,且 room 段正常结束,问题更可能在结算触发或消息投递,而不是战斗服崩溃。没有阶段顺序,这两种故障在日志里看起来都是“后面没日志了”。这就是链路的价值:不是告诉你原因,而是把范围缩小一个数量级。

数据模型与接口契约

  • Record:一条日志、指标样本或 span 都必须携带的字段;
  • ValidationError:一次性列出所有缺失和格式错误,避免 producer 改一轮报一个;
  • Chain:同一 trace 的 span 按阶段拼装后的结果,包含总时长和最慢阶段。

两个边界必须明确。第一,字段只增不改:新增可选字段是兼容的,重命名或改变语义是破坏性的。第二,校验失败时生产环境不能丢玩家请求。推荐做法是 CI 严格拒绝,线上打标放行并计入 contract_violation_total,让问题可见但不扩大故障。

校验策略行为优点风险
严格拒绝缺字段直接报错契约干净线上可能阻断玩家
打标放行补空值并打标不扩大故障脏数据进入下游
静默丢弃直接扔掉实现最省事排障时才发现没数据

选“CI 拒绝 + 线上打标”。静默丢弃是最差选项,它把契约问题推迟到事故现场。

可运行片段(一):字段校验

下面的 contract.go 把阶段、字段和校验规则放在同一张表里。Validate 不会在第一个错误处返回,而是一次收集全部问题,这样 producer 一次就能改完。

// file: internal/obscontract/contract.go
// tier: L1
// verify: go test ./internal/obscontract/
package obscontract

import (
	"errors"
	"fmt"
	"time"
)

// Signal is the telemetry leg a record belongs to. Logs, metrics and traces
// answer different questions and must not be collapsed into one stream.
type Signal string

const (
	SignalLog    Signal = "log"
	SignalMetric Signal = "metric"
	SignalTrace  Signal = "trace"
)

// Stage is one step of the player journey. The order is part of the contract:
// a chain that skips or reorders stages is broken even if every span exists.
type Stage string

const (
	StageLogin  Stage = "login"
	StageMatch  Stage = "match"
	StageRoom   Stage = "room"
	StageBattle Stage = "battle"
	StageSettle Stage = "settle"
)

var stageOrder = map[Stage]int{
	StageLogin:  0,
	StageMatch:  1,
	StageRoom:   2,
	StageBattle: 3,
	StageSettle: 4,
}

// Field names are stable identifiers used in errors, dashboards and sampling
// rules. Renaming one is a breaking change for every consumer.
type Field string

const (
	FieldTraceID  Field = "trace_id"
	FieldSpanID   Field = "span_id"
	FieldPlayerID Field = "player_id"
	FieldServerID Field = "server_id"
	FieldRoomID   Field = "room_id"
	FieldMatchID  Field = "match_id"
)

// Record is the minimum a log line, metric sample or span must carry to be
// joinable with the other two. Optional fields become required by stage.
type Record struct {
	Signal   Signal
	Stage    Stage
	TraceID  string
	SpanID   string
	ParentID string
	PlayerID string
	ServerID string
	RoomID   string
	MatchID  string
	At       time.Time
	Duration time.Duration
	Err      bool
}

// ValidationError lists every failed field, so a producer can fix one batch
// instead of rediscovering the contract one error at a time.
type ValidationError struct {
	Missing   []Field
	Malformed []Field
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("obscontract: missing=%v malformed=%v", e.Missing, e.Malformed)
}

var (
	ErrEmptyChain    = errors.New("obscontract: chain has no records")
	ErrMixedTrace    = errors.New("obscontract: chain mixes trace ids")
	ErrDuplicateSpan = errors.New("obscontract: duplicate span id")
	ErrBrokenLink    = errors.New("obscontract: parent span missing")
	ErrOutOfOrder    = errors.New("obscontract: stage order violated")
)

// Required returns the fields a stage cannot omit. Match and room identifiers
// start at the stage where they are allocated, not earlier.
func Required(stage Stage) []Field {
	switch stage {
	case StageLogin:
		return []Field{FieldTraceID, FieldSpanID, FieldPlayerID, FieldServerID}
	case StageMatch:
		return []Field{FieldTraceID, FieldSpanID, FieldPlayerID, FieldServerID, FieldMatchID}
	case StageRoom, StageBattle, StageSettle:
		return []Field{FieldTraceID, FieldSpanID, FieldPlayerID, FieldServerID, FieldMatchID, FieldRoomID}
	default:
		return []Field{FieldTraceID, FieldSpanID, FieldServerID}
	}
}

func fieldValue(r Record, f Field) string {
	switch f {
	case FieldTraceID:
		return r.TraceID
	case FieldSpanID:
		return r.SpanID
	case FieldPlayerID:
		return r.PlayerID
	case FieldServerID:
		return r.ServerID
	case FieldRoomID:
		return r.RoomID
	case FieldMatchID:
		return r.MatchID
	default:
		return ""
	}
}

func isLowerHex(s string, n int) bool {
	if len(s) != n {
		return false
	}
	for i := 0; i < len(s); i++ {
		c := s[i]
		if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
			return false
		}
	}
	return true
}

// Validate checks presence by stage, then the two identifiers whose format
// every tracing backend assumes: 32-hex trace id and 16-hex span id.
func Validate(r Record) error {
	var bad ValidationError
	for _, f := range Required(r.Stage) {
		if fieldValue(r, f) == "" {
			bad.Missing = append(bad.Missing, f)
		}
	}
	if r.TraceID != "" && !isLowerHex(r.TraceID, 32) {
		bad.Malformed = append(bad.Malformed, FieldTraceID)
	}
	if r.SpanID != "" && !isLowerHex(r.SpanID, 16) {
		bad.Malformed = append(bad.Malformed, FieldSpanID)
	}
	if len(bad.Missing)+len(bad.Malformed) > 0 {
		return &bad
	}
	return nil
}

可运行片段(二):链路拼装

// file: internal/obscontract/chain.go
// tier: L1
// verify: go test ./internal/obscontract/
package obscontract

import (
	"fmt"
	"sort"
	"time"
)

// Segment is one finished span placed on the player journey.
type Segment struct {
	Stage    Stage
	SpanID   string
	Start    time.Time
	Duration time.Duration
}

type Chain struct {
	TraceID  string
	Segments []Segment
	Total    time.Duration
	Slowest  Stage
}

// Assemble joins records belonging to one trace. It sorts by start time and
// rejects a missing parent, a duplicate span or a stage that moves backwards.
func Assemble(records []Record) (Chain, error) {
	if len(records) == 0 {
		return Chain{}, ErrEmptyChain
	}
	traceID := records[0].TraceID
	seen := make(map[string]bool, len(records))
	for _, r := range records {
		if r.TraceID != traceID {
			return Chain{}, ErrMixedTrace
		}
		if r.SpanID == "" {
			continue
		}
		if seen[r.SpanID] {
			return Chain{}, fmt.Errorf("%w: %s", ErrDuplicateSpan, r.SpanID)
		}
		seen[r.SpanID] = true
	}
	for _, r := range records {
		if r.ParentID != "" && !seen[r.ParentID] {
			return Chain{}, fmt.Errorf("%w: span=%s parent=%s", ErrBrokenLink, r.SpanID, r.ParentID)
		}
	}

	ordered := append([]Record(nil), records...)
	sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].At.Before(ordered[j].At) })

	start := ordered[0].At
	maxEnd := start
	chain := Chain{TraceID: traceID}
	lastOrder := -1
	var lastStage Stage
	var slowest time.Duration
	for _, r := range ordered {
		order, ok := stageOrder[r.Stage]
		if !ok {
			return Chain{}, fmt.Errorf("obscontract: unknown stage %q", r.Stage)
		}
		if order < lastOrder {
			return Chain{}, fmt.Errorf("%w: %s after %s", ErrOutOfOrder, r.Stage, lastStage)
		}
		lastOrder, lastStage = order, r.Stage
		seg := Segment{Stage: r.Stage, SpanID: r.SpanID, Start: r.At, Duration: r.Duration}
		chain.Segments = append(chain.Segments, seg)
		if seg.Duration > slowest {
			slowest = seg.Duration
			chain.Slowest = seg.Stage
		}
		if end := r.At.Add(r.Duration); end.After(maxEnd) {
			maxEnd = end
		}
	}
	chain.Total = maxEnd.Sub(start)
	return chain, nil
}

读代码:校验为什么一次返回全部错误

Required 用阶段决定必填集合,而不是维护一份全局必填列表。登录阶段返回四个字段,进房之后才加入 match_id 和 room_id。这样新增阶段只需要改一个 switch,不用同步修改所有 producer 的文档和测试。Validate 先查缺失,再查格式,最后把两类错误放进同一个 ValidationError。顺序不能反:如果先查格式,空字符串会被同时判为“缺失”和“格式错误”,调用方会收到重复噪声。

十六进制校验只接受小写。这不是吹毛求疵:W3C traceparent 规定小写,混用大小写会让同一 trace 在索引里变成两个键。要么在入口统一小写,要么在查询层做归一化,不能两头都不做。

读代码:断链是怎么被拼出来的

Assemble 分三步。第一步遍历所有 span,检查 trace_id 一致、span_id 不重复;第二步检查每个 parent_id 都能在集合里找到;第三步按时间排序后,要求阶段序号不递减。

第三步容易被误解:既然已经按时间排序,为什么还要查阶段顺序?因为客户端和服务端的时钟可能漂移,时间戳会把“结算”排到“对局”前面。阶段顺序是业务事实,时间戳只是观测值;两者冲突时,应该报错而不是相信时间戳。

同一阶段允许出现多个 span,例如匹配重试。它们的时间顺序由排序决定,阶段序号相同所以不会触发“倒退”。真正禁止的是重复的 span_id,因为那意味着 producer 复制了上下文,父子关系会变成环。Total 用最早开始到最晚结束计算,而不是把各段时长相加。后者会把并行段重复计算,也会忽略段与段之间的空隙。排障时,空隙往往比段本身更有信息量:它代表“请求在等什么”。

失败模式与兜底

失败模式兜底
trace_id 由客户端生成,可伪造复用网关重写,客户端原值不参与关联
各服务采样率不一致,链路中段断掉配置中心统一下发,决策写进 span
时钟漂移,子 span 早于父 spanduration 用单调时钟,阶段顺序做校验
player_id 进指标标签,基数爆炸只进日志与追踪,指标按区间分桶
日志打全量业务对象,成本失控字段白名单,只打 ID 与关键状态
错误样本被采样丢弃AlwaysErrors 强制保留并限速

常见错误与边界

常见错误边界
把 trace_id 当会话 ID会话跨多局,两者生命周期不同
用平均延迟代替分位长尾被拉低,玩家感知的是 p99
认为采样后的指标仍精确指标不采样,或使用加权统计
跨租户查询不设权限trace_id 可能泄露,查询绑定租户与角色
只建设不治理契约进 CI,破坏性变更走评审

小结

日志、指标、追踪的分工是:指标发现异常,追踪缩小范围,日志解释细节。让三者能合起来工作的不是平台,而是契约:五个关联字段、按阶段必填、一致的头部采样、明确的成本口径。这一节的 obscontract 把字段校验、采样决策和链路拼装做成了可执行代码。下一节进入游戏专属指标,讨论帧、房间、匹配和经济这四类数据怎么定义,才能既反映玩家体验,又不被平均数骗过去。

阅读导航:上一节:4.3 黑产对抗的架构边界 · 下一节:5.2 游戏专属指标:帧、房间、匹配、经济 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查