一、埋点模型:事件名、公共属性、自定义属性与标识
埋点不是“打日志”,而是一份有 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 后 4 | 138****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 或完整支付信息。
- 高频路径打印逐帧日志,磁盘先满、排障信息反被淹没。
- 本地队列没有容量上限,离线时间一长写满设备磁盘。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。