InnoDB 实现了多种锁,本文若未特殊说明,都是在 MySQL 8.0.16,REPEATABLE READ 隔离级别下验证的。只总结了 InnoDB 存储引擎层面实现的锁,其他诸如 MDL 不属于本篇内容范围。

共享与排他锁(Shared and Exclusive Locks)

这是 InnoDB 实现的标准行级锁,包括 shared(S) 锁和 exclusive(X) 锁。
S 锁允许持有该锁的事务读取行,而 X 锁允许事务更新或者删除行。S 锁可以与 S 锁共存,也就是两个并发事务可以同时获取同一行的 S 锁,但同一行中 S 锁与 X 锁、X 锁与 X 锁不能共存。
假设现有一张 user 表:

CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `level` int(11) DEFAULT NULL,
  `karma` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `ix_level` (`level`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
insert into user values (1, 1, 30), (5, 5, 60), (10, 10, 90), (15, 15, 100), (20, 20, 50);

然后再启动两个 Session 并执行语句:
SX Locks

在 T1 时刻 Session 1 启动并执行一条查询一句,并使用 for share(等同于 lock in share mode)给这行加上了 S 锁,T2 时候 Session 2 启动,第一条 update 语句不会被锁住,第二条 update 语句则需要等待 Session 1 的事务提交才能获得 X 锁,这里引出一个结论,即 InnoDB 的锁是加在索引上的,第一条 update 语句之所以不需要等待,是因为 T1 的查询语句只查了主键 id,id 可以直接通过普通索引 level 得到,因此只需要把锁加在普通索引 level 上,而该句直接通过主键 id update,不需要用到普通索引 level,因此不会被阻塞。而第二条 update 语句则需要等待 Session 1 的 S 锁释放后才能继续。

意向锁(Intention Locks)

InnoDB 支持多粒度锁,允许行锁与表锁共存。意向锁是 InnoDB 的一种表级锁,它用于指示事务未来某个时刻需要对表中行加锁。意向锁有意向共享锁(IS)和意向排他锁(IX)。

select ... from share; -- 给表加上 IS 锁
select ... for update; -- 给表加上 IX 锁

意向锁的协议如下:
在一个事务取得表中某行的 S 锁之前,必须先取得表的 IS 锁或更高级别的锁(如 IX);
在一个事务取得表中某行的 X 锁之前,必须先取得表的 IX 锁。

意向锁不会与行级的共享锁和排他锁互斥,但是表级的排他锁会与意向共享锁、排他锁冲突,表级的共享锁也会与意向排他锁冲突但不与意向共享锁冲突,这里直接搬官方手册的表级锁冲突示意表:
IL Compatibility

试想,在某一时刻要给一张表加上表级读锁,必须要检测是否有其他事务持有该表的表级写锁,也要检测是否有其他事务持有该表的行级写锁。如果没有意向锁,对于后者,就必须要去检测表中的每一行是否存在行级写锁,效率十分低下。如果有意向锁,对于全表的锁就不需要排判断表中的每一行数据是否被加锁,只需要检查该表上是否有意向锁即可。意向锁正是 InnoDB 支持多粒度锁的一个实例。

依然使用上面创建的 user 表来举个例子:
IL

T1 时刻 Session 1 启动事务并给表加上了 IS 锁,T2 时刻 Session 2 给表加上了一个表级读锁,由上面的冲突关系,这里并不会阻塞,可以加锁成功。而 T3 时刻 Session 1 又试图给表加上 IX 锁,这里就被阻塞了,直到 T4 时刻 Session 2 解除了表级读锁。

记录锁(Record Locks)

记录锁是作用于索引记录上的,用于阻止其他事务在持有锁期间对其进行插入、更新、删除操作。即使表没有定义索引,记录锁也始终只会作用于索引上,因为在这种情况下 InnoDB 会创建一个隐藏聚簇索引作为表的主键,并使用此索引进行记录锁定。如果查询条件没有用到任何索引,InnoDB 会锁上整张表。

Record Locks

T2 时刻 Session 2 的 update 语句会阻塞,需要等待 Session 1 commit。在阻塞时,执行 select * from information_schema.innodb_trx;,可以看到 Session 2 的事务正处于 LOCK WAIT 状态,被阻塞时正进行 starting index read 操作。同时执行 show engine innodb status;,可在 ------- TRX HAS BEEN WAITING x SEC FOR THIS LOCK TO BE GRANTED: 处看到关于 RECORD LOCKS 的加锁信息。

间隙锁(Gap Locks)

间隙锁锁住的是索引记录之间的间隙,或锁定第一个或最后一个索引记录之前的间隙。间隙锁旨在一定程度上解决幻行问题(RR 隔离级别下),阻止其他事务往间隙锁的加锁范围内插入数据。

InnoDB 上使用间隙锁的唯一目的是防止其他事务在间隙中插入数据,间隙锁之间可以共存,且共享间隙锁和排他间隙锁没有区别,功能都相同,它们只与往存在间隙锁的间隙插入一个记录这个行为冲突。如果将隔离级别改成 RC,就不会有间隙锁。

间隙锁的加锁规则与索引相关,例如如果是唯一索引上的等值查询且查询结果不为空(使用 for update),不会对邻接间隙加间隙锁。具体规则见 Next-Key Locks。

临键锁(Next-Key Locks)

InnoDB 在搜索或扫描表索引时,会在遇到的索引记录上设置共享锁或排他锁,因此,行级锁事实上是索引的记录锁。索引记录上的临键锁也会影响该索引记录之前的间隙,也就是说,临键锁是索引的记录锁加上索引记录之前的间隙上的间隙锁的组合。

还是以 user 表的聚簇索引 id 为例,计算所有可能的间隙锁和记录锁,组合成所有临键锁的区间如下:

(-inf, 1]
(1, 5]
(5, 10]
(10, 15]
(15, supremum)

对于最后一个间隙,实际上临键锁是将间隙锁范围的上限定在 supremum 之前,而 supremum 是 InnoDB 在每个索引上都会加的一个不存在的最大值,因为 supremum 不是真正的索引记录,因此临键锁在这里仅会加在实际最大索引值之后的间隙。

Next-Key Locks 1

如图,当在索引上使用等值查询时,如果是唯一索引加锁,临键锁退化成行锁,而且唯一索引的等值搜索只要查到到就不会继续往下搜索到第一个不满足条件的值为止,因此 T2 时刻 Session 2 的 insert 语句都不会阻塞。而在普通索引上使用等值查询查找时候,会往下继续搜索到第一个不符合的索引,但会退化成间隙锁,因此 T3 时刻 Session 1 的加锁区间是 (1, 5](5, 10),T4 时刻 Session 2 往其中插入索引记录时就会被锁住。如果 T3 时刻 Session 1 的查询语句是例如 select * from user where level >=5 and level < 6 for update; 这样的范围查询,在搜索到第一个不符合的索引时临键锁不会退化成间隙锁,加锁区间是 (1, 5](5, 10]

Next-Key Locks 2

同样的,临键锁的加锁区间受到 InnoDB 搜索或扫描表索引时遇到的索引记录影响。上图中,由于使用了 limit 1,InnoDB 在扫描到一个索引记录后便停止了搜索,而不会因是非唯一索引而继续搜索至第一个不满足条件的值,因此这里 Session 1 的加锁区间只有 (1, 5],而没有 (5, 10),因此 T2 时刻 Session 2 的第一条 insert 语句是可以执行成功的。

插入意向锁(Insert Intention Locks)

插入意向锁是由 insert 语句产生的一种间隙锁(虽然它叫插入意向锁)。该锁用于表示在某一区间插入数据的意向,当多个事务在同一间隙插入位置不同的多条数据时,事务之间不需要互相等待,也就是说在插入行记录不同的情况下,插入意向锁本身不彼此冲突。当 insert 操作执行成功后,会在新插入的行上设置索引上的记录锁,而插入意向锁是在插入之前在索引之间的间隙上设置的。

Insert Intention Locks

如果上图 T1 时刻 Session 1 的 insert 语句产生的不是插入意向锁而是普通的间隙锁,那势必会锁住主键 (5, 10) 这一区间,那么 T2 时刻 Session 2 的第一条 insert 语句就会被阻塞,并发度会降低。

插入意向锁之间不会冲突,但插入意向锁会被间隙锁阻塞,这也是间隙锁存在的意义,可以配合 MVCC 在 RR 隔离级别下有效解决幻行问题。例如在 T1 时刻 Session 1 启动事务执行 select * from user where id=7 for update;,然后 T2 时刻 Session 2 执行 insert into user values(7, 70, 70); 语句就会被阻塞,通过查询 sys.innodb_lock_waitsselect * from sys.innodb_lock_waits;)可以看到 InnoDB 锁等待情况,waiting_lock_modeX,GAP,INSERT_INTENTION,表示被阻塞的线程是因为插入意向锁被阻塞,而 blocking_lock_modeX,GAP,表示占有资源的是间隙排他锁。

自增锁(AUTO-INC Locks)

按照上面根据建立 user 表 DDL,通过输入 show create table user; 可以看到该表当前的 AUTO_INCREMENT 值为 21。自增值在 MySQL 5.7 及之前版本中是保存在内存中,没有持久化,重启 MySQL 可能修改自增值,而 8.0 之后,这个值保存在 redo log 中,重启时可以恢复。

当往有自增列的表中插入数据时,要避免多个事务取道相同的自增值,需要加锁,而这个锁就是自增锁。自增锁不是一个事务锁,而是一个特殊的表级锁,每次取到自增值后就会立即释放(仅简单的 insert 语句)。可以通过配置 innodb_autoinc_lock_mode 选项用于改变自增锁的算法。

Tips

InnoDB 事务使用两阶段锁协议,行锁只有到需要的时候才加上,但要等到事务结束才释放。
InnoDB 锁是作用于索引之上的,在没有走索引的情况下,InnoDB 默认会使用表级锁。
因为 RC 和 RR 隔离级别下 InnoDB 使用 MVCC 来提高并发读的性能,因此在演示加锁时使用显式加锁语句,MVCC 启用情况下,多数的查询语句走快照读因此不会被 InnoDB 的锁阻塞,但会被表上存在的 MDL 写锁阻塞。
MySQL 8.0 以后,除了 sys.innodb_lock_waits,还可以通过 performance_schema.data_locks 查看事务加锁和持有的锁情况。


Refference:

MySQL 8.0 Reference Manual :: InnoDB Locking

MySQL 8.0 Reference Manual :: Phantom Rows

MySQL 8.0 Reference Manual :: The data_locks Table

InnoDB 中的事务隔离级别和锁的关系