查看原文
其他

关于MySQL的酸与MVCC和面试官小战三十回合

是Yes呀 yes的练级攻略 2022-09-04

我,小Y。

此刻,正坐在办公室里等待面试,心情xue微有点忐忑,不知道待会儿老面试官经不经得住我的折磨。

只见一抹光亮闪过,面试官推门而入,我抬头望去,强者的气息铺面而来,没错是那味儿。

看到面试官头上那“傲然矗立”的头发,脑海中止不住幻想他在无数个凌晨于电脑前挑灯夜码的高大形象,一种敬佩感油然而生, 竟忍不住站起来给他敬了个礼。

面试官:有病?

我:没没没,我谢顶反应综合征犯了,面试官好,我是小 Y ,请多多指教。

面试官:哦哦,确实是有病啊,没事,记得吃药就行。我看你简历写你 MySQL 挺懂的,那我先问问你 MySQL 吧。

我:好嘞,您请。

面试官:你知道什么是 MySQL 的酸吗?

这一来就这么猛的吗?脑海中一顿搜索,只能想起张含韵的我喜欢酸的甜这就是真的我之《酸酸甜甜就是我》,算了蒙一个。

我:事务?

面试官:哟,最近好多谐音梗,我特意玩了个英语单词短语梗,脑子转的挺快啊小伙子。

酸,英文 acid,说的就是事务!这都蒙对了,等下就去买彩票!趁这个机会再表现一下!

我:是啊,国外的人就有拼凑单词的习惯,其实事务主要是为了实现 C ,也就是一致性,具体是通过AID,即原子性、隔离性和持久性来达到一致性的目的,所以这四个不应该相提并论,但是他们就想拼成单词,就把它们排好序搞在一起来念。

嘿嘿,这个B装的我有点舒服,果然面试官有点惊讶。

面试官:可以呀,那你知道 MVCC 吧?

我:知道,Multi-Version  Concurrency Control (多版本并发控制)。

面试官:能先简短的解释下什么是 MVCC 吗?

我:多版本并发控制,其实指的是一条记录会有多个版本,每次修改记录都会存储这条记录被修改之前的版本,多版本之间串联起来就形成了一条版本链。

这样不同时刻启动的事务可以无锁地获得不同版本的数据(普通读)。此时读(普通读)写操作不会阻塞,写操作可以继续写,无非就是多加了一个版本,历史版本记录可供已经启动的事务读取。

(为保持简短,简化了SQL语句,下文也同样简化)

面试官:那你知道事务四种隔离级别吧?

我:读未提交、读已提交、可重复读、可串行化。

面试官:MVCC 用来实现哪几个隔离级别?

我:用来实现读已提交和可重复读。首先隔离级别如果是读未提交的话,直接读最新版本的数据就行了,压根就不需要保存以前的版本。可串行化隔离级别事务都串行执行了,所以也不需要多版本,因此 MVCC 是用来实现读已提交和可重复读的。

面试官:那为什么需要 MVCC ?如果没有 MVCC 会怎样?

我:如果没有 MVCC 读写操作之间就会冲突。想象一下有一个事务1正在执行,此时一个事务2修改了记录A,还未提交,此时事务1要读取记录A,因为事务2还未提交,所以事务1无法读取最新的记录A,不然就是发生脏读的情况,所以应该读记录A被事务2修改之前的数据,但是记录A已经被事务2改了呀,所以事务1咋办?只能用锁阻塞等待事务2的提交,这种实现叫 LBCC(Lock-Based Concurrent Control)。

如果有多版本的话,就不一样了。事务2修改的记录 A,还未提交,但是记录 A 被修改之前的版本还在,此时事务1就可以读取之前的版本数据,这样读写之间就不会阻塞啦,所以说 MVCC 提高了事务的并发度,提升数据库的性能。

面试官:你对这个多版本有没有什么别的理解?

我:(面试官要开始操作我了吗?不过就这,我早有准备!)有点个人的小理解(假装谦虚)。其实这个多版本不是很准确,只是为了便于理解或者说展现出来像多版本的样子而已。

实际上 InnoDB 不会真的存储了多个版本的数据,只是借助 undolog 记录每次写操作的反向操作,所以索引上对应的记录只会有一个版本,即最新版本。只不过可以根据 undolog 中的记录反向操作得到数据的历史版本,所以看起来是多个版本。

面试官:那你能详细的说下 MVCC 是如何实现的吗?

我:您听好啦。

拿上面的insert (1,XX)这条语句举例,成功插入之后数据页的记录上不仅存储 ID 1,name XX,还有 trx_id 和 roll_pointer 这两个隐藏字段:

  • trx_id:当前事务ID。
  • roll_pointer:指向 undo log 的指针。

从图中可以得知此时插入的事务ID是1,此时插入会生成一条 undolog ,并且记录上的 roll_pointer 会指向这条 undolog ,而这条 undolog  是一个类型为TRX_UNDO_INSERT_REC的 log,代表是 insert 生成的,里面存储了主键的长度和值(还有其他值,不提)。

所以 InnoDB 可以根据 undolog  里的主键的值,找到这条记录,然后把它删除来实现回滚(复原)的效果。因此可以简单地理解 undolog 里面存储的就是当前操作的反向操作,所以认为里面存了个delete 1 就行。

此时事务1提交,然后另一个 ID 为 5 的事务再执行 update NO where id 1 这个语句,此时的记录和 undolog 就如下图所示:

没错,之前 insert 产生的 undolog 没了,insert 的事务提交了之后对应的 undolog 就回收了,因为不可能有别的事务会访问比这还要早的版本了,访问插入之前的版本?访问个寂寞吗?

而 update 产生的 undolog 不一样,它的类型为 TRX_UNDO_UPD_EXIST_REC

此时事务 5 提交,然后另一个 ID 为 11 的事务执行update Yes where id 1 这个语句,此时的记录和 undolog 就如下图所示:

没错,update 产生的 undolog 不会马上删除,因为可能有别的事务需要访问之前的版本,所以不能删。这样就串成了一个版本链,可以看到记录本身加上两条 undolog,这条 id 为 1 的记录共有三个版本。

版本链搞清楚了,这时候还需要知道一个概念 readView,这个 readView 就是用来判断哪个版本对当前事务可见的,这里有四个概念:

  • creator_trx_id,当前事务ID。
  • m_ids,生成 readView 时还活跃的事务ID集合,也就是已经启动但是还未提交的事务ID列表。
  • min_trx_id,当前活跃ID之中的最小值。
  • max_trx_id,生成 readView 时 InnoDB 将分配给下一个事务的 ID 的值(事务 ID 是递增分配的,越后面申请的事务ID越大)

对于可见版本的判断是从最新版本开始沿着版本链逐渐寻找老的版本,如果遇到符合条件的版本就返回

判断条件如下:

  • 如果当前数据版本的 trx_id ==  creator_trx_id 说明修改这条数据的事务就是当前事务,所以可见。
  • 如果当前数据版本的 trx_id < min_trx_id,说明修改这条数据的事务在当前事务生成 readView 的时候已提交,所以可见。
  • 如果当前数据版本的 trx_id 在 m_ids 中,说明修改这条数据的事务此时还未提交,所以不可见。
  • 如果当前数据版本的 trx_id >= max_trx_id,说明修改这条数据的事务在当前事务生成 readView 的时候还未启动,所以不可见(结合事务ID递增来看)。

来看一个简单的案例,练一练上面的规则。

读已提交隔离级别下的MVCC

现在的隔离级别是读已提交

假设此时上文的事务1已经提交,事务 5 已经执行,但还未提交,此时有另一个事务在执行update YY where id 2,也未提交,它的事务 ID 为 6,且也是现在最大的事务 ID。

现在有一个查询开启了事务,语句为select name where id 1,那么这个查询语句:

  • 此时 creator_trx_id 为 0,因为一个事务只有当有修改操作的时候才会被分配事务 ID。
  • 此时 m_ids 为 [5,6],这两个事务都未提交,为活跃的。
  • 此时 min_trx_id,为 5。
  • 此时 max_trx_id,为 7,因为最新分配的事务 ID 为 6,那么下一个就是7,事务 ID 是递增分配的。

由于查询的是 ID 为 1 的记录,所以先找到 ID 为 1 的这条记录,此时的版本如下:

和上面的图一样

此时最新版本的记录上 trx_id 为 5,不比 min_trx_id 小,在 m_ids 之中,表明还是活跃的,未提交,所以不可访问,根据 roll_pointer 找到上一个版本。

于是找到了图上的那条 undolog,这条log上面记录的 trx_id 为 1,比 min_trx_id 还小,说明在生成 readView 的时候已经提交,所以可以访问,因此返回结果 name 为 XX。

然后事务 5 提交

此时再次查询 select name where id 1,这时候又会生成新的 readView

  • 此时 creator_trx_id 为 0,因为还是没有修改操作。
  • 此时 m_ids 为 [6],因为事务5提交了。
  • 此时 min_trx_id,为 6。
  • 此时 max_trx_id,为 7,此时没有新的事务申请。

同样还是查询的是 ID 为 1 的记录,所以还是先找到 ID 为 1 的这条记录,此时的版本如下(和上面一样,没变):

此时最新版本的记录上 trx_id 为 5,比 min_trx_id 小,说明事务已经提交了,是可以访问的,因此返回结果 name 为 NO。

这就是读已提交的 MVCC 操作,可以看到一个事务中的两次查询得到了不同的结果,所以也叫不可重复读。

可重复读隔离级别下的MVCC

现在的隔离级别是可重复读

可重复读和读已提交的 MVCC 判断版本的过程是一模一样的,唯一的差别在生成 readView 上

上面的读已提交每次查询都会重新生成一个新的 readView ,而可重复读在第一次生成  readView 之后的所有查询都共用同一个 readView 。

也就是说可重复读只会在第一次 select 时候生成一个 readView ,所以一个事务里面不论有几次 select ,其实看到的都是同一个 readView 。

套用上面的情况,差别就在第二次执行select name where id 1,不会生成新的 readView,而是用之前的 readView,所以第二次查询时:

  • m_ids 还是为 [5,6],虽说事务 5 此时已经提交了,但是这个readView是在事务5提交之前生成的,所以当前还是认为这两个事务都未提交,为活跃的。
  • 此时 min_trx_id,为 5。

(对于判断过程有点卡顿的同学可以再拉上去看看,判断版本的过程和读已提交一致)。

所以在可重复级别下,两次查询得到的 name 都为 XX,所以叫可重复读

说完之后,我对面试官挑了挑眉。

面试官瞥了我一眼:可以,那按你这么说其实 undolog 算是热点资源,多个事务不就会争抢 undolog 了吗?

我:对呀,所以为了提高 undolog 的写入性能,每个事务都有属于自己的 undolog 页面链表,这样就提高了写入并发度啦,再细一点就是 insert 类型的 undolog 和 update 类型的 undolog 属于不同的链表。

面试官:还能细吗?

我:再细一点就是普通表和临时表各有一条 insert 类型的 undolog 和 update 类型的 undolog ,所以最多一个事务可以有四条 undolog 页面链表。

之所以分普通表和临时表是因为普通表的 undolog 写入是需要记录到redolog 中的需要保证崩溃恢复,而临时表则不需要记录,反正就是临时的。

面试官:对了,你上面说 insert 和 update ,那 delete 呢?

我:delete 其实是属于 update 的,不过分了好几种情况,反正 delete 只会给记录上打个标记,表明这条记录被删除了,不会马上删除这条记录,因为记录还得存着给别的事务作为版本链访问呢。

面试官:那这条被删除的记录就永远存在了?

我:不会的,后台有一个 purge 线程,如果探测出当前没有事务会访问这个记录了,就会把它真正的删除。

面试官:你这么细,应该没有女朋友的吧?

我:(???,不对,面试官应该没有人身攻击我,只是说我天天刻苦学习,没时间找女朋友,但是我还是有点不爽)没呢,面试官您头发这么多,应该也还没找到吧?

“我在仰望,月亮之上....”,此时面试官手机响起。

面试官:“喂,亲爱的,来了来了,马上下班了,待会老地方见哈。”那啥我有点事,你先回去吧。

我:(???小丑竟是我自己)好嘞好嘞。

面试官:对了,这一面还没结束,undolog看你挺熟的,下次详细问你,还有 MySQL 锁啊我都还没问,等通知下次再来吧。

我:(还给我布置家庭作业呢?)我一定回去好好准备准备,等待您的宠幸。

这老面试官可以,竟然没折磨到他,等着,下次 undolog 和  MySQL 锁我一定好好招待他!


我的一对一解答服务持续开放,不走知识星球直接私聊我。

个人微信:yes_oba

推荐阅读:《从根儿上理解 MySQL》

今天的分享到此结束,等我下篇哈,如果觉得文章不错。来个点赞、在看、分享三连哟!

我是yes,从一点点到亿点点,我们下篇见。

推荐阅读:

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存