go无真正嵌套事务,只能用savepoint模拟局部回滚;database/sql中在已有事务上调用begin()会panic,必须复用*sql.tx并手动管理保存点。

Go 没有真正的嵌套事务,所谓“嵌套”全是靠 SAVEPOINT 模拟;事务传播行为(如 REQUIRES_NEW)也不存在于标准库或主流 ORM 中,必须手动实现逻辑隔离。
database/sql 里调 Begin() 或 BeginTx() 在已有事务中会 panic
标准库 database/sql 和所有主流驱动(pgx、mysql、sqlite3)都不支持在已存在的事务内再调 Begin()。这不是设计疏漏,而是 SQL 标准本身不允许多层事务嵌套。
-
db.Begin()在事务上下文中调用 → 直接 panic(pgx报"pq: SAVEPOINT can only be used in transaction blocks") -
db.BeginTx(ctx, opts)同样失败,因为底层连接已被占用,无法新建事务句柄 - 唯一合法做法:复用当前
*sql.Tx,通过SAVEPOINT划分逻辑边界
GORM 的 Transaction() 不支持传播,Begin() 在事务内只是创建保存点
GORM 的 tx.Begin() 看似像开启子事务,实际只是执行 SAVEPOINT sp_xxx 并返回一个带保存点名的 *gorm.DB。它不产生新事务对象,也不改变隔离级别或超时控制。
- 调用
inner.Rollback()→ 实际执行ROLLBACK TO SAVEPOINT sp_xxx,外层事务状态不变 - 调用
inner.Commit()→ 无意义,GORM 会忽略,保存点不提供提交能力 -
db.Transaction()是一次性闭环,不能中途“退出但不回滚”,也不支持挂起外层事务再开新事务(即没有REQUIRES_NEW语义) - 若需类似效果,得手动
tx.Rollback()外层后,再用db.BeginTx()开新事务——但这已脱离原上下文,需自行管理重试和状态同步
context 超时只绑定在 BeginTx() 那一刻,后续所有 tx.Query() 都依赖它
事务生命周期的 context 控制是单向且不可替换的。你不能在事务中临时换一个 context 去查数据,也不能指望 tx.QueryContext(ctx2, ...) 生效——*sql.Tx 根本没有 QueryContext 方法。
- ✅ 正确:用
db.BeginTx(ctx, nil)启动,之后所有tx.Exec()、tx.Query()自动响应该ctx的取消或超时 - ❌ 错误:事务中混用
db.QueryRow()→ 这走的是普通连接池,不受事务 context 约束,可能超时未触发、panic 未捕获 - ⚠️ 隐患:若封装了日志写入、埋点上报等辅助操作,且它们内部用了独立
*sql.DB实例,那些操作就游离在事务 context 之外
SAVEPOINT 名必须唯一,且不能跨事务复用
保存点不是全局资源,它只在当前事务生命周期内有效。命名冲突或重复使用会导致 ROLLBACK TO SAVEPOINT 行为不可预测,尤其在并发请求共用同一事务对象时(比如中间件透传 tx)。
- 别硬编码
"sp1"或"savepoint"—— 多个 goroutine 同时执行会互相覆盖 - 推荐用
uuid.NewString()或带调用栈前缀的字符串(如"sp_user_create_" + uuid.NewString()) - MySQL/PostgreSQL 都会在事务
COMMIT或ROLLBACK后自动清理所有保存点,无需手动释放 - 不要试图在保存点后调
tx.Commit()—— 它只提交整个事务,保存点本身不参与提交流程
最易被忽略的一点:SAVEPOINT 是局部回滚工具,不是事务调度器。它解决不了跨服务、跨数据库、跨网络调用的“分布式事务”问题,也提供不了 REQUIRES_NEW 那种挂起+恢复的能力。你需要自己判断哪些操作必须强一致、哪些可以降级,再决定是用保存点兜底,还是拆成多个独立事务加补偿逻辑。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











