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

475 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [GORM, Go, ORM, 事务, Tx, Commit, Rollback, Nested Transaction]
create time: 2026-04-28 00:00
---
# 事务管理
## 概述
事务是数据库操作的「安全网」——它保证一组操作要么全部成功,要么全部失败。在现实业务中,你几乎处处离不开事务:转账时「扣 A 加 B」、下单时「减库存 + 创建订单 + 生成流水」,这些都需要事务来保证数据一致性。
```mermaid
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
```go
// 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()` —— 它接受一个回调函数,内部自动处理开启、提交和回滚:
```go
// 一行代码搞定事务: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` 取出。
> > ```go
> > 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`,但内部包装了事务。这个实例只在事务范围内有效,不能跨事务使用。
## 事务中的错误处理
```go
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`(排他锁),否则可能读到未提交的旧数据。
> 看看下面两种写法的区别:
>
> ```go
> // ❌ 竞态条件:两个并发请求可能都读到余额足够并一起扣款
> 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),而非真正的事务:
```go
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)都会被回滚
### 事务级别控制
```go
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 | ✅ 阻止 | ✅ 阻止 | ✅ 阻止 | 最慢 |
## 事务与连接管理
理解事务底层的连接行为,是写出高性能代码的关键。一个常被忽视的事实:**每个事务都会从连接池中独占一根连接**——这意味着高并发下连接池配置直接影响吞吐量。
```go
// 合理配置连接池参数
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] 长事务是最常见的性能杀手
>
> 以下做法会把连接占用时间拖得很长:
> ```go
> 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),找出运行时间过长的事务。
### 手动管理事务中的错误回滚
在实际项目中,你可能需要一个更简洁的错误处理模式来减少重复代码:
```go
// 通用的事务执行器
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` 字段,更新时带上版本条件:
> ```go
> // 不用事务,靠版本号保证一致性
> 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 框架集成,实现自动事务管理:
```go
// 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)
}
```
## 事务决策流程图
```mermaid
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())` |
## 关联笔记
- [[01-安装与初始化]]
- [[03-CRUD 操作]]
- [[14-错误处理]]
- [[15-性能优化]]