事务与锁
GORM 提供了灵活的事务支持:自动事务(每个写操作为一个事务)、手动事务、SavePoint 回退、嵌套事务。同时支持悲观锁和乐观锁。
自动事务(默认)
GORM 默认将单个 Create、Update、Delete 操作包裹在事务中执行:
// 下面这行代码实际上执行了:
// BEGIN
// INSERT INTO users (name, age) VALUES ('张三', 18)
// COMMIT
db.Create(&User{Name: "张三", Age: 18})// 若要关闭默认事务(提升写性能约 30%)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
SkipDefaultTransaction: true,
})⚡ 性能提示:关闭默认事务可提升大量单条写入的性能(约 30%),但会失去单条写入的原子性。需要保证单个
Create的原子性时,开启默认事务。
手动事务
基础事务
// 方式一:闭包事务(推荐 — 自动 Commit/Rollback)
err := db.Transaction(func(tx *gorm.DB) error {
// 所有在 tx 上的操作都在一个事务内
if err := tx.Create(&User{Name: "张三"}).Error; err != nil {
return err // 返回错误 → 自动 Rollback
}
if err := tx.Create(&Order{UserID: 1, Amount: 100}).Error; err != nil {
return err
}
return nil // 返回 nil → 自动 Commit
})// 方式二:手动控制(需要手动管理 Commit/Rollback)
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
if err := tx.Create(&User{Name: "张三"}).Error; err != nil {
tx.Rollback()
return
}
if err := tx.Create(&Order{UserID: 1, Amount: 100}).Error; err != nil {
tx.Rollback()
return
}
tx.Commit()💡 最佳实践:优先使用闭包事务
db.Transaction()——它会自动处理 Commit/Rollback 和 panic 恢复,避免遗漏。
SavePoint — 事务内回退点
err := db.Transaction(func(tx *gorm.DB) error {
// 创建用户
tx.Create(&User{Name: "张三"})
// 设置 SavePoint
tx.SavePoint("sp1")
// 尝试某操作
if err := tx.Create(&Order{UserID: 1, Amount: -100}).Error; err != nil {
// 回退到 sp1(撤销负金额订单的创建)
tx.RollbackTo("sp1")
}
// 继续其他操作
return tx.Create(&Order{UserID: 1, Amount: 200}).Error
})SavePoint 适用于:一个事务内需要尝试多个操作,且部分操作失败不应影响整体事务。
嵌套事务
GORM 支持嵌套事务——内部事务的 Rollback 不影响外部事务:
db.Transaction(func(tx *gorm.DB) error {
tx.Create(&User{Name: "张三"}) // 外部事务
// 嵌套事务
tx.Transaction(func(tx2 *gorm.DB) error {
tx2.Create(&Order{UserID: 1, Amount: 100})
return errors.New("模拟失败") // 仅回滚嵌套事务
})
// 外部事务继续执行,不受嵌套事务失败影响
tx.Create(&User{Name: "李四"})
return nil
})
// 最终结果:张三创建成功,李四创建成功,订单未创建🔬 深入原理:MySQL 不支持真正的嵌套事务。GORM 通过 SAVEPOINT 模拟:
嵌套事务 BEGIN → SAVEPOINT sp_xxx
嵌套事务 COMMIT → RELEASE SAVEPOINT sp_xxx
嵌套事务 ROLLBACK → ROLLBACK TO SAVEPOINT sp_xxx悲观锁(Pessimistic Lock)
悲观锁在读取时锁定行,直到事务提交才释放:
// MySQL / PostgreSQL: SELECT ... FOR UPDATE
db.Transaction(func(tx *gorm.DB) error {
var product Product
// 锁定行,阻止其他事务修改
tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", 1).
First(&product)
if product.Stock <= 0 {
return errors.New("库存不足")
}
// 扣库存
return tx.Model(&product).
Where("id = ?", product.ID).
Update("stock", gorm.Expr("stock - ?", 1)).Error
})
// 生成 SQL:SELECT * FROM products WHERE id = 1 FOR UPDATE悲观锁类型
| Strength | MySQL | PostgreSQL |
|---|---|---|
"UPDATE" |
FOR UPDATE(排他锁) |
FOR UPDATE |
"SHARE" |
FOR SHARE / LOCK IN SHARE MODE(共享锁) |
FOR SHARE |
"NO KEY UPDATE" |
— | FOR NO KEY UPDATE |
"KEY SHARE" |
— | FOR KEY SHARE |
// 共享锁(允许其他事务读,但阻止写)
tx.Clauses(clause.Locking{Strength: "SHARE"}).First(&product)
// 跳过已锁定的行(避免等待)
tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).First(&product)
// MySQL 8.0+: SELECT ... FOR UPDATE NOWAIT
// PostgreSQL: SELECT ... FOR UPDATE NOWAIT
// 跳过已锁定的行(MySQL)
tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).First(&product)💡 扣库存场景的标准模式:
BEGINSELECT ... FOR UPDATE(锁定目标行)- 校验库存是否充足
UPDATE stock = stock - 1COMMIT
乐观锁(Optimistic Locking)
乐观锁通过版本号字段实现,不使用数据库行锁:
GORM 内置乐观锁
import "gorm.io/plugin/optimisticlock"
type Product struct {
gorm.Model
Name string
Stock int
Version optimisticlock.Version // 自动版本号管理
}
// 更新时自动携带 version 条件
db.Save(&product)
// UPDATE products SET stock = 9, version = version + 1
// WHERE id = 1 AND version = 2
// 如果 version 已被其他事务修改 → RowsAffected = 0手动实现乐观锁
type Product struct {
gorm.Model
Stock int
Version int
}
// 更新时检查版本号
result := db.Model(&Product{}).
Where("id = ? AND version = ?", product.ID, product.Version).
Updates(map[string]interface{}{
"stock": newStock,
"version": gorm.Expr("version + 1"),
})
if result.RowsAffected == 0 {
return errors.New("并发冲突:数据已被他人修改,请重试")
}悲观锁 vs 乐观锁对比
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 机制 | 数据库行锁 (FOR UPDATE) | 版本号比对 |
| 并发冲突频率 | 适合高冲突场景 | 适合低冲突场景 |
| 性能 | 等待锁,吞吐量较低 | 无锁等待,吞吐量高 |
| 实现复杂度 | 低(数据库原生支持) | 需重试逻辑 |
| 死锁风险 | 有(需注意锁顺序) | 无 |
| 适用场景 | 扣库存、转账 | 编辑表单、更新配置 |
💡 扣库存等涉及有限资源竞争的场景优先用悲观锁;编辑表单等用户主动操作场景优先用乐观锁。
死锁预防
// 错误的锁顺序(可能死锁):
// 事务A: 锁产品1 → 锁产品2
// 事务B: 锁产品2 → 锁产品1
// 正确做法:始终按相同顺序加锁
func TransferStock(tx *gorm.DB, fromID, toID uint, quantity int) error {
// 始终先锁 ID 较小的记录
first, second := fromID, toID
if fromID > toID {
first, second = toID, fromID
}
var p1, p2 Product
tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&p1, first)
tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&p2, second)
// ... 执行转移逻辑
return nil
}💡 死锁预防的核心原则:所有事务对资源的锁定顺序必须一致。
事务隔离级别
// MySQL 查看/设置隔离级别
db.Exec("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")
// 或通过 DSN 配置
// MySQL: ...&transaction_isolation=READ-COMMITTED
// PostgreSQL: ...&default_transaction_isolation=read committed| 隔离级别 | 脏读 | 不可重复读 | 幻读 | MySQL 默认 | PostgreSQL 默认 |
|---|---|---|---|---|---|
READ UNCOMMITTED |
✅ | ✅ | ✅ | ❌ | ❌ |
READ COMMITTED |
❌ | ✅ | ✅ | ❌ | ✅ |
REPEATABLE READ |
❌ | ❌ | ✅(部分) | ✅ | ❌ |
SERIALIZABLE |
❌ | ❌ | ❌ | ❌ | ❌ |
💡 PostgreSQL 默认
READ COMMITTED;MySQL 默认REPEATABLE READ。大多数 Web 应用使用READ COMMITTED即可满足需求。
常见陷阱汇总
| 陷阱 | 说明 |
|---|---|
| 🚨 事务忘记 Commit | 手动事务忘记 tx.Commit(),连接一直持有锁 |
| 🚨 长事务 | 事务内做网络请求/文件 IO → 锁持有时间过长 |
| 🚨 事务未重试 | 死锁检测后不重试,直接返回错误给用户 |
| 🚨 默认事务开销 | 单条写也 BEGIN→COMMIT,频繁写入有性能开销 |
| 🚨 锁升级 | MySQL 中不合理的查询条件导致行锁升级为表锁 |
| 🚨 非索引列锁 | MySQL 中 FOR UPDATE 通过非索引列过滤可能锁定全表 |
| 🚨 PostgreSQL 锁差异 | PG 的 FOR UPDATE 与 MySQL 行为不同,注意测试 |