应使用带缓冲的chan *Event并配单goroutine分发器:定义含Type、Data、Timestamp的Event结构体,缓冲大小设128或256,发送端用select+timeout防阻塞,避免chan interface{}导致的消息丢失、goroutine泄漏及顺序失控。

Go 里用回调函数做事件驱动系统,不是不能用,而是容易在三处崩:消息丢失、goroutine 泄漏、顺序/重放失控。真正可靠的方案是 channel + 显式结构体 + 单 goroutine 分发器。
为什么 chan interface{} 不能当事件总线
直接用 chan interface{} 发事件,看着像异步,实则把背压甩给调用方。无缓冲时,没人接收就阻塞;加缓冲又得预估容量——爆掉后要么 panic 要么静默丢消息。这不是异步,是“假装异步”。
- 必须用
chan *Event(带缓冲),Event是显式结构体,含Type、Data、Timestamp字段 - 缓冲大小建议设为 128 或 256,避免过小导致频繁阻塞、过大浪费内存
- 发送端必须用
select+timeout,例如case bus.ch
map[string][]func(*Event) 订阅表的泄漏风险
用 map[string][]func(*Event) 存回调很常见,但没提供取消机制,闭包引用会锁住订阅者对象,尤其在 Web handler 中反复注册时,内存持续增长。
- 每个
Subscribe()返回一个func()取消函数,内部用sync.Map存id → []func(*Event),避免写锁竞争 - 注册时生成唯一
id(如uuid.NewString()),别用fmt.Sprintf("%p", cb)—— 函数地址不可靠,热重载后失效 - 回调签名统一为
func(*Event) error,方便上游做失败标记或重试判断
回调执行时 goroutine 泄漏的典型场景
每次发事件都 go callback(e),但没控制生命周期,旧回调可能还在跑,而订阅者早已退出。比如回调里有 http.Post 或 time.Sleep,进程重启时这些 goroutine 就变成孤儿。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
立即学习“go语言免费学习笔记(深入)”;
- 所有回调执行前先检查
ctx.Err() != nil,订阅时绑定带 cancel 的context.Context - 用
errgroup.Group管理这批回调 goroutine,主流程退出时统一Wait() - 禁止在回调里直接操作全局
map或slice,改用atomic或sync.Mutex,否则go run -race必报 data race
事件重放与顺序保证不是可选项
纯内存总线默认不保序也不支持重放。服务启动时想补推“用户登录”事件,或两个关联事件("order_created" → "payment_succeeded")被乱序处理,业务逻辑就错乱。
- 给
Event加SeqID uint64字段,消费者维护本地last-seq,跳过重复或乱序事件 - 需要重放时,把历史事件注入临时
chan *Event或*bytes.Buffer,再喂给分发 goroutine —— 别直接往主bus.ch写,会污染实时流 - 若强依赖顺序(如状态机流转),必须用单 goroutine 分发器,即所有回调串行调用,而不是并发执行
最难的不是写完代码,是界定哪些事件必须可重放、哪些必须严格保序、哪些允许丢弃——这得从业务语义出发,不是技术能自动决定的。


















