go原生无pub/sub组件,需用sync.map+chan实现:须防goroutine泄漏、send on closed channel panic、并发写map;每个订阅者独占带缓冲chan,subscribe返回取消函数,publish用select非阻塞发送。

Go 原生没有发布订阅(Pub/Sub)内置组件,但用 sync.Map + chan 搭出来的轻量方案,在单机多协程场景下完全够用——前提是避开 goroutine 泄漏、send on closed channel panic、并发写 map 这三类高频翻车点。
为什么直接用 map[string][]chan interface{} 会 panic
普通 map 不支持并发读写。哪怕只是多个 goroutine 同时调用 Subscribe,内部做 append,就会触发 fatal error: concurrent map writes。
- 必须用
sync.Map存 topic → 订阅通道切片的映射,但它只保证键值对的原子存取,不保证对 value(比如切片)的并发安全 - 所以不能直接对
sync.Map中取出的切片做append;得先Load出原切片,复制一份,append后再Store回去 - 更稳妥的做法是:每个 topic 对应一个带
sync.RWMutex的封装结构,锁住切片本身,而不是依赖sync.Map的原子性
订阅者退出后,怎么防止 send on closed channel
这是最常被忽略的内存泄漏源头:订阅者 goroutine 结束了,但它的 chan 还挂在 sync.Map 里,发布者一发消息就 panic。
- 每个订阅者必须持有自己专属的
chan(例如make(chan Event, 16)),不能复用同一通道 -
Subscribe方法要返回一个取消函数,内部执行两件事:close(ch)+ 从 topic 的订阅列表中安全移除该chan - 别指望靠
defer close(ch)自动清理——如果订阅者是 HTTP handler 启动的,handler 返回了,goroutine 却没结束,ch就一直开着 - 推荐绑定
context.Context:订阅时传入ctx,在接收循环里监听ctx.Done(),收到信号就调用取消函数
发布时如何避免卡死或丢消息
发布者不能因为某个订阅者消费慢、channel 满、甚至已关闭,就被拖住或崩溃。
- 遍历每个订阅
chan时,必须用select { case ch 非阻塞发送 - 不要用
go ch 异步发——这会产生大量无法回收的 goroutine,尤其在高频发布时 - 缓冲区大小要合理:太小(如
chan int)容易满;太大(如10000)会掩盖背压问题,还浪费内存;常见取值是16或64 - 如果业务要求“至少一次”,非阻塞失败时应记录丢弃数或打 warn 日志;若要求“至多一次”,静默跳过即可
要不要上 github.com/ThreeDotsLabs/watermill 或 Redis Pub/Sub
watermill 是重型框架,适合 Kafka/RabbitMQ 场景;Redis Pub/Sub 天然支持多进程,但断连期间消息全丢。
- 纯内存方案只适用于单机、进程不重启、且能接受消息丢失的场景(比如配置热更新通知)
- 一旦需要跨实例、持久化、通配符订阅(如
logs.*)、或服务重启后消息不丢,就别硬扛——直接换github.com/go-redis/redis/v9的PubSubConn或nats.go - Redis Pub/Sub 不保证可达,适合实时通知;NATS JetStream 可做到 exactly-once,但需额外部署和运维成本
- 别用 SQLite 或文件模拟 broker:并发写、崩溃恢复、锁竞争全是坑,不是 bug,是设计越界
真正难的从来不是怎么把消息发出去,而是谁负责关 channel、谁负责清理 dead channel、下游处理失败时要不要重试——这些没有标准答案,得看你的业务能容忍什么。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











