你的微服务是否也遇到过这样的“幽灵 Bug”:
用户反馈订单被重复扣款,但后台日志却查不到明显异常?
很多时候,问题并不在支付网关,而是数据库事务与消息队列之间的不一致。
本文将以 easyms.golang 项目中的一次真实线上事故为背景,完整复盘一次问题的排查与解决过程:
从一个看似合理却暗藏风险的 COMMIT 后发送消息 的设计开始,最终通过 事务发件箱(Transactional Outbox)模式,彻底解决重复扣款的问题。
本文基于真实线上经验整理,相关业务细节已做脱敏处理,仅用于技术交流与学习。
大家好,我是 Louis。
程序员的日常往往是枯燥的,但在真实的生产环境中,代码背后其实藏着不少“故事”。
我们靠写代码谋生,但真正让人持续投入的,往往是解决问题时的成就感。
今天,想用一次事故复盘的方式,和你聊聊一个在微服务架构中非常常见、但也非常容易被忽视的问题。
故事开始:一次深夜告警
那是一个看似普通的周五深夜。
一阵急促的告警电话把我从睡梦中叫醒,紧接着客户群开始不断弹出消息:
“订单怎么被扣了两次款?”
“我这里也出现了重复订单。”
“系统是不是出问题了?”
重复扣款,这是妥妥的 P0 级生产事故。
我立刻打开电脑,开始排查。
案发现场:一个“看似正常”的订单创建逻辑
经过初步排查:
- 支付网关没有发起重复支付
- 外部依赖运行正常
问题最终定位到我们的 order-svc(订单服务) 。
事故发生前,订单创建的核心流程如下:
- 开启数据库事务
- 写入订单数据
- 提交事务
- 向 RabbitMQ 发送
OrderCreated事件,通知库存、优惠券等下游服务
简化后的核心代码如下:
// 一个存在隐患的实现
func (s *orderService) CreateOrder(ctx context.Context, order *models.Order) error {
// 1. 开始事务
tx, err := s.db.Begin()
if err != nil {
return err
}
// 2. 创建订单
if err := tx.Insert(order); err != nil {
return err
}
// 3. 提交事务
if err := tx.Commit(); err != nil {
return err
}
// 4. 发送消息
event := mq.Event{ /* ... */ }
if err := s.publisher.Publish(ctx, event); err != nil {
// 数据已提交,但消息发送失败
return err
}
return nil
}
问题,就出在第 4 步。
问题分析:幽灵 Bug 是如何产生的?
tx.Commit() 和 publisher.Publish() 是两个独立的分布式操作:
- 一个操作数据库
- 一个操作消息队列
它们之间没有任何原子性保证。
事故的完整过程大致如下:
- 用户下单
- 数据库事务成功提交,订单 A 被创建
- 服务尝试向 RabbitMQ 发送消息
- 消息其实已经成功投递到了 MQ
- 但在 MQ 返回确认的网络过程中,发生了一次短暂的超时
- 服务误以为消息发送失败,
Publish返回 error - 上层触发 重试机制
- 整个
CreateOrder被重新执行 - 数据库中生成了 第二个订单 B
- 下游服务收到两条几乎相同的
OrderCreated消息 - 优惠券被核销两次,最终表现为 重复扣款
这就是所谓的“幽灵 Bug”:
一次用户操作,被系统执行了两次。
关键转折:事务发件箱模式(Transactional Outbox)
既然无法保证数据库操作与消息发送的原子性,那就换一个思路。
核心思想很简单:
把“发送消息”这件事,转化为一个可以放进数据库事务里的本地操作。
这正是 事务发件箱模式 的核心。
事务发件箱的核心流程
- 本地事务开始
- 创建订单数据
- 不直接发送 MQ 消息
- 在同一个事务中,写入一条
OutboxEvent记录(事件内容) - 本地事务提交
- 独立的后台服务负责扫描
outbox表 - 将事件真正投递到消息队列
- 投递成功后,删除对应的
outbox记录
只要事务提交成功,就可以保证:
- 订单一定存在
- “需要发送一条事件”这个意图一定被记录
代码改造实战
一、定义 Outbox 模型
// internal/shared/models/outbox.go
type OutboxEvent struct {
ID uuid.UUID `gorm:"primaryKey;type:uuid"`
Exchange string `gorm:"not null"`
RoutingKey string `gorm:"not null"`
Payload []byte `gorm:"type:bytea;not null"`
CreatedAt time.Time `gorm:"index"`
}
二、改造 OrderService:只负责“写信”
func (s *orderService) CreateOrder(ctx context.Context, order *models.Order) error {
return s.db.RunInTransaction(func(tx db.TxTransaction) error {
// 1. 创建订单
if err := tx.Insert(order); err != nil {
return err
}
// 2. 构造事件内容
payload, _ := json.Marshal(order)
// 3. 写入 OutboxEvent
event := &models.OutboxEvent{
ID: uuid.New(),
Exchange: "orders.topic",
RoutingKey: "order.created",
Payload: payload,
}
if err := tx.Insert(event); err != nil {
return err
}
return nil
})
}
订单服务的职责被压缩得非常清晰:
只负责业务数据与事件意图的原子写入。
三、Outbox Relay:后台事件中继器
type RelayService struct {
db db.Database
publisher mq.Publisher
}
func (s *RelayService) Start() {
go func() {
for range time.NewTicker(10 * time.Second).C {
s.processOutbox()
}
}()
}
Relay 服务会持续扫描 outbox 表,将事件安全地投递到 RabbitMQ,并在成功后清理记录。
最终效果与收益
通过引入事务发件箱模式,我们获得了:
- 数据一致性
订单数据与事件创建处于同一事务中,避免“订单有了,消息没发”的情况。 - 可靠投递(At-Least-Once)
即使 MQ 短暂不可用,事件也不会丢失。 - 职责解耦
核心业务不再关心消息发送、重试与异常处理。
这不仅是一次代码重构,更是对分布式系统复杂性的一次正视。
结语
从一次“COMMIT 后发消息”的隐患,到一个稳定可控的事务发件箱实现,这次事故让我再次意识到:
分布式系统中,看似合理的设计,往往隐藏着最危险的问题。
如果你对完整实现感兴趣,欢迎查看项目源码:
- GitHub:github.com/louis-xie-p…
- Gitee:gitee.com/louis_xie/e…
也欢迎在评论区分享你在数据库与消息队列一致性方面的实践经验。