3534 2021-01-10 2021-03-28

前言:2021年开篇之作,这次将深入了解一下MySQL中InnoDB存储引擎的内部锁机制。

一、什么是锁

1、简介

锁是数据库系统区别于文件系统的一个关键特性,锁机制用于管理对共享资源的并发访问。

2、lock与latch

这里需要区分一下锁中容易令人混淆的概念lock和latch。在数据库中,lock与latch都可以被称为“锁”,但两者却有着截然不同的概念。如下

locklatch
对象事务线程
保护数据库内容内存数据结构
持续时间整个事务过程临界资源
模式行锁、表锁、意向锁读写锁、互斥量
死锁通过waits-for graph、time out等机制进行死锁检测与处理无死锁检测与处理机制,仅通过应用程序加锁的顺序(lock leveling)保证无死锁的情况发生
存在于Lock Manager的哈希表中每个数据结构的对象中

本篇主要关注的是存在于事务中的lock

二、InnoDB中的锁实现

1、行级锁

InnoDB实现了如下两种标准的行级锁:

  • 共享锁(Share Lock,简称S Lock),允许事务读取一行数据。
  • 排他锁(Exclusive Lock,简称X Lock),允许事务删除或更新一行数据。

如果一个事务T1已经获取到了行r的共享锁,那么另外的事务T2可以礼记获得行r的共享锁,因为读取并没有改变行r的数据,这种情况称之为锁兼容。

但如果此时有其他事务T3想获得行r的排他锁,则其必须等待事务T1、T2释放行r的共享锁,这种情况称之为锁不兼容。具体关系如下

X 锁S 锁
X 锁不兼容不兼容
S 锁不兼容兼容

注意:这里的共享锁-排他锁可以类比读锁-写锁(只有读-读操作能兼容),因为在一个事务当中,当事务在进行读取数据行的操作时,它并不希望之前可以读到的行记录突然就变了(修改或删除),但是运行其他事务进行读操作。

2、意向锁

意向锁是将锁定的对象分为多个层次,希望事务在更细粒度上进行加锁。若将上锁的对象看成一棵树,那么对最下层的对象上锁,也就是对最细粒度的对象进行上锁,那么首先需要对粗粒度的对象上锁。

举例来说,在对行记录r加X锁之前,已经事务对行记录r所在表1进行了S表锁(有些操作对表加锁,比如count、范围查询等),由于表1已存在S锁,之后的事务需要对记录r在表1上加IX锁,由于不兼容,所以该事务需要等待表锁操作的完成。

一个通俗的例子:某个武林高手要杀光某门派全部高手共10人(全部 -> 10人),那么当他实施这个行为时,他希望门派高数10人这个数值不变(不然无穷无尽的高手加入进来,他可能打不过,这样就有点尴尬),因此他发出武林通牒,任何人高手不能再加入该门派,这样他就可以稳稳当当开始行动了。意思就这么个意思,理解就行。

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

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

又由于InnoDB存储引擎支持的是行级别的锁,因此意向锁其实不会阻塞除全表扫描以外的任何请求,故表级意向锁与行级锁的兼容性如下

IS表锁IX表锁S锁X锁
IS表锁兼容兼容兼容不兼容
IX表锁兼容兼容不兼容不兼容
S锁兼容不兼容兼容不兼容
X锁不兼容不兼容不兼容不兼容

三、一致性非锁定读

1、简介

一致性的非锁定读(consistent nonlocking read)是指InnoDB存储引擎通过行多版本控制(multi versioning)的方式来读取当前执行时间数据库中行的数据。

如果读取的行正在执行delete或update操作,这时读取操作不会因此去等待行上锁的释放。相反地,InnoDB存储引擎会去读取行的一个快照数据

之所以称其为非锁定读,是因为不需要等待访问行上X锁的释放。快照数据是指该行之前版本的数据,该实现是通过undo段来完成的。

而undo段是用来在事务中回滚数据,因此快照数据本身是没有额外开销的。此外,读取快照是不需要上锁的,因为没有事务需要对历史的数据进行修改操作。

2、多版本并发控制

快照数据其实就是当前行数据之前的历史版本,每行记录可能有多个版本,即一个行记录可能不止一个快照数据,一般称这种技术为行多版本技术。由此带来的并发控制,称之为多版本并发控制(Multi Version Concurrency Control, MVCC)。

在事务隔离级别为read committed(读提交)和repeatable read(重复读,InnoDB存储引擎的默认事务隔离级别)下,InnoDB存储引擎使用非锁定的一致性读。然而,对于快照数据的定义却不相同。

在read committed事务隔离级别下,对于快照数据,非一致性读总是读取被锁定行的最新一份快照数据。而在repeatable read事务隔离级别下,对于快照数据,非一致性读总是读取事务开始时的行数据版本。

这里就产生了一个疑问:哪种才是符合数据库事务的ACID中的I(Isolation,隔离性)呢?答案是默认的重复读,因为一个事务不应该影响到另外一个事务中的数据的,重复读是开始的行数据,不会随外界发生变化。

3、重复读与读提交

下面我们来验证一下,大致思路如下:分别开两个窗口,两个事务,模拟并发,测试在不同隔离界别下的数据情况。

1、repeatable read

# mysql -uroot -p k-mall

# 窗口 A
# 查看隔离级别
MariaDB [(none)]> select @@tx_isolation;
+-----------------+
| @@tx_isolation  |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set (0.000 sec)

# 查看所有数据
MariaDB [k-mall]> select * from mall_user;
+-----+----------+----------+--------+---------------+
| uid | username | password | status | remark        |
+-----+----------+----------+--------+---------------+
|   1 | admin    | admin    | 1      | 测试用户1     |
|   2 | test     | test     | 1      | 测试用户2     |
|   3 | other    | other    | 1      | 测试用户3     |
+-----+----------+----------+--------+---------------+
3 rows in set (0.000 sec)

# 开始事务
MariaDB [k-mall]> begin;
Query OK, 0 rows affected (0.000 sec)

MariaDB [k-mall]> select * from mall_user where uid = 1;
+-----+----------+----------+--------+---------------+
| uid | username | password | status | remark        |
+-----+----------+----------+--------+---------------+
|   1 | admin    | admin    | 1      | 测试用户1     |
+-----+----------+----------+--------+---------------+
1 row in set (0.000 sec)

# 切换到窗口 B,这里有两个分支
MariaDB [k-mall]> begin;
Query OK, 0 rows affected (0.000 sec)

MariaDB [k-mall]> update mall_user set uid = 1111 where uid = 1;
Query OK, 1 row affected (0.001 sec)
Rows matched: 1  Changed: 1  Warnings: 0

MariaDB [k-mall]> commit;
Query OK, 0 rows affected (0.001 sec)

# 分支一 再切换到窗口 A --- B还未提交
MariaDB [k-mall]> select * from mall_user where uid = 1;
+-----+----------+----------+--------+---------------+
| uid | username | password | status | remark        |
+-----+----------+----------+--------+---------------+
|   1 | admin    | admin    | 1      | 测试用户1     |
+-----+----------+----------+--------+---------------+
1 row in set (0.000 sec)

# 分支二 再切换到窗口 A --- B已提交
MariaDB [k-mall]> select * from mall_user where uid = 1;
+-----+----------+----------+--------+---------------+
| uid | username | password | status | remark        |
+-----+----------+----------+--------+---------------+
|   1 | admin    | admin    | 1      | 测试用户1     |
+-----+----------+----------+--------+---------------+
1 row in set (0.000 sec)

2、read committed

# mysql -uroot -p k-mall

# 窗口 A B 都进行此操作
MariaDB [k-mall]> set tx_isolation='read-committed';
Query OK, 0 rows affected (0.000 sec)

MariaDB [k-mall]> select @@tx_isolation;
+----------------+
| @@tx_isolation |
+----------------+
| READ-COMMITTED |
+----------------+
1 row in set (0.000 sec)


# 查看所有数据
MariaDB [k-mall]> select * from mall_user;
+-----+----------+----------+--------+---------------+
| uid | username | password | status | remark        |
+-----+----------+----------+--------+---------------+
|   1 | admin    | admin    | 1      | 测试用户1     |
|   2 | test     | test     | 1      | 测试用户2     |
|   3 | other    | other    | 1      | 测试用户3     |
+-----+----------+----------+--------+---------------+
3 rows in set (0.000 sec)

# 开始事务
MariaDB [k-mall]> begin;
Query OK, 0 rows affected (0.000 sec)

MariaDB [k-mall]> select * from mall_user where uid = 1;
+-----+----------+----------+--------+---------------+
| uid | username | password | status | remark        |
+-----+----------+----------+--------+---------------+
|   1 | admin    | admin    | 1      | 测试用户1     |
+-----+----------+----------+--------+---------------+
1 row in set (0.000 sec)

# 切换到窗口 B,这里有两个分支
MariaDB [k-mall]> begin;
Query OK, 0 rows affected (0.000 sec)

MariaDB [k-mall]> update mall_user set uid = 1111 where uid = 1;
Query OK, 1 row affected (0.001 sec)
Rows matched: 1  Changed: 1  Warnings: 0

MariaDB [k-mall]> commit;
Query OK, 0 rows affected (0.001 sec)

# 分支一 再切换到窗口 A --- B还未提交
MariaDB [k-mall]> select * from mall_user where uid = 1;
+-----+----------+----------+--------+---------------+
| uid | username | password | status | remark        |
+-----+----------+----------+--------+---------------+
|   1 | admin    | admin    | 1      | 测试用户1     |
+-----+----------+----------+--------+---------------+
1 row in set (0.000 sec)

# 这里就出现了不同,看到了该行的最新数据,不符合隔离性 
# 分支二 再切换到窗口 A --- B已提交
MariaDB [k-mall]> select * from mall_user where uid = 1;
Empty set (0.000 sec)

# 最后,恢复原来的隔离级别,虽然之前的设置默认只会对本窗口有效,关闭之后就没了
set tx_isolation='repeatable-read';

至此,验证完成。

四、一致性锁定读

前面我们知道,在默认配置下,即事务的隔离级别为repeatable read模式下,InnoDB存储引擎的select操作使用一致性非锁定读。但在某些情况下,用户需要显式地对数据库读取操作进行加锁以保证数据逻辑的一致性。而这就要求数据库支持加锁语句,即使是对于select的只读操作。

InnoDB存储引擎对于select语句支持两种一致性的锁定读(locking read)操作:

  • select ... for update
  • select ... lock in share mode

第一句是对读取的行记录加一个X锁,其他事务不能对已锁定的行加任何锁。

第二句是对读取的行记录加一个S锁,其他事务可以向被锁定的行加S锁,但是如果加X锁,则会被阻塞。

对于一致性非锁定读,即使读取的行已被执行了select ... for update,也是可以读取的,这和之前讨论的情况一样。此外,select ... for update和select ... lock in share mode必须存在于一个事务中,当事务提交了,锁也就释放了。

五、锁问题

通过锁定机制可以实现事务的隔离性要求,使得事务可以并非地工作。锁提高了并发,但是却会带来潜在的问题。不过好在因为事务隔离性的要求,锁只会带来三种问题,如果可以防止这三种情况的发生,那将不会产生并非异常。

1、脏读

脏读指的是在不同事务下,当前事务可以读到另外事务未提交的数据,简单来说就是可以读到脏数据。如下示例

Time会话A会话B
1set @@tx_isolation='read-uncommitted';
2 set @@tx_isolation='read-uncommitted';
3begin;begin;
4 mysql> select * from t; 显示一行数据
5insert into t select 2
6 mysql> select * from t; 显示两行数据

我们首先将数据库的隔离级别设置为未提交读,使得在会话A中事务没有提交的前提下,会话B事务中的两次select操作取得不同的结果。显而易见,第二次select出现了未提交的脏数据,违反了事务的隔离性。

脏读现象在生产环境中并不常见,从上面的例子中可以发现,脏读发生的条件是需要事务的隔离级别为read uncommitted(读未提交),而目前绝大部分的数据库都至少设置成read committed(读已提交)。

2、不可重复读

不可重复读是指在同一个事务内多次读取同一数据集合,由于外部事务的修改、删除,导致前后读取数据不一致。

不可重复读和脏读的区别是:脏读是读到未提交的数据,而不可重复读读到确实已经提交的数据,但是其违反了数据库事务的一致性要求。如下是一个演示例子

Time会话A会话B
1set @@tx_isolation='read-committed';
2 set @@tx_isolation='read-committed';
3begin;begin;
4mysql> select * from t; 显示一行数据
5 insert into t select 2
6 commit;
7mysql> select * from t; 显示两行数据

一般来说,不可重复读的问题是可以接受的,因为读到的是已经提交的数据,本身不会带来很大的问题。因此,很多数据库厂商(如Oracle、SQL Server)将其数据库事务的默认隔离级别都设置为了read commited(读提交),在这种隔离级别下允许不可重复读的现象。

而在InnoDB存储引擎中,通过使用Next-Key Lock算法来避免不可重复的问题。在MySQL官方文档中将不可重复读的问题定义为Phantom Problem,即幻读问题。在Next-Key Lock算法下,对于索引的扫描,不仅是锁住扫描到的索引,而且还锁住这些索引覆盖的范围(gap)。

因此在这个范围内的插入都是不允许的,这样就避免了另外事务在这个范围内插入数据导致的不可重复问题。

总结:InnoDB存储引擎的默认事务隔离级别是repeatable read(重复读),采用Next-Key Lock算法,避免了不可重复读的现象。

3、丢失更新

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

  1. 事务T1将行记录r更新为v1,不提交。
  2. 而后,事务T2将行记录r更新为v2,不提交。
  3. 事务T1提交。
  4. 事务T2提交。
  5. 结果,T2覆盖T1的修改操作。

但是,在当前数据库的任何隔离级别下,都不会导致数据库理论意义上的丢失更新。这是因为,即使是在read uncommitted(读未提交)的事务隔离级别,对于行的DML(指插入、更新、删除)操作,需要对行或其他粗粒度级别的对象加锁。

因此,在上述步骤2中,事务T2并不能对行记录r进行更新操作,其会被阻塞,直到事务T1提交。

六、总结

未完待续。

总访问次数: 312次, 一般般帅 创建于 2021-01-10, 最后更新于 2021-03-28

进大厂! 欢迎关注微信公众号,第一时间掌握最新动态!