MySQL锁机制揭秘:记录锁、间隙锁与临键锁详解
以下内容将从 MySQL InnoDB 存储引擎的锁机制入手,重点讲解记录锁(Record Lock)、间隙锁(Gap Lock)及临键锁(Next-key Lock)的概念与区别,配合实际代码示例、ASCII 图解与详细说明,帮助你系统地理解它们在并发控制与防止幻读中的作用。
目录
1. MySQL 锁机制总览
MySQL 中,常见的锁类型包括:
- 表级锁(Table Lock):针对整张表加锁,粒度最粗,在 MyISAM 引擎中较常用。
- 行级锁(Row-level Lock):InnoDB 等事务型存储引擎支持,粒度更细,可大幅提高并发。
在行级锁内部,InnoDB 进一步细分为:
- 记录锁(Record Lock):针对索引上的单条记录加锁。
- 间隙锁(Gap Lock):针对索引记录之间空隙(两个相邻索引值)加锁。
- 临键锁(Next-key Lock):等同于“记录锁 + 间隙锁”,即对记录本身及其左侧的间隙同时加锁。
这三种锁的组合与使用,决定了 InnoDB 在不同隔离级别下如何防止“幻读”(phantom reads)与保证一致性。
2. InnoDB 行级锁简介
2.1 什么是记录锁(Record Lock)
- 定义:记录锁直接作用于索引中的单条记录,防止其他事务对该记录进行更新或删除。
- 触发场景:最典型的是使用
SELECT … FOR UPDATE
、UPDATE … WHERE …
、DELETE … WHERE …
等语句,InnoDB 会定位到满足条件的行并加上记录锁。 - 作用:保证同一行不会被多个事务同时修改,避免数据不一致。
举例说明
-- 会话 1
START TRANSACTION;
SELECT * FROM t WHERE id = 5 FOR UPDATE; -- 对主键 id=5 加记录锁
-- 会话 2
START TRANSACTION;
UPDATE t SET name='X' WHERE id = 5; -- 需要先获得记录锁,会等待会话1释放
2.2 什么是间隙锁(Gap Lock)
- 定义:间隙锁只锁定索引节点之间的空隙(gap),并不锁定行本身。例如,若索引上存在键值 10 与 20,则 10–20 之间的区间就是一个“间隙”;间隙锁将阻止其他事务在该区间插入新行。
- 触发场景:在 InnoDB 的 REPEATABLE READ 隔离级别下,用
SELECT … FOR UPDATE
或UPDATE
、DELETE
等操作,若 WHERE 条件不是精确匹配某个索引值,而是范围扫描,就会隐式对扫描到范围中的间隙加锁。 - 作用:防止“幻读”:即在同一事务中,多次执行相同的范围查询,防止其他事务在这个范围内插入新行导致结果集合不同。
举例说明
假设表中有索引值 100 和 200,当事务 A 使用
SELECT * FROM t WHERE id BETWEEN 100 AND 200 FOR UPDATE;
这条范围查询不仅对 100、200 位置的记录加记录锁,同时会对 (100,200) 区间加间隙锁,其他事务无法在 (101…199) 之间插入新行。
2.3 什么是临键锁(Next-key Lock)
- 定义:临键锁(Next-key Lock)是 InnoDB 默认的锁定粒度,它是“记录锁 + 间隙锁”的合体。即对索引的某个记录和该记录左侧的间隙同时加锁。
- 触发场景:在 REPEATABLE READ 隔离级别下,对索引列进行
SELECT … FOR UPDATE
、UPDATE
、DELETE
等操作时,InnoDB 会对每个匹配行加一个临键锁。这比单纯的记录锁更严格,因为它同时锁定前面的间隙,进而防止幻读。 - 作用:在默认隔离级别下,保证读或写一个索引记录时,其左侧区间也被锁定,阻止在此范围插入,这样可以彻底避免幻读。
3. 锁粒度示意与 ASCII 图解
3.1 索引与记录空间划分
假设我们有一张简单的表 t
,其主键为 id
,当前表中存在以下几条记录:
+----+--------+
| id | name |
+----+--------+
| 10 | Alice |
| 20 | Bob |
| 30 | Cathy |
+----+--------+
在 B+Tree 索引上,逻辑上会分为:
... (−∞,10) 10 (10,20) 20 (20,30) 30 (30, +∞) ...
10
、20
、30
等节点就是“记录”(Record)。(−∞,10)
、(10,20)
、(20,30)
、(30,+∞)
等区间即“间隙”(Gap)。
3.2 记录锁示意图
当事务 A 执行:
SELECT * FROM t WHERE id = 20 FOR UPDATE;
InnoDB 会将主键为 20 的那条记录加上记录锁,不加任何间隙锁。示意图如下,用 X
表示被锁定的记录:
... (−∞,10) 10 (10,20) [20:X] (20,30) 30 (30, +∞) ...
- 只有 20 这条记录被锁,其他事务仍可在 20 左右的间隙插入新行(例如插入 id=15 或 id=25 均无阻碍)。
3.3 间隙锁示意图
当事务 A 执行:
SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;
InnoDB 会对 id=10
、id=20
两条记录加记录锁,同时对 [10,20]
区间的间隙加间隙锁。示意图(粗略表示):
... (−∞,10) [10:X] [(10,20):LOCKED] [20:X] (20,30) 30 (30,+∞) ...
X
表示记录锁;LOCKED
表示该区间被间隙锁。- 事务 B 无法向
(10,20)
(即11..19
)之间插入新行,但仍可插入(−∞,10)
或(20,30)
区间。
3.4 临键锁示意图
当事务 A 执行相同的范围查询(在 REPEATABLE READ 下默认使用临键锁):
SELECT * FROM t WHERE id >= 20 FOR UPDATE;
此时 InnoDB 会对 id=20
及其左侧的间隙 (10,20)
同时加锁。示意图:
... (−∞,10) 10 (10,20:LOCKED) [20:X] (20,30) 30 (30,+∞) ...
[20:X]
:对 20 记录加记录锁。(10,20:LOCKED)
:对紧邻 20 左侧的间隙加间隙锁。- 如果套用下一条记录 30,同样对
(20,30)
或(30,+∞)
视查询条件会加相应锁。
因为它既锁了记录也锁了左侧间隙,这种组合即称“临键锁(Next-key Lock)”。
4. 锁类型的触发条件与示例演示
4.1 示例表及数据准备
首先创建一个示例表,并插入若干测试数据,以便后续演示锁行为:
-- 创建数据库与表
CREATE DATABASE test_locks;
USE test_locks;
CREATE TABLE t (
id INT NOT NULL PRIMARY KEY,
name VARCHAR(20)
) ENGINE=InnoDB;
-- 插入测试数据
INSERT INTO t (id, name) VALUES
(10, 'Alice'),
(20, 'Bob'),
(30, 'Cathy'),
(40, 'David'),
(50, 'Eve');
此时,主键索引上存在 id=10,20,30,40,50 五条记录。
注意:为了观察锁的粒度与范围,务必使用 InnoDB 存储引擎,并确保隔离级别为 REPEATABLE READ(InnoDB 默认)。
可以通过以下命令查看当前隔离级别与锁模式:
SELECT @@tx_isolation; -- 或 @@transaction_isolation
SELECT @@innodb_autoinc_lock_mode;
确保隔离级别是 REPEATABLE-READ
。
4.2 记录锁:SELECT … FOR UPDATE
在会话 A 中:
-- 会话 A
START TRANSACTION;
SELECT * FROM t WHERE id = 20 FOR UPDATE;
- 这条语句会在 id=20 记录上加一个记录锁,如下示意:
... (−∞,10) 10 (10,20) [20:X] (20,30) 30 (30,40) 40 ...
此时在另一个会话 B 中,如果尝试:
-- 会话 B
START TRANSACTION;
UPDATE t SET name='Bob2' WHERE id = 20;
会被阻塞,直到会话 A 执行 COMMIT
或 ROLLBACK
释放锁后,才可继续执行。
而如果会话 B 执行:
INSERT INTO t (id, name) VALUES (20, 'NewBob');
也会被阻塞,因为插入相同主键等价于先定位 id=20,再加记录锁,发现已有记录锁会等待。
但是,如果会话 B 执行:
INSERT INTO t (id, name) VALUES (25, 'NewGuy');
由于 id=25 处于 (20,30)
区间,会话 A 并未对该间隙加锁(仅加了记录锁),因此插入能立即成功。
4.3 间隙锁:REPEATABLE READ 隔离级别下的 INSERT 与 SELECT FOR UPDATE
示例:事务 A 对范围加锁
-- 会话 A
START TRANSACTION;
SELECT * FROM t WHERE id BETWEEN 20 AND 40 FOR UPDATE;
- InnoDB 会对
id=20
、id=30
、id=40
的记录加记录锁,同时对这些记录之间的间隙(20,30)
与(30,40)
加间隙锁,示意:
... (−∞,10) 10 (10,20) [20:X] [(20,30):LOCKED] [30:X] [(30,40):LOCKED] [40:X] (40,50) 50 ...
- 注意
(10,20)
并未加锁,因为范围是 [20,40],左端点处不会锁前一个间隙;而(40,50)
同理不锁。
会话 B 测试插入
-- 会话 B
START TRANSACTION;
-- 尝试在 (20,30) 区间插入:id=25
INSERT INTO t (id,name) VALUES (25,'XChris'); -- 会被阻塞
-- 尝试在 (10,20) 区间插入:id=15
INSERT INTO t (id,name) VALUES (15,'YAnna'); -- 立即成功
-- 尝试在 (40,50) 区间插入:id=45
INSERT INTO t (id,name) VALUES (45,'ZAndy'); -- 立即成功
(20,30)
已被间隙锁锁定,无法插入 id=25;(10,20)
未锁可插入 id=15;(40,50)
未锁可插入 id=45。
当会话 A 执行 COMMIT
释放锁后,会话 B 才能继续插入 25。
4.4 临键锁:NEXT-KEY LOCK 的综合示例
临键锁是 InnoDB 在 REPEATABLE READ 下默认加的锁类型,它等同于记录锁与其左侧间隙锁的组合。
示例:事务 A 对单条记录加锁,触发临键锁
-- 会话 A
START TRANSACTION;
SELECT * FROM t WHERE id = 30 FOR UPDATE;
- 虽然条件是单行
id=30
,但在 REPEATABLE READ 模式下,InnoDB 默认会对(20,30)
区间加间隙锁,再对id=30
加记录锁,示意:
... (−∞,10) 10 (10,20) [20] [(20,30):LOCKED] [30:X] (30,40) 40 ...
会话 B 测试插入
-- 会话 B
START TRANSACTION;
-- 尝试在 (20,30) 区间插入:id=25
INSERT INTO t (id,name) VALUES (25,'XChris'); -- 会被阻塞
-- 尝试在 (30,40) 区间插入:id=35
INSERT INTO t (id,name) VALUES (35,'YAnna'); -- 可以插入,因为 (30,40) 未锁
id=25
所在的(20,30)
区间被临键锁锁定,不能插入;id=35
在(30,40)
,能够顺利插入。
Tip:如果在会话 A 上执行SELECT * FROM t WHERE id = 30 LOCK IN SHARE MODE;
,则会得到同样的临键锁(共享模式),仍会锁住(20,30)
。
5. 防止幻读与隔离级别
5.1 REPEATABLE READ 与幻读
- 幻读(Phantom Read):在同一事务中,执行两次相同的范围查询,若其他事务在范围内插入新行,第一次与第二次查询结果集不一致,即为幻读。
- InnoDB 的默认隔离级别:
REPEATABLE READ
,它通过临键锁来防止幻读,即在读取范围时会对记录本身及左侧间隙加锁,阻止其他事务在该范围内插入新行。
示例:防止幻读
-- 会话 A
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM t WHERE id BETWEEN 20 AND 40; -- 结果包含 20,30,40
-- 会话 B
START TRANSACTION;
INSERT INTO t (id,name) VALUES (25,'NewGuy');
COMMIT;
-- 回到会话 A
SELECT * FROM t WHERE id BETWEEN 20 AND 40; -- 结果仍然仅包含 20,30,40,无 25
COMMIT;
- 即使会话 B 插入了 id=25,会话 A 的第二次查询也不会看到新行,因为在第一次查询时就对
(20,30)
、(30,40)
等间隙加了锁,阻止会话 B 插入。
5.2 READ COMMITTED 模式下的锁行为
- 如果将隔离级别设置为
READ COMMITTED
,InnoDB 不会对单行查询额外加间隙锁,只加记录锁。因此无法防止幻读,只能保证读到的是已提交数据。 - 在
READ COMMITTED
下,前例中会话 A 第二次查询会返回 id=25。
-- 会话 A
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT * FROM t WHERE id BETWEEN 20 AND 40; -- 包含 20,30,40
-- 会话 B
START TRANSACTION;
INSERT INTO t (id,name) VALUES (25,'NewGuy');
COMMIT;
-- 会话 A
SELECT * FROM t WHERE id BETWEEN 20 AND 40; -- 结果包含 20,25,30,40
COMMIT;
6. 实战演练:多 session 并发场景
下面通过真实的 MySQL 会话演练,让你一步步体验记录锁、间隙锁和临键锁的行为。
6.1 场景一:事务 A、B 同时更新同一行
步骤演示
会话 A:
START TRANSACTION; SELECT * FROM t WHERE id = 20 FOR UPDATE; -- 此时锁定 id=20 记录
会话 B(不同终端):
START TRANSACTION; UPDATE t SET name='Bob2' WHERE id = 20; -- 由于会话 A 未提交,会话 B 被阻塞,等待记录锁释放
会话 A:
UPDATE t SET name='Bob1' WHERE id = 20; COMMIT; -- 释放锁
会话 B 恢复:
-- B 此时获得锁,执行 UPDATE COMMIT;
此时,最终 name='Bob2'
,因为会话 B 在 A 提交后获得锁并执行更新。
6.2 场景二:事务 A、B 并发插入相邻数据引发间隙锁冲突
步骤演示
会话 A:
START TRANSACTION; SELECT * FROM t WHERE id BETWEEN 20 AND 30 FOR UPDATE; -- 锁定 20、30 记录及 (20,30) 区间
会话 B:
START TRANSACTION; INSERT INTO t (id, name) VALUES (25, 'XChris'); -- 会话 B 在 (20,30) 区间插入,因间隙锁被阻塞
会话 A:
-- 仍可插入 (30,40) 区间,不影响会话 B INSERT INTO t (id,name) VALUES (35, 'YAnna'); -- 立即成功 COMMIT; -- 释放锁
会话 B 恢复:
-- B 重新尝试插入 id=25 -- 此时 (20,30) 区间无锁 COMMIT;
此时,25
和 35
都插入成功,顺序取决于谁先获锁。
6.3 场景三:事务 A、B 读取相同范围,防止幻读
步骤演示
会话 A:
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT * FROM t WHERE id >= 20 FOR UPDATE; -- 对 (20,30) 区间及 20、30本身加了临键锁
会话 B:
START TRANSACTION; INSERT INTO t (id,name) VALUES (25, 'NewGuy'); -- 会话 B 在 (20,30) 插入被阻塞,因为临键锁锁住了间隙
会话 A:
-- 再次查询同一范围 SELECT * FROM t WHERE id >= 20; -- 依然不会看到 id=25,防止幻读 COMMIT;
会话 B 恢复:
COMMIT; -- 此时可以插入 25
在 READ COMMITTED
下会话 A 第二次查询会看到插入的 25,从而出现幻读。
7. 实用技巧与注意事项
合理选择隔离级别
- 如果对幻读要求严格,可使用默认的
REPEATABLE READ
;若对性能要求更高且可容忍幻读,可切换到READ COMMITTED
。
- 如果对幻读要求严格,可使用默认的
避免范围锁过度
- 大量范围查询(如
SELECT * FROM t WHERE id > 0 FOR UPDATE
)会把整个索引都锁住,影响并发。可加分页或尽量使用精确索引条件。
- 大量范围查询(如
理解自增锁模式
- InnoDB 还有一种
AUTO-INC
锁,用于INSERT … VALUES
情形。在并发插入同一张表时,MySQL 会对插入缓冲区加表级锁。若希望提高并发,可将innodb_autoinc_lock_mode
设置为2(INTERLEAVED)
,允许并行自增。
- InnoDB 还有一种
监控锁等待
- 使用
SHOW ENGINE INNODB STATUS\G
或INFORMATION_SCHEMA.INNODB_LOCKS
、INNODB_LOCK_WAITS
等表查看当前锁状况与等待情况,有助于定位死锁或性能瓶颈。
- 使用
索引选择要覆盖常见查询
- 查询时尽量使用覆盖索引(Covering Index),避免回表,提高锁粒度更细。
尽量减少长事务
- 长事务会持有锁更久,导致其他事务阻塞。应及时提交或拆分事务。
了解 InnoDB 行锁与间隙锁的组合行为
- 在不同版本或配置下(如
innodb_locks_unsafe_for_binlog
、innodb_autoinc_lock_mode
),锁行为可能略有变化,需根据实际版本文档进行验证。
- 在不同版本或配置下(如
8. 小结
本文深入剖析了 MySQL InnoDB 存储引擎的三种行级锁:
- 记录锁(Record Lock):仅锁定单条记录,用于保护该行不被并发更新。
- 间隙锁(Gap Lock):仅锁定索引的空隙区间,用于防止其他事务在该区间插入,从而避免幻读。
- 临键锁(Next-key Lock):记录锁与左侧间隙锁的组合,是 InnoDB 在
REPEATABLE READ
下默认使用的锁方式,可同时保护记录本身与左侧间隙,彻底避免幻读。
通过 ASCII 图示与真实多会话并发演示,帮助你直观地了解这三种锁在索引树上的实际生效范围及对并发的影响。同时,还对如何在不同隔离级别下防止或允许幻读进行了对比,给出了常见实战场景与调优建议。
掌握这些锁机制后,你可以在设计表结构、编写查询或调优并发事务时,更有信心地控制锁粒度与并发性能,有效避免死锁和幻读。在实际项目中,建议结合 SHOW ENGINE INNODB STATUS
、INFORMATION_SCHEMA.INNODB_LOCKS
等工具,实时监控锁情况,及时发现并解决并发瓶颈。
评论已关闭