MySQL 死锁产生原因与避免
我刚工作的时候,有个批量转账的需求:要从 A 账户转钱给 B 账户,同时从 B 账户转钱给 A 账户。结果上线后频繁死锁,用户投诉电话被打爆。
今天咱们就来聊聊 MySQL 死锁的产生原因与避免,看完这篇,你就能设计和排查无死锁的并发系统了。
死锁是啥?
死锁(Deadlock)指的是两个或多个事务互相等待对方释放锁,导致谁也执行不下去。
经典场景
时间轴: T1: 事务 A 持有锁 X,等待锁 Y T2: 事务 B 持有锁 Y,等待锁 X T3: 死锁!谁也执行不下去MySQL 的处理:检测到死锁后,回滚其中一个事务,另一个事务继续执行。
死锁的四个必要条件
死锁的产生需要同时满足四个条件:
- 互斥(Mutual Exclusion):锁只能被一个事务持有
- 占有并等待(Hold and Wait):事务持有锁 X,还要等待锁 Y
- 不可抢占(No Preemption):锁只能自动释放(COMMIT/ROLLBACK),不能被强制剥夺
- 循环等待(Circular Wait):A 等 B,B 等 A
只要打破其中一个条件,就能避免死锁。
- 循环等待(Circular Wait):A 等 B,B 等 A
死锁的常见场景
场景 1:不同顺序加锁
最常见的死锁场景:两个事务以不同顺序加锁。
事故现场
-- 会话 ABEGIN;UPDATEaccountsSETbalance=balance-100WHEREid=1;-- 锁住 id=1-- 会话 BBEGIN;UPDATEaccountsSETbalance=balance-50WHEREid=2;-- 锁住 id=2-- 会话 A(等待会话 B 释放 id=2)UPDATEaccountsSETbalance=balance+100WHEREid=2;-- 阻塞!-- 会话 B(等待会话 A 释放 id=1,死锁!)UPDATEaccountsSETbalance=balance+50WHEREid=1;-- 死锁!结果:MySQL 检测到死锁,回滚其中一个事务。
解决方案 1:按固定顺序加锁
思路:所有事务都按相同的顺序加锁(比如按 ID 升序)。
-- 优化后:都按 ID 升序加锁-- 会话 ABEGIN;UPDATEaccountsSETbalance=balance-100WHEREid=1;-- 先锁 id=1UPDATEaccountsSETbalance=balance+100WHEREid=2;-- 再锁 id=2-- 会话 B(也要按 ID 升序)BEGIN;UPDATEaccountsSETbalance=balance-50WHEREid=1;-- 也要先锁 id=1(等会话 A 释放)-- 不会死锁!因为顺序一样为什么能避免?不会形成循环等待(都先锁 id=1,再锁 id=2)。
解决方案 2:用SELECT ... FOR UPDATE一次性锁住
思路:用SELECT ... FOR UPDATE一次性锁住所有要修改的行。
-- 优化后:一次性锁住-- 会话 ABEGIN;SELECTbalanceFROMaccountsWHEREidIN(1,2)FORUPDATE;-- 一次性锁住 id=1 和 id=2UPDATEaccountsSETbalance=balance-100WHEREid=1;UPDATEaccountsSETbalance=balance+100WHEREid=2;COMMIT;-- 会话 B(也要一次性锁住)BEGIN;SELECTbalanceFROMaccountsWHEREidIN(1,2)FORUPDATE;-- 一次性锁住(等会话 A 释放)-- 不会死锁!为什么能避免?一次性锁住所有行,不会占有并等待。
场景 2:索引没走,锁全表
经典事故:WHERE条件没走索引,导致锁全表(InnoDB 的行锁退化成表锁)。
事故现场
-- age 没有索引BEGIN;UPDATEusersSETage=30WHEREage=25;-- 锁全表!-- 其他会话(阻塞!)UPDATEusersSETage=40WHEREid=1;-- 阻塞!结果:所有对该表的操作都阻塞,形成"伪死锁"(不是真正的死锁,但表现一样)。
解决方案:给 WHERE 条件加索引
思路:确保WHERE条件走索引,只锁符合条件的行。
-- 优化后:给 age 加索引CREATEINDEXidx_ageONusers(age);BEGIN;UPDATEusersSETage=30WHEREage=25;-- 只锁 age=25 的行-- 其他会话(不阻塞)UPDATEusersSETage=40WHEREid=1;-- 不阻塞!场景 3:大事务持锁太久
经典事故:事务里调用了外部 API(耗时 5 秒),导致持锁太久,其他事务都被阻塞。
事故现场
BEGIN;UPDATEaccountsSETbalance=balance-100WHEREid=1;-- 锁住 id=1-- 调用外部 API(耗时 5 秒,持锁 5 秒!)CALLexternal_api();COMMIT;结果:其他要操作id=1的事务,都被阻塞 5 秒。
解决方案:缩小事务范围
思路:事务里只做数据库操作,不要做耗时操作(比如调用外部 API)。
-- 优化后:先调用外部 API,再开事务CALLexternal_api();-- 不持锁BEGIN;UPDATEaccountsSETbalance=balance-100WHEREid=1;COMMIT;-- 持锁时间极短场景 4:间隙锁(Gap Lock)导致死锁
InnoDB 的间隙锁:在 REPEATABLE READ 隔离级别下,会用间隙锁防止幻读。
事故现场
-- 假设 age 有索引,值是:20, 25, 30-- 会话 ABEGIN;UPDATEusersSETage=26WHEREage>25ANDage<30;-- 锁住 (25, 30) 的间隙-- 会话 BBEGIN;UPDATEusersSETage=24WHEREage>20ANDage<25;-- 锁住 (20, 25) 的间隙-- 会话 A(等待会话 B 释放 (20, 25) 的间隙锁,死锁!)INSERTINTOusers(age)VALUES(22);-- 会话 B(等待会话 A 释放 (25, 30) 的间隙锁,死锁!)INSERTINTOusers(age)VALUES(27);结果:MySQL 检测到死锁,回滚其中一个事务。
解决方案 1:缩小间隙锁范围
思路:尽量用精确匹配(=),不要用范围查询(>、<、BETWEEN)。
-- 优化后:用精确匹配-- 会话 ABEGIN;UPDATEusersSETage=26WHEREid=5;-- 精确匹配,只锁 id=5-- 会话 BBEGIN;UPDATEusersSETage=24WHEREid=3;-- 精确匹配,只锁 id=3解决方案 2:用 READ COMMITTED 隔离级别
思路:READ COMMITTED 隔离级别不用间隙锁,只锁已存在的行。
-- 优化后:用 READ COMMITTEDSETSESSIONTRANSACTIONISOLATIONLEVELREADCOMMITTED;BEGIN;UPDATEusersSETage=26WHEREage>25ANDage<30;-- 不用间隙锁注意:READ COMMITTED 可能有不可重复读的问题。
死锁的排查
第 1 步:查看最近一次死锁
SHOWENGINEINNODBSTATUS;重点看:LATEST DETECTED DEADLOCK部分。
输出解读
------------------------ LATEST DETECTED DEADLOCK ------------------------ 2024-01-15 10:30:00 0x7f8a1b2c1700 *** (1) TRANSACTION: TRANSACTION 123456, ACTIVE 2 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 10, OS thread handle 140237518456320, query id 98 localhost root updating UPDATE users SET age = 30 WHERE id = 1 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 2 page no 3 n bits 72 index PRIMARY of test.users trx id 123456 lock_mode X locks rec but not gap waiting Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 0: len 4; hex 80000001; asc ii..; (1); 1: len 6; hex 000000000001; asc .........;; 2: len 7; hex 80000000000000; asc .........;; 3: len 4; hex 8000001e; asc .........; (30); *** (2) TRANSACTION: TRANSACTION 123457, ACTIVE 2 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 11, OS thread handle 140237518456321, query id 99 localhost root updating UPDATE users SET age = 40 WHERE id = 2 *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 2 page no 3 n bits 72 index PRIMARY of test.users trx id 123457 lock_mode X locks rec but not gap waiting Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 0: len 4; hex 80000002; asc ii..; (2); 1: len 6; hex 000000000002; asc .........;; 2: len 7; hex 80000000000000; asc .........;; 3: len 4; hex 80000028; asc .........; (40); *** WE ROLL BACK TRANSACTION (2)关键信息:
- 事务 1:执行
UPDATE users SET age = 30 WHERE id = 1,在等待id=2的锁 - 事务 2:执行
UPDATE users SET age = 40 WHERE id = 2,在等待id=1的锁
- 事务 2:执行
- 死锁!MySQL 回滚了事务 2
第 2 步:分析死锁原因
根据SHOW ENGINE INNODB STATUS的输出,分析:
- 哪两个事务互相等待?
- 各自持有了什么锁?在等待什么锁?
- SQL 是什么?为什么会产生死锁?
第 3 步:优化 SQL 或事务
根据分析结果,优化:
- 按固定顺序加锁
- 给 WHERE 条件加索引
- 缩小事务范围
- 缩小间隙锁范围
死锁的避免
1. 按固定顺序加锁(最重要!)
这是最重要的建议。所有事务都按相同的顺序加锁。
-- 错误:不同顺序加锁(可能死锁)-- 事务 A:先锁 id=1,再锁 id=2-- 事务 B:先锁 id=2,再锁 id=1-- 正确:都按 ID 升序加锁-- 事务 A:先锁 id=1,再锁 id=2-- 事务 B:也要先锁 id=1,再锁 id=22. 给 WHERE 条件加索引
确保WHERE条件走索引,只锁符合条件的行,不锁全表。
-- 错误:没走索引,锁全表UPDATEusersSETage=30WHEREage=25;-- age 没索引-- 正确:走索引,只锁符合条件的行CREATEINDEXidx_ageONusers(age);UPDATEusersSETage=30WHEREage=25;-- 走索引3. 缩小事务范围
事务里只做数据库操作,不要做耗时操作(比如调用外部 API)。
-- 错误:事务里调用外部 API(持锁太久)BEGIN;UPDATEaccountsSETbalance=balance-100WHEREid=1;CALLexternal_api();-- 持锁 5 秒!COMMIT;-- 正确:先调用外部 API,再开事务CALLexternal_api();-- 不持锁BEGIN;UPDATEaccountsSETbalance=balance-100WHEREid=1;COMMIT;-- 持锁时间极短4. 用SELECT ... FOR UPDATE一次性锁住
用SELECT ... FOR UPDATE一次性锁住所有要修改的行,不会占有并等待。
-- 错误:分步加锁(可能死锁)BEGIN;UPDATEaccountsSETbalance=balance-100WHEREid=1;-- 先锁 id=1UPDATEaccountsSETbalance=balance+100WHEREid=2;-- 再锁 id=2(可能死锁)-- 正确:一次性锁住BEGIN;SELECTbalanceFROMaccountsWHEREidIN(1,2)FORUPDATE;-- 一次性锁住UPDATEaccountsSETbalance=balance-100WHEREid=1;UPDATEaccountsSETbalance=balance+100WHEREid=2;5. 设置锁等待超时时间
设置innodb_lock_wait_timeout(默认 50 秒),避免无限等待。
-- 设置锁等待超时为 5 秒SETGLOBALinnodb_lock_wait_timeout=5;-- 或者修改配置文件(永久生效)-- my.cnf:[mysqld]innodb_lock_wait_timeout=5如果等待超过 5 秒,MySQL 会报错:Lock wait timeout exceeded; try restarting transaction。
6. 捕获死锁异常,自动重试
在应用层捕获死锁异常(Deadlock found when trying to get lock),自动重试。
// 伪代码publicvoidtransfer(intfromUserId,inttoUserId,BigDecimalamount){intretryCount=0;while(retryCount<3){// 最多重试 3 次try{// 开启事务BEGIN;// 转账逻辑UPDATEaccountsSETbalance=balance-amountWHEREuser_id=fromUserId;UPDATEaccountsSETbalance=balance+amountWHEREuser_id=toUserId;// 提交事务COMMIT;// 成功,退出break;}catch(DeadlockExceptione){// 死锁异常,回滚,重试ROLLBACK;retryCount++;// 等待随机时间(避免再次冲突)Thread.sleep(newRandom().nextInt(1000));}}}``` ## 总结-**死锁**是两个或多个事务**互相等待对方释放锁**,导致谁也执行不下去--**死锁的四个必要条件**:互斥、占有并等待、不可抢占、循环等待--**死锁的常见场景**:-1.**不同顺序加锁**(最常见)-2.**索引没走,锁全表**-3.**大事务持锁太久**-4.**间隙锁(GapLock)导致死锁**--**死锁的排查**:`SHOWENGINEINNODBSTATUS;`(看 `LATESTDETECTEDDEADLOCK` 部分)--**死锁的避免**:-1.**按固定顺序加锁**(最重要!)-2.**给WHERE条件加索引**-3.**缩小事务范围**-4.**用 `SELECT...FORUPDATE` 一次性锁住**-5.**设置锁等待超时时间**-6.**捕获死锁异常,自动重试**如果你能把死锁的四个必要条件、常见场景、排查方法、避免方案讲清楚,面试官绝对觉得你是高级开发。---**实战代码都在我本地跑过,你可以放心复制。**如果有问题,欢迎评论区交流!