《游戏服务端架构·核心系统与契约》4.3 世界事件与全服广播

世界 Boss 刷新、赛季结束、全服公告看起来都是一条消息,扇出代价却相差几个数量级。本节先算连接数、消息大小与频率的乘积,再按全服、战区、地图、房间四级拆通道,讨论分区订阅如何隔离热点、有界日志如何支撑补发,以及去重、乱序和断线补发分别该怎么处理。

一条公告,三种代价

《群岛纪元》世界 Boss 刷新时,四万名在线玩家(示例值)要在同一分钟收到“Boss 已出现”;《裂隙战场》一局十人,击杀播报只发给本局;《泡泡工坊》一个房间八人,只发房间内。三条消息的内容长度差不多,扇出却从 8 到 40000,差了三个数量级。

更麻烦的是频率。世界 Boss 一分钟一次可以全量广播,但“Boss 血量变化”如果也按十赫兹发给四万人,就是每秒四十万条消息(示例值),网关会先于游戏逻辑崩溃。广播方案不是按事件重要性选,而是按扇出量选。

先算扇出,再选通道

广播成本同样是乘法:

每次广播副本数 = 订阅者数量 C
每秒字节数 ≈ C × 单条字节数 B × 频率 f

按北辰三款产品估算(示例值,来自容量假设而非线上测量):

事件CBf预期字节/秒通道
全服公告400002001/60133 KB网关级广播,网关再复制
战区 Boss 刷新80001601/3043 KB战区订阅
地图战斗播报300120272 KB地图订阅
房间击杀播报88053.2 KB房间订阅

注意第一行:四万人不是让事件总线建立四万个订阅者,而是只发给几十个网关,由网关在本机复制给连接。事件总线的扇出单位应该是网关,不是玩家连接。 否则一次公告就是四万次跨进程写,网络和序列化都会被放大。

四级通道:让热点停在最小范围

级别分区键订阅者顺序要求典型事件
全服空网关、全局服务全局单调公告、赛季结束、维护
战区战区 ID战区网关、战区服务战区内单调跨地图活动、排名结算
地图地图/分片 ID地图工作进程地图内单调Boss、据点、天气
房间房间 ID房间进程、观战者房间内单调击杀、回合、结算

分区的意义不是“分类好看”,而是把热点限制在最小订阅集合内。一张地图的战斗播报不应该经过战区总线,更不应该被全服网关看到;反过来,全服公告也不该逐个地图发布,否则地图进程成了广播放大器。

北辰工作室的规则是:事件在产生它的最内层分区发布,需要跨层时由上一层显式订阅并聚合。 地图事件要上升为战区事件,必须由地图服务主动发一条摘要,而不是把原始事件透传。

幂等与顺序:至少一次,幂等应用

广播系统通常只能做到“至少一次”:网络重试、网关重连、消费者重启都会带来重复。与其追求“恰好一次”,不如把消费端做成幂等:

  • 去重键是事件 ID,不是分区序号。 同一个事件可能从 backlog 和新消息两条路径到达,ID 相同就只应用一次。
  • 顺序按分区单调。 全服、战区、地图、房间各自维护序列号;跨分区不承诺顺序,业务也不该依赖。
  • 缺口触发补发。 消费者发现 seq 跳号时,不能跳过,也不能阻塞等待;应该向总线请求 Backlog(last),按序补齐后再继续。
  • 日志有界。 补发日志不能无限增长;保留最近 N 条,超出后要求消费者拉全量快照。

这也是 Reader.Apply 的判据:重复返回 ErrDuplicateEvent,旧序号返回 ErrOutOfOrder,跳号返回 ErrSequenceGap。三种错误对应三种处理,不能合并成一个“丢弃”。

可运行片段:分级、分区与去重

下面的 internal/globalevent 把四级通道、分区订阅、有界补发日志和消费端去重写成可测试的包。它不依赖具体消息队列,方便替换传输层。

// file: internal/globalevent/globalevent.go
// tier: L1
// verify: go test ./internal/globalevent/
package globalevent

import (
	"errors"
	"fmt"
)

var (
	ErrBadLog          = errors.New("globalevent: log size must be positive")
	ErrBadTier         = errors.New("globalevent: unknown tier")
	ErrBadPartition    = errors.New("globalevent: partition does not match tier")
	ErrEmptyID         = errors.New("globalevent: empty event id")
	ErrEmptySubscriber = errors.New("globalevent: empty subscriber id")
	ErrDuplicateEvent  = errors.New("globalevent: event id already published")
	ErrOutOfOrder      = errors.New("globalevent: sequence did not advance")
	ErrSequenceGap     = errors.New("globalevent: sequence gap requires backlog replay")
	ErrBadSequence     = errors.New("globalevent: sequence must be positive")
)

// Tier selects the fanout channel. Global reaches gateway-level subscribers
// once; the gateway then copies to its local sessions. Theater, map and room
// events go only to subscribers of that exact partition.
type Tier int

const (
	TierGlobal Tier = iota
	TierTheater
	TierMap
	TierRoom
)

func (t Tier) String() string {
	switch t {
	case TierGlobal:
		return "global"
	case TierTheater:
		return "theater"
	case TierMap:
		return "map"
	case TierRoom:
		return "room"
	default:
		return "unknown"
	}
}

func (t Tier) valid() bool { return t >= TierGlobal && t <= TierRoom }

// Event is one immutable broadcast. Seq is assigned by the bus per partition;
// ID is assigned by the producer and is the dedupe key across retries.
type Event struct {
	ID        string
	Tier      Tier
	Partition string
	Seq       uint64
	Payload   string
}

type partitionKey struct {
	tier      Tier
	partition string
}

func keyFor(tier Tier, partition string) (partitionKey, error) {
	if !tier.valid() {
		return partitionKey{}, fmt.Errorf("%w: %d", ErrBadTier, tier)
	}
	if tier == TierGlobal {
		if partition != "" {
			return partitionKey{}, fmt.Errorf("%w: global must not have a partition", ErrBadPartition)
		}
	} else if partition == "" {
		return partitionKey{}, fmt.Errorf("%w: %s needs a partition", ErrBadPartition, tier)
	}
	return partitionKey{tier: tier, partition: partition}, nil
}

// Bus is the publication side. It keeps a bounded per-partition log so a
// reconnecting subscriber can replay what it missed without a full snapshot.
type Bus struct {
	maxLog int
	subs   map[partitionKey]map[string]struct{}
	seen   map[string]bool
	logs   map[partitionKey][]Event
	next   map[partitionKey]uint64
}

func NewBus(maxLog int) (*Bus, error) {
	if maxLog <= 0 {
		return nil, ErrBadLog
	}
	return &Bus{
		maxLog: maxLog,
		subs:   make(map[partitionKey]map[string]struct{}),
		seen:   make(map[string]bool),
		logs:   make(map[partitionKey][]Event),
		next:   make(map[partitionKey]uint64),
	}, nil
}

// Subscribe registers one gateway, worker or session for a channel. A global
// subscription is a gateway subscription, not one per player.
func (b *Bus) Subscribe(tier Tier, partition, subscriber string) error {
	if subscriber == "" {
		return ErrEmptySubscriber
	}
	k, err := keyFor(tier, partition)
	if err != nil {
		return err
	}
	if b.subs[k] == nil {
		b.subs[k] = make(map[string]struct{})
	}
	b.subs[k][subscriber] = struct{}{}
	return nil
}

// Unsubscribe removes one subscriber; removing an unknown one is a no-op.
func (b *Bus) Unsubscribe(tier Tier, partition, subscriber string) error {
	k, err := keyFor(tier, partition)
	if err != nil {
		return err
	}
	delete(b.subs[k], subscriber)
	return nil
}

// Publish assigns the next sequence for the partition and returns the number of
// subscribers that must receive it. A repeated event ID is rejected before it
// can consume a sequence number.
func (b *Bus) Publish(e Event) (Event, int, error) {
	if e.ID == "" {
		return Event{}, 0, ErrEmptyID
	}
	k, err := keyFor(e.Tier, e.Partition)
	if err != nil {
		return Event{}, 0, err
	}
	if b.seen[e.ID] {
		return Event{}, 0, fmt.Errorf("%w: %s", ErrDuplicateEvent, e.ID)
	}
	b.next[k]++
	e.Seq = b.next[k]
	b.seen[e.ID] = true
	b.logs[k] = append(b.logs[k], e)
	if len(b.logs[k]) > b.maxLog {
		b.logs[k] = b.logs[k][len(b.logs[k])-b.maxLog:]
	}
	return e, len(b.subs[k]), nil
}

// Backlog returns events with Seq greater than after, oldest first. The caller
// then applies them through a Reader to close the reconnect window.
func (b *Bus) Backlog(tier Tier, partition string, after uint64) ([]Event, error) {
	k, err := keyFor(tier, partition)
	if err != nil {
		return nil, err
	}
	out := make([]Event, 0, len(b.logs[k]))
	for _, e := range b.logs[k] {
		if e.Seq > after {
			out = append(out, e)
		}
	}
	return out, nil
}

// Fanout is the pre-send cost estimate: subscriber count times bytes per copy.
// Compare it with the gateway's per-second budget before adding a channel.
type Fanout struct {
	Subscribers int
	Bytes       int64
}

func (f Fanout) TotalBytes() int64 { return int64(f.Subscribers) * f.Bytes }

func (b *Bus) Estimate(tier Tier, partition string, bytesPerCopy int64) (Fanout, error) {
	k, err := keyFor(tier, partition)
	if err != nil {
		return Fanout{}, err
	}
	if bytesPerCopy < 0 {
		return Fanout{}, fmt.Errorf("globalevent: negative payload size")
	}
	return Fanout{Subscribers: len(b.subs[k]), Bytes: bytesPerCopy}, nil
}

// Reader applies events for one subscriber. It rejects duplicates, gaps and
// stale sequences; a gap means the caller should fetch Backlog(last) first.
type Reader struct {
	seen map[string]bool
	last map[partitionKey]uint64
}

func NewReader() *Reader {
	return &Reader{seen: make(map[string]bool), last: make(map[partitionKey]uint64)}
}

// Apply advances the reader by exactly one sequence on one partition.
func (r *Reader) Apply(e Event) error {
	if e.ID == "" {
		return ErrEmptyID
	}
	if e.Seq == 0 {
		return ErrBadSequence
	}
	k, err := keyFor(e.Tier, e.Partition)
	if err != nil {
		return err
	}
	if r.seen[e.ID] {
		return fmt.Errorf("%w: %s", ErrDuplicateEvent, e.ID)
	}
	last := r.last[k]
	switch {
	case last == 0:
		// First event on a partition may start anywhere: the reader may have
		// restored a checkpoint before subscribing.
	case e.Seq == last+1:
	case e.Seq <= last:
		return fmt.Errorf("%w: got %d after %d", ErrOutOfOrder, e.Seq, last)
	default:
		return fmt.Errorf("%w: got %d after %d", ErrSequenceGap, e.Seq, last)
	}
	r.seen[e.ID] = true
	r.last[k] = e.Seq
	return nil
}

// Last returns the last applied sequence on a partition.
func (r *Reader) Last(tier Tier, partition string) (uint64, error) {
	k, err := keyFor(tier, partition)
	if err != nil {
		return 0, err
	}
	return r.last[k], nil
}

几个设计点:

  • keyFor 把“全服不能带分区、其他级别必须有分区”变成构造期错误。分区键错了,热点会被广播到错误范围,事后很难从日志看出来。
  • Publish 先查重再分配序号。重复事件不消耗序号,否则一次重试会在分区里留下一个永远补不上的空洞。
  • 补发日志按分区有界。 maxLog 是运维参数:太小会让断线稍久的玩家只能拉快照,太大则内存随分区数增长。
  • Reader 去重按事件 ID、排序按分区序号。两者分开,才能同时处理“重复到达”和“乱序到达”。

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/globalevent
$ go vet ./internal/globalevent/...
$ go test -count=1 ./internal/globalevent/
ok  	gsarchlab/internal/globalevent

6 个表驱动测试全部通过,覆盖级别与分区隔离、序号分配与重复发布、订阅校验、有界补发、消费端去重与跳号、扇出估算。gofmt -l 与 go vet 均无输出。

失败模式与兜底

失败表现兜底
扇出误算公告直接按玩家连接广播以网关为订阅单位,网关本机复制
热点穿透地图事件被全服网关消费分区订阅,跨层必须显式聚合
重复消费同一公告弹两次事件 ID 去重,消费端幂等
乱序到达后发事件先应用分区序号单调,旧序号拒绝
跳号中间事件丢失请求 Backlog(last) 补齐
日志无限增长分区越多内存越大有界日志 + 快照兜底
网关重连风暴所有网关同时补发补发限速,按分区错峰

最容易被低估的是“网关重连风暴”。四百个网关同时重连,每个都请求最近十分钟的公告,补发流量可能超过正常广播本身。补发必须有速率上限和优先级:全服公告优先,地图播报可以降级为快照。

常见错误

第一,把事件总线的订阅者设成玩家连接。四万连接意味着四万次跨进程投递;正确单位是网关或房间进程。

第二,所有事件走同一个主题。一个地图 Boss 事件打满全服通道,真正紧急的维护公告被挤在后面。

第三,用“丢弃重复”代替幂等。重复消息可能携带不同状态;消费端必须按事件 ID 判断是否已应用,而不是看到相似内容就丢。

第四,跳过缺口继续消费。跳号意味着状态可能不完整,继续应用会让后续事件基于错误基线;应先补发或拉快照。

第五,补发日志不设上限。分区数量乘以保留条数就是内存下限,上线前要按最坏分区数估算。

小结

全服广播的选型顺序和 AOI 一样:先算扇出,再选通道。全服、战区、地图、房间四级不是分类,而是把热点限制在最小订阅集合内;事件总线以网关为扇出单位,网关再面向连接复制。重复、乱序、断线补发分别对应去重、单调序号和有界日志,三者不能互相替代。

至此,第 4 章回答了“世界怎么切、玩家怎么看见彼此、全服事件怎么广播”。下一章进入战斗服:房间内、旁路和托管三种模型分别适合什么品类,以及它们和本章的分线、副本如何衔接。

阅读导航:上一节:4.2 分线、副本与主城/野外 · 下一节:5.1 战斗服模型:房间内、旁路与托管 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查