savepoint 不是嵌套事务,只是局部回滚标记;gorm 中 tx.begin() 在已有事务内实为 savepoint,不新建事务、不改变隔离级别,外层 rollback 会清除所有保存点,必须统一用 tx.savepoint()/rollbackto() 并检查错误,避免混用原生 sql。

Savepoint 不是嵌套事务,只是局部回滚标记
GORM 的 tx.Begin() 在已有事务内调用,不会开启新事务,而是执行 SAVEPOINT sp_xxx 并返回一个带保存点名的 *gorm.DB。它不改变隔离级别、不新建连接、也不提供“子事务提交”能力。所谓“局部重试”,本质是:回滚到某个保存点后,继续执行后续逻辑——外层已提交的操作不受影响,但保存点之后的变更被丢弃。
常见误解是把它当 REQUIRES_NEW 用,结果发现 inner.Rollback() 后外层还能继续跑,误以为“事务隔离成功”。其实这只是 SQL 层面的保存点机制,不是 ACID 意义上的嵌套。
- PostgreSQL 和 MySQL 5.7+ 支持 SAVEPOINT,SQLite 默认禁用,需确认驱动启用
_cgo或配置sqlite3.WithSavepoint() - 外层
tx.Rollback()会清除所有保存点,此时再调tx.RollbackTo("sp")必报ERROR: no such savepoint - 别混用
tx.SavePoint("sp")和原生tx.Exec("SAVEPOINT sp"),后者可能被 GORM 内部状态忽略,导致 RollbackTo 失效
怎么安全创建和使用 Savepoint
必须统一用 GORM 提供的接口操作保存点,且每次 RollbackTo() 后必须检查错误。失败不等于“没回滚”,而可能是保存点已不存在(比如外层已 rollback),这时应降级为整体回滚。
- 生成唯一保存点名:用
uuid.NewString(),避免硬编码如"sp1"导致命名冲突 - 正确写法:
sp := uuid.NewString(); tx.SavePoint(sp); ...; tx.RollbackTo(sp) -
tx.RollbackTo(sp)返回 error,若非nil,应立刻return tx.Rollback().Error,不能忽略 - 不要在事务外保存
sp字符串跨函数传递——不同 goroutine 或并发请求可能覆盖或错用
局部重试的典型场景与写法
真正需要 Savepoint 的场景极少,通常是“A 步失败不影响 B 步,但 B 步失败要回滚 A 后的状态”。例如:扣库存成功后发消息失败,希望只回滚发消息部分,保留扣库存在;但这个需求本身就有问题——发消息不该进事务。更合理的做法是拆成两个独立事务 + 幂等重试。
如果仍坚持用 Savepoint 实现局部重试,示例结构如下:
db.Transaction(func(tx *gorm.DB) error {
// 步骤1:扣库存(主流程)
if err := tx.Model(&Stock{}).Where("id = ?", itemID).Update("quantity", gorm.Expr("quantity - 1")).Error; err != nil {
return err
}
<pre class="brush:php;toolbar:false;">// 步骤2:尝试记录日志(可选、允许失败)
sp := uuid.NewString()
if err := tx.SavePoint(sp).Error; err != nil {
return err
}
if err := tx.Create(&Log{Action: "deduct"}).Error; err != nil {
// 日志失败,只回滚这一步
if rerr := tx.RollbackTo(sp).Error; rerr != nil {
return tx.Rollback().Error // 降级整体回滚
}
// 继续往下走,不 return
}
// 步骤3:更新订单状态(必须成功)
if err := tx.Model(&Order{}).Where("id = ?", orderID).Update("status", "paid").Error; err != nil {
return err
}
return nil})
- 所有 DB 操作必须用
tx,不能混用全局db,否则 Savepoint 失效 - Savepoint 后的失败分支不能直接
return err,否则外层事务会因该 error 回滚全部——你要的是“局部失败”,不是“整体失败” - HTTP 调用、缓存写入、MQ 发送一律禁止放在 Savepoint 块内,它们无法回滚,且会拖住事务
比 Savepoint 更稳的替代方案
Savepoint 容易踩坑,尤其在并发、超时、错误处理链路长时。多数所谓“局部重试”需求,其实是业务边界没理清。优先考虑:
- 把“可容忍失败”的操作(如日志、通知)移出事务,改用
tx.AfterCommit()异步触发 - 对必须可靠执行的外部动作(如发消息),改用本地消息表 + 定时任务补偿,保证最终一致性
- 关闭 GORM 默认单语句事务(
SkipDefaultTransaction: true),自己按业务粒度显式包Transaction(),减少隐式开销 - 复杂流程拆成多个短事务,每个事务有明确输入/输出和幂等 key,失败时由上层协调重试
Savepoint 的真正难点不在语法,而在判断“哪一步真的可以局部回滚”——数据库能回滚的只是数据变更,而业务一致性往往依赖多系统协同,这部分没法靠 SQL 解决。











