本文讨论大部分基于下表进行:
开始讨论之前,先让我们了解一些基本概念。
一、并发事务带来的问题
在典型的应用程序中,经常会产生多个事务并发运行。虽然并发事务能够极大的提升系统的可用性,但是也带来了如下问题:
脏读(Dirty Read)
事务T1修改了一行数据Row,事务T2在T1未提交前读到了该行数据(Row)并使用该数据进行业务操作。
丢失修改(Lost To Modify)
事务T1读取了一行数据Row,事务T2在随后也访问了该行数据,随后事务T1对Row进行了更新,事务T2也随后对Row进行更新,事务T2对Row的更新导致了事务T1对Row的更新丢失。
不可重复读(Non-repeatable Read)
事务T1读取了一行数据Row,事务T2在T1读取Row后对Row数据进行了更新,T1再次读取行Row,发现与第一次读取数据不一致,即在同一事务内两次执行同一查询得到的结果不一致。
幻读(Phantom Read)
事务T1读取了一个结果集ResultSet1,事务T2在T1读取ResultSet1后新增了一行数据Row,刚好这个事务T2新插入的这条数据满足T1的抓取规则,导致事务T1再次读取数据时,得到了比ResultSet1更多的结果集ResultSet2。
关于不可重复读和幻读的区别
从上述定义看不可重复读和幻读,都是在一个事务中多次读取得到了不同的数据结果。不可重复读的重点在于修改,而幻读的重点在于新增或删除。
二、SQL隔离级别
ANSI SQL STANDARD定义了4类隔离级别(READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE),不同版本的数据库分别实现了上述隔离级别的一个或者多个,这些不同的事务隔离级别用来限定事务内外的哪些改变是可见的(指对其他事务)。一般说来,低级别的隔离级别一般支持更高的并发处理,并拥有更低的系统开销。ANSI SQL STANDARD定义的4类事务隔离级别如下:
Read Uncommitted(读未提交)
允许读取其他事务未提交的数据,在该隔离级别下,所有事务都可以看到其他未提交事务的执行结果。由于本隔离级别很容易读取到其他事务未提交的脏数据,所以一般很少用于实际应用。
Read Committed(读已提交)
只能读取其他事务已提交的更改。Oracle、SqlServer等大部分数据库的默认隔离级别。
Repeatable Read(可重读)
同一事务多次读取数据时,会看到同样的数据行。这是Mysql默认的隔离级别。ANSI SQL STANDARD对于可重读隔离级别,是允许出现幻读的,而InnoDB存储引擎通过多版本并发控制(MVCC,Multiversion Concurrency Control)机制解决了该问题。但是对于InnoDB在RR隔离级别下是否完全解决幻读,尚有争议,我们将在下文进行讨论。
Serializable(可串行化)
强制事务排序,事务串行化执行。读数据会加上共享锁,读写会相互阻塞,在这个级别下,可能会产生大量的超时现象和锁竞争。
下图是ANSI SQL STANDARD对于各种隔离级别下允许出现的并发事务问题的规定:
三、InnoDB MVCC理解
ANSI SQL STANDARD定义了4类隔离级别,随着隔离级别的提升,并发事务产生数据不一致性的问题就会大大越低,但是并发处理能力也会大大降低,而不同的隔离级别,往往都是通过锁机制来解决并发事务产生的各种问题。数据的锁定分为两种方法,一种叫悲观锁,另外一种叫做乐观锁。绝大部分商业数据库(MySQL、Oracle等)为了性能考虑,都是使用了以乐观锁为理论基础的MVCC(Multi-Version Concurrency Control 多版本并发控制)来解决并发事务带来的数据访问问题。
悲观锁
数据对外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,在整个的数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。在悲观锁的情况下,为了保证事务的隔离性,就需要一致性锁定读。读取数据时给加锁,其它事务无法修改这些数据。修改删除数据时也要加锁,其它事务无法读取这些数据。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销(特别是长事务)。
乐观锁
大多是基于数据版本( Version )记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。MVCC的实现没有固定的规范,每个数据库都会有不同的实现方式。
MVCC在MySQL的InnoDB实现
在InnoDB中,会在每行数据后添加两个额外的隐藏的值来实现MVCC,这两个值一个记录这行数据何时被创建,另外一个记录这行数据何时过期(或者被删除)。 在实际操作中,存储的并不是时间,而是事务的版本号,每开启一个新事务,事务的版本号就会递增。 在可重读Repeatable Reads事务隔离级别下:
SELECT时,读取创建版本号<=当前事务版本号,删除版本号为空或>当前事务版本号。
INSERT时,保存当前事务版本号为行的创建版本号
DELETE时,保存当前事务版本号为行的删除版本号
UPDATE时,插入一条新纪录,保存当前事务版本号为行创建版本号,同时保存当前事务版本号到原来删除的行
通过MVCC,虽然每行记录都需要额外的存储空间,更多的行检查工作以及一些额外的维护工作,但可以减少锁的使用,大多数读操作都不用加锁,读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行,也只锁住必要行。
四、InnoDB RR防止幻读初探
Repeatable Read是nnoDB的默认隔离级别,意味着在RR隔离级别下,一个事务在多个实例并发读取数据时,会看到同样的数据行。关于RC(读已提交)和RR(可重读)的区别,我们用如下两图进行一个说明:
可以看到,在RC隔离级别下,事务A前后两次执行同一查询语句得到了不同的结果,这就很可能带来一些问题。下面我们看看在RR隔离级别下的查询结果,如下图所示:
从上图可以看到,在RR隔离级别下,事务A前后两次执行同一查询语句得到的结果相同,参阅并发事务带来的问题,我们发现,InnoDB在RR隔离级别下,能够解决并发事务带来的脏读、不可重复读、幻读,因此很多人说InnoDB在RR级别下能够防止幻读(后文将展示出现幻读的情况)。我们从MVCC的角度,再来看看上述查询:
五、InnoDB RR无法防止幻读初探
从上一小节中看到,InnoDB在RR隔离级别下,确实未读取到其他并发事务的数据写入和更新,我们可能会认为InnoDB的RR确实防止住了幻读,但经过测试,发现了如下问题:
可以看到,session1在执行一次update操作后,读取到了session2中insert的数据,发生了幻读,因此有人说InnoDB在RR隔离级别下无法防止幻读。但是从ANSI SQL STANDARD定义的4类隔离级别可以看到,在RR隔离级别下,是允许出现幻读的,所以InnoDB在RR隔离级别下出现幻读并不属于BUG。
六、InnoDB在RR隔离级别下的当前读、快照读、行锁、Next-Key锁
从上文内容可以看到,InnoDB在RR隔离级别时,在某些情况下能够防止幻读,但是在某些情况下会出现幻读,为什么会出现这种情况?先让我们了解如下几个概念:
快照读和当前读
事务的隔离级别其实都是对于读数据的定义,但是MySQL中的读,和事务隔离级别中的读,是不一样的。在RR级别中,通过MVCC机制,虽然让数据变得可重复读,但我们读到的数据可能是历史数据,是不及时的数据,不是数据库当前的数据!这在一些对于数据的时效特别敏感的业务中,就很可能出问题。对于这种读取历史数据的方式,我们叫它快照读 (snapshot read),而读取数据库当前版本数据的方式,叫当前读 (current read)。很显然,在MVCC中:
快照读:就是select
select * from table ….;
当前读:特殊的读操作,插入/更新/删除操作,属于当前读,处理的都是当前的数据,需要加锁。
select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update ;
delete;
事务的隔离级别实际上都是定义了当前读的级别,MySQL为了减少锁处理(包括等待其它锁)的时间,提升并发能力,引入了快照读的概念,使得select不用加锁。而update、insert这些“当前读”,就需要另外的模块来解决了。
行锁
MySQL中锁的种类很多,有常见的表锁和行锁,也有新加入的Metadata Lock等等。表锁是对一整张表加锁,虽然可分为读锁和写锁,但毕竟是锁住整张表,会导致并发能力下降,一般是做ddl处理时使用。行锁则是锁住数据行,这种加锁方法比较复杂,但是由于只锁住有限的数据,对于其它数据不加限制,所以并发能力强,MySQL一般都是用行锁来处理并发事务。
在RR级别下,快照读取都是不加锁的(在Serializable隔离级别下,快照读也会加锁,此时不再区分快照读和当前读,所有读操作均为当前读),但是当前读是需要加锁的。通过如下实例,我们来看看行锁(RR隔离级别):
事务A给teacher_id为1的数据行加锁,如果一直不释放,那么事务B将会一直等待(拿不到行锁),直到超时。
此时需要注意一点,teacher_id是有索引的,如果我们将teacher_id更换为无索引的class_name,那么MySQL将会给整张表的数据行都加上行锁。这听起来有点不可思议,但是在SQL运行过程中,如果一个条件无法通过索引快速过滤,存储引擎层面就会将所有记录加锁后返回,再由MySQL Server层进行过滤。
但在实际使用过程当中,MySQL做了一些改进,在MySQL Server过滤条件,发现不满足后,会调用unlock_row方法,把不满足条件的记录释放锁 (违背了二段锁协议的约束)。这样做,保证了最后只会持有满足条件记录上的锁,但是每条记录的加锁操作还是不能省略的。可见即使是MySQL,为了效率也是会违反规范的。所以对一个数据量很大的表做批量修改的时候,如果无法使用相应的索引,MySQL Server过滤数据的的时候特别慢,就会出现虽然没有修改某些行的数据,但是它们还是被锁住了的现象。
Next-Key 锁
事务的隔离级别中虽然只定义了读数据的要求,实际上这也可以说是写数据的要求,为了解决当前读中的幻读问题,MySQL事务使用了Next-Key锁。Next-Key锁是行锁和GAP(间隙锁)的合并。行锁可以防止不同事务版本的数据修改提交时造成数据冲突的情况。但如何避免别的事务插入数据就成了问题。为了理解GAP(间隙锁),我们看下RR和RC级别的对比:
RC级别:
RR级别:
通过RC和RR的对比我们发现,RC隔离级别下,发生了当前读的幻读,事务A在update之后,将事务B insert的数据一并查询了出来,并且是还未被事务A的update语句修改的数据(RC隔离级别下,当前读仍然会有可能发生幻读现象)。
而RR隔离级别下,事务A在update之后,事务B无法insert,事务A在update前后读的数据保持一致,避免了幻读。而使事务B无法insert的这个锁,就是GAP(间隙锁)。在class_teacher这张表中,teacher_id是个索引,那么它就会维护一套B+树的数据关系,为了简化,我们用链表结构来表达(实际上是个树形结构,但原理相同)
如图所示,InnoDB使用的是聚集索引,teacher_id身为二级索引,就要维护一个索引字段和主键id的树状结构(这里用链表形式表现),并保持顺序排列。InnoDB将这段数据分成几个个区间
(negative infinity, 5],
(5,30],
(30,positive infinity);
update class_teacher set class_name=‘初三四班’ where teacher_id=30;不仅用行锁,锁住了相应的数据行;同时也在两边的区间,(5,30]和(30,positive infinity),都加入了GAP(间隙锁)。这样事务B就无法在这个两个区间insert进新数据。受限于这种实现方式,InnoDB很多时候会锁住不需要锁的区间。如下所示:
teacher_id=20是在(5,30]区间,即使没有修改任何数据,InnoDB也会在这个区间加GAP(间隙锁),而其它区间不会影响,事务C正常插入。
如果使用的是没有索引的字段,比如update class_teacher set teacher_id=7 where class_name=‘初三八班(即使没有匹配到任何数据)’,那么会给全表加入GAP(间隙锁)。同时,它不能像上文中行锁一样经过MySQL Server过滤自动解除不满足条件的锁,因为没有索引,则这些字段也就没有排序,也就没有区间。除非该事务提交,否则其它事务无法插入任何数据。
行锁防止别的事务修改或删除,GAP锁防止别的事务新增,行锁和GAP锁结合形成的的Next-Key锁共同解决了RR级别在写数据(当前读)时的幻读问题。
GAP(间隙锁)RR隔离级别的当前读加锁分析
WHERE字段无索引
无索引的条件字段的当前读不仅会把每条记录都加上行(X)锁,还会加上GAP锁。再次强调,当前读或者插入/更新/删除操作需要加上索引。
WHERE字段存在普通索引
普通索引的条件字段的当前读会把符合检索条件的每条记录加上行(X)锁,还会在索引两边的区间加上对应的GAP锁。
WHERE字段存在唯一索引
唯一索引的条件字段的当前读只会为符合检索条件的那条记录加上行(X)锁,不会加上GAP锁。
七、InnoDB在RR级别下能否防止幻读
经过上面的描述,我们可以得出如下结论:
在RR隔离级别下,只进行快照读(不进行当前读),是能够防止幻读的。但是可能会出现P4(Lost To Modify 丢失修改)现象,而针对MVCC实现定义的Snapshot Isolation,其实是不允许P4现象的,出现更新冲突,遵循FIRST-COMMITER-WIN的原则,其它事务需要回滚。
在RR隔离级别下,只进行当前读(SELECT使用排它锁(不使用共享锁)),也能够防止幻读(Next-Key Lock解决幻读问题),并且不会出现P4(Lost To Modify 丢失修改)现象。
在RR隔离级别下,如果先快照读、后进行当前读(指的是后续进行UPDATE、DELETE,先快照读和后面使用锁定读取(如FOR UPDATE)两次结果不一样,不在讨论范围中,个人认为此种情况不能用来证明InnoDB不能防止幻读),是无法防止幻读。MySQL官方对此种情况的答复是:如果一个事务确实更新或者删除了其他事务提交的行,那么这些更改对当前事务是可见的(If a transaction does update or delete rows committed by a different transaction, those changes do become visible to the current transaction)。我们从官方的答复中得知,如果先进行快照读,然后在快照读中执行更新或者删除,而其他事务在快照读之后(本事务执行更新前)对当前事务要更新的行也进行了更新,那么其他事务的对满足条件的行的更新对当前事务是可见的。
ANSI SQL STANDARD对于可重读隔离级别,是允许出现幻读的,但是MySQL可以通过Next-Key Lock来解决幻读问题,当然,如果只进行纯快照读也能避免幻读。