MySQL 的锁机制

并发事务访问相同记录的情况大致可以分为三种:读读、读写和写写。读读的情况并不会产生并发问题,真正会产生问题的是并发的读写或者写写。并发的写写可能会产生脏写和丢失更新的问题,由于这类问题比较严重,数据库会通过加锁的方式阻塞并发的修改,因此在任何隔离级别下数据库都不会出现脏写和丢失更新的情况,但是在应用层面,还是有可能出现逻辑意义上的丢失更新。

并发的读写可能会产生脏读、不可重复读以及幻读的问题。处理这类问题有两种可选的方案,一种是对写操作加锁,读操作则利用 MVCC 机制。在 READ COMMITTED 隔离级别中,一个事务中每次执行普通的 SELECT 操作都会生成一个 ReadView,这样就保证了每次的读操作都只能读到已经提交的事务的更改。而在 REPEATABLE READ 隔离级别中,一个事务中只有第一次执行普通的 SELECT 操作才会生成一个 ReadView,之后的读操作都会复用该 ReadView,因此也就避免了不可重复读和幻读的问题。

另一种就是对读操作和写操作都加锁,如果一些业务场景中不允许读取记录的旧版本,而是每次都需要读取记录的最新版本,那么就需要在读的时候加锁,同时写操作也需要加锁,这也就意味着读写操作也像写写操作那样排队执行。很明显,因为使用 MVCC 可以使读写操作并发执行,因此性能更好。在一般情况下,我们都采用默认的方式,也就是读操作利用 MVCC,写操作加锁。当一些业务需要的时候,再手动给读操作加锁。

MySQL 中的数据操作

在 MySQL 中,读操作分为两种。一种是快照读(Snapshot Read),也叫做一致性读(Consistent Read)或者一致性无锁读。快照读通过 MySQL 的 MVCC 机制实现,不需要加锁,读取的是当前记录的快照。所有普通的 SELECT(plain SELECT)语句在读提交和可重复读隔离级别下都是快照读。

另一种就是锁定读(Locking Read),也叫做当前读(Current Read)。当前读可以看作是一种特殊的读操作,它读取的是最新的记录,包括隐含当前读逻辑的插入、更新和删除,以及一些手工添加锁的读都是当前读。使用 SELECT ... LOCK IN SHARE MODE 语句会为读取到的数据加入 S 锁,这样别的事务可以继续获取这些记录的 S 锁,但是不能获取这些记录的 X 锁。使用 SELECT ... FOR UPDATE 语句会为读取到的数据加入 X 锁,这样别的事务就无法再获取这些记录的 S 锁和 X 锁。

在 MySQL 中,写操作无非就是 DELETE、UPDATE 和 INSERT 这三种。对一条记录进行 DELETE 操作的过程实际上是先在 B+树中定位到这条记录的位置,然后获取该记录的 X 锁,最后再执行设置删除标记位的操作。因此我们可以把删除操作看作是一个获取 X 锁的锁定读。

对一条记录进行 UPDATE 操作时可以分为三种情况。第一种就是没有修改该记录的键值,并且更新后的列占用的存储空间没有发生变化,那么只需要在 B+树中定位该记录,然后获取该记录的 X 锁,最后完成修改操作即可。因此这个过程可以看作是一个获取 X 锁的锁定读。第二种就是没有修改该记录的键值,但是更新后至少有一个列占用的存储空间发生了变化,那么就需要在 B+树中定位该记录,然后获取该记录的 X 锁,接下来将该记录删除掉,最后再插入一条新的记录。这个定位记录的过程可以看作是一个获取 X 锁的锁定读,而接下来新插入的记录由 INSERT 操作提供的隐式锁来保护。第三种就是修改了该记录的键值,此时相当于在原记录上做了 DELETE 操作后又进行了一次 INSERT 操作。

对于 INSERT 操作来说,新插入一条记录的操作会通过隐式锁来保护这条插入的记录在本事务提交之前不会被其他事务访问。

MySQL 中的锁分类

从锁的属性来分,锁分为两种:共享锁(S 锁)和独占锁(X 锁)。一个事务获取到了一些记录的 S 锁,其他事务还可以继续获取这些记录的 S 锁,但是不可以获取这些记录的 X 锁。而当一个事务获取到了一些记录的 X 锁,那么其他事务就不能再获取这些记录的 S 锁和 X 锁。也就是 S 锁和 X 锁互斥,同时 X 锁与 X 锁也互斥,只有 S 锁与 S 锁能够共存。

从锁的粒度来分,锁可以分为行锁和表锁。如果给一个表加了 S 锁,那么其他事务可以继续获取该表或者该表中某些记录的 S 锁,但是不能获取该表或者该表中某些记录的 X 锁。而如果给一个表加了 X 锁,那么其他事务既不能给该表或者该表中的某些记录加 S 锁,也不能给该表或者该表中的某些记录加 X 锁。

有的时候我们需要在给表上锁之前检查表中有没有行锁,此时不可能依次遍历每一条记录,因此 InnoDB 提供了一种称为意向锁的表锁。意向锁可以分为意向共享锁(Intention Shared Lock)和意向独占锁(Intention Exclusive Lock)。意向共享锁也叫 IS 锁,当事务准备给某条记录加 S 锁时,需要先给表加一个 IS 锁。而意向独占锁也叫 IX 锁,当事务准备给某条记录加 X 锁时,需要先给表加一个 IX 锁。有了这两种锁,如果我们需要给表加 S 锁,那么就需要检查该表有没有 IX 锁,如果没有则可以给表加 S 锁,否则需要等 IX 锁释放。同样,如果我们需要给表加 X 锁,需要先看该表有没有 IS 锁和 IX 锁,如果没有则可以直接给表加 X 锁,否则需要等待锁释放。

虽然 IS 和 IX 是表锁,但是它们仅仅是为了在给表加锁时可以快速判断表中的记录是否上锁,因此 IS 与 IX 是可以共存的。

InnoDB 中的表锁

InnoDB 中的表锁除了之前所说的 S 锁和 X 锁,IS 锁和 IX 锁,还有 AUTO-INC 锁,该锁只有在某个列添加了 AUTO_INCREMENT 属性,之后插入记录时才会使用。

在对某个表执行增删改查操作时,InnoDB 存储引擎是不会为该表添加 S 锁或者 X 锁的。只有在一些特殊场景下才会使用,比如崩溃恢复。当然我们也可以手工获取,比如先设置 autocommit=0, innodb_table_locks=1,然后通过 LOCK TABLES t READLOCK TABLES t WRITE 来分别获取表 t 的 S 锁和 X 锁。另外对于一些 DDL 操作,比如 ALTER TABLE、DROP TABLE 等操作,其实是在 MySQL 的 Server 层使用了一种称为元数据锁(Metadata Locks)的东西来实现的。

InnoDB 中的行锁

InnoDB 中的行锁有很多种,包括普通的记录锁(官方名称为 LOCK_REC_NOT_GAP)、GAP 锁(又叫间隙锁,官方名称为 LOCK_GAP)、Next-Key Lock(官方名称为 LOCK_ORDINARY)、插入意向锁(官方称为 LOCK_INSERT_INTENTION)。

接下来为了说明方便,先创建一个表:

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE hero (
number INT,
name VARCHAR(100),
country VARCHAR(100),
PRIMARY KEY (number)
) Engine=InnoDB CHARSET=utf8;

INSERT INTO hero VALUES
(1, 'l刘备', '蜀'),
(3, 'z诸葛亮', '蜀'),
(8, 'c曹操', '魏'),
(15, 'x荀彧', '魏'),
(20, 's孙权', '吴');

Record Lock

普通的记录锁只会锁住当前行,它有 S 锁和 X 锁之分,它们之间的关系与之前讲的一样,S 锁之间可以共存,S 锁与 X 锁之间、X 锁与 X 锁之间不能共存。在 READ COMMITTED 隔离级别下的当前读大多使用该锁,在 REPEATABLE READ 隔离级别下,唯一索引上的等值当前读也会给索引键对应的行加普通的记录锁。比如下面这个例子:

Time Session A Session B
1 BEGIN;
2 SELECT * FROM hero WHERE number = 8 FOR UPDATE;
3 BEGIN;
4 INSERT INTO hero VALUES (4, 'g关羽', '蜀');
5 COMMIT;
6 COMMIT;

由于 number 是主键,因此在会话 A 中只会给 number = 8 的记录加 X 锁,而不是 (3, 8) 这个范围,这样会话 B 中的插入操作可以立即执行而不会阻塞。

如果加锁查询时使用的是辅助索引,那么就需要分别锁定辅助索引和聚簇索引,我们假设上面的例子中 name 是辅助索引。

Time Session A Session B
1 BEGIN;
2 SELECT * FROM hero WHERE name = 'c曹操' FOR UPDATE;
3 BEGIN;
4 INSERT INTO hero VALUES (4, 'b', '蜀'); # 阻塞
5 INSERT INTO hero VALUES (4, 'd', '蜀'); # 阻塞
6 INSERT INTO hero VALUES (4, 't', '蜀'); # 立即执行
7 COMMIT;
8 COMMIT;

上面的例子中,对于聚簇索引,其仅对列 number = 8 的行加普通的记录锁;而对于辅助索引,其使用的是 Next-Key 锁,锁定的范围为 (-∞, c曹操),需要注意的是,InnoDB 存储引擎还会对辅助索引的下一个键值加入 GAP Lock,即还有一个辅助索引范围为 (c曹操, s孙权) 的锁。

在辅助索引中,“c曹操”的前面没有键值,因此这个间隙是从负无穷开始。后面的键值是“s孙权”,原因是在已有的数据当中,name 列按照字典顺序排序,c 字母的下一个就是 s。由于 t 字母不在这个范围内,因此可以立即执行。只有当首字母相同时,才会使用后面的数据继续排序。

GAP Lock

我们说 MySQL 在 REPEATABLE READ 隔离级别下是可以解决幻读问题的,具体来说是通过 MVCC 机制解决了快照读时可能产生的幻读问题,通过 Next-Key Lock 解决了使用当前读时可能产生的幻读问题。

由于在事务在第一次执行读取操作时,那些幻影记录并不存在,因此我们无法给这些记录加上普通的记录锁,这时就可以使用 GAP 锁。如果我们给 number 为 8 的那条记录加一个 GAP 锁,那么就不允许别的事务在 number 值为 8 的记录前的间隙插入新的记录,也就是 (3, 8) 这个区间内是不允许立即插入新记录的。比如一个 number 为 4 记录就无法插入到该间隙中。

可以看到,给一条记录添加一个 GAP 锁,只是不允许其他事务在这条记录前的间隙插入新记录,那么如果向最后一条记录之后的间隙插入新记录又该怎么办呢?答案就是给表中最后一条记录,也就是 number 值为 20 的记录所在页面的 Supremum 记录加上一个 GAP 锁。由于 Supremum 记录总是比该数据页中最大的行记录还大,因此可以阻止其他事务向 (20, +∞) 这个区间插入新记录。

Next-Key Lock

Next-Key 锁本质上就是一个普通的记录锁与一个 GAP 锁的结合,它既能保护该条记录,又能阻止其他事务将新记录插入到当前记录前边的间隙中。因此在上面提供的数据中,可以被 Next-Key Lock 锁住的区间包括:(-∞, 1]、(1, 3]、(3, 8]、(8, 15]、(15, 20]、(20, +∞)。下面通过一个例子来说明 InnoDB 是如何使用 Next-Key 锁来避免当前读可能出现的幻读问题的:

Time Session A Session B
1 BEGIN;
2 SELECT * FROM hero WHERE number > 3 FOR UPDATE;
3 BEGIN;
4 INSERT INTO hero VALUES (4, 'g关羽', '蜀');
5 COMMIT;
6 SELECT * FORM hero WHERE number > 3 FOR UPDATE;

会话 A 中的查询语句,锁的范围为 (3, +∞),这就意味着在会话 B 中的插入操作会被阻塞,这样就可以避免出现幻影行。

Insert Intention Lock

一个事务在插入一条记录时需要判断插入位置是否被别的事务添加了 GAP 锁或者 Next-key 锁,如果有,那么就需要等待锁释放。该事务在等待的同时,也会生成一个锁结构,表明该事务想在某个间隙中插入新记录,但是现在处于等待状态,这个锁就是插入意向锁。插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。