watermill 不是插件式消息总线,必须从服务启动逻辑、事务边界、错误隔离三处重构代码;正确做法是初始化 message.newrouter 并统一注册 handler,而非仅用 publisher/subscriber。

Watermill 不是“插件式消息总线”,它要求你从服务启动逻辑、事务边界、错误隔离三处重构代码——直接替换 message-bus 或手写 channel 消费会丢消息、卡死、重复消费。
Router 必须作为服务主干初始化,不能只用 Publisher/Subscriber
很多人以为 Watermill 是个“增强版 pubsub 客户端”,于是只引入 kafka.NewPublisher 和 kafka.NewSubscriber,再套个 for range messages。这在单 topic 本地测试能跑通,但上线后立刻出问题:
- 多个 topic 需要各自启 goroutine,
defer subscriber.Close()很难覆盖所有生命周期,goroutine 泄漏高发 - 中间件(如重试、超时、去重)无法复用,每个 handler 都得自己写
context.WithTimeout和recover - 一个 handler panic,整个消费 loop 停摆;Router 内部每个 handler 是独立 goroutine,挂掉不影响其他
正确做法:服务启动时初始化 message.NewRouter,所有 topic 处理逻辑通过 router.AddHandler 注册,最后调 router.RunHandlers() 启动统一调度器。
Kafka/RabbitMQ 的 Ack 与 Group 配置必须显式对齐业务语义
Watermill 不决定“什么时候算消费成功”,它把控制权完全交给底层实现和你的 handler 返回值。常见卡住或重复现象,几乎都源于配置错位:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
-
context deadline exceeded+ 消息反复出现 → Kafka 的session.timeout.ms小于 handler 执行时间;解决:用context.WithTimeout包裹业务逻辑,并确保 timeout 值小于 broker 配置 - 所有实例都收到同一条消息 →
ConsumerGroup没设;Kafka 认为每个 subscriber 是独立消费者;必须显式配置consumerGroup(Kafka)或queue(AMQP) - 消息处理完却一直重试 → handler 返回
error或 panic;记住:只有返回nil, nil,Watermill 才调msg.Ack();若用ManualAck模式,还必须手动调msg.Ack(),否则永远不提交 offset
防丢消息不能靠 Publisher 配置,必须绑定数据库事务用 Outbox
kafka.NewPublisher 只是发送客户端,和事务无关。订单、支付类事件上线后丢消息,90% 是因为没上 Outbox 模式:
- 别直接
tx.Commit()后调publisher.Publish();顺序反了,事务提交后发布失败就不可逆 - 要用
outbox.NewPublisher(db, publisher, logger)封装;outboxPub.Publish(ctx, "topic", msg)这行必须在tx.Commit()之前执行 - Outbox 表结构需包含
topic、payload、metadata、published_at字段;轮询协程从该表读取未发布消息并投递
多中间件混用时,Subscriber/Publisher 实例必须按协议隔离
Watermill 支持 Kafka、RabbitMQ、Redis Streams 等多种后端,但它们不能混用同一个 Subscriber 实例:
- 一个服务同时消费 Kafka 的
order_events和 RabbitMQ 的notification_queue?必须分别初始化kafka.NewSubscriber和amqp.NewSubscriber -
router.AddHandler的第 3 和第 5 参数分别是subscriber和publisher实例;不同协议的实例不能交叉传入 - HTTP 或 MySQL binlog 这类非标准 Pub/Sub 实现,需自行实现
pubsub.Subscriber接口;注意其Subscribe()是否支持 context cancel
真正麻烦的不是写几行 AddHandler,而是每个 handler 的 error 分支是否真能被 recover、每条消息的 Ack() 是否在正确时机调用、Outbox 轮询是否和主事务共用同一 DB 连接池——这些细节一漏,就是线上消息积压或丢失。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










