数据库死锁需通过SQL错误码而非Go panic判断:MySQL为“Deadlock found”,PostgreSQL为错误码“40P01”;真实死锁发生在数据库层,Go仅返回error,须结合INNODB STATUS或PG日志分析锁等待顺序与索引使用。

怎么看GORM事务是否真被数据库死锁卡住
别一看到 fatal error: all goroutines are asleep - deadlock! 就认定是数据库死锁——这行 panic 通常只是表象,真实死锁发生在数据库层,Go runtime 根本感知不到。你要盯的是 SQL 错误本身。
MySQL 返回的错误信息里含 "Deadlock found when trying to get lock";PostgreSQL 则是 ERROR: deadlock detected,对应错误码 "40P01"。这两类才是数据库层确认死锁的信号。GORM 不会自动包装成 panic,而是作为 error 返回给你的事务回调函数或执行链。
- 检查日志中是否出现上述明确关键词,而不是只看 Go 进程是否挂了
- 如果只有
context deadline exceeded或 HTTP 超时,大概率是锁等待超时(innodb_lock_wait_timeout/lock_timeout),不等于死锁 - 开启 GORM 的
Logger并设为Info或更低级别,确保 SQL 执行和错误都被记录
怎么从数据库日志还原死锁现场
数据库自身日志才是真相来源。SQL Server 常用 traceflag 1222 输出死锁图;MySQL 查 SHOW ENGINE INNODB STATUS\G 中的 LATEST DETECTED DEADLOCK 段;PostgreSQL 则需开启 log_lock_waits = on 并配合 log_min_error_statement = error。
重点看三块:谁在等哪条记录、谁持有哪条记录、加锁语句的 WHERE 条件和执行顺序。比如日志里出现:
TRANSACTION 12345, ACTIVE 12 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 789, OS thread handle 1402..., query id 45678 localhost root updating UPDATE accounts SET balance = balance - 100 WHERE id = 2
说明该事务正在等 id = 2 的行锁,而另一方很可能正拿着 id = 2 却在等 id = 1 ——这就是 AB/BA 顺序问题。
- 死锁日志里出现的两个事务,一定有相反的资源访问顺序(如先查 A 再查 B vs 先查 B 再查 A)
- 注意 WHERE 条件是否命中索引:全表扫描会升级为表级锁或大量行锁,极大提高死锁概率
- 如果日志里反复出现同一组 ID(如总在
id IN (101, 205)上死锁),说明业务逻辑存在固定竞争点
GORM里怎么写才能避免常见死锁模式
核心就一条:破坏“循环等待”。GORM 本身不控制加锁顺序,得你来定规矩。最稳妥的做法是统一按主键升序锁定涉及的记录。
比如转账场景,不管请求传入的 fromID 和 toID 是什么顺序,都在事务内先排序再查:
if fromID > toID {
fromID, toID = toID, fromID
}
tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
First(&Account{}, "id IN (?, ?) ORDER BY id", fromID, toID)这样所有事务都按 ID 升序加锁,A→B 和 B→A 就不可能同时发生。
- 别用
Save()隐式更新:无主键时 GORM 可能全表扫描,触发意外行锁;务必显式Where("id = ?") - 避免在事务里做非数据库操作(如 HTTP 调用、文件读写),否则锁持有时间不可控
- 批量操作慎用
IN:若参数无序且数量大,GORM 可能生成非确定性执行计划,间接导致加锁顺序混乱
重试逻辑为什么不能直接塞在 Gin handler 里
因为重试必须保证幂等,而 handler 层天然不满足:HTTP 请求可能重发(客户端重试、LB 重试),如果每次进 handler 都跑一遍重试循环,就会造成重复扣款、重复下单。
正确做法是把重试下沉到 service 层,并确保事务内所有操作可重入:
- 不要在重试体里调第三方 API 或发消息;如必须,用本地幂等表或 Redis token 控制
- 重试次数严格限制为 2–3 次,退避间隔用
time.Sleep(10 * time.Millisecond * time.Duration(i*i))类指数增长 - 捕获错误时只认具体数据库码:
pgerr.Code == "40P01"或strings.Contains(err.Error(), "Deadlock found"),别 catch 所有error - 别用
db.Transaction套壳重试——它每次新建事务,无法共享上下文;应手写 for 循环 + 新建db.Begin()
真正难的不是写重试代码,而是判断哪些业务逻辑能安全重试。比如“用户下单”可以重试,“支付回调通知”就不行——后者失败必须走补偿而非重试。


















