0%

MySQL中的锁

本文主要摘抄自 MySQL 的官方文档和《MySQL技术内幕(InnoDB存储引擎)第二版》,少部分来自网络博客和自己补充。注意,该书内容是基于MySQL 5.6 及更早的版本,具体的实现方式有可能发生了改变,有些内容我做了注释和补充,但是不保证百分百的完整和正确。

开发多用户、数据库驱动的应用时,最大的一个难点是:一方面要最大程度地利用数据库的并发访问,另一方面还要确保每个用户能以一致的方式读取和修改数据。为此就有了锁(locking)的机制,同时这也是数据库系统区别于文件系统的一个关键特性。

什么是锁

锁机制用于管理对共享资源的并发访问。InnoDB 存储引擎会在行级别上对表数据上锁,这固然不错。不过InnoDB 存储引擎也会在数据库内部其他多个地方使用锁,从而允许对多种不同资源提供并发访问。例如,操作缓冲池 LRU 列表,删除、添加、移动 LRU 列表中的元素,为了保证一致性,必须有锁的介入。

lock 与 latch

在数据库中,lock 与 latch 都可以被称为“锁”,但是两者有着截然不同的含义,本文主要关注的是lock。

latch 一般称为闩锁(轻量级的锁),因为其要求锁定的时间必须非常短。若持续的时间长,则应用的性能会非常差。在 InnoDB 存储引擎中,latch 又可以分为 mutex(互斥锁)和 rwlock(读写锁)。其目的是用来保证并发线程操作临界资源的正确性,并且通常没有死锁检测的机制。

lock 的对象是事务,用来锁定的是数据库中的对象,如表、页、行。并且一般 lock 的对象仅在事务 commit 或 rollback 后进行释放(不同事务隔离级别释放的时间可能不同)。此外,lock,正如在大多数数据库中一样,是有死锁机制的。下图显示了 lock 与 latch 的不同:

对于 InnoDB 存储引擎中的 latch,可以通过命令SHOW ENGINE INNODB MUTEX来进行查看。具体的数据结果说明如下:

上述信息是比较底层的,一般仅供开发人员参好。但是用户还是可以通过这些参数调优。

相对于 latch 的查看,lock 的信息就显得直观多了。用户可以通过命令SHOW ENGING INNODB STATUSinfomation_schema架构下的表INNODB_TRXINNODB_LOCKSINNODB_LOCK_WAITS来观察锁的信息。

InnoDB 存储引擎中的锁

锁的类型

InnoDB 存储引擎实现了如下两种标准的行级锁:

  • 共享锁(Shared Lock,S Lock),允许事务读一行数据。
  • 排它锁(Exclusive Lock,X Lock),允许事务删除或更新一行数据。

如果一个事务 T1 已经获得了行 r 的共享锁,那么另外的事务 T2 可以立即获得行 r 的共享锁,因为读取并没有改变行 r 的数据,称这种情况为锁兼容(Lock Compatible)。但若有其他事务 T3 想获得行 r 的排它锁,则其必须等待事务 T1、T2 释放行 r 上的共享锁——这种情况称为锁不兼容。下表显示了共享锁和排他锁的兼容性。

可以看出,X 锁与任何所都不兼容,而 S 锁仅和 S 锁兼容。需要特别注意的是,S 和 X 锁都是行锁,兼容是指对同一记录(row)锁的兼容性情况。

此外,InnoDB 存储引擎支持多粒度(granular)锁定,这种锁定允许事务在行级上的锁和表级上的锁同时存在。为了支持在不同粒度上进行加锁操作,InnoDB 存储引擎支持一种额外的锁方式,称之为意向锁(Intention Lock)。意向锁是将锁定的对象分为多个层次,意向锁意味着事务希望在更细粒度(fine granularity)上进行加锁。

怼细粒度的对象上锁,那么首先需要对粗粒度的对象上锁。如图所示,如果要对页上的对象 r 上 X 锁,那么分别需要对数据库A、表、页上 IX 锁,最后对记录 r 上 X 锁。若其中任何一部分导致等待,那么操作都需要等粗粒度锁的完成。

InnoDB 引擎支持意向锁设计的比较简练,其意向锁即为表级别的锁。设计目的主要是为了在一个事务中揭示下一行将被请求的锁类型。其支持两种意向锁:

  1. 意向共享锁(IS Lock),事务想要获得一张表中某几行的共享锁。
  2. 意向排他锁(IX Lock),事务想要获得一张表中某几行的排它锁。

由于 InnoDB 存储引擎支持的是行级别的锁,因此意向锁不会阻塞除全表扫描以外的任何请求。表级意向锁和行级锁的兼容性如图所示:

用户可以通过命令SHOW ENGINE INNODB STATUS命令查看当前锁请求的信息

从 InnoDB 1.0 开始,在INFORMATION_SCHEMA架构下添加了表INNODB_TRXINNODB_LOCKSINNODB_LOCKS_WAITS。通过这三张表,用户可以更简单地监控当前事务并分析可能存在的锁问题。INNODB的定义如下图所示,一共8个字段(我在MySQL 8.0 版本进行测试,发现已经扩充到了24个字段):

这个表可以显示当前运行的 InnoDB 事务,并不能判断锁的一些情况。如果需要查看锁,则还需要访问表INNODB_LOCKS,该表的字段如下所示:

再通过表INNODB_LOCKS查看了每张表上锁的情况后,用户就可以判断由此引发的等待状况了。当事务较小时,用户就可以人为地、直观地进行判断了。但是当事务量非常大,其中锁和等待也时常发生,这个时候就不那么容易判断。但是通过表INNODB_LOCK_WAIT,可以很直观地反应当前事务的等待。表INNODB_LOCK_WAITS由四个字段组成,如下图所示。

注意:在 MySQL 8.0 版本中,INFORMATION_SCHEMA 下的 INNODB_LOCKS 和 INNODB_LOCK_WAITS 表已被删除。 用Performance Schema data_locks和 data_lock_waits 表替代。

一致性非锁定读

一致性的非锁定读(consistent nonlocking read)是指 InnoDB 存储引擎通过行多版本控制(multi versioning)的方式来读取当前执行时间数据库中行的数据。如果读取的行正在执行 DELETE 或者 UPDATE 操作,这时读取操作不会因此去等待行上锁的释放。相反的,InnoDB 存储引擎会去读取行的一个快照存储。

之所以称其为非锁定读,因为不需要等待访问的行上 X 锁的释放。快照数据是指该行的之前版本的数据,该实现是通过 undo 段来完成,而 undo 用来在事务中回滚数据,因此快照数据本身是没有额外的开销的。此外,读取快照数据是不需要上锁的吗,因为没有事务需要对历史的数据进行修改操作。

可以看出,非锁定读机制极大地提提高了数据库的并发性。在 InnoDB 存储引擎的默认设置下,这是默认的读取方式,即读取不会占用和等待表上的锁。但是在不同事务隔离级别下,读取的方式不同,并不是在每个事务隔离级别下都是采用的非锁定的一致性读。此外,即使都是采用的非锁定的一致性读,但是对于快照数据的定义也各不相同。

在事务隔离级别READ COMMITTEDREPEATABLE READ(InnoDB存储引擎的默认事务隔离级别)下,InnoDB 存储引擎使用非锁定的一致性读。然而,对于快照数据的定义却不相同。在READ COMMITTED事务隔离级别下,对于快照数据,非一致性读总是读取被锁定行的最新一份快照数据。而在REPEATABLE READ事务隔离级别下,对于快照数据,非一致性读总是读取事务开始时的行数据版本。

举个栗子,首先在当前 MySQL 数据库的连接回话 A 中执行以下 SQL 语句:

1
mysql> begin;
2
Query OK, 0 rows affected (0.01 sec)
3
4
mysql> select * from parent where id =1;
5
+------+
6
|   id    |
7
+------+
8
|    1     |
9
+------+
10
1 row in set (0.00 sec)

在会话 A 显式的开启了一个事务,并读取了表 parent 中 id 为 1 的数据,但是事务并没有结束。与此同时,用户再开启另一个回话 B,这样可以模拟并发的情况,然后对 B 做如下的操作:

1
mysql> begin;
2
Query OK, 0 rows affected (0.00 sec)
3
4
mysql> update parent set id=3 where id=1;
5
Query OK, 1 row affected (0.00 sec)
6
Rows matched: 1  Changed: 1  Warnings: 0

在会话 B 中将事务表 parent 中 id 为 1 的记录修改为 id=3 ,但是事务同样没有提交。这样 id=1 的行实际上加了一个 X 锁。这时如果在会话 A 中再次读取 id 为 1 的记录,根据 InnoDB 引擎的特性,即在READ COMMITTEDREPEATETABLE READ的事务隔离级别下会使用非锁定的一致性读。回到之前的会话 A ,接着上次未提交的事务,执行 SQL 语句 select * from parent where id=1的操作,这时不管使用READ COMMITTED还是REPEATETABLE READ的事务隔离级别,显示的数据应该都是:

1
mysql> select * from parent where id =1;
2
+------+
3
|   id    |
4
+------+
5
|    1     |
6
+------+
7
1 row in set (0.00 sec)

由于当前 id=1 的数据被修改了1次,因此只有一个行版本的记录。接着,在会话 B 中提交上次的事务:

1
mysql> commit;
2
Query OK, 0 rows affected (0.00 sec)

在会话 B 提交事务之后,在会话 A 中再次运行select * from parent where id=1语句,在READ COMMITTEDREPEATETABLE READ的事务隔离级别下得到的结果就不一样了。对于READ COMMITTED的事务隔离级别,它总是能够读取行的最新版本,如果行被锁定了,则读取最新的一个快照(fresh snapshot)。在上述例子中,因为会话 B 已经提交了事务,所以READ COMMITTED事务隔离级别下会得到如下结果:

1
mysql> select @@transaction_isolation\G;
2
*************************** 1. row ***************************
3
@@transaction_isolation: REPEATABLE-READ
4
1 row in set (0.00 sec)
5
6
mysql> select * from parent where id =1;
7
Empty set (0.00 sec)

而对于REPEATABLE READ的事务隔离级别,总是读取事务开始时的行数据。因此对于REPEATABLE REPEAD事务隔离级别,得到的结果如下:

1
mysql> select @@transaction_isolation\G;
2
*************************** 1. row ***************************
3
@@transaction_isolation: REPEATABLE-READ
4
1 row in set (0.00 sec)
5
6
mysql> select * from parent where id =1;
7
+------+
8
|   id    |
9
+------+
10
|    1     |
11
+------+
12
1 row in set (0.00 sec)

注意,MySQL 8.0 之前查询当前事务隔离级别的语句是select @@tx_isolation,之后是上面用的select @@transaction_isolation

下面将从时间的角度上展现上述演示的示例过程,如下表所示。需要特别注意的是,对于READ COMMITTED的事务隔离级别而言,从数据库理论的角度来看,其违反了事务 ACID 中的 I 的特性,即隔离性。

一致性锁定锁

在前一节讲到,默认配置下,事务隔离级别是REPEATABLE READ模式,InnoDB 存储引擎的SELECT操作使用一致性非锁定锁。但是在某些情况下,用户需要显式得堆数据库读取操作进行加锁以保证数据逻辑的一致性。InnoDB 存储引擎对于SELECT语句支持两种一致性的锁定读(locking read)操作:

  • SELECT … FOR UPDATE
  • SELECT … LOCK IN SHARE MODE

SELECT ... FOR UPDATE堆读取的行记录加一个 X 锁,其他事务不能对已锁定的行加上任何锁。SELECT ... LOCK IN SHARE MODE对读取的行加一个 S 锁,其他事务可以向被锁定的行加 S 锁,但是如果加 X 锁,就会被阻塞。

以上这两个语句必须在同一个事务中,当事务提交了,锁也就释放了。因此在使用两个SELECT 锁的时候,务必记得带上BEGINSTART TRANSACTION或者SET AUTOCOMMIT=0

自增长与锁

自增长是非常常用的一个属性,在 InnoDB 引擎的内存结构中,对每个含有自增长值的表都有一个自增长计数器(auto-increment counter)。当对含有自增长的计数器的表进行插入操作时,这个计数器会被初始化,执行如下的语句来得到计数器的值:

1
SELECT MAX(auto_inc_col) FROM t FOR UPDATE;

插入操作会依据这个自增的计数器的值加 1 赋予自增长列。这个实现方式称作AUTO-INC Locking。这个锁其实是采用一种特殊的表锁机制,为了提高插入的性能,锁不是在一个事务完成后才释放,而是在完成对自增长值插入 SQL 语句后立即释放。

虽然AUTO-INC Locking从一定程度上提高了并发插入的效率,但是还存在一定性能上的问题。首先对于自增长值得咧并发插入性能较差,事务必须等待前一个插入完成。其次,对于INSERT ... SELECT的大数据量插入会影响插入性能。因为另一个事务中的插入会被阻塞。

从 MySQL 5.1.22 开始,InnoDB 存储引擎中提供了一种轻量级互斥锁的自增长实现机制,这种机制大大提高了自增值的插入性能。从该版本开始,InnoDB 存储引擎提供了一个参数innodb_autoinc_lock_mode来控制自增长的模式,该参数的默认值为 1 。我们对自增长的插入进行分类的话,如图所示。

接着来分析参数innodb_autoinc_lock_mode以及各个设置下堆自增的印象,总共有三个有效值可供供设定,即0、1、2,具体说明如下表所示:

在 InnoDB 存储引擎中,自增长的列必须是索引,同时必须是索引的第一个列。如果不是第一个列,则 MySQL 数据库会抛出异常。

外键和锁

在 InnoDB 存储引擎中,对于一个外键列,如果没有显式的对这个列加索引,InnoDB 引擎会自动加一个索引,因为这样可以避免表锁。对于外键的插入或更新,首先需要查询父表中的记录,即 SELECT 父表。单是对于父表的 SELECT 操作,不是使用一致性非锁定读的方式,因为这样会发生数据不一致的问题。因此这时使用的是SELECT ... LOCK IN SHARE MODE方式,即主动对父表加一个 S 锁。如果这时候父表已经加上了一个 X 锁,子表上的操作会被阻塞。

锁的算法

行锁的 3 种算法

InnoDB 存储引擎有三种行锁的算法,分别是:

  • Record Lock:单个行记录上的锁
  • Gap Lock:间隙锁,锁定一个范围,但不包含记录本身
  • Next-Key Lock:Gap Lock+Record Lock,锁定一个范围,并且锁定记录本身

Record Lock

Record Lock 总是会锁住索引记录,如果 InnoDB 存储引擎表在建立的时候没有设置任何一个索引,那么这是 InnoDB 存储引擎会使用隐式的主键来进行锁定。比如, SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE; 会阻止其他任何事务操作t.c1 = 10的行,包括更新、插入和删除操作。

Gap Lock

Gap lock 会锁住两个索引中间的一段间隙(范围),或者索引之前或之后的一段范围。比如, SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE; 会阻止其他事务插入15 到到 t.c1的字段中,无论这个字段中当前有没有记录。因为从 10 到 20 这一段索引已经被锁住了。

这个间隙的范围可以跨越单个索引值,多个索引值,甚至为空。

间隙锁是性能和并发性之间权衡,只在某些事务隔离级别中使用。

对于使用唯一索引来锁定唯一行来锁定行的语句,不需要间隙锁定。(这不包括搜索条件仅包含多列唯一索引的某些列的情况;在这种情况下,会发生间隙锁定。)例如,如果 id 列具有唯一索引,则以下语句仅使用 id 值为 100 的行的索引记录锁,其他会话是否在前面的间隙中插入行都没有关系:

1
SELECT * FROM child WHERE id = 100;

如果 id 未建立索引或索引不唯一,则该语句会锁定前面的间隙。

在这里还值得注意的是,可以通过不同的事务将冲突的锁保持在间隙上。例如,事务 A 可以在间隙上保留一个共享的间隙锁(间隙 S 锁),而事务 B 可以在同一间隙上保留排他的间隙锁(间隙 X 锁)。允许冲突的间隙锁的原因是,如果从索引中清除记录,则必须合并由不同事务保留在记录上的间隙锁。

InnoDB 中的间隙锁是“完全禁止的”,这意味着它们的唯一目的是防止其他事务插入间隙。间隙锁可以共存。一个事务进行的间隙锁定不会阻止另一事务对相同的间隙进行间隙锁定。共享和专用间隙锁之间没有区别。它们彼此不冲突,并且执行相同的功能。

间隙锁定可以显式禁用。如果将事务隔离级别更改为 READ COMMITTED,则会发生这种情况。在这种情况下,将禁用间隙锁定来进行搜索和索引扫描,并且间隙锁定仅用于外键约束检查和重复键检查。

使用 READ COMMITTED 隔离级别还有其他影响。 MySQL 评估 WHERE 条件后,将释放不匹配行的记录锁。对于 UPDATE 语句,InnoDB 进行“半一致”读取,以便将最新的提交版本返回给 MySQL,以便 MySQL 可以确定该行是否与 UPDATE 的 WHERE 条件匹配。

Next-Key Lock

Next-Key Lock 是结合了 Gap Lock 和 Record Lock 的一种锁定算法,在 Next-Key Lock 算法下,InnoDB 对于行的查询都是采用这种锁定算法。例如一个索引有10,11,13和20这四个值,那么该索引可能被 Next-Key Lock 的区间为:

1
(-∞,10]
2
(10,11]
3
(11,13]
4
(13,20]
5
(20,+∞)

采用 Next-Key Lock 的锁定技术称为 Next-Key Locking。其设计的目的是为了解决 Phantom Problem,这将在下一个小节介绍。利用这种技术,锁定的不是单个值,而是一个范围,是谓词锁(predict lock)的一种改进。除了 Next-Key Locking,还有 Previous-Key Locking 技术。

同样上述索引 10,11,13 和 20,如果采用 Previous-Key Locking 技术,可锁定的区间会变成:

1
(-∞,10)
2
[10,11)
3
[11,13)
4
[13,20)
5
[20,+∞)

若事务 T1 已经通过 Next-Key Locking 锁定了如下范围:

(10,11]、(11,13]

当插入新的记录 12 的时候,锁定的范围会变成:

(10,11]、(11,12]、(12,13]

当查询的索引含有唯一属性的时候,InnoDB 存储引擎会对 Next-Key Lock 进行优化,将其降级为 Record Lock,即仅锁住索引本身,而不是范围,从而提高应用的并发性。

使用下列代码创建测试表 t :

1
create table t(a int primary key);
2
insert into t select 1;
3
insert into t select 2;
4
insert into t select 5;

然后执行下列语句:

表 t 一共只有1、2、5 三个值。在上面的例子中,会话 A 首先对 a=5 进行 X 素哟定。而由于 a 是主键且唯一,因此锁定的仅仅是 5 这个值,而不是(2,5)这个范围,这样在会话 B 中插入值 4 而不会阻塞,可以立即插入并返回。

若是辅助索引,情况会完全不同。根据下列代码创建测试表 z:

1
mysql> create table z(a int,b int, primary key(a),key(b));
2
mysql> insert into z select 1,1;
3
mysql> insert into z select 3,1;
4
mysql> insert into z select 5,3;
5
mysql> insert into z select 7,6;
6
mysql> insert into z select 10,8;

表 z 的列 b 是辅助索引,若在会话 A 中开启事务,执行下面的 SQL 语句:

1
select  * from z where b=3 for update;

这时 SQL 语句将通过索引列 b 进行查询,因此使用传统的 Next-Key Locking技术进行加锁,并且由于有两个索引,需要对其分别加锁。对于聚集索引,仅对列 a 等于 5 的索引加上 Record Lock。而对于辅助索引,其加上的是 Next-Key Lock,锁定范围是(1,3),需要特别注意的,InnoDB 存储引擎还会对辅助索引下一个键值加上 gap lock,即还有一个辅助索引范围为(3,6)的锁,因此,若在新回话 B 中运行下面的 SQL 语句,都会被阻塞。

1
select * from z where a=5 lock in share mode;
2
insert into z select 4,2;
3
insert into z select 6,5;

原因如下:

  • 第一个 SQL 语句不能执行,因为在会话 A 中执行的 SQL 语句已经对聚集索引中列 a=5 的值加上 X 锁,因此执行会被阻塞。
  • 第二个 SQL 语句,主键插入 4 ,没有问题,但是插入的辅助索引 2 在锁定的范围(1,3)中,所以同样会阻塞
  • 第三个 SQL 语句,插入的主键 6 没有被锁定,5 也不在范围(1,3)之间,但是插入的值 5 在另一个锁定的范围(3,6)中,所以同样需要等待。

上面的例子可以看出,Gap Lock 的作用是为了阻止多个事务将记录插入到同一个范围内,而这会导致 Phantom Problem 问题的产生。例如上面的例子中,会话 A 用户锁定了 b=3 的记录,如果此时没有锁定(3,6),那么用户可以插入 b 列为 3 的记录,这会导致会话 A 中的用户再次执行查询时返回不同的记录,这就导致了 Phantom Problem 问题的产生。

用户可以通过以下两种方式来显式的关闭 Gap Lock:

  • 将事务的隔离级别设置为READ COMMITTED
  • 将参数innodb_locks_unsafe_for_binlog设置为 1

在上述配置下,除了外键约束和唯一性检查仍然需要 Gap Lock,其余情况仅使用 Record Lock 进行锁定。但需要牢记的是,上述设置破坏了事务的隔离性,并且对于 replication,可能会导致主从数据的不一致。因此从性能上看,READ COMMITTED也不会优于默认的事务隔离级别READ REPEATABLE

需要再次提醒的是,对于唯一键值的锁定,Next-Key Lock 降级为 Record Lock 仅存在于查询所有的唯一索引列。若唯一索引由多个列组成,而查询仅是查找多个唯一索引列中的其中一个,那么查询其实是 range 类型查询,而不是 point 类型查询,故 InnoDB 存储引擎依然使用 Next-Key Lock 进行锁定。

解决 Phantom Problem

在默认的事务隔离级别下,即REPEATABLE READ下,InnoDB 存储引擎采用 Next-Key Locking 机制来避免 Phantom Problem(幻想问题)。

Phantom Problem 是指在同一事务下,连续执行两次同样的 SQL 可能导致不同的结果,第二次的 SQL 语句可能会返回之前不存在的行。

还是以上一节创建的表 t 为例,表 t 由 1、2、5 三个值组成,如果事务 T1 执行如下 SQL 语句:

1
select * from t where a>2 for update;

注意这时候 T1 并没有进行提交操作,上述应该返回 5 这个结果。如果与此同时,另一个事务 T2 插入了 4 这个值,并且数据库允许该操作,那么事务 T1 再次执行查询 SQL 语句的时候,就会得到结果 4 和 5 。这与第一次查询的结果不同,违反了事务的隔离性,即当前事务可以看到其他事务的结果。

InnoDB 引擎采用了 Next-Key Locking 算法避免出现上面的情况。对于上面的 SQL,实际上锁住的不只是 5 这个单个值,而是对(2,+∞)范围都加了 X 锁,因此对于这个范围内的插入都是不允许的,从而避免 Phantom Problem。

InnoDB 引擎默认的事务隔离级别是REPEATABLE READ,在该隔离级别下,采用 Next-Key Locking 来加锁。而在事务隔离级别READ COMMITTED级别下,仅采用 record Lock,因此在上述示例中,会话 A 需要将事务的隔离级别设置为READ COMMITTED

此外,用户可以通过 InnoDB 存储引擎的 Next-Key Locking 机制在应用层面实现唯一性检查:

1
select * from table where col=xxx lock in share mode;

如果用户通过索引查询一个值,并对该行加上一个 S Lock,那么即使查询的值不存在,锁定的也是一个范围,因此若此行没有任何返回,那么新插入的值一定是唯一的。

这里如果在第一步select ... lock in share mode操作的回收,有多个事务并发操作,这种唯一性检查机制是否存在问题呐?答案是不会,因为这时候会导致死锁,只有一个事务操作成功,其他会抛出死锁的错误提示。

锁问题

通过锁定机制可以实现事务的隔离性要求,使得事务可以并发的工作。锁提高了并发,但是也有潜在的问题。不过好在事务隔离性的要求,锁只会带来以下三种问题。

脏读

在理解脏读(Dirty Read)之前,需要理解脏数据的概念。脏数据是指未提交的数据,如果读到了脏数据,即一个事务可以读到另外一个事务未提交的数据,则显然违反了数据的隔离性。

下面的表格显示了一个脏读的例子:

表 t 还是上面创建的那个,不过不同于上述例子,这里把事务隔离级别改成了READ UNCOMMITTED。因此在会话 A 未提交的前提下,事务 B 两次 SELECT 取得了不同结果,即产生了脏读。

脏读现象在生产环境并不经常发生,由上面例子可以看出来,脏读需要隔离级别为READ UNCOMMITTED,实际上大多数数据库至少都是READ COMMITTED,而 InnoDB 默认的都是READ REPEATABLE

脏读隔离看似毫无用处,但是一些特殊的情况下还可以将事务的隔离级别放开到READ UNCOMMITTED。例如 replication 环境下的 slave 节点,并且在该 slave 节点上的查询并不需要特别精确的返回值。、

不可重复读

不可重复读是指在一个事务内多次读取同一个数据集合。在这个事务还没有结束时,另一个事务也访问该同一个数据集合,并做了一些 DML 操作。因此在第一个事务两次读数据之间,由于第二个事务的修改,那么第一个事务读到的数据可能是不一样的。这种情况成为不可重复读。

不可重复读和脏读的区别:脏读读到的是未提交的数据,不可重复读读到的却是提交过的数据,但是其违反了数据库事务一致性的要求。下面的表格展示了一个例子:

这个例子的前提是,两个事务的隔离级别都调整为READ COMMITTED。会话 B 的事务提交之后,事务 A 进行读取,读到的是 1 和 2 两条记录。

一般情况下,不可重复读是可以接受的,因为读到的是已经提交的数据,本身不会带来太大的问题,因此很多数据库的默认级别设置为READ COMMITTED,允许不可重复读。

在 InnoDB 存储引擎中,使用 Next-Key Lock 算法来避免不可重复读的问题。在 Next-Key Lock 算法下,对于索引的扫描,不仅是锁住了扫描到的索引,还锁着了索引覆盖的范围(gap)。因此在这个范围内的插入都是不被允许的。这样就避免了其他事务在这个范围内插入数据导致不可重复读的问题。

丢失更新

丢失更新是另一个锁导致的问题,简单来说就是一个事务的更新操作会被另一个事务的更新操作覆盖,从而导致数据不一致。例如:

  1. 事务 T1 将行记录 r 更新为 v1,但是事务 T1 尚未提交。
  2. 与此同时,事务 T2 将行记录 r 更新为 v2,事务 T2 未提交。
  3. 事务 T1 提交。
  4. 事务 T2 提交。

虽然在数据库的任何隔离级别下,都不会导致理论意义上的丢失更新问题,即使是READ UNCOMMITTED的事务隔离级别,对于任何行的 DML 操作,需要对行或者其他粗粒度级别的对象加锁。因此上述步骤中,T2 的更新操作不能顺利执行,会被阻塞。

但是,生产中还会出现类似的另一个逻辑意义上的丢失更新问题,尤其是多用户的计算机系统环境下:

  1. 事务 T1 查询一行数据,放到本地内存,并显示给一个终端用户 User1。
  2. 与此同时,事务 T2 也查询了数据,将数据展示给另一个用户 User2。
  3. User1 修改了这行记录,更新数据库提交。
  4. User2 也修改了记录,更新数据库提交。

显然,这个过程中 User1 的更新操作“丢失”了。为了避免这种问题,需要让事务操作串行化,而不是并行。即在操作步骤 1 中,对用户读取的记录加上一个排他锁 X 。同样,在步骤 2 中,同样需要加入排它锁 X 。通过这种方式,步骤 2 就必须等待步骤 1 和 3 完成。下面的表格演示了如何避免上述逻辑上丢失更新问题的产生。

阻塞

由于不同锁之间的兼容关系,有时候一个事务中的锁需要等待另一个事务中的锁释放它占用的资源,这就是阻塞。

在 InnoDB 存储引擎中,参数innodb_lock_wait_timeout控制等待时间,innodb_rollback_on_timeout用来设定是否在等待超时时堆进行中的事务进行回滚操作。参数innodb_lock_wait_timeout是动态的,可以在数据库运行时动态调整,而innodb_rollback_on_timeout是静态的,不能在运行中修改。

死锁

死锁的概念

死锁是指两个或两个以上事务在执行过程中,因争夺锁资源而造成的一种相互等待的现象。

解决死锁最简单的方式是超时,当两个事务相互等待的时候,当其中一个等待超过阈值,进行回滚,另一个事务就可以继续进行。超时机制虽然简单,但是仅仅通过超时后事务回滚的方式处理,或者根据 FIFO 的顺序选择回滚对象。但如果超时的事务所占权重比较大,如事务操作更新很多航,占用了较多的 undo log,这时候采用 FIFO 的方式就不合适了。

因此,除了超时机制,当前数据库还普遍采用 wait-for graph(等待图)的方式来进行死锁检测。 wait-for graph 要求数据库保存以下两种信息:

  • 锁的信息链表
  • 事务等待链表

通过上述链表可以构造一张图,如果这张图中存在回路,就代表的存在死锁。 wait-for graph 是一种比较主动的死锁检测机制,InnoDB 一般会选择回滚 undo 量最小的事务。

死锁概率

死锁的概率推导过程就不抄了,直接截图放这里:

可以看出死锁发生概率与以下几点有关:

  • 系统中事务数量(n),数量越多死锁概率越大。
  • 每个事务的操作量,数量越多死锁概率越大。
  • 操作数据的集合(R),越小则死锁的概率越大。

死锁的示例

下面的表演示了一个死锁的经典情况:

上面的例子中,会话 B 抛出了 1213 这个错误提示,即表示事务发生了死锁。大多数死锁 InnoDB 引擎本身可以侦测到,不需要人为进行干预。

还有另外一种情况,即当前事务持有了待插入记录的下一个记录的 X 锁,但是在等待队列中存在一个 S 锁的请求,则可能发生死锁。举个栗子,先创建一个表:

1
CREATE TABLE t( a INT PRIMARY KEY)ENGINE=INNODB;
2
INSERT INTO t VALUES (1),(2),(4),(5);

然后运行下表所示的查询:

可以看到,会话 A 中已经对记录 4 持有了 X 锁,但是会话 A 中插入记录 3 时会导致死锁发生。这个问题的产生是由于会话 B 中请求记录 4 的 S 锁而发生等待,但之前请求的锁对于主键值记录 1、2 都已经成功,若在时间点 5 能插入记录,那么会话 B 在获得记录 4 持有的 S 锁之后,还需要向后获得记录 3 的记录,这样就显得不合理。因此 InnoDB 引擎在这里主动选择了死锁,而回滚的是 undo log 记录大的事务,这与 AB-BA 死锁的处理方式又有所不同。

锁升级

锁升级(Lock Escalation)是指将当前锁的粒度降低。举例来说,数据库可以吗一个表的 1000 个行锁升级为一个页锁,或者页锁升级为一个表锁。如果数据库设计中认为锁是稀有资源,想要尽量避免锁的开销,就会频繁出现锁升级现象。

InnoDB 存储引擎不存在锁升级的问题。因为其不是根据每个记录来产生行锁的,而是采用位图。不管一个事务锁住页中的一个记录还是多个记录,开销通常都是一致的。