> 文档中心 > MYSQL 事务、事务隔离级别和MVCC,幻读

MYSQL 事务、事务隔离级别和MVCC,幻读


快照读和当前读

 

快照读:快照读就是读取的是快照数据,不加锁的普通 SELECT 都属于快照读。如下面语句:

select  * from table where .....

当前读:当前读就是读的是最新数据,而不是历史的数据,加锁的 SELECT,或者对数据进行增删改都会进行当前读。如下面语句:

SELECT * FROM table LOCK IN SHARE MODE;

SELECT FROM table FOR UPDATE;

INSERT INTO table values ...

DELETE FROM table WHERE ...

UPDATE table SET ...

事务

事务是由一步或几步数据库操作序列组成逻辑执行单元,这系列操作要么全部执行,要么全部放弃执行。事务实现的主要两种方式:自动提交和手动提交, mysql默认是自动提交的。这两个方式实现如下:

1.用begin,rollback,commit来实现如下:
    begin开始一个事务
    rollback事务回滚
       commit 事务确认

2. 直接用set来改变mysql的自动提交模式,mysql默认是自动提交的

        set autocommit = 0 禁止自动提交
        set autocommit = 1 开启自动提交

在MYSQL中事务是由引擎来实现的,因此并不是所有引擎都支持事务,如MYSQL的InnoDB 引擎是支持事务的,而 MyISAM 引擎是不支持事务的。

事务的特性

事务看起来感觉简单,但必须遵守ACID 特性:原子性(atomicity)、一致性(consistency)、隔离性(isolation)、持久性(durability)。这ACID 特性如下:

原子性(atomicity):事务是一个不可分割的工作单位,要么全部执行成功,要么全部不执行。只要其中一个指令执行失败,所有的指令都执行失败,数据进行回滚,回到执行指令前的数据状态

一致性(Consistency:数据库的完整性不会因为事务的执行而受到破坏,比如表中有一个字段为姓名,它有唯一约束,也就是表中姓名不能重复,如果一个事务对姓名字段进行了修改,但是在事务提交后,表中的姓名变得非唯一性了,这就破坏了事务的一致性要求,这时数据库就要撤销该事务,返回初始化的状态

隔离性(isolation):一个事务的执行不能被其他事务所影响,比如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离

持久性(durability):是指一个事务一旦正确完成后,它对数据库中数据的改变就是永久性的

我们知道了ACID 特性之后,那么我们就会InnoDB 引擎是通过什么技术保证ACID 特性的呢?

1. 原子性和持久性是通过 redo log (重做日志)来保证的

2. 一致性是通过 undo log(回滚日志) 来保证的

3. 隔离性是通过 MVCC(多版本并发控制) 或锁机制来保证的

这里我们重点讲解隔离性,而 一致性、原子性和持久性是通过日志来保证的,所以会在MYSQL的日志文章中详细讲解

MySQL开始事务命令:

1. begin/start transaction 命令

2. start transaction with consistent snapshot 命令

这两种开启事务的命令,事务的启动时机是不同的:

1.  执行了 begin/start transaction 命令后,并不代表事务启动了。只有在执行这个命令后,执行了增删查改操作的 SQL 语句,才是事务真正启动的时机;

2.  执行了 start transaction with consistent snapshot  命令,就会马上启动事务

并发事务有引起什么问题?

当MYSQL服务端同时处理多个事务时,可能会出现下面问题:

脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)

脏读(dirty read)

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

脏读现象

脏读现象图解释说明:

1.  有A和B两个事务同时对数据库处理

2. 事务A先读取数据库余额数据,然后再执行更新余额数据,但并没有提交事务

3. 此时事务B刚好读取数据库余额数据,而此时事务B读到的数据刚好是事务A没有提交事务更新的余额数据

4. 事务B读取余额数据之后,事务A发生回滚操作

从图和图解释我们可以知道, 事务B读取到的数据是过期的数据,而这种现象叫做脏读。即这种读取到另一个事务未提交的数据的现象就是脏读(Dirty Read)。

不可重复读(non-repeatable read)

不可重复读:在一个事务内多次读取同一个数据,如果出现前后两次读到的数据不一样的情况,就意味着发生了「不可重复读」现象。不可重复读现象如下图:

不可重复读现象

 不可重复读现象图解释说明:

1.  有A和B两个事务同时对数据库处理

2. 事务A先读取数据库余额数据,读取数据之后,去干别的事了

3. 此时事务B来更新数据库余额数据,并成功提交了事务

4. 然后事务A再读取数据库余额数据,而发现事务A前后两次读取的数据是不一致

从图和图解释我们可以知道, 事务A前后两次读取的数据是不一致的这种现象叫做脏读。即这种在同一个事务中,前后两次读取的数据不一致的现象就是不可重复读(Nonrepeatable Read)。

幻读(phantom read)

幻读:在一个事务内多次查询某个符合查询条件的「记录数量」,如果出现前后两次查询到的记录数量不一样的情况,就意味着发生了「幻读」现象。幻读现象如下图:

幻读现象

 幻读现象图解释说明:

1.  有A和B两个事务同时对数据库处理

2. 事务A先读取数据库余额大于100万的数据记录,发现有5条

3. 然后事务B已同样的条件查询数据库,发现满足条件的数据记录也是5条

4. 然后事务A向数据插入一条余额大于100万的数据记录,并提交事务

5. 然后事务B已同样的条件查询数据库,发现满足条件的数据记录也是6条

从图和图解释我们可以知道, 事务B发现和前一次读到的记录数量不一样了,就感觉发生了幻觉一样,这种现象就被称为幻读。即这种在同一个事务中,相同的查询条件,前后两次读取的数据记录数量不一致的现象就是幻读(phantom read)。

并发事务也会出现两类更新丢失问题,如下:

第一类更新丢失
第二类更新丢失

事务隔离级别

从前面提到的并发事务,我们知道并发事务可能会出现以下现象:脏读、不可重复读、幻读。

脏读:读到其他事务未提交的数据

不可重复读:前后读取的数据不一致

幻读:前后读取的记录数量不一致

为了解决这些并发事务出现的现象,SQL使用四种隔离级别来规避这些现象,隔离级别越高,性能效率就越低。这四个隔离级别如下:

  • 读未提交(read uncommitted,指一个事务还没提交时,它做的变更就能被其他事务看到。是最低的隔离级别。只能防止第一类更新丢失,不能解决脏读,可重复读,幻读,所以很少应用于实际项目

  • 读提交(read committed,指一个事务提交之后,它做的变更才能被其他事务看到。可以防止脏读和第一类更新丢失,但是不能解决可重复读和幻读的问题

  • 可重复读(repeatable read,指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB 引擎的默认隔离级别。可以防止脏读、不可重复读、第一类更新丢失、第二类更新丢失的问题,不过还是会出现幻读

  • 串行化(serializable );会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。可以解决上面提到的所有并发问题,但可能导致大量的超时现象和锁竞争,通常不会用这个隔离级别。

按隔离级别高低排序如下:

隔离级别从高到低

 隔离级别对并发问题的解决情况:

注意:InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它通过next-key lock 锁(行锁和间隙锁的组合)来锁住记录之间的“间隙”和记录本身,防止其他事务在这个记录之间插入新的记录,这样就避免了幻读现象

到这里我们知道这四种隔离级别分别是什么,与其他们的概念。但是它们是怎么实现的呢?如下:

  • 对于「读未提交」隔离级别的事务来说,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就好了;

  • 对于「串行化」隔离级别的事务来说,通过加读写锁的方式来避免并行访问;

  • 对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 Read View 来实现的,它们的区别在于创建 Read View 的时机不同,大家可以把 Read View 理解成一个数据快照,就像相机拍照那样,定格某一时刻的风景。「读提交」隔离级别是在「每个语句执行前」都会重新生成一个 Read View,而「可重复读」隔离级别是「启动事务时」生成一个 Read View,然后整个事务期间都在用这个 Read View。

MVCC

什么是 MVCC

MVCC 全称 Multi-Version Concurrency Control,即多版本并发控制,主要是为了提高数据库的并发性能。同一行数据平时发生读写请求时,会上锁阻塞住。但 MVCC 用更好的方式去处理读写请求,做到在发生读写请求冲突时不用加锁。这个读是指的快照读,而不是当前读,当前读是一种加锁操作,是悲观锁。

MVCC解决什么问题

在数据库并发情况中,如果只有读-读操作可以并发,而读-写写-读写-写操作都要阻塞,这样就会导致 MySQL 的并发性能极差。而采用了 MVCC 机制后,只有写写之间相互阻塞,其他三种操作都可以并行,这样就可以提高了 MySQL 的并发性能。也就是说MVCC 解决了以下问题:

1. 并发读-写时:可以做到读操作不阻塞写操作,同时写操作也不会阻塞读操作

2.   解决脏读幻读不可重复读等事务隔离问题,但不能解决上面的写-写(需要加锁)问题

MVCC原理

它的实现原理主要是版本链,undo日志,Read View 来实现

版本链

在InnoDB数据库存储结构 中,我们知道聚簇索引有三个隐藏列,如下:

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

对该记录每次更新后,都会将旧值放到一条 undo 日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。

另外,每个版本中还包含生成该版本时对应的事务 id。于是可以利用这个记录的版本链来控制并发事务访问相同记录的行为,那么这种机制就被称之为多版本并发控制(MVCC)

undo日志

undo log 主要用于记录数据被修改之前的日志,在表信息修改之前先会把数据拷贝到undo log里。当事务进行回滚时可以通过 undo log 里的日志进行数据还原。MySQL 的日志 undo log、redo log、binlog

Undo log 的用途

1. 保证事务进行rollback时的原子性和一致性,当事务进行回滚的时候可以用 undo log 的数据进行恢复

2. 用于 MVCC快照读的数据,在 MVCC 多版本控制中,通过读取undo log历史版本数据可以实现不同事务版本号都拥有自己独立的快照数据版本

undo log 主要分为两种

insert undo log:  代表事务在 insert 新记录时产生的 undo log , 只在事务回滚时需要,并且在事务提交后可以被立即丢弃

update undo log: 事务在进行 update 或 delete 时产生的 undo log, 不仅在事务回滚时需要,在快照读时也需要。所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除

Read View

认识Read View?

在Read View 中有四个重要的字段,如下:

说明:

max_trx_id并不是指m_ids中的最大值,因为事务 id 是递增分配的,假如现在有 id 为 1,2,3 这三个事务,之后 id 为 3 的事务提交了,而id 为 1,2 的事务未提交 。那么一个新的读事务在生成 ReadView 时,m_ids 就包括 1 和 2,min_trx_id 的值就是 1,max_trx_id 的值就是 4。

Read View解决什么问题?

首先,我们知道在查询记录时,我们可以读到那个版本的数据记录?如下:

1. 读未提交(read uncommitted隔离级别中,可以直接读取记录的最新版本

2. 读提交(read committed)和 可重复读(repeatable read隔离级别中,是读到已提交的修改记录,如果没有提交是读取不到的

3. 串行化(serializable )隔离级别中,因为InnoDB是使用加锁方式访问记录,所以不存在并发问题

所以,现在就有个问题就是读提交(read committed)和 可重复读(repeatable read隔离级别对应的不可重复读幻读都是指同一个事务在两次读取记录时出现不一致的情况,怎么判断版本链中的哪个版本是当前事务可见的?

Read View就是用来解决这个问题的,可以帮助我们解决可见性问题。事务进行快照读操作的时候就会产生 Read View,它保存了当前事务开启时所有活跃(未提交的事务)的事务列表。

Read View怎么解决这个问题的?

从上面我们知道Read View的重要字段有什么,但是我们还要知道聚簇索引记录中也有两个很重要的隐藏列:trx_id和roll_pointer。这两个隐藏列含义如下:

trx_id:当一个事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里

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

所以我们可以根据记录中的 trx_id和创建Read View的max_trx_id和min_trx_id来划分情况: 

分析如下:

1.  当 trx_id < min_trx_id时,示这个版本的记录是在创建 Read View 已经提交的事务生成的,所以该版本的记录对当前事务可见

2. 当 trx_id >= max_trx_id时,表示这个版本的记录是在创建 Read View 才启动的事务生成的,所以该版本的记录对当前事务不可见

3.当 min_trx_id <= trx_id < max_trx_id时,则要判断  trx_id是否在m_ids中:

       a.  如果记录的 trx_id   m_ids 列表中,表示生成该版本记录的活跃事务依然活跃着                   (还没提交事务),所以该版本的记录对当前事务不可见

        b.  如果记录的 trx_id 不在  m_ids 列表中,表示生成该版本记录的活跃事务已经被提                 交,所以该版本的记录对当前事务可见

其实,这种通过「版本链」来控制并发事务访问同一个记录时的行为就叫 MVCC(多版本并发控制)

在 MySQL 中,RC和 RR 隔离级别的的一个非常大的区别就是它们生成 ReadView 的时机不同。下面我们就会对这两种隔离级别进行分析。

RC中Read View怎么工作的

读提交隔离级别是在每次读取数据时,都会生成一个新的 Read View。也就是说,事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。

下面通过例子说明提交隔离级别是怎么工作的?首先假设事务 A (事务 id 为51)启动后,紧接着事务 B (事务 id 为52)也启动了,接着按顺序执行了以下操作:

1. 事务 B 读取数据(创建 Read View),ian的账号余额为 100 万;

2. 事务 A 修改数据(还没提交事务),将ian的账户余额从 100 万修改成了 200 万;

3. 事务 B 读取数据(创建 Read View),ian的账户余额为 100 万;

4. 事务 A 提交事务;

5. 事务 B 读取数据(创建 Read View),ian的账户余额为 200 万;

下面看事务 B每次读取数据时创建的  Read View,如下:

第一次事务B读取数据
事务A修改数据
第二次事务B读取数据
事务A提交之后,第三次事务B读取数据

 从上面的图分析可得,事务 B 第二次读数据时,事务 A修改了数据但没有提交事务,但事务 B会先看到记录trx_id为51的,但在事务 B 的 Read View 的 min_trx_id 和 max_trx_id 之间,所以就要判断trx_id是否在m_ids范围内,从图可知在m_ids范围内,所以这条记录是被还未提交的事务修改的,这时事务 B 并不会读取这个版本的记录。这时候,我们就要沿着 undo log 链条往下找旧版本的记录,即就是trx_id 为 50 的记录。

但是,当事务 B 第三次读数据时,发现trx_id 是 51的记录比事务 B的min_trx_id 小,所以该版本(trx_id 是 51)的记录对事务 B 是可见的。

结论:因为在读提交隔离级别下,事务每次读数据时都重新创建 Read View,那么在事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。

RR中Read View怎么工作的

可重复读隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View。

同样也通过例子说明,而且例子和[RC中Read View怎么工作的]的例子一样,步骤也一样。同样是事务 A (事务 id 为51)启动后,紧接着事务 B (事务 id 为52)也启动了,但是第三次事务 B读到的数据是100万了。下面看事务 A和事务 B创建的  Read View,如下:

事务开启时,创建的Read View
事务 A 修改之后的数据记录

 从如分析可知,事务 B 第一次读数据时,先会找到trx_id为50的记录,而trx_id为50的记录比事务B的Read View中的min_trx_id(51)小,所以该版本(trx_id为50的记录)的记录对事务 B 可见的(即获得该版本数据记录)。

然后在事务 A 修改记录但没提交记录时,事务 B第二次读数据,先会找到trx_id为51的记录,在事务 B的Read View中的min_trx_id和max_trx_id之间,则就会判断trx_id是否在m_ids中,判断结果是在的,说明该记录还没有提交,所以事务B不会读取该记录。而会沿着 undo log 链条找更旧的记录,即找到trx_id为50的记录。

再接着,如果事务A提交了事务,由于在可重复读的隔离级别下,Read View是在启动事务时创建的,所以这时候读到的数据记录结果和第二次一样的。

结论:「在可重复读」隔离级别下在事务期间读到的记录都是事务启动前的记录

避免幻读的方法

避免幻读的方案:

1. 针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题

2. 针对当前读(select ... for update 等语句),是通过 next-key lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题

但是,这些方案只是避免幻读,并不会完全解决幻读问题。比如下面情况:

第一种情况:

1. 事务 A 先执行「快照读语句」:select * from t_test where id > 10 得到了 3 条记录。

2. 然后 事务 B 往插入一个 id= 200 的记录并提交

3. 最后,事务 A 再执行「当前读语句」 select * from t_test where id > 100 for update 就会得到 4 条记录,此时也发生了幻读现象

这种情况,最好是在开启事务之后,马上执行 select ... for update 这类当前读的语句,这样会马上对记录加 next-key lock,从而避免其他事务插入一条新记录

第二种情况:

1. 事务 A 先执行「快照读语句」:select * from t_test where id = 10 ,而这条数据是没有的,所以返回空

2. 然后 事务 B 往插入一个 id= 10 的记录并提交

3. 接着,事务 A 再执行update t_test set balance = 300 where id = 10 

4. 最后,事务 A 再执行「快照读语句」:select * from t_test where id = 10,会发现找到了这条数据

 这是因为在可重复读隔离级别下,事务 A启动时生成了 ReadView,之后事务 B向表添加了id = 10 的记录并提交事务。接着,事务 A还更新了这条(id =10)数据记录,从而把记录隐藏列trx_id的值变成了事务 A 的事务 id,所以再执行查询时可以找到(id = 10)这条数据记录,而产生幻读。