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 死锁会容易很多。