深色模式
04 · 锁与死锁
03 篇讲了"不加锁"的路(MVCC),这篇讲"加锁"的路。两条路配合,才是 InnoDB 的全貌。
一句话本质
锁的作用是让并发事务在修改同一份数据时排队。代价是并发度下降,所以锁的粒度越细越好,但要能管得住。
生活类比:图书馆的借阅登记
| 锁 | 场景 |
|---|---|
| 共享锁(S) | "我在阅览室看这本书,你可以一起看,但谁都不能带走" |
| 排他锁(X) | "我借走了,你们谁都别想" |
| 意向锁 | 门口挂个牌子:"里面有人占了某个座位",让管理员不用挨个问 |
| 行锁 | 只锁这一本书 |
| 表锁 | 整个书架都锁了 |
| 间隙锁 | 锁住"第 5 到第 10 本之间的空位",不让人往里插新书 |
| 死锁 | A 拿着 1 号等 2 号,B 拿着 2 号等 1 号 |
核心机制
一、锁的分类(两个维度)
维度 1:兼容性
共享锁(S 锁 / 读锁):
多个事务可以同时持有
持有 S 锁时:别人能加 S,不能加 X
排他锁(X 锁 / 写锁):
只能一个事务持有
持有 X 锁时:别人既不能加 S,也不能加 X| S 锁 | X 锁 | |
|---|---|---|
| S 锁 | ✅ 兼容 | ❌ 冲突 |
| X 锁 | ❌ 冲突 | ❌ 冲突 |
维度 2:粒度
表级锁:
- 表锁(LOCK TABLES)
- 意向锁(IS / IX):表示"我打算给某行加 S/X 锁"
- 自增锁(AUTO-INC)
行级锁:
- 记录锁(Record Lock):锁住某一条索引记录
- 间隙锁(Gap Lock):锁住记录之间的空隙
- Next-Key Lock:记录锁 + 间隙锁,锁住左开右闭区间
- 插入意向锁:插入前加,表示"我要在这个间隙插入"二、意向锁:为什么需要它
问题:事务 A 要给第 5 行加 X 锁。事务 B 要加表锁。B 怎么知道"表里有没有行被锁"?
如果没有意向锁,B 必须遍历所有行检查有没有行锁——太慢。
解法:加行锁之前,先给表加一个"意向锁"。
事务 A:LOCK 表(加 IX 意向锁)> 再给第 5 行加 X 锁
事务 B:想加表锁 > 看到表上有 IX > 直接知道"有人锁了行" > 等待意向锁之间是兼容的(IS 和 IX 兼容),它只和表级的 S/X 锁冲突。
三、InnoDB 的行锁是"加在索引上的"
这是最重要的一句话:InnoDB 的行锁锁的是索引记录,不是行本身。
推论:
① 如果 WHERE 条件没走索引 > 无法定位到索引记录 > 只能锁全表(实际上是锁所有行)
② 如果走了索引 > 只锁命中的索引记录这就是"没走索引的 UPDATE 会锁全表"的原因,也是线上事故的高发点。
四、三种行锁的区别
假设索引上有 id = 1, 5, 10
【记录锁】锁住 id = 5 这一条
SELECT * FROM t WHERE id = 5 FOR UPDATE;
【间隙锁】锁住 (1, 5) 这个空隙(不包含两端的记录)
SELECT * FROM t WHERE id BETWEEN 2 AND 4 FOR UPDATE;
> 别人不能插入 id = 2,3,4
【Next-Key Lock】锁住 (1, 5](左开右闭)
SELECT * FROM t WHERE id = 5 FOR UPDATE; < RR 下默认是这个
> 锁住 id=5 这条记录 + (1,5) 这个间隙为什么 RR 默认用 Next-Key Lock:因为只锁记录防不住"插入"(幻读)。加上间隙锁才能阻止别人在空隙里插新行。
RC 级别下没有间隙锁,只有记录锁。
五、加锁规则(实用口诀)
InnoDB 在 RR 下的加锁规则,可以总结成几条(这是《MySQL 实战 45 讲》里的经典总结):
① 加锁的基本单位是 Next-Key Lock
② 查找过程中访问到的对象才会加锁
③ 索引上的等值查询,给唯一索引加锁时,Next-Key Lock 退化为记录锁
④ 索引上的等值查询,向右遍历时最后一个不满足条件的值,Next-Key Lock 退化为间隙锁
⑤ 唯一索引上的范围查询会访问到不满足条件的第一个值为止实战中最该记住的两条:
- 唯一索引等值查询命中 > 只锁一行(规则 ③),这是最理想的情况。
- 非唯一索引等值查询 > 会锁住"值相等的所有行 + 后面的间隙",锁范围比想象的大。
六、死锁:怎么产生,怎么处理
事务 A:UPDATE t SET x=1 WHERE id=1; (持有 id=1 的 X 锁)
事务 B:UPDATE t SET x=2 WHERE id=2; (持有 id=2 的 X 锁)
事务 A:UPDATE t SET x=1 WHERE id=2; (等 B 释放 id=2)
事务 B:UPDATE t SET x=2 WHERE id=1; (等 A 释放 id=1)
> 死锁四个必要条件(和 OS 完全一样):互斥、持有并等待、不可剥夺、循环等待。
InnoDB 的处理方式:
① 死锁检测(默认开启 innodb_deadlock_detect = ON)
每次加锁时检查"等待图"有没有环 > 有环就报错,回滚代价最小的事务
代价:O(n²) 的检测开销(n 是等待的事务数),高并发时很吃 CPU
② 死锁超时(innodb_lock_wait_timeout,默认 50 秒)
等太久了自动放弃
代价:响应慢,且可能回滚了不该回滚的事务高并发下的死锁检测问题:1000 个并发事务同时更新同一行,每个都要检测"有没有环",检测成本 O(n),总成本 O(n²) > CPU 打满。这就是"热点行更新"的性能瓶颈。
解法:
- 关闭死锁检测,靠超时兜底(适合冲突极少的场景)。
- 减少热点:把一行拆成多行(比如账户余额拆成 10 个子账户,随机选一个更新)。
- 在应用层串行化:用队列把对同一行的更新排队。
七、怎么避免死锁(实战)
① 按固定顺序访问资源
所有事务都按 id 升序更新 > 不会形成环
② 缩短事务
事务越长,持有锁越久,冲突概率越大
> 把"查询"放到事务外,事务里只做写
③ 用索引,避免锁全表
没走索引的 UPDATE 会锁所有行 > 极易死锁
④ 降低隔离级别到 RC
RC 没有间隙锁,锁范围小,死锁概率大幅下降
⑤ 避免大事务批量更新
拆成小批次(每次 100 行),减少锁持有时间为什么这么设计
反事实 1:如果只有表锁
并发度极低。一个用户下单锁住整张订单表,其他用户全部排队。这就是 MyISAM 的问题(它只支持表锁)。
InnoDB 的行锁是它取代 MyISAM 的核心原因。
反事实 2:如果没有意向锁
加表锁时要遍历所有行检查行锁,O(n) 的开销。有了意向锁,O(1) 就能判断。
反事实 3:如果没有间隙锁
RR 级别下,SELECT ... FOR UPDATE 只能锁住已存在的行,别人仍能在范围内插入新行 > 幻读。间隙锁是"锁住未来的行"。
代价:锁范围变大,死锁概率上升。这是"正确性换性能"的典型。
面试怎么问
Q:InnoDB 有哪些锁?
按粒度:表锁、意向锁、行锁(记录锁 / 间隙锁 / Next-Key Lock / 插入意向锁)。 按兼容性:共享锁(S)、排他锁(X)。
Q:为什么 InnoDB 的行锁实际上是锁索引?
因为行锁加在索引记录上。没走索引就定位不到记录,只能锁所有行(表现为"锁表")。所以索引不仅影响查询速度,还影响锁的范围。
Q:间隙锁的作用?有什么代价?
作用:阻止在间隙中插入,解决 RR 下的幻读。 代价:锁范围大,并发度下降,死锁概率上升。这也是很多公司改 RC 的原因。
Q:怎么避免死锁?
固定访问顺序、缩短事务、用索引避免锁全表、降级到 RC、批量操作拆小。
Q:死锁检测有什么问题?
每次加锁都要检测等待图,O(n) 复杂度,高并发下总成本 O(n²),CPU 打满。解法:关掉检测靠超时、拆分热点行、应用层排队。
追问链:
事务怎么保证隔离?(锁 + MVCC)
> 锁有哪些?(S/X、表/行、间隙锁)
> 行锁加在哪?(索引上)
> 没走索引会怎样?(锁全表)
> 间隙锁干什么?(防幻读)
> 代价是什么?(死锁)
> 怎么避免?(固定顺序、缩事务、RC)和其他知识的连接
- < 来自 调度与并发同步:CPU 分给谁,共享数据谁先动:死锁四条件、锁的兼容性,和 OS 完全同源
- < 来自 隔离级别与 MVCC:锁是 MVCC 之外的另一条路,两者配合使用
- < 来自 索引与 B+ 树:索引决定了锁的范围,这是索引的第二个重要作用
- > 影响 SQL 执行与优化:
EXPLAIN看到type=ALL意味着全表扫,也意味着可能锁全表 - > 影响 工程实践:热点账户、秒杀扣库存、批量更新,都是死锁高发场景
自测 3 问
Q1:为什么"没走索引的 UPDATE"会锁全表?
点开看答案
因为 InnoDB 的行锁加在索引记录上。
如果 UPDATE t SET x=1 WHERE name='张三' 而 name 没有索引:
- 数据库无法通过索引定位到具体行,只能全表扫描。
- 扫描过程中,每一行都会被访问到,按加锁规则"访问到的对象才会加锁",于是每一行都被加了锁。
- 表现为"锁全表"。
后果:
- 其他事务改任何一行都会被阻塞。
- 死锁概率大幅上升。
- 并发度直接降到 1。
这就是"给 WHERE 条件加索引"不只是优化查询,更是避免锁表。
Q2:RC 级别下为什么没有间隙锁?
点开看答案
因为 RC 级别允许幻读,所以不需要间隙锁来防它。
RC 的设计目标是"读已提交",只保证"不读到未提交的数据",不保证"同一事务内两次读结果一致"。所以:
- 每次 SELECT 生成新的 ReadView(能读到别人刚提交的)。
- 加锁时只锁命中的记录,不锁间隙。
好处:锁范围小,并发高,死锁少。 代价:幻读存在,业务层要自己处理(比如用 SELECT ... FOR UPDATE 时,仍然可能插入新行)。
这也是"隔离级别是权衡,不是越高越好"的体现。
Q3:为什么"按固定顺序更新"能避免死锁?
点开看答案
因为死锁的第四个条件是循环等待:A 等 B 的资源,B 等 A 的资源。
如果所有事务都按"id 从小到大"的顺序加锁:
- 事务 A 要改 id=1 和 id=2 > 先锁 1,再锁 2
- 事务 B 也要改 id=1 和 id=2 > 也必须先锁 1
那么 B 在等 1 的时候,A 已经持有 1 并去申请 2。此时 2 没人占,A 拿到就完成了。B 等 A 释放 1 就行。
不可能出现"A 持有 2 等 1、B 持有 1 等 2"——因为没人会先拿 2 再拿 1。
代价:可能需要把一批操作排序(业务上不一定自然有序),且跨表操作时"全局顺序"难以定义。
类比失效处 / 易错点
- "锁书"类比会让人以为锁是加在"行"上。实际是加在索引记录上,所以没有索引就没有"行锁",只有全表锁。
- 间隙锁不锁记录本身。它锁的是"两条记录之间的空间",所以
SELECT ... WHERE id=5 FOR UPDATE在 RR 下锁的是(1,5],包括 1 到 5 之间的空隙。 - 意向锁不阻塞行锁。IS 和 IX 之间是兼容的,它只和表级 S/X 锁冲突。很多人误以为意向锁会互相阻塞。
- 死锁不一定是 bug。高并发下死锁是正常的,数据库会自动回滚一个。关键是应用层要能处理"死锁异常并重试"。
SELECT ... FOR UPDATE在 RC 和 RR 下锁范围不同。同样的 SQL,换个隔离级别,锁的行数可能差很多。
✅ 验收清单
- 能画出 S 锁和 X 锁的兼容矩阵
- 能解释"为什么需要意向锁"(要说出"避免遍历所有行")
- 能说清"行锁加在索引上",并由此推出"没走索引会锁全表"
- 能区分记录锁、间隙锁、Next-Key Lock 三者的锁范围
- 能说出 RR 和 RC 在加锁上的区别(有无间隙锁)
- 能说出至少 4 种避免死锁的实战做法
- 能解释"死锁检测在高并发下为什么是瓶颈"(O(n²))
- 合上书,用自己的话讲 3 分钟,中途不卡壳