🎍目录
- ⛳ MVCC 原理详解
- 🐾 一、事务回顾
- 📐 1.1、什么是数据库事务,为什么要有事务
- 🎉 1.2、事务包括哪几个特性?
- 🎍 1.3、事务并发存在的问题
- 1.3.1、脏读
- 1.3.2、不可重复读
- 1.3.3、幻读
- 🎒 1.4 四大隔离级别
- 1.4.1 读未提交
- 1.4.2 读已提交
- 1.4 3 可重复读
- 1.4.4 串行化
- 🎃 1.5、MySQL数据库是如何保证事务的隔离性的呢?
- 🏭 二、什么是 MVCC ?
- 🎨 三、MVCC 实现的关键知识点
- 🏭 3.1、事务版本号
- 👣 3.2、隐式字段
- 🐾 3.3、undo log
- 📝 3.4、版本链
- 💭 3.5、快照和当前读
- 🏀 3.6、Reader View
- 🎁 四、MVCC的演示过程
⛳ MVCC 原理详解
🐾 一、事务回顾
📐 1.1、什么是数据库事务,为什么要有事务
事务,由一个有限的数据库操作序列构成,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。
假如 A 转账给 B 100 元,先从 A 的账户里扣除 100 元,再在 B 的账户上加上 100 元。如果扣完 A 的 100 元后,还没来得及给 B 加上,银行系统异常了,最后导致 A 的余额减少了,B 的余额却没有增加。所以就需要事务,将 A 的钱回滚回去,就是这么简单。
为什么要有事务呢? 就是为了保证数据的最终一致性。
🎉 1.2、事务包括哪几个特性?
事务四个典型特性,即 ACID,原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。
- 原子性:事务作为一个整体被执行,包含在其中的对数据库的操作要么全部都执行,要么都不执行。
- 一致性:指在事务开始之前和事务结束以后,数据不会被破坏,假如 A 账户给 B 账户转 10 块钱,不管成功与否,A 和 B 的总金额是不变的。
- 隔离性:多个事务并发访问时,事务之间是相互隔离的,一个事务不应该被其他事务干扰,多个并发事务之间要相互隔离。
- 持久性:表示事务完成提交后,该事务对数据库所作的操作更改,将持久地保存在数据库之中。
🎍 1.3、事务并发存在的问题
事务并发会引起脏读、不可重复读、幻读问题;
1.3.1、脏读
如果一个事务读取到了另一个未提交事务修改过的数据,我们就称发生了脏读现象。
假设现在有两个事务 A、B :
- 假设现在 Jay 的余额是 100 ,事务 A 正在准备查询 Jay 的余额;
- 事务 B 先扣减 Jay 的余额,扣了 10 ,但是还没提交;
- 最后 A 读到的余额是 90 ,即扣减后的余额;
**脏读:**因为事务 A 读取到事务 B 未提交的数据,这就是脏读。
1.3.2、不可重复读
同一个事务内,前后多次读取,读取到的数据内容不一致;
假设现在有两个事务 A 和 B:
- 事务 A 先查询 Jay 的余额,查到结果是 100
- 这时候事务 B 对 Jay 的账户余额进行扣减,扣去 10 后,提交事务
- 事务 A 再去查询 Jay 的账户余额发现变成了 90
不可重复读:事务 A 被事务 B 干扰到了!在事务 A 范围内,两个相同的查询,读取同一条记录,却返回了不同的数据,这就是不可重复读。
1.3.3、幻读
如果一个事务先根据某些搜索条件查询出一些记录,在该事务未提交时,另一个事务写入了一些符合那些搜索条件的记录(如 insert、delete、update),就意味着发生了幻读。
假设现在有两个事务 A、B:
- 事务 A 先查询 id 大于 2 的账户记录,得到记录 id=2 和 id=3 的两条记录
- 这时候,事务 B 开启,插入一条 id=4 的记录,并且提交了
- 事务 A 再去执行相同的查询,却得到了 id=2,3,4 的 3 条记录了。
**幻读:**事务 A 查询一个范围的结果集,另一个并发事务 B 往这个范围中插入新的数据,并提交事务,然后事务 A 再次查询相同的范围,两次读取到的结果集却不一样了,这就是幻读。
🎒 1.4 四大隔离级别
问题:四大隔离级别,都会存在哪些并发问题?
为了解决并发事务存在的脏读、不可重复读、幻读等问题,数据库大叔设计了四种隔离级别。分别是读未提交,读已提交,可重复读,串行化(Serializable)。
- Oracle 支持的 2 种事务隔离级别:READ COMMITED, SERIALIZABLE。 Oracle 默认的事务隔离级别为: READ COMMITED 。
- Mysql 支持 4 种事务隔离级别。Mysql 默认的事务隔离级别为: REPEATABLE READ。
1.4.1 读未提交
读未提交隔离级别,只限制了两个数据不能同时修改,但是修改数据的时候,即使事务未提交,都是可以被别的事务读取到的,这级别的事务隔离有脏读、不可重复读、幻读的问题;
1.4.2 读已提交
读已提交隔离级别,当前事务只能读取到其他事务提交的数据,所以这种事务的隔离级别解决了脏读问题,但还是会存在不可重复读、幻读问题;
1.4 3 可重复读
可重复读隔离级别,限制了读取数据的时候,不可以进行修改,所以解决了不可重复读的问题,但是读取范围数据的时候,是可以插入数据,所以还会存在幻读问题;
1.4.4 串行化
事务最高的隔离级别,在该级别下,所有事务都是进行串行化顺序执行的。可以避免脏读、不可重复读与幻读所有并发问题。但是这种事务隔离级别下,事务执行很耗性能。
🎃 1.5、MySQL数据库是如何保证事务的隔离性的呢?
数据库是通过加锁,来实现事务的隔离性的。这就好像,如果你想一个人静静,不被别人打扰,你就可以在房门上加上一把锁。
加锁确实好使,可以保证隔离性。比如串行化隔离级别就是加锁实现的。但是频繁的加锁,导致读数据时,没办法修改,修改数据时,没办法读取,大大降低了数据库性能。
那么,如何解决加锁后的性能问题的?
答案就是,MVCC 多版本并发控制!它实现读取数据不用加锁,可以让读取数据同时修改。修改数据时同时可读取。
🏭 二、什么是 MVCC ?
MVCC 体现了两种思想:
- MVCC 是 写时复制(copy on writer)思想的一种体现
- MVCC 是 以空间换时间 思想的一种体现;
MVCC (Muti-Version Concurrency Control )多版本并发控制,是用来在数据库中控制并发的⽅法,实现对数据库的并发访问⽤的,就是⼀种写时复制的思想的应⽤。在MySQL中,MVCC只在读取已提交(Read Committed)和可重复读(Repeatable Read)两个事务级别下有效。其是通过Undo⽇志中的版本链和ReadView⼀致性视图来实现的。MVCC就是在多个事务同时存在时,SELECT语句找寻到具体是版本链上的哪个版本,然后在找到的版本上返回其中所记录的数据的过程。
⾸先需要知道的是,在MySQL中,会默认为我们的表后⾯添加三个隐藏字段:
- DB_ROW_ID:⾏ID,MySQL的B+树索引特性要求每个表必须要有⼀个主键。如果没有设置的话,会⾃动寻找第⼀个不包含NULL的唯⼀索引列作为主键。如果还是找不到,就会在这个DB_ROW_ID上⾃动⽣成⼀个唯⼀值,以此来当作主键(该列和MVCC的关系不⼤);
- DB_TRX_ID:事务ID,记录的是当前事务在做INSERT或UPDATE语句操作时的事务ID(DELETE语句被当做是UPDATE语句的特殊情况,后⾯会进⾏说明);
- DB_ROLL_PTR:回滚指针,通过它可以将不同的版本串联起来,形成版本链。相当于链表的next指针。
数据库中同时存在多个版本的数据,并不是整个数据库的多个版本,而是某一条记录的多个版本同时存在,在某个事务对其进行操作的时候,需要查看这一条记录的隐藏列事务版本 id,比对事务 id 并根据事物隔离级别去判断读取哪个版本的数据。
🎨 三、MVCC 实现的关键知识点
🏭 3.1、事务版本号
事务每次开启前,都会从数据库获得一个自增长的事务ID,可以从事务ID判断失误的执行先后顺序。这就是事务版本号。
👣 3.2、隐式字段
对于 InnoDB 存储引擎,每一行记录都有两个隐藏列 trx_id、roll_pointer,如果表中没有主键和非 NULL 唯一键时,则还会有第三个隐藏的主键列 row_id。
🐾 3.3、undo log
undo log,回滚日志,用于记录数据被修改前的信息。在表记录修改之前,会先把数据拷贝到 undo log 里,如果事务回滚,即可以通过 undo log 来还原数据。
直接上有种后悔药叫undo log
可以这样认为,当 delete 一条记录时,undo log 中会记录一条对应的 insert 记录,当 update 一条记录时,它记录一条对应相反的 update 记录。
undo log 有什么用途呢?
- 事务回滚时,保证原子性和一致性。
- 用于 MVCC 快照读。
📝 3.4、版本链
多个事务并行操作某一行数据时,不同事务对该行数据的修改会产生多个版本,然后通过回滚指针(roll_pointer),连成一个链表,这个链表就称为版本链。如下:
其实,通过版本链,我们就可以看出事务版本号、表格隐藏的列和 undo log 它们之间的关系。我们再来小分析一下。
-
假设现在有一张 core_user 表,表里面有一条数据,id 为 1,名字为孙权:
-
现在开启一个事务 A:对 core_user 表执行
update core_user set name ="曹操" where id=1
,会进行如下流程操作- 首先获得一个事务 ID=100
- 把 core_user 表修改前的数据,拷贝到 undo log
- 修改 core_user 表中,id=1 的数据,名字改为曹操
- 把修改后的数据事务 Id=101 改成当前事务版本号,并把 roll_pointer 指向 undo log 数据地址。
💭 3.5、快照和当前读
快照读: 读取的是记录数据的可见版本(有旧的版本)。不加锁,普通的 select 语句都是快照读,如:
select * from core_user where id > 2;
当前读:读取的是记录数据的最新版本,显式加锁的都是当前读
select * from core_user where id > 2 for update;
select * from account where id>2 lock in share mode;
🏀 3.6、Reader View
Read View 是什么呢? 它就是事务执行 SQL 语句时,产生的读视图。实际上在 innodb 中,每个 SQL 语句执行前都会得到一个 Read View。
Read View 有什么用呢? 它主要是用来做可见性判断的,即判断当前事务可见哪个版本的数据~
Read View 是如何保证可见性判断的呢?我们先看看 Read view 的几个重要属性
- m_ids:当前系统中那些活跃(未提交)的读写事务 ID, 它数据结构为一个 List。
- min_limit_id:表示在生成 Read View 时,当前系统中活跃的读写事务中最小的事务 id,即 m_ids 中的最小值。
- max_limit_id:表示生成 Read View 时,系统中应该分配给下一个事务的 id 值。
- creator_trx_id: 创建当前 Read View 的事务 ID
(其中min_id指向ReadView中未提交事务数组中的最⼩事务ID,⽽max_id指向ReadView中的已经创建的最⼤事务ID)
- 如果落在绿⾊区间(DB_TRX_ID < min_id):这个版本⽐min_id还⼩(事务ID是从⼩往⼤顺序⽣成的),说明这个版本在SELECT之前就已经提交了,所以这个数据是可⻅的。或者(这⾥是短路或,前⾯条件不满⾜才会判断后⾯这个条件)这个版本的事务本身就是当前SELECT语句所在事务的话,也是⼀样可⻅的;
- 如果落在红⾊区间(DB_TRX_ID > max_id):表示这个版本是由将来启动的事务来⽣成的,当前还未开始,那么是不可⻅的;
- 如果落在⻩⾊区间(min_id <= DB_TRX_ID <= max_id):这个时候就需要再判断两种情况:
- 如果这个版本的事务ID在ReadView的未提交事务数组中,表示这个版本是由还未提交的事务⽣成的,那么就是不可⻅的;
- 如果这个版本的事务ID不在ReadView的未提交事务数组中,表示这个版本是已经提交了的事务⽣成的,那么是可⻅的。
如果在上述的判断中发现当前版本是不可⻅的,那么就继续从版本链中通过回滚指针拿取下⼀个版本来进⾏上述的判断。
🎁 四、MVCC的演示过程
下⾯通过⼀个示例来具体演示MVCC的执⾏过程(假设是在可重复读事务级别下),当前account表中已经有了⼀条初始数据(id=1,name=monkey):
从左往右分别是五个事务,从上到下是时刻点。其中在第2和3时刻点中事务100和事务200(这⾥两个事务之间相差100只是为了更加⽅便去看,正常来说下个事务的ID是以+1的⽅式来创建的)分别执⾏了⼀条UPDATE语句,这两条语句并⽆实际作⽤,只是为了⽣成事务ID的,所以在下⾯的MVCC执⾏过程中就不分析这两条语句所带来的影响了,我们只研究account表。⽽其中最后两个事务,我是注明没有事务ID的。因为事务ID是执⾏⼀条更新操作(增删改)的语句后才会⽣成(这也是事务100和事务200要先执⾏5⼀条更新语句的意义),并不是开启事务的时候就会⽣成。最后两个事务中可以看到就是执⾏了⼀些SELECT语句⽽已,所以它们并没有事务ID。
⾸先来看⼀下初始状态时的版本链和ReadView(ReadView此时还未⽣成):
其中事务1在account表中创建了⼀条初始数据。
之后在第1时刻点,五个事务分别开启了事务(如上所说,这个时候还没有⽣成事务ID)。
在第2时刻点,第⼀个事务执⾏了⼀条UPDATE语句,⽣成了事务ID为100。
在第3时刻点,第⼆个事务执⾏了⼀条UPDATE语句,⽣成了事务ID为200。
在第4时刻点,第三个事务执⾏了⼀条UPDATE语句,将account表中id为1的name改为了monkey301。同时⽣成了事务ID为300。
在第5时刻点,事务300也就是上⾯的事务执⾏了commit操作。
在第6时刻点,第四个事务执⾏了⼀条SELECT语句,想要查询⼀下当前id为1的数据(如上所说,该事务没有⽣成事务ID)。此时的版本链和ReadView如下:
因为在第5时刻点,事务300已经commit了,所以ReadView的未提交事务数组中不包含它。此时根据上⾯所说的⽐对规则,拿版本链中的第⼀个版本的事务ID为300进⾏⽐对,⾸先当前这条SELECT语句没有在事务300中进⾏查询,然后发现是落在⻩⾊区间,⽽且事务300也没有在ReadView的未提交事务数组中,所以是可⻅的。即此时在第6时刻点,第四个事务所查找到的结果是monkey301。
在第7时刻点,事务100执⾏了⼀条UPDATE语句,将account表中id为1的name改为了monkey101。
在第8时刻点,事务100⼜执⾏了⼀条UPDATE语句,将account表中id为1的name改为了monkey102。
在第9时刻点,第四个事务执⾏了⼀条SELECT语句,想要查询⼀下当前id为1的数据。此时的版本链和ReadView如下:
注意,因为当前是在可重复读的事务级别下,所以此时的ReadView沿⽤了在第6时刻点⽣成的ReadView(如果是在读取已提交的事务级别下,此时就会重新⽣成⼀份ReadView了)。然后根据上⾯所说的⽐对规则,拿版本链中的第⼀个版本的事务ID为100进⾏⽐对,⾸先当前这条SELECT语句没有在事务100中进⾏查询,然后发现是落在⻩⾊区间,⽽且事务100是在ReadView的未提交事务数组中,所以是不可⻅的。此时通过回滚指针拿取下⼀个版本,发现事务ID仍然为100,经过分析后还是不可⻅的。此时⼜拿取下⼀个版本:事务ID为300进⾏⽐对,⾸先当前这条SELECT语句没有在事务300中进⾏查询,然后发现是落在⻩⾊区间,但是事务300没有在ReadView的未提交事务数组中,所以是可⻅的。即此时在第9时刻点,第四个事务所查找到的结果仍然是monkey301(这也就是可重复读的含义)。
在第10时刻点,事务100commit提交事务了。同时事务200执⾏了⼀条UPDATE语句,将account表中id为1的name改为了monkey201。
在第11时刻点,事务200⼜执⾏了⼀条UPDATE语句,将account表中id为1的name改为了monkey202。
在第12时刻点,第四个事务执⾏了⼀条SELECT语句,想要查询⼀下当前id为1的数据。此时的版本链和ReadView如下:
跟第9时刻点⼀样,在可重复读的事务级别下,ReadView沿⽤了在第6时刻点⽣成的ReadView。然后根据上⾯所说的⽐对规则,拿版本链中的第⼀个版本的事务ID为200进⾏⽐对,⾸先当前这条SELECT语句没有在事务200中进⾏查询,然后发现是落在⻩⾊区间,⽽且事务200是在ReadView的未提交事务数组中,所以是不可⻅的。此时通过回滚指针拿取下⼀个版本,发现事务ID仍然为200,经过分析后还是不可⻅的。此时⼜拿取下⼀个版本:事务ID为100进⾏⽐对,⾸先当前这条SELECT语句没有在事务100中进⾏查询,然后发现是落在⻩⾊区间内,同时在ReadView的未提交数组中,所以依然是不可⻅的。此时⼜拿取下⼀个版本,发现事务ID仍然为100,经过分析后还是不可⻅的。此时再拿取下⼀个版本:事务ID为300进⾏⽐对,⾸先当前这条SELECT语句没有在事务300中进⾏查询,然后发现是落在⻩⾊区间,但是事务300没有在ReadView的未提交事务数组中,所以是可⻅的。即此时在第12时刻点,第四个事务所查找到的结果仍然是monkey301。
同时在第12时刻点,第五个事务执⾏了⼀条SELECT语句,想要查询⼀下当前id为1的数据。此时的版本链和ReadView如下:
注意,此时第五个事务因为是该事务内的第⼀条SELECT语句,所以会重新⽣成在当前情况下的ReadView,即上图中所示的内容。可以看到,和第四个事务⽣成的ReadView并不⼀样,因为在之前的第10时刻点,事务100已经提交事务了。然后根据上⾯所说的⽐对规则,拿版本链中的第⼀个版本的事务ID为200进⾏⽐对,⾸先当前这条SELECT语句没有在事务200中进⾏查询,然后发现是落在⻩⾊区间,⽽且事务200是在ReadView的未提交事务数组中,所以是不可⻅的。此时通过回滚指针拿取下⼀个版本,发现事务ID仍然为200,经过分析后还是不可⻅的。此时⼜拿取下⼀个版本:事务ID为100进⾏⽐对,发现是在绿⾊区间,所以是可⻅的。即此时在第12时刻点,第五个事务所查找到的结果是monkey102(可以看到,即使是同⼀条SELECT语句,在不同的事务中,查询出来的结果也可能是不同的,究其原因就是因为ReadView的不同)。
在第13时刻点,事务200执⾏了commit操作,整段分析过程结束。
以上演示的就是MVCC的具体执⾏过程,在多个事务下,版本链和ReadView是如何配合进⾏查找的。上⾯还遗漏了⼀种情况没有进⾏说明,就是如果是DELETE语句的话,也会在版本链上将最新的数据插⼊⼀份,然后将事务ID赋值为当前进⾏删除操作的事务ID。但是同时会在该条记录的信息头(recordheader)⾥⾯的deleted_flag标记位置为true,以此来表示当前记录已经被删除。所以如果经过版本⽐对
后发现找到的版本上的deleted_flag标记位为true的话,那么也不会返回,⽽是继续寻找下⼀个。
另外,如果当前事务执⾏rollback回滚的话,会把版本链中属于该事务的所有版本都删除掉