业务日志里隔三差五冒一条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会报未知系统变量。