一、背景
在写 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 方法就能无感接入,不需要大改。