《游戏服务端编程实践》5.2.3 动态转发与广播

区分单播、组播、房间广播、全服广播与区域广播的语义和扇出代价,给出广播边界、消息合并与限流方法,分析慢消费者在丢弃、踢人、阻塞之间的取舍以及不同消息类型的处理策略,并说明跨网关广播的去重与过期规则;附可运行 Go Hub 实现和队列满丢弃计数测试。

一、单播、组播、广播与区域广播

语义接收者典型消息主要代价
单播一个玩家私聊、个人结算一次查找加一次入队
组播队伍或公会成员队伍消息、公会公告成员列表膨胀
房间广播房间内玩家帧同步、准备状态房间人数乘发送频率
全服广播所有在线玩家公告、活动开启在线人数乘消息大小
区域广播AOI 九宫格或兴趣集移动、外观空间查询加去重

语义不是 API 名字,而是“接收者集合如何确定”。集合越大,扇出越高;同一条消息发给 N 个人,就产生 N 次入队。广播设计的第一句话应该是“最多发给谁”,而不是“怎么发”。

二、广播范围必须有边界

全服广播不能真的遍历所有连接。常见边界包括按场景或地图分频道、按兴趣网格只发九宫格、按活动实例分房间。每条广播都要有上限:单房间最多 200 人、单条世界消息最多 10 万接收者,超过阈值就转成分批拉取。

边界划分依据适用场景
地图或线路场景 ID世界频道、地图事件
兴趣网格坐标格移动、技能、外观
房间或对局roomID帧同步、结算
全服频道无公告、维护

“全服”也要能降级:在线 50 万时,一条公告的扇出是 50 万次入队;按网关分批、每批间隔 50ms,比一次性打满所有发送队列安全。

三、扇出放大与消息合并

一条世界消息乘在线人数,只是第一层放大;如果每个接收者还要查数据库、渲染模板,成本会再乘一层。控制手段有四条:

  • 序列化一次,广播时只传同一份字节切片,禁止每个接收者重新 marshal。
  • 状态同步合并:同一实体 100ms 内多次变化只发最后一帧,客户端按 tick 应用。
  • 限流:世界频道每秒最多 N 条,超出排队或拒绝。
  • 分批:按网关、按房间切片,批间让出 CPU,避免长时间占用。

对必须到达的消息(支付结果、邮件),不要走广播通道,改为持久化、拉取加单播确认。

四、慢消费者与背压

每个连接一个发送队列,队列满时只有三种策略:

策略行为适用消息风险
丢弃不入队并计数位置、血量等可重算状态客户端短暂落后
踢下线关闭连接协议错误、持续积压误伤弱网玩家
阻塞等待队列可写绝不允许丢弃的关键消息拖垮整个网关线程

结论是分类型:状态同步可丢,聊天和交易结果不可丢。不可丢的消息不要塞进同一个有界队列,应该走可靠投递,或落库后由客户端拉取。队列满计数必须暴露成指标,按玩家和网关聚合;没有计数,丢消息就是静默故障。

五、跨网关广播的路径

玩家分散在多个网关,全服公告要先到各网关,再由网关本地扇出:

  1. 广播服务为消息分配 (msgID, version)。
  2. 按网关列表分批投递,每个网关只收一次。
  3. 网关在本地快照成员并逐条入队,不回到中心服务。
  4. 网关按 msgID 去重,重试不会让玩家收到两遍。

顺序只在单个成员队列内有保证;跨玩家的全局顺序没有意义,也不要用全局锁去换。跨网关消息要带过期时间,网关离线恢复后不应补发已经过期的活动公告。

六、失败场景:锁内广播拖垮网关

某网关在持有全局连接锁的情况下遍历所有连接并调用 Write。一个弱网玩家的 TCP 发送缓冲区满,Write 阻塞,锁一直不释放,其他玩家的心跳、登录、战斗消息全部排队,最终整个网关超时。

修复分三步:广播前在锁内只做成员快照,发送在锁外进行;每个连接使用有界队列和非阻塞入队;队列满时按消息类型丢弃或踢人,并累加计数。任何在锁内做 I/O 的代码,都应该视为故障源。

七、Go 实现:Hub

Hub 维护玩家到 Member 的映射和房间成员表;BroadcastRoom 与 BroadcastAll 先取快照,再在锁外逐条非阻塞入队。

// file: ch05/forward/broadcast.go
// tier: L1
// verify: go test ./ch05/forward/

package forward

import (
	"sync"
	"sync/atomic"
)

// Member 是一个在线玩家在 Hub 中的投递队列;队列满时丢弃并计数,绝不阻塞发送方。
type Member struct {
	PlayerID uint64
	RoomID   uint64
	queue    chan []byte
	dropped  atomic.Uint64
	closed   atomic.Bool
}

func (m *Member) Messages() <-chan []byte { return m.queue }
func (m *Member) Dropped() uint64         { return m.dropped.Load() }
func (m *Member) Closed() bool            { return m.closed.Load() }

func (m *Member) enqueue(msg []byte) bool {
	if m.closed.Load() {
		return false
	}
	select {
	case m.queue <- msg:
		return true
	default:
		m.dropped.Add(1)
		return false
	}
}

// Hub 维护 playerID 到 Member 的映射和房间成员表;广播先取快照,再在锁外逐条投递。
type Hub struct {
	mu      sync.RWMutex
	size    int
	members map[uint64]*Member
	rooms   map[uint64]map[uint64]struct{}
}

func NewHub(queueSize int) *Hub {
	if queueSize <= 0 {
		queueSize = 1
	}
	return &Hub{
		size:    queueSize,
		members: make(map[uint64]*Member),
		rooms:   make(map[uint64]map[uint64]struct{}),
	}
}

// Join 注册或顶替玩家连接;旧 Member 被标记关闭,写协程应尽快退出。
func (h *Hub) Join(playerID, roomID uint64) *Member {
	h.mu.Lock()
	defer h.mu.Unlock()
	if old, ok := h.members[playerID]; ok {
		old.closed.Store(true)
		h.removeFromRoomLocked(old)
	}
	m := &Member{PlayerID: playerID, RoomID: roomID, queue: make(chan []byte, h.size)}
	h.members[playerID] = m
	if roomID != 0 {
		if h.rooms[roomID] == nil {
			h.rooms[roomID] = make(map[uint64]struct{})
		}
		h.rooms[roomID][playerID] = struct{}{}
	}
	return m
}

func (h *Hub) Leave(playerID uint64) {
	h.mu.Lock()
	defer h.mu.Unlock()
	m, ok := h.members[playerID]
	if !ok {
		return
	}
	m.closed.Store(true)
	h.removeFromRoomLocked(m)
	delete(h.members, playerID)
}

func (h *Hub) removeFromRoomLocked(m *Member) {
	if m.RoomID == 0 {
		return
	}
	members := h.rooms[m.RoomID]
	delete(members, m.PlayerID)
	if len(members) == 0 {
		delete(h.rooms, m.RoomID)
	}
}

func (h *Hub) SendToPlayer(playerID uint64, msg []byte) bool {
	h.mu.RLock()
	m := h.members[playerID]
	h.mu.RUnlock()
	if m == nil {
		return false
	}
	return m.enqueue(msg)
}

func (h *Hub) BroadcastRoom(roomID uint64, msg []byte) int {
	h.mu.RLock()
	snapshot := make([]*Member, 0, len(h.rooms[roomID]))
	for playerID := range h.rooms[roomID] {
		if m := h.members[playerID]; m != nil {
			snapshot = append(snapshot, m)
		}
	}
	h.mu.RUnlock()
	sent := 0
	for _, m := range snapshot {
		if m.enqueue(msg) {
			sent++
		}
	}
	return sent
}

func (h *Hub) BroadcastAll(msg []byte) int {
	h.mu.RLock()
	snapshot := make([]*Member, 0, len(h.members))
	for _, m := range h.members {
		snapshot = append(snapshot, m)
	}
	h.mu.RUnlock()
	sent := 0
	for _, m := range snapshot {
		if m.enqueue(msg) {
			sent++
		}
	}
	return sent
}

// Dropped 返回在线玩家因队列满被丢弃的消息数;玩家已离线时第二个返回值为 false。
func (h *Hub) Dropped(playerID uint64) (uint64, bool) {
	h.mu.RLock()
	m := h.members[playerID]
	h.mu.RUnlock()
	if m == nil {
		return 0, false
	}
	return m.Dropped(), true
}

八、测试:慢连接、丢弃计数与房间边界

测试验证慢连接不影响其他成员、队列满时丢弃并计数、房间广播不越界,以及离开或换房后不再收到旧房间消息。

// file: ch05/forward/broadcast_test.go
// tier: L1
// verify: go test ./ch05/forward/

package forward

import "testing"

func TestSlowMemberDoesNotBlockOthers(t *testing.T) {
	hub := NewHub(1)
	slow := hub.Join(1, 10)
	fast := hub.Join(2, 10)
	if got := hub.BroadcastAll([]byte("m1")); got != 2 {
		t.Fatalf("first broadcast=%d, want 2", got)
	}
	if msg := <-fast.Messages(); string(msg) != "m1" {
		t.Fatalf("fast got %q, want m1", msg)
	}
	if got := hub.BroadcastAll([]byte("m2")); got != 1 {
		t.Fatalf("second broadcast=%d, want 1", got)
	}
	if slow.Dropped() != 1 || fast.Dropped() != 0 {
		t.Fatalf("dropped slow=%d fast=%d, want 1/0", slow.Dropped(), fast.Dropped())
	}
	if msg := <-slow.Messages(); string(msg) != "m1" {
		t.Fatalf("slow got %q, want queued m1", msg)
	}
	if msg := <-fast.Messages(); string(msg) != "m2" {
		t.Fatalf("fast got %q, want m2", msg)
	}
}

func TestQueueFullDropsAreCounted(t *testing.T) {
	hub := NewHub(1)
	m := hub.Join(1, 0)
	if !hub.SendToPlayer(1, []byte("a")) {
		t.Fatal("first send should be queued")
	}
	if hub.SendToPlayer(1, []byte("b")) {
		t.Fatal("second send should be dropped")
	}
	if hub.SendToPlayer(1, []byte("c")) {
		t.Fatal("third send should be dropped")
	}
	if n, ok := hub.Dropped(1); !ok || n != 2 {
		t.Fatalf("dropped=%d/%v, want 2/true", n, ok)
	}
	if got := len(m.Messages()); got != 1 {
		t.Fatalf("queued=%d, want 1", got)
	}
	if _, ok := hub.Dropped(99); ok {
		t.Fatal("offline player should not report drops")
	}
}

func TestBroadcastRoomIsBounded(t *testing.T) {
	hub := NewHub(4)
	p1 := hub.Join(1, 10)
	p2 := hub.Join(2, 10)
	p3 := hub.Join(3, 20)
	if got := hub.BroadcastRoom(10, []byte("room")); got != 2 {
		t.Fatalf("room broadcast=%d, want 2", got)
	}
	if got := len(p3.Messages()); got != 0 {
		t.Fatalf("outsider queued=%d, want 0", got)
	}
	if got := hub.BroadcastAll([]byte("all")); got != 3 {
		t.Fatalf("all broadcast=%d, want 3", got)
	}
	if got := len(p1.Messages()); got != 2 {
		t.Fatalf("p1 queued=%d, want room+all", got)
	}
	if msg := <-p1.Messages(); string(msg) != "room" {
		t.Fatalf("p1 first=%q, want room", msg)
	}
	if msg := <-p1.Messages(); string(msg) != "all" {
		t.Fatalf("p1 second=%q, want all", msg)
	}
	if got := hub.BroadcastRoom(99, []byte("x")); got != 0 {
		t.Fatalf("empty room broadcast=%d, want 0", got)
	}
	hub.Join(3, 10)
	if got := hub.BroadcastRoom(20, []byte("y")); got != 0 {
		t.Fatalf("old room broadcast=%d, want 0 after move", got)
	}
	hub.Leave(2)
	if got := hub.BroadcastRoom(10, []byte("z")); got != 2 {
		t.Fatalf("room broadcast after leave=%d, want 2", got)
	}
	_ = p2
}

九、运行测试

cd .local/checks/gs-practice-lab && gofmt -l ch05/forward && go vet ./ch05/forward/ && go test ./ch05/forward/ -count=1
ok  	gspractice/ch05/forward	0.565s

小结

广播的代价随接收者数量线性增长,所以先定边界再谈实现。状态类消息可以合并和丢弃,关键消息要走可靠通道;慢消费者不能阻塞发送方,队列满必须计数并可观测;跨网关广播要按消息 ID 去重、按过期时间丢弃,顺序只保证在单个成员队列内。

常见错误与边界

  • 全服广播无上限,在线人数一涨就打满发送队列。
  • 在全局锁内做广播和网络写,一个慢连接拖垮整个网关。
  • 所有消息共用一条有界队列,关键消息被状态同步挤掉。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist