《MMO 服务器开发》3.1 日志该记什么

日志不是越多越好。这一节先把一条请求日志该带的最小字段集钉死——trace_id、player_id、cmd、seq、cost_ms、result——再用 log/slog 写出一个能跑的实现,讲清日志分级、错误不采样的采样策略、手机号与邮箱的写盘前脱敏,最后给出一张该记与不该记的清单。

3.1 日志该记什么

前三卷把 aegis 从网络内核、单服玩法一路做到了分布式集群,可整套系统跑起来之后,团队第一次真正慌的时候往往不是「服务挂了」,而是**「服务没挂,但玩家说卡了、东西丢了,而我们翻遍日志也说不清到底发生了什么」**。压测能告诉你系统能扛多少人,性能调优能告诉你哪个函数慢,但线上真实故障里 90% 的问题,只能靠日志、指标、链路这三样东西去还原。这一章就是可观测性。

日志是这三样里最古老、最便宜、也最容易做错的一样。最常见的两个极端:一种是「什么都不记」,出事后只能靠复现;另一种是「什么都记」,一个战斗技能命中就往日志里写十条,一天几十个 G,真出事了 grep 都 grep 不动,还顺手把玩家的手机号写进了磁盘。这一节要做的,不是教你 fmt.Println 怎么用,而是先把**「一条日志该带哪些字段」这件事定成契约**,再解决分级、采样、脱敏三个工程问题。

先说结论:一条请求级日志,字段应该是固定的六个——trace_id、player_id、cmd、seq、cost_ms、result。多一个字段就多一份基数、多一份脱敏责任;少一个字段,出事时就少一条线索。定完这六个,日志才从「一堆文本」变成「可被机器聚合的结构化事件」。

与站内既有文章的分工

这一节不重复站内已有架构文对可观测性的全局讨论,只落在 aegis 里那份能 go test 的日志实现上。分工如下:

站内既有文章(根级路径)它负责讲什么本节负责讲什么
/game-server-observability-architecture-design/日志、指标、链路三件套的整体架构与选型取舍把其中的「日志」一件落成 aegis 可跑的 RequestLogger
/game-server-observability-playbook-for-live-ops/线上运维手册:什么现象查什么、怎么定位提供可被这本手册消费的稳定字段契约
/what-game-server-logs-should-record/「日志该记什么」的原则讨论与正反例把原则固化成带断言的结构化字段集

想先建立判断力,可以先读这三篇;想直接看实现,往下走。

六个字段:一条请求日志的最小契约

为什么是这六个?把一次「玩家点了技能」的处理链拆开看:网关收到帧,知道 cmd(命令号)与 seq(帧序号);它把请求转给场景/战斗节点,节点查到这个玩家是 player_id;整条链路被一个 trace_id 串起来;处理完记下 cost_ms(耗时)与 result(结果)。这六个字段一旦齐了,下面这些问题就都能用一句查询回答:

字段类型它回答的问题
trace_idstring这次请求在整条链路上经历了哪些节点?
player_iduint64是哪个玩家出的问题?(内部数值 ID,非隐私)
cmduint16出问题的是哪条命令?
sequint32是这一条命令的第几次请求?能不能和抓包对上?
cost_msfloat64这条命令处理了多久?慢在哪一段?
resultstring成功、报错还是超时?

注意 cmd 记的是命令号本身,而不是玩家名、物品名这类文本。字段值必须来自有限集合(枚举、数值),否则聚合查询立刻退化成文本搜索。下面这段是 L1,go test 可以直接跑:

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

import (
	"context"
	"log/slog"
	"math/rand/v2"
	"regexp"
)

// Field names are frozen: 日志查询、告警规则、看板全部按这几个 key 过滤,
// 一旦改名就等于全站查询失效。
const (
	FieldTraceID  = "trace_id"
	FieldPlayerID = "player_id"
	FieldCmd      = "cmd"
	FieldSeq      = "seq"
	FieldCostMS   = "cost_ms"
	FieldResult   = "result"
)

// 结果取值。只有这三种,不允许自由发挥,否则采样与告警没法按 result 聚合。
const (
	ResultOK      = "ok"
	ResultErr     = "err"
	ResultTimeout = "timeout"
)

// Event 是「一条请求日志」的最小字段集。字段少不是偷懒:
// 多一个字段就多一份 cardinality、多一份脱敏责任,先定死这六个。
type Event struct {
	TraceID  string
	PlayerID uint64
	Cmd      uint16
	Seq      uint32
	CostMS   float64
	Result   string
}

// Sampler 决定一条日志是否落盘:错误全留,成功按比例采。
// 用确定性 PRNG,同一个 seed 序列可复现,便于测试与回放。
type Sampler struct {
	OKRatio float64
	rng     *rand.Rand
}

// NewSampler 构造采样器。okRatio=0 表示成功全丢,=1 表示成功全留。
func NewSampler(okRatio float64, seed1, seed2 uint64) *Sampler {
	return &Sampler{OKRatio: okRatio, rng: rand.New(rand.NewPCG(seed1, seed2))}
}

// Keep 报告一条事件是否应当写出。
func (s *Sampler) Keep(e Event) bool {
	if e.Result != ResultOK {
		return true // 错误与超时永远不采,宁可多写也不漏
	}
	if s.OKRatio >= 1 {
		return true
	}
	if s.OKRatio <= 0 {
		return false
	}
	return s.rng.Float64() < s.OKRatio
}

// 隐私脱敏:手机号与邮箱是玩家日志里最常见的两种 PII。
// 规则在写盘之前执行,绝不把原始值交给 Handler。
var (
	rePhone = regexp.MustCompile(`1[3-9]\d{9}`)
	reEmail = regexp.MustCompile(`[\w.+-]+@[\w-]+\.[\w.-]+`)
)

// Redact 抹掉自由文本里的手机号与邮箱。注意 player_id 是内部数值 ID,
// 用于链路关联,本身不是隐私;真正要防的是账号 uid、手机号、邮箱、聊天原文。
func Redact(msg string) string {
	msg = rePhone.ReplaceAllString(msg, "1**********")
	msg = reEmail.ReplaceAllString(msg, "***@***")
	return msg
}

// level 把业务 result 映射到日志级别:成功 Info,超时 Warn,其余 Error。
func level(result string) slog.Level {
	switch result {
	case ResultOK:
		return slog.LevelInfo
	case ResultTimeout:
		return slog.LevelWarn
	default:
		return slog.LevelError
	}
}

// RequestLogger 是「一条请求一行日志」的唯一出口:先采样、再脱敏、最后写。
type RequestLogger struct {
	log *slog.Logger
	smp *Sampler
}

// NewRequestLogger 组装请求日志器。
func NewRequestLogger(log *slog.Logger, smp *Sampler) *RequestLogger {
	return &RequestLogger{log: log, smp: smp}
}

// Log 写出一条事件,返回是否真的写了(被采样丢弃时返回 false)。
func (rl *RequestLogger) Log(ctx context.Context, e Event, msg string) bool {
	if rl.smp != nil && !rl.smp.Keep(e) {
		return false
	}
	rl.log.LogAttrs(ctx, level(e.Result), Redact(msg),
		slog.String(FieldTraceID, e.TraceID),
		slog.Uint64(FieldPlayerID, e.PlayerID),
		slog.Int(FieldCmd, int(e.Cmd)),
		slog.Int(FieldSeq, int(e.Seq)),
		slog.Float64(FieldCostMS, e.CostMS),
		slog.String(FieldResult, e.Result),
	)
	return true
}

这段代码里有三个刻意的设计。第一,字段名用常量冻结:查询语句、看板、告警规则全都按字符串 "trace_id" 过滤,一旦某处改成 traceID,全站查询就断了,所以用常量把名字钉死。第二,采样器用确定性 PRNG(math/rand/v2 的 PCG),同一 seed 得到同一序列,测试与回放才可复现——这也是卷二第 7 章定下的口径,绝不用全局 rand。第三,脱敏在写盘之前:Redact 是 Log 的最后一道闸,任何自由文本(错误信息、聊天片段)都必须过它。

分级、采样与脱敏

分级不是给日志排座次,而是给「谁来读它」分派。Debug 只给开发本地开;Info 是请求流水,量大但可采样;Warn 是「不致命但要看」的信号,比如一次超时;Error 是「必须有人处理」的故障。上表已经把它们映射好了:成功 Info、超时 Warn、其余 Error。这条映射的关键是让级别和 result 一一对应,而不是让每个开发自己拍脑袋——否则同一个错误,有人写 Info 有人写 Error,告警就没法按级别配。

采样解决的是量的问题。一个千人同时在线的战斗场景,每秒可能产生几万条请求日志。全记不现实,全丢又不行。aegis 的策略是:错误与超时 100% 记录,成功按比例采。理由很直接——排障关心的是「出错的那些请求」,成功的请求只用来估分布,采 5% 就够画曲线。这里最忌讳的是「按比例随机丢所有日志」,那会把最关键的报错也一起丢掉。

脱敏是底线。玩家日志里绝对不能出现的东西:账号 uid、手机号、邮箱、身份证、聊天原文、支付流水。手机号和邮箱用正则抹掉是最低要求;更严格的做法是根本不把原始值传进日志调用,需要关联时记内部 player_id。下面这段测试是 L1,断言输出字段齐全、错误级别正确、脱敏生效:

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

import (
	"bytes"
	"context"
	"encoding/json"
	"log/slog"
	"strings"
	"testing"
)

// 断言:一条请求日志必须同时带齐六个字段,且值正确。
func TestRequestLoggerEmitsAllFields(t *testing.T) {
	var buf bytes.Buffer
	lg := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
	rl := NewRequestLogger(lg, NewSampler(1.0, 1, 2))

	e := Event{TraceID: "tr-abc123", PlayerID: 10086, Cmd: 0x0401, Seq: 7, CostMS: 3.5, Result: ResultOK}
	if !rl.Log(context.Background(), e, "battle hit") {
		t.Fatal("success event should be kept when ratio=1")
	}

	var got map[string]any
	if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
		t.Fatalf("log is not valid JSON: %v (%q)", err, buf.String())
	}
	for _, k := range []string{FieldTraceID, FieldPlayerID, FieldCmd, FieldSeq, FieldCostMS, FieldResult} {
		if _, ok := got[k]; !ok {
			t.Fatalf("missing field %q in %v", k, got)
		}
	}
	if got[FieldTraceID] != "tr-abc123" {
		t.Fatalf("trace_id = %v", got[FieldTraceID])
	}
	if got[FieldResult] != ResultOK {
		t.Fatalf("result = %v", got[FieldResult])
	}
	if got["level"] != "INFO" {
		t.Fatalf("ok should log at INFO, got %v", got["level"])
	}
}

// 断言:错误事件级别为 ERROR,且永不采样丢弃。
func TestErrorAlwaysLoggedAtError(t *testing.T) {
	var buf bytes.Buffer
	lg := slog.New(slog.NewJSONHandler(&buf, nil))
	rl := NewRequestLogger(lg, NewSampler(0.0, 1, 2)) // 成功全丢

	if rl.Log(context.Background(), Event{Result: ResultOK, Cmd: 0x0401}, "ok line") {
		t.Fatal("ok event must be sampled out when ratio=0")
	}
	if !rl.Log(context.Background(), Event{Result: ResultErr, Cmd: 0x0401}, "boom") {
		t.Fatal("error event must always be kept")
	}

	var got map[string]any
	_ = json.Unmarshal(buf.Bytes(), &got)
	if got["level"] != "ERROR" {
		t.Fatalf("err should log at ERROR, got %v", got["level"])
	}
}

// 断言:脱敏函数抹掉手机号与邮箱,player_id 数值保留。
func TestRedactStripsPII(t *testing.T) {
	in := "player 13812345678 mail foo.bar@game.com login"
	out := Redact(in)
	if strings.Contains(out, "13812345678") {
		t.Fatalf("phone leaked: %q", out)
	}
	if strings.Contains(out, "foo.bar@game.com") {
		t.Fatalf("email leaked: %q", out)
	}
	if !strings.Contains(out, "1**********") || !strings.Contains(out, "***@***") {
		t.Fatalf("mask not applied: %q", out)
	}
}

// 断言:采样是确定性的——同一 seed 得到同一结果序列。
func TestSamplerDeterministic(t *testing.T) {
	a := NewSampler(0.5, 42, 99)
	b := NewSampler(0.5, 42, 99)
	for i := 0; i < 200; i++ {
		e := Event{Result: ResultOK}
		if a.Keep(e) != b.Keep(e) {
			t.Fatalf("sampler not deterministic at step %d", i)
		}
	}
}

本节小结:该记与不该记

把整节压成一张清单,左边是必须记的,右边是绝不能记的:

必须记绝不能记
trace_id / player_id / cmd / seq / cost_ms / result账号 uid、手机号、邮箱、身份证
结果枚举(ok / err / timeout)聊天原文、私聊内容
处理耗时(用于画 p99)支付卡号、订单号原文
出错的命令号与错误码密码、token、session 明文
影响状态的 GM 操作(谁改了什么)逐帧的技能命中流水(用指标而非日志)

本机实测(go test ./internal/telemetry/ -v,Go 1.26.0)四条断言全绿:字段齐全、错误级别为 ERROR 且不被采样、手机号与邮箱被抹掉、同 seed 采样序列一致。真正的工程价值不在代码量,而在于这六个字段是契约:3.2 的指标打点、3.3 的链路追踪,都会复用同一个 trace_id 口径;一旦这里放任每个开发自由加字段,后面的告警与看板就全部是空中楼阁。

阅读导航:上一节:目录 · 下一节:指标与 Prometheus 打点 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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