RR 隔离级别下,UPDATE / DELETE 操作会加什么锁?示意图分析。
What locks are acquired by UPDATE / DELETE operations under the RR isolation level? A diagrammatic analysis.
考察要点
这道题面试官想看你是否深入理解 InnoDB 在默认隔离级别(Repeatable Read)下的加锁机制。核心是考察你对 Record Lock(行锁)、Gap Lock(间隙锁)和 Next-Key Lock(临键锁) 的掌握程度,以及能否清晰地分析出在不同查询条件(唯一索引、普通索引、无索引)下,UPDATE/DELETE 语句会施加哪种锁、锁定多大的范围。这直接关系到数据库的并发性能和死锁排查能力。
核心概念
在 MySQL 的 RR 隔离级别下,UPDATE 和 DELETE 属于当前读(Current Read),它们会读取并锁定数据库中最新的、已提交的数据版本,以防止其他事务并发修改。这与普通 SELECT 使用的快照读(Snapshot Read)(基于 MVCC)形成对比。其加锁行为主要依赖以下三种锁:
-
Record Lock(行锁):- 定义: 这是最基本的锁,直接锁定单条索引记录。
- 作用: 防止其他事务对该行进行
UPDATE或DELETE。 - 示例:
UPDATE users SET age = 30 WHERE id = 1;(假设id是主键),会给id=1的主键索引记录加上一个Record Lock。
-
Gap Lock(间隙锁):- 定义: 锁定一个开区间,即两个索引记录之间的间隙,或者第一个索引记录之前的间隙,或最后一个索引记录之后的间隙。它不锁定任何实际存在的记录。
- 作用: 防止其他事务在这个间隙中
INSERT新的记录,这是解决幻读问题的关键。 - 示意图: 假设
users表age字段有索引,且存在值为 20 和 30 的记录。text1... (age < 20) | record(age=20) | (20 < age < 30) | record(age=30) | (age > 30) ...2 ^ ^ ^ ^3 |-- Gap Lock --| |-- Gap Lock --| |-- Gap Lock --|
-
Next-Key Lock(临键锁):- 定义:
Record Lock和Gap Lock的结合体,锁定一个左开右闭的区间。即,它既锁定一个索引记录本身,也锁定该记录之前的那个间隙。 - 公式:
Next-Key Lock=Record Lock+Gap Lock(on the gap before the record) - 作用:
InnoDB在 RR 级别下,进行范围扫描和更新时的默认加锁方式。 - 示意图: 锁定
age=30的Next-Key Lock,实际锁定了(20, 30]这个区间。text1... (age < 20) | record(age=20) | (20 < age < 30) | record(age=30) | (age > 30) ...2 ^-----------------^3 |-- Next-Key Lock --|
- 定义:
不同场景下的加锁分析
为了方便演示,我们创建一张示例表:
1CREATE TABLE `users` (2 `id` int(11) NOT NULL AUTO_INCREMENT,3 `name` varchar(255) DEFAULT NULL,4 `age` int(11) DEFAULT NULL,5 PRIMARY KEY (`id`),6 KEY `idx_age` (`age`)7) ENGINE=InnoDB;89INSERT INTO `users` (`id`, `name`, `age`) VALUES (1, 'Alice', 10), (5, 'Bob', 20), (10, 'Charlie', 30), (15, 'David', 40);
age 索引上的记录分布为 10, 20, 30, 40。
场景一:WHERE 条件命中唯一索引(主键或唯一键),且为等值查询
- SQL 示例:
UPDATE users SET name = 'Alice_new' WHERE id = 5; - 加锁行为:
InnoDB会将Next-Key Lock退化为Record Lock。- 原因: 因为索引是唯一的,查询能精确命中一行,不需要保护任何间隙来防止幻读(不可能有另一个
id=5的行被插入)。 - 锁定范围: 仅锁定
id=5这一行记录。这是最高效的加锁方式。
场景二:WHERE 条件命中普通索引,且为等值查询
- SQL 示例:
UPDATE users SET name = 'Bob_new' WHERE age = 20; - 加锁行为:
- 首先,在
idx_age索引上,找到age=20的记录。由于age不是唯一的,可能存在多个age=20的记录。InnoDB需要防止新的age=20的记录被插入。 - 它会对
age=20的索引记录加上Next-Key Lock,锁定(10, 20]这个区间。 - 为了防止其他事务在
age=20之后插入新的age=20的记录,扫描会继续向后查找,直到下一个不等于 20 的值(即 30)。然后在这个间隙(20, 30)上加一个Gap Lock。 - 然后,对于每个找到的
age=20的记录,通过主键索引回表,给对应的主键记录(id=5)加上Record Lock。
- 首先,在
- 锁定范围:
idx_age上的(10, 30)区间被锁定,以及id=5的主键记录被锁定。
场景三:WHERE 条件命中普通索引,且为范围查询
- SQL 示例:
DELETE FROM users WHERE age >= 20 AND age < 35; - 加锁行为:
InnoDB会在idx_age索引上进行范围扫描。- 它会锁定所有扫描到的索引记录。
- 扫描从
age=20开始,给它加上Next-Key Lock,锁住(10, 20]。 - 继续扫描到
age=30,给它加上Next-Key Lock,锁住(20, 30]。 - 继续扫描到
age=40,这是第一个不满足age < 35的记录,扫描停止。但为了保护范围的右边界,需要给age=40的记录也加上Next-Key Lock,锁住(30, 40]。 - 同时,所有匹配记录(
id=5,id=10)对应的主键索引记录会被加上Record Lock。
- 锁定范围: 在
idx_age上,锁定了(10, 40]这个大区间。
场景四:WHERE 条件无索引
- SQL 示例:
UPDATE users SET age = 99 WHERE name = 'Bob'; - 加锁行为:
- 由于
name字段没有索引,MySQL 别无选择,只能进行全表扫描。 - 为了防止在扫描过程中出现幻读(比如另一个事务插入了满足条件的行),
InnoDB会对聚簇索引(主键索引)上的每一条记录都施加Next-Key Lock。 - 这相当于锁定了整张表,以及所有记录之间的间隙。
- 由于
- 锁定范围: 全表锁定。任何其他事务都无法对该表进行任何
INSERT、UPDATE、DELETE操作。这是性能灾难,必须在生产中极力避免。
常见低分回答
- “
UPDATE/DELETE就是加行锁”: 这个回答过于笼统,没有区分不同索引和查询条件下的差异,忽略了Gap Lock和Next-Key Lock的存在,这是 RR 级别并发控制的核心。 - “只有
SELECT ... FOR UPDATE才加锁,UPDATE和DELETE不加”: 这是对当前读的根本性误解。UPDATE和DELETE本身就是一种锁定读操作,它们必须加锁以保证数据一致性。 - 混淆 RC 和 RR 的加锁行为: 回答说“RR 级别下加的是行锁”,实际上这是 RC 级别的主要行为(RC 默认禁用
Gap Lock,只在特定情况如外键约束检查或唯一性检查时使用)。RR 的核心就是Next-Key Lock。 - 不知道无索引查询会锁全表: 这是一个非常严重的知识盲点。候选人如果不知道这一点,说明其对数据库性能和并发风险的理解不足。
- “
Gap Lock是为了解决不可重复读”: 这是概念性错误。Gap Lock主要为了解决幻读问题(Phantom Read),而不可重复读(Non-Repeatable Read)是由MVCC或Record Lock解决的。
工程实践与优化
- 为高频更新/删除的
WHERE条件建立索引: 这是最重要的优化。确保UPDATE/DELETE语句的WHERE子句中的列都有合适的索引,可以极大地缩小锁的范围,从全表锁降级为Next-Key Lock甚至Record Lock,从而提高并发性能。 - 使用
EXPLAIN分析执行计划: 在上线任何重要的UPDATE/DELETE语句前,使用EXPLAIN检查其执行计划。重点关注type列是否为ALL(全表扫描),key列是否使用了预期索引。 - 缩小事务范围,尽快提交: 大事务会长时间持有锁,严重阻塞其他会话。应将业务逻辑拆分,让事务尽可能简短,操作完成后立即
COMMIT或ROLLBACK释放锁。 - 使用唯一索引进行点更新: 如果业务逻辑允许,尽可能使用主键或唯一键作为
UPDATE/DELETE的条件。这能让锁退化为Record Lock,实现最大程度的并发。 - 死锁排查: 当发生死锁时,使用
SHOW ENGINE INNODB STATUS命令查看LATEST DETECTED DEADLOCK部分。它会详细列出死锁涉及的两个事务、它们持有的锁、正在等待的锁以及发生冲突的 SQL 语句,是定位死锁根源的最有效工具。
常见追问与延伸题目
-
追问:为什么 RR 级别需要
Gap Lock和Next-Key Lock,而 RC 级别基本不需要?- 回答要点: 这是由隔离级别的定义决定的。RR(可重复读)的契约是在一个事务内,多次执行相同的范围查询,结果集应该完全一致,不能多出“幻影”行。为了阻止其他事务在两次查询之间插入新的、符合范围条件的行,就必须用
Gap Lock锁住这些可能被插入的“间隙”。而 RC(读已提交)只保证能读到已提交的数据,它允许幻读和不可重复读的发生,因此不需要Gap Lock来保护间隙,默认只对实际操作的行加Record Lock,并发性更好。
- 回答要点: 这是由隔离级别的定义决定的。RR(可重复读)的契约是在一个事务内,多次执行相同的范围查询,结果集应该完全一致,不能多出“幻影”行。为了阻止其他事务在两次查询之间插入新的、符合范围条件的行,就必须用
-
追问:如果我的业务场景可以容忍幻读,但对并发写入性能要求极高,应该怎么做?
- 回答要点:
- 调整隔离级别: 最直接的方法是将数据库或当前会话的隔离级别设置为
READ COMMITTED。SET GLOBAL transaction_isolation = 'READ-COMMITTED';或SET SESSION transaction_isolation = 'READ-COMMITTED';。这会禁用Gap Lock,大大减少锁冲突,提升并发写入(尤其是INSERT)的性能。 - 检查
Binlog格式: 在此之前,必须确认 MySQL 的binlog_format设置为ROW或MIXED。如果是STATEMENT格式,在 RC 级别下主从复制可能出现数据不一致问题。幸运的是,MySQL 5.7.7+ 默认就是ROW格式。 - 评估业务影响: 确认业务逻辑上确实能接受幻读带来的影响,避免出现意料之外的数据不一致。
- 调整隔离级别: 最直接的方法是将数据库或当前会话的隔离级别设置为
- 回答要点:
-
追问:
UPDATE users SET name = 'New' WHERE id = 9;,假设表中没有id=9的记录,这条 SQL 会加什么锁?- 回答要点: 这是一个经典的边界情况测试。即使记录不存在,
InnoDB仍然需要加锁来防止这条记录被插入,以维护 RR 隔离级别。- 扫描定位: 数据库会扫描主键索引,定位到
id=9应该插入的位置。根据我们的示例数据(1, 5, 10, 15),id=9位于id=5和id=10之间。 - 加间隙锁:
InnoDB会在这个位置加上一个Gap Lock,锁定的范围是(5, 10)。 - 效果: 这个
Gap Lock会阻塞任何其他事务执行INSERT INTO users (id, ...) VALUES (9, ...)或任何id在(5, 10)区间内的插入操作,直到当前事务提交或回滚。这完美地防止了幻读。
- 扫描定位: 数据库会扫描主键索引,定位到
- 回答要点: 这是一个经典的边界情况测试。即使记录不存在,