Skip to content

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)

和其他知识的连接 ​

自测 3 问 ​

Q1:为什么"没走索引的 UPDATE"会锁全表?

点开看答案

因为 InnoDB 的行锁加在索引记录上。

如果 UPDATE t SET x=1 WHERE name='张三' 而 name 没有索引:

  1. 数据库无法通过索引定位到具体行,只能全表扫描。
  2. 扫描过程中,每一行都会被访问到,按加锁规则"访问到的对象才会加锁",于是每一行都被加了锁。
  3. 表现为"锁全表"。

后果:

  • 其他事务改任何一行都会被阻塞。
  • 死锁概率大幅上升。
  • 并发度直接降到 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 分钟,中途不卡壳