GORM批量插入触发MySQL死锁的根本原因是隐式间隙锁与加锁顺序不一致,而非批量本身;并发事务未按主键升序插入时,因唯一索引冲突导致锁范围交叉和AB/BA循环等待。

为什么 GORM 批量插入会触发 MySQL 死锁
根本原因不是“批量”本身,而是 GORM 自动生成的 SQL 在无明确加锁控制时,隐式锁住非预期行范围,且多事务间加锁顺序不一致。比如两个并发事务同时执行 db.CreateInBatches(),若底层表有唯一索引,MySQL 可能因插入路径不同(如 B+ 树分裂、间隙锁覆盖差异)而以不同顺序加锁,形成 AB/BA 循环等待。
常见诱因包括:
- 未显式按主键或唯一键排序就批量插入,导致各事务锁行顺序随机
- 使用
Save()或Create()处理含关联模型的数据,GORM 自动触发 Upsert + 关联插入,语句分散、锁点不可控 - 表存在多个唯一索引(如
email和phone),冲突检测路径不同,间隙锁范围交叉 - 事务中混合了
SELECT FOR UPDATE和批量插入,读锁与写锁范围重叠但顺序错位
GORM v2 中 CreateInBatches 的锁行为陷阱
CreateInBatches 本身不加锁,但它生成的 INSERT 语句在遇到唯一约束冲突时,会触发 MySQL 的“duplicate key handling”机制——此时 InnoDB 不仅锁住命中行,还会对相关间隙(gap)加锁,用于防止幻读。如果一批数据里有部分重复、部分不重复,MySQL 可能为每条记录单独评估锁范围,导致锁粒度碎片化。
关键事实:
- 即使你没写
FOR UPDATE,INSERT ... ON DUPLICATE KEY UPDATE也会持锁直到事务结束 -
CreateInBatches默认按切片原始顺序插入,若输入数据未排序,不同 goroutine 提交的批次可能锁定 id=5,10,3 → id=3,7,12 这类交错序列 - GORM 不会自动为你加
ORDER BY或做预排序,需手动保障输入 slice 按主键升序排列
如何用 Cluases(clause.Locking) 主动控制锁顺序
不能依赖 GORM 自动生成逻辑来规避死锁,必须显式统一加锁顺序。最有效方式是在事务开始时,用 SELECT ... FOR UPDATE 预占所有将要操作的主键行,并强制按固定顺序(如升序)获取锁,破坏循环等待条件。
实操要点:
- 先收集所有待插入/更新记录的主键(或唯一键值),去重后升序排序
- 在事务内执行:
tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).Where("id IN ?", sortedIDs).Find(&[]Model{}) - 确保该查询带
ORDER BY id,否则 MySQL 优化器可能改变锁获取顺序 - 之后再调用
CreateInBatches或Save,此时锁已按统一顺序持有,后续写操作不会引入新冲突
死锁错误码捕获与幂等重试的落地细节
MySQL 死锁错误是运行时返回的 SQL 错误,不是 Go panic,GORM 不会自动重试。必须在 service 层手动处理,且重试逻辑必须满足幂等性。
具体判断和处理方式:
- MySQL 死锁错误信息固定包含字符串:
"Deadlock found when trying to get lock",用strings.Contains(err.Error(), "Deadlock found")判断 - 重试次数建议 ≤ 3 次,首次延迟 10ms,后续用指数退避(如
time.Sleep(time.Millisecond * time.Duration(math.Pow(3, float64(i))))) - 避免在 handler 层写重试循环;应封装成
func(ctx context.Context, data []Model) error接口,在 service 内部完成 - 若批量操作中混有发消息、调 webhook 等副作用,必须抽离到事务外,或使用本地消息表+定时补偿,否则重试会导致重复触发
真正难的不是加锁或重试,而是确认“哪些键必须参与排序”以及“哪些操作必须移出事务”。很多团队卡在把 CreateInBatches 塞进一个大事务里还试图靠重试兜底,结果只是把死锁延后几毫秒而已。


















