Golang发布订阅模式的基础实现思路

最简发布订阅用map+chan实现,以主题为key、带缓冲通道为value,配RWMutex保护并发;发布时快照通道列表并select非阻塞发送;订阅需返回取消函数清理map和通道,主题应语义化而非UUID。

map + chan 实现最简发布订阅

核心就是维护一个主题(string)到订阅者通道(chan interface{})的映射,发布时遍历所有对应通道发送消息。注意:不能直接在发布时向 chan 写入而不做保护——如果某个订阅者没及时读,写操作会阻塞整个发布流程。

实操建议:

  • 每个订阅者应启动独立 goroutine 消费自己的 chan,避免阻塞发布者
  • 使用 sync.RWMutex 保护 map 的并发读写,尤其在动态增删订阅时
  • 通道建议带缓冲(如 make(chan interface{}, 1)),防止单个慢消费者拖垮全局
  • 不推荐用无缓冲通道做订阅通道,极易因消费滞后导致发布卡死

sync.Map 能替代普通 map 吗?

可以,但不推荐作为首选。虽然 sync.Map 免去了显式加锁,但它不支持遍历——而发布动作必须“找到所有监听该主题的通道”,这就必须能枚举键值对。sync.MapRange 是快照式遍历,期间新增/删除订阅可能被跳过;且无法保证遍历与写入的严格一致性。

更稳妥的做法仍是 mapsync.RWMutex,并在读取前加读锁、写入时加写锁:

mu.RLock()
chans := make([]chan interface{}, 0, len(m[topic]))
for _, ch := range m[topic] {
    chans = append(chans, ch)
}
mu.RUnlock()

for _, ch := range chans { select { case ch <- msg: default: // 避免阻塞,丢弃或记录 } }

如何安全关闭订阅通道并清理资源

单纯关闭 chan 不足以清理,因为已关闭的通道仍可读(返回零值),且 map 中残留的 nil 或已关闭通道会导致后续发布 panic 或逻辑错乱。

关键动作是「移出 map」+「通知消费者退出」:

  • 不要在发布逻辑里检查 cap(ch) == 0ch == nil 来跳过,这不可靠
  • 订阅函数应返回一个 func() 取消函数,内部完成:从 map 删除通道 + 关闭通道 + 唤醒消费者 goroutine
  • 消费者 goroutine 应用 for msg := range ch 模式,通道关闭后自动退出
  • 若需等待消费者真正退出,可用 sync.WaitGroup 计数,但注意别在持有锁时 wg.Wait()

为什么不用第三方库如 github.com/google/uuid 生成主题名

主题名本质是业务语义标识(如 "user.created""order.paid"),不是为了唯一性。用 UUID 当主题名反而让调试和监控变困难——你没法一眼看出事件类型,日志里全是随机字符串,Prometheus 标签也难以聚合。

真正需要唯一性的场景是「临时请求响应匹配」(比如 RPC 回调),那属于请求-响应模式,不是发布订阅。发布订阅的主题应是稳定、可读、可预测的字符串常量或拼接结果。

容易被忽略的一点:主题层级设计会影响扩展性。例如用 "payment.usd.success" 而非 "payment_usd_success",后续就能支持通配符订阅(如 "payment.*.success"),但 Go 标准库不内置通配匹配,得自己实现或引入轻量库如 github.com/robfig/pat 做前缀匹配。