一、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的返回值等于玩家已收到,忽略网关到客户端的下一跳。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。