写点什么

MySQL 读取的记录和我想象的不一致

  • 2023-06-13
    广东
  • 本文字数:12925 字

    阅读完需:约 42 分钟

本文分享自华为云社区《MySQL读取的记录和我想象的不一致——事物隔离级别和MVCC》,作者:砖业洋__。

事务的特性简介

1.1 原子性(Atomicity)


要么全做,要么全不做,一系列操作都是不可分割的,如果在执行操作的过程发生了错误,那么就把已经执行的操作恢复成没执行之前的样子。比如转账不能只有一方扣钱另一方不增加余额。

1.2 隔离性(Isolation)


任何其他状态操作不能影响本次状态操作转换,比如 A 几乎同时向 B 转 2 次账,不同的事务读取出的卡上余额都是 12 元,在第一个事务 A-5 元后,第二个事务 A-5(那这里是 12-5 还是 7-5 呢?),所以 MySQL 需要一些措施保证这些操作的隔离。

1.3 一致性(Consistency)


如果数据库的数据全部符合现实世界的约束,则这些数据就是一致性的,或者说符合一致性的。


比如余额不能小于 0,有一些业务 id 不能为空。数据库本身能为我们解决一部分一致性需求,比如 NOT NULL 来拒绝 NULL 值的插入,但是更多的是需要靠写业务代码的程序员自己保证,比如在 Spring Boot 里面,入参就可以 @NotNull 或者 @NotBlank 之类的来进行入参校验。


数据库检查一致性是一个耗费性能的工作,比如为表建立一个触发器,每当插入或更新记录的时候就会校验是否满足条件,如果涉及到某一些列的计算,就会严重影响插入或更新的速度。


尽量不要把校验参数的判断条件(一致性检查)写在 MySQL 语句中,不仅影响插入更新的速度,而且数据库连接也是很耗时的。能在业务层面解决就在业务层面判断。


提示:建表时的 CHECK 子句对于一致性检查没什么用,在 MySQL 中也不会去检查 CHECK 子句中的约束是否成立。比如:


create table test (
  id unsigned int not null auto_increment comment ‘主键id’,
  name varchar(100) comment ‘姓名’,
  balance int comment ‘余额’,
  primary key (id),
  check (balance >= 0)
);
复制代码

1.4 持久性 (Durability)


数据库修改的数据都应该在磁盘中保留下来,无论发生什么事故,本次操作的影响都不应该丢失。比如转账成功后不可以又恢复到没转账之前的样子,那样钱就没了。


我们把这四种特性的首字母提出来加以排序就是一个英文单词:ACID(英文中“酸”的意思),方便记忆

2. 建表


CREATE TABLE hero (
number INT,
name VARCHAR(100),
country varchar(100),
PRIMARY KEY (number),
KEY idx_hero_name (name)
) Engine=InnoDB CHARSET=utf8;
复制代码


这里把 hero 表的主键命名为 number 是为了与后面的事务 id 进行区分,为了简单,就不写约束条件和注释了。


然后向这个表里插入一条数据:


INSERT INTO hero VALUES(1, '刘备', '蜀');
复制代码


现在表里的数据就是这样的:


3. 事务隔离级别


MySQL 是一个客户端/服务器架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接后,就形成了一个会话(Session)。每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分。服务器可以同时处理来自多个客户端的多个事务。

3.1 事务并发执行时遇到的一致性问题


在不同隔离级别中,对数据库的操作可能会出现几种现象。如下:

3.1.1 脏写(Dirty Write)(用于熟悉和理解 ACID 特性,实际中不可能存在脏写)


如果一个事务修改了另一个未提交事务修改过的数据,那就意味着发生了脏写。如下:


假设两个会话各开启了一个事务 TA 和 TB,


  • 原有 x=0, y=0,TA 先修改了 x=3,TB 修改了 x=1,y=1,然后 TB 提交,最后 TA 回滚。

    如果 TA 回滚导致 x=0,那么对于 TB 来说破坏了原子性,因为 x 被回滚,y 还是正常修改。

    如果 TA 回滚导致 TB 所有的修改都回滚,那么对于 TB 来说破坏了持久性,明明 TB 都提交了,怎么能让一个未提交的 TA 将 TB 的持久性破坏掉呢?


无论哪种隔离级别,都不允许脏写的存在,所以脏写也可以作为介绍事务特性的一个序言,了解即可。

3.1.2 脏读(Dirty Read)


如果一个事务读到了另一个未提交事务修改过的数据,那就意味着发生了脏读,示意图如下:



Session A 和 Session B 各开启了一个事务,Session B 中的事务先将 number 列为 1 的记录的 name 列更新为’关羽’,然后 Session A 中的事务再去查询这条 number 为 1 的记录,如果读到列 name 的值为’关羽’,而 Session B 中的事务稍后进行了回滚,那么 Session A 中的事务相当于读到了一个不存在的数据,这种现象就称之为脏读。


这里例子中 Session B 中的事务是 rollback,即使是 commit 了,虽然最终数据库的状态是一致的,但是在 Session A 中的事务读取 number=1 这条记录的时候,这个事务却得到了不一致的状态。数据库不一致的状态是不应该暴露给用户的。


严格一点的解释:假设事务 T1、T2 并发执行,它们都要访问数据项 X,T1 先修改了 X 的值,然后 T2 又读取了未提交事务 T1 修改后的 X 值,之后 T1 中止而 T2 提交。这就意味着 T2 读到了一个根本不存在的值,这也是脏读的严格解释。

3.1.3 不可重复读(Non-Repeatable Read)


如果一个事务修改了另一个未提交事务读取的数据,就意味着发生了不可重复读现象,或者叫模糊读(Fuzzy Read)现象。



读的’刘备’,被修改为’关羽’ ,读的’关羽’ 又被修改为了’张飞’ 。


严格一点的解释:假设事务 T1、T2 并发执行,它们都要访问数据项 X,T1 先读取了 X 的值,然后 T2 又修改了未提交事务 T1 读取的 X 的值,之后 T2 提交,然后 T1 再次读取数据项 X 的值时会得到与第一次读取时不同的值。

3.1.4 幻读(Phantom)


如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读,示意图如下:



严格一点的解释:假设事务 T1、T2 并发执行,T1 先读取符合搜索条件 P 的记录,然后 T2 写入了符合搜索条件 P 的记录。之后 T1 再读取符合搜索条件 P 的记录时,会发现两次读取的记录时不一样的。


如果 Session B 中是删除了一些符合 number > 0 的记录而不是插入新记录,那 Session A 中之后再根据 number > 0 的条件读取的记录变少了,这种现象算不算幻读呢?明确说明下,这种现象不属于幻读,幻读强调的是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录。


我们这里只考虑 SQL 标准中提到的,不考虑其他论文的描述,对于 MySQL 来说,幻读强调的是“一个事务在按照某个相同的搜索条件多次读取记录时,在后续读取到了之前没读到的记录”,可能是别的事务 insert 操作引起的。那对于先前已经读到的记录,之后又读取不到这种情况算啥呢?我们把这种现象认为是结果集中的每一条记录分别发生了不可重复读的现象。


比如:第一次读到 abc 三条记录,第二次读到 abd,既多了 d 记录,又少了 c 记录,这怎么分析?


对于记录 c 来说,发生了不可重复读,对于记录 d 来说,发生了幻读。一致性问题针对每条记录分析即可。


是否有可能发生一致性问题的判断依据是,在准备读取的那一刻,想查询的数据库某些列的值与实际查询出来的可能会有出入,则认为可能会发生一致性问题。


综上:脏读、不可重复读、幻读都可能会发生一致性问题。


既然会出现这些问题,那么 SQL 也有一些标准来处理这些问题,接着看吧

3.2 SQL 标准中的四种隔离级别


我们给可能导致一致性问题的严重性给这些现象排一下序:


脏读 > 不可重复读 > 幻读


舍弃一部分隔离性来换取一部分性能在这里就体现在:设立一些隔离级别,隔离级别越低,越可能发生严重的问题。有一帮人(并不是设计 MySQL 的大叔)制定了一个所谓的 SQL 标准,在标准中设立了 4 个隔离级别:


  • READ UNCOMMITTED:未提交读。

  • READ COMMITTED:已提交读 (又简称为 RC) 。

  • REPEATABLE READ:可重复读 (又简称为 RR)

  • SERIALIZABLE:可串行化。


SQL 标准中规定(是 SQL 标准中规定,不是 MySQL 中规定),针对不同的隔离级别,并发事务可以发生不同的现象,具体情况如下:



SQL92 标准中并没有指出脏写的现象,脏写这个现象对于一致性影响太严重了,无论哪种隔离级别都不允许脏写的发生,这里就不多提。

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


不同的数据库厂商对 SQL 标准中规定的 4 种隔离级别支持不一样,比方说 Oracle 就只支持 READ COMMITTED(Oracle 的默认隔离级别)和 SERIALIZABLE 隔离级别。这里所讨论的 MySQL 虽然支持 4 种隔离级别,但与 SQL 标准中所规定的各级隔离级别允许发生的问题却有些出入,MySQL 在 REPEATABLE READ 隔离级别下,是可以很大程度上禁止幻读问题的发生的(关于如何禁止之后会详细说明的)。


MySQL 的默认隔离级别为 REPEATABLE READ,我自己手上的项目在生产环境的隔离级别是 READ COMMITTED,而且相关的一些接口可能同时操作同一张表的某一个账号,并发性较高,我的操作是:每次进入事务之前都会用 Redis 分布式锁去锁住这个账号再进入事务,操作同一个账号同一时间只能有一个成功,这样就不会出现多个事务并发去操作这个账号相关性的数据,也就不会有这条记录出现不可重复读和幻读的机会。

3.3.1 如何设置事务的隔离级别


我们可以通过下边的语句修改事务的隔离级别(实际开发中是不会让开发人员随意有这种操作的,可以在自己电脑尝试):


SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level;
复制代码


其中的 level 可选值有 4 个:


level: {
REPEATABLE READ
| READ COMMITTED
| READ UNCOMMITTED
| SERIALIZABLE
}
复制代码


设置事务的隔离级别的语句中,在 SET 关键字后可以放置 GLOBAL 关键字、SESSION 关键字或者什么都不放,这样会对不同范围的事务产生不同的影响,具体如下:


  • 使用 GLOBAL 关键字(在全局范围产生影响):


比如下面这样:


SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;
复制代码


则:


  1. 只对执行完该语句之后新产生的会话起作用。

  2. 当前已经存在的会话无效。


所谓新产生的会话,如果你是 navicat 操作,得关闭连接之后再打开连接才算新的会话,如果仅仅是新建查询还算同一个会话,是看不到设置前后隔离级别的变化的。



  • 使用 SESSION 关键字(在会话范围影响):


比方说这样:


SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
复制代码


则:


  1. 对当前会话的所有后续的事务有效

  2. 该语句可以在已经开启的事务中间执行,但不会影响当前正在执行的事务。

  3. 如果在事务之间执行,则对后续的事务有效。


  • 上述两个关键字都不用(只对执行这个 SET 语句后的下一个事务产生影响):


比如下面这样:


SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
复制代码


则:


  1. 只对当前会话中下一个即将开启的事务有效。

  2. 下一个事务执行完后,后续事务将恢复到之前的隔离级别。

  3. 该语句不能在已经开启的事务中间执行,否则会报错。


如果我们在服务器启动时想改变事务的默认隔离级别,可以修改启动参数 transaction-isolation 的值,比方说我们在启动服务器时指定了--transaction-isolation=SERIALIZABLE,那么事务的默认隔离级别就从原来的 REPEATABLE READ 变成了 SERIALIZABLE。


可以通过查看系统变量 transaction_isolation 的值来确定当前会话默认的隔离级别:


SHOW VARIABLES LIKE 'transaction_isolation';
复制代码



注意:transaction_isolation 是在 MySQL 5.7.20 的版本中引入来替换 tx_isolation 的,如果你使用的是之前版本的 MySQL,请将上述用到系统变量 transaction_isolation 的地方替换为 tx_isolation。


或者使用更简便的写法:


SELECT @@transaction_isolation;
复制代码



我们之前使用 SET TRANSACTION 语法来设置事务的隔离级别时,其实就是在间接设置系统变量 transaction_isolation 的值,我们也可以直接修改系统变量 transaction_isolation 来设置事务的隔离级别。系统变量一般系统变量只有 GLOBAL 和 SESSION 两个作用范围,而这个 transaction_isolation 却有 3 个(GLOBAL、SESSION、仅作用于下一个事务),设置语法上有些特殊,更多详情可以参见文档:transaction_isolation。


这里总结下:


4. MVCC 原理

4.1 版本链


在前文底层揭秘MySQL行格式记录头信息说过,对于使用 InnoDB 存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列(row_id 并不是必要的,我们创建的表中有主键或者有 NOT NULL 限制的 UNIQUE 键时都不会包含 row_id 列):


  • trx_id:每次一个事务对某条聚集索引记录进行改动时,都会把该事务的事务 id 赋值给 trx_id 隐藏列。

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


比方说我们的表 hero 现在只包含一条记录:



假设插入该记录的事务 id 为 80,那么此刻该条记录的示意图如下所示:



假设之后两个事务 id 分别为 100、200 的事务对这条记录进行 UPDATE 操作,操作流程如下:



是否可以在两个事务中交叉更新同一条记录呢?不可以!这不就是一个事务修改了另一个未提交事务修改过的数据,沦为了脏写了么?InnoDB 使用锁来保证不会有脏写情况的发生,也就是在第一个事务更新某条记录前,就会给这条记录加锁,另一个事务再次更新该记录时,就需要等待第一个事务提交,把锁释放之后才可以继续更新。所以这里 trx 200 在③④⑤步的时候因为锁的原因是被阻塞的,关于锁,后续文章再介绍。

每次对记录进行改动,都会记录一条 undo 日志,每条 undo 日志也都有一个 roll_pointer 属性(INSERT 操作对应的 undo 日志没有该属性,因为该记录并没有更早的版本),可以将这些 undo 日志都连起来,串成一个链表,所以现在的情况就像下图一样:



在每次更新该记录后,都会将旧值放到一条 undo 日志中(就算是该记录的一个旧版本),随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务 id(这很重要),我们之后会利用这个记录的版本链来控制并发事务访问相同记录的行为,我们把这种机制称之为多版本并发控制(Multi-Version Concurrency Control,MVCC)


从上图可以看到,聚集索引记录和 undo 日志中的 roll_pointer 属性可以串连成一个记录的版本链。同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)


在 UPDATE 操作产生的 undo 日志中,只会记录一些索引列以及被更新的列的信息,并不会记录所有列的信息,上一张图展示的 undo 日志中,之所以将一条记录的全部列的信息都画出来是为了方便理解(因为这样很直观的显示了该版本中各个列的值是什么)。比如对于 trx_id 为 80 的那条 undo 日志来说,本身是没有记录 country 列的信息的,那怎么知道该版本中 country 列的值是多少呢?没有更新该列则说明该列和上一个版本中的值相同。如果上一个版本的 undo 日志也没有记录该列的值,那么就和上上个版本中该列的值相同。如果各个版本的 undo 日志都没有记录该列的值,说明该列从未被更新过,那么 trx_id 为 80 的那个版本的 country 列的值就和数据页中的聚集索引记录的 country 列的值相同。

4.2 ReadView


一条记录竟然更新了那么多版本?版本链中的哪个版本的记录是当前事务可见的?这在不同隔离级别中可见性是不相同的


  • 对于使用 READ UNCOMMITTED 隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。(不生成 ReadView)

  • 对于使用 SERIALIZABLE 隔离级别的事务来说,设计 InnoDB 的大叔规定使用加锁的方式来访问记录。(不生成 ReadView)

  • 对于使用 READ COMMITTED 和 REPEATABLE READ 隔离级别的事务来说,都必须保证读到已经提交的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,则不能直接读取最新版本的记录。(只有 RC、RR 这 2 个隔离级别在读取数据的时候生成 ReadView)


一定要注意,没有事务就没有 ReadView,ReadView 是事务产生的,而且是基于整个数据库的。


对此,设计 InnoDB 的大叔提出了一个 ReadView(有的翻译为“一致性视图”)的概念

注意!在 MySQL 里有两个“视图”的概念:


一个是 view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view ...,而它的查询方法与表一样。 另一个是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC 和 RR 隔离级别的实现。 ReadView 它没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”。


这个 ReadView 中主要包含 4 个比较重要的内容:


  1. m_ids:表示在生成 ReadView 时当前系统中活跃的读写事务的事务 id 列表。“活跃”指的就是,启动了但还没提交。

  2. min_trx_id:表示在生成 ReadView 时当前系统中活跃的读写事务中最小的事务 id,也就是 m_ids 中的最小值。

  3. 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。


  • creator_trx_id:表示生成该 ReadView 的事务的事务 id。


只有在对表中的记录做改动时(执行 INSERT、DELETE、UPDATE 这些语句时)才会为事务分配 trx_id,否则在一个只读事务中的事务 id 的值 trx_id 都默认为 0,未分配 trx_id 前,creator_trx_id 的值为 0,分配 trx_id 后,creator_trx_id 就变化成了对应的事务的 trx_id。


在 MySQL 中,READ COMMITTED 和 REPEATABLE READ 隔离级别的的一个非常大的区别就是它们生成 ReadView 的时机不同。我们还是以表 hero 为例来,假设现在表 hero 中只有一条由事务 id 为 80 的事务插入的一条记录:



注意:当一个 ReadView 生成了,m_ids、min_trx_id、max_trx_id、creator_trx_id 等变量的值都是固定的,比如此时有事务提交,m_ids 活跃事务列表的值也不会变。ReadView 就像快照一样,生成了就不再变,除非生成新的。


接下来看一下 READ COMMITTED 和 REPEATABLE READ 所谓的生成 ReadView 的时机不同到底不同在哪里。

4.2.1 READ COMMITTED —— 一个事务中每次读取数据前都生成一个 ReadView


比如,现在系统里有两个事务 id 分别为 100、200 的事务在执行:



再次强调,事务执行过程中,只有在第一次真正修改记录时(比如使用 INSERT、DELETE、UPDATE 语句),才会被分配一个唯一的事务 id,这个事务 id 是递增的。所以我们才在 Transaction 200 中更新一些别的表的记录,目的是让它分配事务 id。


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



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


# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
复制代码


这个 SELECT1 的执行过程如下:


  1. 在执行 SELECT 语句时会先生成一个 ReadView,ReadView 的 m_ids 列表的内容就是[100, 200],min_trx_id 为 100,max_trx_id 为 201,creator_trx_id 为 0。

  2. 然后从版本链中挑选可见的记录。从图中可以看出,最新版本的列 name 的内容是’张飞’,该版本的 trx_id 值为 100,在 m_ids 列表内,说明 trx_id 为 100 的事务还没提交,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。

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

  4. 下一个版本的列 name 的内容是’刘备’,该版本的 trx_id 值为 80,小于 ReadView 中的 min_trx_id 值 100,说明 trx_id 为 80 的事务已经提交了,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 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 的记录的版本链就长这样:



然后再到刚才使用 READ COMMITTED 隔离级别的事务中继续查找这个 number 为 1 的记录,如下


# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200均未提交(第一次查询两个事务均未提交)
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
# SELECT2:Transaction 100提交,Transaction 200未提交(第二次查询事务id为100的事务提交了)
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'张飞'
复制代码


分析一下 SELECT2 的执行过程


  1. 在执行 SELECT 语句时会又会单独生成一个 ReadView,该 ReadView 的 m_ids 列表的内容就是[200](事务 id 为 100 的那个事务已经提交了,所以再次生成 ReadView 时就没有它了),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 为’张飞’的记录。


以此类推,如果之后事务 id 为 200 的记录也提交了,再次在使用 READ COMMITTED 隔离级别的事务中查询表 hero 中 number 值为 1 的记录时,得到的结果就是’诸葛亮’了。总结一下就是:使用 READ COMMITTED 隔离级别的事务在每次查询开始时都会生成一个独立的 ReadView。


注意:RC 下,在一个事务中,一条查询语句执行完,事务生成的 ReadView 就没用了,下次查询得重新生成 ReadView。

4.2.2 REPEATABLE READ —— 一个事务中第一次读取数据时生成一个 ReadView


按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后这个事务执行期间,其他事务的更新对它不可见。


对于使用 REPEATABLE READ 隔离级别的事务来说,只会在第一次执行查询语句时生成一个 ReadView,之后的查询就不会重复生成了。来用和之前相同的例子再次分析。


比如,现在系统里有两个事务 id 分别为 100、200 的事务在执行:



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



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


# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
复制代码


这里和 4.2.1 节的 READ COMMITTED 隔离级别的 SELECT1 分析过程一模一样,不赘述。查询出来是 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 的记录的版本链就长这样:



一直到这里,例子分析和 4.2.1 节的 READ COMMITTED 隔离级别的分析过程一样。接下来,不一样的来了。


然后再到刚才使用 REPEATABLE READ 隔离级别的事务中继续查找这个 number 为 1 的记录,如下:


# 使用REPEATABLE READ隔离级别的事务300
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。 之前的 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 列表中是包含值为 100 的事务 id 的,所以该版本也不符合要求,同理下一个列 name 的内容是’关羽’的版本也不符合要求。继续跳到下一个版本。

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


也就是说在 REPEATABLE READ 隔离级别下,事务的两次查询得到的结果是一样的。记录的 name 列值都是’刘备’,这就是为什么在 RR 下,不会出现不可重复读的理由。如果我们之后再把事务 id 为 200 的记录提交了,然后再到刚才使用 REPEATABLE READ 隔离级别的事务中继续查找这个 number 为 1 的记录,得到的结果还是’刘备’。


要想读到最新 name 值为’诸葛亮’该如何操作呢?


前提: 把事务 id 为 100、200 的事务提交。


  1. 此时把事务 id 为 300 的事务提交,ReadView 就没用了,下次开启新的事务查询的时候会生成新的 ReadView,m_ids 列表中没有 100、200,就可以查询到 name 为’诸葛亮’了。

  2. 如果新的查询没有事务,那就没有 ReadView 这一说了,直接 select 查询就可以查到 name 为’诸葛亮’的记录,因为事务 100、200 已提交。


注意对比:


RR 下,一个事务提交时,它生成的 ReadView 就没用了。


RC 下,在一个事务中,一条查询语句执行完,事务生成的 ReadView 就没用了,下次查询得重新生成 ReadView。


小提示:


在 RR 下,如果使用 START TRANSACTION WITH CONSISTENT SNAPSHOT 语句开启事务,会在执行该语句后立即生成一个 ReadView,而不是在执行第一条 SELECT 语句时才生成。


使用 START TRANSACTION WITH CONSISTENT SNAPSHOT 这个语句开始,创建一个持续整个事务的 ReadView。所以在 RC 隔离级别下(每次读都创建 ReadView),这个用法就没意义了,等效于普通的 start transaction。

4.2.3 ReadView 的可见性规则总结


在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:


  1. trx_id = creator_trx_id 时,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。

  2. trx_id < min_trx_id 时,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。

  3. trx_id ≥ max_trx_id 时,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。

  4. min_trx_id ≤ trx_id ≤ max_trx_id 之间,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。


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


上面说了,ReadView 是基于整库的。如果一个库有 100G,那么我启动一个事务,MySQL 就要拷贝 100G 的数据出来吗,这样多慢啊,可是,我们平时的事务执行起来很快啊。


实际上,我们并不需要拷贝出这 100G 的数据。InnoDB 利用版本链和活跃的事务 id 列表,可以实现“秒级创建 ReadView”。


思考题:


RR 隔离级别下事务 T1 和 T2 并发执行,T1 先根据某个搜索条件读取到 3 条记录,然后事务 T2 插入一条符合相应搜索条件的记录并提交,然后事务 T1 再根据相同搜索条件执行查询,结果如何?


分析:根据版本链和 ReadView 分析,T1 第一次搜索 3 条记录的时候生成了 ReadView,此时 T1、T2 都在 m_ids 列表,都是活跃的,那么 T2 中插入的版本记录 T1 是不可见的,所以事务 T1 第二次搜索仍然是 3 条记录。此时在 RR 下避免了幻读的产生。


由于 MySQL 的具体实现问题,RR 隔离级别下并不能完全避免幻读(只能很大程度避免),只有加锁才可以完全避免。

4.3 为什么不推荐使用长事务?


前面讲版本链的时候说过,每条记录在更新的时候都会同时记录一条回滚的 undo 日志 (也称为回滚段)。通过回滚操作,都可以得到前一个状态的值。



当前 number 为 1 的记录 name 为是'诸葛亮',但是在查询这条记录的时候,不同时刻启动的事务会有不同的 ReadView。如图,要得到 name 为'刘备'的记录,就必须将当前值依次执行图中所有的回滚操作得到。


  • 回滚段非常占用内存,那回滚段什么时候删除呢?


从上图可以看到回滚段里都是之前事务修改过的记录,事务提交后该记录的旧版本就不需要了,所以只有当开启回滚段以来的所有事务都提交的时候,回滚段就可以删除。


  • 为什么不推荐使用长事务?


长事务意味着系统里面会存在很老的记录,事务不提交,记录的旧版本会一直存在。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。


在 MySQL 5.5 及以前的版本,回滚日志是跟数据字典一起放在 ibdata 文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。有时候数据只有 20GB,而回滚段有 200GB 的库。最终只好为了清理回滚段,重建整个库。


除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库。


  • 如何查询长事务?


在 information_schema 库的 innodb_trx 这个表中查询长事务,比如下面这个语句,用于查找持续时间超过 60s 的事务。


select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60
复制代码

4.4 非聚集索引与 MVCC


前面说过,只有聚集索引记录才有 trx_id 和 roll_pointer 隐藏列,如果某个查询语句是使用二级索引来执行查询时,该如何判断可见性呢?


begin;
select * from hero where name = '刘备';
复制代码


这里判断条件是 name 了,这是一个普通的非聚集索引,没有 trx_id 和 roll_pointer 该怎么根据版本链和 ReadView 去判断可见性呢?


注:trx_id 是记录存放该事务的事务 id 的地方,没有这一列只能说明非聚集索引记录没存,并不代表执行事务时没有事务 id 了。


过程如下:


步骤 1:非聚集索引页面的 Page Header 部分有一个名为 PAGE_MAX_TRX_ID 的属性,每当对该页面中的记录执行增删改操作的时候,如下:


// 这里用伪代码说明更便捷
if(如果执行该事务的事务id > PAGE_MAX_TRX_ID) {
PAGE_MAX_TRX_ID = 如果执行该事务的事务id;
}
复制代码


所以 PAGE_MAX_TRX_ID 属性值代表修改该非聚集索引页面的最大的事务 id。


当 SELECT 语句根据条件找到某个非聚集索引记录时,如下:


if (对应ReadView的min_trx_id > PAGE_MAX_TRX_ID) {
说明该页面中的所有记录都对该ReadView可见
} else {
执行步骤2
}
复制代码


步骤 2: 根据主键回表后,得到满足搜索条件的聚集索引记录后,根据版本链找到该 ReadView 可见的第一个版本,然后判断该版本中相应的非聚集索引列的值是否与利用该非聚集索引查询时的值相同。本例子就是判断可见版本的 name 是不是’刘备’。如果是,就把这条记录发送给客户端(如果 where 子句中还有其他搜索条件的话还需要继续判断筛选后再返回),否则就跳过该记录。

4.5 MVCC 小结


所谓的 MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用 READ COMMITTD、REPEATABLE READ 这两种隔离级别的事务执行普通的 SELECT 操作时,访问记录的版本链的过程。这样可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。READ COMMITTD、REPEATABLE READ 这两个隔离级别的一个很大不同,就是生成 ReadView 的时机不同,READ COMMITTD 在一个事务中每一次进行普通 SELECT 操作前都会生成一个 ReadView,而 REPEATABLE READ 在一个事务中只在第一次进行普通 SELECT 操作前生成一个 ReadView,之后的查询操作都重复使用这个 ReadView。

5. 全篇的反思与小结,你需要弄懂这几个问题


  1. 事务的概念是什么?

  2. MySQL 的事务隔离级别读未提交, 读已提交, 可重复读, 串行读各是什么意思?

  3. 读已提交, 可重复读是怎么通过视图构建实现的?

  4. 事务隔离是怎么通过 ReadView(读视图)实现的?

  5. 并发版本控制(MVCC)的概念是什么, 是怎么实现的?

  6. 使用长事务的弊病? 为什么使用长事务可能拖垮整个库?

  7. 怎么查询各个表中的长事务?

  8. 如何避免长事务的出现?


点击关注,第一时间了解华为云新鲜技术~

发布于: 刚刚阅读数: 3
用户头像

提供全面深入的云计算技术干货 2020-07-14 加入

生于云,长于云,让开发者成为决定性力量

评论

发布
暂无评论
MySQL读取的记录和我想象的不一致_数据库_华为云开发者联盟_InfoQ写作社区