本地消息表必须与业务表同库以保证事务原子性,INSERT消息需在事务内完成且不可异步,投递须加锁校验并避免HTTP调用,核心是确保可查、可重试、可人工干预。

本地消息表必须和业务表在同一个数据库实例
跨库写入无法保证原子性,这是最常被忽略的前提。GORM 的 tx.Commit() 只能确保当前 *gorm.DB 会话里的 SQL 全部生效或全部回滚。如果消息表在另一个 MySQL 实例(哪怕只是另一个 schema),INSERT INTO message 和 UPDATE orders 就完全脱离事务控制——订单成功、消息丢失,或消息写入、订单失败,都会发生。
实操建议:
- 建表时明确指定同一 database:比如业务库叫
shop_db,消息表也必须建在shop_db.message,不能建在mq_db.message - 用 GORM 迁移时,确保
db.Migrator().CreateTable(&Message{})调用的是和订单表相同的*gorm.DB实例 - 检查连接字符串是否混用:不同
sql.Open()实例指向不同地址,即使库名相同也不行
INSERT INTO message 必须紧跟在业务 SQL 后面
很多人把发消息逻辑包进 goroutine 或异步队列,以为“反正最后要发”,结果事务一提交,goroutine 才开始执行,此时数据库已不可见未提交的数据,消息内容可能读到旧状态,甚至因并发导致重复发。
正确做法是把消息插入作为事务内最后一步,且不依赖任何外部变量:
- 不要用闭包捕获外部
order.ID,而应在事务块内查出或传入确定值 - 避免
go func() { tx.Create(&msg) }()这类写法——tx在 goroutine 中已失效 - 消息字段如
event_id必须由事务内生成(如 UUIDv4),不能靠外部服务分配后传入,否则无法回滚
投递任务必须加分布式锁 + 状态校验
本地消息表只是“落盘”,不等于“已投递”。轮询器若无锁并发扫描,多个实例可能同时取到同一批 status = 'pending' 消息,造成重复消费;若不校验业务状态,订单已被取消还去扣库存,就会超卖。
关键控制点:
- Redis 锁 key 建议带时间戳前缀,如
lock:msg:dispatch:20261020,避免单日锁堆积 - SELECT 查询必须带上业务状态过滤,例如:
SELECT * FROM message WHERE status = 'pending' AND biz_type = 'order_created' AND EXISTS (SELECT 1 FROM orders WHERE id = message.biz_id AND status = 'created') - 更新状态用原子 SQL:
UPDATE message SET status = 'processing', updated_at = NOW() WHERE id = ? AND status = 'pending',防止条件竞态
GORM 事务内不能直接调用 HTTP 客户端
事务生命周期内执行 http.Post() 是典型反模式:网络延迟、超时、下游宕机都会拖长事务持有时间,增加死锁概率;更严重的是,HTTP 成功不代表下游业务成功,而事务早已提交,无法回滚。
可靠做法是只做“记录”,不做“执行”:
- 事务中只写
message表,内容包含完整上下文(如{"order_id":"123","sku":"SKU-001","expected_stock":1}) - 投递器拿到消息后,先调用库存服务的幂等接口,如
POST /inventory/deduct?Idempotency-Key=evt_abc123 - 库存服务内部需查自身状态再决策,不能无脑加减——例如发现该订单已关闭,就跳过并标记消息为
ignored
事务边界的刚性比代码行数更重要。一个 INSERT 和一个 UPDATE 是否在同一个 tx 里,决定了系统是“尽力而为”还是“可修复的一致”。本地消息表不是银弹,它把问题从“如何不丢”转向“如何可查、可重试、可人工干预”——而这恰恰是生产环境最需要的。


















