前言
前面介绍了MySQL中比较重要的日志-undo日志,该日志主要是在事务回滚时,恢复到事务开始之前的模样,同时还有一个重要的作用,为了实现MVCC,就是接下来要介绍的内容。
传送门:
什么是事务?
我们知道MySQL是一个客户端+服务器架构的软件。对于同一个服务器来说,可以有多个客户端连接,每个客户端与服务器建立连接后,就形成了一个会话。每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分。服务器可以同时处理来自多个客户端的多个事务。
事务就是一个最小的、不可在分的工作单元,通常一个事务对应一个完整的业务。(比较经典的就是银行转账,要么都成功,要么都失败,没有中间状态)
事务的ACID
- 原子性(Atomicity):一个事务是一个不可再分割的整体,要么全部成功,要么全部失败。
事务在数据库中就是一个基本的工作单位,事务中包含的逻辑操作(SQL 语句),只有两种情况:成功和失败。事务的原子性其实指的就是这个。
-
一致性(Consistency):一个事务可以让数据从一种一致状态切换成另一种一致状态。比如:张三给李四转账 100 元,那么张三的余额应减少 100 元,李四的余额应增加 100 元,张三的余额减少和李四的余额增加这是两个逻辑操作具有一致性。
-
隔离性(Isolution):一个事务不受其他事务的影响,并且多个事务彼此隔离。一个事务内部的操作及使用的数据,对并发的其他事务是隔离的,并发执行的各个事务之间不会互相干扰。
-
持久性(Durability):一个事务一旦被提交,在数据库中的改变就是永久的,提交后就不能再回滚。一个事务被提交后,在数据库中的改变就是永久的,即使系统崩溃重新启动数据库数据也不会发生改变。
事务必须满足ACID,否则数据就会出现混乱,这是不能忍受的。那如何实现ACID呢,最简单粗暴的方法就是系统在同一时间最多允许一个事务执行。其他事务只能在当前事务执行完毕,才能执行,相当于事务排队,这种也称为 串行执行。
串行执行缺点很明显,会降低系统的吞吐量和资源利用率,同时增加事务的执行时间,这肯定也不行。鱼和熊掌不可兼得,解决办法就是,牺牲一部分隔离性来换取系统的吞吐量和资源利用率。
在说如何实现之前,我们先了解一下,多个事务并发执行,会遇到哪些问题??
事务并发执行的问题
脏读
如果一个事务读取到另一个未提交事务修改过的数据,就意味着发生了脏读现象。
不可重复读
是指在一个事务内,多次读取同一数据。在这个事务还没有结束时,另外一个事务也访问该条数据,并修改了数据。在第一个事务中两次读取数据之间,由于第二个事务的修改,那么第一个事务两次读到的数据可能是不一样的。这样就发生了在一个事务内两次读到的数据是不一样的现象。
幻读
如果一个事务先根据某些搜索条件查询出一些记录,在该事务未提交时,另一个事物写入了一些符合那些搜索条件的记录(这里的写入可以是INSERT、DELETE、UPDATE),这就意味着发生了幻读现象。(简单来说就是,一个事务根据某些条件搜索,第一次查询是3条符合,第二次再查变成了5条符合)
不可重复读和幻读区别:不可重复读的重点是修改,幻读的重点是新增或者删除。
根据产生问题的严重性,我们将以上三个问题进行排序:脏读>不可重复读>幻读
为了解决事务并发产生的以上三个问题,SQL中规定了4种隔离级别,下面来介绍一下事务的隔离级别。
事务隔离级别
-
读未提交(read uncommitted):允许读取尚未提交事务的数据。
-
读已提交(read committed):允许读到并发事务已提交的数据。
-
可重复读(repeatable read):一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。
-
可串行化(serializable):最高的事务隔离级别,主要通过强制事务排序来解决事务的并发问题。
以上4中级别,依次递增,但同时也说过鱼和熊掌不可兼得,性能肯定是依次降低。
那这4种隔离级别,能完全解决事务并发过程中,产生的三种问题吗,下面来介绍一下。
隔离级别 | 脏读 | 不可重复度 | 幻读 |
读未提交 | 可能 | 可能 | 可能 |
读已提交 | 不可能 | 可能 | 可能 |
可重复读 | 不可能 | 不可能 | 可能 |
可串行化 | 不可能 | 不可能 | 不可能 |
上面说的4中隔离级别,是SQL规范中规定的,不同的数据库可能执行不一样。MySQL是支持这4中隔离级别,默认级别是可重复读(repeatable read)。
MVCC原理
版本链
我们前面介绍undo日志时,说过InnoDB存储引擎的表,聚簇索引记录中会包含两个隐藏列,分别是trx_id、roll_pointer。
- trx_id:一个事务每次对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。
- roll_pointer:每次对某条聚簇索引进行改动时,都会把旧的版本写入到undo日志中。这个隐藏列相当于一个指针,通过它可以找到该记录被修改前的值。
这里还把上篇文章中的表拿过来:
-- 创建学生表
CREATE TABLE students (
student_id INT PRIMARY,
student_name VARCHAR(50) NOT NULL,
age INT NOT NULL,
is_delete int
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 插入数据
INSERT INTO students (student_id,student_name, age, is_delete) VALUES
(1001,'张三', 20, 0);
假设上面插入数据的事务是60,那么该条记录的 trx_id和roll_pointer值,如下所示:
上一篇undo日志文章中,也说过,insert undo在事务提交后,它占用的回滚段会被回收掉。虽然真正的insert undo日志占用的undo页面链表可能会被重用或者回收,但是roll_pointer的值不会被清除。还有就是下面介绍的undo日志,主要是在MVCC中的应用,而不是在事务回滚中的应用。所以后续会把insert undo给去掉。
后续会有事务对该记录,进行修改操作。假设之后有两个事务id分别是80、90,分别对这条记录进行修改,具体如下所示:
这里大家可能会有个疑问,为啥发生的事件编号,都是错开的,事务并发的时候,不可能这么巧吧。。这里要说下后面要说的功能--锁,这里简单提一下,肯定是必须错开的,因为两个事务同一时间更新同一条记录的话,不就意味着一个事务修改了另一个未提交事务修改的数据,这不就是脏写了么。这肯定不允许的。那假如说我同一时间就有两个事务修改同一条数据,MySQL的解决办法就是,加锁。这个后一篇文章中再做介绍。
每次对记录进行一次改动,都会记录一条undo日志,每条undo日志都会有一个roll_pointer属性,这里要特别说一下insert操作对应的undo日志没有该属性,因为insert是最早的版本。通过这个属性可以将这些undo日志串成一个链表。如下图所示:
每次跟新该记录后,都会将旧值放到一条undo日志中,这就是该记录的一个旧版本。随着更新的次数增多,所有的版本都会被roll_pointer属性连接成一个链表,这个链表也称为 版本链。版本链的头节点就是当前记录的最新值,在上面的图中,版本链中还包含生成该undo日志产生的事务id,这个属性很重要,稍后会介绍到。我们后面会用这个版本链来控制并发事务访问相同记录时的行为,我们把这种机制称为 多版本并发控制(Multi-Version Concurrency Control,MVCC) 。
ReadView
上面介绍了4种事务隔离级别,对于读未提交(read uncommitted)来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了;对于可串行化(serializable)来说,隔离级别是最高的,InnoDB使用锁来实现,具体下一篇锁的文章再说;对于读已提交(read committed)和可重复读(repeatable read)来说,都必须要保证自己读到已经提交的事务修改过的记录。那如何实现呢,这就是接下来要介绍的ReadView,也称 一致性视图。一致性视图解决的问题是,在事务执行过程中,我可以读到版本链中哪个版本的数据。
ReadView主要包含一下内容:
- m_ids:在生成ReadView时,当前系统中活跃的读写事务的事务id列表。
- min_trx_id:在生成ReadView时,当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。
- max_trx_id:在生成ReadView时,系统应该分配给下一个事务的事务id值。
- creator_trx_id:生成该ReadView的事务的事务id。
这里再回顾一下,上篇undo日志文章说过,事务中在对表进行insert、update、delete时,并且是第一次进行增删改操作,才会产生全局唯一的事务id,否则该事务的id为0,后续该事务中再进行增删改操作,事务都是相同的。
有了ReadView后,在访问某条数据时,会按照下面步骤来判断读取版本链中哪个版本的数据。
- 如果访问版本的trx_id值与ReadView中的creator_trx_id值相同,这就意味着当前事务在访问自己修改过的数据,可以访问。
- 如果被访问版本的trx_id值小于ReadView中的min_trx_id值,表明当前生成该版本的事务在当前事务生成ReadView前已提交,所以也是可以访问的。
- 如果被访问版本的trx_id值大于或等于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可被当前事务访问。
- 如果被访问的trx_id值在ReadView的min_trx_id和max_trx_id之间,则需要判断trx_id值是否在m_ids列表中。如果在,说明创建ReadView时生成该版本的事务还是活跃的,则该版本不可被访问;如果不在,说明创建ReadView时事务已经提交,则该版本可以访问。
如果某个版本的数据对当前事务不可见,那就顺着版本链找到下一个版本的数据,并继续执行上述的步骤来判断是否可见,以此类推。(上述几步,需要大家耐心看完,有点绕。。)
因为InnoDB默认的事务隔离级别是可重复读(repeatable read),在介绍可重复读的情况下, 也来介绍一下读已提交(read committed)二者的区别。
二者的区别主要在于生成ReadView的时机不同。
读已提交(read committed)
读已提交是每次读取数据前都生成一个ReadView,也就是说在一个事务中,前后两次执行SELECT查询,会生成两个不同的ReadView。
下面介绍一个具体的例子,来说明一下读已提交。有三个事务,其中两个事务是前面说过的两个事务id分别是80、90,现在又开启一个事务100。
在trx_3的执行过程中,第一个SELECT的执行过程如下:
- 首先生成一个ReadView,其中m_ids值为[80,90],min_trx_id值为80,max_trx_id值为91,creator_trx_id值为0(因为该事务没有执行增删改操作)。
- 然后从版本链中挑选可见的记录,从上图可以看出,在SELECT查询时,其他两个事务都还没提交,但是trx_1进行了修改名称所以该版本的trx_id为80,在m_ids列表中,所以不符合可见性要求。
- 继续下一个版本链查询,下一个版本的名称是张三,trx_id为60,不在m_ids中,并且还小于min_trx_id,所以符合可见性要求,数据返回的名称是张三。
在trx_3的执行过程中,第二个SELECT的执行过程如下:
- 首先生成一个ReadView,其中m_ids值为[90],min_trx_id值为90,max_trx_id值为91,creator_trx_id值为0(因为该事务还没有执行增删改操作)。
- 然后从版本链中挑选可见的记录,从上图可以看出,trx_1事务已经提交了。并且trx_2对数据也进行了修改。所以该数据的最新版本trx_id为90,也在m_ids列表中,所以不符合可见性要求。
- 然后继续从版本链中查询,下一个版本就是名称为李四,年龄为25的数据,该版本的事务id为80,不在m_ids中,并且还小于min_trx_id,所以符合可见性要求,数据返回的名称是李四,,年龄为25的数据。
如果后续trx_3还有对student_id = 1001的数据进行访问,同理,继续比较版本链中的trx_id。
总结一下就是,读已提交(read committed)隔离级别在事务每次查询开始时,都会生成不同的ReadView。
可重复读(repeatable read)
可重复读(repeatable read)只会在第一次执行查询是生成一个ReadView,之后再查询就不会生成ReadView了。
还是相同的例子:
在trx_3的执行过程中,第一个SELECT的执行过程,与上面 读已提交(read committed)例子一致,这里就不做过多介绍了。下面主要介绍trx_id中第二个SELECT语句的查询。
在trx_3的执行过程中,第二个SELECT的执行过程如下:
- 因为事务在执行第一个SELECT查询中,已经生成过ReadView了,所以这里直接复用。其中m_ids值为[80,90],min_trx_id值为80,max_trx_id值为91,creator_trx_id值为0。
- 下面的步骤其实跟上面第一个SELECT执行过程是一致的。最后还是会返回名称为张三,年龄为20的数据。
总结一下,在可重复读(repeatable read)隔离级别下,事务的两次查询得到的结果都是一致的。(这里有个前提,查询的SQL语句是相同的)
我们知道只有聚簇索引记录才会有trx_id和roll_pointer两个隐藏列,这里有个问题,那如果我查询语句命中的是二级索引呢??该如何判断数据的可见性呢。
BEGIN;
SELECT * FROM students WHERE student_name = '张三'
假设student_name是二级索引列,并根据student_name进行查询。
二级索引记录是否可见,主要分为两步:
- 二级索引页的Page Header中有个属性叫做 PAGE_MAX_TRX_ID。每当对该页中的记录进行增删改操作时,如果执行的该操作的事务id大于PAGE_MAX_TRX_ID的值,就会把该事务id赋值给PAGE_MAX_TRX_ID属性值。当某个查询语句需要访问二级索引时,首先会对比一下ReadView的min_trx_id是否大于该页面的PAGE_MAX_TRX_ID的值。如果是,说明该页面中的所有记录都对该ReadView可见;如果不是,则需要执行下一步,回表之后判断可见性。
- 利用二级索引记录中的主键执行回表操作,得到对应的聚簇索引记录后再根据前面讲过的方式找到可见的第一版本数据。然后再判断二级索引记录中的值与聚簇索引记录中student_name的值是否一致,如果是,则返回给客户端,不是则跳过该记录。
MVCC小结
讲了这么多,可以看出MVCC就是在使用 读已提交(read committed)和可重复读(repeatable read),这两种隔离级别的事务执行普通SELECT操作时,访问记录的版本链过程。这样可以使不同事务并发执行,从而提升系统性能。读已提交(read committed)和可重复读(repeatable read)主要区别在于,生成的ReadView的时机不同,这里就不再复述了。
在上一章文章中说过,在执行DELETE语句或者更新主键值的UPDATE语句时,并不会立即删除对应的记录,而是执行一个delete mark操作。只是给要删除的记录打一个标记,这里就是主要为MVCC服务。如果两个事务并发执行,事务的隔离级别都是可重复读,T1事务根据搜索条件读取一条数据,然后T2事务将该数据删除,然后T1再根据相同的条件查询。如果T2直接删除,那么T1就读不到那条数据了,所以T2只是执行了一个delete mark操作,打一个删除的标记而已。
总结
本篇文章主要介绍,事务在并发过程中出现的一系列可能引发一致性的问题,比如脏读、不可重复读、幻读。
SQL标准中定义了4中隔离级别,读未提交(read uncommitted)、读已提交(read committed)、可重复读(repeatable read)、可串行化(serializable)。MySQL中InnoDB默认的隔离级别是可重复读(repeatable read)。
记录每次被修改,都会记录undo日志,日志之间根据roll_pointer连接成一个链表,也就是版本链。后续根据版本链来控制并发事务访问相同记录时的行为,我们把这种机制称为 多版本并发控制(Multi-Version Concurrency Control,MVCC) 。
为了提升在隔离级别读已提交(read committed)、可重复读(repeatable read)中,普通查询语句的性能,提出来ReadView,也就是一致性视图。
主要还介绍了MVCC在读已提交(read committed)、可重复读(repeatable read)两种隔离级别下,如何根据版本链找出可见的数据。