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 打点 · 下一节:容器化与编排 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。