《游戏服务端编程实践》15.2 埋点与日志系统

从事件名、公共属性、自定义属性与用户设备标识讲起,说明埋点版本与字典、批量压缩上报、失败重试、本地落盘、采样与全量的取舍、结构化日志分级、敏感信息脱敏与保留周期,并用可运行的 Go 批量上报器演示缓冲、定时 flush、重试不丢数据和采样比例。

一、埋点模型:事件名、公共属性、自定义属性与标识

埋点不是“打日志”,而是一份有 schema 的事件契约。一条事件至少包含事件名、公共属性、自定义属性与主体标识:事件名回答“发生了什么”,公共属性回答“在什么环境发生”,自定义属性回答“这次的具体参数”,标识回答“是谁”。

{"name":"purchase_success","v":3,"ts":"2026-11-04T10:34:40+08:00","user_id":"u_1024","device_id":"d_9f3c","common":{"app_version":"1.8.2","server_id":"s1001","platform":"ios"},"props":{"sku":"gem_60","amount":6,"currency":"CNY","order_id":"o_7781"}}

公共属性由 SDK 或框架自动填充,业务代码不要每条手写。自定义属性只放本次事件特有字段,不要把整个玩家档案塞进 props。标识分匿名与登录态:登录前只有 device_id,登录后才有 user_id 与 role_id;同一个人可有多台设备、多个角色、多个区服。

标识生命周期用途注意点
device_id安装/设备级匿名行为重装可能变化,不能当永久用户 ID
user_id账号级登录、付费、留存跨区可能共用,也可能按区隔离
role_id角色级成长、战斗一个账号可有多个角色
server_id区服级分服运营合服后要映射历史区服

事件名用“对象_动作”的小写蛇形命名,如 login_success、battle_start。属性值只允许标量或标量数组,类型固定,枚举值用稳定英文码;展示名可以变,存储值不要变。

二、埋点版本与字典

埋点一旦上线就会被历史数据引用,必须像接口一样版本化。新增可选字段通常兼容;删除字段、改变含义、修改枚举值不兼容,应升版本或新增事件名,不要复用旧名。字典至少记录事件名、版本、负责人、属性列表、类型、枚举值、上下线时间;没有字典的系统,三个月后没人说得清 status=2 是什么。版本号要随事件上报,服务端按 name+v 查字典校验,未知版本进入隔离表。

变更兼容性处理
新增可选属性兼容原版本追加并同步字典
新增事件兼容分配新事件名
删除属性/改含义/改枚举不兼容升版本或新增字段,新旧并行
修改事件名不兼容新名上线,旧名标记下线

三、上报链路:批量、压缩、重试与本地落盘

不要每条事件同步打一次网络请求。正确链路是“内存缓冲 → 按条数或时间批量切分 → 压缩 → 发送 → 有限重试 → 失败落盘 → 恢复后重放”。

环节做法失败处理
缓冲进程内有界缓冲满时返回错误或落盘
批量100 条或 5 秒先到者,另设字节上限不足一批由定时器触发
压缩gzip/zstd压缩失败降级为不压缩
发送HTTP/gRPC,带超时超时不无限等待
重试有限次数、固定或指数退避耗尽后落盘/放回缓冲
落盘本地文件或嵌入式队列启动或网络恢复后重放

批量参数要同时看条数与时间:只按条数,低峰期可能几小时不发;只按时间,高峰期单批过大。重试必须有限且可观测,无限重试会在服务端故障时拖死客户端。落盘重放可能重复,事件需稳定 ID,服务端按 ID 幂等写入;本地队列要有容量上限和淘汰策略。

四、采样与全量的选择

付费、登录、关键战斗结果必须全量;高频位置同步、普通 UI 点击、重复心跳可以采样。客户端采样省带宽和电量,但采样率必须随事件上报,否则服务端无法还原总量;服务端采样省存储,但接入流量已经发生。

按用户哈希采样比按事件随机采样更适合行为分析。按事件随机会把同一用户的行为切碎,漏斗、留存、路径分析都会失真;按稳定标识哈希能保证“这个用户要么一直在样本里,要么一直不在”。

方式省什么风险建议
客户端按比例带宽、电量、请求数采样率丢失则无法还原事件带 sample_rate
客户端按用户哈希保持行为连续小样本用户可能被排除用稳定标识哈希
服务端按比例下游存储与计算接入流量不变适合高 QPS 内部事件

五、日志分级与结构化

日志回答“服务内部发生了什么”,埋点回答“业务发生了什么”。把业务事件写成文本日志再解析,短期省事,长期失去 schema、字典和版本管理。

生产环境 DEBUG 默认关闭,INFO 保留关键节点,WARN 聚合告警,ERROR 带堆栈与上下文,FATAL 触发退出。结构化日志用固定字段而不是拼字符串:ts、level、service、instance、trace_id、request_id、user_id(脱敏后)、event、error、duration_ms。高频路径不要打印整包协议、完整玩家档案或逐帧位置:日志会先打满磁盘,再拖慢服务,真正排障时反而被噪声淹没;对高频路径用采样日志,或只在慢请求时打印详细上下文。

六、敏感信息脱敏与保留周期

脱敏要在写入点完成,不要指望下游清洗。手机号、身份证、邮箱、Token、密码、支付账号、精确地址都不应以明文落盘。

字段处理示例
手机号保留前 3 后 4138****5678
邮箱保留首字符与域名a***@example.com
身份证只保留后 4 位或哈希****1234
Token/密码永不记录,必要时记哈希前缀sha256:9f3c…
支付账号只记尾号****7781

保留周期按用途定:调试日志 7–14 天,业务日志 30–90 天,聚合指标一年,审计数据按法规。到期删除要自动化。埋点平台、日志平台、数据仓库权限分离,能看聚合报表的人不一定能看原始事件,导出与查询留审计记录。

七、可运行实现:带缓冲、批量与重试的上报器

下面的实现把职责拆开:Track 负责采样与入缓冲,Flush 按批切分,send 做有限重试,requeue 把失败批次按原顺序放回。采样发生在入缓冲之前,被丢弃的事件不占内存;失败批次连同其后未发送的事件一起放回头部,已成功批次不重复发送;放回后缓冲区可能短暂超限,此时新的 Track 收到 ErrBufferFull,调用方应降级落盘或记录丢弃计数。

// file: ch15/tracker/tracker.go
// tier: L1
// verify: go test ./ch15/tracker/

// Package tracker 实现一个带缓冲、批量发送和失败重试的埋点上报器。
//
// 设计目标是“进程内不丢已接受的埋点”:Track 只在缓冲区满或
// 已关闭时返回错误;Flush 发送失败的批次会按原顺序放回缓冲区,
// 等待下一次 Flush 或 Close 重试。采样发生在进入缓冲区之前,
// 被采样丢弃的事件不占用缓冲区。
package tracker

import (
	"context"
	"errors"
	"fmt"
	"math/rand"
	"sync"
	"time"
)

var (
	// ErrClosed 表示 Tracker 已关闭。
	ErrClosed = errors.New("tracker: closed")
	// ErrBufferFull 表示缓冲区已满,调用方需要降级或丢弃。
	ErrBufferFull = errors.New("tracker: buffer full")
)

// Event 是一条埋点。公共属性与自定义属性由调用方合并在 Props 中。
type Event struct {
	Name   string
	UserID string
	Time   time.Time
	Props  map[string]any
}

// Sink 负责把一批事件送到远端;返回错误表示整批发送失败。
type Sink interface {
	Send(ctx context.Context, events []Event) error
}

// Options 控制缓冲区、批量、采样与重试。
//
// SampleRate 小于等于 0 表示关闭采样、全部保留;大于等于 1 也
// 全部保留;只有 (0,1) 之间的值才按比例随机保留。
type Options struct {
	BufferSize    int
	MaxBatch      int
	FlushInterval time.Duration
	SampleRate    float64
	MaxAttempts   int
	Backoff       time.Duration
	Rand          *rand.Rand
}

func (o Options) withDefaults() Options {
	if o.BufferSize <= 0 {
		o.BufferSize = 1024
	}
	if o.MaxBatch <= 0 {
		o.MaxBatch = 100
	}
	if o.MaxBatch > o.BufferSize {
		o.MaxBatch = o.BufferSize
	}
	if o.SampleRate > 1 {
		o.SampleRate = 1
	}
	if o.MaxAttempts <= 0 {
		o.MaxAttempts = 3
	}
	if o.Backoff <= 0 {
		o.Backoff = 50 * time.Millisecond
	}
	if o.Rand == nil {
		o.Rand = rand.New(rand.NewSource(time.Now().UnixNano()))
	}
	return o
}

// Tracker 是一个并发安全的批量埋点缓冲器。
type Tracker struct {
	mu      sync.Mutex
	flushMu sync.Mutex
	opts    Options
	sink    Sink
	rng     *rand.Rand
	buf     []Event
	closed  bool
	stop    chan struct{}
	done    chan struct{}
}

// New 创建 Tracker;FlushInterval 大于 0 时启动后台定时发送。
func New(sink Sink, opts Options) *Tracker {
	opts = opts.withDefaults()
	t := &Tracker{
		opts: opts,
		sink: sink,
		rng:  opts.Rand,
		stop: make(chan struct{}),
		done: make(chan struct{}),
	}
	if opts.FlushInterval > 0 {
		go t.loop()
	} else {
		close(t.done)
	}
	return t
}

// Track 尝试记录一条事件。
//
// 返回 nil 表示事件已进入缓冲区或被采样丢弃;返回 ErrBufferFull
// 表示调用方需要自行降级,例如写本地磁盘或记录丢弃计数。
func (t *Tracker) Track(ev Event) error {
	t.mu.Lock()
	defer t.mu.Unlock()
	if t.closed {
		return ErrClosed
	}
	if t.opts.SampleRate > 0 && t.opts.SampleRate < 1 {
		if t.rng.Float64() >= t.opts.SampleRate {
			return nil
		}
	}
	if len(t.buf) >= t.opts.BufferSize {
		return ErrBufferFull
	}
	if ev.Time.IsZero() {
		ev.Time = time.Now()
	}
	t.buf = append(t.buf, ev)
	return nil
}

// Flush 把当前缓冲区按 MaxBatch 切分发送。
//
// 某一个批次重试耗尽后,该批次及其后尚未发送的事件会按原顺序
// 放回缓冲区,已成功的批次不会重复发送。放回后缓冲区可能短暂
// 超过 BufferSize,此时新的 Track 会得到 ErrBufferFull,直到
// 下一次 Flush 排空。
func (t *Tracker) Flush(ctx context.Context) error {
	t.flushMu.Lock()
	defer t.flushMu.Unlock()

	t.mu.Lock()
	pending := t.buf
	t.buf = nil
	t.mu.Unlock()

	for start := 0; start < len(pending); start += t.opts.MaxBatch {
		end := start + t.opts.MaxBatch
		if end > len(pending) {
			end = len(pending)
		}
		batch := append([]Event(nil), pending[start:end]...)
		if err := t.send(ctx, batch); err != nil {
			t.requeue(pending[start:])
			return err
		}
	}
	return nil
}

// send 对单个批次做有限次重试;ctx 取消时立即返回。
func (t *Tracker) send(ctx context.Context, batch []Event) error {
	var lastErr error
	for attempt := 1; attempt <= t.opts.MaxAttempts; attempt++ {
		if err := ctx.Err(); err != nil {
			return err
		}
		lastErr = t.sink.Send(ctx, batch)
		if lastErr == nil {
			return nil
		}
		if attempt == t.opts.MaxAttempts {
			break
		}
		timer := time.NewTimer(t.opts.Backoff)
		select {
		case <-ctx.Done():
			timer.Stop()
			return ctx.Err()
		case <-timer.C:
		}
	}
	return fmt.Errorf("tracker: send batch after %d attempts: %w", t.opts.MaxAttempts, lastErr)
}

// requeue 把失败批次放回缓冲区头部,保持事件原始顺序。
func (t *Tracker) requeue(events []Event) {
	t.mu.Lock()
	defer t.mu.Unlock()
	combined := make([]Event, 0, len(events)+len(t.buf))
	combined = append(combined, events...)
	combined = append(combined, t.buf...)
	t.buf = combined
}

// loop 按 FlushInterval 周期性发送;单次失败留给下一次重试。
func (t *Tracker) loop() {
	defer close(t.done)
	ticker := time.NewTicker(t.opts.FlushInterval)
	defer ticker.Stop()
	for {
		select {
		case <-ticker.C:
			_ = t.Flush(context.Background())
		case <-t.stop:
			return
		}
	}
}

// Close 停止定时发送,并把剩余事件做最后一次 Flush。
//
// 关闭后 Track 返回 ErrClosed;Flush 仍可调用,便于调用方在进程
// 退出前用带超时的 ctx 完成收尾。
func (t *Tracker) Close(ctx context.Context) error {
	t.mu.Lock()
	if t.closed {
		t.mu.Unlock()
		<-t.done
		return nil
	}
	t.closed = true
	t.mu.Unlock()
	close(t.stop)
	<-t.done
	return t.Flush(ctx)
}

// Len 返回当前缓冲区中的事件数,主要用于观测和测试。
func (t *Tracker) Len() int {
	t.mu.Lock()
	defer t.mu.Unlock()
	return len(t.buf)
}

测试覆盖批量边界(5 条、每批 3 条切成 3+2)、三种采样设置、重试两次后成功、重试耗尽后事件仍在缓冲区、缓冲区满、Close 收尾 flush 与定时器触发。假 Sink 让失败可控,不依赖真实网络。

八、失败场景:每个事件一次请求

某休闲游戏在每个关卡结束时直接 POST 一条事件,没有批量、重试和落盘。买量后同时在线从 2 万涨到 10 万(示例值),采集端开始大量超时。

时间事件后果
T+0活动开启,在线上涨单事件请求数同步上涨
T+30s采集接口 P99 超过 3 秒客户端超时后重发
T+60s重发叠加正常上报请求数进一步放大
T+120s部分客户端放弃关卡数据出现缺口
T+300s采集端限流新老请求一起被拒绝
T+1h发现没有本地缓存缺口无法补回

问题不是采集端扛不住,而是客户端把上报做成了同步关键路径。正确做法是事件先进内存缓冲、批量发送,失败有限重试后落盘,上报与游戏逻辑解耦。另一个事故是日志打印明文手机号和 Token,修复不能只删日志,还要在写入点做字段级脱敏,并排查历史日志的访问记录。

小结

埋点系统的核心是模型与契约:事件名、公共属性、自定义属性、标识先定义,版本和字典随事件维护。上报链路用缓冲、批量、压缩、有限重试和本地落盘组合,不能每条事件一次同步请求。采样要区分全量与抽样,日志要结构化、分级、脱敏并设定保留周期。上一篇 /game-server-in-action-15-1/ 讲口径与指标,这一篇把口径落到可采集、可重放、可审计的数据链路。

常见错误与边界

  • 每个事件发一次 HTTP 请求,高峰期限流后数据成片丢失。
  • 埋点没有版本和字典,字段含义三个月后无人能解释。
  • 采样率不随事件上报,服务端无法把样本还原成总量。
  • 按事件随机采样而不是按用户哈希,漏斗和留存分析失真。
  • 日志里打印明文手机号、身份证、Token 或完整支付信息。
  • 高频路径打印逐帧日志,磁盘先满、排障信息反被淹没。
  • 本地队列没有容量上限,离线时间一长写满设备磁盘。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist