事务隔离级别与MVCC

事务隔离级别与MVCC

开发过程中,或多或少会碰到需要使用数据库事务的业务场景,而Spring框架提供的能力使得开发者无需过多地关注事务本身,这带来诸多便利,但也带来弊端:开发者只知其貌,而不知其理,一旦Spring框架抛出异常,便往往手足无措。本文简单地介绍了MySQL事务相关的基本概念,使用例证阐述了不同事务隔离级别下MySQL的数据可见性,简单讨论了MySQL如何保证数据一致性。有了这些基本的概念,遇到事务与数据库锁相关的问题时,开发者能够多一点思考。

事务的概念可以这样理解:

在MySQL中,有些操作必须要分步完成,那么我们可以把这些分步完成的操作声明为一个“事务”,用来保证MySQL数据一致性。

对于单条SQL语句而言,MySQL将其处理为一个「隐式事务」。

看起来,事务的概念还是有些空泛。事实上,了解并发的概念之后,更加容易理解事务这个概念,这并不是说事务一个并发概念,不过,事务是有了并发之后才衍生的概念,这很容易理解。试想一个只容许一个客户端连接的MySQL服务,是否需要“事务”呢?答案应该是否定的。单个客户端执行SQL语句总是有序的,数据一致性就能得到保证了1。试想,如果是多客户端的系统(事实上正是如此)同时执行SQL语句,就好似多线程同时访问资源一样,对于数据库系统而言,所有的数据表都是共享资源2,那么事务就像是那把保证并发安全的“锁”。

1 事务的ACID性质 #

事务可以保证被声明为事务的分步操作要么都成功执行,要么都不执行。若某一分步遇到错误,执行成功的那些步骤操作会被回滚,这样不会对MySQL的数据完整性进行破坏。

  • Atomicity 原子性

    原子性的概念和所有并发编程的概念一样,如果声明一个操作是原子的,那么这个操作要么执行,要么不执行。如果声明了一个事务,那么就可以把事务开启之后执行的SQL语句看作一个「并发编程里面的临界区」中的代码,它只能允许一个SQL连接访问数据。包含的SQL分步操作要么全部执行,要么全不执行。

  • Consistency 一致性

    数据库的状态只会从一个一致状态转移到另一个一致状态,事务操作不应该影响数据的一致性。比如转账操作,A账户转出100,B账户一定会转入100。若A账户转出100之后系统崩溃,账户A也不会损失100元,因为事务没有提交,所有事务的修改不会提交到数据库中。

  • Isolation 隔离性

    如果有多个事务并发执行,那么事务之间应该不能产生干扰,这也是保证数据安全性的重要环节。比如A账户转出100元,此时有另一个事务读取了A账户的值,读取到的账户的值不应该是A账户减去100元的值,而应该是原始值。

  • Durability 持久性

    一旦事务被提交,其对数据的修改是永久性的。即使系统崩溃,修改的数据也不会丢失。

2 事务的隔离级别 #

如果事务满足ACID性质,那么数据安全性就不会受到威胁。那么,设计一个逻辑来保证事务的ACID性质不就解决问题了么,为什么还要设计事务的隔离级别呢?结合实际情况来看,并非所有的事务都需要满足ACID特性,有些数据对准确性要求不高的的事务,是允许读取到其他事务修改的数据例证。另外,在实现过程中,一个满足ACID特性的数据库系统要复杂的多,系统需要做很多额外的操作来满足ACID特性,这样会造成额外的性能开销,系统会占用更多的资源而影响数据库的执行效率。这也是数据库中仍然有不支持事务的存储引擎一席之地的原因3

事务的隔离级别并不是MySQL独有的,它是SQL标准中定义的,每种隔离级别都规定了一个事务中所做的修改在哪些事务内和事务间是可见的,哪些是不可见的。较低的隔离级别支持更高的并发、拥有更高的效率。

  • READ UNCOMMITTED读未提交

    这个隔离级别中,事务中的修改,即使没有提交,对其他事务都是可见的。其他事务可以读取到未提交的数据,这种情况称为脏读dirty read)。这个级别会导致很多问题,一般很少使用。

  • READ COMMITTED读已提交

    这个隔离级别中,满足隔离性的简单定义,一个事务开始前,只能读取到已经提交的事务所做的修改。换言之,一个事务对数据库所做的任何修改在其提交之前都其他事务都是不可见的。这会导致是个现象:一个事务可能2次读取到的数据是不一致的(事务A提交前与提交后),这种情况称为不可重复读nonrepeatable read

  • REPEATABLE READ可重复读

    可重复读解决了脏读的问题,同时也保证了在事务了多次对同一个数据取样,读取到的数据是一致的。

    但是,理论上,该隔离级别的不能解决幻读phantom read)的问题:幻读指的是某个事务在读取某个范围的记录时,另外一个事务又在该范围内插入了新的记录,那么,当之前的事务再次读取这个范围的记录时,就会出现幻行phantom row)。

    不过InnoDB引擎通过MVCC(multi version concurrency control)多版本并发控制解决了幻读的问题。

    可重复读是MySQL的默认隔离级别

  • SERIALIZABLE串行化

    SERIALIZABLE是最高的隔离级别,它通过强制所有的SQL语句串行执行来避免幻读的问题。该隔离级别下,每一行使用到的数据都会加锁,所以当数据库请求量大的时候,就有可能造成大量的超时等待4和锁争用的问题。这个隔离级别很少使用,因为其牺牲了很大量的性能来保证数据一致性。如果不是严格地要求数据一致性,一般不考虑此隔离级别。

下表展示了事务隔离级别以及其可能引起的后果之间的关系:

隔离级别脏读可能性不可重复读可能性幻读可能性加锁读
READ UNCOMMITTEDYYYN
READ COMMITTEDNYYN
REPEATABLE READNNYN
SERIALIZABLENNNY

3 查看数据库的基本信息 #

在实例演示之前,需要查看几个基本信息,来确保测试环境的一致性与可行性。这些基本信息包括,数据库当前所使用的引擎,数据库当前的事务隔离级别。

3.1 引擎 #

使用下面的命令查看当前数据库的引擎信息

 1mysql> show  variables like '%engine%';
 2+----------------------------------+--------+
 3| Variable_name                    | Value  |
 4+----------------------------------+--------+
 5| default_storage_engine           | InnoDB |
 6| default_tmp_storage_engine       | InnoDB |
 7| disabled_storage_engines         |        |
 8| internal_tmp_disk_storage_engine | InnoDB |
 9+----------------------------------+--------+
104 rows in set (0.01 sec)

看到系统当前数据库(版本5.7)的默认存储引擎是InnoDB,InnoDB引擎支持事务,就用这个引擎测试。

我们使用供应商表vendors完成接下来的测试,先看看vendors表所使用的引擎:

 1mysql> show table status like 'vendors' \G
 2*************************** 1. row ***************************
 3           Name: vendors
 4         Engine: InnoDB
 5        Version: 10
 6     Row_format: Dynamic
 7           Rows: 6
 8 Avg_row_length: 2730
 9    Data_length: 16384
10Max_data_length: 0
11   Index_length: 0
12      Data_free: 0
13 Auto_increment: 1008
14    Create_time: 2020-11-25 18:37:46
15    Update_time: NULL
16     Check_time: NULL
17      Collation: utf8_general_ci
18       Checksum: NULL
19 Create_options:
20        Comment:
211 row in set (0.00 sec)

我们看到vendors表的引擎也是InnoDB。

3.2 隔离级别 #

使用如下命令查看当前数据库的事务隔离级别

1mysql> show variables like 'tx_isolation';
2+---------------+-----------------+
3| Variable_name | Value           |
4+---------------+-----------------+
5| tx_isolation  | REPEATABLE-READ |
6+---------------+-----------------+
71 row in set (0.01 sec)

当前数据库的事务隔离级别为可重复读,加上InnoDB引擎的MVCC,基本上是满足事务的ACID特性的数据库系统。

在进行测试时,我们可以通过

1mysql> SET TRANSACTION ISOLATION LEVEL [tx_isolation];

来控制当前客户端连接的下个事务隔离级别——只对当前连接的下一次事务生效生效,这样便于测试。

更多关于set transaction命令的内容: https://dev.mysql.com/doc/refman/8.0/en/set-transaction.html

mysql 8查看以及设置事务隔离级别的命令有所变化:

查看事务隔离级别:

select @@global.transaction_isolation, @@transaction_isolation;

设置事务隔离级别:

set session transaction isolation level read committed;
set global transaction isolation level read committed;

在测试过程中,涉及到的客户端命令有:

  • begin或者start transaction开始事务
  • rollback回滚/结束事务
  • commit提交/结束事务

4 读未提交 READ UNCOMMITTED #

脏读

READ UNCOMMITTED 隔离级别出现脏读和不可重复读的问题

从上面的执行图可以看到:

  • 会话1(左)和会话2(右)的事务隔离级别都设置为READ UNCOMMITTED
  • 会话1将vend_id=1007对应的vend_address改为sz
  • 此时会话2中去读取vend_id=1007的数据,已经读取到了会话1未提交的更改;<==脏读
  • 接着会话1将更改回滚
  • 会话2再次读取vend_id=1007的数据,发现数据列vend_address变为初始值。<==不可重复读

上面的执行流程完整的演示了在READ UNCOMMITTED隔离级别下的脏读不可重复读现象。

5 读已提交 READ COMMITTED #

不可重复度

READ COMMITTED 隔离级别出现不可重复读的问题

从上面的执行图可以看到:

  • 会话1和会话2的事务隔离级别都设置为READ COMMITTED
  • 会话1将vend_id=1007对应的vend_address改为sz
  • 此时会话2中去读取vend_id=1007的数据,不能读取到会话1未提交的更改;<==旧数据
  • 会话1提交更改
  • 会话2再次读取vend_id=1007的数据,读取到数据列vend_address的更改。<==新数据

上面的执行流程完整演示了在READ COMMITTED隔离级别下,两次读取到的结果不一致的现象,即在此隔离级别下不可重复读

6 多版本并发控制(MVCC) #

前文提到,mysql的InnoDB引擎使用MVCC解决了在可重复读(REPEATABLE READ)隔离级别下幻读(phantom read)的问题。因此,在执行可重复读隔离级别的测试之前,先介绍一下多版本并发控制(Multi Version Cocurrency Control)5

可以认为,MVCC是行级锁的一个变种,但是其在很多情况下避免了加锁操作6,因此可以节省部分开销,获得更好的性能。

在InnoDB的MVCC中,通过在每行记录之后添加2个隐藏的列来实现的:

\\隐藏列1隐藏列2
记录内容行的创建时间行的过期时间(删除时间)

要注意的是,实际上存储的并不是实际的时间值,而是当前的系统版本号(system version number)。系统版本号有如下特征:

  • 每开始一个新事务,系统版本号都会递增
  • 事务开始时的版本号作为事务的版本号
  • 事务的版本号用来和查询到的每条记录的版本号对比,决定语句的操作;

REPEATABLE READ隔离级别下,MVCC的具体行为是:

  • SELECT InnoDB会根据一下条件检查每行记录:

    1. InnoDB只查找版本号早于当前事务版本号的数据(小于等于事务的版本号) 。这样做,可以保证当前事务只能读取事务开始前已经存在的数据行,或者该事务自身插入或者修改的数据行;
    2. 行的删除版本要么未定义,要么大于当前事务版本号。这可以保证事务开始前,读取到的行未被删除
  • INSERT InnoDB为新插入的每一行保存当前系统版本号所为行版本号

  • DELETE InnoDB为删除的每一行保存当前系统版本号作为删除标识

  • INSERT InnoDB为插入一行新纪录保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识

事实上,MVCC是通过保存数据在某个时间点的快照snapshot)来实现的,也就是说不管事务执行多长时间,其所能看到的数据都是一致的。这就可能造成一个现象:

根据事务开始的时间的不同,每个事务对同一张表,同一时刻看到的数据可能是不一致的

在接下来的演示中,我们将会看到MVCC在事务执行过程中的行为。

7 可重复读 REPEATABLE READ #

可重复读

REPEATABLE READ 是默认的事务隔离级别

这是一个相对完整的示例,演示了InnoDB引擎在默认事务隔离级别下,不同事务在处理同一行数据之间的表现,其中有一些结果出乎意料却又在MVCC以及事务隔离级别的“情理之中”。在这个示例中我们可以看到以下重要内容:

  1. 事务只能读取到在其开始之前就已经存在的数据,或者其自身修改的数据;
  2. 事务使用了行级锁来保证数据一致;
  3. 事务B可以修改事务A创建但未提交的数据,并且事务B随即可读取之,这验证了第1点;
  4. 事务A无法读取到事务B的修改(只要这个修改发生在事务A开始之后,无论事务B是否提交),这保证了可重复读
  5. 如果数据行在事务A在开始前已经存在,但随即被事务B删除,那么事务A无法再对数据行进行修改。但是事务A依旧可以读取到数据行的内容。这就是“快照”的概念在MVCC中的行为。

遗憾的是,笔者试图从MVCC“系统版本号”的概念去推断事务的执行,始终无法得出与预期一致的结果,所以关于MVCC“系统版本号”的工作机制,此文尚不能详述,不过,程序的执行期望却和前文描述的MVCC行为是一致的。其实,使用“快照”的概念去理解MVCC的行为,会显得更容易。

8 串行 SERIALIZABLE #

串行

SERIALIZABLE 隔离级别效率很低

当使用最高的事务级别同时开启2个事务时,2个事务只能依次执行,换言之,会话2会阻塞会话1的insert操作,只有当会话2commit/rollback之后,会话1才会结束阻塞。

上图中第一次执行insert的时候,发现语句迟迟不返回,以为是语句故障,使用ctrl-c结束了语句执行,控制台输出:

1ERROR 1317 (70100): Query execution was interrupted

看到关键字interruptted,证明了insert操作确实是处于阻塞状态。

9 参考 #


  1. 类比资源的序列访问,可能不太恰当。 ↩︎

  2. 当然可以使用权限控制将某个资源排除对特定连接的共享。 ↩︎

  3. 这也是有些业务不需要事务支持,使用MyISAM(indexed sequencial access method)作为数据库引擎的原因。 ↩︎

  4. 一些存储引擎在处理数据库死锁的时选用的方法。InnoDB并不是采用的此方法,其是将持有最少行锁的事务回滚。 ↩︎

  5. 不仅仅mysql,很多数据库系统包括Oracle,PostGreSQL都实现了MVCC,尽管其实现机制不尽相同。 ↩︎

  6. MVCC的并发控制有乐观加锁和悲观加锁两种方式,并不是所有的实现都不加锁,只有使用乐观锁是不加锁的。 ↩︎

  7. 此链接正文部分关于索引的讨论有些谬误,这些谬误在评论区可找到相关讨论。 ↩︎