序
最近在学习MySQL中的MVCC,看了网上的各种版本,什么创建版本号、删除版本号,一开始看的时候,好像很对的样子,但实在经不起推敲。经过好几天的查阅对比,在几篇博客的帮助下,才算是觉得正确理解了MySQL中的MVCC,感谢以下博客:
MySQL-InnoDB-MVCC多版本并发控制
MySQL数据库事务各隔离级别加锁情况--read committed && MVCC
InnoDB存储引擎MVCC的工作原理
MVCC多版本并发控制
Mysql Innodb中undo-log和MVCC多版本一致性读 的实现
InnoDB事务分析-MVCC
本文就是学习了这些博客后的一些复制粘贴总结,如果你刚接触MVCC,请坚持看到最后,一定会对你有收获。
目录
1、MVCC概念
多版本控制(Multiversion Concurrency Control): 指的是一种提高并发的技术。最早的数据库系统,只有读读之间可以并发,读写,写读,写写都要阻塞。引入多版本之后,只有写写之间相互阻塞,其他三种操作都可以并行,这样大幅度提高了InnoDB的并发度。在内部实现中,InnoDB是在undo log中实现的,通过undo log可以找回数据的历史版本。找回的数据历史版本可以提供给用户读(按照隔离级别的定义,每个事务读到的数据版本可能是不一样的)。
可以认为MVCC是行级锁的一个变种, 但是它在很多情况下避免了加锁操作, 因此开销更低。
MVCC只在 Read Committed 和 Repeatable Read两个隔离级别下工作。其他两个隔离级别和MVCC不兼容, 因为 Read Uncommitted总是读取最新的数据行, 而不是符合当前事务版本的数据行。而 Serializable 则会对所有读取的行都加锁。
此处转自:link
MVCC保证了数据在事务中的连续性,在同一个事务中,用户只能看到该事务之前已经提交的修改和该事务本身做的修改。
MySQL的InnoDB存储引擎默认事务隔离级别是RR(可重复读), 是通过 "行排他锁+MVCC"一起实现的。而MCVV的实现依赖三个隐藏字段、Read View、Undo log。
1.1、三个隐藏字段
InnoDB存储引擎在数据库每行数据的后面添加了三个隐藏字段:
- DB_TRX_ID(6字节):表示最后一次修改(insert | update)当前记录行的事务ID。至于delete操作,InnoDB认为是一个update操作,不过会更新一个另外的特殊位,将行表示为deleted。并非真正删除。
- DB_ROLL_PTR(7字节):回滚指针,指向当前记录行的undo信息
- DB_ROW_ID(6字节):随着新行插入而单调递增的行ID。理解:当表没有主键或唯一索引时,innodb就会使用这个行ID作为主键,自动产生聚簇索引。如果表有主键或唯一索引,聚簇就不会包含这个行ID了。这个DB_ROW_ID跟MVCC关系不大。
1.2、Read View 结构
其实read view,跟快照、snapshot是一个概念。
Read View 主要是用来做可见性判断的, 比较普遍的解释便是"本事务不可见的当前其他活跃事务"
Read View 结构源码
① low_limit_id:目前为止出现过的最大的事务ID + 1,即下一个被分配(未分配)的事务ID。源码 350行:
max_trx_id的定义如下,源码 618行,翻译过来就是“还未分配的最小事务ID”,那不就是目前为止出现过(已分配)的最大的事务ID + 1嘛。
② up_limit_id:活跃事务列表trx_ids中最小的事务ID。源码 358行:
因为trx_ids中的活跃事务号是逆序的,所以最后一个为最小活跃事务ID。网上其他地方传的up_limit_id为已提交的最大事务ID + 1是不对的。
③ trx_ids:Read View创建时其他未提交的活跃事务ID列表。理解起来就是创建Read View时,将当前其他活跃事务ID记录下来,后续即使他们提交了,对于当前事务也是不可见的。
注意:进入Read View中trx_ids的活跃事务,这其中不包括当前事务自己,和已/正在提交的事务(正在内存中)。源码 295行:
④ creator_trx_id:当前创建事务的ID,是一个递增的编号,源码 345行 。这个编号跟DB_ROW_ID不是一个东西。
RR和RC的Read View快照产生区别:
①在innodb中的Repeatable Read级别, 事务在begin/start transaction之后的第一条select读执行后, 会创建一个快照(read view), 将当前系统中活跃的其他事务记录记录起来;并且之后都是使用的这个快照,不会改变,直到事务结束。(注意:只有查询select语句会创建快照,update、delete不会)
②在innodb中的Read Committed级别, 事务中每条select语句都会创建一个快照(read view)。
1.3、Undo log
Undo log中存储的是老版本数据,当一个事务需要读取历史版本数据时,为了能读取到老版本的数据,需要顺着undo log链找到满足其可见性的记录。
大多数对数据的变更操作包括 insert/update/delete,其中insert操作在事务提交前只对当前事务可见,因此产生的Undo日志可以在事务提交后直接删除;而对于update/delete则需要维护多版本信息;在InnoDB里,insert的undo log为一类,update和delete产生的Undo日志为另一类。如下:
①insert undo log : 事务对insert新记录时产生的undo log, 只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。
②update undo log : 事务对记录进行delete和update操作时产生的undo log, 不仅在事务回滚时需要, 一致性读(快照读)也需要,所以不能随便删除,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被purge线程删除。
Purge线程:从前面的分析可以看出,为了实现InnoDB的MVCC机制,更新或者删除操作都只是设置一下旧记录的deleted_bit,并不真正将过时的记录删除。
为了节省磁盘空间,InnoDB有专门的purge线程来清理deleted_bit为true的记录。为了不影响MVCC的正常工作,purge线程自己也维护了一个read view(这个read view相当于系统中最老活跃事务的read view);如果某个记录的deleted_bit为true,并且DB_TRX_ID相对于purge线程的read view可见,那么这条记录一定是可以被安全清除的。
此处转自:https://segmentfault.com/a/1190000012650596
2、记录行修改的具体流程
假设persion表有一条记录行如下,name为Jerry, age为24岁,隐式主键是1,事务ID和回滚指针一开始为NULL。
(1)现在事务A对该记录的name做出了修改,改为Tom:
①事务A先对该行加排它锁
②然后把该行数据拷贝到undo log中,作为旧记录
③拷贝完毕后,修改该行name为Tom,并且修改DB_TRX_ID为当前事务A的ID(假设为1), 回滚指针指向拷贝到undo log的副本记录。(其实这里还会将修改后的最新数据写入redo log)
④事务提交后,释放排他锁
(2) 接着事务B修改person表的同一个记录行,将age修改为30岁:
①事务B先对该行加排它锁
②然后把该行数据拷贝到undo log中,作为旧记录
③拷贝完毕后,修改该行age为30,并且修改DB_TRX_ID为当前事务B的ID(假设为2), 回滚指针指向拷贝到undo log的副本记录
④事务提交后,释放排他锁
从上面,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log成为一条链表,undo log的链首就是最新的旧记录,链尾就是最早的旧记录(当然就像之前说的该undo log的节点可能是会purge线程清除掉,向图中的第一条insert undo log,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)
此处转自:https://www.jianshu.com/p/8845ddca3b23
3、可见性比较算法
(这里比较算法后面的描述是建立在RR级别下,RC级别也是使用该比较算法,此处未做描述)
在innodb中,创建一个新事务后,执行第一个select语句的时候,innodb会创建一个快照(read view),快照中会保存系统当前不应该被本事务看到的其他活跃事务id列表(即trx_ids)。当用户在这个事务中要读取某个记录行的时候,innodb会将该记录行的DB_TRX_ID与该Read View中的活跃事务id进行比较。
假设当前事务要读取某一个记录行,该记录行的DB_TRX_ID(即最新修改该行的事务ID)为trx_id_edit,Read View的活跃事务列表trx_ids中最早的事务ID为up_limit_id,将在生成这个Read Vew时系统出现过的最大的事务ID+1记为low_limit_id(即还未分配的事务ID)。
具体的比较算法如下:
1. 如果 trx_id_edit < up_limit_id, 那么表明“最新修改该行的事务”在“当前事务”创建快照之前就提交了,所以该记录行的值对当前事务是可见的。跳到步骤5。
2. 如果 trx_id_edit >= low_limit_id, 那么表明“最新修改该行的事务”在“当前事务”创建快照之后才修改该行,所以该记录行的值对当前事务不可见。跳到步骤4。
3. 如果 up_limit_id <= trx_id_edit < low_limit_id, 表明“最新修改该行的事务”在“当前事务”创建快照的时候可能处于“活动状态”或者“已提交状态”;所以就要对活跃事务列表trx_ids进行查找(源码中是用的二分查找,因为是有序的):
(1) 如果在活跃事务列表trx_ids中能找到 id 为 trx_id_edit 的事务,表明“该记录行的值”在“当前事务”创建快照后,又被 “id为trx_id_edit的事务” 修改了,所以这个记录行的值对当前事务是不可见的,跳到步骤4;
(2)在trx_ids中找不到,则表明“id为trx_id_edit的事务”在“当前事务”创建快照前已经提交了,记录行对当前事务可见,跳到步骤5。
(请一定看懂这段,或者边看后面的例子,边看这段。)
4. 在该记录行的 DB_ROLL_PTR 指针所指向的undo log回滚段中,取出最新的的旧事务号DB_TRX_ID, 将它赋给trx_id_edit,然后跳到步骤1重新开始判断。
5. 将该可见行的值返回。
此处参考自:link
比较算法源码 84行,也可看下图,有注释,图代码来自 link:
4、当前读和快照读
“MVCC+行排它锁”实现的RR隔离级别,不仅可以保证可重复读,还能防止部分幻读,但不是完全防止。
比如事务A开始后,执行普通select语句,创建了快照;之后事务B执行insert语句;然后事务A再执行普通select语句,得到的还是之前B没有insert过的数据,因为这时候A读的数据是符合快照可见性条件的数据。这就防止了幻读,此时事务A是快照读。
但是,如果事务A执行的不是普通select语句,而是update、delete、select ... for update等语句,这时候,事务A是当前读,每次语句执行的时候都是读的最新数据。也就是说,A先执行 select ... where nid = 1 … for update;然后事务B再执行insert … nid = 1 …;然后 A 再执行select ... where nid = 1 … for update,就会发现,多了一条B insert进去的记录。这就产生了幻读了。
快照读(snapshot read):简单的select操作(不包括 select ... lock in share mode, select ... for update)
当前读(current read) :select ... lock in share mode,select ... for update,insert,update,delete(参考官方文档:link),读取的是数据库中的最新的数据,并且,在RR隔离级别时会锁住读取的行(排它锁)和gap(间隙锁)。如果不能获得锁,则会一直等待,直到获得或者超时。RC隔离级别的当前读没有gap lock,RC的update语句进行的是“半一致性读”,和RR的update语句的当前读不一样。
此处转自:link
5、例子
| 假设原始数据行: | |||
| Field | DB_ROW_ID | DB_TRX_ID | DB_ROLL_PTR |
| 1 | 10 | 10001 | 0x13525342 |
| 事务10002 | 事务10003 | 事务10004 | 事务10005 |
| begin | |||
| …… | begin | ||
|
第一次执行select, 当前记录行的DB_TRX_ID为10001,10001 < up_limit_id,可见。 |
|||
| …… | |||
| begin | |||
| …… | |||
|
第一次执行select, 当前记录行的DB_TRX_ID为10001,10001 < up_limit_id,可见。 |
…… | ||
| …… | |||
| …… | |||
| …… | …… | ||
| commit | |||
| …… | 执行update set Field=2 | ||
| …… | commit (这里记录行的DB_TRX_ID变为10004) |
||
| begin | |||
|
第二次执行select, trx_ids:[10004, 10002] 此时最新记录行的DB_TRX_ID为10004,up_limit_id < 10004 <= low_limit_id,所以要在trx_ids列表中查找,发现10004存在,那么这个记录行不可见。 undo log 回滚,找到上一条记录,上一条记录的DB_TRX_ID为10001,10001<up_limit_id,所以可见,返回, |
|||