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

事务必须用同一个 *gorm.DB 实例
跨 handler 或不同 repository 实例调用 DB.Create、DB.Update 等操作,哪怕都指向同一数据库,也默认不在同一事务内。GORM 的事务上下文只在传入的 *gorm.DB 实例内部生效。
常见错误是:在 service 层拆分多个函数,每个函数都直接用全局 models.DB,结果事务无法回滚。
- 正确做法:显式将事务句柄
tx作为参数传递给所有参与操作的函数 - 或统一在 handler 层开启事务,所有 repository 方法接收
*gorm.DB参数(而非硬编码全局DB) - 避免在事务中混用
models.DB和tx,否则部分操作会脱离事务控制
Preload + 事务组合容易丢数据
Preload 是查询时加载关联数据,它本身不参与写操作;但若你在事务中先 tx.Preload("Lesson").Find(&student),再修改 student.Lesson 并 tx.Save(&student),GORM 不会自动同步更新中间表 lesson_student —— 多对多关系需显式操作。
典型场景:学生退课、选新课,不能只改内存里的 slice,必须用 Association API。
- 添加关联:
tx.Model(&student).Association("Lesson").Append(&lesson1, &lesson2) - 删除关联:
tx.Model(&student).Association("Lesson").Delete(&lesson3) - 替换全部:
tx.Model(&student).Association("Lesson").Replace(lessons...) - 注意:这些操作必须在同一个
tx上执行,且不能和Preload混用为“读+改”一步到位
嵌套事务与 SavePoint 的实际取舍
GORM 的 tx.SavePoint("sp1") 和 tx.RollbackTo("sp1") 看似灵活,但 MySQL 对 savepoint 的支持有隐性开销,且 Go 层难以做精细控制。多数业务场景下,不如拆成独立事务或用条件判断提前拦截。
例如:下单时扣库存 + 创建订单 + 写日志,若日志失败不应影响前两步 —— 这不是事务该管的事,而是应把日志写入异步队列或降级为 warning。
- 慎用嵌套事务:MySQL 实际不支持真正的嵌套事务,GORM 的
Transaction嵌套只是模拟,外层 rollback 会连带回滚内层 - SavePoint 适合单次事务内局部回退(如循环插入时跳过某条脏数据),但不要用于跨业务模块的“部分成功”逻辑
- 真正需要隔离的子操作(如发消息、调外部 API),应剥离出事务,改用最终一致性方案
并发写中间表时的死锁风险
多对多中间表(如 lesson_student)在高并发选课/退课时,INSERT INTO lesson_student 和 DELETE FROM lesson_student 容易因索引顺序或间隙锁引发死锁,尤其当事务里还包含主表 UPDATE student 或 UPDATE lesson。
这不是代码写错,而是 MySQL InnoDB 的锁机制特性。GORM 默认不处理这类竞争。
- 加唯一索引:确保
(lesson_id, student_id)是联合唯一键,避免重复插入触发锁等待 - 固定执行顺序:所有事务按
lesson_id升序 →student_id升序操作中间表,减少交叉锁 - 捕获死锁错误:
if errors.Is(err, gorm.ErrDeadlock) { time.Sleep(10 * time.Millisecond); retry++ },最多重试 3 次 - 避免在事务中做耗时操作(如 HTTP 请求、文件读写),延长锁持有时间
事务最难的不是语法,是判断哪些操作真得绑在一起 —— 余额扣减和订单创建必须原子,但记录操作日志、通知用户、更新缓存,通常不该塞进同一个事务。











