死锁是数据库因事务加锁顺序冲突主动报错的结果,GORM不检测不预防;必须按固定顺序(如ID升序)显式加锁、命中索引、捕获错误幂等重试,并用context控制事务超时。

死锁不是 Go 层面的问题,而是数据库在事务间加锁顺序冲突时主动报错的结果;GORM 本身不检测、不预防、也不自动重试——你得自己写逻辑。
按固定顺序加锁,破坏循环等待
两个事务分别更新 id=1 和 id=2 的记录,若一个先锁 1 再锁 2,另一个反着来,MySQL/PG 就会检测到死锁并 kill 其中一个。解决方法不是“更小心”,而是强制统一顺序。
- 对涉及多行更新的主键或唯一标识,始终按升序(或降序)排列后再查询加锁,例如:
WHERE id IN (?, ?) ORDER BY id - 避免 ORM 自动生成条件顺序漂移:GORM 的
Where("a = ? AND b = ?", x, y)和Where("b = ? AND a = ?", y, x)在某些 MySQL 版本下可能触发不同执行计划,导致锁顺序不一致 - 不要依赖字段名顺序或参数传入顺序,显式用
ORDER BY控制加锁物理顺序
用 Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}) 显式加锁
GORM 默认所有读操作都是快照读(MVCC),不加锁。想防止并发修改,必须显式声明行级锁,且不能靠字符串拼 SQL 或自定义语句模拟。
- 写法唯一正确:使用
Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}),不是"FOR UPDATE"字符串 - 必须在事务内调用,否则锁立即释放,无意义
-
WHERE条件务必命中索引,否则 InnoDB 可能升级为间隙锁甚至全表锁——比如WHERE email LIKE '%@gmail.com'或WHERE DATE(created_at) = '2026-10-19'都危险
捕获死锁错误并幂等重试
数据库死锁错误不会 panic,也不会被 Go runtime 捕获,它只是普通 error,需手动识别并处理。
- PostgreSQL:检查
pgerr.Code == "40P01"(deadlock_detected) - MySQL:用
strings.Contains(err.Error(), "Deadlock found")判断 - 重试次数建议 ≤ 3 次,每次前加指数退避(如
time.Sleep(time.Millisecond * time.Duration(math.Pow(10, float64(i))))) - 重试逻辑必须下沉到 service 层,不能塞在 HTTP handler 里;且事务内所有操作必须幂等(比如不能在重试时重复发 webhook)
事务生命周期必须绑定 context 超时
不用 BeginTx(ctx, opts) 开启事务,就等于放弃对超时的控制——即使后续所有 QueryContext 都带超时,事务本身仍可无限期挂着,锁不释放、连接不归还。
- 必须用
db.BeginTx(ctx, nil)启动事务,且该ctx应来自context.WithTimeout(parentCtx, 30*time.Second) - 不要依赖 GORM 的
DefaultTransactionTimeout,它只在没传WithContext时生效;一旦你写了db.WithContext(ctx).Transaction(...),全局配置就失效 - 事务结束前,应显式检查
ctx.Err() != nil并主动Rollback(),否则锁可能长期持有
最容易被忽略的是:死锁重试不是加个 for 循环就行,它要求整个事务逻辑可重入、所有外部副作用(发消息、调支付)都得前置判断或幂等封装;而比重试更重要的是——从一开始就不让两个 goroutine 有机会按不同顺序碰同一组行。











