4. MVCC实现原理之ReadView
MVCC 的实现依赖于:隐藏字段、Undo Log、Read View
4.1什么是ReadView
在MVCC机制中,多个事务对同一个行记录进行更新会产生多个历史快照,这些历史快照保存在Undo Log里。如果一个事务想要查询这个行记录,需要读取哪个版本的行记录呢?这时就需要用到ReadView了,它解决了行的可见性问题
ReadView就是事务A在使用MVCC机制进行快照读操作时产生的读视图。当事务启动时,会生成数据库系统当前的一个快照,InnoDB为每个事务构造了一个数组,用来记录并维护系统当前活跃事务的ID(“活跃"指的就是,启动了但还没提交)
4.2设计思路
使用READ UNCONNMITTED隔离级别的事务,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。
使用SERIALIZABLE隔离级别的事务,InnoDB规定使用加锁的方式来访问记录。
使用 READ COMMITTED 和 REPEATABLE READ 隔离级别的事务,都必须保证读到 已经提交了的 事务修改过的记录。假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是需要判断一下版本链中的哪个版本是当前事务可见的,这是ReadView要解决的主要问题。
这个ReadView中主要包含4个比较重要的内容,分别如下:
1.creator_trx_id ,创建这个 Read View 的事务 ID。
说明:只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0。
2.trx_ids ,表示在生成ReadView时当前系统中活跃的读写事务的 事务id列表 。
3.up_limit_id ,活跃的事务中最小的事务 ID。
4.low_limit_id ,表示生成ReadView时系统中应该分配给下一个事务的 id 值。low_limit_id 是系统最大的事务id值,这里要注意是系统中的事务id,需要区别于正在活跃的事务ID。
注意:low_limit_id并不是trx_ids中的最大值,事务id是递增分配的。比如,现在有id为1,2,3这三个事务,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时,trx_ids就包括1和2,up_limit_id的值就是1,low_limit_id的值就是4。
举例:
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 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。20可以访问自己
如果被访问版本的trx_id属性值小于ReadView中的 up_limit_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。10可以访问最新
如果被访问版本的trx_id属性值大于或等于ReadView中的 low_limit_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。(20被10阻塞)
如果被访问版本的trx_id属性值在ReadView的 up_limit_id 和 low_limit_id之间,那就需要判断一下trx_id属性值是不是在 trx_ids 列表中。
如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问。(10没提交,不能访问最新)*
如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。(10提交之后,就能访问最新)
4.4 MVCC整体操作流程
了解了这些概念之后,来看下当查询一条记录的时候,系统如何通过MVCC找到它:
- 首先获取事务自己的版本号,也就是事务 ID;
- 获取 ReadView;
- 查询得到的数据,然后与 ReadView 中的事务版本号进行比较;
- 如果不符合 ReadView 规则,就需要从 Undo Log 中获取历史快照;
- 最后返回符合规则的数据。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
InnoDB中,MVCC是通过Undo Log + Read View进行数据读取,Undo Log保存了历史快照,而Read View规则帮我们判断当前版本的数据是否可见。
在隔离级别为读已提交(Read Committed)时,一个事务中的每一次 SELECT 查询都会重新获取一次Read View。
如表所示:
事务 | 说明 |
begin; | |
select * from student where id >2; | 获取一次Read View |
… | |
select * from student where id >2; | 获取一次Read View |
commit; |
注意,此时同样的查询语句都会重新获取一次Read View,这时如果Read View 不同,就可能产生不可重复读或者幻读的情况。
当隔离级别为可重复读的时候,就避免了不可重复读,这是因为一个事务只在第一次SELECT的时候会获取一次Read View,而后面所有的SELECT都会复用这个Read View,如下表所示:
5. 举例说明
假设现在student表中只有一条由事务id
为8
的事务插入的一条记录:
SELECT * FROM student ; /* +----+--------+--------+ | id | name | class | +----+--------+--------+ | 1 | 张三 | 一班 | +----+--------+--------+ 1 row in set (0.07 sec) */
MVCC只能在READ COMMITTED和REPEATABLE READ两个隔离级别下工作。接下来看一下READ COMMITTED
和REPEATABLE READ
所谓的生成ReadView的时机不同到底不同在哪里
5.1 READ COMMITTED隔离级别下
READ COMMITTED :每次读取数据前都生成一个ReadView
现在有两个 事务id
分别为 10
、 20
的事务在执行
# Transaction 10 BEGIN; UPDATE student SET name="李四" WHERE id=1; UPDATE student SET name="王五" WHERE id=1; # Transaction 20 BEGIN; # 更新了一些别的表的记录(为了分配事务id) ...
说明:事务执行过程中,只有在第一次真正修改记录时(比如使用INSERT、DELETE、UPDATE语句),才会被分配一个单独的事务id,这个事务id是递增的。所以我们才在事务2中更新一些别的表的记录,目的是让它分配事务id。
此刻,表student 中id为1的记录得到的版本链表如下所示:
假设现在有一个使用 READ COMMITTED 隔离级别的事务开始执行:
# 使用READ COMMITTED隔离级别的事务 BEGIN; # SELECT1:Transaction 10、20未提交 SELECT * FROM student WHERE id = 1; # 得到的列name的值为'张三'
这个·SELECT1·的执行过程如下:
步骤1∶在执行SELECT语句时会先生成一个ReadView ,ReadView的trx_ids列表的内容就是[10,20],up_limit_id为10, low_limit_id为21, creator_trx_id为0。
步骤2:从版本链中挑选可见的记录,从图中看出,最新版本的列name的内容是’王五’,该版本的trx_id值为10,在trx_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本
步骤3:∶下一个版本的列name的内容是’李四’,该版本的trx_id值也为10,也在trx_ids列表内,所以也不符合要求,继续跳到下一个版本
步骤4:下一个版本的列name的内容是’张三’,该版本的trx_id值为8,小于ReadView中的up_limit_id值10,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为‘张三’的记录
之后,把 事务id 为 10 的事务提交一下:
# Transaction 10 BEGIN; UPDATE student SET name="李四" WHERE id=1; UPDATE student SET name="王五" WHERE id=1; COMMIT;
然后再到 事务id
为 20
的事务中更新一下表 student
中 id
为 1
的记录:
# Transaction 20 BEGIN; # 更新了一些别的表的记录 ... UPDATE student SET name="钱七" WHERE id=1; UPDATE student SET name="宋八" WHERE id=1;
此刻,表student中 id
为 1
的记录的版本链就长这样:
然后再到刚才使用 READ COMMITTED
隔离级别的事务中继续查找这个 id
为 1
的记录,如下:
# 使用READ COMMITTED隔离级别的事务 BEGIN; # SELECT1:Transaction 10、20均未提交 SELECT * FROM student WHERE id = 1; # 得到的列name的值为'张三' # SELECT2:Transaction 10提交,Transaction 20未提交 SELECT * FROM student WHERE id = 1; # 得到的列name的值为'王五'
这个SELECT2的执行过程如下:
步骤1∶在执行SELECT语句时会又会单独生成一个ReadView,该ReadView的trx_ids列表的内容就是[20],up_limit_id为20,low_limit_id为21, creator_trx_id为0。
步骤2:从版本链中挑选可见的记录,从图中看出,最新版本的列name的内容是’宋八’,该版本的trx_id值为20,在trx_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
步骤3∶下一个版本的列name的内容是‘钱七’,该版本的trx_id值为20,也在trx_ids列表内,所以也不符合要求,继续跳到下一个版本
步骤4∶下一个版本的列name的内容是’王五’,该版本的trx_id值为10,小于ReadView中的up_limit_id值20,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为’王五’的记录。
以此类推,如果之后事务id为20的记录也提交了,再次在使用READ COMMITED 隔离级别的事务查询表student中id值为1的记录时,得到的结果就是'宋八'了,具体流程我们就不分析了。
强调:使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView
5.2 REPEATABLE READ隔离级别下
使用 REPEATABLE READ
隔离级别的事务来说,只会在第一次执行查询语句时生成一个 ReadView
,之后的查询就不会重复生成了。
比如,系统里有两个 事务id
分别为 10
、 20
的事务在执行:
# Transaction 10 BEGIN; UPDATE student SET name="李四" WHERE id=1; UPDATE student SET name="王五" WHERE id=1; # Transaction 20 BEGIN; # 更新了一些别的表的记录 ...
此刻,表student 中 id
为 1
的记录得到的版本链表如下所示:
假设现在有一个使用 REPEATABLE READ
隔离级别的事务开始执行:
# 使用REPEATABLE READ隔离级别的事务 BEGIN; # SELECT1:Transaction 10、20未提交 SELECT * FROM student WHERE id = 1; # 得到的列name的值为'张三'
这个SELECT1的执行过程如下:
步骤1:在执行·SELECT·语句时会先生成一个ReadView,ReadView的trx_ids列表的内容就是[10,20],up_limit_id为10, low_limit_id为21, creator_trx_id为0。
步骤2:然后从版本链中挑选可见的记录,从图中看出,最新版本的列name的内容是’王五’,该版本的trx_id值为10,在trx_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
步骤3:下一个版本的列name的内容是’李四’,该版本的trx_id值也为10,也在trx_ids列表内,所以也不符合要求,继续跳到下一个版本。
步骤4∶下一个版本的列name的内容是’张三’,该版本的trx_id值为8,小于ReadView中的up_limit_id值10,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为’张三’的记录
之后,我们把事务id为10的事务提交一下,就像这样:
# Transaction 10 BEGIN; UPDATE student SET name="李四" WHERE id=1; UPDATE student SET name="王五" WHERE id=1; COMMIT;
然后再到 事务id
为 20
的事务中更新一下表 student 中 id 为 1 的记录:
# Transaction 20 BEGIN; # 更新了一些别的表的记录 ... UPDATE student SET name="钱七" WHERE id=1; UPDATE student SET name="宋八" WHERE id=1;
此刻,表student 中 id 为 1 的记录的版本链长这样:
然后再到刚才使用 REPEATABLE READ
隔离级别的事务中继续查找这个id
为 1
的记录,如下:
# 使用REPEATABLE READ隔离级别的事务 BEGIN; # SELECT1:Transaction 10、20均未提交 SELECT * FROM student WHERE id = 1; # 得到的列name的值为'张三' # SELECT2:Transaction 10提交,Transaction 20未提交 SELECT * FROM student WHERE id = 1; # 得到的列name的值仍为'张三'
SELECT2的执行过程如下:
步骤1:因为当前事务的隔离级别为REPEATABLE READ,而之前在执行SELECT1时已经生成过ReadView了,所以此时直接复用之前的ReadView,之前的ReadView的trx_ids列表的内容就是[10,20],up_limit_id为10,low_limit_id为21, creator_trx_id为0。
步骤2:然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是’宋八’,该版本的trx_id值为20,在trx_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本
步骤3:下一个版本的列name的内容是’钱七’,该版本的trx_id值为20,也在trx_ids列表内,所以也不符合要求,继续跳到下一个版本
步骤4∶下一个版本的列name的内容是’王五’,该版本的trx_id值为10,而trx_ids列表中是包含值为10的事务id的,所以该版本也不符合要求,同理下一个列name的内容是‘李四’的版本也不符合要求。继续跳到下一个版本
步骤5:下一个版本的列name的内容是’张三’,该版本的trx_id值为8,小于ReadView中的up_limit_id值10,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为‘张三’的记录。
两次SELECT查询得到的结果是重复的,记录的列c值都是‘张三',这就是可重复读的含义。如果我们之后再把事务id为20的记录提交了,然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个id为1的记得到的结果还是‘张三',具体执行过程大家可以自己分析一下。