持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第7天,点击查看活动详情
面试官:MVCC你有了解过吗?谈谈你的理解
Undo Log版本链
每条数据其实都有两个隐藏字段,一个是trx_id,一个是roll_pointer,这个trx_id就是最近一次更新这条数据的事务id,roll_pointer就是指向你了你更新这个事务之前生成的undo log。
现在假设有一个事务A(id=50),插入了一条数据,插入的这条数据的值是值A,因为事务A的id是50,所以这条数据的txr_id就是50,roll_pointer指向一个空的undo log,因为之前这条数据是没有的。
接着假设有一个事务B跑来修改了一下这条数据,把值改成了值B,事务B的id是58,那么此时更新之前会生成一个undo log记录之前的值,然后会让roll_pointer指向这个实际的undo log回滚日志。
事务B修改了值为值B,此时表里的那行数据的值就是值B了,那行数据的txr_id就是事务B的id,也就是58,roll_pointer指向了undo log,这个undo log就记录你更新之前的那条数据的值。所以大家看到roll_pointer指向的那个undo log,里面的值是值A,txr_id是50,因为undo log里记录的这个值是事务A插入的,所以这个undo log的txr_id就是50。
接着假设事务C又来修改了一下这个值为值C,他的事务id是69,此时会把数据行里的txr_id改成69,然后生成一条undo log,记录之前事务B修改的那个值。数据行里的值变成了值C,txr_id是事务C的id,也就是69,然后roll_pointer指向了本次修改之前生成的undo log,也就是记录了事务B修改的那个值,包括事务B的id,同时事务B修改的那个undo log还串联了最早事务A插入的那个undo log,过程很清晰明了。
先不管多个事务并发执行是如何执行的,起码先搞清楚一点,就是多个事务串行执行的时候,每个人修改了一行数据,都会更新隐藏字段txr_id和roll_pointer,同时之前多个数据快照对应的undo log,会通过roll_pinter指针串联起来,形成一个重要的版本链!
ReadView机制
执行一个事务的时候,就会生成一个ReadView,里面比较关键的东西有4个:
- 一个是m_ids,这个就是说此时有哪些事务在MySQL里执行还没提交的;
- 一个是min_trx_id,就是m_ids里最小的值;
- 一个是max_trx_id,这是说mysql下一个要生成的事务id,就是最大事务id;
- 一个是creator_trx_id,就是你这个事务的id;
假设原来数据库里就有一行数据,很早以前就有事务插入过了,事务id是32,他的值就是初始值。接着呢,此时两个事务并发过来执行了,一个是事务A(id=45),一个是事务B(id=59),事务B是要去更新这行数据的,事务A是要去读取这行数据的值的。
现在事务A直接开启一个ReadView,这个ReadView里的m_ids就包含了事务A和事务B的两个id,45和59,然后min_trx_id就是45,max_trx_id就是60,creator_trx_id就是45,是事务A自己。这个时候事务A第一次查询这行数据,会走一个判断,就是判断一下当前这行数据的txr_id是否小于ReadView中的min_trx_id,此时发现txr_id=32,是小于ReadView里的min_trx_id就是45的,说明你事务开启之前,修改这行数据的事务早就提交了,所以此时可以查到这行数据。
接着事务B开始动手了,他把这行数据的值修改为了值B,然后这行数据的txr_id设置为自己的id,也就是59,同时roll_pointer指向了修改之前生成的一个undo log,接着这个事务B就提交了。
这个时候事务A再次查询,此时查询的时候,会发现一个问题,那就是此时数据行里的txr_id=59,那么这个txr_id是大于ReadView里的min_txr_id(45),同时小于ReadView里的max_trx_id(60)的,说明更新这条数据的事务,很可能就跟自己差不多同时开启的,于是会看一下这个txr_id=59,是否在ReadView的m_ids列表里?果然,在ReadView的m_ids列表里,有45和59两个事务id,直接证实了,这个修改数据的事务是跟自己同一时段并发执行然后提交的,所以对这行数据是不能查询的!
那么既然这行数据不能查询,那查什么呢?简单,顺着这条数据的roll_pointer顺着undo log日志链条往下找,就会找到最近的一条undo log,trx_id是32,此时发现trx_id=32,是小于ReadView里的min_trx_id(45)的,说明这个undo log版本必然是在事务A开启之前就执行且提交的。好了,那么就查询最近的那个undo log里的值好了,这就是undo log多版本链条的作用,他可以保存一个快照链条,让你可以读到之前的快照值。
多个事务并发执行的时候,事务B更新的值,通过这套ReadView+undo log日志链条的机制,就可以保证事务A不会读到并发执行的事务B更新的值,只会读到之前最早的值。
接着假设事务A自己更新了这行数据的值,改成值A,trx_id修改为45,同时保存之前事务B修改的值的快照。此时事务A来查询这条数据的值,会发现这个trx_id=45,居然跟自己的ReadView里的creator_trx_id(45)是一样的,说明什么?说明这行数据就是自己修改的啊!自己修改的值当然是可以看到的了!
接着在事务A执行的过程中,突然开启了一个事务C,这个事务的id是78,然后他更新了那行数据的值为值C,还提交了。这个时候事务A再去查询,会发现当前数据的trx_id=78,大于了自己的ReadView中的max_trx_id(60),此时说明什么?说明是这个事务A开启之后,然后有一个事务更新了数据,自己当然是不能看到的了!此时就会顺着undo log多版本链条往下找,自然先找到值A自己之前修改的过的那个版本,因为那个trx_id=45跟自己的ReadView里的creator_trx_id是一样的,所以此时直接读取自己之前修改的那个版本。
通过undo log多版本链条,加上你开启事务时候生产的一个ReadView,然后再有一个查询的时候,根据ReadView进行判断的机制,你就知道你应该读取哪个版本的数据。而且他可以保证你只能读到你事务开启前,别的提交事务更新的值,还有就是你自己事务更新的值。假如说是你事务开启之前,就有别的事务正在运行,然后你事务开启之后 ,别的事务更新了值,你是绝对读不到的!或者是你事务开启之后,比你晚开启的事务更新了值,你也是读不到的!
Read Committed隔离级别
RC隔离级别,实际上意思就是说你事务运行期间,只要别的事务修改数据还提交了,你就是可以读到人家修改的数据的,所以是会发生不可重复读的问题,包括幻读的问题,都会有的。
那么所谓的ReadView机制,他是基于undo log版本链条实现的一套读视图机制,他意思就是说你事务生成一个ReadView,然后呢,如果是你事务自己更新的数据,自己是可以读到的,或者是在你生成ReadView之前提交的事务修改的值,也是可以读取到的。但是如果是你生成ReadView的时候,就已经活跃的事务,在你生成ReadView之后修改了数据,接着提交了,此时你是读不到的,或者是你生成ReadView以后再开启的事务修改了数据,还提交了,此时也是读不到的。
其实这里的一个非常核心的要点在于,当你一个事务设置他处于RC隔离级别的时候,他是每次发起查询,都重新生成一个ReadView!
首先假设我们的数据库里有一行数据,是事务id=50的一个事务之前就插入进去的,然后现在呢,活跃着两个事务,一个是事务A(id=60),一个是事务B(id=70)。现在的情况就是,事务B发起了一次update操作,更新了这条数据,把这条数据的值修改为了值B,所以此时数据的trx_id会变为事务B的id=70,同时会生成一条undo log,由roll_pointer来指向。
这个时候,事务A要发起一次查询操作,此时他一发起查询操作,就会生成一个ReadView,此时ReadView里的min_trx_id=60,max_trx_id=71,creator_trx_id=60。这个时候事务A发起查询,发现当前这条数据的trx_id是70。也就是说,属于ReadView的事务id范围之间,说明是他生成ReadView之前就有这个活跃的事务,是这个事务修改了这条数据的值,但是此时这个事务B还没提交,所以ReadView的m_ids活跃事务列表里,是有[60, 70]两个id的,所以此时根据ReadView的机制,此时事务A是无法查到事务B修改的值B的。接着就顺着undo log版本链条往下查找,就会找到一个原始值,发现他的trx_id是50,小于当前ReadView里的min_trx_id,说明是他生成ReadView之前,就有一个事务插入了这个值并且早就提交了,因此可以查到这个原始值。
接着,咱们假设事务B此时就提交了,好了,那么提交了就说明事务B不会活跃于数据库里了,是不是?可以的,事务B现在提交了。那么按照RC隔离级别的定义,事务B此时一旦提交了,说明事务A下次再查询,就可以读到事务B修改过的值了,因为事务B提交了。那么到底怎么让事务A能够读到提交的事务B修改过的值呢?
很简单,就是让事务A下次发起查询,再次生成一个ReadView。此时再次生成ReadView,数据库内活跃的事务只有事务A了,因此min_trx_id是60,mac_trx_id是71,但是m_ids这个活跃事务列表里,只会有一个60了,事务B的id=70不会出现在m_ids活跃事务列表里了。此时事务A再次基于这个ReadView去查询,会发现这条数据的trx_id=70,虽然在ReadView的min_trx_id和max_trx_id范围之间,但是此时并不在m_ids列表内,说明事务B在生成本次ReadView之前就已经提交了。那么既然在生成本次ReadView之前,事务B就已经提交了,就说明这次你查询就可以查到事务B修改过的这个值了,此时事务A就会查到值B。
RC隔离级别如何实现的,他的关键点在于每次查询都生成新的ReadView,那么如果在你这次查询之前,有事务修改了数据还提交了,你这次查询生成的ReadView里,那个m_ids列表当然不包含这个已经提交的事务了,既然不包含已经提交的事务了,那么当然可以读到人家修改过的值了。
这就是基于ReadView实现RC隔离级别的原理,实际上,基于undo log多版本链条以及ReadView机制实现的多事务并发执行的RC隔离级别、RR隔离级别,就是数据库的MVCC多版本并发控制机制。他本质是协调你多个事务并发运行的时候,并发的读写同一批数据,此时应该如何协调互相的可见性。
Repeatable Read隔离级别
在MySQL中让多个事务并发运行的时候能够互相隔离,避免同时读写一条数据的时候有影响,是依托undo log版本链条和ReadView机制来实现的。
RR级别下,你这个事务读一条数据,无论读多少次,都是一个值,别的事务修改数据之后哪怕提交了,你也是看不到人家修改的值的,这就避免了不可重复读的问题。同时如果别的事务插入了一些新的数据,你也是读不到的,这样你就可以避免幻读的问题。
首先我们还是假设有一条数据是事务id=5的一个事务插入的,同时此时有事务A和事务B同时在运行,事务A的id是60,事务B的id是70。
这个时候,事务A发起了一个查询,他就是第一次查询就会生成一个ReadView,此时ReadView里的creator_trx_id是60,min_trx_id是60,max_trx_id是71,m_ids是[60, 70]。
这个时候事务A基于这个ReadView去查这条数据,会发现这条数据的trx_id为50,是小于ReadView里的min_trx_id的,说明他发起查询之前,早就有事务插入这条数据还提交了,所以此时可以查到这条原始值的。
接着就是事务B此时更新了这条数据的值为值B,此时会修改trx_id为70,同时生成一个undo log,而且关键是事务B此时他还提交了,也就是说此时事务B已经结束了。这个时候思考一个问题,ReadView中的m_ids此时还会是60和70吗?那必然是的,因为ReadView一旦生成了就不会改变了,这个时候虽然事务B已经结束了,但是事务A的ReadView里,还是会有60和70两个事务id。他的意思其实就是,在你事务A开启查询的时候,事务B当时是在运行的,就是这个意思。
那么好,接着此时事务A去查询这条数据的值,他会惊讶的发现此时数据的trx_id是70了,70一方面是在ReadView的min_trx_id和max_trx_id的范围区间的,同时还在m_ids列表中。说明起码是事务A开启查询的时候,id为70的这个事务B还是在运行的,然后由这个事务B更新了这条数据,所以此时事务A是不能查询到事务B更新的这个值的,因此这个时候继续顺着指针往历史版本链条上去找。
接着事务A顺着指针找到下面一条数据,trx_id为50,是小于ReadView的min_trx_id的,说明在他开启查询之前,就已经提交了这个事务了,所以事务A是可以查询到这个值的,此时事务A查到的是原始值。你事务A多次读同一个数据,每次读到的都是一样的值,除非是他自己修改了值,否则读到的一直会一样的值。不管别的事务如何修改数据,事务A的ReadView始终是不变的,他基于这个ReadView始终看到的值是一样的!
接着我们来看看幻读的问题他是如何解决的。假设现在事务A先用select * from x where id>10来查询,此时可能查到的就是一条数据,而且读到的是这条数据的原始值的那个版本。
现在有一个事务C插入了一条数据,然后提交了。
接着,此时事务A再次查询,此时会发现符合条件的有2条数据,一条是原始值那个数据,一条是事务C插入的那条数据,但是事务C插入的那条数据的trx_id是80,这个80是大于自己的ReadView的max_trx_id的,说明是自己发起查询之后,这个事务才启动的,所以此时这条数据是不能查询的。因此事务A本次查询,还是只能查到原始值一条数据。
所以可以看到,在这里,事务A根本不会发生幻读,他根据条件范围查询的时候,每次读到的数据都是一样的,不会读到人家插入进去的数据,这都是依托ReadView机制实现的!
MySQL实现MVCC机制的时候,是基于undo log多版本链条+ReadView机制来做的,默认的RR隔离级别,就是基于这套机制来实现的,依托这套机制实现了RR级别,除了避免脏写、脏读、不可重复读,还能避免幻读问题。这就是数据库的隔离机制以及底层的原理。