用 sync.Map + 带缓冲 chan 可实现线程安全纯内存 Pub/Sub;需用 sync.Map 存 topic→chan 切片映射,切片操作须额外加锁;每个订阅者应独占带缓冲 chan(如容量10),发布时用 select 避免阻塞。

用 sync.Map + 带缓冲 chan 实现线程安全订阅管理
纯内存 Pub/Sub 在单机多协程场景下完全够用,但直接用 map[string][]chan interface{} 会触发 concurrent map read and map write panic。必须用 sync.Map 存 topic → 切片映射,且切片本身需额外保护——因为 sync.Map 不支持对 value 做原子切片操作。
常见错误是把所有订阅者塞进一个无缓冲 chan,结果某个消费者卡住,整个 Publish 调用就阻塞。正确做法是每个订阅者独占一个带缓冲的 chan interface{}(比如容量 10),发布时用 select { case ch 非阻塞投递。
- 订阅方法必须返回取消函数,内部调用
Unsubscribe(topic, ch)并关闭通道 -
Unsubscribe要用sync.Map.Load/Store原子更新切片,不能直接append或copy - 别在
Subscribe内部启动 goroutine 消费——谁订阅谁负责读,Broker 只分发
Publish 必须异步执行且带背压处理
同步广播是线上事故高发区:一旦某个 ch 缓冲满或消费者卡顿,Publish 就 hang 住,HTTP 接口超时、定时任务失败全跟着来。这不是性能问题,是架构误用。
正确姿势是为每次发布启一个独立 goroutine,并对每个订阅通道做非阻塞尝试。漏掉 default 分支等于放弃可观测性——线上出问题时根本不知道是哪个模块消费不过来。
- 必须记录
default分支日志,打上topic和ch地址,方便定位积压点 - 不要给
Publish加全局锁,否则高并发下退化成串行 - 消息体禁止用
interface{},必须是具名结构体,否则接收方只能靠断言,失去类型安全
主题命名必须带业务域前缀,禁用裸名
看似只是字符串约定,实则影响权限控制、日志追踪、监控切片和未来对接 Kafka/NATS 的平滑度。见过太多团队初期用 "login"、"pay" 这类裸名,后期加风控模块要监听所有支付事件,结果发现订单、退款、充值全混在一个 topic 下。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
强制格式为 ",例如 "order.created"、"payment.refunded"。禁止用动词过去式以外的形态(如不用 "order_create" 或 "onOrderCreate")。
- 运行时拼接 topic(如
"user." + userID)前,必须校验空值和非法字符 - Redis 中若用裸名,中间件可能拦截或路由混乱;Kafka 中裸名无法做 ACL 粒度控制
- 日志和监控按
domain切片时,裸名会导致指标聚合失真
别在订阅回调里做 DB/HTTP 调用
典型反模式:订阅 "user.registered" 后,在 for range mailCh 循环里直接调 sendWelcomeEmail()。这个函数如果因网络抖动卡住 5 秒,而你开了 10 个 worker,瞬间就堆积 50 个卡住的 goroutine。
goroutine 泄漏比内存泄漏更难查,因为它不报错、不 OOM,只默默拖垮服务 CPU 和连接数。
- 回调里只做轻量通知(如发信号、写 channel、打 metric),重逻辑交给后台 worker
- worker 必须包
defer func() { if r := recover(); r != nil { log.Printf("worker panic: %v", r) } }() - DB 操作务必套
context.WithTimeout(ctx, 3*time.Second),超时就丢弃或落盘到失败队列
实际跑起来最易被忽略的是:订阅者退出后没关通道,发布者一发消息就 panic:send on closed channel;还有就是忘了给 ReceiveMessage 加 context 超时,导致 goroutine 永远挂着。这两处不修,服务上线三天内必出问题。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










