不能直接裸用chan interface{}做事件总线,因其本质是点对点管道,不支持广播、动态订阅/退订,易导致事件漏收、发布者死锁;须封装路由层,用sync.Map管理topic到typed channel的映射,并遍历分发。

用 channel 实现内部事件总线,为什么不能直接 chan interface{}
直接暴露 chan interface{} 给业务层,等于把“点对点管道”当“广播系统”用——它既不支持动态订阅/退订,也不保证所有监听者收到同一事件。常见现象是:发一条事件,只有第一个 range 到它的 goroutine 收到,其余静默;或者无缓冲时发布者卡死,触发 fatal error: all goroutines are asleep - deadlock。
真正可用的轻量总线,得自己封装一层路由逻辑:
- 用
sync.Map存map[string][]chan *Event,key 是 topic(如"user.created"),value 是该 topic 下所有监听者的私有 channel - 每个监听者启动自己的
for range ch循环,避免互相阻塞 - 发布时遍历对应 topic 的所有 channel,
select { case ch + <code>default防止某个 listener channel 满导致阻塞 - 缓冲大小按场景设:
make(chan *Event, 16)适合通知类;日志聚合可设到 256,但别超过 GC 压力阈值
nats.go 接入 JetStream 时,哪些配置项必须显式设置
默认 NATS 是无状态、不持久的——消费者断连期间发布的事件直接丢。要让事件“可回溯、不丢失”,JetStream 必须启用,且关键配置不能靠默认值:
-
js, _ := nc.JetStream(nats.PublishAsyncMaxInflight(100))—— 否则异步 publish 可能 silently fail - 创建 stream 时指定
nats.DiscardNew或nats.DiscardOld策略,避免磁盘撑爆 - 消费者必须设
nats.AckWait(30 * time.Second),否则处理超时自动重发 - 订阅时用
js.Subscribe("order.created", handler, nats.DeliverAll(), nats.ManualAck())——DeliverAll保证重启后能拉历史事件,ManualAck是幂等前提 - 别漏掉
nats.MaxAckPending(1000),防未 ack 消息堆积拖垮内存
事件结构体里不带 Type 和 Version 字段会出什么问题
没有 Type 字段的 JSON 事件,等于把路由逻辑扔给运行时 guess——收到 {"id":123,"email":"a@b.c"},你根本没法判断该交给 UserCreatedEvent 还是 PasswordResetEvent 处理器。更危险的是版本缺失:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
立即学习“go语言免费学习笔记(深入)”;
- 旧消费者反序列化新字段(比如新增
ReferrerID string)会 panic 或静默忽略,导致业务逻辑错乱 - 字段重命名或类型变更(如
Amount int→Amount float64)直接导致json.Unmarshal失败 - 正确做法:所有事件 struct 显式嵌入公共头:
Type string `json:"type"`+Version string `json:"version"`(如"v1") - Payload 用具体 struct(如
OrderCreatedEvent),而不是map[string]interface{}或interface{}—— 类型断言失败时无提示,调试成本翻倍
消费者不做幂等 + 手动 ACK,重复消费几乎必然发生
所有消息中间件(NATS JetStream / Kafka / RabbitMQ)在连接闪断、GC STW、网络抖动时都会重投消息。如果你的库存扣减逻辑只写 DB 一行 UPDATE inventory SET qty = qty - 1 WHERE sku = ?,一次重试就超卖。
必须组合两层防护:
- 业务层幂等键:用
event.Type + event.ID(如"order.created:12345")作为唯一键,写入 RedisSETNX或数据库唯一索引;先查再处理,已存在直接 return - 协议层手动 ACK:NATS JetStream 中必须调用
msg.Ack();Kafka 中控制CommitInterval,避免“处理完但 offset 未提交”导致重启后重复消费 - 别依赖中间件的 “exactly-once” 语义——Go 客户端库实现参差不齐,且需服务端开启事务支持,生产环境仍以应用层幂等为第一防线
最易被忽略的点:事件处理函数里启的 goroutine,如果没绑定 context.Context 或没监听 done chan,一旦主流程退出,这些 goroutine 就泄漏,持续占用内存和连接句柄——上线后 pprof 查 /debug/pprof/goroutine?debug=2 一眼就能看到几百个卡在 select 的 goroutine。


















