MySQL之事务的隔离性

一提到事务,我们就会想到事务的4个特性ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性和持久性)。本篇文章,主要讲的就是隔离性。

mysql里面并不是所有的存储引擎都支持事务,因此本文中针对事务的讨论都是基于存储引擎innodb。

一、隔离级别

1.1 “隔离级别”的概念

脏读(dirty read): A 事务可以读到B事务未提交的数据,就叫脏读。
不可重复读(no-repeatable read): 事务A开始时只能看到其它事务已经提交的修改。换句话说,事务A的两次查询可能看到不同的结果。(其它事务中途提交修改)。不可重复读针对的是单条记录。
幻读(phantom read):当某个事务在读取某个范围内的记录时,另外一个事务又在该范围内插入了新的记录,当之前的事务再次读取该范围的记录时,会产生幻行(Phantom Row)。

当数据库上有多个事务 并行执行 的时候,会出现脏读(dirty read)、不可重复读(no-repeatable read)、幻读(phanton read)的问题,为了解决这些问题,就出现了”隔离级别”的概念。

1.2 不同的隔离级别

在谈隔离级别之前,我们首先要直到,你隔离的越严实,效率就会越低。因此很多时候,我们需要在二者之间寻找一个平衡点。

标准的事务隔离级别有以下几种:

  1. 读未提交(read uncommited): 一个事务还没提交时,它做的变更就能被别的事务看到。
  2. 读提交(read commited): 一个事务提交之后,它做的变更才会被其他事务看到。
  3. 可重复读(repeatable read): 一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
  4. 串行化(sirializable): 对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

1.3 隔离级别的修改

隔离级别的查看

1
2
3
4
5
6
7
mysql> show variables like "%transaction_isolation%";
+-----------------------+-----------------+
| Variable_name | Value |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+
1 row in set (0.00 sec)

隔离级别的修改

1
set [ global | session ] transaction isolation level [ Read uncommitted  | Read committed | Repeatable read | Serializable ];

注:[] 表示可选。

1.4 可重复读隔离级别的演示

由于mysql默认的隔离级别是可重复读,因此这里就以可重复读为例进行演示,其它情况可通过修改隔离级别模拟。

步骤1:创建测试表,插入测试数据

1
2
mysql> create table T(c int) engine=InnoDB;
insert into T(c) values(1);

步骤2:起2个事务模仿并发的情况,以下是两个事务中不同操作的执行顺序。

可重复读

从图中可以看到,右边的事务无论是执行 update 操作,还是提交事务,左边的事务都是看不到的。当左边的事务提交之后,再次查询,才能看到更新之后的数据。完美契合可重复读的定义:事务执行期间看到数据可事务开启时看到的数据一致。

其它隔离级别读者可自行实验。

提示

  • 如果是读未提交,那在上图第5步的时候,会发现数据发生变化
  • 如果是读提交,那在上图第7步的时候,会发现数据发生变化
  • 如果是串行化,那在执行第4步的时候,会失败,必须等左边的事务提交,右边的事务才能继续执行。

1.5 事务隔离的实现

实现上,数据库会创建一个视图,访问的时候以视图的逻辑结果为准(注:此视图实为一致性读视图 consistend read view,不是MySQL中特指查询结果的那个视图)。不同时刻启动的事务,会有不同的视图,这意味着不同视图里面的同一条数据会有不同的版本,这就是数据库多版本并发控制(MVCC)。

“读未提交”隔离级别下,直接读取的最新数据,没有视图的概念;”读提交”隔离级别下,事务中每次执行查询语句前,都会新建一个视图;”可重复读”隔离级别下,事务启动的时候,会创建一个视图,整个事务执行期间都用这个视图;”串行化”隔离级别下,直接用加锁的方式来避免并行访问。

以可重复读为例:

在可重复读隔离级别下,不同事务在启动的时候,就拍了不同的”快照”: 一个基于整个库的快照。我们先来看下这个快照是怎么实现的。

Innodb中每个事务都有一个transaction id,这个id是在事务开始的时候向系统申请的,是按申请顺序严格递增的。

数据库中的每行数据有多个版本,每一个版本都是和一个transaction id绑定的。比如分别有事务trx_id_1,trx_id_2,trx_id_3 对某行数据进行更新,更新过程中该行数据产生了3个版本v1,v2,v3,记录的时候就会这样记:(v1, trx_id_1),(v2, trx_id_2),(v3, trx_id_3)。实际存储的时候,数据库只会记录最新的记录(v3, trx_id_3),之前的记录则是通过undo日志计算出来的。

有了transaction id和多版本的概念,快照的实现方式可以这样描述:

  1. InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在活跃的所有事务 ID。“活跃”指的就是,启动了但还没提交。
  2. 数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。(注意:这里的ID的最大值是已经创建过的所有事务ID的最大值)

这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。

数据版本的可见性规则,就是基于数据的 trx_id 和这个一致性视图的对比结果得到的。

假设数据版本对应的事务id为trx_id,则可见性规则如下:

  1. 如果trx_id小于低水位,则数据可见。
  2. 如果trx_id大于高水位,则数据不可见
  3. 如果trx_id在高低水位之间,有两种可能:
    1. 如果trx_id在数组里,则数据不可见
    2. 如果trx_id不在数组里,则数据可见

此外,如果是这个事务自己更新的数据,它自己还是要认的。

假设事务A启动的时候,当前系统中活跃的事务ID为[trx_id_1, trx_id_2, trx_id_3],最小事务ID为trx_id_1,系统里已经创建过的事务ID的最大值加1为trx_id_max,当前数据版本为(V4,trx_id_x4)。

如果trx_id_x4 > trx_id_max,就认为当前V4不可见,数据版本根据undolog回退到上一个版本(V3,trx_id_x3),如果trx_id_x3在高低水位之间,并且在数组里面,仍然认为V3不可见,在根据undolog回退到上一个版本(V2,trx_id_x2),如果trx_id_x2小于低水位,认为数据可见。那么当前事务A在整个事务期间看到的这一行数据的版本都是V2。

而读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:

  • 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
  • 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。

InnoDB 正是利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。

1.5 不同隔离级别的应用场景

二、事务到底是隔离的还是非隔离的

上面介绍的不同的隔离级别下看到的数据版本针对的是当前事务的操作是纯读的情况,如果当前事务事务中存在更新操作,那会是什么情况呢?

当然,串行化的隔离级别下,无论读写,都会加锁,并等待锁的释放,所以这种情况下和原来一样。其它三种级别下,事务执行过程中,如果出现update操作,就会应用这样一条规则:

更新数据都是先读后写的,而这个读,只能读当前最新版本的值,称为当前读(current read)

读未提交隔离级别下,一直都是读的最新版本值,所以这条规则实际影响的是 读提交可重复读

在可重复读的事务过程中,如果出现了update操作,会等其它事务的update完成,释放行锁,然后读取当前值,在执行update语句。在这之后的select语句的执行结果都是更新后的值,因为1.5中:

此外,如果是这个事务自己更新的数据,它自己还是要认的。

三、尽量避免使用长事务

事务的隔离中用到一致性视图,而一致性视图需要用到回滚日志undolog。那回滚日志什么时候删除呢?答案是,在不需要的时候才删除。也就是说,系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志会被删除。

什么时候才不需要了呢?就是当系统里没有比这个回滚日志更早的 read-view 的时候。

长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库。

因此建议总是使用 set autocommit=1, 通过显式语句的方式来启动事务(这样之前挂起的事务会被隐式提交)。


gongzhonghaopic


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!