MySQL InnoDB 锁机制详解
在并发业务中,多个事务可能同时修改或者读取相同的数据。如果没有并发控制,就可能出现脏写、数据覆盖、幻读等问题。
InnoDB 通过锁机制和 MVCC 等机制共同实现事务的并发控制。
例如两个事务同时修改同一条订单:
UPDATE orders
SET status = 2
WHERE id = 100;
如果事务 A 尚未提交,事务 B 同时执行相同的 UPDATE,那么事务 B 通常不能立即完成,而是需要等待事务 A 释放相关锁。
因此,理解 InnoDB 锁机制,对于分析以下问题非常重要:
为什么 UPDATE 会等待?
为什么 SELECT 有时候也会加锁?
为什么一个 UPDATE 影响了一行,却可能产生多个锁?
为什么会出现 Gap Lock?
为什么会发生死锁?
1. InnoDB 为什么需要锁
假设账户余额为:
balance = 1000
事务 A:
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
同时事务 B:
UPDATE accounts
SET balance = balance - 200
WHERE id = 1;
如果两个事务完全不进行并发控制,就可能发生:
事务 A 读取 1000
事务 B 读取 1000
事务 A 写入 900
事务 B 写入 800
最终结果可能丢失事务 A 的修改。
数据库需要保证对同一数据的并发修改按照一定规则进行。
可以简单理解为:
事务 A
↓
锁定数据
↓
修改数据
↓
COMMIT
↓
释放锁
事务 B
↓
尝试修改相同数据
↓
等待
↓
事务 A 提交
↓
获得锁
↓
继续执行
锁的核心作用就是:
协调多个事务对共享数据的并发访问。
不过 InnoDB 并不是所有查询都依赖锁。普通一致性读主要通过 MVCC 实现,而锁主要用于写操作以及显式锁定读等场景。
2. 共享锁与排他锁
InnoDB 最基础的两类锁是:
S Lock(Shared Lock)
共享锁
X Lock(Exclusive Lock)
排他锁
共享锁 S Lock
共享锁允许多个事务同时持有,但会限制其他事务获取排他锁。
例如:
SELECT *
FROM orders
WHERE id = 100
FOR SHARE;
可以理解为:
事务 A → S Lock
事务 B → S Lock
事务 C → S Lock
多个事务可以同时读取并持有共享锁。
但如果事务 D 希望:
UPDATE orders
SET status = 2
WHERE id = 100;
就需要获得 X Lock,因此需要等待前面的共享锁释放。
排他锁 X Lock
排他锁用于修改数据。
例如:
UPDATE orders
SET status = 2
WHERE id = 100;
通常会对相关记录获取排他锁。
在事务提交之前:
其他事务不能同时对同一记录获取冲突的 X Lock
也不能获取与之冲突的锁。
可以简单记忆:
S + S → 通常兼容
S + X → 冲突
X + X → 冲突
锁兼容关系的具体表现还取决于锁类型以及锁定的对象。
3. Record Lock:记录锁
Record Lock,也就是记录锁,是 InnoDB 锁定索引记录的一种方式。
例如:
UPDATE orders
SET status = 2
WHERE id = 100;
如果 id 是主键,并且条件能够通过主键定位到记录:
id = 100
那么 InnoDB 可以对对应索引记录加记录锁。
可以简单理解成:
orders
id
---
98
99
100 ← Record Lock
101
102
此时其他事务修改:
UPDATE orders
SET status = 3
WHERE id = 100;
就需要等待。
需要特别注意:
InnoDB 的行锁实际上是加在索引记录上的。
这也是为什么索引设计会直接影响锁的范围和并发性能。
如果查询条件没有合适的索引,InnoDB 可能需要扫描更多记录,从而导致更多记录被锁定或检查,具体行为取决于语句、隔离级别和执行计划。
因此:
索引
↓
数据访问范围
↓
锁定范围
↓
并发性能
之间存在直接关系。
4. Gap Lock:间隙锁
Record Lock 锁定的是已有记录,而 Gap Lock 锁定的是索引记录之间的间隙。
例如索引中存在:
10
20
30
40
那么:
(10, 20)
(20, 30)
(30, 40)
这些就是索引记录之间的间隙。
Gap Lock 并不是锁住某一条已经存在的数据,而是限制其他事务在对应索引范围内插入新的记录。
例如某个事务执行范围锁定读:
SELECT *
FROM orders
WHERE id > 20
AND id < 40
FOR UPDATE;
在适用的隔离级别和执行条件下,InnoDB 可能对相关索引范围使用 Gap Lock。
这时另一个事务执行:
INSERT INTO orders (id, ...)
VALUES (30, ...);
如果新记录落在被锁定的索引间隙中,就可能发生锁等待。
因此 Gap Lock 的核心作用可以理解为:
防止其他事务向特定索引范围中插入新的记录。
这与防止幻读有关。
5. Next-Key Lock
Next-Key Lock 是 InnoDB 中非常重要的一种锁。
它可以理解为:
Record Lock + Gap Lock
假设索引记录:
10
20
30
40
如果锁定记录 30,对应的 Next-Key Lock 可以理解为锁住:
(20, 30]
也就是:
20 < id <= 30
其中:
(20, 30)
是 Gap Lock 部分,
30
是 Record Lock 部分。
所以:
Next-Key Lock
=
Gap Lock
+
Record Lock
它的意义在于:
锁住已有记录
+
阻止其他事务在对应范围插入新记录
这也是 InnoDB 防止范围内出现新记录、控制幻读的重要机制之一。
需要注意的是,实际锁定范围会受到:
事务隔离级别
索引结构
查询条件
唯一索引
执行计划
等因素影响,不能把所有 WHERE 条件都简单理解成固定的 Next-Key Lock。
6. 普通 SELECT 与锁定读
这是实际开发中非常容易混淆的一点。
普通:
SELECT *
FROM orders
WHERE id = 100;
通常属于一致性读,不需要为了读取当前最新版本而直接阻塞在其他事务的排他锁上。
InnoDB 可以利用 MVCC 读取符合当前事务可见性规则的数据版本。
而:
SELECT *
FROM orders
WHERE id = 100
FOR UPDATE;
则属于锁定读。
它要求读取当前版本,并对相关记录加排他性质的锁,以便后续修改。
例如典型的库存扣减:
START TRANSACTION;
SELECT stock
FROM products
WHERE id = 100
FOR UPDATE;
UPDATE products
SET stock = stock - 1
WHERE id = 100;
COMMIT;
这里:
SELECT ... FOR UPDATE
的目的不是单纯“查询库存”,而是:
读取当前库存
+
锁定对应记录
+
防止其他事务同时修改
因此需要区分:
普通 SELECT
→ 一致性读
→ 主要依赖 MVCC
SELECT ... FOR UPDATE
→ 锁定读
→ 获取排他性质的锁
SELECT ... FOR SHARE
→ 锁定读
→ 获取共享锁
锁机制和 MVCC 并不是互相替代的两套系统,而是共同实现 InnoDB 的并发控制。
7. 索引为什么会影响锁范围
这是实际排查锁等待时非常重要的一点。
例如:
UPDATE orders
SET status = 2
WHERE user_id = 100;
假设:
user_id
没有索引。
数据库需要扫描大量数据寻找:
user_id = 100
如果建立:
KEY idx_user_id (user_id)
那么访问路径就可以变成:
idx_user_id
↓
找到 user_id = 100
↓
定位相关记录
↓
执行 UPDATE
索引可以显著减少需要扫描的数据范围。
更复杂的情况是范围条件:
SELECT *
FROM orders
WHERE user_id = 100
AND status = 1
FOR UPDATE;
如果索引为:
KEY idx_user_status (user_id, status)
数据库可以利用联合索引缩小访问范围。
如果索引设计不合理,执行计划可能需要扫描更多索引记录,从而增加锁竞争。
因此,数据库并发性能和索引设计并不是两个完全独立的问题:
索引设计
↓
访问路径
↓
扫描范围
↓
锁竞争范围
↓
事务并发能力
这也是为什么很多“数据库锁问题”,最后实际上需要从索引入手解决。
8. 锁等待与锁超时
当事务 A 持有锁:
id = 100
事务 B 尝试修改:
UPDATE orders
SET status = 2
WHERE id = 100;
就可能出现:
事务 A
↓
持有 X Lock
↓
未提交
事务 B
↓
请求 X Lock
↓
等待
如果 A 长时间不提交,B 就会一直等待,直到:
A COMMIT
或者:
A ROLLBACK
释放锁。
如果等待时间超过配置的锁等待超时时间,事务可能收到类似:
Lock wait timeout exceeded
这类问题在实际系统中非常常见。
例如:
DB::transaction(function () {
$order = DB::table('orders')
->where('id', $id)
->lockForUpdate()
->first();
// 复杂业务逻辑
// 调用外部接口
// 大量计算
// ...
DB::table('orders')
->where('id', $id)
->update([
'status' => 2,
]);
});
如果事务内部执行大量耗时操作,就会让锁持续很长时间。
更合理的原则是:
BEGIN
↓
尽快完成必要的数据库操作
↓
COMMIT
不要在持有数据库锁的事务中执行不必要的:
HTTP 请求
RPC 调用
文件操作
复杂计算
长时间等待
9. 实际排查锁问题
遇到数据库请求突然变慢,可以先判断是不是锁等待。
可以查看当前事务:
SELECT *
FROM information_schema.innodb_trx;
查看锁等待关系,可以结合 MySQL 提供的 InnoDB 锁等待相关信息进行分析。
在较新的 MySQL 版本中,也可以重点关注:
performance_schema.data_locks
performance_schema.data_lock_waits
例如:
SELECT *
FROM performance_schema.data_lock_waits;
重点关注:
谁在等待?
谁持有锁?
等待什么锁?
锁定的是哪张表?
哪个索引?
哪个事务?
实际排查过程可以整理成:
发现 SQL 变慢
↓
确认是否锁等待
↓
找到等待事务
↓
找到阻塞事务
↓
确认锁对象
↓
检查 SQL
↓
检查索引
↓
检查事务持续时间
↓
优化事务和 SQL
如果问题进一步发展为:
事务 A 等待事务 B
事务 B 等待事务 A
那么就进入了死锁问题。
死锁并不是简单的“锁等待时间太长”,而是事务之间形成了循环等待关系。
另一篇 :MySQL 死锁:产生原因、分析与解决
10. 总结
InnoDB 的锁机制可以先建立下面这张知识图:
InnoDB Lock
│
┌──────────┴──────────┐
│ │
S Lock X Lock
共享锁 排他锁
│ │
└──────────┬──────────┘
│
锁定索引记录
│
┌──────────┼──────────┐
│ │ │
Record Lock Gap Lock Next-Key Lock
记录锁 间隙锁 临键锁
几个概念需要重点记住:
Record Lock
锁定已有的索引记录
Gap Lock
锁定索引记录之间的间隙
主要用于阻止范围内插入
Next-Key Lock
Record Lock + Gap Lock
普通 SELECT
通常使用 MVCC 一致性读
SELECT … FOR UPDATE
锁定读
获取排他性质的锁
另外还有一个非常重要的结论:
InnoDB 的锁是建立在索引访问路径之上的,索引设计会直接影响锁的范围和并发性能。
因此,分析 MySQL 锁问题时不能只看:
“这条 SQL 加了什么锁?”
还应该同时看:
使用了什么索引?
扫描了什么范围?
事务持续了多久?
其他事务在等待什么?
最终可以把锁问题的分析思路归纳为:
SQL
↓
执行计划
↓
索引访问范围
↓
锁类型与锁范围
↓
事务持续时间
↓
锁等待
↓
并发性能
掌握这些基础之后,再分析 MySQL 死锁会容易很多。