《MMO 服务器开发》6.2 埋点与数据管道

口径定好了,原始事件从哪里来?这一节先分清客户端埋点与服务端埋点的边界,再把「攒一批、带 EventID 去重、用 CmdTelemetryBatch 0x1301 上报」的事件模型写清楚,最后真连 NATS 跑一条采集、缓冲、清洗、落库四段式管道,用断言证明重复与畸形事件都被正确剔除。

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 是核心模式的非持久化消息,没有落盘数据,因此取消订阅就是全部清理,不会污染别的消费者。

本节小结

这一节确立了三件事:

  1. 两端埋点边界分明:涉及数值、结算、付费的事实只认服务端,客户端只补充体验类事件,两端靠 EventID / PlayerID 关联。
  2. 事件模型的两个关键设计是「EventID 做去重键」和「攒批上报(CmdTelemetryBatch 0x1301)」。前者防重复上报,后者防把网关打爆。
  3. 管道分四段:采集(哑投递)、缓冲(NATS 削峰解耦)、清洗(去重 + 丢畸形)、落库(聚合入数仓)。清洗必须在落库之前,否则错的是整张表。

原始事件进了数仓,接下来的问题就是:怎么用它们回答运营最关心的三个问题——玩家第二天还来不来、愿不愿意花钱、活动每一步漏掉了多少人。 下一节就用构造数据把留存、付费和漏斗一次算清。

阅读导航:上一节:6.1 指标体系与口径 · 下一节:6.3 留存、付费与活动转化分析 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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