MySQL报错Deadlock found when trying to get lock的排查方法(SHOW ENGINE INNODB STATUS定位死锁与加锁顺序调整)

业务日志里隔三差五冒一条ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction,重试一次又能过,压测时一秒能出十几条。这条报错InnoDB自己处理掉了——它检测到循环等待,挑了一个事务回滚,被回滚的那个就收到1213。数据库没坏,数据也没丢,只是那个事务白干了一遍。

动手前先分清两件事。死锁是两个事务各自握着对方要的锁、互相干等,形成一个环,InnoDB当场检测出来并回滚其中一个;锁等待超时是另一回事,一个事务等另一个持有的锁,等到innodb_lock_wait_timeout(默认50秒)还没拿到,报的是ERROR 1205: Lock wait timeout exceeded,而且默认只回滚卡住的那条语句,不是整个事务。1205往往比1213难查,因为它意味着某个事务忘了提交,一直占着锁不放。

MySQL 5.7、8.0、8.4,这几个版本的死锁机制没变,变的只是查锁用的系统表。

把现场抓下来

最直接的一条命令:

SHOW ENGINE INNODB STATUS\G

输出里找LATEST DETECTED DEADLOCK这一段。它只保留最近一次死锁,下一次死锁会把它覆盖,重启之后更是直接没了,所以看到报错要马上去看,晚了就只剩"发生过"这个事实。

段里的关键行长这样:

*** (1) TRANSACTION:
TRANSACTION 421937, ACTIVE 12 sec starting index read
UPDATE account SET balance = balance - 100 WHERE id = 2
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS index PRIMARY of table `shop`.`account` lock_mode X locks rec but not gap
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS index PRIMARY of table `shop`.`account` lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
UPDATE account SET balance = balance + 100 WHERE id = 1
*** WE ROLL BACK TRANSACTION (2)

读这段有个固定顺序:先看两个事务各自正在执行的SQL,再看谁持有什么锁、在等什么锁,最后一行告诉你回滚了哪一边。上面这个例子一眼能看明白——事务1改完id=2想改id=1,事务2反过来,各自攥着对方要的那行,环就形成了。

让死锁留档

偶发死锁靠人守着抓不现实,把每一次都写进错误日志:

SET GLOBAL innodb_print_all_deadlocks = ON;

开完再去错误日志里grep,每次死锁的完整信息都在,带时间戳。这个变量动态生效,不用重启;排查完记得关掉,高并发下日志涨得很快——实测每分钟几十次死锁的场景,一天能写出去几百兆。

看当前谁堵着谁

8.0以后锁信息搬到了performance_schema,5.7还在information_schema:

SELECT * FROM performance_schema.data_locks\G
SELECT * FROM performance_schema.data_lock_waits\G
SELECT * FROM sys.innodb_lock_waits\G

sys.innodb_lock_waits最省事,它把等待关系整理成"谁在等谁、等了多久、建议kill哪个",8.0.1、8.0.36、8.0.40,这些版本里都有。还在跑的事务查这张:

SELECT trx_id, trx_started, trx_rows_locked, trx_query
FROM information_schema.innodb_trx ORDER BY trx_started LIMIT 5;

见过一个跑了四十分钟的事务,trx_query是NULL(说明连接闲着没干活),trx_rows_locked却有八万多行。kill掉之后,后面排队的请求瞬间全部通过。

最常见的成因:加锁顺序反了

两个业务方法更新同一批记录,一个按id升序、一个按业务时间排,串行跑没事,并发一上来就成环。改法是统一顺序:先把要更新的id查出来排序,再按顺序更新。这段代码放在调用方比塞进存储过程好维护。

批量接口同样危险。UPDATE ... WHERE id IN (3,1,2)这种写法,优化器不按IN里的顺序加锁,实际次序取决于它选的索引。当初有个对账接口就是这么写的,改成先排序再逐条更新之后,死锁直接归零。

第二个成因:没索引,锁一大片

死锁输出里出现lock_mode X locks gap before rec,或者被锁行数远大于实际改动的行数,那就是REPEATABLE READ下的间隙锁在作怪,8.0默认隔离级别正是它:

SELECT @@transaction_isolation;

WHERE条件里那个字段没索引,InnoDB只能全表扫描,扫过的每一条都加next-key锁,两个事务一交叉必然撞车。补上索引通常就解决了;如果这块业务确实不需要防幻读,把会话级隔离降到READ COMMITTED,间隙锁会大幅减少:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

隔离级别影响的是正确性,不是性能开关,动之前先确认业务能接受不可重复读。另外binlog格式还是STATEMENT时不允许用READ COMMITTED,得先把binlog_format改成ROW。

第三个成因:锁升级

先SELECT ... FOR SHARE读一遍,再在同一个事务里UPDATE同一行,两个事务都这么写就死锁:各自握着共享锁,又都等着升级成排他锁。既然要改,从第一步就写FOR UPDATE,别分两步。

还有个隐蔽的:更新带二级索引的列时,InnoDB同时锁聚簇索引记录和二级索引项,两个事务在同一行上也能交叉成环。死锁输出里看到同一个表的不同index名,就是这一种。

应用侧收尾:1213必须重试

死锁不可能被彻底消灭,它是并发的固有产物,调用方得能兜住。抓错误码1213或SQLSTATE 40001,回滚整个事务后重跑。重跑前要重新读一遍数据,别拿上一次算好的值直接写回去。

for attempt in range(3):
    try:
        with conn.cursor() as cur:
            cur.execute("UPDATE account SET balance = balance - %s WHERE id = %s",(100,2))
        conn.commit()
        break
    except pymysql.err.OperationalError as e:
        if e.args[0] != 1213:
            raise
        conn.rollback()
        time.sleep(0.05 * 2 ** attempt)

退避间隔按50ms、100ms、200ms递增,三次基本都能过;超过五次还没成就别再试,抛出去让人看。想盯趋势,SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks'是个累计数,隔一小时采一次,差值持续在涨就说明访问模式出了问题,而不是偶发。

数据截至2026年9月,MySQL 5.7、8.0、8.4与9.x行为一致;额外说明一句,innodb_deadlock_detect是Percona Server与MariaDB才有的开关,官方MySQL没有这个变量,照着Percona的文档去set会报未知系统变量。