用 sync.Map 实现线程安全订阅表,键为 topic 字符串,值封装带 ID 的回调结构体;发布时需 recover 每个回调 panic 并异步执行耗时逻辑;取消订阅应依赖唯一 ID 而非函数值比较。

用 sync.Map 实现线程安全的订阅者注册表
Go 原生没有内置 Pub-Sub,得自己搭骨架。核心难点是多个 goroutine 同时 Subscribe 和 Publish 时,订阅列表不能乱。别用普通 map[string][]func(interface{}) —— 并发写会 panic:fatal error: concurrent map writes。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
-
sync.Map是最轻量的选择,适合读多写少场景(比如订阅基本在启动期完成,发布高频) - 键用
string类型的主题名(如"user.created"),值存*list.List或自定义的回调切片(注意:切片本身不安全,需配合sync.RWMutex封装) - 别把回调函数直接塞进
sync.Map的 value —— 它不支持泛型,且类型擦除后难调试;建议封装成结构体,带id和fn字段 - 如果订阅关系动态极强(比如每秒上千次
Subscribe/Unsubscribe),sync.Map的哈希竞争反而拖慢,此时改用分段锁(sharded map)或runtime.SetFinalizer配合手动清理
发布时如何避免阻塞和 panic
发布消息时最常踩的坑是:某个订阅者函数 panic 了,整个 Publish 流程就断了,后续回调全被跳过 —— 这在事件驱动架构里是灾难性的。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 每个回调必须包裹
defer func() { recover() }(),且 recover 后至少打日志(比如log.Printf("panic in subscriber %s: %v", sub.id, r)) - 别在回调里做耗时操作(如 HTTP 调用、DB 写入),否则会卡住整个发布循环;应启动新 goroutine 处理,但要注意生命周期控制(比如用
context.WithTimeout) - 如果主题没人订阅,
Publish应静默返回,而不是报错或告警 —— 这是正常情况,尤其在微服务中模块可能异步上线 - 参数传递推荐用
interface{}+ 显式类型断言,别用any别名混淆;若消息结构固定,可定义type Event struct{ Topic string; Payload interface{} }统一入口
取消订阅时为什么 Unsubscribe 总失效
常见现象:调用了 Unsubscribe("topic", handler),但之后发布消息,handler 还是被执行了。根本原因不是逻辑错,而是函数比较失效 —— Go 中闭包、方法值、匿名函数即使内容相同,== 也返回 false。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 不要依赖函数值相等来匹配取消目标;给每次
Subscribe返回一个唯一string或int64id(比如用atomic.AddInt64(&counter, 1)),Unsubscribe只认这个 id - 如果必须用函数作标识,要求用户传入显式
name string参数(如Subscribe("user.created", "send_welcome_email", handler)),内部用 name 查找 - 务必在
Unsubscribe里加锁并检查订阅者是否存在,避免重复删除导致list.Removepanic - 测试时用
reflect.ValueOf(fn).Pointer()辅助验证函数地址是否一致 —— 仅限 debug,生产环境禁用
要不要用第三方库比如 github.com/ThreeDotsLabs/watermill
Watermill 功能全,但引入它等于把 Kafka/RabbitMQ 抽象层提前加载进来 —— 如果你只是进程内事件通知(比如模块间松耦合通信),它重了,配置复杂度和启动开销都高。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 纯内存 Pub-Sub,手写 200 行以内能搞定,重点是控制权在自己手里:知道每条消息怎么流转、谁在 hold 引用、何时 GC
- 如果已有消息中间件,且未来一定上云或扩集群,那直接用 Watermill 或
github.com/segmentio/kafka-go更稳妥,避免二次迁移 - 注意 Watermill 的
Subscriber接口默认假定消息持久化,本地开发时容易因 broker 不可用而卡住,需显式设SetAckOnFailure(false)和超时 - 所有第三方库都会增加构建时间和二进制体积,CI 环境拉依赖失败概率上升;简单场景优先手写,留好接口抽象(比如定义
type Broker interface { Publish(...); Subscribe(...) }),以后替换成本低
真正麻烦的是跨 goroutine 生命周期管理 —— 比如一个 HTTP handler 订阅了事件,但 handler 结束了,回调还在跑,引用的局部变量已失效。这种问题不会报错,只会静默读到零值或 panic,得靠代码审查和 go vet -shadow 提前揪出来。











