用 channel 实现本地事件总线易踩 goroutine 泄漏和类型不安全两大坑:订阅者未调 Unsubscribe 导致 channel 持有引用、GC 无法回收;chan interface{} 广播需 type switch 或反射判别事件类型,遗漏即 panic。

用 channel 实现本地事件总线容易踩哪些坑
纯 in-process 的 chan interface{} 事件总线适合单体或开发验证,但上线后极易出问题。最典型的是 goroutine 泄漏和类型不安全。
- 订阅者没调用
Unsubscribe(),channel 持有引用导致 GC 不掉,内存持续上涨 - 用
chan interface{}广播所有事件,handler 内部必须做type switch或反射判断,稍有遗漏就 panic select { case ch 看似防阻塞,实则静默丢事件;高并发下丢失率陡增- 事件名散落在各处(如硬编码
"order.created"),重构时无法全局搜索,改错一个漏十个
正确做法是:定义具体事件 struct(如 OrderCreatedEvent),用 map[string][]chan OrderCreatedEvent 分类型管理,每个 channel 带缓冲(make(chan OrderCreatedEvent, 16)),handler 必须循环读取并检查 ok 状态。
选 Kafka 还是 NATS?看这三点再决定
消息中间件不是越重越好,关键匹配业务阶段和 SLA 要求。
- 要精确一次语义、按序消费、支持历史回溯 → 选
Kafka,用segmentio/kafka-go库,注意开启幂等生产者(EnableIdempotence: true)和事务(BeginTransaction) - 中小系统、低延迟敏感、想快速上线 → 选
NATS JetStream,用nats-io/nats.go+jetstream模块,Stream自带存储,PullConsumer支持手动 ack 和 backoff 重试 - 需要复杂路由(如 topic/exchange binding)、已有 RabbitMQ 运维经验 → 用
streadway/amqp,但必须实现手动ack+reject+ DLQ 转发逻辑,否则消息会丢失
别忽略连接复用:所有客户端都应封装成单例或通过 DI 注入,避免每个 handler 都新建 connection,否则 fd 耗尽、DNS 解析风暴频发。
立即学习“go语言免费学习笔记(深入)”;
事件结构定义和版本演进怎么不出错
事件是服务间契约,不是临时数据包。字段删改、类型变更都会引发消费者 panic 或逻辑错乱。
- 事件名必须用过去式(
OrderPaid而非PayOrder),表明“已发生”,避免时序歧义 - 所有字段导出 + 加
json:tag,时间字段统一用time.Time(序列化为 RFC3339 字符串) - 新增字段设默认值(如
Version int `json:"version,omitempty"`),旧消费者忽略该字段;重大变更(如金额单位从元改为分)必须升级事件名(OrderPaidV2) - 在
event/types.go集中定义全部事件类型,禁止任何包内硬编码字符串
测试时用 json.Marshal + json.Unmarshal 双向校验,确保旧版 consumer 能无 panic 解析新版 event(字段多但不崩)。
消费端幂等和失败处理的真实写法
“收到事件就处理”是最大误区。网络抖动、重复投递、消费者重启都会导致同一条事件被多次送达。
- 幂等键必须含两部分:
event.ID(全局唯一) +event.Type(防止不同事件 ID 冲突),存 Redis 或 DB,TTL 设为业务合理窗口(如 24h) - 失败分三类处理:网络超时 → 立即重试(用
backoff.Retry);业务校验失败(如库存不足)→ 记日志 + skip,不重试;解码错误/panic → 入死信队列(DLQ),人工介入 - offset 提交必须在业务处理成功后,且用同步方式(Kafka 的
CommitSync,NATS 的Ack),别用异步提交(CommitAsync)——崩溃时 offset 已提交但业务未完成,数据丢失
真正难的不是写对一次,而是让这套逻辑在 1000 QPS、5 种异常注入、跨 AZ 网络分区下仍不丢不重。压测时重点看 DLQ 积压量和幂等 key 冲突率,这两个数字比日志更诚实。



















