背景
在 Go + GORM 的日常开发中,事务写法通常如下:
err := db.Transaction(func(tx *gorm.DB) error {
// 业务逻辑
if err := tx.Create(&user).Error; err != nil {
return err // 回滚
}
// 更多操作...
return nil // 提交
})
这个模型很直觉:返回 nil → 提交事务,返回 error → 回滚事务。
但最近踩了一个坑:在事务内吞掉了错误并返回 nil,GORM 却报了 commit unexpectedly resulted in rollback。
问题复现
简化后的伪代码如下:
err := db.Transaction(func(tx *gorm.DB) error {
// 第一条 SQL:创建记录
if err := tx.Create(&recordA).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
// 唯一键冲突,认为是可以接受的,跳过
return nil // ← 问题出在这里
}
return err
}
// 第二条 SQL:创建另一条记录
if err := tx.Create(&recordB).Error; err != nil {
return err
}
return nil
})
预期行为:遇到重复键冲突时,跳过并提交事务。
实际行为:
ERROR: commit unexpectedly resulted in rollback
事务直接失败,err 不为 nil。
根因分析
关键在于 GORM(以及底层的数据库驱动)的事务状态机:
SQL 执行报错时,数据库驱动会将事务标记为
aborted状态。 PostgreSQL 等数据库在遇到约束违反(如唯一键冲突)后,当前事务即进入 aborted 状态,后续所有操作都会被拒绝,直到执行 ROLLBACK。GORM 的
Transaction()方法看到你返回nil,就会尝试COMMIT。但 COMMIT 一个已经 aborted 的事务,数据库会返回错误——因为事务已经被隐式回滚了,无法提交。
时序如下:
tx.Create(&recordA) → 唯一键冲突,PostgreSQL 标记事务为 aborted
return nil → GORM 尝试 COMMIT
COMMIT → 数据库拒绝:事务已经是 aborted 状态
→ GORM 收到错误,报 "commit unexpectedly resulted in rollback"
核心问题:你吞掉了错误,但事务状态并没有"恢复原状"。数据库驱动已经记住了这个错误。
解决方案
思路:把"可预期的冲突"包装成一个特殊的错误类型,在事务外处理
第一步:定义业务错误类型
// ErrDuplicated 重复键冲突的业务错误
var ErrDuplicated = errors.New("record already exists")
第二步:事务内返回该错误,而不是 return nil
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&recordA).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return ErrDuplicated // ← 返回业务错误,让事务正常回滚
}
return err
}
if err := tx.Create(&recordB).Error; err != nil {
return err
}
return nil
})
第三步:在事务外部区分处理
if err != nil {
if errors.Is(err, ErrDuplicated) {
// 重复键冲突是可预期的,正常处理
log.Println("记录已存在,跳过")
return nil
}
// 其他错误正常上报
return err
}
为什么这样是对的?
- 事务内部:任何错误都如实返回,让 GORM 执行 ROLLBACK,保持事务状态干净。
- 事务外部:通过错误类型区分"业务可接受的冲突"和"真正的失败"。
- 不会触碰数据库事务状态机的"禁区"。
总结
| 做法 | 结果 |
|---|---|
事务内吞掉 DB 错误,返回 nil | 事务状态已 aborted,COMMIT 必然失败 |
| 事务内返回自定义错误,外部判断处理 | 事务正常 ROLLBACK,业务逻辑正确分流 |
记住:GORM 事务中,return nil 永远意味着"请提交"。如果你不能提交,就不要返回 nil。
即使某个错误在业务上是"可以接受的",也要在事务外部去接受它,而不是在事务内部悄悄吞掉它。