5月28日 01:11

GORM 中如何使用事务?

GORM 中的事务机制是保证数据库操作原子性和一致性的核心能力。面试中常围绕手动事务与自动事务的区别、嵌套事务的实现原理、隔离级别的选择策略展开追问。

GORM 的事务模式有哪些?

GORM 提供三种事务使用方式,适用场景各不相同:

自动事务(默认行为)

GORM 默认将单个 Create/Update/Delete 操作包裹在事务中执行,确保单条写入的原子性:

go
// GORM 内部自动开启事务,执行完毕后自动提交 db.Create(&user) db.Save(&user) db.Delete(&user)

如果业务不需要这个默认行为,可以在初始化时关闭以获得约 30% 的性能提升:

go
db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{ SkipDefaultTransaction: true, })

关闭后,单个写操作不再自动开启事务,需要自行保证数据一致性。

手动事务

当多个操作必须作为原子单元执行时,需要手动控制事务边界:

go
tx := db.Begin() defer func() { if r := recover(); r != nil { tx.Rollback() } }() if err := tx.Create(&order).Error; err != nil { tx.Rollback() return err } if err := tx.Model(&product).Update("stock", gorm.Expr("stock - ?", 1)).Error; err != nil { tx.Rollback() return err } return tx.Commit().Error

关键要点:事务内必须使用 tx 而非 db 执行操作,否则操作不会参与事务。

回调事务(推荐方式)

db.Transaction() 封装了 Begin/Commit/Rollback 的样板代码,返回 error 自动回滚,返回 nil 自动提交:

go
err := db.Transaction(func(tx *gorm.DB) error { if err := tx.Create(&order).Error; err != nil { return err } if err := tx.Model(&product).Update("stock", gorm.Expr("stock - ?", 1)).Error; err != nil { return err } return nil })

即使闭包内发生 panic,Transaction 方法也会自动回滚。相比手动事务,代码更简洁且不易遗漏 Rollback。

嵌套事务与保存点如何工作?

GORM 通过数据库的 SAVEPOINT 机制实现嵌套事务。内层 tx.Transaction() 会创建保存点而非新事务:

go
err := db.Transaction(func(tx *gorm.DB) error { if err := tx.Create(&user).Error; err != nil { return err } // 内层嵌套:创建保存点 sp1 return tx.Transaction(func(tx2 *gorm.DB) error { if err := tx2.Create(&profile).Error; err != nil { return err // 回滚到 sp1,外层事务继续 } return nil // 释放保存点 sp1 }) })

也可以手动控制保存点:

go
tx.SavePoint("sp1") // 执行一些操作... tx.RollbackTo("sp1") // 回滚到保存点 tx.Exec("RELEASE SAVEPOINT sp1") // 释放保存点

嵌套事务适用于需要部分回滚的复杂业务场景,比如订单主流程中某个可选步骤失败时,只回滚该步骤而不影响主流程。

事务隔离级别怎么选?

GORM 通过 sql.TxOptions 设置隔离级别,在 Begin 时传入:

go
// 串行化隔离——最高一致性,最低并发 tx := db.Begin(&sql.TxOptions{ Isolation: sql.LevelSerializable, }) // 只读事务——适用于报表查询,数据库可优化执行 tx := db.Begin(&sql.TxOptions{ ReadOnly: true, })

四种隔离级别的选择依据:

隔离级别脏读不可重复读幻读适用场景
Read Uncommitted可能可能可能几乎不用
Read Committed不会可能可能大多数业务默认选择
Repeatable Read不会不会可能MySQL 默认,订单/库存场景
Serializable不会不会不会资金/账户等强一致性场景

面试追问:MySQL InnoDB 默认是 Repeatable Read,通过 MVCC + Next-Key Lock 在很大程度上避免了幻读,不需要轻易升级到 Serializable。

如何用 Context 控制事务超时?

长时间运行的事务会持有锁,阻塞其他请求。通过 Context 设置超时是生产环境的必要做法:

go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() tx := db.BeginTx(ctx, nil) if err := tx.Create(&order).Error; err != nil { tx.Rollback() return err } return tx.Commit().Error

Context 超时或取消后,事务会自动回滚。也可以用 db.WithContext(ctx) 配合回调事务:

go
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return tx.Create(&order).Error })

事务中的行级锁怎么用?

在并发场景下,仅靠事务隔离级别可能不够,需要加行级锁防止并发修改:

go
// 悲观锁:查询时加 FOR UPDATE,其他事务无法修改该行 var account Account err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", accountID). First(&account).Error

典型场景——扣减余额的完整实现:

go
func DeductBalance(db *gorm.DB, accountID uint, amount float64) error { return db.Transaction(func(tx *gorm.DB) error { var account Account // 加锁查询,防止并发扣减导致余额为负 if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ?", accountID). First(&account).Error; err != nil { return err } if account.Balance < amount { return errors.New("余额不足") } return tx.Model(&account).Update("balance", gorm.Expr("balance - ?", amount)).Error }) }

如果不加 FOR UPDATE,两个并发请求可能同时读到余额为 100,都判断通过后各自扣减,最终余额为负数。

事务使用有哪些常见陷阱?

忘记使用 tx 而非 db

go
// 错误:db 不在事务中 tx := db.Begin() db.Create(&order) // 这个操作不在事务内! tx.Create(&profile) tx.Commit()

未处理 panic

手动事务如果忘记 recover,panic 会导致事务既不提交也不回滚,连接泄漏:

go
tx := db.Begin() defer func() { if r := recover(); r != nil { tx.Rollback() } }()

使用 db.Transaction() 则无需担心,它内部已处理 panic。

事务范围过大

在事务中执行耗时操作(外部 API 调用、文件上传等)会导致锁持有时间过长:

go
// 错误:事务中调用外部服务 db.Transaction(func(tx *gorm.DB) error { tx.Create(&order) result, err := paymentService.Charge() // 耗时操作,不应在事务中 if err != nil { return err } tx.Create(&payment) return nil })

正确做法是先完成事务外的准备工作,事务内只做数据库操作。

忽略 Begin 返回的错误

go
tx := db.Begin() // 如果连接池耗尽,Begin 可能返回带错误的 tx // 后续操作看似正常但实际不在事务中 if tx.Error != nil { return tx.Error }

追问:GORM 事务在微服务架构下够用吗?

GORM 的事务机制局限于单个数据库实例。在微服务架构下,一个业务操作可能跨多个服务,每个服务有独立的数据库,此时需要分布式事务方案:

  • Saga 模式:将长事务拆分为一系列本地事务,每个本地事务完成后发送事件触发下一步,失败时通过补偿操作回滚
  • TCC(Try-Confirm-Cancel):业务层面实现 Try(预留资源)、Confirm(确认提交)、Cancel(取消回滚)三个阶段
  • 基于消息队列的最终一致性:通过消息队列确保各服务操作最终一致

GORM 事务是本地事务的基础,分布式事务框架(如 dtm、seata-go)底层仍依赖它来完成单个服务内的数据库操作。

标签:Gorm