多版本并发控制
文章目录
- 多版本并发控制
- 1. 什么是MVCC
- 2.快照读和当前读
- 3. 复习 : 隐藏字段, undo log版本链
- 4. MVCC实现原理之ReadView
- 4.1什么是ReadView
- 4.2 设计思路
- 4.3 ReadView的规则
- 4.4MVCC整体操作流程
- 一定注意: 我们最开始找的时候肯定是找行记录, 然后从行记录的roll_pointer属性找到undo log, 然后跟着undo log往下继续找, 一直到找到当前记录的版本链中有一个符合ReadView规则的版本为止(第一次找到的就是最后修改的, 此时这个版本就是我们要返回的版本)
- 注意: MVCC是找到某个行记录之后沿着版本连一直找, 一直是沿着roll_pointer找, 但是rollback(回滚)不是的, 回滚的时候由于是为每个undo日志都维护了序号的, 是沿着需要从大到小一直往前回滚, 两者的机制完全是不同的, 这一点一定要清楚
- 所以Undo Log和ReadView都是快照, Undo Log是对这样记录各个版本的一个快照, 而ReadView是为了实现MVCC, 所以在执行一些事物中的select操作时的一个快照
- 在隔离级别为读已提交(Read Committed)时, 一个事务中的每一次select查询都会重新获取一次ReadView.
- 当隔离级别为可重复读的时候, 就避免了不可重复读, 这是因为一个事物只在第一次select的时候会获取一次ReadView, 而后面所有的select都会复用这个ReadView, 如下表所示:
- 5.总结:
1. 什么是MVCC
MVCC(Multiversion Concurrency Control), 多版本并发控制. 顾名思义, MVCC是通过数据行的多个版本管理来实现数据库的并发控制. 这项记录使得在InnoDB的事物隔离级别下执行一致性读操作有了保证. 换言之, 就是为了查询一些正在被另一个事物更新的行, 并且可以看到他们被更新之前的值, 这样在做查询的时候就不用等待另一个事物释放锁
- 而insert操作在事物之间是没有共享的, 所以insert操作是不用使用并发版本控制的
2.快照读和当前读
MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能, 用更好的方式去处理读-写冲突, 做到即使有读写冲突时, 也能做到不加锁, 非阻塞并发读, 而这个读指的就是快照读, 而非是当前读. 当前读实际上是一种加锁的操作, 是悲观锁的实现, 而MVCC本质是采用乐观锁思想的一种方式:
-
以下的一些操作都是当前读(也就是都是加锁实现的) :
select * from student lock in stare mode; //共享锁
select * from student for update; //排它锁
insert into student values ... #排他锁
delete from student where ... #排它锁
update student set ... #排它锁
我们在学习锁的时候说过, 使用锁可以解决读写并发问题, 写写并发问题, 当时说读写如果并发如果使用锁来实现是性能比较低的, 所以我们这里讲的MVCC其实就是更好的解决读写并发问题
2.1 快照读
快照读又叫一致性读, 读取的是快照数据. 不加锁的简单的select都属于快照读, 即不加锁的非阻塞读; 如下就是快照读:
select * from player where ...
之所以出现快照读的情况, 是基于提高并发性能的考虑, 快照读的实现是基于MVCC, 它在很多情况下, 避免了加锁操作, 降低了开销
既然是基于多版本控制, 那么快照读可能读到的并不一定是数据的最新版本, 而是有可能是之前的历史版本.
快照读的前提是隔离级别不是串行化级别, 串行化级别下的快照读会退化成当前读.
2.2 当前读
当前读读取的是记录的最新版本(最新数据, 而不是历史版本的数据), 读取时还要保证其他并发事物不能修改当前记录, 会对读取的记录进行加锁. 加锁的select, 或者对数据进行增删该都会进行当前读.
所以说其实读未提交就是什么都没有使用到, 读已提交和可重复读之下快照读是MVCC实现的, 读已提交和可重复读之下的当前读是锁实现的, 串行化之下只有当前读, 所以串行化整个就是使用锁实现的
- MySQL实现当前读是通过共享锁 + 排它锁 + Next-Key Lock实现的
- 每次对行数据进行读取的时候, 加共享锁. 此时就不允许修改, 但是其他事物是可以读取的, 所以每次都可以读取到最新数据
- 每次对行数据进行修改的时候加排它锁, 不允许其他事物读取和修改. 这种情况下其他事物读取的数据也一定是最新的数据
- 每次对范围行数据进行读取的时候, 对这个范围加一个范围共享锁
- 每次对范围行数据进行修改的时候, 读这个范围加一个范围排它锁
3. 复习 : 隐藏字段, undo log版本链
回顾一下undo日志的版本链, 对于使用InnoDB存储引擎的表来说, 它的聚簇索引记录中都包含两个必要的隐藏列
- trx_id : 每次一个事物对某条聚簇索引记录进行改动时, 都会把该事务的事务id赋值给trx_id隐藏列
- roll_pointer : 每次对某条聚簇索引记录进行改动时, 都会把旧的版本写入到undo日志中, 然后这个隐藏列就相当于一个指针, 可以通过它来找到该记录修改前的信息
- 还有一个隐藏字段, 是在自己没有提供主键并且没有非空唯一索引的时候数据库自动生成的作为聚簇索引管理信息的列(row_id)
举例 : student表数据如下:
select * from student;
id | name | class |
---|---|---|
1 | 张三 | 一班 |
假设插入该记录了的事务id为8, 那么此刻该条记录的示意图如下所示:
insert undo只在是事务回滚时起作用, 当事务提交后, 该类型的undo日志就没用了, 它占用的Undo log segment也会被系统回收 (也就是该undo日志占用的undo页面链表要么被重用, 要么被释放)
假设之后两个事务id分别为10, 20的事务对这条记录进行update操作, 操作流程如下:
发生时间顺序 | 事物10 | 事物20 |
---|---|---|
1 | BEGIN | |
2 | BEGIN | |
3 | update student set name = “李四” where id = 1; | |
4 | update student set name = “王五” where id = 1; | |
5 | commit; | |
6 | update student set name = “钱七” where id = 1; | |
7 | update student set name = “宋八” where id = 1; | |
8 | commit; |
# 能不能在两个事务中交叉更新同一条记录呐? 不能! 这不就是一个事物中修改了另一个未提交事务修改过的数据? 就是脏写.
# InnoDB使用锁来保证不会有脏写的情况的发生, 也就是在第一个事物更新某条记录时就会给这条记录加锁, 另一个事物再次更新时就需要等待第一个事物提交了, 把锁释放之才可以继续更新
每次对记录进行改动, 都会记录一条undo日志, 每条undo日志也都有一个roll_pointer属性 (注意 : insert操作对应的undo日志没有该属性, 因为该记录并没有更早的版本),可以将这些undo日志都连起来, 串成一个链表
- 注意: 一定要知道insert undo log是没有roll pointer属性的, 这条记录插入之前怎么可能有这条记录? 不可能有的, 有的同学会认为那如果是插入记录之后对这个记录进行一次修改呐, 要注意, 那你是修改在后, 也是你的update undo log的roll pointer属性指向insert undo log
- 所以也就是说 : insert undo log一定是链表的尾节点
那么为什么delete操作导致的 update undo log也是有roll pointer属性的?
- 其实既然设计者都已经将delete的undo日志称之为update undo log了, 那么所以说其实就是和update操作导致的undate undo log是一样的, 所以肯定是有roll pointer的
- 而且其实只要是我们了解了MySQL中删除记录的操作, 其实刚刚开始的时候delete操作只是将对应记录的delete mark值修改为了1, 但是这个时候这个记录物理层面还是存在的, 因为这个删除的记录还是有可能是会恢复的, 所以我们是没有直接加到页中的垃圾链表中的, 这个时候我们需求就是通过undo 日志链一定是要能回滚的, 那么肯定就是要能恢复, 那么如果在删除以前做过更新, 那么删除操作的undo log不是就需要指向更新操作的undo log?
对该记录每次更新后, 都会将旧值放到一条undo日志中, 就算是该记录的一个旧版本, 随着更新次数的增多, 所有的版本都会被roll_pointer属性连接成一个链表, 我们把这个链表称之为版本链, 版本链的头结点就是当前记录最新的值
- 注意: 上图中的undo日志都是update undo log, 没有insert undo log, 每次update操作的时候都会生成一个update undo log, 里面记录了修改之前的列信息
4. MVCC实现原理之ReadView
MVCC 的实现依赖于: 隐藏字段, Undo Log, Read View.
4.1什么是ReadView
在MVCC机制中, 多个事物对同一个行记录进行更新会产生多个历史快照, 这些历史快照保存在Undo log里, 如果一个事务想要查询这个行记录, 需要读取哪个版本的行记录呐? 这时就需要使用到ReadView了, 它帮我们解决了行的可见性问题
ReadView就是事务在使用MVCC机制进行快照读操作时产生的读视图. 当事务启动时, 会生成数据库系统当前的一个快照, InnoDB为每个事务构造了一个数组, 用来记录并维护系统当前活跃事物的ID ("活跃"指的就是, 启动了但是还没提交)
4.2 设计思路
使用read uncommitted隔离级别的事务, 由于可以读到未提交事务修改过的记录, 所以直接读取记录的最新版本就好了
使用serialiable隔离级别的事物, InnoDB规定使用加锁的方式来访问记录
- 使用了加锁的方式读取数据, 那么肯定也是不用走MVCC机制的, 加了锁之后就是按照锁的规定完成读取即可, 因为加了锁了, 所以其实就是串行的执行的, 就是你执行完成之后我执行即可
使用read committed和repeatable read隔离级别的事务, 都必须保证读到了已经提交的事务修改过的记录, 假如另一个是事务已经修改了记录但是尚未提交, 是不能直接读取最新版本的记录的, 核心问题就是需要判断一下版本链中哪个版本是当前事物可见的, 这是readview要解决的主要问题:
这个ReadView中主要包含4个比较重要的内容, 分别如下:
-
creator_trx_id : 创建这个Read View的事务ID
# 说明 : 只有在对表中的记录做改动时(执行insert, delete, update这些语句时) 才会为事务分配事务id, 否则在一个只读事务中的事务id值都默认为0 * 如果是select操作在事务中执行, 那么MySQL是会为这个查询操作分配一个伪事务id的
-
trx_ids : 表示在生成ReadView的时候当前系统中活跃的读写事务的事务id列表
- 注意 : 是在生成ReadView的时候的活跃的读写事物的事物id列表
-
up_limit_id : 活跃的事物中最小的事物ID
-
low_limit_id : 表示生成ReadView时系统中应该分配给下一个事物的id值. low_limit_id是系统中最大的事物id值, 这里要注意是系统中的事物id, 需要区别于正在活跃是事物ID
- 注意 : up_limit_id是最小值, low_limie_id是最大值, 刚刚是反过来的, 一定要注意
# 注意 : low_limit_id并不是trx_ids中的最大值, 事物id是逐增分配的. 比如, 现在有id为1, 2, 5的这三个事物, 之后id为5的事务提交了. 那么一个新的读事务在生成ReadView时, trx_ids就包括1和2,但是此时up_limit_id的值就是1, low_limit_id的值就是6, 不是3, 不是最大的活跃事务id + 1
举例:
trx_ids为trx2, trx3, trx5和trx8的集合, 系统的最大事务ID(low_limit_id)为trx8 + 1(如果之前没有其他的新增事务), 活跃的最小事务ID(up_limit_id)为trx2.
4.3 ReadView的规则
有了这个ReadView, 这样在访问某条记录时, 只需要按照下边的步骤判断记录的某个版本是否可见
- 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同, 意味着当前事物在访问它自己修改过的记录, 所以该版本可以被当前事务访问
- 如果被访问版本的trx_id属性值小于ReadView中的up_limit_id值, 表明生成该版本的事物在当前事物生成ReadView之前已经提交了, 所以该版本可以被当前事物访问
- 如果被访问版本的trx_id属性值大于或等于ReadView中的low_limit_id值, 表明生成该版本的事物在当前事物生成ReadView之后才开启, 所以该版本不可以被当前事物访问到
- 如果被访问版本的trx_id属性值在ReadView的up_limit_id和low_limit_id之间, 那么就需要判断一下trx_id属性值是不是在trx_ids列表中
- 如果在, 说明创建ReadView时生成该版本的事务还是活跃的, 该版本不可以被访问
- 如果不在, 说明创建ReadView时生成该版本的事物已经被提交, 该版本可以被访问
4.4MVCC整体操作流程
了解了这些概念之后, 我们来看下当查询一条记录的时候, 系统如何通过MVCC找到它:
- 首先获取事务自己的版本号, 也就是事务ID
- 获取ReadView;
- 查询得到的数据, 然后与ReadView中的事物版本号进行比较
- 如果不符合ReadView规则, 就需要从Undo Log中获取历史快照
- 最后返回符合规则的数据
如果某个版本的数据对当前事务不可见的话, 那就顺着版本连找到下一个版本的数据, 继续按照上边的步骤判断可见性, 依次类推, 知道版本链中的最后一个版本. 如果最后一个版本也不可见的话, 那么就意味着该条记录对该事物完全不可见, 查询结果就不包含该记录.
-
一定注意: 我们最开始找的时候肯定是找行记录, 然后从行记录的roll_pointer属性找到undo log, 然后跟着undo log往下继续找, 一直到找到当前记录的版本链中有一个符合ReadView规则的版本为止(第一次找到的就是最后修改的, 此时这个版本就是我们要返回的版本)
-
注意: MVCC是找到某个行记录之后沿着版本连一直找, 一直是沿着roll_pointer找, 但是rollback(回滚)不是的, 回滚的时候由于是为每个undo日志都维护了序号的, 是沿着需要从大到小一直往前回滚, 两者的机制完全是不同的, 这一点一定要清楚
# InnoDB中, MVCC是通过Undo Log + ReadView进行数据读取, Undo log保存了历史快照, 而ReadView规则则帮助我们判断当前版本数据是否可见
-
所以Undo Log和ReadView都是快照, Undo Log是对这样记录各个版本的一个快照, 而ReadView是为了实现MVCC, 所以在执行一些事物中的select操作时的一个快照
- 但这这两者快照都不是说是直接就是存的数据快照, 而是逻辑快照, 通过快照中存储的数据能得到我们想要的版本数据而已
在隔离级别为读已提交(Read Committed)时, 一个事务中的每一次select查询都会重新获取一次ReadView.
如表所示:
事物 | 说明 |
---|---|
begin; | |
select * from student where id > 2; | 获取一次ReadView |
… | |
select * from student where id > 2; | 获取一次ReadView |
commit; |
注意, 此时执行同样的查询语句也是会重新获取一次ReadView, 这时如果ReadView不同, 就可能产生不可重复读或者幻读的情况
当隔离级别为可重复读的时候, 就避免了不可重复读, 这是因为一个事物只在第一次select的时候会获取一次ReadView, 而后面所有的select都会复用这个ReadView, 如下表所示:
事物 | 说明 |
---|---|
begin; | |
select * from user where id > 2; | 获取一次ReadView |
… | |
select * from user where id > 2; | 使用第一次获取的ReadView |
commit; |
5.总结:
这里介绍了MVCC在read committed, repeatable read这两种隔离级别的事务在执行快照读操作时访问记录版本链的过程. 这样使不同事务的读-写, 写-读操作并发执行, 从而提升系统性能
核心点在于readview的原理, read committed, repeatable read这两个隔离级别的很大不同就是生成ReadView的时机不同:
- read committed在每一次进行普通select操作前都会生成一个readview
- repeatable read只是在第一次进行普通select操作前会生成一个readView, 之后的查询操作都重复使用这个ReadView就好了
# 说明: 我们之前说执行delete语句或者更新主键的update与并不会立刻把对应记录完全从页面中删除, 而是执行一个所谓的delete mark操作, 相当于只是对记录打上了一个删除标记位, 这主要就是为MVCC服务的.
通过MVCC我们可以解决:
- 读写之间阻塞的问题 : 通过MVCC可以让读写互相不阻塞, 即读不阻塞写, 写不阻塞读, 这样就可以提升事务并发处理能力
- 降低了死锁的概率 : 这是因为MVCC采用了乐观锁的方式, 读取数据时并不需要加锁, 对于写操作, 也只是锁定必要的行
- 既然没有加锁肯定就会降低死锁的概率
- 解决快照读的问题 : 我们查询数据库在某个时间点的快照时, 只能看到这个时间点之前事物提交更新的结果, 而不能看到这个时间点之后事物提交的更新结果
- 也就是实现了快照读