文章目录
- MySQL 锁与死锁讲透:行锁、间隙锁、Next-Key Lock 与排查方法
- 一、锁的分类
- 二、行锁的三种类型
- 2.1 Record Lock(记录锁)
- 2.2 Gap Lock(间隙锁)
- 2.3 Next-Key Lock(临键锁)
- 三、不同 SQL 加什么锁
- 验证加锁范围
- 四、死锁是怎么产生的
- 4.1 最经典的场景:加锁顺序不同
- 4.2 间隙锁导致的死锁
- 4.3 唯一键冲突导致的死锁
- 五、死锁日志怎么读
- 六、锁等待与超时
- 七、减少死锁的六条实践
- 八、死锁 vs 锁等待超时
- 九、小结
MySQL 锁与死锁讲透:行锁、间隙锁、Next-Key Lock 与排查方法
“线上突然报 Deadlock found when trying to get lock”,
然后日志里一段看不懂的 LATEST DETECTED DEADLOCK。本篇讲清楚 InnoDB 有哪些锁、什么情况下加什么锁、
死锁怎么产生、以及拿到死锁日志后该怎么读。
一、锁的分类
按粒度分:
| 粒度 | 说明 | 开销 | 冲突概率 |
|---|---|---|---|
| 表级锁 | 锁整张表 | 小 | 高 |
| 行级锁 | 锁单行或范围 | 大 | 低 |
| 页级锁 | 折中(BDB 引擎用,基本见不到) | 中 | 中 |
InnoDB 支持行级锁,这是它取代 MyISAM 的核心原因。
但要注意:行锁是加在索引上的,
如果 SQL 没走索引,会退化成锁全表的所有行(甚至表锁)。
按模式分:
-- 共享锁(S 锁):读锁,多个事务可同时持有SELECT*FROMtWHEREid=1LOCKINSHAREMODE;-- 排他锁(X 锁):写锁,独占SELECT*FROMtWHEREid=1FORUPDATE;UPDATEtSET...WHEREid=1;-- 自动加 X 锁DELETEFROMtWHEREid=1;-- 自动加 X 锁兼容性矩阵:
| 已持有 \ 请求 | S | X |
|---|---|---|
| S | ✅ 兼容 | ❌ 冲突 |
| X | ❌ 冲突 | ❌ 冲突 |
二、行锁的三种类型
这才是 InnoDB 锁的精髓所在。
2.1 Record Lock(记录锁)
锁住一条具体的索引记录。
SELECT*FROMtWHEREid=10FORUPDATE;-- id 是主键,锁 id=10 这一行2.2 Gap Lock(间隙锁)
锁住两条记录之间的间隙,防止别的事务往这个间隙里插入数据。
假设表里有 id = 1, 5, 10 三条记录,那么间隙有:(-∞, 1)、(1, 5)、(5, 10)、(10, +∞)
SELECT*FROMtWHEREidBETWEEN5AND10FORUPDATE;-- 会锁住 (1,5]、(5,10]、(10,+∞) 这些范围间隙锁的唯一目的是防止幻读——阻止别的事务在范围内插入新行。
⚠️ 两个关键特性:
- 间隙锁之间不冲突:两个事务可以同时对同一个间隙加间隙锁
(因为它们都是为了防止插入,目标一致) - 间隙锁只在 RR 及以上级别存在。
RC 级别下没有间隙锁(这是 RC 并发更高的主因)
2.3 Next-Key Lock(临键锁)
Record Lock + Gap Lock 的组合,锁住"左开右闭"的区间(prev, current]。
这是 InnoDB 在 RR 级别下的默认加锁单位。
-- 表里有 id = 1, 5, 10SELECT*FROMtWHEREid=5FORUPDATE;-- 加的是 Next-Key Lock,锁定范围 (1, 5]注意:等值查询命中唯一索引时,Next-Key Lock 会退化成 Record Lock。
这是 InnoDB 的优化——既然 id 是唯一的,
锁住 5 这一行就够了,不需要间隙。
-- id 是主键(唯一索引)SELECT*FROMtWHEREid=5FORUPDATE;-- 只锁 id=5 这一行(退化)-- age 是普通索引,可能重复SELECT*FROMtWHEREage=20FORUPDATE;-- 不退化!锁住所有 age=20 的行 + 前后的间隙三、不同 SQL 加什么锁
这是最实用的部分,建议对着看:
| SQL | 索引情况 | 加锁 |
|---|---|---|
SELECT ... FROM | — | 不加锁(快照读,走 MVCC) |
SELECT ... LOCK IN SHARE MODE | 唯一索引等值 | S 型 Record Lock |
SELECT ... FOR UPDATE | 唯一索引等值 | X 型 Record Lock |
SELECT ... FOR UPDATE | 唯一索引范围 | X 型 Next-Key Lock(扫到的范围) |
SELECT ... FOR UPDATE | 普通索引等值 | X 型 Next-Key Lock(不退化) |
SELECT ... FOR UPDATE | 无索引 | 锁全表所有行 + 所有间隙 |
UPDATE / DELETE | 同上逻辑 | 同 FOR UPDATE |
最后一行是最危险的情况:
-- name 字段没有索引UPDATEuserSETstatus=1WHEREname='Tom';-- 全表扫描,锁住所有记录和所有间隙 → 整张表实际上不可写生产事故高发点:一条不走索引的 UPDATE,把整张表锁死。
所以UPDATE/DELETE的 WHERE 条件必须有索引,这是硬性纪律。
验证加锁范围
-- MySQL 8.0+ 可以用 performance_schema 查看SELECT*FROMperformance_schema.data_locks;-- 5.7 用SHOWENGINEINNODBSTATUS\G四、死锁是怎么产生的
死锁的四个必要条件:互斥、占有且等待、不可抢占、循环等待。
InnoDB 无法破坏前三个(锁的本质),所以只能检测循环等待并回滚。
4.1 最经典的场景:加锁顺序不同
-- 事务 ABEGIN;UPDATEaccountSETbalance=balance-100WHEREid=1;-- 锁 id=1UPDATEaccountSETbalance=balance+100WHEREid=2;-- 等 id=2-- 事务 B(同时)BEGIN;UPDATEaccountSETbalance=balance-50WHEREid=2;-- 锁 id=2UPDATEaccountSETbalance=balance+50WHEREid=1;-- 等 id=1时间线:
T1 A 锁住 id=1 T2 B 锁住 id=2 T3 A 请求 id=2 → 等待 T4 B 请求 id=1 → 等待 T5 InnoDB 检测到环路 → 回滚其中一个,报 Deadlock解决方案:统一加锁顺序。比如永远按 id 升序处理:
ids=sorted([1,2])# 排序!foriinids:cursor.execute("UPDATE account SET ... WHERE id = %s",(i,))这一行sorted()能消除绝大多数死锁。
4.2 间隙锁导致的死锁
这个更隐蔽。RR 级别下,两个事务对同一间隙加间隙锁不冲突,
但后续的插入操作会冲突:
-- 表里有 id = 5-- 事务 ABEGIN;SELECT*FROMtWHEREid=5FORUPDATE;-- 加 Next-Key Lock,锁 (?, 5]-- 事务 BBEGIN;SELECT*FROMtWHEREid=5FORUPDATE;-- 间隙锁不冲突,也成功-- 事务 AINSERTINTOt(id)VALUES(4);-- 等待 B 释放间隙锁-- 事务 BINSERTINTOt(id)VALUES(4);-- 等待 A 释放 → 死锁!这种死锁在 RC 级别下不会发生(没有间隙锁)。
这也是很多团队改用 RC 的原因之一。
4.3 唯一键冲突导致的死锁
并发插入相同的唯一键时,一个成功一个失败,
失败的会加 S 锁等待,如果此时有其他事务持有锁,可能形成环路。
五、死锁日志怎么读
死锁发生后:
SHOWENGINEINNODBSTATUS\G找到LATEST DETECTED DEADLOCK段落:
------------------------ LATEST DETECTED DEADLOCK ------------------------ 2026-10-07 06:20:11 0x7f8b *** (1) TRANSACTION: TRANSACTION 421568, ACTIVE 2 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 15, OS thread handle ..., query id 88 localhost root updating UPDATE account SET balance = balance - 100 WHERE id = 1 *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 58 page no 3 n bits 80 index PRIMARY of table `test`.`account` trx id 421568 lock_mode X locks rec but not gap waiting *** (2) TRANSACTION: TRANSACTION 421569, ACTIVE 1 sec starting index read UPDATE account SET balance = balance + 50 WHERE id = 2 *** (2) HOLDS THE LOCK(S): RECORD LOCKS ... index PRIMARY ... trx id 421569 lock_mode X locks rec but not gap *** WE ROLL BACK TRANSACTION (2)读日志的关键四点:
(1) TRANSACTION和(2) TRANSACTION—— 两个互相等待的事务,
各自下面有它正在执行的 SQLlock_mode X locks rec but not gap—— 锁类型。X= 排他锁locks rec but not gap= Record Lock(没有间隙锁)locks gap before rec= 间隙锁next-key/ 无后缀 = Next-Key Lock
index PRIMARY—— 锁在哪个索引上。
如果显示的是二级索引,说明还回表锁了主键WE ROLL BACK TRANSACTION (2)—— 谁被回滚了。
InnoDB 选择回滚影响行数更少的那个(权重小的)
⚠️ 一个坑:SHOW ENGINE INNODB STATUS只保留最近一次死锁。
要看历史得开参数:
-- MySQL 5.6+ 可以把死锁日志写进 error logSETGLOBALinnodb_print_all_deadlocks=ON;生产环境建议常开,否则死锁信息会被覆盖掉。
六、锁等待与超时
-- 查看锁等待超时时间(默认 50 秒)SHOWVARIABLESLIKE'innodb_lock_wait_timeout';-- 查看当前等待锁的事务(MySQL 8.0+)SELECT*FROMperformance_schema.data_lock_waits;-- 5.7 查锁等待SELECT*FROMinformation_schema.innodb_lock_waits;SELECT*FROMinformation_schema.innodb_locks;-- 已加锁的-- 查看活跃事务SELECT*FROMinformation_schema.innodb_trx;找阻塞源的经典 SQL(8.0+):
SELECTwaiting_pidAS被阻塞的线程,waiting_queryAS被阻塞的SQL,blocking_pidAS阻塞者线程,blocking_queryAS阻塞者的SQL,wait_ageAS已等待时长FROMsys.innodb_lock_waits;sys库是 MySQL 5.7+ 自带的诊断视图集合,innodb_lock_waits把上面几张表 join 好了,直接用就行。
处理:确认后KILL <blocking_pid>杀掉阻塞源。
七、减少死锁的六条实践
1. 统一加锁顺序(最重要)
批量更新前排序,永远按同一顺序访问资源。
2. 缩小事务范围
# ❌ 事务里夹着 RPC 调用withtransaction():db.update(...)requests.post("https://外部服务")# 可能耗时几秒,锁一直占着db.update(...)# ✅ 事务里只做数据库操作withtransaction():db.update(...)db.update(...)requests.post(...)# 放到事务外事务越长,锁持有越久,冲突概率指数上升。
3. 降低隔离级别
RR → RC 可以消除大部分间隙锁死锁。
4. 避免无索引的 UPDATE/DELETE
WHERE 条件必须有索引,否则锁全表。
5. 用SELECT ... FOR UPDATE显式预加锁
BEGIN;SELECT*FROMtWHEREid=1FORUPDATE;-- 一开始就锁住-- 业务逻辑UPDATEtSET...WHEREid=1;COMMIT;先读后写的场景,在事务开始就加锁,避免中途升级锁造成环路。
6. 重试机制
死锁无法完全避免,应用层必须有重试:
fromfunctoolsimportwrapsimportpymysql,time,randomdefretry_on_deadlock(times=3):defdeco(fn):@wraps(fn)defwrapper(*a,**kw):foriinrange(times):try:returnfn(*a,**kw)exceptpymysql.err.OperationalErrorase:ife.args[0]==1213:# 1213 = deadlocktime.sleep(0.1*(2**i)+random.random()*0.1)continueraiseraisereturnwrapperreturndeco加了随机抖动,避免多个请求同时重试再次撞车。
八、死锁 vs 锁等待超时
| 死锁 | 锁等待超时 | |
|---|---|---|
| 原因 | 循环等待 | 长时间拿不到锁 |
| 检测 | InnoDB 主动检测(立即回滚) | 等innodb_lock_wait_timeout秒 |
| 错误码 | 1213 | 1205 |
| 处理 | 重试即可 | 要排查为什么持有这么久 |
死锁其实"好处理"——InnoDB 会立刻发现并回滚一个,
应用层重试就行。锁等待超时更麻烦,它说明有长事务,
需要查innodb_trx找出是谁。
九、小结
- InnoDB 行锁分三种:Record Lock(行)、Gap Lock(间隙)、
Next-Key Lock(前两者组合,RR 下默认) - 等值查询 + 唯一索引 → 退化成 Record Lock;普通索引不退化
- 间隙锁只在 RR 存在,RC 没有 → RC 并发更高、死锁更少
- WHERE 没索引 = 锁全表,这是最危险的情况
- 死锁主因是加锁顺序不同→ 批量操作前
sorted() - 读死锁日志看四点:两个事务的 SQL、锁类型、锁在哪个索引、谁被回滚
- 生产建议开
innodb_print_all_deadlocks,否则日志会被覆盖 - 查阻塞用
sys.innodb_lock_waits - 应用层必须做死锁重试(错误码 1213),加随机抖动
下一篇讲慢查询排查——锁解决"数据正确性",
慢查询解决"为什么这么慢",两者经常一起出现:
一条慢 SQL 持有锁太久,就会引发大面积锁等待。