搞清MVCC
一、什么是MVCC?
MVCC,全称 Multi-Version Concurrency Control ,即多版本并发控制,是一种即使不加锁也能够解决并发操作中的读写冲突的并发控制手段,即做到了非阻塞读。其中,不加锁可以大大降低程序开销。
补充:读写冲突会造成脏读、幻读、不可重复读。
二、什么是当前读、快照读?
- 当前读:当前读获取到的记录是最新记录;读取记录时,要对记录进行加锁操作,保证记录不被其他事务所修改。可以看作是悲观锁的一种实现。
- 快照读:快照读可以看作是MVCC的一种实现,主要是为了通过不加锁的方式解决读写冲突。
三、MVCC实现原理:
MVCC的实现主要依赖于3个隐藏字段、undo日志、Read View(读视图)。
1、隐藏字段:
一条行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID, DB_ROLL_PTR, DB_ROW_ID 等字段。
- DB_TRX_ID:6 byte,最近修改(修改/插入)事务 ID:记录创建这条记录/最后一次修改该记录的事务ID
- DB_ROLL_PTR:7 byte,回滚指针,指向这条记录的上一个版本(存储于 rollback segment 里)
- DB_ROW_ID:6 byte,隐含的自增 ID(隐藏主键),如果数据表没有主键,InnoDB 会自动以DB_ROW_ID产生一个聚簇索引
- 实际还有一个删除 flag 隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除 flag 变了。
如上图,DB_ROW_ID 是数据库默认为该行记录生成的唯一隐式主键,DB_TRX_ID 是当前操作该记录的事务 ID ,而 DB_ROLL_PTR 是一个回滚指针,用于配合 undo日志,指向上一个旧版本。
2、undo日志:
undo log主要分为两种:
- insert undo log:代表事务在 insert 新记录时产生的 undo log, 只在事务回滚时需要,并且在事务提交后可以被立即丢弃。
- update undo log:事务在进行 update 或 delete 时产生的 undo log ; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快照读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除。
对 MVCC 有帮助的实质是 update undo log ,undo log 实际上就是存在 rollback segment 中旧记录链。
它的执行流程如下:
(1)比如一个事务插入 person 表插入了一条新记录,记录如下,name 为 Jerry , age 为 24 岁,隐式主键是1,事务ID和回滚指针,我们假设为 NULL
(2)现在来了一个事务A对该记录的 name 做出了修改,改为 Tom。
- 在事务A修改该行(记录)数据时,数据库会先对该行加排他锁
- 然后把该行数据拷贝到 undo log 中,作为旧记录,即在 undo log 中有当前行的拷贝副本
- 拷贝完毕后,修改该行name为Tom,并且修改隐藏字段的事务 ID 为当前事务A的 ID, 我们默认从 1 开始,之后递增,回滚指针指向拷贝到 undo log 的副本记录,即表示我的上一个版本就是它
- 事务提交后,释放锁
(3)又来了个事务B修改 person 表的同一个记录,将age修改为 30 岁。
- 在事务B修改该行数据时,数据库也先为该行加锁
- 然后把该行数据拷贝到 undo log 中,作为旧记录,发现该行记录已经有 undo log 了,那么最新的旧数据作为链表的表头,插在该行记录的 undo log 最前面
- 修改该行 age 为 30 岁,并且修改隐藏字段的事务 ID 为当前事务B的 ID, 那就是 2 ,回滚指针指向刚刚拷贝到 undo log 的副本记录
- 事务提交,释放锁
从上面,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log成为一条记录版本线性表,即链表,undo log 的链首就是最新的旧记录,链尾就是最早的旧记录(当然就像之前说的该 undo log 的节点可能是会 purge 线程清除掉,向图中的第一条 insert undo log,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)。
3、Read View:
Read View是事务进行快照读所产生的读视图,用来记录并维护系统当前活跃的事务ID。有了读视图,我们就可以判断当前事务能够看到哪个版本的数据,既可能读取到最新记录,也可能是undo log中某个版本的记录。
有了这个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时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
而上述提到的判断其实也就是找出其他事务已经提交的最新版本,我的理解就是把已提交事务进行压栈,栈顶就是我们可以读到的版本。
对于RC、RR,读视图有不同的读取机制,下面我们就来具体了解一下MVCC怎么实现的RC、RR。
四、Repeatable Read是如何实现的?
假设有事务A、事务B差不多在同一时刻开启,那这两个事务会分别得到如下的视图。
在RR的隔离级别下,事务一开启就会得到上图那样的ReadView,并且只要事务不提交这个ReadView就一直有效、一直不变。这也解释了,为什么一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。
就上图来说:在事务A的视图中,它的事务ID=61,此时活跃的事务集合是[61、62],活跃的事务ID中最小的事务id是它本身。下一个事务id应该是63。
在事务B的视图中,它的事务ID=62,此时活跃的事务集合是[61、62],活跃的事务ID中最小的事务id是61。下一个事务id应该是63。
先让事务A尝试去读取name列的数据。
它会发现的这行数据的Data_TRX_ID=60,通过和trx_ids对比发现这个事务ID不在活跃的事务id集合trx_ids中,并且小于它本身的61。说明:在事务A开启之前,事务ID=60的事务早就提交过了。所以事务A能直接这行数据name = tom。
然后事务B通过update语句尝试去修改这行数据,想将name 改成 jerry。这时MySQL会记录相应的undo log,并以链表的方式串联起来,于是我们会得到下图:
你可以看到上图中,由于事务B将name改成jerry,导致多出一条undo log。这条undo对应的事务ID=事务B的事务ID = 62。并且通过一个指针指向它的上一个undo log记录。
这时如果事务A重新去读,首先它会读取到的记录是name = jerry,但是它也会发现该记录的trx_id = 62 , 比自己的61还大,并且比下一个事务ID 63小。说明:它读到记录其实是和自己同时开启的某个事务修改后的产物,这时他就会沿着undo log链条往前找,直到找到第一个trx_id等于或者小于自己事务ID的记录为止。所以事务A再一次读取到trx_id = 60的记录。
另外需要注意的是:就上例来说,在RR的隔离级别下,确实能保证事务A每次读取出来的结果都是一样的,而且在事务B将其修改后,事务A依然能读取出name = tom。但是这时name=tom真的只是个快照,本质上它已经可以算是不存在是数据了。
五、Read Commited 是如何实现的?
在Read Commited隔离级别下,每次select 、都会创建一个新的视图。
还是使用这个例子:假设事务A和事务B并发开启,并且各自得到了图中的ReadView。然后很快,事务B就将数据name = tom改成了name = jerry(未提交)。那这时事务A去select会检索出什么结果呢?
事务A检索过程:事务A首先会沿着undo log链条从头开始找,于是它首先找到name = jerry的列。但是它也发现该列的trx_id = 62 不但比自己的事务ID60大,而且还在trx_ids这个活跃事务列表中,说明name = jerry是被和自己差不多同时开启的其他事务更改的。它自然也就读不到,于是顺着undo log链条往前找。
紧接着事务B提交事务,然后事务A select会重新开启一个新的视图,得到如下图:
当事务A沿着undo log链条往下查找时,他发现首先发现的name = jerry的行的trx_id是62,竟然比自己的事务ID 61还大,但是进一步发现,这个事务ID 62并不在trx_ids中。说明,这个其实是已经被提交了的数据,那直接就意味着其实自己是允许读出这条数据的。
总结:在 RC 隔离级别下,是每个快照读都会生成并获取最新的 Read View;而在 RR 隔离级别下,则是同一个事务中的第一个快照读才会创建 Read View, 之后的快照读获取的都是同一个 Read View。
参考:
【MySQL笔记】正确的理解MySQL的MVCC及实现原理_mysqlmvcc实现原理-CSDN博客
我劝!这位年轻人不讲MVCC,耗子尾汁!
史上最详尽,一文讲透 MVCC 实现原理_一文讲透mvcc实现原理-CSDN博客