一条公告,三种代价
《群岛纪元》世界 Boss 刷新时,四万名在线玩家(示例值)要在同一分钟收到“Boss 已出现”;《裂隙战场》一局十人,击杀播报只发给本局;《泡泡工坊》一个房间八人,只发房间内。三条消息的内容长度差不多,扇出却从 8 到 40000,差了三个数量级。
更麻烦的是频率。世界 Boss 一分钟一次可以全量广播,但“Boss 血量变化”如果也按十赫兹发给四万人,就是每秒四十万条消息(示例值),网关会先于游戏逻辑崩溃。广播方案不是按事件重要性选,而是按扇出量选。
先算扇出,再选通道
广播成本同样是乘法:
每次广播副本数 = 订阅者数量 C
每秒字节数 ≈ C × 单条字节数 B × 频率 f
按北辰三款产品估算(示例值,来自容量假设而非线上测量):
| 事件 | C | B | f | 预期字节/秒 | 通道 |
|---|---|---|---|---|---|
| 全服公告 | 40000 | 200 | 1/60 | 133 KB | 网关级广播,网关再复制 |
| 战区 Boss 刷新 | 8000 | 160 | 1/30 | 43 KB | 战区订阅 |
| 地图战斗播报 | 300 | 120 | 2 | 72 KB | 地图订阅 |
| 房间击杀播报 | 8 | 80 | 5 | 3.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 战斗服模型:房间内、旁路与托管 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。