面试笔记(三)MySQL事务深入学习

上一篇文章 面试笔记(一)事务连环炮 中,写了下事务的基本概念。这篇文章中将继续深入学习下 MySQL 事务的 ACID 四个特性的实现原理。

1. 隔离性

事务的隔离性实现是最复杂的,也是最难的,所以 MySQL 对隔离性做了四个级别的实现。事务的隔离性其实是指,两个事务之间的操作在未提交时相关不可见。这跟 Java 多线程里的可见性正好相反。MySQL 通过 MVCC、锁等手段

1.1 读未提交(Read uncommitted)

这种事务隔离级别下,读到的数据是其他事务没有提交的数据,所以不需要做特殊处理,可以直接读取当前数据即可。

1.2 读已提交(read committed)

MySQL 通过 多版本并发控制(MVCC) 实现了 一致性非锁定读 ** 的能力。当一个事务对某个记录进行操作时,会对该行记录进行加锁,在 RC 级别下,如果另外一个事务要读取当前数据的话,则不会等待锁释放,而是读取行记录的一个快照版本。所以才叫非锁定读。因为读的是快照数据,所以也叫快照读**。

下面我们开启两个事务看下 RC 级别下的快照读情况。

首先修改 事务隔离级别为 RC 级别,并且设置binlog的模式

SET session transaction isolation level read committed;

然后开启事务 A

begin;
update my_test set name ="李四" where id = 1;

先不提交,然后我们再打开一个事务B。

begin;
select * from my_test;

+----+--------+------+
| id | name   | age  |
+----+--------+------+
|  1 | 张三   |   11 |
+----+--------+------+
1 row in set (0.00 sec)

然后我们提交一下事务A,发现事务B已经能够读取到最新的数据了。

begin;
select * from my_test;

+----+--------+------+
| id | name   | age  |
+----+--------+------+
|  1 | 张三   |   11 |
+----+--------+------+
1 row in set (0.00 sec)

select * from my_test;

+----+--------+------+
| id | name   | age  |
+----+--------+------+
|  1 | 李四   |   11 |
+----+--------+------+
1 row in set (0.00 sec)

也就是说,我们可以直接读取到其他事务锁定的数据,这个就是非锁定读。读取到的数据是其他事务提交后的数据,没有提交的数据读取不到。所以隔离级别也叫读已提交。事务B两次查询请求的结果不一致的现象也叫不可重复读,即同一个事务里两次读取的结果不一致。这个问题在 RR 级别下就会解决。

整个 SQL 执行过程:

事务A 事务B
begin;
update my_test set name =”李四” where id = 1;
begin;
select * from my_test;
没有读取到更新的数据
commit;
select * from my_test;
读取到更新的数据

1.3 可重复读(repeatable read)

将事务隔离级别调整到 RR 级别。

SET session transaction isolation level repeatable read;

在 RR 隔离级别下可以解决不可重复读的问题。使用的方法也是多版本并发控制(MVCC)

首先开启一个 事务 A。执行 读取数据。

begin;
select * from my_test;
+----+--------+------+
| id | name   | age  |
+----+--------+------+
|  1 | 李四   |   11 |
+----+--------+------+

然后开始事务B,执行更新操作,并提交。

begin;
update my_test set name = "李四2" where id = 1;
commit;

然后事务A再执行读取操作,发现读取的结果没有变化。

begin;
select * from my_test;
+----+--------+------+
| id | name   | age  |
+----+--------+------+
|  1 | 李四   |   11 |
+----+--------+------+

select * from my_test;
+----+--------+------+
| id | name   | age  |
+----+--------+------+
|  1 | 李四   |   11 |
+----+--------+------+

所以是解决了不可重复读的问题。同一个事务里,第一次读取和第二次读取的数据是一致的。

但是如果你使用下面的语句进行查询的话,就会发现会读到最新的数据

select * from my_test lock in share mode;
+----+---------+------+
| id | name    | age  |
+----+---------+------+
|  1 | 李四2   |   11 |
+----+---------+------+
2 rows in set (0.00 sec)

select * from my_test for update;
+----+---------+------+
| id | name    | age  |
+----+---------+------+
|  1 | 李四2   |   11 |
+----+---------+------+
2 rows in set (0.00 sec)

这是因为 MySQL 有两种读取方式。一种被称为快照读,一种被称为当前读。在 MVCC 中 select * from my_test 就是快照读,读取的是快照数据,而select * from my_test lock in share mode;select * from my_test for update; 是当前读,会读取当前版本的数据。MySQL 通过 MVCC 实现了上面这种能力。

1.4 多版本并发控制(MVCC)

在MySQL中建表时,每个表都会有三列隐藏记录,其中和MVCC有关系的有两列。

  • 记录的创建版本号 (DB_TRX_ID)
  • 记录的删除版本号(DB_ROLL_PT)

这两列的值是事务的版本号。而事务的版本号是每开启一个新事务,事务版本号就会递增。每次在事务中进行操作时都会使用这两个值。

在 RR 级别下:

  • select时,读取 DB_TRX_ID 小于等于当前事务版本号,DB_ROLL_PT 为空或者大于当前事务版本号的数据。
  • insert时,保存当前事务版本号为 DB_TRX_ID。
  • delete时,保存当前事务版本号为 DB_ROLL_PT。
  • update时,复制需要更新的行,并将当前事务的版本号保存为复制行的 DB_TRX_ID,并更新原行的 DB_ROLL_PT 为当前事务版本号

这样就能够保证 RR 级别下的可重复读

下面我们梳理下 RR 级别的操作对 这两个值的影响。

假如当前数据库的数据是下面这样:

id name age DB_TRX_ID DB_ROLL_PT
1 李四 11 1 NULL

然后开启事务A 执行查询操作

begin; # 事务ID = 2
select * from my_test;
+----+--------+------+
| id | name   | age  |
+----+--------+------+
|  1 | 李四   |   11 |
+----+--------+------+

根据 MVCC 的要求,读取 DB_TRX_ID 小于等于当前事务版本号,DB_ROLL_PT 为空或者大于当前事务版本号的数据。id = 1 的行 DB_TRX_ID = 1 且 DB_ROLL_PT = NULL,所以可以读取到 id = 1 的记录。

然后开启事务 B,执行更新操作,并提交。

begin; # 事务ID = 3
update my_test set name = "李四2" where id = 1;
commit;

更新时会 复制需要更新的行,并将当前事务的版本号保存为复制行的 DB_TRX_ID,并更新原行的 DB_ROLL_PT 为当前事务版本号。所以数据库的数据会变成下面这样:

id name age DB_TRX_ID DB_ROLL_PT
1 李四 11 1 3
1 李四2 11 3 NULL

然后事务 A 再执行查询操作。

begin; # 事务ID = 2
select * from my_test;
+----+--------+------+
| id | name   | age  |
+----+--------+------+
|  1 | 李四   |   11 |
+----+--------+------+

select * from my_test;
+----+--------+------+
| id | name   | age  |
+----+--------+------+
|  1 | 李四   |   11 |
+----+--------+------+

由于第二行数据的 DB_TRX_ID = 3 大于当前事务ID,所以读取不到更新的数据。这样就保证了同一个事务里是可重复读的。因为读取的是快照数据。而 RC 级别下能够读取到第二行数据,是因为 RC 级别下的 select 操作读取的永远是最新的数据。没有对这两个值进行校验。快照读的快照数据就是说的这里的快照。而MVCC里的版本,说的就是DB_TRX_ID和DB_ROLL_PT的数值。如果存在多个事务,同一条数据就会存在多个版本号。所以叫多版本。

1.5 串行化(Serializable)

所有 SQL 全部进行加锁处理,读加读锁排他锁,写加写排他锁。这样就不会有并发的问题了。但是性能很差。

1.6 幻读

网上的大部分资料上都有提到过,MySQL 的 Innodb 引擎需要在 Serializable 隔离级别下才能够解决幻读问题。下面我们在 RR 级别下来试下。

开启事务A:

begin; # 事务ID = 5
select * from my_test;
+----+---------+------+
| id | name    | age  |
+----+---------+------+
|  1 | 李四2   |   11 |
+----+---------+------+
2 rows in set (0.00 sec)

开启事务B:

begin; # 事务ID = 6
select * from my_test;
+----+---------+------+
| id | name    | age  |
+----+---------+------+
|  1 | 李四2   |   11 |
+----+---------+------+
2 rows in set (0.00 sec)

两个事务里的查询结果是一样的,现在我们在事务ID = 3 的事务里插入一条数据,并提交:

begin; # 事务ID = 5
select * from my_test;
+----+---------+------+
| id | name    | age  |
+----+---------+------+
|  1 | 李四2   |   11 |
+----+---------+------+
2 rows in set (0.00 sec)

insert into my_test value(null,'王五',1);

commit;

根据MVCC的特性,我们先看下数据库的表结构。

插入数据之前:

id name age DB_TRX_ID DB_ROLL_PT
1 李四2 11 3 NULL

事务会读取 DB_TRX_ID 小于等于当前事务版本号,DB_ROLL_PT 为空或者大于当前事务版本号的数据。所以都能读到 id = 1 的数据,是没问题的,现在我们插入新的数据:

id name age DB_TRX_ID DB_ROLL_PT
1 李四2 11 3 NULL
2 王五 1 5 NULL

由于 id = 2 的 DB_TRX_ID = 5 小于 这两个事务,所以正常情况下事务 5 和事务 6 都会读取到最新的数据。这种情况就是幻读。我们可以说,事务 6 发生了幻读现象。

但是,我们在事务 6 里执行下查询语句发现并没有出现幻读现象:

begin; # 事务ID = 6
select * from my_test;
+----+---------+------+
| id | name    | age  |
+----+---------+------+
|  1 | 李四2   |   11 |
+----+---------+------+
2 rows in set (0.00 sec)

select * from my_test;
+----+---------+------+
| id | name    | age  |
+----+---------+------+
|  1 | 李四2   |   11 |
+----+---------+------+
2 rows in set (0.00 sec)

可见在MySQL的 RR级 别中,是解决了幻读的读问题的,上面的 select 是快照读,下面我们再看下当前读的场景。

首先我们需要插入几条数据。目前的表数据如下:

mysql> select * from my_test;
+----+---------+------+
| id | name    | age  |
+----+---------+------+
|  1 | 李四    |   11 |
|  2 | 张三    |   12 |
|  3 | 王五    |    1 |
+----+---------+------+
3 rows in set (0.00 sec)

然后我们对 age 字段加一下辅助索引:

alter table my_test add index idx_age(age);

然后开启两个事务

 beign; # 事务 ID = 7
 update my_test set age = 10 where age = 11;

执行更新数据但是并不提交,这个时候开启另外一个事务。

begin; #事务ID = 8
insert into my_test value(null,'赵六',5);

你会发现,事务8 阻塞在这里了。好像事务7对整张表都进行了加锁。我们执行 rollback命令将两个事务进行回滚。再执行下上面的过程。区别是修改下插入数据的 age 的数值。比如说把 5 改成 13,你会发现竟然执行成功了,没有阻塞。这个现象就是 Innodb 的 Next key 锁在起作用。Next key 锁定是 GAP lock (间隙锁)+ record lock (行锁)组合起来的。

Innodb 首先会将上面的数据分成几段:

(negative infinity,1]
(1,11]
(11,12]
(12,positive infinity)

事务7会使用 GAP 锁来锁定 (1,11] 这个区间。然后使用行锁锁定 11 这一行,所以你看到的现象就是有些 age 的值是可以插入成功的,因为没有落到 (1,11] 这个区间。需要注意的是。where 条件里的字段一定要有索引,因为没有索引,则这些字段也就没有排序,也就没有区间。除非该事务提交,否则其它事务无法插入任何数据。

行锁防止别的事务修改或删除,GAP锁防止别的事务新增,行锁和GAP锁结合形成的的Next-Key锁共同解决了RR级别在写数据时的幻读问题。

2.一致性&原子性

事务的一致性和原子性是通过 undo log实现的。可以通过 undo log 将事务回滚到之前的装。undo log 是一种逻辑日志。当 Innodb 执行回滚时,会对每个 insert 操作执行一次 delete 操作,对于每个 delete 操作执行一次 insert 操作,对于每个 update 操作都会执行一次相反的 update。这样就实现了事务的一致性和原子性。

此外,undo log 还用于实现 MVCC ,即 Innodb 中的 MVCC 是通过 undo log 来实现的 网上的大部分资料都会像上文中一样,用所谓的”隐藏列”来解释MVCC,其实这种解释是一种概念上的说明,Innodb 是通过 undo log 来真正实现的 MVCC。

由于 insert 操作只对当前事务可见,所以 Innodb 将 undo log 分为了 insert undo log 和 update undo log。其中 insert undo log 格式如下图:

20200117-1
  • next:记录了下一个 undo log 的开始位置。
  • type_cmpl:undo log 的类型,insert undo log 总为11
  • undo no::事务ID
  • table id:对应的表对象
  • start:记录的是 undo log 的开始位置

中间这部分是记录了所有主键的列和值。如果需要 rollback 的话就可以根据这些数据直接定位到具体的行进行操作就可以了。

update undo log 是记录了delete 和 update 两种操作的 undo log。其格式入下图所示:

20200117-2

这里的内容比 insert undo log 要多很多。

  • DATA_TRX_ID:用于 MVCC
  • DATA_ROLL_PTR:用于 MVCC
  • update vector:记录的是每个列的修改信息。包括修改前和修改后的值。

所以,当触发 MVCC 后,读取的数据其实是 undo log 里的数据。回滚的时候也是根据 update vector 里的数据执行的逆过程。

3.持久性

数据库的持久性是说事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。所以需要记录事务里的操作。能够在系统故障时恢复数据。这个功能就是通过 redo log 实现的。redo log又叫做 *重做日志文件 * 它由两部分组成一是重做日志缓冲(redo log buffer),是保存在内存里的,所以是易失的。二是重做日志文件(redo log file)是持久化的。与 undo log 不同,redo log 记录的内容不是逻辑日志,而是实际的值。

为了确保日志缓冲每次都能够持久化到日志文件中,所以每次执行 commit 的时候都会执行一次 fsync 操作,所以尽量不要在for循环内使用事务,这样会导致大量的 fsync 操作。而 fsync 操作的效率取决于磁盘的性能。for循环量大时,有可能会影响业务。尽量使用批量操作,一次 commit。

版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!
本文链接:https://zdran.com/20210117.html