InnoDB的非锁定一致性读

Mon, Jun 7, 2021 阅读时间 1 分钟

非锁定一致性读

《MySQL的锁》这篇文章中我介绍了MySQL的锁定一致性读,即通过加锁的方式来保证读取数据的正确性。而为了提高效率,InnoDB还实现了非锁定的一致性读,即读取操作不需要等待行锁的释放就可以完成数据读取,提高读的效率。这也是InnoDB默认的读取方式。

实现方式

InnoDB通过版本控制的方式读取当前执行时间数据库中的快照数据,快照数据是指该行之前版本的数据,如上图所示,当要读取的数据被加了X锁时,可以不等到锁释放,而是直接读取数据旧版本的快照,即可实现一致性读。而在不同的事务隔离级别下,读取数据的版本也不同,这就是行多版本技术,由此带来的并发控制,就是多版本并发控制(Multi Version Concurrency Control,MVCC)

MVCC

MVCC是一种概念,很多数据库都有自己的实现,而InnoDB的实现方式是结合自己的undo log和在表中增加了两个隐藏的字段实现的。

undo log

用于记录数据被修改的历史日志,当行被修改时,相反的操作会被写入,undo log是逻辑日志,它的作用就是事务回滚时的数据恢复还原,以及MVCC。

根据行为的不同,undo log分成insert undo log和update undo log。

  • insert undo log就是在insert操作中产生的undo log,由于insert操作的行记录只对本事务可见,所以insert undo log可以在事务提交后就删除。
  • update undo log是在update和delete操作中产生的undo log,因为这样的操作对已有的行造成了影响,所以会在事务提交后,也不能删除,后续会在确保了版本链的数据不会被引用后由InnoDB的purge线程清理(这方面可以查看另一篇InnoDB特性的文章)。

两个隐藏字段

TRX_ID:全局事务ID,即transaction_id,在每个事务开始时被分配,全局递增,在MVCC中可以把它理解为版本号,每当一个事务操作了一行,那么这行的版本就会更新。

DB_ROLL_PTR:行的回滚指针,即指向该行当前版本数据的上一个版本在undo log的指针,通过这个指针,InnoDB就可以从undo log上找到这行数据历史版本的快照。

MVCC的过程

Read-View

MVCC中的快照读Read-View是一个数据结构,在事务执行过程中被创建,它主要包含以下4个字段:

  • m_ids:当前活跃的事务ID集合
  • min_trx_id:最小活跃事务编号
  • max_trx_id:预分配事务编号,当前最大事务编号+1
  • creator_trx_id:创建此Read-View的事务ID

InnoDB中只有Read CommittedRepeatable Read两个隔离等级下,可以使用MVCC实现非锁定的一致性读,方式都是一样的,只不过是根据隔离等级的要求,读取的数据版本不同。

Read Committed隔离等级:可以读取所有已经提交的事务,所以可以读取行的最新事务版本

Repeatable Read隔离等级:要求可重复读,所以要读取到这行数据在该事务开始时的那个版本

注意:上面所说的读取都是执行Select语句的快照读,而不是写入语句或者加锁的当前读!

RC隔离级别下的MVCC过程

RC级别下在事务中每执行一次Select语句,就会生成一个Read-View对象,比如下图:

每一次的select语句的快照读,都会根据read-view,以及对应隔离级别下的访问规则,获取应该得到的数据。会从当前的这行数据开始判断能不能访问,不能访问就要根据DB_ROLL_PTR从undo log中找到上一个版本再继续判断。是否可访问的判断规则如下:

  1. read-view的creator_trx_id是否等于要访问行的trx_id?

    如果是,就证明是自己这个事务改的这一行,自己改的要认,所以这行数据可以访问。否则需要继续向下判断。

  2. 判断行的trx_id是否小于read-view的min_trx_id?

    如果是,就说明这一行当前没有事务在访问,并且上一个访问它的事务已经提交了,所以这行数据就可以访问。否则就要继续向下判断。

  3. 判断行的trx_id是否大于max_trx_id?

    如果是,就说明这一行在read-view生成之后又被另一个新事务访问了,那么这行数据就不能被访问,需要从undo log中找到上一个版本再从规则1开始判断。否则就还可以继续向下判断。

  4. 判断trx_id是否在min_trx_id和max_trx_id之间?

    由于2、3的访问都过了,所以这里一定会满足,此时就要判断trx_id是否在m_ids中,如果是就说明这一行还有其他事务在访问,不能被访问,需要从undo log找到上一个版本再从规则1开始判断;如果不在,证明这一行的事务已经被提交了,那就可以访问。

RR隔离级别下的MVCC过程

由于RR隔离级别要求事务中只能读取到事务一开始时已经提交的事务,要满足可重复读的条件,所以RR级别下,只会在第一次执行Select语句从而发生快照读时,生成一次read-view对象,后续的每一次的快照读,都复用这个read-view,如下图:

由于RR级别的隔离要求,在检查一个行的版本时,需要保证行的trx_id要小于等于creator_trx_id,并且trx_id不能在m_ids中。即只能读到比自己版本小或者是自己版本的,并且是已提交过的版本,这样才能保证可以重复读。

关于幻读问题

MVCC不能完全解决RR隔离级别下的幻读现象!虽然read-view是复用的,但是当一个事务中两次快照读之间存在当前读,都会导致read-view重新生成,就会导致幻读的产生(由于MVCC在RR级别下的规则,不可重复读问题不会产生),要完全解决该问题,需要通过间隙锁临键锁来解决((锁相关的内容可以查看MySQL的锁这篇文章,幻读问题可以查看MySQL的事务这篇文章)

快照读和当前读的说明

快照读就是执行select语句时的读取数据的方式。

当前读是在执行insert、update、delete、select … for update和select … lock in share mode时进行数据读取的方式。

快照读可以读到旧版本的数据,而当前读必须要获取最新的版本的数据。所以只有快照读时,才能用到MVCC;如果是当前读,就要通过InnoDB的锁机制来读取,当前读是一定要加锁的。

MVCC可以说是一个基于乐观锁的实现。我们主要讨论的也是在行被加了X锁时可以在不阻塞的情况下依然读取到正确的数据。而如果是并发写的操作,那么由于写操作都是当前读,在RR隔离级别下就可能会出现写失败的问题。

有一个典型的例子可以说明这个问题:

如下,我们有如下的表,表中有四行数据他们的id字段和c字段都是一样的:

+----+----+
| id | c  |
+----+----+
|  1 |  1 |
|  2 |  2 |
|  3 |  3 |
|  4 |  4 |
+----+----+

然后我们开启一个事务A,执行select语句后该事务真正开启:

事务A 事务B
begin;
select * from t;
update t set c=5 where id=c;
update t set c=0 where id=c;
select * from t;

然后开启一个事务B,执行update操作把所有c值都变成5并立刻提交(必须立刻提交,如果不提交,则update的行都会被锁住,事务A中的update语句会被阻塞),然后我们在事务A中执行update语句,给所有的c值都变成0,此时A中的操作会不成功,显示修改了0个row:

但当我们在事务A中再次执行select语句时,得到的结果却依然是:

+----+----+
| id | c  |
+----+----+
|  1 |  1 |
|  2 |  2 |
|  3 |  3 |
|  4 |  4 |
+----+----+

发现事务A中出现了吊诡的一幕,明明通过select语句能看到where条件是满足的,但却无法完成修改。原因就是在事务B中所有行都被修改了,而事务A中的update 操作是当前读,只能读取最新的数据,然后再写,而最新的数据又是不满足条件的,所以就会造成修改了0个row的问题,RR级别又会保证可重复读,所以无论读多少次,都依然看起来是满足条件的。

解决方式也很简单,可以当失败时再重新起一个事务执行原本的操作(失败的判断依据是affected_rows是不是等于预期的值,以防止出现这种情况)。