事务必须显式开启、手动提交或回滚,Gin 不封装事务逻辑,需在 handler 中用 GORM 的 Begin/Commit/Rollback 控制;中间件无法可靠统一管理事务生命周期,正确做法是将 *gorm.DB 实例通过 c.Set() 透传至 service 层,并确保每条路径都明确终结事务。

事务必须显式开启、手动提交或回滚,Gin 本身不提供任何事务封装逻辑。它只负责把请求上下文传递下去,真正控制事务生命周期的是你写的代码和 GORM 的 Begin/Commit/Rollback 调用。
为什么不能直接在 Gin 中间件里统一开事务
常见误区是想写一个中间件自动 DB.Begin(),然后靠 defer 或 recover 做回滚。这行不通,因为:
- Gin 中间件和 handler 是分阶段执行的,
defer在中间件函数退出时就触发,不是在整个请求生命周期结束时 - 多个 handler 可能共用一个 DB 实例,但事务是会话级的,不能跨 goroutine 或跨调用复用
- HTTP 请求可能被超时中断、客户端断连,Gin 不会通知你“该收尾了”,没法可靠触发
Commit或Rollback
正确的事务包裹方式:用 gin.Context 透传事务 DB 实例
核心思路是:在 handler 入口手动 Begin,把返回的 *gorm.DB 实例塞进 c.Set("tx", tx),后续 service 层从 context 取出来用。这样既避免全局变量污染,又保证事务作用域可控。
示例片段:
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
立即学习“go语言免费学习笔记(深入)”;
func (o *OrderController) AddOne(c *gin.Context) {
tx := global.DB.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 提交前任何一步出错都应显式 Rollback
if err := service.CreateOrder(tx, ...); err != nil {
tx.Rollback()
c.JSON(400, gin.H{"error": err.Error()})
return
}
if err := tx.Commit().Error; err != nil {
c.JSON(500, gin.H{"error": "commit failed"})
return
}
c.JSON(200, gin.H{"ok": true})
}
- 别依赖 panic 恢复来 rollback —— 有些错误(比如校验失败)根本不会 panic,必须主动判断
-
tx.Commit()本身可能失败(如锁冲突、主键冲突),要检查其Error - 不要把
tx存到全局变量或结构体字段里,它不是线程安全的
嵌套事务和 SavePoint 的实际使用场景
GORM 支持 SavePoint,但要注意它只是逻辑标记点,不是真正的子事务。MySQL 的 SAVEPOINT 本质是回滚锚点,不是隔离级别提升。
- 适合单个 handler 内部多步操作,某几步失败可局部回退,不影响前面已成功步骤(比如创建订单 + 扣库存,扣库存失败只 rollback 到 savepoint,订单仍可保留)
- 用法:
tx.SavePoint("sp1")→ 出错时tx.RollbackTo("sp1") - 不要指望用 SavePoint 实现“事务内事务”,GORM 的
Begin在已有事务上调用,仍是同一个底层 connection - MySQL 默认隔离级别是
REPEATABLE READ,SavePoint 不改变可见性,只控制回滚范围
事务最易被忽略的点是:**连接池复用下,事务状态不会自动清理**。一次 Begin 后没 Commit 或 Rollback,这个连接会被标记为“in transaction”状态,下次从池里取到它时可能带着未结束的事务上下文 —— 这会导致静默失败或数据不一致。务必确保每个 Begin 都有明确的终点路径。

















