#

作者:Ethan.Yang
博客:https://blog.ethanyang.cn (opens new window)


# InnoDB 锁的分类原理

# 锁的概述

InnoDB 是 MySQL 默认的事务型存储引擎,支持多种类型的锁机制,主要分为 锁的粒度锁的类型(模式与算法) 两个维度来理解。

# 锁的粒度

锁的粒度指的是 锁定数据的范围大小,粒度越小并发性能越高,但开销越大。

锁粒度 描述 应用场景
表锁 锁住整张表 并发控制简单,但并发性差
行锁 锁住单行记录(本质是索引) 并发性好,开销大,InnoDB 默认使用

# 锁的类型

  1. 按锁的模式分

    锁模式 粒度 作用 说明
    共享锁(S) 行级 允许多个事务读取同一数据,但不能修改 通常用于 SELECT ... LOCK IN SHARE MODE
    排他锁(X) 行级 只允许一个事务读取并修改数据 用于 SELECT ... FOR UPDATE、DML 操作
    意向锁(IS / IX) 表级 表示事务打算对某些行加 S/X 锁,用于协调表锁和行锁 InnoDB 自动加锁,无需手动处理
  2. 实现算法(Locking Algorithm)

    锁算法类型 描述
    Record Lock(记录锁) 锁住一条索引记录,防止其他事务修改或删除该记录
    Gap Lock(间隙锁) 锁住两条记录之间的“间隙”,防止插入新数据(避免幻读)
    Next-Key Lock(临键锁) Record Lock + Gap Lock,锁住当前记录及其前后间隙
    Insert Intention Lock(插入意向锁) 一种特殊的 Gap Lock,多个插入操作之间不互相冲突,提高插入并发性能

# 锁的模式

InnoDB 中常见的锁包括共享锁、排它锁和意向锁。

# 共享锁(Shared Lock,S 锁)

共享锁是行级锁,又称为读锁。多个事务可以同时获取同一行的共享锁,但不能有其他事务对其加排它锁。

  • 加锁方式:SELECT ... LOCK IN SHARE MODE
  • 解锁方式:提交事务或回滚事务时释放。

# 排它锁(Exclusive Lock,X 锁)

排它锁也是行级锁,又称为写锁。当某个事务持有排它锁时,其他事务无法再对这行加任何锁(包括读锁和写锁)。

  • 加锁方式:
    • 自动加锁:执行 INSERTDELETEUPDATE 时自动加排它锁。
    • 手动加锁:SELECT ... FOR UPDATE
  • 解锁方式:提交事务或回滚事务。

# 意向锁(Intent Lock)

意向锁是表级锁,用于表示事务打算对表中某些行加什么类型的锁,由 InnoDB 自动加锁。

  • 意向共享锁(IS):表示打算对某些行加共享锁。
  • 意向排它锁(IX):表示打算对某些行加排它锁。

作用: 避免加表锁时扫描整个表判断是否已有行锁,起到加表锁的优化作用。

注意事项

在一个事务中,如果先加共享锁,再尝试对同一行加排它锁,会因锁升级失败而造成等待。例如:

-- 事务 1
BEGIN;
SELECT * FROM user WHERE id = 1 LOCK IN SHARE MODE;
UPDATE user SET name = 'Tom' WHERE id = 1; -- 等待自身读锁释放,陷入阻塞或死锁
1
2
3
4

# 行锁的原理(基于索引实现)

InnoDB 的行锁是基于 索引 来实现的,而不是直接锁住某一行的物理记录。下面通过几个例子说明这一点:

准备三张表:

  • t1:没有任何索引
  • t2:主键为 id
  • t3:唯一索引为 name
  1. 没有索引(t1)

    事务1 事务2
    begin;
    SELECT * FROM t1 WHERE id =1 FOR UPDATE;
    select * from t1 where id=3 for update; -- 被阻塞
    INSERT INTO t1 (`id, 'name') VALUES (5, '5'); -- 被阻塞

    步骤说明:

    1. 事务 1 通过 WHERE id = 1 加排它锁。
    2. 事务 2 试图对另一行(id = 3)加锁,甚至插入新行(id = 5),都被阻塞

    原因分析:

    虽然看起来只锁了 id=1 的行,但由于 没有索引,InnoDB 只能扫描整张表并对所有记录加锁,即“锁表”。

    ✅ 结论:在未命中索引的情况下,行锁会退化为表锁。

  2. 有主键索引 id(t2)

    事务1 事务2
    begin;
    select * from t2 where id=1 for update;
    select * from t2 where id=1 for update; -- 被阻塞
    select * from t2 where id=4 for update; // OK

    步骤说明:

    1. 事务 1 对主键为 id=1 的记录加锁。
    2. 事务 2 尝试对相同主键加锁 → 阻塞;
    3. 事务 2 对不同主键加锁 → 成功。

    原因分析:

    主键查询是基于聚簇索引的,能直接定位到具体记录,加的是精确的行锁

    ✅ 结论:主键索引命中后,加的是精确行锁,互不干扰。

  3. 唯一索引为 name(t3)

    事务1 事务2
    begin;
    select * from t3 where name= '4' for update;
    select * from t3 where name = '4' for update; -- 被阻塞
    select * from t3 where id = 4 for update; -- 被阻塞

    步骤说明:

    1. 事务 1 通过唯一索引 name = '4' 加锁;
    2. 事务 2 尝试对相同 name 加锁 → 阻塞;
    3. 事务 2 尝试通过 id = 4(主键)加锁 → 也被阻塞。

    原因分析:

    查询 name='4' 命中的是二级索引。InnoDB 为了读取完整记录,需要回表到聚簇索引查主键,再取出整行数据。这一过程会导致:

    • 对二级索引 name = '4' 加锁;
    • 同时对回表时访问到的主键 id = 4 也加锁。

    ✅ 结论:访问二级索引时,会同时锁住对应的主键记录,可能导致额外的锁冲突。

    两个核心问题解析:

    1. 为什么没有索引会锁全表? 因为不能通过索引快速定位记录,InnoDB 被迫对隐藏的聚簇索引进行全表扫描和加锁(扫描叶子节点链表),实际上是对所有记录都上锁。
    2. 为什么二级索引加锁会连主键索引也被锁住? 因为二级索引的叶子节点只存储主键值,InnoDB 为了获取完整行,需要通过主键“回表”,因此对主键也加锁,这就是造成主键也被阻塞的根本原因。

# 锁的算法

InnoDB 为了实现高并发下的数据一致性,设计了多种锁的算法,主要包括:记录锁(Record Lock)间隙锁(Gap Lock)临键锁(Next-Key Lock)。这些锁机制在不同的 SQL 场景下自动触发,极大地影响了并发性能与事务安全。

# 什么是记录锁(Record Lock)?

定义: 记录锁是加在索引记录上的锁,只锁住某一行数据。这是最常见的一种行级锁。

生效场景

  • 精确等值匹配主键或唯一索引,并命中索引。

示例:

数据行:
| id = 8 | id = 9 | id = 10 | id = 11 | id = 12 |

SQLSELECT * FROM user WHERE id = 10 FOR UPDATE;

-- 加锁:[🔒10]
-- 说明:只锁住 id=10 这一行,其他记录可并发操作。
1
2
3
4
5
6
7
8

# 什么是间隙锁(Gap Lock)?

定义: 间隙锁是加在索引之间的“空隙”上的锁,不锁具体记录,只锁住“某段范围”,防止其他事务插入数据。

生效场景

  • 范围查询 + 加锁语句(FOR UPDATE / LOCK IN SHARE MODE)。

  • 没有命中记录时,为了防止“幻读”,锁住“空隙”。

示例:

数据行:
| id = 8 | id = 9 | id = 11 | id = 12 |

SQLSELECT * FROM user WHERE id > 9 AND id < 11 FOR UPDATE;

-- 加锁:(🔒9, 11)
-- 说明:虽然 id=10 不存在,但锁住 9~11 之间的“空隙”,禁止插入 id=10。
1
2
3
4
5
6
7
8

# 什么是临键锁(Next-Key Lock)?

定义: 临键锁是记录锁 + 间隙锁的组合,锁住某行记录的同时,也锁住它前面的间隙。

生效场景

  • 可重复读(RR)隔离级别下的大多数加锁查询。
  • 范围查询中锁住已存在的记录(防止读到新插入的数据,解决幻读问题)。

示例:

数据行:
| id = 8 | id = 9 | id = 10 | id = 11 | id = 12 |

SQLSELECT * FROM user WHERE id >= 10 AND id < 12 FOR UPDATE;

-- 加锁:(9,10] (10,11] (11,12)
          🔒     🔒     🔒
-- 说明:范围查询命中, 锁住前间隙锁(9, 10], 当前行(10, 11], 以及next(11,12)
-- 每个临键锁锁住前间隙 + 当前行,如 (9,10] 锁住 id=10 和它前的间隙。主要是防止前面有数据插入导致幻读
1
2
3
4
5
6
7
8
9
10

# 隔离级别的实现(InnoDB)

# Read Uncommitted(读未提交)

  • 普通 SELECT:不使用 MVCC,读到未提交的数据(脏读),不加任何锁
  • 风险:容易读到其他事务尚未提交的数据,可能导致业务错误。

# Read Committed(读已提交)

  • 普通 SELECT:使用 MVCC(快照读),单个事务中每次查询都会生成ReadView, 每次读都是最新已提交版本。
  • 加锁 SELECT(如 SELECT ... FOR UPDATE):
    • 使用 记录锁(Record Lock)
    • 不加 Gap Lock,也不加临键锁
  • INSERT/UPDATE/DELETE:会使用记录锁(锁定具体行)

优点:避免脏读 ⚠️ 问题:可能出现不可重复读幻读

# Repeatable Read(可重复读)——InnoDB 默认级别

  • 普通 SELECT:使用 MVCC(快照读), 单个事务中只有第一次查询生成ReadView, 后面查询使用相同的ReadView。
    • 多次读取结果一致,除非事务主动提交
  • 加锁 SELECT
    • SELECT ... FOR UPDATESELECT ... IN SHARE MODE
    • 使用 当前读(Current Read)
    • 会触发 记录锁 + 间隙锁 + 临键锁(Next-Key Lock),防止幻读
  • UPDATE / DELETE
    • 同样属于当前读,也使用上述锁机制

优点:可重复读,避免幻读 ⚠️ 缺点:并发性比 RC 略低

# Serializable(串行化)

  • 所有的 SELECT 自动变为:

    SELECT ... LOCK IN SHARE MODE
    
    1
  • 无论是否加锁,都会进行加锁读取(共享锁或排他锁),使并发事务串行执行。

  • 会触发大量锁等待或死锁,性能最差,但最安全。

优点:完全避免并发问题,严格一致性 ⚠️ 缺点:性能极差,基本不建议在高并发下使用