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

21.1 事前准备

sql 复制代码
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 读未提交 Possible Possible Possible
READ COMMITTED 读已提交 Not Possible Possible Possible
REPAEATABLE READ 可重复读 Not Possible Not Possible Possible
SERIALIZABLE 串行化 Not Possible Not Possible Not Possible

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

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

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

sql 复制代码
# 如何设置事务的隔离级别
# 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 时生成该版本的事务已经被提交,该版本可以被访问。
java 复制代码
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
sql 复制代码
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;

# Transaction 200
BEGIN;

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

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

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

sql 复制代码
# 使用READ COMMITTED隔离级别的事务
BEGIN;

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

SELECT1 的执行过程如下:

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

    json 复制代码
    {
    	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的事务继续更新

sql 复制代码
# 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 隔离级别的事务开始执行查询:

sql 复制代码
# 使用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:

    json 复制代码
    {
    	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
sql 复制代码
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;

# Transaction 200
BEGIN;

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

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

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

sql 复制代码
# 使用REPEATABLE READ隔离级别的事务
BEGIN;

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

SELECT1 的执行过程如下:

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

    json 复制代码
    {
    	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的事务继续更新

sql 复制代码
# 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 隔离级别的事务开始执行查询:

sql 复制代码
# 使用 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

    json 复制代码
    {
    	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 隐藏列都只存在于聚簇索引,当使用二级索引查询时,该如何判断可见性?

sql 复制代码
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
相关推荐
qq_4330994020 分钟前
Ubuntu20.04从零安装IsaacSim/IsaacLab
数据库
Dlwyz21 分钟前
redis-击穿、穿透、雪崩
数据库·redis·缓存
工业甲酰苯胺2 小时前
Redis性能优化的18招
数据库·redis·性能优化
没书读了3 小时前
ssm框架-spring-spring声明式事务
java·数据库·spring
i道i4 小时前
MySQL win安装 和 pymysql使用示例
数据库·mysql
小怪兽ysl4 小时前
【PostgreSQL使用pg_filedump工具解析数据文件以恢复数据】
数据库·postgresql
wqq_9922502774 小时前
springboot基于微信小程序的食堂预约点餐系统
数据库·微信小程序·小程序
爱上口袋的天空4 小时前
09 - Clickhouse的SQL操作
数据库·sql·clickhouse
Oak Zhang5 小时前
sharding-jdbc自定义分片算法,表对应关系存储在mysql中,缓存到redis或者本地
redis·mysql·缓存
聂 可 以6 小时前
Windows环境安装MongoDB
数据库·mongodb