Go 的 sql.Tx 不自动回滚,必须显式调用 Rollback();需 defer tx.Rollback() 并在成功时 Commit(),所有事务操作须传入 *sql.Tx 而非 db,且 SAVEPOINT 需手写 SQL 实现。

Go 的 sql.Tx 不自动回滚,必须显式调用 Rollback()
很多人以为只要用了 Begin() 就进了事务安全区,其实不是。Go 标准库的 sql.Tx 完全不感知业务逻辑成败,哪怕 panic 或 return 前忘了 Commit() 或 Rollback(),连接就卡在事务中,后续可能被复用导致状态错乱或死锁。
实操建议:
- 务必用
defer tx.Rollback()开头,再在成功路径末尾tx.Commit()并return,确保唯一出口能提交 - 不要在
defer里直接写tx.Rollback()而不判断状态——它会在已Commit()后再执行,报"sql: transaction has already been committed or rolled back" - 更稳妥写法是定义一个
err变量,在所有 SQL 操作后检查,只在err == nil时Commit(),否则让defer回滚
嵌套函数调用时,*sql.Tx 必须手动透传,Go 没有上下文自动绑定事务
不像 Python 的 Django 或 Java 的 Spring,Go 的 database/sql 没有“当前事务上下文”概念。你在 func A() 里开了 tx,调 B() 时如果不把 tx 当参数传进去,B() 用的还是 db.Exec() —— 那是新连接、新事务,完全脱离 ACID 保证。
常见错误现象:
立即学习“go语言免费学习笔记(深入)”;
-
B()插入成功但A()最终Rollback(),B()的数据还在库里 - 多个函数各自开
Begin(),互相不知道对方在事务里,隔离性失效
实操建议:
- 所有参与事务的数据库操作函数,签名统一改成接收
*sql.Tx参数,例如func CreateUser(tx *sql.Tx, name string) error - 避免封装成 “通用 DB 层” 自动选
db还是tx——逻辑分支易出错,且掩盖了事务边界
tx.QueryRow() 和 tx.Exec() 才属于事务,db.QueryRow() 永远游离在外
这是最隐蔽的坑:看起来只差一个变量名,实际执行环境天壤之别。用 db 对象发的任何查询/更新,哪怕和 tx 在同一 goroutine、同一函数里,也绝不共享事务快照、隔离级别或回滚能力。
使用场景提醒:
- 读取主键生成的 ID(如 PostgreSQL 的
RETURNING id),必须用tx.QueryRow(),否则刚插入的数据对后续db.QueryRow()不可见(取决于隔离级别) - 事务中做条件判断(比如先查余额再扣减),必须全部走
tx,否则查的是旧快照或已提交数据,导致超扣
参数差异小但关键:tx.QueryRow() 返回的 *sql.Row 底层绑的是事务连接;db.QueryRow() 绑的是连接池里的任意空闲连接,不可控。
PostgreSQL 的 SAVEPOINT 需手写 SQL,database/sql 不支持子事务
标准库 sql.Tx 是扁平事务模型:要么全提交,要么全回滚。如果你需要“局部失败不影响整体”,比如批量导入中某条脏数据跳过,就得靠数据库原生 SAVEPOINT,Go 层不提供封装。
实操建议:
- 用
tx.Exec("SAVEPOINT sp1")设保存点,出错时tx.Exec("ROLLBACK TO SAVEPOINT sp1"),而不是tx.Rollback() - 注意 PostgreSQL 的保存点名不能重复,建议用带前缀的随机字符串或递增序号,避免嵌套时冲突
- MySQL 5.7+ 也支持
SAVEPOINT,但 SQLite 的实现较弱,某些驱动版本下不兼容,上线前必须实测
ACID 里的 “A”(原子性)和 “I”(隔离性)在 Go 里不是开箱即得,而是靠你亲手把每个 tx 传对、每个 Rollback() 写稳、每个查询都确认走的是 tx 而不是 db —— 少一个点,事务就漏了。










