性能文章>美团面试官:可重复读隔离级别实现原理是什么?(一文搞懂MVCC机制)>

美团面试官:可重复读隔离级别实现原理是什么?(一文搞懂MVCC机制)原创

4664610
大家好,我是tin,这是我的第26篇原创文章
还记得MySQL数据库事务都有哪些隔离级别么?如果忘记可以到这里重新温习:还傻傻搞不懂MySQL事务隔离级别么(图文并茂,保证你懂!)
 
今天主要说一说MySQL是如何实现可重复读隔离级别的, 话不多说,先上一个目录:
 

一、MVCC的实现原理

    1.1 什么是MVCC

    1.2 两个隐式列

    1.3 undo日志 

    1.4 Read View读视图

二、可重复读实现原理

    2.1 什么是可重复读

    2.2 基于多版本实现可重复读

    2.3 可重复读可以解决幻读么

三、结语

一、MVCC的实现原理

在讲可重复读隔离级别之前,必须先来了解MVCC的实现原理,因为它的实现依赖MVCC,而MVCC的实现又依赖undo log和 Read View,下面一一讲解。

1.1 什么是MVCC

MVCC,直译多版本并发控制,它的全称是Multi-Version Concurrency Control, 直白说就是在同一时刻同一条记录在系统中可以存在多个版本,这就是数据库的多版本并发控制

在MySQL InnoDB中,MVCC的实现主要是为了提高数据库并发性能,它能很好地处理MySQL的读写冲突,做到尽量不加锁。

 

比如现在我的账户表中有这样一条Tom的余额记录:

 

除了正常id,user_name,balance字段以外,其实还有两个隐藏字段,我已一并画出,后续会补充讲解。
如果同时有多个事务操作这行数据时,为了避免多事务并发场景下数据不一致问题的出现,InnoDB会通过一条 "链条"把每个事务的数据"快照版本"串联起来, 如下面这样:

每个事务启动的时候都会给自己分配一个事务ID(trx_id)并生成一个当时所有活跃事务的事务ID列表(其实就是Read View,下文有讲到)。

 

当一个事务操作更新一条记录后,会同时把当前事务ID更新到这条数据记录上(隐藏字段)。当下一个事务要操作这条记录时,会先比较trx_id和自己的事务ID大小,同时和自己维护的活跃事务列表ID比较,如果不能直接读取记录行的数据,那么会顺着undo日志的"链条"找到自己能用的数据版本,这就是MVCC

1.2 两个隐藏列

 

 

上文已经提到,MySQL每行记录都会有两个隐藏字段,一个是事务ID(trx_id),一个是回滚指针(roll_pointer)

 

trx_id :每次一个事务对某条聚簇索引记录改动时,都会把自己的事务id重新写到 trx_id 隐藏列。

 

roll_pointer :每次对某条聚簇索引记录进行改动时,都会把旧版本记录写入到 undo日志中,roll_pointer这个隐藏列是一个地址指针,通过它可以找到该聚簇索引记录历史修改信息。

1.3 undo 日志

 

undo 日志:又名undo log,也称回滚日志,它是 Innodb 存储引擎层生成的日志。在数据更新之前,MySQL就需要先把更新前的数据记录到 undo log 日志中,当事务回滚时,可以利用 undo log 来进行回滚。

比如现在Tom的账户余额有100,现在有一个事务需要把Tom的账户余额更新为300,大致的流程如下图:

undo log主要分为两种:

insert undo log:顾名思义,此代表执行insert语句时产生的undo log, 它只在事务回滚时需要,因为这种log只是对本事务可见,其他事务不可见,所以当事务提交后,这种类型的undo log就会被系统直接删除回收(也就是该undo log占用的undo页面链表被释放)。

update undo log:事务在进行update或者delete时产生的undo log; 不仅在事务回滚时需要,在快照读时也需要(也就是MVCC),所以不能在事务提交后马上删除,只在提交后放入undo log的链表,等待purge线程进行最后的删除。

1.4 Read View读视图

什么是Read View?

说白了,Read View就是事务进行快照读操作的时候产生的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统数据以及当前活跃事务的ID(就是启动了还没提交的事务)。

什么是当前读和快照读?

当前读:使用到当前读的场景有select lock in share mode(共享锁)、 select for update 、 update、insert、delete(排他锁)等,这些操作都是一种当前读,因为它需要读取记录的最新版本,而且读取时还有可能会通过加锁保证其他事务不能同时修改当前记录。

快照读:快照读一般指不加锁的select操作,当然如果MySQL数据库的事务隔离级别是串行隔离级别(什么是串行隔离级别?忘了可以重新看这里:还傻傻搞不懂MySQL事务隔离级别么(图文并茂,保证你懂!)),串行级别下的快照读会退化成当前读

一个Read View 包含以下四个字段:

 

creator_trx_id :指创建该 Read View 的事务的事务 id。

m_ids :指创建该 Read View 时数据库中所有活跃事务的事务 id 列表,这是一个列表,活跃事务则代表是已启动但未提交的事务

min_trx_id :指创建 Read View 时数据库中所有活跃事务中最小的事务 id,也就是 m_ids 中的最小值。

max_trx_id :指下一个要创建 Read View 的事务的事务 id,它并不是m_ids中的最大值,需要加以区分。

可重复读隔离级别下,Read View是在事务开始(begin)之后且执行第一条sql时创建,创建Read View的同时也就生成了一个新的事务id(直到commit结束),事务会依赖该 Read View保证查询结果保持不变直到该事务结束。

二、可重复读实现原理

2.1 什么是可重复读

可重复读(REPEATABLE READ),指在整个事务过程中该事务看到的记录,自始至终都是一样的。

 

比如现在有两个活跃事务,事务A和事务B,在事务B给Tom账户余额增加100并提交后,事务A能马上看得见么?如下图:

 

 

可重复读隔离级别下,事务A在提交前自始至终查到的值都必须一样,所以,也即使事务B更新了Tom的余额也一样。所以余额R1、R2都是100,当事务A提交后再查询(其实是新事务)才能查到新的值,所以R3是200。

之所以有这样的效果,是因为事务A一开始就创建好了Read View,一直到提交前都采用同一个Read View

2.2 基于多版本实现可重复读

有了以上对隐藏列undo logRead View的了解,要分析MVCC的实现原理就简单多了。

假如现在Tom的账户余额有100,当前该记录上的事务id是10。

 

现在MySQL系统有且只有两个活跃事务,两个事务同时在操作Tom的账户,分别为事务A和事务B。事务A在事务B前启动,但事务A的第一条sql执行前事务B也已启动

基于以上,事务A和事务B的Read View如下:

 

为什么两个事务的m_ids都是[11, 12]?因为前面已经说过,RR隔离级别下的Read View是事务内第一个sql语句时才会创建。

 

在事务 A 的 Read View 中,它的事务 id 是 11,由于是在事务 B 启动后才创建,所以此时活跃的事务id就有 11 和 12,活跃的事务 id 中最小的事务 id 11,下一个事务 id 是 13。

 

在事务 B 的 Read View 中,因为它后启动于事务A,所以它的事务 id 是 12,当然此时活跃的事务的事务 id 有 11 和 12,活跃的事务 id 中最小的事务 id 还是11,下一个事务 id 依然是 13。

 

我先把事务A和事务B内部操作流程画出来:

 

在时间线第③步,事务 A 查询Tom账户余额时,对应记录上的隐藏字段记录着事务id(trx_id) = 10,通过和事务 A 自己本身的 Read View 的 m_ids 字段对比可以发现,trx_id = 10并不在活跃事务的列表中,并且事务 A 的事务 id(11)比它还大,这就可以得出这条记录的事务(trx_id = 10)已经在事务A启动前提交过了,所以该记录直接对事务 A 可见,事务A查询Tom账户余额是100。

 

接着,在时间线第④步,事务B更新Tom账户余额,给Tom余额增加100,Tom的账户余额变成了200,这时 MySQL 会记录相应的历史版本undo log,并通过隐藏列roll_pointer指向该版本log的地址,形成版本链(更早的undo log则由新的一行undo log指向),如下图:

 

事务 B 修改了Tom的余额记录,Tom的余额记录就会变成200,同时事务id列(trx_id)记录的是事务B的事务id(12)

回滚指针列(roll_pointer)则指向新生成的历史版本数据,也就是undo log,这样,最新记录和旧版本记录就通过链表的方式串起来了。

接着,到时间线的第⑤步,事务A继续查询Tom的余额,这时会发现Tom余额记录上的trx_id 为 12,比自己的事务 id(11) 还大,并且12还在活跃事务列表(m_ids)中,也比下一个事务 id(max_trx_id)小

这时,事务 A 并不会读取这条记录,而是通过roll_pointer找到undo log上的旧版本数据,通过每个版本的roll_pointer一直往下找,直到找到 trx_id 等于或者小于自己的事务id且又不在自己Read View的m_ids列表的第一条记录

最终,事务 A 读取到 trx_id = 10 的记录,也即查询Tom账户余额还是100。

通过以上的方式MySQL实现了可重复读隔离级别,总而言之,通过多版本并发控制(MVCC),让一个事务在整个事务生命周期内读到的数据保持了一致。

2.3 可重复读可解决幻读么

之前一直都讲,MySQL InnoDB引擎默认的事务隔离级别是可重复读隔离级别,那么既然是这样,可重复读级别解决了幻读问题么?

答案当然是可重复读没有解决幻读问题

只是MySQL通过一种next-key lock的锁机制一定程度上避免了幻读问题。那MySQL又是如何避免幻读问题的呢?

写到最后发现这是一个需要长篇幅才能说清楚的问题,今天在这里就写啦,欢迎关注我的下一篇文章。

三、结语

我是tin,一个在努力让自己变得更优秀的普通工程师。自己阅历有限、学识浅薄,如有发现文章不妥之处,非常欢迎加我提出,我一定细心推敲并加以修改。

本文首发于公众号【看点代码再上班】,欢迎围观,第一时间获取最新文章。 

 

点赞收藏
看点代码再上班
请先登录,查看6条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

随机一门技术分享之Netty

随机一门技术分享之Netty

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

API性能调优
日常Bug排查-偶发性读数据不一致

日常Bug排查-偶发性读数据不一致

10
6