锁机制
开篇:数据库的锁就像十字路口的红绿灯
十字路口如果没有红绿灯会怎样?车从四面八方涌来,谁也不让谁,结果就是全堵死——谁都走不了。
数据库面临同样的问题:多个事务同时读写同一份数据,如果不加以协调,就会出现脏写、脏读、数据覆盖等一系列混乱。锁就是数据库里的"红绿灯",用来协调并发访问,保证数据的一致性。
但红绿灯也不是越多越好——每个路口都装红绿灯,交通是安全了,但通行效率也下去了。锁的设计也是一样:锁的范围越大越安全,但并发度越低。MySQL 的锁机制,本质上就是在安全性和并发度之间找平衡。
一、锁的分类全景图
MySQL 中的锁可以从多个维度分类,下面这张图展示了核心分类:
不同维度看到的是同一把锁的不同面。比如一个 SELECT ... FOR UPDATE 语句,从模式看是排他锁(X 锁),从粒度看是行级锁,从算法看可能是 Next-Key Lock,从思想看属于悲观锁。
二、表级锁
表级锁的锁定粒度最大——锁住整张表。开销小、加锁快,不会出现死锁,但并发度最低。
2.1 表级 S 锁 / X 锁
InnoDB 在执行普通的 SELECT、INSERT、UPDATE、DELETE 时,不会加表级 S 锁或 X 锁——它更喜欢行锁。
表级锁一般只在两种情况下出现:
- DDL 操作:
ALTER TABLE、DROP TABLE等会加表级锁(通过 MDL 机制),阻止其他 DML 并发 - 手动锁表:
LOCK TABLES t READ/WRITE(不推荐在 InnoDB 中使用)
-- 手动加表级读锁(其他事务可读不可写)
LOCK TABLES users READ;
-- 手动加表级写锁(其他事务既不可读也不可写)
LOCK TABLES users WRITE;
-- 解锁
UNLOCK TABLES;注意
在 InnoDB 中应尽量避免 LOCK TABLES,它会降低并发能力。InnoDB 的优势就在于更细粒度的行锁。
2.2 元数据锁(MDL 锁)
MDL 锁的作用是 防止 DML 和 DDL 操作冲突。
想象一个场景:你正在用 SELECT 遍历一张表的数据,另一个线程突然 ALTER TABLE 删了一列——你拿到的查询结果就和表结构对不上了。
MDL 锁就是为了避免这类问题:
- 执行 DML(SELECT/INSERT/UPDATE/DELETE)时,自动加 MDL 读锁
- 执行 DDL(ALTER/DROP/CREATE INDEX)时,自动加 MDL 写锁
- 读锁之间不互斥(多个 DML 可以并发),读写锁互斥(DDL 期间 DML 阻塞)
MDL 锁是自动加的,不需要也不能手动操作。
2.3 意向锁(Intention Lock)
意向锁是为了 协调行锁和表锁的共存 而设计的。
场景:事务 A 对表中某一行加了行级排他锁。现在事务 B 想对整张表加表级锁——它需要先检查有没有行级锁存在。如果逐行扫描,效率太低。
InnoDB 的解决方案:当事务要加行锁时,先自动在表级别加一个意向锁,表明"有人在某些行上持有锁"。事务 B 看到表上有意向排他锁,就知道不能直接加表级共享锁。
- 意向共享锁(IS):事务打算给某些行加 S 锁
- 意向排他锁(IX):事务打算给某些行加 X 锁
意向锁之间互不冲突,它们只和表级 S/X 锁冲突:
| 表级 S 锁 | 表级 X 锁 | IS 锁 | IX 锁 | |
|---|---|---|---|---|
| 表级 S 锁 | 兼容 | 冲突 | 兼容 | 冲突 |
| 表级 X 锁 | 冲突 | 冲突 | 冲突 | 冲突 |
| IS 锁 | 兼容 | 冲突 | 兼容 | 兼容 |
| IX 锁 | 冲突 | 冲突 | 兼容 | 兼容 |
意向锁由 InnoDB 自动管理,用户无需手动操作。
2.4 自增锁(AUTO-INC Lock)
当向含有 AUTO_INCREMENT 列的表中插入数据时,InnoDB 会获取一种特殊的表级锁来保证自增值的连续性。
一个事务持有自增锁期间,其他事务的 INSERT 必须等待。可以通过 innodb_autoinc_lock_mode(取值 0、1、2)控制锁的行为,在连续性和并发度之间取舍。
三、行级锁
行级锁是 InnoDB 的核心优势——锁定粒度最小,并发度最高,但加锁开销也最大。
InnoDB 的行级锁锁的是索引记录,不是数据行本身。 这一点非常重要:如果 UPDATE 的 WHERE 条件没有命中索引,InnoDB 会扫描全表并对扫描到的每一行加锁(虽然不是"锁表",但效果接近)。
3.1 Record Lock:锁住一行
记录锁锁定的是 索引上的某一条记录。
-- 对 id=10 的记录加排他锁
SELECT * FROM users WHERE id = 10 FOR UPDATE;其他事务无法修改或删除 id=10 的行,但不影响其他行。
Record Lock 分为 S 型和 X 型:
- S 型记录锁允许其他事务再加 S 锁,但不允许加 X 锁
- X 型记录锁不允许其他事务加任何锁
3.2 Gap Lock:锁住间隙
间隙锁锁定的不是记录本身,而是 两条记录之间的"空隙",目的是防止其他事务在间隙中插入新数据。
假设表中有 id 为 5、10、15 的记录,间隙锁可能锁住 (5, 10) 这个开区间——其他事务无法插入 id=6、7、8、9 的记录。
间隙锁有几个特点:
- 只在 RR 隔离级别下生效,RC 下不使用间隙锁
- 共享 Gap 锁和排他 Gap 锁的作用完全相同——都只是阻止插入
- 两个事务可以同时对同一个间隙加 Gap 锁,它们之间不冲突
间隙锁的唯一目的就是防止幻读。
3.3 Next-Key Lock:Record + Gap
Next-Key Lock 是记录锁和间隙锁的组合体,锁定范围是 左开右闭区间。
假设索引中有值 10、11、13、20,可能的 Next-Key Lock 区间为:
(-∞, 10]
(10, 11]
(11, 13]
(13, 20]
(20, +∞)Next-Key Lock 是 InnoDB 在 RR 级别下 加锁的基本单位。
加锁规则(来自丁奇《MySQL 实战 45 讲》的总结):
| 规则 | 内容 |
|---|---|
| 原则 1 | 加锁的基本单位是 Next-Key Lock(前开后闭) |
| 原则 2 | 查找过程中访问到的对象才会加锁 |
| 优化 1 | 唯一索引上的等值查询,Next-Key Lock 退化为 Record Lock |
| 优化 2 | 等值查询向右遍历,最后一个不满足条件时,Next-Key Lock 退化为 Gap Lock |
| Bug | 唯一索引上的范围查询会访问到不满足条件的第一个值为止 |
举个例子:表中有 id = 5, 10, 15, 20 四条记录。
-- 查询 id=7(不存在)
UPDATE t SET d=d+1 WHERE id = 7;
-- 按原则1:加锁单位是 (5, 10]
-- 按优化2:等值查询 id=7,id=10 不满足条件,退化为 Gap Lock (5, 10)-- 范围查询
SELECT * FROM t WHERE id >= 10 AND id < 11 FOR UPDATE;
-- id=10 加行锁(优化1,唯一索引等值匹配退化为 Record Lock)
-- 继续向右找到 id=15 停下,加 Next-Key Lock (10, 15]
-- 最终锁定:行锁 id=10 + Next-Key Lock (10, 15]3.4 插入意向锁(Insert Intention Lock)
当一个事务想在被 Gap Lock 锁定的间隙中插入数据时,它不会直接等待,而是先生成一个 插入意向锁。
插入意向锁是一种特殊的 Gap 锁(不是意向锁),它表明"我想在这个间隙里插入一行"。多个事务在同一间隙中插入不同位置的数据时互不阻塞——只有插入的位置完全相同才需要等待。
四、死锁
4.1 死锁产生的条件
死锁就是两个或多个事务互相等待对方释放锁,谁也走不了。
经典场景:
事务 A:锁住了记录 X,想要锁记录 Y
事务 B:锁住了记录 Y,想要锁记录 X
→ 互相等待,永远不会结束即使是操作同一条记录也可能死锁。因为 InnoDB 的锁锁的是索引,一条 UPDATE 语句可能先锁普通索引再回表锁主键索引,而另一个事务的操作顺序刚好相反:
-- 事务 A:先锁 name 索引,再回表锁主键
UPDATE users SET name='A', age=20 WHERE name='张三';
-- 事务 B:先锁主键,再锁 name 索引
SELECT * FROM users WHERE id=1 FOR UPDATE;
UPDATE users SET age=30 WHERE name LIKE '张%';4.2 如何排查死锁
最直接的方式是使用 SHOW ENGINE INNODB STATUS,其中 LATEST DETECTED DEADLOCK 部分会显示最近一次死锁的详细信息:
SHOW ENGINE INNODB STATUS\G输出中会包含:
- 参与死锁的事务和它们持有/等待的锁
- InnoDB 选择回滚了哪个事务(通常回滚代价较小的那个)
4.3 如何避免死锁
| 方法 | 说明 |
|---|---|
| 固定访问顺序 | 所有事务按相同顺序访问表和行,避免交叉等待 |
| 缩短事务时长 | 事务越短,持锁时间越短,死锁概率越低 |
| 减少锁的范围 | 使用 RC 代替 RR,避免间隙锁带来的额外锁冲突 |
| 减少操作的数据量 | 避免一个事务中锁定大量行 |
| 合理使用索引 | 确保 UPDATE/DELETE 的 WHERE 条件走索引,避免全表扫描加锁 |
InnoDB 有两种自动处理死锁的机制:
- 死锁检测:
innodb_deadlock_detect = ON(默认开启),InnoDB 实时检测死锁并回滚代价较小的事务 - 锁等待超时:
innodb_lock_wait_timeout(默认 50 秒),等待超时则回滚
五、乐观锁 vs 悲观锁
乐观锁和悲观锁不是数据库提供的具体锁类型,而是两种并发控制的 设计思想。
5.1 悲观锁:先锁再操作
悲观锁假设"冲突一定会发生",所以操作前先加锁。
BEGIN;
-- 加排他锁,锁住 id=1 的行
SELECT quantity FROM items WHERE id = 1 FOR UPDATE;
-- 确认库存足够后扣减
UPDATE items SET quantity = quantity - 1 WHERE id = 1;
COMMIT;SELECT ... FOR UPDATE 会对查询结果加排他锁,其他事务的写操作必须等当前事务提交后才能执行。
优点:安全,不会出现更新丢失。缺点:并发度低,可能造成死锁。
5.2 乐观锁:先操作再检查
乐观锁假设"冲突很少发生",操作时不加锁,提交时检查是否有冲突。
常见实现方式是 版本号机制 或 CAS:
-- 先查出当前版本
SELECT quantity, version FROM items WHERE id = 1;
-- 假设读到 quantity=10, version=3
-- 更新时带上版本号作为条件
UPDATE items SET quantity = 9, version = 4
WHERE id = 1 AND version = 3;
-- 如果 affected rows = 0,说明被别人改过了,需要重试注意:乐观锁虽然没有显式加锁,但 UPDATE 语句执行时 InnoDB 内部仍然会对行加短暂的排他锁。乐观锁的优势在于 锁的持有时间极短(只在 UPDATE 那一瞬间),而悲观锁从 SELECT FOR UPDATE 到 COMMIT 全程持锁。
5.3 如何选择
| 悲观锁 | 乐观锁 | |
|---|---|---|
| 适用场景 | 写多读少,冲突概率高 | 读多写少,冲突概率低 |
| 实现方式 | 数据库锁(FOR UPDATE) | 应用层版本号/CAS |
| 优点 | 安全可靠 | 并发度高,无死锁 |
| 缺点 | 并发度低,可能死锁 | 冲突多时重试开销大 |
六、常见面试题精选
Q1:InnoDB 的行级锁锁的到底是什么?
锁的是 索引记录,不是数据行。如果 UPDATE 的 WHERE 条件没有走索引,InnoDB 会对全表扫描到的每一行加锁。没有显式定义主键时,InnoDB 会使用隐藏的 db_row_id 作为聚簇索引来加锁。
Q2:UPDATE 没走索引会锁表吗?
严格来说不是"锁表"——InnoDB 仍然加的是行级锁,但因为全表扫描,每一行都被锁住了,效果上等同于锁表。所以一定要确保 UPDATE/DELETE 的 WHERE 条件走索引。
Q3:InnoDB 加索引会锁表吗?
MySQL 5.6 起支持 Online DDL,创建普通索引时一般不会阻塞 DML。但在操作开始和结束时仍有短暂的 MDL 写锁,大表操作建议在低峰期执行。
Q4:操作同一条记录也会死锁吗?
会。因为一条 UPDATE 可能先锁普通索引再回表锁主键索引,而另一个事务的操作顺序相反,就形成了交叉等待。解决方法:保持所有事务的索引访问顺序一致。
Q5:什么是锁升级?InnoDB 支持吗?
锁升级是将大量行锁合并为表锁的过程。InnoDB 不支持锁升级,它始终使用行级锁,即使锁定了大量行也不会自动升级为表锁。
小结
| 概念 | 一句话总结 |
|---|---|
| 行锁 vs 表锁 | 行锁并发高但开销大,表锁开销小但并发低。InnoDB 优先使用行锁 |
| Record/Gap/Next-Key | 分别锁记录、锁间隙、锁记录+间隙。Next-Key Lock 是 RR 下的加锁基本单位 |
| 死锁 | 事务交叉等待对方释放锁。通过固定访问顺序、缩短事务、减少锁范围来预防 |
| 乐观锁 vs 悲观锁 | 本质是安全性和并发度的取舍。读多写少用乐观锁,写多读少用悲观锁 |
附录:并发事务的三种场景
在理解锁之前,先搞清楚并发事务会面临哪些场景:
读-读并发
多个事务同时读取同一数据。读操作不会修改数据,所以完全不需要加锁,怎么读都没问题。
写-写并发
多个事务同时修改同一数据。如果不加控制,就会出现 脏写——一个事务的修改被另一个事务覆盖掉。这是最严重的并发问题,任何隔离级别都不允许发生。
解决方案:加锁排队。一个事务在修改数据前先获取锁,其他事务必须等它提交或回滚后释放锁才能继续。
读-写并发
一个事务在读,另一个事务在写。这种情况可能导致脏读、不可重复读和幻读。
有两种解决方案:
- 方案一:读操作走 MVCC(快照读),写操作走加锁 → 读写不阻塞,性能好
- 方案二:读操作和写操作都加锁 → 读写需要排队,性能差
InnoDB 默认采用方案一,这也是它高并发能力的核心。
附录:共享锁与排他锁详解
不论是表级锁还是行级锁,从模式上都分为 共享锁(S 锁) 和 排他锁(X 锁)。
共享锁(S 锁 / 读锁)
允许多个事务同时读取同一数据,但不允许修改。
-- 显式加共享锁
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;
-- MySQL 8.0 新语法
SELECT * FROM users WHERE id = 1 FOR SHARE;当一行数据被加了 S 锁:
- 其他事务 可以 再加 S 锁(多个读并发)
- 其他事务 不能 加 X 锁(禁止写)
排他锁(X 锁 / 写锁)
当前事务独占数据,既能读又能写,其他事务什么锁都加不了。
-- 显式加排他锁
SELECT * FROM users WHERE id = 1 FOR UPDATE;当一行数据被加了 X 锁:
- 其他事务 不能 加 S 锁
- 其他事务 不能 加 X 锁
InnoDB 在执行 INSERT、UPDATE、DELETE 时会自动对相关行加 X 锁。
S 锁与 X 锁的兼容矩阵
| S 锁 | X 锁 | |
|---|---|---|
| S 锁 | 兼容 | 冲突 |
| X 锁 | 冲突 | 冲突 |
附录:Next-Key Lock 加锁实战
理论讲完了,我们用一个完整的例子走一遍 Next-Key Lock 的加锁过程。
假设表结构和数据如下:
CREATE TABLE t (
id INT PRIMARY KEY,
c INT,
d INT,
KEY idx_c (c)
);
INSERT INTO t VALUES (5, 5, 5), (10, 10, 10), (15, 15, 15), (20, 20, 20);案例一:唯一索引上的等值查询(记录存在)
SELECT * FROM t WHERE id = 10 FOR UPDATE;- 按原则 1:加锁基本单位是 Next-Key Lock (5, 10]
- 按优化 1:唯一索引等值查询命中记录,退化为 Record Lock,只锁 id=10 这一行
- 最终:行锁 id=10
案例二:唯一索引上的等值查询(记录不存在)
SELECT * FROM t WHERE id = 7 FOR UPDATE;- 按原则 1:加锁基本单位是 Next-Key Lock (5, 10]
- 按优化 2:等值查询 id=7,id=10 不满足等值条件,退化为 Gap Lock
- 最终:Gap Lock (5, 10)
案例三:普通索引上的等值查询
SELECT id FROM t WHERE c = 10 LOCK IN SHARE MODE;- 按原则 1:在索引 c 上加 Next-Key Lock (5, 10]
- c 是普通索引,需要向右遍历到 c=15 才停下
- 按原则 2:访问到 (10, 15] 也要加锁
- 按优化 2:c=15 不满足等值条件 c=10,(10, 15] 退化为 Gap Lock (10, 15)
- 最终:索引 c 上的 Next-Key Lock (5, 10] + Gap Lock (10, 15)
- 因为走了覆盖索引,主键索引上不加锁
案例四:普通索引上的范围查询
SELECT * FROM t WHERE c >= 10 AND c < 11 FOR UPDATE;- 在索引 c 上加 Next-Key Lock (5, 10]
- 范围查询继续向右,找到 c=15 停下,加 Next-Key Lock (10, 15]
- c 是非唯一索引,不退化
- 最终:索引 c 上的 (5, 10] + (10, 15]
这些加锁规则看起来复杂,但核心逻辑就一个:InnoDB 在扫描过程中,把访问到的所有索引记录及其间隙都锁住,然后通过优化规则适当缩小锁的范围。
附录:Online DDL 与锁
MySQL 5.6 起引入了 Online DDL,允许在不阻塞 DML 的情况下执行大部分 DDL 操作。
在 Online DDL 之前,创建索引会锁表,期间所有读写都被阻塞。现在大部分索引操作使用 INPLACE 模式,只在开始和结束时短暂持有 MDL 写锁。
不过要注意几点:
- 资源竞争:Online DDL 会占用 CPU、内存和 I/O,大表操作可能影响正常业务
- 并非完全无锁:某些复杂操作(如修改列类型)仍可能短暂锁表
- 主从延迟:DDL 需要同步到从库,高峰期可能加重主从延迟
最佳实践:DDL 变更安排在非高峰期执行,并提前做好测试和回滚方案。