发布订阅模式(Pub/Sub Pattern)
发布订阅模式(Pub/Sub Pattern)
Go 特有/事件驱动模式
一、核心思想
发布订阅模式将消息的发送者(Publisher)和接收者(Subscriber)解耦:发布者不需要知道谁在订阅,只管向主题(Topic)发送消息;订阅者只关心自己感兴趣的主题,不关心消息来自谁。
一对多通信的本质
Publisher ──→ [ Topic ] ──→ Subscriber A
└────→ Subscriber B
└────→ Subscriber C
发布者发一条消息,所有订阅该主题的订阅者各自收到一份拷贝。这是与工作队列(一条消息只被一个 worker 消费)的本质区别。
生活中的类比
广播电台:电台只管发射信号,不关心有多少台收音机在接收。每台收音机调到同一频率都能收到同一节目。电台 ↔ 频率 ↔ 收音机 = Publisher ↔ Topic ↔ Subscriber。
二、观察者模式 vs 发布订阅模式
之前学过观察者模式,两者很像但有关键区别:
| 对比维度 | 观察者模式 | 发布订阅模式 |
|---|---|---|
| 通信方式 | 直接调用观察者的方法 | 通过中间件(Topic/EventBus)转发 |
| 耦合度 | 主题知道观察者存在 | 发布者订阅者互不感知 |
| 进程范围 | 通常是进程内 | 可进程内,也可跨进程(消息队列) |
| Go 典型实现 | Subject 持有 []Observer | EventBus map[topic][]chan |
简单说:观察者是"打电话"(直接通知),发布订阅是"发广播"(中间人转发)。
三、Go 实现要点
1. 核心数据结构
type EventBus struct {
mu sync.RWMutex
subscribers map[string][]chan Event // topic → subscriber channels
}
2. 关键设计决策
| 决策点 | 选项 | 推荐 |
|---|---|---|
| 订阅者 channel 缓冲 | 无缓冲 / 有缓冲 | 有缓冲,防止慢消费者阻塞发布者 |
| 发布方式 | 同步发 / 异步发 | 异步 goroutine 发,避免慢消费者拖垮发布者 |
| 关闭策略 | 关闭所有订阅 channel / 仅停止接收 | 先停止接收新消息,再 close 所有 channel |
| 取消订阅 | 删除 channel / 关闭 channel | 从 slice 中删除 + close,防止 goroutine 泄漏 |
| 慢消费者处理 | 阻塞 / 丢弃 / 缓冲 | 视场景而定,推荐缓冲 + 丢弃策略 |
3. 发布时拷贝订阅者列表
发布消息时,不能直接遍历 subscribers[topic],因为遍历过程中其他 goroutine 可能修改列表。正确做法:先拷贝一份快照再遍历。
四、Go 实现示例
完整的进程内 EventBus
package main
import (
"fmt"
"sync"
"time"
)
// ============================================================
// Event 事件结构
// ============================================================
// Event 是总线上传递的事件
type Event struct {
Topic string
Payload any
}
// ============================================================
// EventBus 事件总线
// ============================================================
// EventBus 管理所有主题和订阅者
type EventBus struct {
mu sync.RWMutex
subscribers map[string][]chan Event
closed bool
}
// NewEventBus 创建事件总线
func NewEventBus() *EventBus {
return &EventBus{
subscribers: make(map[string][]chan Event),
}
}
// Subscribe 订阅指定主题,返回一个接收 channel
// bufSize 为订阅者 channel 的缓冲大小
func (eb *EventBus) Subscribe(topic string, bufSize int) chan Event {
eb.mu.Lock()
defer eb.mu.Unlock()
if eb.closed {
ch := make(chan Event)
close(ch)
return ch
}
ch := make(chan Event, bufSize)
eb.subscribers[topic] = append(eb.subscribers[topic], ch)
return ch
}
// Unsubscribe 取消订阅并关闭 channel
func (eb *EventBus) Unsubscribe(topic string, ch chan Event) {
eb.mu.Lock()
defer eb.mu.Unlock()
subs, ok := eb.subscribers[topic]
if !ok {
return
}
for i, sub := range subs {
if sub == ch {
// 从 slice 中删除
eb.subscribers[topic] = append(subs[:i], subs[i+1:]...)
close(ch)
return
}
}
}
// Publish 异步发布事件到指定主题的所有订阅者
func (eb *EventBus) Publish(topic string, payload any) {
eb.mu.RLock()
defer eb.mu.RUnlock()
if eb.closed {
return
}
// 拷贝订阅者列表快照,避免遍历时被修改
subs, ok := eb.subscribers[topic]
if !ok {
return
}
snapshot := make([]chan Event, len(subs))
copy(snapshot, subs)
evt := Event{Topic: topic, Payload: payload}
// 异步发送给每个订阅者
for _, ch := range snapshot {
go func(c chan Event) {
// 非阻塞发送:如果 channel 满了则丢弃
// 生产环境可根据需求改为阻塞或重试
select {
case c <- evt:
default:
fmt.Printf(" ⚠ 订阅者缓冲已满,事件被丢弃: topic=%s\n", topic)
}
}(ch)
}
}
// Close 关闭事件总线,关闭所有订阅者 channel
func (eb *EventBus) Close() {
eb.mu.Lock()
defer eb.mu.Unlock()
if eb.closed {
return
}
eb.closed = true
for topic, subs := range eb.subscribers {
for _, ch := range subs {
close(ch)
}
delete(eb.subscribers, topic)
}
}
// ============================================================
// 使用示例
// ============================================================
func main() {
bus := NewEventBus()
// 订阅 "user.created" 主题 —— 日志服务
logCh := bus.Subscribe("user.created", 10)
go func() {
for evt := range logCh {
fmt.Printf("[日志服务] 收到事件: %+v\n", evt.Payload)
}
fmt.Println("[日志服务] channel 已关闭,停止消费")
}()
// 订阅 "user.created" 主题 —— 邮件服务
mailCh := bus.Subscribe("user.created", 10)
go func() {
for evt := range mailCh {
fmt.Printf("[邮件服务] 发送欢迎邮件: %+v\n", evt.Payload)
}
fmt.Println("[邮件服务] channel 已关闭,停止消费")
}()
// 订阅 "order.paid" 主题 —— 库存服务
inventoryCh := bus.Subscribe("order.paid", 10)
go func() {
for evt := range inventoryCh {
fmt.Printf("[库存服务] 扣减库存: %+v\n", evt.Payload)
}
fmt.Println("[库存服务] channel 已关闭,停止消费")
}()
// 等待订阅者就绪
time.Sleep(100 * time.Millisecond)
// 发布事件
fmt.Println("=== 发布 user.created 事件 ===")
bus.Publish("user.created", map[string]any{
"userID": 1,
"name": "张三",
"email": "zhangsan@example.com",
})
time.Sleep(200 * time.Millisecond)
fmt.Println("\n=== 发布 order.paid 事件 ===")
bus.Publish("order.paid", map[string]any{
"orderID": "ORD-2026-001",
"amount": 199.00,
"products": []string{"Go语言圣经", "设计模式"},
})
time.Sleep(200 * time.Millisecond)
fmt.Println("\n=== 取消邮件服务订阅 ===")
bus.Unsubscribe("user.created", mailCh)
time.Sleep(100 * time.Millisecond)
fmt.Println("\n=== 再次发布 user.created 事件 ===")
bus.Publish("user.created", map[string]any{
"userID": 2,
"name": "李四",
"email": "lisi@example.com",
})
time.Sleep(200 * time.Millisecond)
fmt.Println("\n=== 关闭事件总线 ===")
bus.Close()
time.Sleep(200 * time.Millisecond)
fmt.Println("\n程序结束")
}
运行结果
=== 发布 user.created 事件 ===
[邮件服务] 发送欢迎邮件: map[...
[日志服务] 收到事件: map[...
=== 发布 order.paid 事件 ===
[库存服务] 扣减库存: map[...
=== 取消邮件服务订阅 ===
=== 再次发布 user.created 事件 ===
[日志服务] 收到事件: map[...
[邮件服务] channel 已关闭,停止消费
=== 关闭事件总线 ===
[日志服务] channel 已关闭,停止消费
[库存服务] channel 已关闭,停止消费
程序结束
五、关键设计分析
1. 为什么发布时用 goroutine?
如果同步遍历发送,一个慢消费者会阻塞整个发布:
// ❌ 同步发送:慢消费者拖垮发布者
for _, ch := range subs {
ch <- evt // 如果某个 ch 缓冲满了,整个 Publish 阻塞
}
// ✅ 异步发送:互不影响
for _, ch := range subs {
go func(c chan Event) {
select { case c <- evt: default: /* 丢弃 */ }
}(ch)
}
2. 为什么用 select + default(非阻塞发送)?
如果消费者处理太慢,channel 缓冲会满。阻塞发送会导致 goroutine 堆积甚至死锁。select + default 实现了背压降级——满则丢弃,保证发布者不被阻塞。
生产环境可选策略:
- 丢弃:日志、监控等允许丢失的场景
- 阻塞:必须保证不丢消息的核心业务
- 更大的缓冲:突发流量场景
3. 为什么拷贝订阅者列表?
// ✅ 安全:先拷贝快照
snapshot := make([]chan Event, len(subs))
copy(snapshot, subs)
// 然后释放锁,遍历快照
// ❌ 危险:直接遍历 + goroutine 发送
// goroutine 执行时锁已释放,subs 可能已被修改
// 可能向已关闭的 channel 发送 → panic
for _, ch := range subs {
go func() { ch <- evt }()
}
4. Unsubscribe 时必须 close channel
// ✅ 正确:删除 + close,让消费者的 range 循环退出
eb.subscribers[topic] = append(subs[:i], subs[i+1:]...)
close(ch)
// ❌ 只删不关:消费者的 for range 永远不退出 → goroutine 泄漏
eb.subscribers[topic] = append(subs[:i], subs[i+1:]...)
六、Go 标准库/生态中的 Pub/Sub
| 实现 | 特点 | 适用场景 |
|---|---|---|
sync.Map + channel |
手写最简版 | 小型应用 |
container/list + goroutine |
标准库链表 | 进程内事件 |
github.com/asaskevich/EventBus |
第三方库 | 中型应用 |
| Redis Pub/Sub | 跨进程 | 分布式系统 |
| Kafka / RabbitMQ | 持久化 + 分区 | 大规模分布式 |
| Go channels(原生) | 最轻量 | 单进程简单场景 |
七、与观察者模式的关系回顾
本系列第 12 天学习了观察者模式,两者结构相似但解耦程度不同:
观察者模式: Subject ────直接调用───→ Observer.Update()
发布订阅: Publisher ──→ EventBus ──→ Subscriber channel
观察者 = 打电话(双方知道对方)
发布订阅 = 发广播(通过中间人,双方互不认识)
Go 中由于 channel 天然支持"管道"通信,发布订阅模式的实现比 Java/C++ 更自然——channel 就是最好的"主题"中间件。
八、学习小结
发布订阅模式是构建松耦合系统的核心模式。核心要点:
- EventBus 管理主题→订阅者映射,是解耦的关键
- 异步发布 + 非阻塞发送 防止慢消费者拖垮发布者
- 拷贝订阅者列表 再发布,避免并发修改导致的 panic
- Unsubscribe 必须 close channel,否则 goroutine 泄漏
- Go 的 channel 天然适合实现 Pub/Sub,比传统 OOP 语言更简洁

浙公网安备 33010602011771号