消息中心需严格区分通知(单向触达)与消息(状态追踪),Golang中应拆分为Notifier(投递)和MessageCenter(存储与状态管理)两个子系统,避免模板修改牵连多服务、索引缺失致查询缓慢、Redis计数未清理或非原子操作等常见错误。

消息中心系统必须先区分「通知」和「消息」
很多人一上来就堆 SendSMS()、SendEmail(),结果改个模板要动三个服务。根本问题是没分清:「通知」是单向触达(比如密码重置链接),而「消息」需要状态追踪(已读/未读/撤回/回复)。Golang 里最稳妥的做法是拆成两个子系统——Notifier 负责投递,MessageCenter 负责存储与状态管理。
常见错误是把用户未读数存在 Redis 里却忘了定时清理过期会话,导致数字虚高;或者用 MySQL 存百万级消息却不加 user_id + created_at 复合索引,查“最近10条”直接拖垮接口。
-
Notifier接口必须抽象为Send(ctx context.Context, to string, templateID string, data map[string]interface{}) error,渠道实现(短信、邮件、站内信)只管自己那块,不碰业务逻辑 -
MessageCenter的核心表至少要有id、user_id、category(如"system"/"order")、status("unread"/"read"/"archived")、payload(JSONB 或 text 字段存结构化内容) - 不要在写入时就渲染富文本或 HTML —— 渲染交给前端或专门的
Renderer服务,否则改个样式要全量重发消息
用 channel + worker 模式扛住突发写入压力
临床试验场景下万名患者同时提交,消息中心不能卡在数据库写入上。Go 原生 channel 是第一道缓冲,但别直接用无缓冲 channel —— 容易阻塞生产者。实际做法是:开一个带缓冲的 chan *Message(比如容量 1000),再起固定数量的 goroutine 消费它,每条消息走事务写入 DB + 更新 Redis 未读计数。
容易踩的坑是 worker panic 后没 recover,整个 channel 堵死;或者忘记给 DB 操作加超时,一条慢查询拖垮全部 worker。
- worker 启动时用
for range持续消费,内部必须包一层defer func() { if r := recover(); r != nil { log.Printf("worker panic: %v", r) } }() - DB 写入务必套
context.WithTimeout(ctx, 3*time.Second),超时就丢弃或落盘到失败队列 - Redis 计数更新要用
INCR和DECR原子操作,别先 GET 再 SET —— 并发时会丢计数
已读/未读状态同步不能依赖轮询
App 端如果每 30 秒 GET 一次 /messages/unread_count,服务器压力不大,但用户感知延迟高。更现实的方案是:WebSocket 维持长连接,服务端在用户打开消息列表时发一条 {"type":"mark_as_read","message_ids":[101,102]},然后批量更新 DB 和 Redis。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
注意别让 WebSocket handler 直接执行 SQL —— 高并发下容易锁表。应该把标记请求转成消息,扔进另一个内部 channel,由专用 worker 处理。
- WebSocket 连接建立后,立即推送当前未读数,避免首次加载空白
- 批量标记已读时,SQL 要用
UPDATE ... WHERE id IN (?) AND user_id = ?,别用循环单条更新 - Redis 的未读数 key 命名建议为
msg:unread:{user_id},过期时间设为 7 天,避免长期占用内存
RabbitMQ 不是必须,但得预留接入点
小团队起步用纯 Go 实现没问题,但一旦消息量破万 QPS 或需要跨机房投递,就得切到 RabbitMQ。关键不是重写,而是设计时留好抽象层:把 Broker 定义为 interface,本地用 MemoryBroker(基于 channel),线上换 RabbitBroker(基于 streadway/amqp)只需改一行初始化代码。
最容易被忽略的是死信队列配置。短信网关返回 429(限频)或 503(不可用)时,消息不能丢,必须进 DLX 重试三次后才进告警通道。
-
RabbitBroker.Publish()必须支持mandatory=true和immediate=false,确保路由失败能被捕获 - 所有外部渠道调用(短信/邮件 API)必须包装重试逻辑,最多 3 次,指数退避,失败日志带完整 request/response
- 别在
Broker层做消息去重 —— 应该由上游业务生成幂等 ID(如order_id + event_type),DB 建唯一索引拦截
真正难的不是发消息,是当用户说“我明明点了已读,为什么还显示红点”,你得能在 10 秒内定位是 Redis 计数没更新、DB 事务回滚了、还是 WebSocket 连接断了没重连。状态一致性永远比吞吐量更值得花时间设计。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










