一次“三张图都正常”的丢单
《裂隙战场》赛季结算后的第二天,客服收到一条工单:玩家打满整个赛季,奖励没到账。值班同学打开三个系统:日志平台按玩家昵称搜不到,因为结算服务只打了订单号;指标看板显示结算成功率 99.98%(示例值),绿色;追踪系统采样率 1%(示例值),这笔请求没被采到。三张图都正常,问题却真实存在。
这不是工具不够多,而是三条腿之间没有契约。日志不知道追踪的 trace_id,指标不知道玩家的身份,追踪又只保留了极少数请求。每一件工具单独看都成立,合起来却回答不了“这个玩家的这一局发生了什么”。可观测性要建设的不是三个平台,而是一份 producer 愿意执行、consumer 能够 join 的字段契约。
三条腿各回答什么
| 腿 | 回答的问题 | 典型查询 | 成本主导 | 典型失效 |
|---|---|---|---|---|
| 日志 | 发生了什么 | 按 trace_id 或 player_id 取上下文 | 存储与索引 | 字段缺失、采样丢弃 |
| 指标 | 是否异常 | 速率、错误率、分位趋势 | 标签基数 | 口径错误、高基数 |
| 追踪 | 慢在哪 | 一次请求的跨服务时序 | 采样与存储 | 断链、时钟漂移 |
三条腿不能互相替代。用日志算指标,成本高且口径随文案漂移;用指标定位单玩家,指标里根本没有身份;用追踪做审计,采样决定了它必然漏。合理的分工是:指标负责发现异常,追踪负责缩小范围,日志负责解释细节。
观测契约:五个字段
契约的核心是一组稳定的关联字段。字段名一旦发布就是接口,改名等于让所有下游查询失效。
| 字段 | 格式 | 产生位置 | 缺失后的后果 |
|---|---|---|---|
| trace_id | 32 位小写十六进制 | 网关入口 | 无法跨服务拼接 |
| span_id | 16 位小写十六进制 | 每个服务 | 无法表达父子关系 |
| 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 早于父 span | duration 用单调时钟,阶段顺序做校验 |
| player_id 进指标标签,基数爆炸 | 只进日志与追踪,指标按区间分桶 |
| 日志打全量业务对象,成本失控 | 字段白名单,只打 ID 与关键状态 |
| 错误样本被采样丢弃 | AlwaysErrors 强制保留并限速 |
常见错误与边界
| 常见错误 | 边界 |
|---|---|
| 把 trace_id 当会话 ID | 会话跨多局,两者生命周期不同 |
| 用平均延迟代替分位 | 长尾被拉低,玩家感知的是 p99 |
| 认为采样后的指标仍精确 | 指标不采样,或使用加权统计 |
| 跨租户查询不设权限 | trace_id 可能泄露,查询绑定租户与角色 |
| 只建设不治理 | 契约进 CI,破坏性变更走评审 |
小结
日志、指标、追踪的分工是:指标发现异常,追踪缩小范围,日志解释细节。让三者能合起来工作的不是平台,而是契约:五个关联字段、按阶段必填、一致的头部采样、明确的成本口径。这一节的 obscontract 把字段校验、采样决策和链路拼装做成了可执行代码。下一节进入游戏专属指标,讨论帧、房间、匹配和经济这四类数据怎么定义,才能既反映玩家体验,又不被平均数骗过去。
阅读导航:上一节:4.3 黑产对抗的架构边界 · 下一节:5.2 游戏专属指标:帧、房间、匹配、经济 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。