一、单播、组播、广播与区域广播
| 语义 | 接收者 | 典型消息 | 主要代价 |
|---|---|---|---|
| 单播 | 一个玩家 | 私聊、个人结算 | 一次查找加一次入队 |
| 组播 | 队伍或公会成员 | 队伍消息、公会公告 | 成员列表膨胀 |
| 房间广播 | 房间内玩家 | 帧同步、准备状态 | 房间人数乘发送频率 |
| 全服广播 | 所有在线玩家 | 公告、活动开启 | 在线人数乘消息大小 |
| 区域广播 | AOI 九宫格或兴趣集 | 移动、外观 | 空间查询加去重 |
语义不是 API 名字,而是“接收者集合如何确定”。集合越大,扇出越高;同一条消息发给 N 个人,就产生 N 次入队。广播设计的第一句话应该是“最多发给谁”,而不是“怎么发”。
二、广播范围必须有边界
全服广播不能真的遍历所有连接。常见边界包括按场景或地图分频道、按兴趣网格只发九宫格、按活动实例分房间。每条广播都要有上限:单房间最多 200 人、单条世界消息最多 10 万接收者,超过阈值就转成分批拉取。
| 边界 | 划分依据 | 适用场景 |
|---|---|---|
| 地图或线路 | 场景 ID | 世界频道、地图事件 |
| 兴趣网格 | 坐标格 | 移动、技能、外观 |
| 房间或对局 | roomID | 帧同步、结算 |
| 全服频道 | 无 | 公告、维护 |
“全服”也要能降级:在线 50 万时,一条公告的扇出是 50 万次入队;按网关分批、每批间隔 50ms,比一次性打满所有发送队列安全。
三、扇出放大与消息合并
一条世界消息乘在线人数,只是第一层放大;如果每个接收者还要查数据库、渲染模板,成本会再乘一层。控制手段有四条:
- 序列化一次,广播时只传同一份字节切片,禁止每个接收者重新 marshal。
- 状态同步合并:同一实体 100ms 内多次变化只发最后一帧,客户端按 tick 应用。
- 限流:世界频道每秒最多 N 条,超出排队或拒绝。
- 分批:按网关、按房间切片,批间让出 CPU,避免长时间占用。
对必须到达的消息(支付结果、邮件),不要走广播通道,改为持久化、拉取加单播确认。
四、慢消费者与背压
每个连接一个发送队列,队列满时只有三种策略:
| 策略 | 行为 | 适用消息 | 风险 |
|---|---|---|---|
| 丢弃 | 不入队并计数 | 位置、血量等可重算状态 | 客户端短暂落后 |
| 踢下线 | 关闭连接 | 协议错误、持续积压 | 误伤弱网玩家 |
| 阻塞 | 等待队列可写 | 绝不允许丢弃的关键消息 | 拖垮整个网关线程 |
结论是分类型:状态同步可丢,聊天和交易结果不可丢。不可丢的消息不要塞进同一个有界队列,应该走可靠投递,或落库后由客户端拉取。队列满计数必须暴露成指标,按玩家和网关聚合;没有计数,丢消息就是静默故障。
五、跨网关广播的路径
玩家分散在多个网关,全服公告要先到各网关,再由网关本地扇出:
- 广播服务为消息分配
(msgID, version)。 - 按网关列表分批投递,每个网关只收一次。
- 网关在本地快照成员并逐条入队,不回到中心服务。
- 网关按
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 去重、按过期时间丢弃,顺序只保证在单个成员队列内。
常见错误与边界
- 全服广播无上限,在线人数一涨就打满发送队列。
- 在全局锁内做广播和网络写,一个慢连接拖垮整个网关。
- 所有消息共用一条有界队列,关键消息被状态同步挤掉。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。