2.4.1. 事务隔离级别是怎么实现的
InnoDB 引擎通过什么技术来保证事务的这四个特性的呢?
- 持久性是通过 redo log (重做日志)来保证的;
- 原子性是通过 undo log(回滚日志) 来保证的;
- 隔离性是通过 MVCC(多版本并发控制) 或锁机制来保证的;
- 一致性则是通过持久性+原子性+隔离性来保证;
MySQL 在「可重复读」隔离级别下,可以很大程度上避免幻读现象的发生,使用「序列化」隔离级别会影响性能,所以很少使用「序列化」隔离级别来避免幻读现象的发生。
「可重复读」通过两种方式很大程度避免了幻读:
- 快照读(普通select语句),通过MVCC方式解决幻读,该隔离级别下事务过程中的数据和事务启动时的数据是一致的,即使过程中有其他事务插入数据,也是查询不出来的,所以很好的避免了幻读问题。
- 当前读(select... for update等语句),通过 next-key lock(记录锁+间隙锁)方式解决幻读,当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。
四种隔离级别具体是如何实现的呢?
- 「读未提交」隔离级别的事务:可以读到未提交的事务修改的数据,所以直接读取最新的数据;
- 「序列化」隔离级别的事务:通过加读写锁的方式来避免并行访问;
- 「读提交」和「可重复读」隔离级别的事务:通过 Read View 来实现的,区别在于创建 Read View (可理解为数据快照)的时机不同,「读提交」隔离级别是在「每个语句执行前」都会重新生成一个 Read View,而「可重复读」隔离级别是「启动事务时」生成一个 Read View,然后整个事务期间都在用这个 Read View。
两种开启事务的命令,事务的启动时机是不同的:
- 执行了 begin/start transaction 命令后,执行了第一条 select 语句,才是事务真正启动的时机;
- 执行了 start transaction with consistent snapshot 命令,就会马上启动事务。
2.4.1.1. Read View 在 MVCC 里如何工作的?
Read View有4个重要的字段:
- m_ids :创建 Read View 时,当前数据库中「活跃事务(启动了但没提交)」的事务 id 列表,注意是一个列表。
- min_trx_id :创建 Read View 时,当前数据库中「活跃事务」中事务 id 最小的事务,也就是 m_ids 的最小值。
- max_trx_id :不是 m_ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 + 1;
- creator_trx_id :指的是创建该 Read View 的事务的事务 id。
聚集索引中的两个隐藏列:
InnoDB 存储引擎的数据库表,它的聚集索引记录中都包含下面两个隐藏列:
- trx_id,保存对某条聚集索引记录改动的事务id
- roll_pointer,每次对某条聚集索引记录进行改动时,都会把旧版本的记录写入到 undo 日志中,然后这个隐藏列是个指针,指向每一个旧版本记录,可以通过它找到修改前的记录。
创建Read View 后,记录中的 trx_id 划分这三种情况:
一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况:
- 如果记录的 trx_id(事务id) 值小于 Read View 中的 min_trx_id(未提交事务) 值,表示这个版本的记录是在创建 Read View 前已经提交的事务生成的,所以该版本的记录对当前事务可见。
- 如果记录的 trx_id 值大于等于 Read View 中的 max_trx_id 值,表示这个版本的记录是在创建 Read View 后才启动的事务生成的,所以该版本的记录对当前事务不可见。
- 如果记录的 trx_id 值在 Read View 的 min_trx_id 和 max_trx_id 之间,需要判断 trx_id 是否在 m_ids (启动未提交事务)列表中:
-
- 如果记录的 trx_id 在 m_ids 列表中,该事务还没提交,所以该版本的记录对当前事务不可见。
- 如果记录的 trx_id 不在 m_ids列表中,表示该事务已经被提交,所以该版本的记录对当前事务可见。
这种通过「版本链」来控制并发事务访问同一个记录时的行为就叫 MVCC(多版本并发控制)。
2.4.1.2. 可重复读是如何工作的?(快照读如何避免幻读?)
可重复读隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View。
启动两个事务,A和B,当前有一条记录,事务B读取了这条记录,该记录的事务id比当前数据库中活跃事务的最小值还小,说明是在事务B启动前就提交了,所以能够查询到。
现在事务A修改了这条记录,MySQL记录相应的undo log,旧版本和新版本记录通过链表连接起来,将当前的事务id+1。
事务B第二次读取这条记录,当前的事务id在活跃事务的列表内,说明是未提交事务修改的所以不读取这个版本的记录,通过undo log找到小于事务B中活跃事务最小值的第一条记录。
事务A提交事务后,当前隔离级别是可重复读,事务B在读取记录时,还是基于启动事务时的Read View,所以读取的数据依然是事务A修改前的数据。
2.4.1.3. 读提交是如何工作的?
读提交隔离级别是在每次读取数据时,都会生成一个新的 Read View。
在读提交隔离级别下,事务每次读数据时都重新创建 Read View,在事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。
2.4.1.4. MySQL可重复读隔离级别,完全解决幻读了吗?
当前读如何避免幻读?
除了普通查询是快照读,其他都是当前读,比如 update、insert、delete,这些语句执行前都会查询最新版本的数据,然后再做进一步的操作。
Innodb 引擎为了解决「可重复读」隔离级别使用「当前读」而造成的幻读问题,就引出了间隙锁。
事务 A 执行了上面这条锁定读语句后,就对表中的记录加上 id 范围为 (2, +∞] 的 next-key lock(next-key lock 是间隙锁+记录锁的组合)。
事务 B 在执行插入语句的时候,判断到插入的位置被事务 A 加了 next-key lock,于是事物 B 会生成一个插入意向锁,同时进入等待状态,直到事务 A 提交了事务。这就避免了由于事务 B 插入新记录而导致事务 A 发生幻读的现象。
可重复读隔离级别下虽然很大程度上避免了幻读,但是还是没有能完全解决幻读。
对于快照读, MVCC 并不能完全避免幻读现象。当事务 A 更新了一条事务 B 插入的记录,这条新纪录的trx_id隐藏列的值会变成事务A的事务id,所以就发生幻读。
对于当前读,如果事务开启后,并没有执行当前读,而是先快照读,然后这期间如果其他事务插入了一条记录,那么事务后续使用当前读进行查询的时候,就会发现两次查询的记录条目就不一样了,所以就发生幻读。避免这类特殊场景下发生幻读的现象的话,就是尽量在开启事务之后,马上执行 select ... for update 这类当前读的语句,因为它会对记录加 next-key lock,从而避免其他事务插入一条新记录。