事务必须用BeginTx(ctx, opts)启动,因Go原生Begin()不支持context超时控制;正确做法是传入带超时的ctx(如WithTimeout),所有子操作复用同一ctx,异步任务须脱离请求ctx,超时后需手动Rollback。

事务必须用 BeginTx(ctx, opts) 启动,不能用 Begin()
Go 原生 database/sql 的 Begin() 不接受 context.Context,它开启的事务完全脱离超时控制。哪怕你后续所有 QueryContext 都带超时,事务本身仍会卡在 open 状态——tx.Commit() 或 tx.Rollback() 仍可能执行成功,但数据已不可信。
正确入口只有 db.BeginTx(ctx, nil),且该 ctx 必须是带超时的,例如:
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
tx, err := db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
// ctx.Err() 可能已是 context.DeadlineExceeded
return err
}
- 事务生命周期从此刻起就和
ctx绑定:ctx 超时 → tx 底层驱动收到ctx.Done()→ 当前阻塞语句中断 - 不要在事务函数里新建
context.Background(),GORM 的Transaction()方法内部也是调用BeginTx,所以务必传入已设好超时的ctx -
DefaultTransactionTimeout仅对未显式传ctx的操作兜底,一旦用了WithContext(),它就完全失效
GORM Transaction() 闭包里必须复用同一个 ctx
很多人写成这样:
db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 错误:子查询又自己 new ctx
subCtx, _ := context.WithTimeout(context.Background(), 5*time.Second)
tx.WithContext(subCtx).First(&u)
return nil
})
这会导致主事务感知不到子操作超时,锁不释放、连接泄漏。正确做法是所有操作共用最外层 ctx:
- 所有
tx.WithContext(ctx)都传同一个ctx,包括 Savepoint、子查询、关联更新 - 若需更短超时(如降级),应在事务外就用
context.WithTimeout缩小范围,而不是在闭包里重造 - 嵌套事务(Savepoint)不是新事务,只是标记点,仍受同一
ctx约束
HTTP handler 中启 goroutine 必须脱离请求 ctx
这是线上最常踩的坑:在 Gin handler 里写 go func() { db.WithContext(c.Request.Context()).Save() }(),结果响应一返回,协程立刻收到 context canceled 错误。
- 请求级
c.Request.Context()生命周期随 HTTP 连接结束而终止,野生 goroutine 没有同步保障 - 异步任务必须用
context.Background()+ 自定义 timeout,或交由任务队列(如 Asynq)管理 - 若真要复用请求上下文,得用
context.WithCancel(c.Request.Context())显式延长,并确保在协程退出前调用cancel()
超时后必须手动 Rollback,不能依赖 defer
ctx.Err() 触发后,tx.Commit() 仍可能返回 nil,但此时数据库状态已混乱。GORM 不会自动回滚,也不会拦截已超时的提交。
- 每次事务操作后都应检查
if errors.Is(ctx.Err(), context.DeadlineExceeded),立即tx.Rollback() - 不要只靠
defer tx.Rollback():超时发生在中间步骤时,defer还没触发,而后续语句仍在执行 - 推荐模式:在事务闭包末尾统一判断
ctx.Err(),有错则return tx.Rollback(),否则return tx.Commit()
事务真正的边界不在 SQL 语句之间,而在 context 的 Done 通道被关闭那一刻。任何想绕过这个信号的操作,都会让一致性变成概率事件。











