《MMO 服务器开发》3.3 链路追踪与告警

日志看得见单点、指标看得见全局,但「这次请求到底走了哪几个节点、卡在哪一段」只有链路追踪答得上来。这一节讲清 trace/span 的传播(trace_id 怎么跟着帧走)、跨服务链路怎么拼装,再讲告警规则的两种判据——阈值告警与变化率告警——以及抖动抑制与抑制关系。

3.3 链路追踪与告警

日志(3.1)看得见「单次请求」,指标(3.2)看得见「全局趋势」,但这两样都答不上一个具体问题:「玩家张三这次点技能,请求到底走了哪几个节点,卡在哪一段?」 日志里这条请求的四行记录散在四个进程的文件里,靠 player_id 和 seq 手工拼,拼一次要十几分钟;指标只能告诉你「battle_cast 的 p99 涨了」,却不知道涨在网关转发、场景路由还是战斗计算。链路追踪(tracing) 补的就是这一块:它把一次请求在所有节点上的处理,拼成一棵有父子关系的树。

这一节做两件事。第一,讲清 trace 与 span 的传播——trace_id 怎么在服务之间跟着帧走,跨服务的链路怎么从「一段段孤立的 span」拼成「一条完整链路」。第二,讲告警——有了日志、指标、链路之后,怎么把它们变成「主动通知人」的规则:阈值告警和变化率告警各管什么、怎么用「连续 N 次」抑制抖动、以及多条告警同时炸时怎么用抑制关系收敛噪音。

trace 与 span:一段段拼成一条链路

链路追踪的模型只有三个概念:

  • trace:一次完整请求,由 trace_id 唯一标识。整条链路里所有节点共享同一个 trace_id。
  • span:链路里的一段处理,由 span_id 标识。一次请求会跨多个节点,每个节点产生自己的 span。
  • parent:span 记录自己的父 span 的 span_id。父子关系是拼装链路的唯一依据——有了 parent_id,散落在各节点的 span 就能重建成一棵树。

关键在于传播:网关收到请求时起一个根 span,然后把 trace_id 和当前 span_id 一起塞进帧里转发给下游;下游从帧里取出上下文,开一个以它为父的子 span,再继续往下传。卷一定稿的 aegis 帧固定头是 11 字节(length | cmd | seq | flags),塞不下 16 字节的 trace_id,所以 aegis 把 trace 上下文作为 payload 头部的 32 字节前缀——固定头不动,协议向后兼容。链路长这样:

client ──frame──▶ gateway-03 ──frame──▶ scene-01 ──frame──▶ battle-02
  (起根 span)        │ 转发, 附 ctx        │ 转发, 附 ctx        │ 计算, 记 result
                     ▼                     ▼                     ▼
  trace_id = T  ┌──────────┐  ┌──────────┐  ┌──────────┐
  共享同一条 T  │ span A   │  │ span B   │  │ span C   │
                │ parent=∅ │  │ parent=A │  │ parent=B │
                └──────────┘  └──────────┘  └──────────┘

下面这段是 L1,go test 可以直接跑。它实现极简的 Tracer、Trace(负责拼装)以及帧内的 trace 上下文注入 / 提取:

// file: internal/telemetry/trace.go
// tier: L1
// verify: go test ./internal/telemetry/
package telemetry

import (
	"crypto/rand"
	"encoding/hex"
)

// TraceContext 是要在服务之间传播的最小上下文:trace_id 标识整条链路,
// span_id 标识「当前这一段」。跨进程时这两样必须跟着帧一起走。
type TraceContext struct {
	TraceID string
	SpanID  string
}

// Span 是链路里的一段。ParentID 为空表示它是整条链路的根。
type Span struct {
	TraceID  string
	SpanID   string
	ParentID string
	Service  string
	Cmd      uint16
	CostMS   float64
	Err      bool
}

// Tracer 负责生成 span。每个服务一个实例,Service 就是节点名(如 gateway-03)。
type Tracer struct {
	Service string
}

func newID() string {
	var b [8]byte
	_, _ = rand.Read(b[:])
	return hex.EncodeToString(b[:]) // 16 个十六进制字符
}

// StartTrace 起一条全新链路,返回它的根上下文与根 span。
func (t *Tracer) StartTrace() (*TraceContext, Span) {
	tc := TraceContext{TraceID: newID(), SpanID: newID()}
	return &tc, Span{TraceID: tc.TraceID, SpanID: tc.SpanID, Service: t.Service}
}

// StartSpan 在父上下文下开一段子 span。返回的新上下文里 span_id 已经换成子段,
// 网关把它转发给下游时,下游看到的自然就是「子」,父子关系由此自动拼出来。
func (t *Tracer) StartSpan(parent TraceContext) (*TraceContext, Span) {
	tc := TraceContext{TraceID: parent.TraceID, SpanID: newID()}
	return &tc, Span{TraceID: tc.TraceID, SpanID: tc.SpanID, ParentID: parent.SpanID, Service: t.Service}
}

// Trace 收集同一条链路的所有 span,负责把散落的段拼成树。
type Trace struct {
	TraceID string
	Spans   []Span
}

// Add 收一段 span。
func (tr *Trace) Add(s Span) { tr.Spans = append(tr.Spans, s) }

// Roots 返回所有根 span(ParentID 为空)。
func (tr *Trace) Roots() []Span {
	var out []Span
	for _, s := range tr.Spans {
		if s.ParentID == "" {
			out = append(out, s)
		}
	}
	return out
}

// Children 返回某个 span 的直接子段。
func (tr *Trace) Children(spanID string) []Span {
	var out []Span
	for _, s := range tr.Spans {
		if s.ParentID == spanID {
			out = append(out, s)
		}
	}
	return out
}

// Depth 返回某段相对根的层级:根为 0,逐级 +1。找不到返回 -1。
func (tr *Trace) Depth(spanID string) int {
	byID := map[string]Span{}
	for _, s := range tr.Spans {
		byID[s.SpanID] = s
	}
	d, cur, guard := 0, spanID, 0
	for {
		s, ok := byID[cur]
		if !ok {
			return -1
		}
		if s.ParentID == "" {
			return d
		}
		cur = s.ParentID
		d++
		if guard++; guard > len(tr.Spans)+1 {
			return -1 // 防环
		}
	}
}

// metaSize 是注入到 aegis 帧 payload 头部的固定长度:
// traceID(16 字符) + spanID(16 字符) = 32 字节。固定头 11 字节不动。
const metaSize = 32

// InjectMeta 把 trace 上下文塞进 payload 头部,返回新 payload。
func InjectMeta(payload []byte, tc TraceContext) []byte {
	out := make([]byte, 0, metaSize+len(payload))
	out = append(out, []byte(tc.TraceID)...)
	out = append(out, []byte(tc.SpanID)...)
	return append(out, payload...)
}

// ExtractMeta 从 payload 头部取出 trace 上下文,返回剩余的业务载荷。
// ok=false 表示这段载荷没有携带 trace 信息(老客户端 / 心跳)。
func ExtractMeta(frame []byte) (tc TraceContext, rest []byte, ok bool) {
	if len(frame) < metaSize {
		return TraceContext{}, frame, false
	}
	return TraceContext{TraceID: string(frame[:16]), SpanID: string(frame[16:32])}, frame[metaSize:], true
}

跨服务链路拼装

拼装的时机和位置是个架构问题。有两种做法:实时拼装(每个节点把自己那段 span 上报给一个收集器,收集器按 trace_id 归并)和离线拼装(span 先落盘,查询时再按 trace_id 拉出来组树)。aegis 用后者,因为游戏服务端的高频命令下,实时上报本身会成为负担。但无论哪种,拼装的逻辑是同一套:按 trace_id 分组,再用 parent_id 连边。

拼装正确与否,靠断言验证。下面这段是 L1 测试里的关键部分(// snippet:,节选自 internal/telemetry/trace_test.go):网关起根、场景开子、战斗开孙,然后断言三段的 trace_id 一致、有且仅有一个根、Children 链正确、层级依次是 0/1/2:

// snippet: 节选自 internal/telemetry/trace_test.go(完整文件已随 go test 跑通)
gw := &Tracer{Service: "gateway-03"}
tc, root := gw.StartTrace()          // 根 span
scene := &Tracer{Service: "scene-01"}
sc, sceneSpan := scene.StartSpan(*tc) // 子 span
battle := &Tracer{Service: "battle-02"}
_, battleSpan := battle.StartSpan(*sc) // 孙 span

// 三段共享同一 trace_id;有且仅有一个根
// Children(root) == scene,Children(scene) == battle
// Depth: root=0, scene=1, battle=2

这里要读出的是:链路追踪的价值不在「能画出图」,而在「能定位慢在哪一段」。当 battle_cast 的 p99 涨了,运维拉出这条链路的 span 树,一眼看到 CostMS 从网关 0.5ms 涨到 3ms,就知道瓶颈在网关转发而不是战斗计算——这是单看指标永远得不出、单看日志要拼半天的结论。

告警:阈值 vs 变化率,抖动与抑制

有了日志、指标、链路,最后一步是把它们变成主动通知。告警规则有两种基本判据,用错了要么天天误报、要么真出事不响:

判据适合的量例子用错的后果
阈值告警(Threshold)有明确红线的绝对量内存使用 > 90%、在线人数 < 1000用在无红线的量上,阈值只能拍脑袋
变化率告警(RateChange)没有红线、但突变要报的量错误率 5 分钟翻了 3 倍、QPS 骤降 80%用在本来就剧烈波动的量上,全天误报

两条最容易忽略的工程细节:

  • 抖动抑制:线上指标天然抖动,一次采样越线不代表出事。所以规则带一个 For——连续满足 N 次才触发,中途恢复正常就清零。这能把「毛刺」和「真故障」区分开。
  • 抑制关系(inhibition):当一个节点整体宕机时,它上面跑的每一个服务都会同时报警,几十条告警刷屏反而淹没了根因。做法是:高严重度的告警触发时,抑制被它导致的低严重度告警——node_down 一响,latency、cpu_high 就静默,值班的人只看得到「节点挂了」这一条根因。

下面这段是 L1,go test 可以直接跑,实现了两种判据 + 抖动抑制 + 抑制关系:

// file: internal/telemetry/alert.go
// tier: L1
// verify: go test ./internal/telemetry/
package telemetry

import "math"

// Kind 是告警判据的类型。
type Kind int

const (
	// Threshold:绝对值越过上限。适合「内存使用 > 90%」这类有明确红线的量。
	Threshold Kind = iota
	// RateChange:相对上一次的变化率越过上限。适合「错误率 5 分钟翻了 3 倍」
	// 这类没有绝对红线、但突变一定要报的量。
	RateChange
)

// Rule 是一条告警规则。
type Rule struct {
	Name     string
	Kind     Kind
	Limit    float64 // Threshold 是上限;RateChange 是变化率上限(0.5 表示 50%)
	For      int     // 连续满足多少次才触发,用来抑制抖动
	Severity int     // 越大越严重,供抑制关系使用
}

// Evaluator 维护每条规则的「连续满足计数」,实现「持续 N 次才触发」的抖动抑制。
type Evaluator struct {
	streak map[string]int
}

// NewEvaluator 构造一个判定器。
func NewEvaluator() *Evaluator { return &Evaluator{streak: map[string]int{}} }

// Eval 用当前样本(以及变化率需要的上一值)判断规则是否触发。
// prev 只在 RateChange 时用;Threshold 忽略它。
func (e *Evaluator) Eval(r Rule, prev, cur float64) bool {
	hit := false
	switch r.Kind {
	case Threshold:
		hit = cur > r.Limit
	case RateChange:
		if prev != 0 {
			hit = math.Abs(cur-prev)/math.Abs(prev) > r.Limit
		}
	}
	if hit {
		e.streak[r.Name]++
	} else {
		e.streak[r.Name] = 0
	}
	forN := r.For
	if forN < 1 {
		forN = 1
	}
	return e.streak[r.Name] >= forN
}

// Inhibit 应用抑制关系:当某条更严重的告警正在触发时,被它抑制的告警不发出。
// higher 的 key 是抑制者,value 是被它抑制的告警名列表。
func Inhibit(firing map[string]bool, higher map[string][]string) map[string]bool {
	out := map[string]bool{}
	for name, on := range firing {
		if on {
			out[name] = true
		}
	}
	for inhibitor, victims := range higher {
		if !out[inhibitor] {
			continue
		}
		for _, v := range victims {
			delete(out, v)
		}
	}
	return out
}

本节小结 / 实测结论

链路追踪和告警,一个负责「把一次请求的路径还原成树」,一个负责「把树的异常主动推给人」。前者的核心是传播——trace_id 必须跟着帧跨进程走,否则链路永远拼不起来;后者的核心是判据选型 + 噪音控制——有红线的量用阈值、会突变的量用变化率,再用 For 抑制抖动、用抑制关系收敛刷屏。

本机实测(go test ./internal/telemetry/ -v,Go 1.26.0)五条断言全绿:三段 span 拼成的链路 trace_id 一致、有且仅有一个根、Children 父子链正确、层级为 0/1/2;trace 上下文经 InjectMeta/ExtractMeta 往返后一字不差、业务载荷不受影响;阈值告警连续 3 次才触发且中途恢复会清零;变化率告警在 400% 突增时触发、20% 波动时静默;抑制关系正确地把 latency、cpu_high 从 node_down 触发时移除。至此,可观测性三件套——日志、指标、链路——在 aegis 里都有了能跑的实现,第 4 章开始谈怎么把它们部署上线。

阅读导航:上一节:指标与 Prometheus 打点 · 下一节:容器化与编排 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标