《游戏服务端编程实践》10.3.1 消息通道与订阅模型

从 Redis Pub/Sub 的至多一次语义出发,梳理聊天频道命名、发布与订阅连接分离、断线重连后的补订阅,并给出可测试的内存 Broker 实现与频道校验规则,帮助读者判断哪些消息能走 Pub/Sub、哪些必须换用持久化队列;文中测试覆盖多订阅者、退订与重连补订阅。

一、Pub/Sub 的语义:至多一次

Redis Pub/Sub 是一条广播通道:发布者把消息交给 Redis,Redis 立刻转发给当前在线的订阅者,然后就把消息忘掉。没有确认、没有重投、没有历史。发布命令返回的数字是当时成功入队的订阅者数量,不是“玩家已收到”。

这决定了它适合什么:世界频道喊话、公会公告、系统广播这类“晚到就没有意义”的消息。玩家离线三秒错过一条聊天,体验上可以接受;订单支付成功的事件错过一条,就是资损。

机制投递语义持久化断线补偿典型用途
Pub/Sub至多一次无无在线聊天、通知广播
Stream + 消费组至少一次有,可回放有 Pending订单事件、奖励发放
List + BRPOP至少一次有需自行实现简单任务队列
本地内存 fanout至多一次无无单机测试、同进程事件

表中的“至多一次”不是实现缺陷,而是设计取舍。广播场景里,重复消息往往比丢消息更烦人;可靠场景里,丢消息不可接受。先确定语义,再选通道,顺序不能反。

二、频道命名:作用域与注入

频道名是路由键,也是权限边界。推荐三段式:

频道订阅者示例
chat:world所有网关世界频道
chat:guild:有该公会在线玩家的网关chat:guild:1024
chat:player:玩家当前所在网关chat:player:90001
chat:sys:broadcast所有聊天服系统公告

固定前缀让运维可以按模式统计和限流,冒号分段让 ID 不会和前缀粘连。最忌讳把客户端传来的字符串直接拼进频道名:玩家把公会名改成 7:admin,就可能订阅到本不属于自己的频道;如果名字里带换行或空格,还可能破坏协议帧。

通配订阅 PSUBSCRIBE chat:guild:* 只适合排障和监控,不要放进业务主路径。它会把匹配成本压到 Redis 单线程上,频道一多就拖慢所有发布者。

三、订阅连接与发布连接分离

发布连接可以放进连接池,由多个业务协程复用;订阅连接进入订阅模式后,在 RESP2 下不能执行普通命令,必须独占。把两者混在一条连接上,轻则收到 ERR only (P)SUBSCRIBE...,重则读循环被业务写操作打乱。

维度发布连接订阅连接
数量共享连接池,少量每个网关 1 到 N 条
状态无状态,随用随取订阅模式,不能跑普通命令
并发多协程复用单读循环加内部分发
故障影响单次发送失败可重试断线期间消息全部丢失
扩容水平扩展按本地在线玩家增减频道

网关只订阅“本地确实有玩家”的频道。某公会最后一个在线玩家下线后,应退订 chat:guild:<id>,否则长尾公会把订阅表撑爆,Redis 的客户端输出缓冲也会被无意义的消息占满。

四、重连与重新订阅

订阅连接断开时,Redis 不会保留任何待发消息。重连后必须重新订阅全部频道,而且重连与重订阅之间仍有一个窗口:这期间发布的消息一样会丢。聊天可以接受,可靠事件不能接受。

实现上要维护一份“期望订阅表”,重连成功后按表重建,而不是依赖旧连接的记忆。连接频繁抖动时,还要防止重复订阅:每次重连生成新的连接代次,只有当前代次的订阅回调可以写入分发器,旧代次的回调直接丢弃。

心跳与退避同样重要。固定一秒重连会在 Redis 重启时形成惊群,建议指数退避加抖动,并设置上限;连续失败要告警,而不是无限静默重试。重连后的第一条消息不要立刻高并发发送,先做一次订阅确认,避免“以为订阅成功、实际还在握手”。

五、失败场景:把 Pub/Sub 当可靠队列

某项目用 Pub/Sub 传递“充值到账”事件。运营发布时聊天服正好滚动重启,三秒内没有订阅者,事件被 Redis 直接丢弃。玩家付款成功,发货服务却从未收到通知,客服只能按订单表人工补发。根因不是网络,而是把广播通道当成了队列。

第二个常见故障是订阅连接被业务阻塞。读循环里同步查数据库、调用第三方 HTTP,单条消息处理两百毫秒,输出缓冲很快堆满。Redis 对超过限制的客户端会直接断开,表现为“偶发丢消息”,日志里却只有一行连接关闭。

第三个是频道注入。公会 ID 来自客户端且未校验,攻击者提交 7 加上换行和额外命令,或者提交 * 试图匹配全部频道。防御方式很朴素:只允许字母、数字、下划线、短横线、点和冒号,限制总长度,拒绝空段,并且永远不要把原始输入直接当频道名。

六、Go 实现:内存 Broker

下面的 Broker 用内存模拟同样的语义:Publish 向当前订阅者非阻塞投递,缓冲满就跳过;Subscription.Close 幂等;Session 负责断线后的重新订阅。ValidateChannel 把频道名校验收口到一处,发布和订阅都必须经过它。

// file: ch10/hub/pubsub.go
// tier: L1
// verify: go test ./ch10/hub/

package hub

import (
	"errors"
	"fmt"
	"strings"
	"sync"
)

var (
	ErrEmptyChannel = errors.New("hub: empty channel")
	ErrBadChannel   = errors.New("hub: invalid channel name")
)

const maxChannelLen = 128

// ValidateChannel 只接受分段式 ASCII 频道名,避免命令注入和空段。
func ValidateChannel(name string) error {
	if name == "" {
		return ErrEmptyChannel
	}
	if len(name) > maxChannelLen {
		return fmt.Errorf("%w: too long", ErrBadChannel)
	}
	for _, part := range strings.Split(name, ":") {
		if part == "" {
			return fmt.Errorf("%w: empty segment", ErrBadChannel)
		}
		for i := 0; i < len(part); i++ {
			if !validByte(part[i]) {
				return fmt.Errorf("%w: byte %q", ErrBadChannel, part[i])
			}
		}
	}
	return nil
}

func validByte(c byte) bool {
	return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
		c == '_' || c == '-' || c == '.'
}

// Message 是 Broker 投递的消息;Pub/Sub 不保存历史,Seq 只用于本地排障。
type Message struct {
	Channel string
	Seq     uint64
	Payload []byte
}

// Broker 模拟 Redis Pub/Sub 的至多一次投递:订阅者不在线就收不到。
type Broker struct {
	mu     sync.Mutex
	nextID uint64
	seq    uint64
	buffer int
	subs   map[string]map[uint64]*Subscription
}

// NewBroker 的 buffer 是每个订阅者的缓冲深度,非正值按 16 处理。
func NewBroker(buffer int) *Broker {
	if buffer <= 0 {
		buffer = 16
	}
	return &Broker{buffer: buffer, subs: make(map[string]map[uint64]*Subscription)}
}

// Subscription 是一条独立订阅,关闭后消息通道随之关闭。
type Subscription struct {
	id       uint64
	channel  string
	broker   *Broker
	messages chan Message
	once     sync.Once
}

func (s *Subscription) Channel() string          { return s.channel }
func (s *Subscription) Messages() <-chan Message { return s.messages }

// Close 幂等;重复关闭不会重复关闭通道。
func (s *Subscription) Close() { s.once.Do(func() { s.broker.unsubscribe(s) }) }

// Subscribe 校验频道名后注册订阅者。
func (b *Broker) Subscribe(channel string) (*Subscription, error) {
	if err := ValidateChannel(channel); err != nil {
		return nil, err
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	b.nextID++
	s := &Subscription{
		id:       b.nextID,
		channel:  channel,
		broker:   b,
		messages: make(chan Message, b.buffer),
	}
	if b.subs[channel] == nil {
		b.subs[channel] = make(map[uint64]*Subscription)
	}
	b.subs[channel][s.id] = s
	return s, nil
}

func (b *Broker) unsubscribe(s *Subscription) {
	b.mu.Lock()
	defer b.mu.Unlock()
	subs, ok := b.subs[s.channel]
	if !ok {
		return
	}
	if _, ok := subs[s.id]; !ok {
		return
	}
	delete(subs, s.id)
	if len(subs) == 0 {
		delete(b.subs, s.channel)
	}
	close(s.messages)
}

// Publish 返回入队的订阅者数量;缓冲满的订阅者被跳过,发布者不会被阻塞。
func (b *Broker) Publish(channel string, payload []byte) (int, error) {
	if err := ValidateChannel(channel); err != nil {
		return 0, err
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	b.seq++
	msg := Message{Channel: channel, Seq: b.seq, Payload: append([]byte(nil), payload...)}
	delivered := 0
	for _, s := range b.subs[channel] {
		select {
		case s.messages <- msg:
			delivered++
		default:
		}
	}
	return delivered, nil
}

// Session 表示一条客户端订阅连接:断线时订阅全部失效,重连后按原频道重建。
type Session struct {
	broker   *Broker
	channels []string
	mu       sync.Mutex
	subs     []*Subscription
}

// NewSession 去重频道并按给定顺序订阅。
func NewSession(b *Broker, channels ...string) (*Session, error) {
	s := &Session{broker: b}
	seen := make(map[string]struct{}, len(channels))
	for _, ch := range channels {
		if err := ValidateChannel(ch); err != nil {
			return nil, err
		}
		if _, ok := seen[ch]; ok {
			continue
		}
		seen[ch] = struct{}{}
		s.channels = append(s.channels, ch)
	}
	return s, s.subscribeAll()
}

func (s *Session) subscribeAll() error {
	subs := make([]*Subscription, 0, len(s.channels))
	for _, ch := range s.channels {
		sub, err := s.broker.Subscribe(ch)
		if err != nil {
			for _, old := range subs {
				old.Close()
			}
			return err
		}
		subs = append(subs, sub)
	}
	s.subs = subs
	return nil
}

func (s *Session) Subscriptions() []*Subscription {
	s.mu.Lock()
	defer s.mu.Unlock()
	return append([]*Subscription(nil), s.subs...)
}

// Disconnect 模拟断线:订阅立即失效,期间的消息不会补发。
func (s *Session) Disconnect() {
	s.mu.Lock()
	defer s.mu.Unlock()
	for _, sub := range s.subs {
		sub.Close()
	}
	s.subs = nil
}

// Reconnect 关闭残留订阅后按原频道重建,重复调用不会产生重复订阅。
func (s *Session) Reconnect() error {
	s.mu.Lock()
	defer s.mu.Unlock()
	for _, sub := range s.subs {
		sub.Close()
	}
	s.subs = nil
	return s.subscribeAll()
}

有三个细节值得注意。第一,Publish 在锁内完成入队,关闭订阅也在同一把锁内完成,因此不会向已关闭的通道发送。第二,投递使用 select 的 default 分支,慢订阅者只会丢自己的消息,不会拖住发布者。第三,Reconnect 先关闭残留订阅再重建,重复调用不会叠加订阅。

七、测试覆盖

测试文件位于实验目录的 ch10/hub/pubsub_test.go,与实现同包,重点验证语义而不是覆盖率:

测试断言
TestBrokerFanoutAndUnsubscribe两个订阅者都收到;退订后不再投递
TestSessionReconnectResubscribes断线期间消息丢失;重连两次仍只有一份订阅
TestValidateChannelRejectsInjection换行、空段、超长频道被拒绝
TestPublishDoesNotBlockOnSlowSubscriber缓冲满时发布返回 0,不阻塞

重连测试特意发布了一条断线期间的消息,再断言重连后收不到它。这不是遗漏,而是把“至多一次”固定成可回归的行为:如果未来有人把实现改成持久化补偿,测试会提醒他这是一次语义变更。

八、运行验证

在实验根目录执行:

cd .local/checks/gs-practice-lab && gofmt -l ch10/hub && go vet ./ch10/hub/ && go test ./ch10/hub/ -count=1
ok  	gspractice/ch10/hub	0.275s

gofmt -l 没有输出表示格式无误;go vet 通过;测试覆盖发布、退订、重连和慢订阅者四种路径。

小结

Pub/Sub 的语义是至多一次、无持久化、断线即丢,它适合在线广播,不适合可靠事件。频道名要按作用域分段并做白名单校验;发布连接与订阅连接必须分离;重连后按期望订阅表重建,并接受重连窗口内的消息丢失。内存 Broker 把这三条语义固化成可测试的代码,后续群聊与分片都建立在它之上。

常见错误与边界

  • 用 Pub/Sub 传递支付、发货、奖励等可靠事件,重启窗口直接丢单。
  • 在订阅读循环里做同步 IO,输出缓冲堆满后被 Redis 断开。
  • 把客户端输入直接拼进频道名,造成越权订阅或协议注入。
  • 重连只恢复业务状态、不恢复订阅,玩家在线却收不到任何广播。
  • 连接抖动时重复订阅,同一条消息被投递多次。
  • 用 PSUBSCRIBE * 做业务分发,频道一多就拖垮 Redis 单线程。
  • 认为 Publish 的返回值等于玩家已收到,忽略网关到客户端的下一跳。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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