This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/GORM/08-事务管理.md
T
2026-04-28 20:56:51 +08:00

16 KiB
Raw Blame History

tags, create time
tags create time
GORM
Go
ORM
事务
Tx
Commit
Rollback
Nested Transaction
2026-04-28 00:00

事务管理

概述

事务是数据库操作的「安全网」——它保证一组操作要么全部成功,要么全部失败。在现实业务中,你几乎处处离不开事务:转账时「扣 A 加 B」、下单时「减库存 + 创建订单 + 生成流水」,这些都需要事务来保证数据一致性。

flowchart TD
    Start[开始事务 tx = db.Begin] --> Op1["执行操作 1"]
    Op1 --> OK1{"操作 1<br/>成功?"}
    OK1 --> |是| Op2["执行操作 2"]
    OK1 --> |否| Rollback["tx.Rollback()"]
    Op2 --> OK2{"操作 2<br/>成功?"}
    OK2 --> |是| Op3["执行操作 3"]
    OK2 --> |否| Rollback
    Op3 --> OK3{"操作 3<br/>成功?"}
    OK3 --> |是| Commit["tx.Commit()"]
    OK3 --> |否| Rollback
    
    style Start fill:#4FC08D,color:#fff
    style Commit fill:#3B82F6,color:#fff
    style Rollback fill:#EF4444,color:#fff

[!definition] 事务四大特性(ACID)

特性 含义 示例
原子性(Atomicity) 全部提交或全部回滚 转账:A 扣钱和 B 加钱不可分割
一致性(Consistency) 事务前后数据满足业务规则 转账前后总金额不变
隔离性(Isolation) 并发事务互不干扰 两个用户同时下单不会超卖
持久性(Durability) 提交后永不过期 断电后数据仍在

基本用法

Begin / Commit / Rollback

// 1. 开启事务
tx := db.Begin()
defer func() {
    if r := recover(); r != nil {
        tx.Rollback() // panic 时确保回滚
    }
}()

// 2. 所有操作通过 tx 执行
user := User{Name: "Alice", Age: 30}
if err := tx.Create(&user).Error; err != nil {
    tx.Rollback()
    return err
}

order := Order{UserID: user.ID, Amount: 99.9}
if err := tx.Create(&order).Error; err != nil {
    tx.Rollback()
    return err
}

// 3. 全部成功,提交
if err := tx.Commit().Error; err != nil {
    return err
}
return nil

[!warning] defer Rollback 的注意事项 Commit() 成功后再执行 defer Rollback() 会报错(事务已提交)。所以推荐显式处理错误路径的回滚,defer 只用于兜底 panic。

GORM Transaction() 便捷方法

手动写 Begin + Commit/Rollback 很繁琐,GORM 提供了 db.Transaction() —— 它接受一个回调函数,内部自动处理开启、提交和回滚:

// 一行代码搞定事务:GORM 自动管理生命周期
err := db.Transaction(func(tx *gorm.DB) error {
    // tx 已经处于事务中,直接操作即可
    
    var fromAccount Account
    if err := tx.First(&fromAccount, fromID).Error; err != nil {
        return err // 返回 error → GORM 自动 Rollback
    }
    
    fromAccount.Balance -= amount
    if err := tx.Save(&fromAccount).Error; err != nil {
        return err // ← 同样自动 Rollback
    }
    
    // 返回 nil → GORM 自动 Commit
    return nil
})

[!tip] 为什么推荐 Transaction()?

  • 自动清理:回调内任何非 nil 的返回值都会触发 Rollback,不需要手动写
  • 回调内的事务是同一个 tx:在回调里不能再调 Transaction(),否则 panic
  • 无法直接访问 tx 实例:出了闭包就拿不到 tx 了,天然防止"事务泄漏"

💡 思考题:如果回调中需要向外层传递某个计算结果(比如新生成的订单号),该怎么设计?

答案:将结果放入闭包捕获的外部变量,或在回调内 Create 后从数据库 First 取出。

var orderID uint
err := db.Transaction(func(tx *gorm.DB) error {
    order := Order{Amount: 99.9}
    if err := tx.Create(&order).Error; err != nil {
        return err
    }
    orderID = order.ID // 通过闭包变量传出
    return nil
})

[!warning] Transaction() vs 手动 Begin()

场景 推荐方式
单个函数内的多步操作 db.Transaction() 回调
跨函数/跨包的分布式事务 手动 db.Begin() 并传入 *gorm.DB
Web 请求级自动事务 中间件 + defer 模式

[!tip] 理解 tx.Transaction 返回值 GORM 的 Begin() 返回 *gorm.DB,但内部包装了事务。这个实例只在事务范围内有效,不能跨事务使用。

事务中的错误处理

func TransferMoney(fromID, toID uint, amount float64) error {
    // 开启事务
    tx := db.Begin()
    
    // 查入账和出账账户(锁行!)
    var fromAccount Account
    if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&fromAccount, fromID).Error; err != nil {
        tx.Rollback()
        return fmt.Errorf("查询转出账户失败: %w", err)
    }
    
    var toAccount Account
    if err := tx.First(&toAccount, toID).Error; err != nil {
        tx.Rollback()
        return fmt.Errorf("查询入账账户失败: %w", err)
    }
    
    // 余额不足
    if fromAccount.Balance < amount {
        tx.Rollback()
        return errors.New("余额不足")
    }
    
    // 扣款
    fromAccount.Balance -= amount
    if err := tx.Save(&fromAccount).Error; err != nil {
        tx.Rollback()
        return fmt.Errorf("扣款失败: %w", err)
    }
    
    // 入账
    toAccount.Balance += amount
    if err := tx.Save(&toAccount).Error; err != nil {
        tx.Rollback()
        return fmt.Errorf("入账失败: %w", err)
    }
    
    return tx.Commit().Error
}

[!important] FOR UPDATE 锁行 事务中的查询如果要用更新的数据做决策,必须用 FOR UPDATE(排他锁),否则可能读到未提交的旧数据。 看看下面两种写法的区别:

// ❌ 竞态条件:两个并发请求可能都读到余额足够并一起扣款
db.Where("id = ?", id).First(&account)
account.Balance -= 100
db.Save(&account)

// ✅ 加锁读取,另一个事务只能等待当前事务结束
db.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", id).First(&account)
account.Balance -= 100
db.Save(&account)

💡 思考题:FOR UPDATE 在所有数据库中都叫这个名字吗? PostgreSQL 中同样使用 SELECT ... FOR UPDATE;但 SQLite 在事务期间默认就是串行执行的,不需要显式加锁。理解底层数据库的行为,才能写出可移植的代码。

嵌套事务

当代码结构有分层时,内部函数可能需要发起自己的事务。GORM 支持嵌套事务机制——内部实际上是保存点(Savepoint),而非真正的事务:

func CreateOrderWithAudit(tx *gorm.DB, order Order) error {
    // 检查外层是否已有事务
    if !tx.IsTransaction() {
        // 外层不是事务,自己开一个
        tx = db.Begin()
        defer func() {
            if v := recover(); v != nil {
                tx.Rollback()
                panic(v)
            }
        }()
    }
    
    // 保存点:如果内部失败可以回滚到这里,不影响外部
    tx.SavePoint("create_order")
    
    // 主逻辑
    if err := tx.Create(&order).Error; err != nil {
        tx.RollbackTo("create_order") // 回滚到保存点
        return err
    }
    
    // 审计日志记录
    audit := Audit{Table: "orders", Action: "create", RecordID: order.ID}
    if err := tx.Create(&audit).Error; err != nil {
        tx.RollbackTo("create_order") // 审计失败也可以回滚
        return err
    }
    
    // 成功 —— 如果不外层事务则提交,否则由外层统一提交
    if !tx.IsTransaction() {
        return tx.Commit().Error
    }
    return nil
}

[!tip] 嵌套事务的本质 MySQL/PostgreSQL 不支持真正的嵌套事务,它们用的是 Savepoint。这意味着:

  • 内部回滚只会回滚到最近的 savepoint,不会影响外层事务
  • 最终 COMMIT 仍然是一次性的,从外层事务发出
  • 如果外层事务 ROLLBACK,整个事务树(包括所有 savepoint)都会被回滚

事务级别控制

import "database/sql"

tx := db.Session(&gorm.Session{
    DryRun: false, // 模拟模式:构建 SQL 但不执行
}).Begin(&sql.TxOptions{
    Isolation: sql.LevelRepeatableRead, // 设置隔离级别
    ReadOnly:  false,                    // 读写事务
})

// 或者针对特定查询
db.Set("gorm:prepare_stmt", true).Find(&users) // 预编译语句

[!example] 隔离级别速查

级别 脏读 不可重复读 幻读 性能影响
Read Uncommitted ❌ 允许 ❌ 允许 ❌ 允许 最快
Read Committed ✅ 阻止 ❌ 允许 ❌ 允许 快
Repeatable Read(MySQL 默认) ✅ 阻止 ✅ 阻止 ⚠️ 部分阻止 中等
Serializable ✅ 阻止 ✅ 阻止 ✅ 阻止 最慢

事务与连接管理

理解事务底层的连接行为,是写出高性能代码的关键。一个常被忽视的事实:每个事务都会从连接池中独占一根连接——这意味着高并发下连接池配置直接影响吞吐量。

// 合理配置连接池参数
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
db.DB().SetMaxOpenConns(100)    // 最大连接数(含事务)
db.DB().SetMaxIdleConns(20)     // 空闲连接保留数
db.DB().SetConnMaxLifetime(time.Hour)

[!definition] 事务的连接行为

关键点 说明
独占连接 一个事务从连接池中取出一个连接,整个事务期间该连接归它独有
并发限制 同一个 *gorm.DB 在同一时刻只能有一个活跃事务
连接归还 Commit() 或 Rollback() 后,连接才回到连接池(而非真正关闭)
MaxIdleConns 影响 高并发场景下如果 MaxIdleConns 太小,大量事务会排队等待连接

[!warning] 长事务是最常见的性能杀手

以下做法会把连接占用时间拖得很长:

tx := db.Begin()

// ❌ 耗时操作放在事务内!其他事务可能要等这个锁很久
resp, _ := http.Get("http://external-api.com/validate")
tx.Create(&record)
tx.Commit()

// ✅ 先获取外部数据,再开启事务
resp, _ := http.Get("http://external-api.com/validate")
tx := db.Begin()
tx.Create(&record)
tx.Commit()

排查建议:通过数据库监控观察 information_schema.innodb_trx(MySQL),找出运行时间过长的事务。

手动管理事务中的错误回滚

在实际项目中,你可能需要一个更简洁的错误处理模式来减少重复代码:

// 通用的事务执行器
func ExecTx(db *gorm.DB, fn func(tx *gorm.DB) error) error {
    tx := db.Begin()
    defer func() {
        if r := recover(); r != nil {
            tx.Rollback()
            panic(r)
        }
    }()
    
    if err := fn(tx); err != nil {
        tx.Rollback()
        return err
    }
    return tx.Commit().Error
}

// 使用方式
err := ExecTx(db, func(tx *gorm.DB) error {
    var fromAccount Account
    if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&fromAccount, fromID).Error; err != nil {
        return fmt.Errorf("查询账户失败: %w", err)
    }
    if fromAccount.Balance < amount {
        return errors.New("余额不足")
    }
    fromAccount.Balance -= amount
    if err := tx.Save(&fromAccount).Error; err != nil {
        return fmt.Errorf("扣款失败: %w", err)
    }
    // ... 入账逻辑
    return nil
})

[!tip] 封装的代价与收益

  • 收益:消除每个函数里重复的 Begin/Rollback 样板代码,错误路径统一由 ExecTx 处理
  • 代价:无法在闭包外拿到 tx,调试时 log 某个中间状态稍不方便
  • 建议:简单场景用 db.Transaction(),复杂业务可考虑 ExecTx 封装

什么时候不该用事务?

过度使用事务是常见的性能陷阱。以下场景中你可以选择不使用事务:

[!tip] 免事务场景清单

场景 理由 替代方案
单条 INSERT / UPDATE / DELETE 本身就是原子的 直接调用即可
统计查询(COUNT、SUM) 只读不涉及数据修改 普通查询 + 缓存
写多读的流水表 追加即成功,无需原子性 批量 CreateInBatches
跨服务的分布式操作 涉及多个独立数据库 Saga / Outbox Pattern

💡 思考题:「扣库存」场景一定要用事务吗?

不一定。如果你的并发量不高,可以使用乐观锁——在库存表加一个 version 字段,更新时带上版本条件:

// 不用事务,靠版本号保证一致性
db.Model(&Product{}).
    Where("id = ? AND stock >= ? AND version = ?", id, qty, product.Version).
    Updates(map[string]any{
        "stock":   gorm.Expr("stock - ?", qty),
        "version": gorm.Expr("version + 1"),
    })

如果影响行数为 0,说明发生了冲突,重试即可。这在高并发读多写少的场景下比事务效率高得多。

🔍 延伸方向:比较「悲观锁」(FOR UPDATE)vs「乐观锁」(version 字段)的适用场景。

  • 悲观锁:写竞争激烈、冲突率高时使用
  • 乐观锁:读多写少、冲突率低时使用

事务与中间件结合

在实际项目中,经常需要将事务与 Gin 等 Web 框架集成,实现自动事务管理:

// Gin 中间件:每个 HTTP 请求自带一个事务
func TransactionMiddleware(db *gorm.DB) gin.HandlerFunc {
    return func(c *gin.Context) {
        // 开始事务
        tx := db.Begin()
        c.Set("db", tx) // 存入 Context
        
        defer func() {
            if p := recover(); p != nil {
                tx.Rollback()
                panic(p) // 重新抛出让上层捕获
            }
            
            // 请求处理完毕且无错误,提交事务
            if c.Writer.Status() >= 200 && c.Writer.Status() < 400 {
                tx.Commit()
            } else {
                tx.Rollback()
            }
        }()
        
        c.Next()
    }
}

// 在 handler 中使用
func CreateUser(c *gin.Context) {
    // 从 Context 获取当前事务
    txVal, _ := c.Get("db")
    tx := txVal.(*gorm.DB)
    
    user := User{Name: c.PostForm("name")}
    if err := tx.Create(&user).Error; err != nil {
        c.JSON(500, gin.H{"error": err.Error()})
        return
    }
    
    c.JSON(201, user)
}

事务决策流程图

flowchart TD
    Start[需要多步数据操作] --> InTx{"是否在已有<br/>事务中?"}
    InTx --> |是| SaveQ{"需要局部<br/>回滚能力?"}
    InTx --> |否| NewTx{"操作数量?"}
    
    NewTx --> |单条 SQL| SimpleTx["不需要事务<br/>直接操作即可"]
    NewTx --> |多条 SQL| BeginTx["db.Begin()"]
    
    SaveQ --> |需要| Savepoint["SavePoint('name')"]
    SaveQ --> |不需要| Continue["继续执行"]
    
    BeginTx --> Ops["依次执行各操作"]
    Continue --> Ops
    Savepoint --> Ops
    
    Ops --> AllOK{"全部成功?"}
    AllOK --> |是| Commit["Commit()"]
    AllOK --> |否| RollBack["Rollback()"]
    
    style Start fill:#4FC08D,color:#fff
    style Commit fill:#3B82F6,color:#fff
    style RollBack fill:#EF4444,color:#fff
    style SimpleTx fill:#A0AEC0,color:#fff

常见坑点速查

问题 原因 解决方案
忘记 Commit/Rollback 漏了错误分支的回滚逻辑 对所有错误分支显式调用 Rollback,或用 db.Transaction() 回调
事务内查询被其他事务修改 没加 FOR UPDATE 关键查询加 Set("gorm:query_option", "FOR UPDATE")
嵌套事务外层的 Commit 失效 内部使用了独立连接 始终复用同一个 *gorm.DB 对象
长事务导致行锁堆积 事务中包含耗时操作(RPC、HTTP) 将耗时操作移出事务范围
defer Rollback 导致已提交事务报错 Commit 后再执行 deferred Rollback 用 panic 兜底,正常路径不依赖 defer
连接池耗尽,请求卡死 MaxOpenConns 太小或事务未释放 调大 MaxOpenConns,排查泄露的事务
并发更新同一条记录覆盖数据 没做版本控制或行锁 用乐观锁(version)或悲观锁(FOR UPDATE)
SavePoint 名称冲突 多层嵌套用了相同的保存点名 使用唯一命名:fmt.Sprintf("sp_%d", time.Now().UnixNano())

关联笔记