6.2 埋点与数据管道
6.1 把「指标怎么定义」定死了,但指标本身不会凭空出现——每一个 DAU、每一笔 ARPU 的背后,都是一条条原始事件:「谁、在什么时刻、对什么、做了什么、结果如何」。把这些事件从客户端和服务端收集上来,一路送进数仓,就是埋点与数据管道要解决的事。这件事看起来简单:埋点嘛,不就是客户端打几个日志、服务端写几行记录?但它有两个真实的坑。第一个坑是信源不可靠:客户端可以被改、会丢包、会重复上报;服务端才是权威。第二个坑是流量不可控:一个 10 万在线的服,如果每条事件都单独发一个包,网关会被自己人的埋点打爆。这一节就把这两个坑填上——先分清两端埋点的边界,再用「攒批 + 去重」的事件模型和一条四段式管道把它们接住。
客户端埋点与服务端埋点
埋点有两个信源,边界必须分清,否则同一个事实会被算两遍、或者被伪造:
| 信源 | 报什么 | 举例 | 可信度 |
|---|---|---|---|
| 客户端 client | 玩家操作与体验 | 点击活动入口、进入副本、界面停留、充值发起 | 可被篡改,只作参考 |
| 服务端 server | 世界权威事实 | 登录成功、道具掉落、任务完成、充值到账 | 权威,结算只认它 |
规则只有一条:凡是涉及数值、结算、付费的事实,一律以服务端埋点为准;客户端埋点只用来补充「体验类」信息。 为什么?因为客户端上报的「我充值了 648 元」只是一个意图,钱到没到账、给了多少钻石,只有服务端在订单回调里说了算。如果运营拿客户端的充值事件去算流水,早晚会和支付渠道的账对不上。
反过来,客户端也有服务端看不见的东西:「活动弹窗停留了 12 秒才点关闭」这种体验数据,服务端根本没有感知。所以两端不是二选一,而是各报各的、事后用 EventID 和 PlayerID 关联。这也正是下一节事件模型要解决的事。
事件模型与批量上报
一条事件长这样(对应下面代码里的 Event):EventID(客户端生成的唯一号,去重键)、Name(事件名,如 login)、PlayerID、ServerID、TsMs、Source(client / server)、Props(键值对,如 {"level":"10"})。两个设计要点:
EventID是去重键,不是流水号。 客户端网络抖动时会重发,玩家断线重连后会补发;如果不去重,同一笔purchase会被数两遍,流水直接虚高。去重键必须由客户端在事件产生时生成,而不是服务端收到时生成——否则重发就是一条新事件。- 攒批上报,而不是一事件一包。 客户端不会每产生一条事件就发一个网络包,而是攒到一批(比如 20 条或 5 秒)再发一个
CmdTelemetryBatch 0x1301帧。服务端同理,把一帧内多条事件合并投递。这样把「10 万在线 × 每秒若干事件」的包量压下来,网关才扛得住。
批量帧复用的是卷一定稿的 wire 格式:固定头 11 字节(length(4) | cmd(2) | seq(4) | flags(1)),length = 7 + N,帧总长 11 + N,命令号 0x1301,ack 是 0x1302。复用同一套编解码,意味着埋点流量和玩法流量走同一条解码路径,只多一个分支,不多一套 bug。
数据管道:采集 → 缓冲 → 清洗 → 落库
事件进了服务端,要经过四段才能变成数仓里的一张表:
客户端 / 服务端
│ CmdTelemetryBatch 0x1301
▼
[采集 collect] gateway 收到帧,原样投递,不做业务判断
│
▼
[缓冲 buffer] NATS subject: aegis.telemetry.events.> 削峰 + 解耦
│
▼
[清洗 clean] 丢弃畸形事件、按 EventID 去重
│
▼
[落库 store] 按事件名聚合,写入数仓(ClickHouse / Doris)
逐段看:
- 采集:网关收到
0x1301帧后不做任何业务处理,只负责把原始字节投出去。保持「哑管道」很关键——一旦网关开始解析事件内容,它就从「连接层」变成了「业务层」,升级节奏会被埋点需求拖住。 - 缓冲:中间隔一个消息队列(这里是 NATS),把「上游的突发流量」和「下游的写入速度」解耦。活动开服瞬间事件量会翻几十倍,缓冲层负责把这个尖峰摊平。
- 清洗:丢掉畸形事件(没名字、没玩家),按
EventID去重。去重必须发生在落库之前,否则数仓里就有重复行,后面怎么算都是错的。 - 落库:把清洗后的事件按事件名聚合成计数或明细,写进数仓。这一层才和 6.1 的指标口径对接。
可运行产物:真连 NATS 的埋点管道
下面这段是 L2,真连本机的 NATS(nats://127.0.0.1:34222)跑。internal/analytics/telemetry.go 把四段管道写成一个文件:帧编解码、事件模型、Sink(落库)、Publisher(采集)与 Consumer(缓冲→清洗→落库):
// file: internal/analytics/telemetry.go
// tier: L2
// verify: go test ./internal/analytics/ -run TestTelemetry
//
// This file is the whole telemetry path in miniature: the frame codec (shared
// with gameplay traffic), the event model, and the four pipeline stages --
// collect, buffer, clean, store. Buffering is NATS; the other three are here.
package analytics
import (
"encoding/binary"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/nats-io/nats.go"
)
// SubjectPrefix is the NATS subject namespace for chapter six's pipeline. Every
// test publishes under it, so cleanup is a single unsubscribe.
const SubjectPrefix = "aegis.test.ch06."
const (
// HeaderSize is length(4) + cmd(2) + seq(4) + flags(1).
HeaderSize = 11
// MaxPayload bounds one frame so a buggy client cannot exhaust memory.
MaxPayload = 1 << 20
// CmdTelemetryBatch carries a batch of client/server events upstream.
CmdTelemetryBatch uint16 = 0x1301
// CmdTelemetryAck acknowledges a batch, echoing the accepted count.
CmdTelemetryAck uint16 = 0x1302
)
// Frame is the aegis wire frame from volume one: a fixed 11-byte header plus a
// payload. Telemetry reuses it unchanged, so the same decoder handles gameplay
// traffic and analytics traffic.
type Frame struct {
Cmd uint16
Seq uint32
Flags uint8
Payload []byte
}
// Encode serialises a frame: length = 7 + N, total = 11 + N.
func Encode(f Frame) ([]byte, error) {
if len(f.Payload) > MaxPayload {
return nil, fmt.Errorf("payload %d exceeds MaxPayload %d", len(f.Payload), MaxPayload)
}
buf := make([]byte, HeaderSize+len(f.Payload))
binary.BigEndian.PutUint32(buf[0:4], uint32(7+len(f.Payload)))
binary.BigEndian.PutUint16(buf[4:6], f.Cmd)
binary.BigEndian.PutUint32(buf[6:10], f.Seq)
buf[10] = f.Flags
copy(buf[HeaderSize:], f.Payload)
return buf, nil
}
// Decode parses a frame and checks that the length field agrees with the buffer.
func Decode(buf []byte) (Frame, error) {
if len(buf) < HeaderSize {
return Frame{}, fmt.Errorf("frame too short: %d", len(buf))
}
if length := binary.BigEndian.Uint32(buf[0:4]); int(length) != len(buf)-4 {
return Frame{}, fmt.Errorf("length %d does not match buffer %d", length, len(buf)-4)
}
payload := make([]byte, len(buf)-HeaderSize)
copy(payload, buf[HeaderSize:])
return Frame{binary.BigEndian.Uint16(buf[4:6]), binary.BigEndian.Uint32(buf[6:10]), buf[10], payload}, nil
}
// Source marks where an event was produced: the client reports what the player
// did, the server reports what the world did in response.
type Source string
const (
SourceClient Source = "client"
SourceServer Source = "server"
)
// Event is one tracked action. EventID is client-generated and is the dedup
// key: the same event re-sent after a reconnect must not be counted twice.
type Event struct {
EventID string `json:"event_id"`
Name string `json:"name"`
PlayerID uint64 `json:"player_id"`
ServerID uint16 `json:"server_id"`
TsMs int64 `json:"ts_ms"`
Source Source `json:"source"`
Props map[string]string `json:"props,omitempty"`
}
// TelemetryBatch is the payload of a CmdTelemetryBatch frame.
type TelemetryBatch struct {
BatchID string `json:"batch_id"`
Events []Event `json:"events"`
}
// Encode wraps a batch in a CmdTelemetryBatch frame.
func (b TelemetryBatch) Encode() ([]byte, error) {
payload, err := json.Marshal(b)
if err != nil {
return nil, err
}
return Encode(Frame{Cmd: CmdTelemetryBatch, Seq: 1, Payload: payload})
}
// DecodeBatch parses a frame and requires it to be a telemetry batch.
func DecodeBatch(buf []byte) (TelemetryBatch, error) {
f, err := Decode(buf)
if err != nil {
return TelemetryBatch{}, err
}
if f.Cmd != CmdTelemetryBatch {
return TelemetryBatch{}, fmt.Errorf("unexpected cmd 0x%04X", f.Cmd)
}
var b TelemetryBatch
if err := json.Unmarshal(f.Payload, &b); err != nil {
return TelemetryBatch{}, err
}
return b, nil
}
// Sink is the "落库" stage: an in-memory aggregation of cleaned events. In
// production it would be ClickHouse or Doris; the clean rules are the same.
type Sink struct {
mu sync.Mutex
Counts map[string]int // event name -> cleaned count
Seen map[string]bool // dedup keys
DropDup int // duplicate EventIDs dropped
DropBad int // malformed events dropped
}
// NewSink returns an empty sink.
func NewSink() *Sink {
return &Sink{Counts: map[string]int{}, Seen: map[string]bool{}}
}
// Clean applies the clean rules -- drop malformed events and duplicate
// EventIDs -- and aggregates the survivors by name. It is the "清洗 -> 落库"
// hop and returns how many events it accepted and dropped.
func (s *Sink) Clean(b TelemetryBatch) (accepted, dropped int) {
s.mu.Lock()
defer s.mu.Unlock()
for _, e := range b.Events {
if e.Name == "" || e.PlayerID == 0 {
s.DropBad++
dropped++
continue
}
if s.Seen[e.EventID] {
s.DropDup++
dropped++
continue
}
s.Seen[e.EventID] = true
s.Counts[e.Name]++
accepted++
}
return accepted, dropped
}
// Publisher is the "采集" stage: it encodes a batch into a CmdTelemetryBatch
// frame before publishing, so the bytes on NATS are what a gateway would send.
type Publisher struct{ nc *nats.Conn }
// NewPublisher wraps a NATS connection.
func NewPublisher(nc *nats.Conn) *Publisher { return &Publisher{nc: nc} }
// Publish encodes and sends a batch to subject.
func (p *Publisher) Publish(subject string, b TelemetryBatch) error {
frame, err := b.Encode()
if err != nil {
return err
}
return p.nc.Publish(subject, frame)
}
// Consumer is the "缓冲 -> 清洗 -> 落库" stage: it drains a NATS subscription,
// decodes each frame, and feeds the batch to a Sink.
type Consumer struct {
sub *nats.Subscription
sink *Sink
}
// Subscribe creates a synchronous subscription and flushes it, so a publish
// issued after this call is guaranteed to be seen by this consumer.
func Subscribe(nc *nats.Conn, subject string, sink *Sink) (*Consumer, error) {
sub, err := nc.SubscribeSync(subject)
if err != nil {
return nil, err
}
if err := nc.Flush(); err != nil {
return nil, err
}
return &Consumer{sub: sub, sink: sink}, nil
}
// Run drains n messages within timeout, decoding and cleaning each.
func (c *Consumer) Run(n int, timeout time.Duration) (accepted, dropped int, err error) {
for i := 0; i < n; i++ {
msg, err := c.sub.NextMsg(timeout)
if err != nil {
return accepted, dropped, fmt.Errorf("waiting for message %d: %w", i, err)
}
b, err := DecodeBatch(msg.Data)
if err != nil {
return accepted, dropped, fmt.Errorf("decode batch %d: %w", i, err)
}
a, d := c.sink.Clean(b)
accepted += a
dropped += d
}
return accepted, dropped, nil
}
// Close unsubscribes the consumer.
func (c *Consumer) Close() error { return c.sub.Unsubscribe() }
测试 internal/analytics/telemetry_test.go 发两批事件:第一批是正常会话,第二批故意重发 e1(重复)并夹带一条空名事件(畸形)。关键断言如下:
// snippet: internal/analytics/telemetry_test.go (节选)
accepted, dropped, err := consumer.Run(2, 3*time.Second)
if err != nil {
t.Fatalf("consume: %v", err)
}
if accepted != 4 || dropped != 2 {
t.Fatalf("accepted=%d dropped=%d, want 4 and 2", accepted, dropped)
}
if sink.DropDup != 1 || sink.DropBad != 1 {
t.Fatalf("dropped(dup=%d, bad=%d), want (1, 1)", sink.DropDup, sink.DropBad)
}
if sink.Counts["login"] != 2 || sink.Counts["level_up"] != 1 || sink.Counts["purchase"] != 1 {
t.Fatalf("counts = %v, want login:2 level_up:1 purchase:1", sink.Counts)
}
本机实跑结果(go test ./internal/analytics/ -run TestTelemetry -v):
=== RUN TestTelemetryFrameRoundTrip
--- PASS: TestTelemetryFrameRoundTrip (0.00s)
=== RUN TestTelemetryPipelineOverNATS
--- PASS: TestTelemetryPipelineOverNATS (0.04s)
PASS
ok aegis/internal/analytics 1.488s
这条测试守住了管道的两条底线:accepted=4, dropped=2 说明六条事件里恰好两条被剔除(一条重复、一条畸形),Counts["login"]=2 说明 e1 只被计了一次。测试用的是独立 subject 前缀 aegis.test.ch06.,跑完即 Unsubscribe;NATS 是核心模式的非持久化消息,没有落盘数据,因此取消订阅就是全部清理,不会污染别的消费者。
本节小结
这一节确立了三件事:
- 两端埋点边界分明:涉及数值、结算、付费的事实只认服务端,客户端只补充体验类事件,两端靠
EventID/PlayerID关联。 - 事件模型的两个关键设计是「
EventID做去重键」和「攒批上报(CmdTelemetryBatch 0x1301)」。前者防重复上报,后者防把网关打爆。 - 管道分四段:采集(哑投递)、缓冲(NATS 削峰解耦)、清洗(去重 + 丢畸形)、落库(聚合入数仓)。清洗必须在落库之前,否则错的是整张表。
原始事件进了数仓,接下来的问题就是:怎么用它们回答运营最关心的三个问题——玩家第二天还来不来、愿不愿意花钱、活动每一步漏掉了多少人。 下一节就用构造数据把留存、付费和漏斗一次算清。
阅读导航:上一节:6.1 指标体系与口径 · 下一节:6.3 留存、付费与活动转化分析 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。