订单重复扣款事故复盘:Go 微服务中事务发件箱模式的实战

54 阅读5分钟

你的微服务是否也遇到过这样的“幽灵 Bug”:
用户反馈订单被重复扣款,但后台日志却查不到明显异常?

很多时候,问题并不在支付网关,而是数据库事务与消息队列之间的不一致

本文将以 easyms.golang 项目中的一次真实线上事故为背景,完整复盘一次问题的排查与解决过程:
从一个看似合理却暗藏风险的 COMMIT 后发送消息 的设计开始,最终通过 事务发件箱(Transactional Outbox)模式,彻底解决重复扣款的问题。

本文基于真实线上经验整理,相关业务细节已做脱敏处理,仅用于技术交流与学习。


大家好,我是 Louis。

程序员的日常往往是枯燥的,但在真实的生产环境中,代码背后其实藏着不少“故事”。
我们靠写代码谋生,但真正让人持续投入的,往往是解决问题时的成就感。

今天,想用一次事故复盘的方式,和你聊聊一个在微服务架构中非常常见、但也非常容易被忽视的问题


故事开始:一次深夜告警

那是一个看似普通的周五深夜。

一阵急促的告警电话把我从睡梦中叫醒,紧接着客户群开始不断弹出消息:

“订单怎么被扣了两次款?”
“我这里也出现了重复订单。”
“系统是不是出问题了?”

重复扣款,这是妥妥的 P0 级生产事故

我立刻打开电脑,开始排查。


案发现场:一个“看似正常”的订单创建逻辑

经过初步排查:

  • 支付网关没有发起重复支付
  • 外部依赖运行正常

问题最终定位到我们的 order-svc(订单服务)

事故发生前,订单创建的核心流程如下:

  1. 开启数据库事务
  2. 写入订单数据
  3. 提交事务
  4. 向 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() 是两个独立的分布式操作

  • 一个操作数据库
  • 一个操作消息队列

它们之间没有任何原子性保证。

事故的完整过程大致如下:

  1. 用户下单
  2. 数据库事务成功提交,订单 A 被创建
  3. 服务尝试向 RabbitMQ 发送消息
  4. 消息其实已经成功投递到了 MQ
  5. 但在 MQ 返回确认的网络过程中,发生了一次短暂的超时
  6. 服务误以为消息发送失败,Publish 返回 error
  7. 上层触发 重试机制
  8. 整个 CreateOrder 被重新执行
  9. 数据库中生成了 第二个订单 B
  10. 下游服务收到两条几乎相同的 OrderCreated 消息
  11. 优惠券被核销两次,最终表现为 重复扣款

这就是所谓的“幽灵 Bug”:

一次用户操作,被系统执行了两次。


关键转折:事务发件箱模式(Transactional Outbox)

既然无法保证数据库操作与消息发送的原子性,那就换一个思路

核心思想很简单:

把“发送消息”这件事,转化为一个可以放进数据库事务里的本地操作

这正是 事务发件箱模式 的核心。


事务发件箱的核心流程

  1. 本地事务开始
  2. 创建订单数据
  3. 不直接发送 MQ 消息
  4. 在同一个事务中,写入一条 OutboxEvent 记录(事件内容)
  5. 本地事务提交
  6. 独立的后台服务负责扫描 outbox
  7. 将事件真正投递到消息队列
  8. 投递成功后,删除对应的 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 后发消息”的隐患,到一个稳定可控的事务发件箱实现,这次事故让我再次意识到:

分布式系统中,看似合理的设计,往往隐藏着最危险的问题

如果你对完整实现感兴趣,欢迎查看项目源码:

也欢迎在评论区分享你在数据库与消息队列一致性方面的实践经验。