锁机制
导学
事务的隔离性靠什么实现?答案是锁。MySQL 5.7 的 InnoDB 提供了丰富的锁机制,理解锁的类型、粒度和兼容性,是排查死锁和性能问题的关键。
定义
锁(Lock):数据库用于控制并发访问的机制。锁防止多个事务同时修改同一数据,确保数据一致性。InnoDB 支持行级锁和表级锁,默认使用行级锁。
准备测试数据
CREATE TABLE employees (
emp_id INT PRIMARY KEY AUTO_INCREMENT,
emp_name VARCHAR(20),
dept VARCHAR(20),
score DECIMAL(5,2)
);
INSERT INTO employees (emp_name, dept, score) VALUES
('大翔', '技术部', 100),
('白歌', '技术部', NULL);
当前数据状态:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
| 2 | 白歌 | 技术部 | NULL |
行级锁类型
共享锁(S Lock / Shared Lock)
共享锁允许多个事务同时读取同一行数据,但阻止其他事务获取排他锁。
示例:共享锁的并发效果
| 时间顺序 | 会话 A | 会话 B |
|---|---|---|
| T1 | START TRANSACTION; | |
| T2 | SELECT * FROM employees WHERE emp_id = 1 LOCK IN SHARE MODE; | |
| T3 | START TRANSACTION; | |
| T4 | SELECT * FROM employees WHERE emp_id = 1 LOCK IN SHARE MODE; -- 成功 | |
| T5 | UPDATE employees SET score = 150 WHERE emp_id = 1; -- 阻塞! | |
| T6 | COMMIT; | |
| T7 | UPDATE 继续执行 |
完整 SQL 演示:
-- ========== 会话 A ==========
START TRANSACTION;
-- 对 emp_id = 1 的行加共享锁
SELECT * FROM employees WHERE emp_id = 1 LOCK IN SHARE MODE;
当前数据状态:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
结果解读:
LOCK IN SHARE MODE对id = 1的行加了共享锁(S 锁)- 此时其他事务也可以对该行加 S 锁(兼容)
-- ========== 会话 B ==========
START TRANSACTION;
-- 也可以加共享锁(S 锁与 S 锁兼容)
SELECT * FROM employees WHERE emp_id = 1 LOCK IN SHARE MODE;
会话 B 的查询结果:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
-- 会话 B 尝试更新(需要 X 锁,与 S 锁冲突)
UPDATE employees SET score = 150 WHERE emp_id = 1;
-- 结果:被阻塞,等待会话 A 释放 S 锁
结果解读:
- S 锁和 X 锁不兼容,会话 B 的 UPDATE 被阻塞
- 只有会话 A 提交(COMMIT)后,会话 B 的 UPDATE 才能继续
-- ========== 会话 A ==========
COMMIT;
-- 会话 B 的 UPDATE 现在可以执行了
-- ========== 会话 B ==========
COMMIT;
-- 验证最终数据
SELECT * FROM employees WHERE emp_id = 1;
操作后的数据状态:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 150 |
排他锁(X Lock / Exclusive Lock)
排他锁只允许一个事务持有,阻止其他事务获取任何锁(包括 S 锁和 X 锁)。
示例:排他锁的并发效果
| 时间顺序 | 会话 A | 会话 B |
|---|---|---|
| T1 | START TRANSACTION; | |
| T2 | SELECT * FROM employees WHERE emp_id = 2 FOR UPDATE; | |
| T3 | START TRANSACTION; | |
| T4 | SELECT * FROM employees WHERE emp_id = 2; -- 快照读,不加锁,成功 | |
| T5 | SELECT * FROM employees WHERE emp_id = 2 LOCK IN SHARE MODE; -- 阻塞! | |
| T6 | COMMIT; | |
| T7 | S 锁查询继续执行 |
完整 SQL 演示:
-- ========== 会话 A ==========
START TRANSACTION;
-- 对 emp_id = 2 的行加排他锁
SELECT * FROM employees WHERE emp_id = 2 FOR UPDATE;
当前数据状态:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 2 | 白歌 | 技术部 | NULL |
-- ========== 会话 B ==========
START TRANSACTION;
-- 普通 SELECT(快照读,不加锁,可以执行)
SELECT * FROM employees WHERE emp_id = 2;
会话 B 普通查询结果:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 2 | 白歌 | 技术部 | NULL |
-- 会话 B 尝试加共享锁(S 锁与 X 锁冲突,阻塞)
SELECT * FROM employees WHERE emp_id = 2 LOCK IN SHARE MODE;
-- 结果:被阻塞,等待会话 A 释放 X 锁
结果解读:
- 普通
SELECT(快照读)不加锁,不受 X 锁影响(MVCC 机制) SELECT ... LOCK IN SHARE MODE需要 S 锁,与 X 锁冲突,被阻塞SELECT ... FOR UPDATE需要 X 锁,也与 X 锁冲突
-- 会话 A 提交
COMMIT;
-- 会话 B 的 S 锁查询现在可以执行
-- ========== 会话 B ==========
COMMIT;
锁兼容性矩阵
| S | X | |
|---|---|---|
| S | ✅ 兼容 | ❌ 冲突 |
| X | ❌ 冲突 | ❌ 冲突 |
意向锁(Intention Locks)
意向锁是表级锁,用于表明事务稍后要对表中的某些行加锁:
- IS(Intention Shared):事务意图对某些行加 S 锁
- IX(Intention Exclusive):事务意图对某些行加 X 锁
意向锁之间互相兼容,但与表级 S/X 锁冲突。它的作用是快速判断表级锁是否可用,而无需检查每一行。
示例:意向锁的作用
-- 会话 A 对某行加行级 X 锁
START TRANSACTION;
SELECT * FROM employees WHERE emp_id = 1 FOR UPDATE;
-- 此时表级会加 IX 锁,行级加 X 锁
-- 会话 B 尝试对全表加读锁(表级 S 锁)
LOCK TABLES employees READ;
-- 结果:被阻塞!因为表上已有 IX 锁,与表级 S 锁冲突
结果解读:
- 会话 A 的
FOR UPDATE在表级加了 IX 意向锁 - 会话 B 的
LOCK TABLES ... READ需要表级 S 锁 - IX 与表级 S 锁冲突,所以会话 B 被阻塞
- 意向锁的存在让 MySQL 无需检查每一行,快速判断表级锁是否可用
-- 会话 A 提交
COMMIT;
-- 会话 B 的 LOCK TABLES 现在可以执行
UNLOCK TABLES;
Gap Lock 和 Next-Key Lock
Gap Lock(间隙锁)
间隙锁锁定索引记录之间的"间隙",防止其他事务在间隙中插入数据。
示例:Gap Lock 演示
当前 employees 表主键值:1, 2
间隙包括:(-∞, 1), (1, 2), (2, +∞)
-- ========== 会话 A ==========
START TRANSACTION;
-- 锁定 emp_id > 2 的间隙(查询返回空集,但会加 Gap Lock)
SELECT * FROM employees WHERE emp_id > 2 FOR UPDATE;
查询结果:空集(因为没有 emp_id > 2 的记录)
结果解读:
- 虽然查询返回空集,但
FOR UPDATE会在(2, +∞)的间隙上加 Gap Lock - 这会阻止其他事务插入
emp_id = 3的记录
-- ========== 会话 B ==========
START TRANSACTION;
-- 尝试在间隙中插入数据
INSERT INTO employees (emp_name, dept, score) VALUES ('孔蓝', '技术部', 90);
-- 结果:被阻塞!因为间隙 (2, +∞) 被 Gap Lock 锁定
-- ========== 会话 A ==========
COMMIT;
-- 会话 B 的 INSERT 现在可以执行
-- ========== 会话 B ==========
COMMIT;
-- 验证插入成功
SELECT * FROM employees WHERE emp_name = '孔蓝';
操作后的数据状态:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 3 | 孔蓝 | 技术部 | 90 |
注:演示后执行
DELETE FROM employees WHERE emp_name = '孔蓝';恢复为 2 条数据。
Next-Key Lock(临键锁)
Next-Key Lock 是行锁 + 间隙锁的组合,锁定"索引记录本身 + 记录前面的间隙"。
示例:Next-Key Lock 演示
-- ========== 会话 A ==========
START TRANSACTION;
-- 在 REPEATABLE READ 下,InnoDB 默认使用 Next-Key Lock
SELECT * FROM employees WHERE emp_id = 1 FOR UPDATE;
查询结果:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
结果解读:
- Next-Key Lock 会锁定
id = 1的行本身(行锁),以及前面的间隙(-∞, 1](间隙锁) - 这会阻止其他事务:1) 修改
id = 1的行;2) 在(-∞, 1]间隙中插入数据
-- ========== 会话 B ==========
START TRANSACTION;
-- 尝试修改 emp_id = 1 的行(被行锁阻塞)
UPDATE employees SET score = 150 WHERE emp_id = 1;
-- 结果:被阻塞
-- 尝试插入 emp_id = 0 的记录(被间隙锁阻塞)
INSERT INTO employees (emp_name, dept, score) VALUES ('赵鸣', '技术部', 80);
-- 结果:被阻塞(如果 emp_id=0 还不存在)
-- ========== 会话 A ==========
COMMIT;
-- 会话 B 的操作现在可以执行
-- ========== 会话 B ==========
COMMIT;
结果解读:
- Next-Key Lock 是 InnoDB 在
REPEATABLE READ下防止幻读的核心机制 - 它同时保护已有记录和记录前的间隙
SQL 示例:死锁演示
场景:两个事务相互等待
操作过程
| 时间顺序 | 会话 A | 会话 B |
|---|---|---|
| T1 | START TRANSACTION; | |
| T2 | UPDATE employees SET score = 90 WHERE emp_id = 1; | |
| T3 | START TRANSACTION; | |
| T4 | UPDATE employees SET score = 80 WHERE emp_id = 2; | |
| T5 | UPDATE employees SET score = 90 WHERE emp_id = 2; -- 阻塞 | |
| T6 | UPDATE employees SET score = 80 WHERE emp_id = 1; -- 死锁! | |
| T7 | MySQL 检测到死锁,回滚其中一个 |
完整 SQL 演示:
-- ========== 会话 A ==========
START TRANSACTION;
-- 更新 emp_id = 1,加 X 锁
UPDATE employees SET score = 90 WHERE emp_id = 1;
-- 查看当前状态
SELECT * FROM employees WHERE emp_id IN (1, 2);
会话 A 中的中间状态:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 90 |
| 2 | 白歌 | 技术部 | NULL |
-- ========== 会话 B ==========
START TRANSACTION;
-- 更新 emp_id = 2,加 X 锁
UPDATE employees SET score = 80 WHERE emp_id = 2;
-- 查看当前状态
SELECT * FROM employees WHERE emp_id IN (1, 2);
会话 B 中的中间状态:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
| 2 | 白歌 | 技术部 | 80 |
-- ========== 会话 A ==========
-- 尝试更新 emp_id = 2(会话 B 已持有 X 锁,阻塞)
UPDATE employees SET score = 90 WHERE emp_id = 2;
-- 结果:等待会话 B 释放锁
-- ========== 会话 B ==========
-- 尝试更新 emp_id = 1(会话 A 已持有 X 锁,死锁!)
UPDATE employees SET score = 80 WHERE emp_id = 1;
-- 结果:ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
结果解读:
- 会话 A 等待会话 B 释放
id = 2的锁 - 会话 B 等待会话 A 释放
id = 1的锁 - 形成循环等待,即死锁
- MySQL 的 InnoDB 死锁检测器会自动检测到死锁,并选择代价较小的事务进行回滚
- 被回滚的事务会收到
ERROR 1213,应用层应捕获此错误并重试
-- 被回滚的事务(假设是会话 B)需要重试
-- ========== 会话 B ==========
ROLLBACK;
-- 应用层应重新执行整个事务
START TRANSACTION;
UPDATE employees SET score = 80 WHERE emp_id = 2;
UPDATE employees SET score = 80 WHERE emp_id = 1;
COMMIT;
-- ========== 会话 A ==========
-- 会话 B 被回滚后,会话 A 的 UPDATE 继续执行
COMMIT;
-- 验证最终状态
SELECT * FROM employees WHERE emp_id IN (1, 2);
操作后的数据状态:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 90 |
| 2 | 白歌 | 技术部 | 90 |
死锁排查
示例:查看死锁信息
-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS;
-- 在输出中查找 LATEST DETECTED DEADLOCK 部分
示例输出(节选):
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 100, OS thread handle 123456789, query id 500 localhost 127.0.0.1 root updating
UPDATE employees SET score = 90 WHERE emp_id = 2
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `test`.`employees` trx id 12345 lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 8 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 101, OS thread handle 123456790, query id 501 localhost 127.0.0.1 root updating
UPDATE employees SET score = 80 WHERE emp_id = 1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `test`.`employees` trx id 12346 lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table `test`.`employees` trx id 12346 lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (2)
结果解读:
TRANSACTION 12345:等待id = 2的行锁TRANSACTION 12346:持有id = 2的行锁,等待id = 1的行锁WE ROLL BACK TRANSACTION (2):MySQL 选择回滚事务 2(通常选择修改行数较少的事务)
-- 查看当前锁等待
SELECT * FROM information_schema.innodb_lock_waits;
-- 查看当前锁
SELECT * FROM information_schema.innodb_locks;
-- 查看当前事务
SELECT * FROM information_schema.innodb_trx;
减少死锁的建议
- 按固定顺序访问资源:所有事务都先操作 A 再操作 B
- 缩小事务范围:事务越短,锁持有时间越短
- 使用低隔离级别:READ COMMITTED 比 REPEATABLE READ 锁粒度更小
- 为查询条件列建索引:无索引时行锁可能升级为表锁
示例:按固定顺序避免死锁
-- 会话 A 和会话 B 都按 emp_id 升序更新,避免死锁
-- ========== 会话 A ==========
START TRANSACTION;
UPDATE employees SET score = 90 WHERE emp_id = 1;
UPDATE employees SET score = 90 WHERE emp_id = 2;
COMMIT;
-- ========== 会话 B ==========
START TRANSACTION;
UPDATE employees SET score = 80 WHERE emp_id = 1; -- 等待 A 释放
UPDATE employees SET score = 80 WHERE emp_id = 2; -- 等待 A 释放
COMMIT;
结果解读:
- 两个事务都先更新
id = 1,再更新id = 2 - 会话 B 在更新
id = 1时会等待会话 A 释放锁 - 不会形成循环等待,因此不会死锁
- 虽然会话 B 需要等待,但等待比死锁+回滚+重试更高效
常见误区
| 误区 | 正解 |
|---|---|
| InnoDB 没有表锁 | 有。DDL 操作、无索引更新的行锁升级等情况会使用表锁。 |
SELECT 不加锁 | 在 RR 下,普通 SELECT 是快照读(MVCC)不加锁;但 SELECT ... FOR UPDATE 和 SELECT ... LOCK IN SHARE MODE 会加锁。 |
| 死锁是 Bug | 死锁是正常现象。InnoDB 会自动检测并回滚其中一个事务,应用层只需捕获错误重试。 |
面试考点
Q:InnoDB 的行锁有哪些类型?
共享锁(S Lock)和排他锁(X Lock)。此外,为了支持多粒度锁定,还有意向共享锁(IS)和意向排他锁(IX)。在 RR 隔离级别下,还有 Gap Lock 和 Next-Key Lock。
Q:SELECT ... FOR UPDATE 和 SELECT ... LOCK IN SHARE MODE 的区别?
FOR UPDATE加排他锁(X),其他事务不能读也不能写;LOCK IN SHARE MODE加共享锁(S),其他事务可以读但不能写。
Q:如何避免死锁?
- 按固定顺序访问资源;2. 尽量缩短事务;3. 为查询条件列建索引避免锁升级;4. 应用层捕获死锁异常并重试。
小结
- InnoDB 默认使用行级锁,包括共享锁(S)和排他锁(X)
- 意向锁(IS/IX)用于表级锁的快速判断
- Gap Lock 和 Next-Key Lock 是 RR 级别防止幻读的机制
- 死锁是并发正常现象,MySQL 自动检测并回滚,应用层需重试
下一章引子:锁是悲观并发控制,而 MVCC 则是乐观并发控制的核心——它让读操作几乎不加锁,极大提升了并发性能。