面试官:undo log 是如何实现 MVCC 的?
你知道的越多,不知道的就越多,业余的像一棵小草!
你来,我们一起精进!你不来,我和你的竞争对手一起精进!
编辑:业余草
推荐:https://www.xttblog.com/?p=5319
看过我历史文章的网友都知道,MySQL 有多种日志文件。其中 undo log 日志,经常会在面试过程中被问到,今天我们一起来聊聊它!
undo log 日志涉及到日志回滚,所以非常重要。建议大家收藏起来,慢慢的细品!
事务
多线程并发执行多个事务
说到事务,我相信大家都不陌生。都被它伤害过,而且还时不时的在面试中遇到事务失效的问题!
现在是一个多核时代,程序都充分的利用着多核 CPU。对于我们的业务系统去访问数据库而言,他往往都是多个线程并发的去执行多个事务的。对于数据库而言,它会有多个事务同时执行,可能这多个事务还会同时更新和查询同一条数据。那么如果是你来设计,你会如何来做?
MySQL 的开发者在设计之初就想到了这个问题,通过 undo log 等一系列巧妙的设计,让事务的功能得意完美的实现。
每个事务都会执行各种各样的增删改查语句,MySQL 底层会把磁盘上的数据页加载到 buffer pool 的缓存页里来,然后更新缓存页,记录 redo log 和 undo log,最终提交事务或者是回滚事务,多个事务会并发干上述一系列事情。
多客户端、多线程并发的执行多个事务,一般会带来的下面几个问题:
脏写
事务 A 和事务 B 同时在更新一条数据,事务 A 先把他更新为 A 值,事务 B 紧接着就把他更新为B 值。 此时事务 A 突然回滚了,那么就会用他的 undo log 日志去回滚。 事务 B 看到的场景,就是自己明明更新了,结果值却没了。
本质就是事务 B 去修改了事务 A 修改过的值,但是此时事务 A 还没提交,所以事务 A 随时会回滚,导致事务 B 修改的值也没了,这就是脏写。
和 Java 中的 ABA 问题类似;但完全不一样。就像一个随时可以撕毁合同的客户一样😊
脏读
事务 A 更新了一行数据的值为 A 值,此时事务 B 去查询了一下这行数据的 A 值 事务 B 此时拿到刚查出来的 A 值在做一些业务处理 事务 A 回滚了事务,导致刚才更新的 A 值没了,此时那行数据的值回滚为 A 更新之前的旧值 事务 B 此时再次查询那行数据的值,看到的居然此时是 A 更新之前的旧值
本质其实就是事务 B 去查询了事务 A 修改过的数据,但是此时事务 A 还没提交,所以事务 A 随时会回滚导致事务 B 再次查询就读不到刚才事务 A 修改的数据了!这就是脏读。
脏读又称无效数据的读出,值得注意的是,脏读一般是针对于 update 操作的。
无论是脏写还是脏读,都是因为一个事务去更新或者查询了另外一个还没提交的事务更新过的数据。因为另外一个事务还没提交,所以他随时可能会反悔会回滚,那么必然导致你更新的数据就没了,或者你之前查询到的数据就没了,这就是脏写和脏读两种坑爹场景。
不可重复读
说到脏读和和脏写,面试官还可能问到“不可重复读”。
假设我们有一个事务 A 开启了,在这个事务 A 里会多次对一条数据进行查询。
并且假设是事务 A 只能在事务 B 提交之后读取到他修改的数据(避免脏读)。
假设缓存页里一条数据原来的值是 A 值,此时事务 A 开启之后,第一次查询这条数据,读取到的就是 A 值 事务 B 更新了那行数据的值为 B 值,同时事务 B 立马提交了,然后事务 A 此时可是还没提交! 事务 A 执行期间第二次查询数据,此时查到的是事务 B 修改过的值,A 值,因为事务 B 已经提交了,所以事务 A 可以读到的
其实要说没问题也可以是没问题,毕竟事务 B 和事务 C 都提交之后,事务 A 多次查询查到他们修改的值,是 ok 的。
但是你要说有问题,也可以是有问题的,就是事务 A 可能第一次查询到的是 A 值,那么他可能希望的是在事务执行期间,如果多次查询数据,都是同样的一个 A 值,它希望这个 A 值是他重复读取的时候一直可以读到的!它希望这行数据的值是可重复读的!
这个问题简单来说,就是一个事务多次查询一条数据,结果每次读到的值都不一样,这个过程中可能别的事务会修改这条数据的值,而且修改值之后事务都提交了,结果导致人家每次查到的值都不一样,都查到了提交事务修改过的值,这就是所谓的不可重复读。
幻读
接下来,还有一个幻读,也是面试中必问的知识点。
事务 A,先发送一条 SQL 语句,他一开始查询出来了 10 条数据 事务 B 往表里插入了几条数据,而且事务 B 还提交了,此时多了 2 行数据出来 事务 A 此时第二次查询,还是那条 SQL,还是那个查询条件,但是查询出来的数据是 12 条
幻读指的就是你一个事务用一样的 SQL 多次查询,结果每次查询都会发现查到了一些之前没看到过的数
据注意,幻读特指的是你查询到了之前查询没看到过的数据!此时就说你是幻读了。
SQL标准四种事务隔离
这 4 种级别包括了:read uncommitted(读未提交)
,read committed(读已提交)
,repeatable read(可重复读)
,serializable(串行化)
。
事务级别 | 可能出现的问题 | 备注 |
---|---|---|
读未提交 | 脏读,不可重复读,幻读 | |
读已提交RC | 不可重复读,幻读 | |
可重复读 RR | 幻读 | |
串行化 | 性能降低 | 根本就不允许你多个事务并发执行 |
MySQL事务隔离
但是要注意的一点是,MySQL 默认设置的事务隔离级别,是 RR 级别的(一般其他的数据库是 RC)。并且 MySQL 的 RR 级别的语义跟 SQL 标准的 RR 级别不同的,毕竟 SQL 标准里规定 RR 级别是可以发生幻读的,但是 MySQL 的 RR 级别就避免了幻读。
MySQL 里执行的事务,默认情况下不会发生脏写、脏读、不可重复读和幻读的问题,事务的执行都是并行的,大家互相不会影响,我不会读到你没提交事务修改的值,即使你修改了值还提交了,我也不会读到的,即使你插入了一行值还提交了,我也不会读到的,总之,事务之间互相都完全不影响!
当然,要做到这么神奇和牛叉的效果,MySQL 是下了苦功夫的,后续我抽时间再给大家讲解 MySQL 里的 MVCC 机制,就是多版本并发控制隔离机制,依托这个「MVCC」机制,就能让 RR 级别避免不可重复读和幻读的问题。但是一般来说,真的其实不用修改这个级别,就用默认的 RR 其实就特别好,保证你每个事务跑的时候都没人干扰,何乐而不为呢。
也可以参考我历史文章中的 MVCC 部分的内容。
undo log
undo log 日志结构
一条日志必须得有自己的一个开始位置,这个没什么好说的,很多存储的数据都是这样设计的 那么主键的各列长度和值是什么意思?大家都知道,你插入一条数据,必然会有一个主键!如果你自己指定了一个主键,那么可能这个主键就是一个列,比如 id 之类的,也可能是多个列组成的一个主键,比如 id + name + age
三个字段组成的一个联合主键,也是有可能的。所以这个主键的各列长度和值,意思就是你插入的这条数据的主键的每个列,他的长度是多少,具体的值是多少。即使你没有设置主键,MySQL 自己也会给你弄一个row_id
作为隐藏字段,做你的主键(可见有主见是多么的重要😊)。接着是表 id,这个就不用多说了,你插入一条数据必然是往一个表里插入数据的,那当然得有一个表id,记录下来是往哪个表里插入的数据了。 undo log 日志编号,这个意思就是,每个 undo log 日志都是有自己的编号的。而在一个事务里会有多个 SQL 语句,就会有多个 undo log 日志,在每个事务里的 undo log 日志的编号都是从 0 开始的,然后依次递增。 undo log 日志类型,就是 TRX_UNDO_INSERT_REC
,insert 语句的 undo log 日志类型就是这个TRX_UNDO_INSERT_REC
。undo log 日志的结束位置,这个自然也不用多说了,它就是告诉你 undo log 日志结束的位置是多少。
undo log 版本链
每条数据其实都有两个隐藏字段,一个是trx_id
,一个是roll_pointer
,这个trx_id
就是最近一次更新这条数据的事务 id,roll_pointer
就是指向你了你更新这个事务之前生成的 undo log。
假设有一个事务 A(id=50),插入了一条数据,那么此时这条数据的隐藏字段以及指向的 undo log 如下图所示,插入的这条数据的值是值 A,因为事务 A 的 id 是 50,所以这条数据的 txr_id
就是 50,roll_pointer
指向一个空的 undo log,因为之前这条数据是没有的。事务 B 跑来修改了一下这条数据,把值改成了值 B,事务 B 的 id 是 58,那么此时更新之会生成一个 undo log 记录之前的值,然后会让 roll_pointer
指向这个实际的 undo log 回滚日志,这个 undo log 就记录你更新之前的那条数据的值。事务 C 又来修改了一下这个值为值 C,他的事务 id 是 69,此时会把数据行里的 txr_id
改成 69,然后生成一条 undo log,记录之前事务 B 修改的那个值。
「多个事务串行执行的时候,每个人修改了一行数据,都会更新隐藏字段txr_id
和roll_pointer
,同时之前多个数据快照对应的 undo log,会通过roll_pinter
指针串联起来,形成一个重要的版本链」!
ReadView
「生成readview时机」
RC 隔离级别:每次读取数据前,都生成一个 readview;
RR 隔离级别:在第一次读取数据前,生成一个 readview;
当我们执行一个事务的时候,就给你生成一个 ReadView,里面比较关键的东西有 4 个。
「readview四个主要元素」
m_ids
:表示在生成 readview 时,当前系统中活跃的读写事务 id 列表;min_trx_id
:表示在生成 readview 时,当前系统中活跃的读写事务中最小的事务 id,也就是m_ids
中最小的值;max_trx_id
:表示生成 readview 时,系统中应该分配给下一个事务的 id 值;creator_trx_id
:表示生成该 readview 的事务的事务 id;
「readview判断版本链」
有了 readview,在访问某条记录时,按照以下步骤判断记录的某个版本是否可见。
如果被访问版本的
trx_id
,与 readview 中的creator_trx_id
值相同,表明当前事务在访问自己修改过的记录,该版本可以被当前事务访问;如果被访问版本的
trx_id
,小于 readview 中的min_trx_id
值,表明生成该版本的事务在当前事务生成 readview 前已经提交,该版本可以被当前事务访问;如果被访问版本的
trx_id
,大于 readview 中的max_trx_id
值,表明生成该版本的事务在当前事务生成 readview 后才开启,该版本不可以被当前事务访问;如果被访问版本的
trx_id
,值在 readview 的min_trx_id
和max_trx_id
之间,就需要判断trx_id
属性值是不是在m_ids
列表中?如果在:说明创建 readview 时生成该版本的事务还是活跃的,该版本不可以被访问
如果不在:说明创建 readview 时生成该版本的事务已经被提交,该版本可以被访问;
通过 undo log 多版本链条,加上你开启事务时候生产的一个 ReadView,然后再有一个查询的时候,根据 ReadView 进行判断的机制,你就知道你应该读取哪个版本的数据。而且他可以保证你只能读到你事务开启前,别的提交事务更新的值,还有就是你自己事务更新的值。假如说是你事务开启之前,就有别的事务正在运行,然后你事务开启之后 ,别的事务更新了值,你是绝对读不到的!或者是你事务开启之后,比你晚开启的事务更新了值,你也是读不到的!通过这套机制就可以实现多个事务并发执行时候的数据隔离。
基于 ReadView 实现 RR 事务
在 MySQL 中让多个事务并发运行的时候能够互相隔离,避免同时读写一条数据的时候有影响,是依托 undo log 版本链条和 ReadView 机制来实现的。
「第一步」:首先我们还是假设有一条数据是事务id=50
的一个事务插入的,同时此时有事务 A 和事务 B 同时在运行,事务 A 的 id 是 60,事务 B 的 id 是 70。
「第二步」:这个时候,事务 A 发起了一个查询,他就是第一次查询就会生成一个 ReadView。
「第三步」:事务 A 基于这个 ReadView 去查这条数据,通提供过对比这条数据的事务 id 和 ReadView 比较
「第四步」:事务B此时更新了这条数据的值为值 B,此时会修改trx_id
为 70,同时生成一个 undo log 版本链,而且关键是事务 B 此时他还提交了,也就是说此时事务 B 已经结束了,如下图所示。
这个时候大家思考一个问题,ReadView 中的m_ids
此时还会是 60 和 70 吗?那必然是的,因为 ReadView 一旦生成了就不会改变了,这个时候虽然事务 B 已经结束了,但是事务 A 的 ReadView 里,还是会有 60 和 70 两个事务 id。
「第五步」:事务 A 继续查询数据,结果发现这条数据的trx_id
为7 0,如果被访问版本的trx_id
,值在 readview 的min_trx_id
和max_trx_id
之间,就需要判断trx_id
属性值是不是在m_ids
列表中,而我们发现我们的 ReadView 中,刚好有 70。说明创建 Readview 时生成该版本的事务 B 还是活跃的,该版本不可以被访问。
因此这个时候只能顺着指针往历史版本链条上,找到下面一条数据,trx_id
为 50,是小于 ReadView 的min_trx_id
的,说明在他开启查询之前,就已经提交了这个事务了,所以事务 A 是可以查询到这个值的,此时事务 A 查到的是原始值。
你事务 A 多次读同一个数据,每次读到的都是一样的值,除非是他自己修改了值,否则读到的一直会一
样的值。不管别的事务如何修改数据,事务 A 的 ReadView 始终是不变的,他基于这个 ReadView 始终看到的值是一样的!
RR事务解决幻读
接着我们来看看幻读的问题他是如何解决的。假设现在事务 A 先用select * from x where id>10
来查询,此时可能查到的就是 10 条数据,而且读到的是这条数据的原始值的那个版本。具体原因同上。
select * from x where id>10
如果被访问版本的trx_id
,值在 readview 的min_trx_id
和max_trx_id
之间,就需要判断trx_id
属性值是不是在m_ids
列表中?
如果在:说明创建 readview 时生成该版本的事务还是活跃的,该版本不可以被访问
如果不在:说明创建 readview 时生成该版本的事务已经被提交,该版本可以被访问;
「第一步」:现在有一个事务 C 插入了一条数据
「第二步」:此时事务 A 再次查询,此时会发现符合条件的有 12 条数据,10 条是原始值那个数据,2 条是事务 C 插入的那条数据,但是事务 C 插入的那条数据的trx_id
是 80,这个 80 是大于自己的 ReadView 的max_trx_id
的,说明是自己发起查询之后,这个事务才启动的,所以此时这条数据是不能查询的。
因此事务 A 本次查询,还是只能查到原始值 10 条数据,如下图。
所以大家可以看到,在这里,事务A根本不会发生幻读,他根据条件范围查询的时候,每次读到的数据
都是一样的,不会读到人家插入进去的数据,这都是依托 ReadView 机制实现的。
MVCC机制
multi-version concurrent control
简称 MVCC,就是多版本并发控制机制。
MySQL 实现 MVCC 机制的时候,是基于 undo log 多版本链条 + ReadView 机制来做的,默认的 RR 隔离级别,就是基于这套机制来实现的,依托这套机制实现了 RR 级别,除了避免脏写、脏读、不可重复读,还能避免幻读问题。因此一般来说我们都用默认的 RR 隔离级别就好了。
这就使得别的事务可以修改这条记录,反正每次修改都会在版本链中记录。SELECT 可以去版本链中拿记录,这就实现了读写,写读的并发执行,提升了系统的性能。
往期内容阅读:MySQL当中的 “My” 是什么意思?