MySQL死锁怎么从日志里分析原因并避免
摘要:# MySQL死锁:别慌,从日志里揪出“元凶”其实不难 我前两天刚处理完一个线上系统的死锁问题,那感觉,就像半夜两点被报警电话吵醒——血压瞬间就上来了。但说真的,死锁这事儿,在稍微有点规模的MySQL数据库里,简直跟感冒一样常见。很多开发一看到“Dead…
MySQL死锁:别慌,从日志里揪出“元凶”其实不难
我前两天刚处理完一个线上系统的死锁问题,那感觉,就像半夜两点被报警电话吵醒——血压瞬间就上来了。但说真的,死锁这事儿,在稍微有点规模的MySQL数据库里,简直跟感冒一样常见。很多开发一看到“Deadlock found when trying to get lock”就懵,要么重启大法,要么就想着“加机器、升配置”。
其实吧,绝大多数死锁,根源都在代码逻辑和事务设计上。今天我就跟你聊聊,怎么像老中医一样,从MySQL的日志里“望闻问切”,把死锁的原因给揪出来,并且告诉你以后怎么尽量躲开这些坑。
一、死锁日志:你的“案发现场”报告
首先,你得知道去哪儿找线索。MySQL很贴心,每次发生死锁,只要innodb_print_all_deadlocks这个参数是ON的(建议你打开),它就会在错误日志里给你留一份详细的“尸检报告”。
这份报告看着有点吓人,一堆LOCK WAIT、TRX ID,但其实核心就看几个地方。我直接拿一个真实的日志片段给你拆解:
LATEST DETECTED DEADLOCK
------------------------
2024-05-27 10:23:17 0x7f8e6c0b1700
*** (1) TRANSACTION:
TRANSACTION 123456789, ACTIVE 5 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 100, OS thread handle 12345, query id 10001 10.0.0.1 app_user updating
UPDATE orders SET status = 'shipped' WHERE user_id = 100 AND order_id = 500;
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 100 page no 10 n bits 80 index idx_user_id of table `shop`.`orders` trx id 123456789 lock_mode X locks rec but not gap
Record lock, heap no 5 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 100 page no 20 n bits 80 index PRIMARY of table `shop`.`orders` trx id 123456789 lock_mode X locks rec but not gap waiting
Record lock, heap no 7 PHYSICAL RECORD: n_fields 10; compact format; info bits 0
*** (2) TRANSACTION:
TRANSACTION 987654321, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 101, OS thread handle 54321, query id 10002 10.0.0.2 app_user updating
UPDATE orders SET amount = amount + 50 WHERE order_id = 500;
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 100 page no 20 n bits 80 index PRIMARY of table `shop`.`orders` trx id 987654321 lock_mode X locks rec but not gap
Record lock, heap no 7 PHYSICAL RECORD: n_fields 10; compact format; info bits 0
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 100 page no 10 n bits 80 index idx_user_id of table `shop`.`orders` trx id 987654321 lock_mode X locks rec but not gap waiting
看着眼晕?别急,我用人话给你翻译一下:
- 两个“凶手”(事务):事务1(TRX 123456789)和事务2(TRX 987654321)。
- 它们各自手里拿着什么(HOLDS THE LOCK):
- 事务1:已经锁住了
idx_user_id索引上user_id=100相关的记录。 - 事务2:已经锁住了主键(PRIMARY)上
order_id=500的记录。
- 事务1:已经锁住了
- 它们各自在等什么(WAITING FOR):
- 事务1:在眼巴巴地等主键上
order_id=500的那把锁。 - 事务2:在苦苦地等
idx_user_id索引上user_id=100的那把锁。
- 事务1:在眼巴巴地等主键上
这下明白了吧?经典的“抱死”场景:事务1拿着A锁等B锁,事务2拿着B锁等A锁,俩人谁都不撒手,系统一看,得,你俩“死锁”吧。
说白了,分析死锁日志,你就盯紧这个“谁拿了什么,又在等什么”的死循环。找到这个环,原因就找到了八成。
二、揪出元凶:常见死锁场景大起底
光看懂日志还不够,咱得知道这些“死循环”是怎么产生的。我总结了几种最常见、也最要命的场景,你看看是不是似曾相识。
场景1:不同顺序的更新操作
这是死锁界的“头号杀手”,上面那个日志例子就是典型。事务A先更新user_id=100的记录,再更新order_id=500的记录;事务B呢,正好反过来,先更新order_id=500,再更新user_id=100。在并发量一上来的时候,这种交叉等待几乎必然发生。
怎么破? 这是代码规范问题。团队内部必须强制约定,对同一批业务数据的更新,必须遵循相同的顺序。比如,都约定先按user_id过滤再按order_id处理。听起来简单,但很多团队就是没这个规矩,坑都是自己挖的。
场景2:Gap锁(间隙锁)的“幽灵”
这个有点隐蔽,但危害极大,尤其是在REPEATABLE-READ隔离级别下。比如你的事务执行了SELECT ... WHERE id > 100 FOR UPDATE,它不光锁住id>100的现有记录,还会锁住那个范围(一个“间隙”),防止其他事务在这个范围内插入。
这时候,如果另一个事务刚好想在这个间隙里插入一条新记录(比如INSERT ... id = 150),它就会被卡住。如果两个事务互相卡住了对方需要的间隙,死锁就又来了。这种死锁在日志里会看到lock_mode X locks gap before rec的字样。
怎么破? 首先评估一下,你的业务真的需要REPEATABLE-READ吗?很多业务用READ-COMMITTED完全够用,还能大幅减少间隙锁。如果非要用,那写FOR UPDATE或UPDATE语句时,尽量让条件精准命中记录(用唯一键),别动不动就SELECT *然后FOR UPDATE一大片。
场景3:单表多行更新的“随机”锁
你以为一次更新很多行(UPDATE ... WHERE status = 'new')是一个原子操作?在InnoDB里,它其实是逐行加锁的。加锁的顺序,默认跟底层存储的物理顺序或者索引扫描顺序有关。这个顺序,你的代码控制不了。
如果两个这样的事务并发,又各自持有了对方需要的某行锁,死锁就出现了。这种死锁在日志里可能看到多个Record lock。
怎么破? 对于这种批量更新,如果业务允许,拆成多次、小批量的更新,并确保每次更新后事务尽快提交,减少锁的持有时间。或者,在应用层做排队,让同类更新串行执行。虽然损失了点并发,但换来了稳定。
三、避坑指南:从设计上远离死锁
分析是“治已病”,设计才是“治未病”。下面这几条,是我自己趟过坑之后总结的,你听听看有没有道理。
-
事务要短小精悍,快进快出 这是黄金法则。别在事务里做网络调用、别处理复杂业务逻辑、别让用户交互。事务的边界越清晰,锁持有的时间就越短,撞车的概率指数级下降。我见过最离谱的,一个事务里还调了第三方支付接口,不断超时重试,锁了快一分钟……你不死锁谁死锁?
-
访问顺序,团队要有“潜规则” 就像前面说的,更新多张表或者多个条件时,定个死规矩。比如,统一按“用户表 -> 订单表 -> 日志表”的顺序操作。把这个规矩写到代码规范里,新人来了先培训这个。
-
索引不是越多越好,要“刚刚好” 很多死锁是因为索引设计不当,导致加锁范围变大。比如,你有个非唯一索引,更新时可能就会产生间隙锁。定期用
EXPLAIN看看你的慢查询和核心事务SQL,确保它们用上了最合适的索引,避免全表扫描(那会锁全表!)。 -
考虑降级隔离级别 真别把
REPEATABLE-READ当默认宝贝。对于绝大多数互联网业务,READ-COMMITTED在数据一致性上完全够用,还能彻底避免间隙锁带来的各种奇葩死锁。改个配置,可能就解决了一大半问题。 -
准备好“后手”:重试机制 承认吧,在复杂的并发环境下,想完全杜绝死锁几乎不可能。所以,在应用层做一层优雅的重试就特别重要。捕获到死锁异常(MySQL会返回
1213错误码)后,别慌,等个几百毫秒(随机退避一下),然后重试整个事务。大部分情况下,重试一两次就能成功。这招虽然土,但是真管用。
最后说两句
处理死锁,心态很重要。别一出问题就想着“是不是MySQL有bug”,或者“赶紧升级硬件压过去”。99%的死锁,都是人写出来的。 把它当成一个优化代码和架构的契机。
下次再看到死锁日志,别头大。就按我今天说的,先找到那个“等待环”,再对照常见场景对号入座,最后想想怎么从根上调整。多来几次,你就有感觉了。
行了,关于MySQL死锁,今天就聊这么多。如果你有更奇葩的死锁案例,或者有更好的解决思路,欢迎来聊聊——毕竟,踩坑的路上,多几个人做伴,总归是好的。

