MVCC真的解决幻影读了吗?(事务隔离级别详解)

2,916 阅读12分钟

本文主要通过多线程并发读写数据库场景下产生的问题引出不同的事务隔离级别、相应的算法以及在Innodb中的实现方式,并穿插对于Java多线程下可见性、原子性、有序性的比较。 主要参考:《数据密集型应用系统设计》 掘金小册-《从根上理解Mysql》 PS: 如果不了解Java是如何保证多线程下访问的安全性,可以参考:synchronized与volatile是如何保证原子、可见、有序的?

如果对事务隔离性有过了解,具体MVCC下的幻读问题可以直接跳到MVCC下的幻读问题目录下看~

隔离(可见性)问题

ACID规定了事务应该具有原子性、一致性、隔离性、持久性。

原子性

多线程语义下的原子性是指:如果一个线程正在执行原子操作,这意味着其他线程无法看到该操作的中间结果,而只能看到该操作发生前或者操作完成之后的状态。

数据库事务的原子性定义与多线程下的定义并不相同:如果一个线程正在执行一个原子的数据库事务,期间出现了导致事务无法提交的故障时,则会中止当前事务,并回滚当前事务所做的更改。

因此数据库中的原子性称为可中止性更为贴切。

隔离性

隔离性主要定义了多个事务的并发访问下的问题,因此与多线程下的原子性+有序性定义类似,即其他事务不可以看到当前事务执行的中间结果(多线程原子性),并且在多个事务并行访问(读写)相同的数据时,其因为网络原因而导致的修改先后问题不会导致数据库磁盘语义的错误(多线程有序性)。

要完全满足隔离性,即并行的多个事务表现的语义和串行一致,需要付出较大的代价,因而提出了弱隔离级别的概念,并通过不同的加锁算法以及多版本并发控制(MVCC)来实现;但是不同层次的隔离级别也会带来不同的并发问题。

PS:(我们在多线程下对数据库的操作无非是并发读、并发写,并由此产生并发读数据库的事务观察并发写数据库的事务应该看到什么,并发写数据库的事务彼此之间应该互相看到什么以及应该呈现给并发读数据库的事务什么?这样三个问题,而这三个问题的不同答案对应了事务不同的隔离级别。)

多个事务读-读是不会有事务隔离性问题的,因此我们主要关注多个事务对于相关联对象的读-写、写-写问题。

写-读问题

脏读

脏读即为一个事务已经完成了部分数据写入,但是尚未提交,此时另一个事务已经可以看到该事务中未提交的数据;

保证不会出现该问题的事务隔离级别-提交读

不可重复读(读倾斜)

在提交读的事务隔离级别下,用户B在commit事务后,用户A即可看到用户B提交的值,而这导致了对于用户A而言的不可重复读。

但是这样不是保证了另一个事务获取了当前数据行的最新值吗?会带来什么问题吗?

这会导致应用层获取到的数据库状态不一致:比如说用户拥有两个账户(存储在同一数据库中),并在使用其中一个账户汇款100元到另一个账号(账户1的余额-100,账户2的余额+100),如果该用户在转帐前查看两个账户的总额,那么可能会看见值变为1100900

因此我们可以为每个事务开启时提供一份当前数据库状态的快照,之后的SELECT查询从快照中读取从而保证不会被其他事务的更改所影响。

保证不会出现该问题的事务隔离级别-可重复读(快照隔离级别)

幻读

幻读是不可重复读的一种特例,不可重复读强调的是在同一个事务中通过相同sql读取到的结果数据行发生了改变或删除,而幻读强调的是在同一个事务中通过相同sql读取到的结果数据行发生了增加,但 其本质亦是因为一个事务中的写入改变了另一个事务的查询结果

注意:快照级别隔离可以避免只读查询时的幻读,对于存在update、delete、insert操作的事务,快照隔离级别并不能保证不出现幻读问题,原因在分析Innodb的解决方案时讲述。

写-写问题

脏写

如果两个事务同时更新相同的对象,会发生什么情况呢?我们不清楚写入的顺序,但是我们知道后写的操作会覆盖比较早的写入。

但是如果先前的写入是尚未提交事务的一部分,并且被后写的事务所覆盖,这种现象我们定义为脏写(注意区分与之后介绍的更新丢失的区别)。

保证不会出现该问题的隔离级别-提交读

更新丢失

脏写是写事务并发场景下的一个特例,强调未提交事务的修改被覆盖。而更新丢失一般是由Read And Modify(读后改)操作导致的,比如有两个事务先获取了值A=42(读锁),然后再分别获取写锁执行加一的操作,那么显然最后两个事务更新的结果都为43,这就造成了更新丢失的问题

JMM中也存在更新丢失的问题,并且volatile无法解决++i这种数据依赖的问题,可以通过加锁,或者通过CAS+循环重试(原子类)的方式保证原子性,数据库中也大同小异,在下面Innodb的解决方案中会提到。

写倾斜

写倾斜不同于脏写和更新丢失,一般是由Check then Act(检查后操作) 操作导致的,比如存在一个应用层条件:医院晚上必须有一个人值班,而当晚有两个员工同时按下了请假键,应用程序首先会查询数据库当前在岗人数是否 >1,因为同时按下,那么数据库的返回都是2,因而接下来便会将在岗人数依次获取写锁后-1,最终导致无一人在岗。

保证不会出现该问题的事务隔离级别-串行化

Innodb的解决方案

Innodb本质上是通过MVCC的不同使用方法与不同粒度的锁的组合来实现不同的隔离级别来保证避免较弱隔离级别下会产生的并发问题。

MVCC(多版本并发控制)

我们可以以写事务的提交为时间点,为写事务所涉及到的行在数据库中保留两个共存的版本-新旧版本,使得在该事务提交前其他事务进行的相关行查询返回旧版,该事务提交后进行的查询返回新版。从而保证了在某个时间点开启的事务可以获取到一致的数据库状态。 (基于MVCC的实现思路)

Innodb为每个事务提供了一个全局递增的唯一标识-trx_id,通过该标识判断多个事务请求数据库时间先后,并额外存储在每个聚簇索引的数据行中:

image.png

Innodb为了保证事务的原子性,提供了undo日志Innodb将每个数据行对应的undo日志通过额外存储在每个数据行中的roll_pointer串联起来形成版本链,从而实现一个数据行在数据库中存储多个版本:

image.png

在事务开始前,数据库中应该存在一个已经确定的数据行版本(即最近提交的一个事务对应的版本),可能还会存在一个或多个未提交的事务版本,Innodb通过ReadView和相应的行可见算法实现了在多个版本并存的情况下,通过trx_id确认当前事务所需要访问的数据行的版本

对于每个事务,数据库都会为其生成一个ReadView对象,对象中存储数据库事务当前状态的快照:max_trx_id:当前数据库活跃事务列表中trx_id的最大值;min_trx_id:当前数据库活跃事务列表中trx_id的最小值;m_ids:当前数据库活跃事务的trx_id列表;creator_trx_id:创建当前ReadView的事务id...

对于读写事务而言,依据其ReadView中的trx_id在与版本链中数据行的trx_id比较时会出现几种情况:

image.png

  1. 当前数据行的trx_id在[min_trx_id, max_trx_id]之间 :(1)当前事务的trx_id=当前数据行的trx_id,说明是当前事务本身的修改,自然对当前事务可见;(2)当前数据行的trx_id不在m_ids列表中,说明该数据行版本对应的事务已经被提交,则该数据行版本对于当前事务可见;(3)在m_ids列表中,说明修改该数据行的事务仍未提交,则对当前事务不可见。

  2. 当前数据行trx_id>max_trx_id,说明该数据行版本是在生成当前ReadView之后建立的事务修改的,则该数据行版本对于当前事务不可见。

  3. 当前数据行trx_id<min_trx_id,说明该数据行版本对应的事务已经被提交,则该数据行版本对于当前事务可见。

上述描述即为Innodb实现快照读(非锁定一致读) 的方式。

MVCC下的幻读问题

但是通过MVCC我们仅可以避免只读查询时的幻读;考虑如下在并发场景下两个事务下的非只读查询(按照时间线先后排序):

开启事务A,并查询blog表,发现此时该表中存在一行数据:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from blog where id < 5; 
+----+-----------------+--------------+
| id | secondary_index | normal_field |
+----+-----------------+--------------+
|  1 | index_text1     | normal_text1 |
+----+-----------------+--------------+
1 row in set (0.00 sec)

另外开启事务B,插入一条id=2的数据并进行提交:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from blog;
+----+-----------------+--------------+
| id | secondary_index | normal_field |
+----+-----------------+--------------+
|  1 | index_text1     | normal_text1 |
+----+-----------------+--------------+
1 row in set (0.00 sec)

mysql> insert into blog (secondary_index, normal_field) values ("index_text2", "normal_text2");
Query OK, 1 row affected (0.28 sec)

mysql> select * from blog;
+----+-----------------+--------------+
| id | secondary_index | normal_field |
+----+-----------------+--------------+
|  1 | index_text1     | normal_text1 |
|  2 | index_text2     | normal_text2 |
+----+-----------------+--------------+
2 rows in set (0.00 sec)

mysql> commit;
Query OK, 0 rows affected (0.06 sec)

此时在事务A中修改所有id<5secondary_index的值变为mvcc,然后查询blog表中的数据发现多了id=2的行数据-(产生幻影读问题):

mysql> update blog set secondary_index = "mvcc" where id < 5;
Query OK, 2 rows affected (5.66 sec)
Rows matched: 2  Changed: 2  Warnings: 0

mysql> select * from blog;
+----+-----------------+--------------+
| id | secondary_index | normal_field |
+----+-----------------+--------------+
|  1 | mvcc            | normal_text1 |
|  2 | mvcc            | normal_text2 |
+----+-----------------+--------------+
2 rows in set (0.00 sec)

mysql> commit;
Query OK, 0 rows affected (0.06 sec)

因为在一个事务运行过程中,只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,并且重新生成一个ReadView(因为之前的ReadView中是没有新分配的trx_id的)。然后我们根据MVCC ReadView判断数据行对当前事务是否可见的方式进行判断,因为事务A运行update语句是在事务B提交之后,因此事务A被分配到的trx_id是大于事务B的,因此在重新生成的ReadView中,事务B的改动对于事务A是可见的。

所以MVCC只能解决部分幻读问题,而根源解决幻读问题还是通过锁机制-谓词锁与Next-Key Lock,其中Next-Key Lock是对谓词锁的优化。

谓词锁、Gap锁、Next-key Lock(索引区间锁)

如果我们将上述事务A中的SELECT语句改为SELECT * FROM blog wher id < 5 FOR UPDATE,事务A会为id ∈(-∞, 5]这个范围加上排他锁,使得事务B不再可以在(-∞, 5)之间进行数据行的插入,自然也不会导致事务A中的幻影读问题。

Gap锁,即为锁住sql查询结果的范围:(-∞,5),而Next-Key Lock则锁住了(-∞, 5],即Gap+Record(行)锁,是对于Gap锁的优化;Innodb对于聚簇索引进行范围查询即通过加Next-Key Lock锁解决该问题;

如果我们对聚簇索引进行等值查询,**因为聚簇索引具有主键唯一性,所以Nexk-key Lock可以退化为Record Lock

上述即为对于Innodb聚簇索引的加锁方式;

需要注意的是:如果我们对于二级(辅助)索引进行等值锁定查询时并不是进行Record Lock,因为二级索引值不具有唯一性,所以也需要进行范围锁定,如 SELECT * FROM blog where secondary_index = "index_text1" FOR UPDATEInnodb会在(-∞,index_text1], (index_text1, index_text2)之间加上一个Next-Key LockGap Lock,防止在该范围内重复插入一条 secondary_index = "index_text1的二级索引记录导致幻读。

二级索引加完对应的锁后,需要回表对二级索引中主键值对应的聚簇索引行进行加锁,用于保证索引之间数据的一致性。

因此Innodb通过MVCC + Next-Key Lock可重复读级别上解决了幻读的问题。

当然在该级别下我们仍然无法解决写倾斜的问题-Check Then Act,要解决这个问题,

上述问题产生的本质是另一个事务改变了当前事务的check条件的状态,而另一个事务对此没有再次检查。

因此我们可以通过MVCC与不同粒度的锁实现可串行化隔离级别

  1. 通过显式加锁(select xxx for update)保证同时只有一个线程可以查询check条件,从而保证只有前一个事务释放锁后另一个事务才可以查询check条件来保证当前获取的是条件的最新值(2PL,两阶段加锁);
  2. 在快照隔离的基础上通过乐观并发+冲突检测实现,对于每个事务存在两个检查的时间点:执行事务前、完成事务前;执行事务前需要检查是否存在影响当前check条件的未提交改动; 完成事务前也需要检查是否有影响当前Check条件的未提交事务已经提交(trx_id在该事务执行前的确定事务版本后)。