第 21 章 一条记录的多幅面孔——事务的隔离级别与 MVCC

21.1 事前准备

CREATE TABLE hero ( 
    number INT, 
    NAME VARCHAR ( 100 ), 
    country VARCHAR ( 100 ), 
    PRIMARY KEY ( number ) 
) ENGINE = INNODB CHARSET = utf8;

INSERT INTO hero VALUES ( 1, '刘备', '蜀' );

21.2 事务隔离级别

在保证事务隔离性的前提下,使用不同的隔离级别,来尽量提高多个事务访问同一数据的性能。

21.2.1 事务并发执行遇到的问题
  1. 脏写(Dirty Write):一个事务修改了另一个未提交事务修改过的数据。
  2. 脏读(Dirty Read):一个事务读到了另一个未提交事务修改过的数据。
  3. 不可重复读(Non-Repeatable Read):一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交,该事务都能查询得到最新值。
  4. 幻读(Phantom):一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次查询时,能把另一个事务插入的记录也读出来。
21.2.2 SQL 标准中的四种隔离级别

按严重性:

脏写 > 脏读 > 不可重复读 > 幻读

隔离级别中文名脏读不可重复读幻读
READ UNCOMMITTED读未提交PossiblePossiblePossible
READ COMMITTED读已提交Not PossiblePossiblePossible
REPAEATABLE READ可重复读Not PossibleNot PossiblePossible
SERIALIZABLE串行化Not PossibleNot PossibleNot Possible

不论哪种隔离级别,都不允许脏写的情况发生。

21.2.3 MySQL 中支持的四种隔离级别

MySQL 的默认隔离级别为 REPEATABLE READ ,我们可以手动修改一下事务的隔离级别。

# 如何设置事务的隔离级别
# SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level;
SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;

#  查看当前会话默认的隔离级别
SHOW VARIABLES LIKE 'transaction_isolation';

21.3 MVCC 原理

21.3.1 版本链

对于使用 InnoDB 存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列:

  1. trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给 trx_id 隐藏列。
  2. roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo log 中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。

在这里插入图片描述

Tips:实际上 insert undo 只在事务回滚时起作用,当事务提交后,该类型的 undo log 就没用了。

假设之后有两个事务 id 分别为100和200的事务对这条记录进行 UPDATE 操作:

在这里插入图片描述

对该记录每次更新后,都会将旧值放到一条 undo log 中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,我们把这个链表称为 版本链,它的头节点就是当前记录最新的值,另外,每个版本还包含生成该版本时对应的事务 id。

在这里插入图片描述

21.3.2 ReadView
  1. 对于 READ UNCOMMITTED 级别的事务来说,直接读取记录的最新版本;
  2. 对于 SERIALIZABLE 级别的事务来说,使用加锁的方式来访问;
  3. 对于 READ COMMITED 和 REPAEATABLE READ 级别的事务来说,必须保证讲到已经提交了的事务修改过的记录,而不能直接读取最新版本版本的记录。那么怎么判断版本链中哪个版本是当前事务可见的?于是便有了 ReadView 的概念。

ReadView 主要包括以下4个重点:

  1. m_ids:表示在生成 ReadView 时当前系统中活跃的读写事务的**事务 id **列表
  2. min_trx_id:表示在生成 ReadView 时当前系统中活跃的读写事务中最小的**事务 id **,也就是 m_ids 中的最小值。
  3. max_trx_id:表示生成 ReadView 时系统中应该分配给下一个事务的 id 值。
  4. creator_trx_id:表示生成该 ReadView 的事务的 事务 id

TIPS:只有在对表中的记录做改动时才会为事务分配事务 id,否则在一个只读事务中的事务 id 都默认为 0。

通过 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_id 和 max_trx_id 之间,那就需要判断一下 trx_id 是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
public boolean isShow(long trx_id, long creator_trx_id, long min_trx_id, long max_trx_id, List<Long> m_ids) {
        if (trx_id == creator_trx_id) {
            return true;
        }
        if (trx_id < min_trx_id) {
            return true;
        }
        if (trx_id > max_trx_id) {
            return false;
        }
        return !m_ids.contains(trx_id);
    }

如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本,直到最后一个版本。

READ COMMITTED 和 REPEATABLE READ 生成 ReadView 的时机不同。

21.3.2.1 READ COMMITTED——每次读取数据前都生成一个ReadView
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;

# Transaction 200
BEGIN;

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

此时 number=1 的记录版本链:

在这里插入图片描述

使用 READ COMMITED 隔离级别的事务开始执行查询:

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

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

SELECT1 的执行过程如下:

  1. 在执行 SELECT 语句时会先生成一个 ReadView:

    {
    	m_ids:[100, 200],
        min_trx_id: 100,
        max_trx_id: 201,
        creator_trx_id: 0
    }
    
  2. 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name = ‘张飞’ ,trx_id = 100 ,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。

  3. 下一个版本的列 name = ‘关羽’ ,trx_id = 100 ,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。

  4. 下一个版本的列 name = ‘刘备’ ,trx_id = 80 ,小于 ReadView 中的 min_trx_id 值100 ,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为 ‘刘备’ 的记录。

之后,把事务id=100的事务提交,并使用事务id=200的事务继续更新

# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
COMMIT;

# Transaction 200
BEGIN;

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

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

此时 number=1 的记录版本链:

在这里插入图片描述

再次使用 READ COMMITED 隔离级别的事务开始执行查询:

# 使用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 的执行过程如下:

  1. 在执行 SELECT 语句时会先生成一个 ReadView:

    {
    	m_ids:[200],
        min_trx_id: 200,
        max_trx_id: 201,
        creator_trx_id: 0
    }
    
  2. 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name = ‘诸葛亮’ ,trx_id = 200 ,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。

  3. 下一个版本的列 name = ‘赵云’ ,trx_id = 200 ,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。

  4. 下一个版本的列 name = ‘张飞’ ,trx_id = 100 ,小于 ReadView 中的 min_trx_id 值200 ,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为 ‘张飞’ 的记录

每次 SELECT 查询都可以读到最新已提交的记录,这就是读已提交

21.3.2.2 REPAEATABLE READ——在第一次读取数据时生成一个ReadView
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;

# Transaction 200
BEGIN;

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

此时 number=1 的记录版本链:

在这里插入图片描述

使用 REPAEATABLE READ 隔离级别的事务开始执行查询:

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

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

SELECT1 的执行过程如下:

  1. 在执行 SELECT 语句时会先生成一个 ReadView:

    {
    	m_ids:[100, 200],
        min_trx_id: 100,
        max_trx_id: 201,
        creator_trx_id: 0
    }
    
  2. 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name = ‘张飞’ ,trx_id = 100 ,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。

  3. 下一个版本的列 name = ‘关羽’ ,该版本的 trx_id = 100 ,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。

  4. 下一个版本的列 name = ‘刘备’ ,该版本的 trx_id = 80 ,小于 ReadView 中的 min_trx_id 值100 ,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为 ‘刘备’ 的记录。

之后,把事务id=100的事务提交,并使用事务id=200的事务继续更新

# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
COMMIT;

# Transaction 200
BEGIN;

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

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

此时 number=1 的记录版本链:

在这里插入图片描述

再次使用 REPAEATABLE READ 隔离级别的事务开始执行查询:

# 使用 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 的执行过程如下:

  1. 使用 REPEATABLE READ 隔离级别时,因为之前执行 SELECT1 时已经生成过 ReadView 了,所以此时不再重新生成,而是直接复用之前的 ReadView

    {
    	m_ids:[100, 200],
        min_trx_id: 100,
        max_trx_id: 201,
        creator_trx_id: 0
    }
    
  2. 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name = ‘诸葛亮’ ,trx_id = 200 ,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。

  3. 下一个版本 name = ‘赵云’ ,trx_id = 200 ,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。

  4. 下一个版本的列 name = ‘张飞’ ,trx_id = 100 ,也在 m_ids 列表内,所以也不符合要求,同理下一个列 name 的内容是 ‘关羽’ 的版本也不符合要求。继续跳到下一个版本。

  5. 下一个版本的列 name = ‘刘备’ ,trx_id = 80 ,小于 ReadView 中的 min_trx_id 值
    100 ,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 c 为 ‘刘备’ 的记录。

两次 SELECT 查询得到的结果都是相同的,这就是所谓的可重复读

21.3.3 二级索引与MVCC

前面使用的 trx_id 和 roll_pointer 隐藏列都只存在于聚簇索引,当使用二级索引查询时,该如何判断可见性?

BEGIN;

SELECT name FROM hero WHERE name = '刘备';
  1. 二级索引页面的 Page Header 部分有一个名为 PAGE_MAX_TRX_ID 的属性,每次有事务增删改本页面中的记录时,如果该事务的 id 大于 PAGE_MAX_TRX_ID ,就把这个 id 赋值给 PAGE_MAX_TRX_ID。这也就意味 PAGE_MAX_TRX_ID 是修改该二级索引页面的最大事务 id。
  2. 当 SELECT 语句访问某个二级索引记录时,如果对应的 ReadView 的 min_trx_id 大于该页面的 PAGE_MAX_TRX_ID,说明该页面的所有记录都对该 ReadView 可见,否则执行步骤 3。
  3. 利用二级索引进行回表,得到对应的聚簇索引记录后按照前面说的方式判断是否可见。
21.3.4 MVCC 小结
  1. 所谓 MVCC(Multi-Version Concurrency Control,多版本并发控制)指的就是在使用 READ COMMITED、REPEATABLE READ 这两种隔离级别的事务在执行普通的 SELECT 操作时访问记录的版本链的过程,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。
  2. READ COMMITED、REPEATABLE READ 这两种隔离级别主要不同在于生成 ReadView 的时机。
  3. 只有在进行普通 SELECT 查询时,MVCC 才生效。

21.4 关于 purge

在合适的时候把 update undo 日志以及仅仅被标记为删除的记录彻底删除掉,这个删除操作就称为 purge。

21.5 总结

  1. 并发的事务在运行过程中会出现一些可能的引发一致性问题
  2. SQL 标准中有4种隔离级别
  3. 版本链
  4. ReadView
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值