475 lines
16 KiB
Markdown
475 lines
16 KiB
Markdown
---
|
||
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-性能优化]]
|