GORM 事务中的隐藏陷阱:return nil 不代表没事发生

背景

在 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(以及底层的数据库驱动)的事务状态机:

  1. SQL 执行报错时,数据库驱动会将事务标记为 aborted 状态。 PostgreSQL 等数据库在遇到约束违反(如唯一键冲突)后,当前事务即进入 aborted 状态,后续所有操作都会被拒绝,直到执行 ROLLBACK。

  2. GORM 的 Transaction() 方法看到你返回 nil,就会尝试 COMMIT

  3. 但 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

即使某个错误在业务上是"可以接受的",也要在事务外部去接受它,而不是在事务内部悄悄吞掉它。