undo日志 与 MVCC版本链

一、前情提要

隐藏列

    聚簇索引的记录 除了会保存完整的用户数据以外 ,还会自动添加名为trx_idroll_pointer的隐藏列,如果用户没有在表中定义 主键 以及 非NULL 的 UNIQUE键,还会自动添加一个名为row_id的隐藏列。
    其中 trx_id列是 某个 对 这个聚簇索引记录 做改动的语句 所在的事务对应的 事务ID【服务器在内存中维护一个全局变量为事务分配 ID ,每次把该变量自增1。】
    roll_pointer本质就是一个指针,指向记录对应的undo日志,可以通过它来找到 该记录 修改前的信息 。

二、事务回滚-undo日志

    InnoDB 存储引擎在实际进行增、删、改一条记录时,为之后回滚 起见,都需要生成对应的记录—— undo 日志。
     在一个事务执行过程中,可能混着执行 insert、delete、update 不同操作,而不同操作产生的 undo日志 格式是不同的,这些 undo 日志会被放置在专门的 页中。日志记录 与 日志记录之间,一条续一条,是“亲密无间”的,而页 与 页之间,就像 B+树索引那样,会形成 双链表

(并且,对链表的分类如下:
     一个事务执行过程需要 2 个 Undo页面的链表,一个称之为 insert undo 链表,另一个称之为 update undo 链表。
     又规定 对 普通表 和 临时表 的记录改动时 产生的 undo日志 要分别记录。
     所以在一个事务中最多有 4 个以 Undo页面为节点组成的链表,即 普通表的 insertt undo 链表、普通表的 update undo 链表、临时表的… …)

  • 插入一条记录时:
        会把这条记录的 主键值 记下来,之后回滚的时候只需要把 这个主键值对应的记录 删掉就好了。如果记录中的主键包含多个列,那么每个列占用的存储空间大小 和 对应的真实值 都需要记录下来。
  • 删除一条记录时:
        会把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录 插入到表中 就好了。
        插入到页面中的记录会根据 记录头信息中的 next_record 属性组成一个 单向链表,把这个链表称之为正常记录链表;被删除的记录也会根据记录头信息中的 next_record 属性组成一个链表,只不过这个链表中的记录占用的存储空间可以被重新利用,所以也称这个链表为垃圾链表
  • 阶段一【delete mark】:
        仅仅将记录的 delete_mask 标识位设置为 1 ,当该删除语句所在的事务提交之后,会有专门的线程后来真正地把记录删除掉。所谓真正的删除就是把该记录从正常记录链表中移除,并且加入到垃圾链表中。
  • 阶段二:【purge】
        当该删除语句所在的事务提交之后,会有专门的线程后来真正的把记录删除掉,并调整一些页面的其他信息。

    把阶段二执行完了,这条记录就算是真正的被删除掉了。这条已删除记录占用的存储空间也可以被重新利用了。

  • 修改一条记录时:
        在执行 UPDATE 语句时,InnoDB 对 更新主键 和 不更新主键这两种情况有截然不同的处理方案。
  • 不更新主键的情况
        如果对于被更新的每个列来说,更新后的列 和 更新前占用的存储空间一样大,那么就可以进行 就地更新 ,也就是直接在原记录的基础上修改对应列的值。
        否则,先删除掉旧记录,(也就是放到垃圾链表中)再插入新记录。
  • 更新主键的情况
        因为在聚簇索引中,记录 是按照主键值的大小 连成了一个单向链表的,如果我们更新了某条记录的主键值,意味着这条记录在聚簇索引中的位置将会发生改变,InnoDB 在 聚簇索引 中分了两步处理:
    (1)将旧记录进行 delete mark 操作(不是真正删除掉,只是标记,放到垃圾链表中,删除的操作是由)
    (2)根据更新后 各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)。

    只在事务回滚时起作用,当事务提交后,该类型的undo日志就没用了,它占用的Undo Log Segment也会被系统回收(也就是该undo日志占用的Undo页面链表要么被重用,要么被释放)。虽然真正的insert undo日志占用的存储空间被释放了,但是roll_pointer的值并不会被清除

三、MVCC

    如上所述,每次对 记录 进行改动,都会记录一条 undo日志,把旧值相关信息放入日志,就相当于 该记录的旧版本,每条 undo日志都有一个 roll_pointer 属性(INSERT操作对应的 undo日志没有该属性,因为该记录并没有更早的版本),那么所有的版本都会被roll_pointer属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含 生成该版本时对应的事务ID。
    对于使用 读未提交 隔离级别的事务来说,(由于可以读到未提交事务修改过的记录),所以直接读取记录的最新版本就好了;
    对于使用 串行 隔离级别的事务来说,InnoDB 规定使用加锁的方式来访问记录;
    对于使用 读提交 和 可重复读 隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。 为此,提出了一个 ReadView 的概念,这个ReadView中主要包含4个比较重要的内容:

  • creator_trx_id:
    表示生成该 ReadView 的事务的事务ID。
  • m_ids:
    表示在生成 ReadView 时当前系统中活跃的 读写事务的 事务ID 列表
  • min_trx_id:
    表示在生成 ReadView 时当前系统中活跃的读写事务中 最小的事务ID,也就是m_ids中的最小值。
  • max_trx_id:
    表示生成 ReadView 时系统中应该分配给下一个事务的 ID 值
    ( 注意 max_trx_id 并不是m_ids中的最大值,事务 ID 是递增分配的。比方说现在有ID 为1,2,3这三个事务,之后 ID 为 3 的事务提交了。那么一个新的读事务在生成ReadView 时,m_ids 就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。)

    有了 ReadView,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:
(1)如果 被访问版本的trx_id属性值与 ReadView 中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
(2)如果 被访问版本的trx_id属性值小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务 在 当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。
(3)如果被访问版本的trx_id属性值 大于或等于 ReadView 中的 max_trx_id 值,表明生成该版本的事务在当前事务生成ReadView 后 才开启,所以该版本不可以被当前事务访问。
(4)如果被访问版本的trx_id属性值在 ReadView 的 min_trx_idmax_trx_id 之间,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

    如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。

    在MySQL中,读提交 和 可重复读 隔离级别的的一个非常大的区别就是它们生成 ReadView 的时机不同。

    以表hero为例,假设现在 表hero 中只有一条由 事务ID 为80的事务插入的一条记录:
在这里插入图片描述

mysql> SELECT * FROM hero;
+--------+--------+---------+
| number | name   | country |
+--------+--------+---------+
|      1 | 刘备   ||
+--------+--------+---------+
1 row in set (0.07 sec)

    接下来看一下 读提交 和 可重复读 生成 ReadView 的时机不同到底不同在哪里:

读提交:每次读取数据前都会生成一个独立的 ReadView


    比方说现在系统里有两个事务 ID 分别为100、200的事务在执行:

# Transaction 100
BEGIN;

UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;


# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...

    此刻,表hero 中 number为1 的记录得到的版本链表如下所示:
在这里插入图片描述

    假设现在有一个使用 读提交 隔离级别的事务开始执行:

# 使用 READ COMMITTED 隔离级别的事务
BEGIN;

# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; 
# 得到的列name的值为'刘备'

这个 SELECT1 的执行过程如下:
    在执行 SELECT 语句时会先生成一个 ReadView,ReadView 的m_ids列表的内容就是[100, 200],min_trx_id为100,max_trx_id为201,creator_trx_id为0。
    然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是’张飞’,该版本的 trx_id 值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
    下一个版本的列name的内容是’关羽’,该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
    下一个版本的列name的内容是’刘备’,该版本的trx_id值为80,小于 ReadView 中的 min_trx_id 值100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为’刘备’的记录。

    之后,我们把事务 ID 为100的事务,也就是插入”关羽“、”张飞“的记录提交一下:

# Transaction 100
BEGIN;

UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;

COMMIT;

    然后再到事务 ID 为200的事务中更新一下表 hero 中 number为1的记录:

# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;

    此刻,表 hero 中 numbe r为1的记录的版本链就长这样:

在这里插入图片描述
    然后再到刚才使用 读 提交 隔离级别的事务中继续查找这个number为1的记录,如下:

# 使用READ COMMITTED隔离级别的事务
BEGIN;

# SELECT1:Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'

# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'张飞'

    这个 SELECT2 的执行过程如下:
    在执行 SELECT 语句时又会单独生成一个 ReadView ,该ReadView的m_ids列表的内容就是[200](事务id为100的那个事务已经提交了,所以再次生成快照时就没有它了),min_trx_id为200,max_trx_id为201,creator_trx_id为0。
    然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的内容是’诸葛亮’,该版本的trx_id值为200,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
    下一个版本的列name的内容是’赵云’,该版本的trx_id值为200,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
    下一个版本的列 name 的内容是’张飞’,该版本的trx_id值为100,小于 ReadView 中的min_trx_id值200,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为’张飞’的记录。
    以此类推,如果之后事务 ID 为200的记录也提交了,再此在使用 读提交 隔离级别的事务中查询表 hero 中 number 值为1的记录时,得到的结果就是’诸葛亮’了,具体流程就不分析了。

可重复读: 只在第一次读取数据时生成一个ReadView


    对于使用 可重复读 隔离级别的事务来说,只会在第一次执行查询语句时生成一个 ReadView ,之后的查询就不会重复生成了。
    比方说现在系统里有两个事务 ID 分别为100、200的事务在执行:

# Transaction 100
BEGIN;

UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;

# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

    此刻,表 hero 中 number 为1的记录得到的版本链表如下所示:

在这里插入图片描述
    假设现在有一个使用 可重复读 隔离级别的事务开始执行:

# 使用REPEATABLE READ隔离级别的事务
BEGIN;

# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'

    这个 SELECT1 的执行过程如下:
    在执行 SELECT 语句时会 先生成一个ReadView,ReadView的m_ids列表的内容就是[100, 200],min_trx_id为100,max_trx_id为201,creator_trx_id为0。
    然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的内容是’张飞’,该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
    下一个版本的列 name 的内容是’关羽’,该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
    下一个版本的列 name 的内容是’刘备’,该版本的trx_id值为80,小于 ReadView 中的min_trx_id值 100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为’刘备’的记录。
    之后,我们把事务ID 为 100的事务提交一下,就像这样:

# Transaction 100
BEGIN;

UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;

COMMIT;

    然后再到事务 ID 为200的事务中更新一下表 hero 中number为1的记录:

# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;

    此刻,表 hero 中 number为1的记录的版本链就长这样:
在这里插入图片描述
    然后再到刚才使用 可重复读 隔离级别的事务中继续查找这个 number为1 的记录,如下:

# 使用REPEATABLE READ隔离级别的事务
BEGIN;

# SELECT1:Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'

# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值仍为'刘备'

    这个 SELECT2 的执行过程如下:
    因为当前事务的隔离级别为可重复读 ,而之前在执行 SELECT1 时已经生成过 ReadView 了,所以此时直接复用之前的 ReadView ,之前的 ReadView 的m_ids列表的内容就是[100, 200],min_trx_id为100,max_trx_id为201,creator_trx_id为0。
    然后从 版本链 中挑选可见的记录,从图中可以看出,最新版本的列name的内容是’诸葛亮’,该版本的trx_id值为200,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
    下一个版本的列 name 的内容是’赵云’,该版本的 trx_id 值为200,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
    下一个版本的列 name 的内容是’张飞’,该版本的trx_id值为100,而m_ids列表中是包含值为 100 的事务ID 的,所以该版本也不符合要求,同理下一个列 name 的内容是’关羽’的版本也不符合要求。继续跳到下一个版本。
    下一个版本的列 name 的内容是’刘备’,该版本的trx_id值为80,小于 ReadView 中的 min_trx_id 值100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为’刘备’的记录。
    也就是说两次 SELECT 查询得到的结果是重复的,记录的列c值都是’刘备’,这就是可重复读的含义。如果我们之后再把事务 ID 为 200 的记录提交了,然后再到刚才使用 可重复读 隔离级别的事务中继续查找这个 number 为 1 的记录,得到的结果还是’刘备‘。

MVCC 总结

    每次对 记录 进行改动,都会生成一条 undo日志 记录,把旧值相关信息放入日志,就相当于 该记录的旧版本,每条 undo日志都有一个 roll_pointer 属性(INSERT操作对应的 undo日志没有该属性,因为该记录并没有更早的版本),那么所有的版本都会被roll_pointer属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含 生成该版本时对应的事务ID。
    MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用 读提交、可重复读 这两种隔离级别的事务,在执行普通的 select 操作时访问记录的版本链的过程。
    对于不同的隔离级别,需要判断版本链中的版本,哪个是当前事务可见的,所以在事务进行快照读操作时 会生产数据库系统当前的一个快照 :读视图Read View。
     读提交、可重复读 这两个隔离级别的一个很大不同就是生成 ReadView 的时机不同,读提交 在每一次进行普通 select 操作前都会生成一个 ReadView,而 可重复读 只在第一次进行普通 select 操作前生成一个 ReadView,之后的查询操作都重复使用这个ReadView。
     ReadView 里维护了 生成该 ReadView 的事务的ID、当前系统中活跃的 读写事务的 事务ID 列表、最小事务ID、系统应该分配给下一个事务的 ID 值。遍历版本链,根据版本 ID 与 ReadView 维护的四个变量比较,可知 select 返回的结果集。

好处:

    使不同事务的 读-写、写-读 操作并发执行,从而提升系统性能。

    (之前说执行 delete 语句或者 更新主键的 updata 语句并不会立即把对应的记录完全从页面中删除,而是执行一个所谓的 delete mark 操作,相当于只是对记录打上了一个删除标志位,这主要就是为MVCC服务的。
MVCC只是在我们进行普通的SEELCT查询时才生效。)

    我们说insert undo在事务提交之后就可以被释放掉了,而update undo由于还需要支持MVCC,不能立即删除掉。

    所谓的 MVCC ,就是通过生成一个 ReadView ,然后通过 ReadView 找到符合条件的记录版本(历史版本是由 undo 日志构建的),其实就像是在生成 ReadView 的那个时刻做了一次时间静止(就像用相机拍了一个快照),查询语句只能读到在生成 ReadView 之前已提交事务所做的更改,在生成 ReadView 之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用 MVCC 时,读-写操作并不冲突。

    我们说过普通的 SELECT 语句在 READ COMMITTED 和 REPEATABLE READ 隔离级别下会使用到MVCC读取记录。在 READ COMMITTED 隔离级别下,一个事务在执行过程中每次执行SELECT 操作时都会生成一个 ReadView,ReadView 的存在本身就保证了事务不可以读取到未提交的事务所做的更改,也就是避免了脏读现象;REPEATABLE READ 隔离级别下,一个事务在执行过程中只有第一次执行SELECT操作才会生成一个ReadView,之后的 SELECT 操作都复用这个ReadView,这样也就避免了不可重复读和幻读的问题。

    事务利用MVCC进行的读取操作称之为 一致性读,或者一致性无锁读,有的地方也称之为快照读。
    所有普通的SELECT语句(plain SELECT)在 READ COMMITTED、    REPEATABLE READ隔离级别下都算是一致性读
    一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。

♥ MVCC不能解决幻读问题

    在 可重复读 隔离级别下,T1第一次执行普通的SELECT语句时生成了一个ReadView,之后T2向hero表中新插入了一条记录便提交了,ReadView并不能阻止T1执行UPDATE或者DELETE语句来对改动这个新插入的记录(因为T2已经提交,改动该记录并不会造成阻塞),但是这样一来这条新记录的trx_id隐藏列就变成了T1的事务id,之后T1中再使用普通的SELECT语句去查询这条记录时就可以看到这条记录了,也就把这条记录返回给客户端了。因为这个特殊现象的存在,你也可以认为InnoDB中的MVCC并不能完完全全的禁止幻读。

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值