MVCC多版本并发控制
MVCC (Multiversion Concurrency Control) 中文全程叫多版本并发控制,是现代数据库(包括 MySQL、Oracle、PostgreSQL 等)引擎实现中常用的处理读写冲突的手段,目的在于提高数据库高并发场景下的吞吐性能。
一、undolog
我们在进行数据更新操作的时候,不仅会记录 redolog 日志,而且也会记录 undolog 日志,如果因为某些原因导致事物回滚,那么这个时候 MySQL 就需要回滚保证事物的一致性,使用 undolog 将数据恢复到事物开始之前的状态。
例如删除一条数据:
delete from T where id = 1;
此时 undolog 日志就会记录一条对应的 insert 语句(反向操作的语句) ,保证在事物回滚的时候,可以把数据还原回去。
insert into T(id) values (1)
更新一条数据:
---修改之前name=张三
update user set name = "李四" where id = 1;
undolog 日志就会记录一条相反的 update 语句;
update user set name = "张三" where id = 1;
undolog 日志是 MVCC 多版本并发控制重要的数据来源
二、视图
在 MySQL 里,有两个视图
的概念:
- 一个是 view。它是一个用于查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果,创建视图的语法是
create view...
而它的查询方式跟表一样 - 另外一个是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC 和 RR 隔离级别的实现
三、MVCC 实现原理
1. 数据版本可见性规则
在 InnoDB 中,每一行记录都有三个隐藏列:trx_id
,roll_ptr
,db_row_id
(如果没有主键,则还会多一个隐藏的主键列)
- trx_id:记录
最近更新这条记录的事务 ID
- roll_ptr:表示
指向改行回滚段的指针
,InnoDB 通过整个指针找到之前的版本,该行记录上所有的旧版本,在 undolog 中通过链表的形式组织 - db_row_id:行标识(隐藏单调自增 ID)
而且在 InnoDB 里面每个事务都有一个唯一的事务ID
,叫做 transaction id,它是在事务开启的时候向事务系统申请的,是按申请顺序严格递增
的
每次事务更新的时候,都会生成一个新的版本数据,并且把 transaction id 赋值给整个数据版本的事务 ID
,即上面的trx_id
,同时旧的版本要保留,通过roll_ptr
直接找到上一个版本
以下就是一条记录被多个事务变更后的流程:
图中是同一行数据的四个版本,当前最新版本是 V4,k=22,他是被 transaction id 为 25 的事务更新的,因此他的 trx_id=25,其中虚线部分就是用 roll_ptr 指针来串联起来的 undolog 日志
图中的 V1,V2,V3 并不是物理上真实存在的,而是每次需要的时候,根据当前版本和 undolog 日志计算出来的,比如我现在要 V3 的数据,就需要从 V4 找出上个版本然后返回
InnoDB 为每个事务构造了一个数组
,称之为 m_ids,用来保存这个事务启动瞬间,当前正在活跃的所有事务ID
,活跃指的是事务启动了,但还没有提交
;
数组里面事务 ID 的最小值为 low_limit_id,最大值为 up_limit_id + 1
,这个视图数组和最大值最小值就组成了当前事物的一致性视图,而数据版本的可见性就是基于数据的 trx_id 和这个一致性视图对比结果得到的
这样,对于当前事务的启动瞬间来说,一条记录的数据版本trx_id,有以下几种可能:
- 如果在绿色部分,表示这个版本是已提交事物的或者是当前事务自己生成的,这个数据是可见的
- 如果是红色部分,表示这个版本是由将来的事务生成的,是不可见的
- 如果在黄色部分,那就有两种可能
a. 若trx_id在活跃事务数组中,则表示这个版本是由没提交的事务生成的,不可见
b. 若trc_id不在活跃事务数组中,则表示这个版本是已经提交了的事务生成的,可见
2. 案例实践
id | k | row_id | roll_ptr |
---|---|---|---|
1 | 1 | 99 | xxx |
这里我们不妨做下假设:
- 事务A开始前,系统里只有一个活跃事务ID是99
- 事务A,B,C的版本分别是100,101,102,且当前系统里只有这四个事务
- 三个事务开始前,(1,1)这一行数据的trx_id是90
这样事务A的活跃事务数组就是[99,100],事务B的视图数组是[99,100,101],事务C的视图数组是[99,100,101,102]
从图中可以看出,第一个有效更新是事务C,把数据k=1改成了k=2,这时候,数据最新版本trx_id = 102,而trx_id=90则成为了历史版本
第二个有效更新是事务B,把数据k=2改成了k=3,这时候数据最新版本trx_id=101,而102又成为了历史
这个时候A事务要来读取数据,见第8行,事务A的视图数组是[99,100],当然读数据都是从当前版本读起,所以事务A查询语句的读取数据流程是:
- 找到k=3的时候,判断trx_id=101,在事务A的视图数据最大值 100+1,处于红色区域不可见
- 接着根据roll_ptr的指针,从undolog日志中找到上一个版本trx_id = 102,也是处于红色区域,不可见
- 继续往前找,找到trx_id=90,此时比A事务视图数组最低值还要小,处于绿色区域,所以是可见的,所以事务A查询的数据k=1
问题:但是如果按照一致性读,事务B在第6行的更新中,不是应该看到k=1嘛,算出来应该k=2,为什么会算出k=3呢?
是的,如果在事务B更新之前,也就是第6行之前查询,k确实是1,但是当事务B要去更新数据的时候,就不能在历史的版本上更新了
,否则C事务的更新就丢失了,因此事务B此时的set k = k+1是在k=2的基础上操作的
所以这里就引出了这样一条规则: 更新数据都是先读后写,而这个读,只能读当前的值,称为当前读
,当然只能读取到提交的数据
所以在更新的时候,当前读拿到的k=2,更新后k=3,这时候trx_id = 101,当第7行事务B查询语句的时候,发现trx_id=101,自己的版本号也是101,是自己的更新,可以直接使用,所以第7行查出来的k=3
一个版本数据,对于一个事务视图来说,除了自己更新的总可以见以外,有三种情况:
- 版本未提交,不可见
- 版本已提交,但是是在视图创建后提交的,不可见
- 版本已提交,而且是在视图创建前提交的,可见
以上都是基于可重复读隔离级别来看数据的,那么如果隔离级别是读已提交下,事务A和事务B查询到的k,分别又是多少呢?
事务A的查询语句,第9行的视图数组是在执行这个语句的时候创建的,看图上k=2,k=3都在事务A查询语句之前,但是这个时候k=3还没有提交,属于情况1,不可见,k=2已经提交了,属于情况3,可见,所以第9行查询出来的是k=2
当然,事务B查询出来的k=3
四、总结
可重复读的核心就是一致性读,而事务更新数据的时候,只能用当前读
,如果当前记录的行锁被其他事务占用的话,就需要进入锁等待
而都提交的逻辑和可重复读逻辑类似,他们的主要区别是:
- 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
- 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。
我是一零贰肆,一个关注Java技术和记录生活的博主。
欢迎扫码关注“一零贰肆”的公众号,一起学习,共同进步,多看路,少踩坑。