Go 项目里怎么使用 GORM 事务

128 阅读2分钟

一、背景

在写 Go 的业务代码时,我们通常会把数据库操作放在 Repository 层,而上层的 Usecase/Service 层 来组合业务逻辑。
但是,当业务逻辑里需要用到事务时,很多人会遇到一个老大难问题:

Repo 里的方法,默认用的是全局的 db,一旦加了事务就失效了。

举个例子:

// 假设你的 Repo 里有个保存订单的方法
func (r *orderRepo) Save(ctx context.Context, order *Order) error {
    return r.data.db.WithContext(ctx).Create(order).Error
}

在没有事务时,这样写没问题。
但如果上层用了事务:

uc.db.Transaction(func(tx *gorm.DB) error {
    // 这里想用事务保存订单
    return uc.orderRepo.Save(ctx, order)
})

结果你以为在事务里,实际上 Save 还是直接用的全局 db,它会独立提交。
万一后续库存扣减失败回滚了,订单还是照样写进数据库,数据就不一致了。

这就是典型的 读取-修改-写入冲突

二、为什么会这样?

GORM 的事务是通过 传入一个新的 *gorm.DB 句柄(tx) 来实现的,事务里的所有操作都必须基于这个 tx

而我们原来的 Repo 方法,直接用了 r.data.db,它并不知道外层有没有事务,更不可能自己切换到 tx

所以问题的根本原因是:
事务句柄(tx)和 Repo 方法之间没有打通。

三、解决方案:通过 Context 注入事务

既然 Repo 不能自己感知事务,那就需要上层把事务“带进去”。
比较优雅的做法是:context.Context 来传递事务句柄

1. 定义一个辅助函数

在 Repo 层写一个小工具函数,优先从 ctx 里拿事务句柄,如果没有,就用默认的 db

// 在 data 包里
type contextKey string

const txKey contextKey = "gorm_tx"

func (r *orderRepo) getDB(ctx context.Context) *gorm.DB {
    if tx, ok := ctx.Value(txKey).(*gorm.DB); ok {
        return tx
    }
    return r.data.db
}

2. 修改 Repo 方法

把所有用 r.data.db 的地方,替换成 r.getDB(ctx)

// 修改前
func (r *orderRepo) Save(ctx context.Context, order *Order) error {
    return r.data.db.WithContext(ctx).Create(order).Error
}

// 修改后
func (r *orderRepo) Save(ctx context.Context, order *Order) error {
    db := r.getDB(ctx).WithContext(ctx)
    return db.Create(order).Error
}

这样,Repo 方法就可以透明地在事务中运行了。

3. 在业务层注入事务

事务的启动还是放在 Usecase 层:

func injectTx(ctx context.Context, tx *gorm.DB) context.Context {
    return context.WithValue(ctx, txKey, tx)
}

func (uc *OrderUsecase) CreateOrder(ctx context.Context, order *Order, stockID int) error {
    return uc.db.Transaction(func(tx *gorm.DB) error {
        txCtx := injectTx(ctx, tx)

        // 订单保存 & 库存扣减,都走事务
        if err := uc.orderRepo.Save(txCtx, order); err != nil {
            return err
        }
        if err := uc.stockRepo.Deduct(txCtx, stockID, 1); err != nil {
            return err
        }
        return nil
    })
}

这样一来,事务句柄就能通过 ctx 一路传到 Repo,整个调用链都跑在事务里。

四、总结

  • 事务控制应该在业务层,不要放在 Repo。
  • Repo 层要支持事务,就得写成“可感知事务”的形式,常见做法是通过 context.Context 注入事务句柄。
  • 好处:业务层只要注入一次事务,Repo 方法就能无感接入,不需要大改。