探索MySQL事务隔离性

由八股文开篇

事务特性 ACID

  • 原子性(Atomicity):要么都做,要么都不做
  • 一致性(Consistency):执行前后的数据都是合法的,得满足业务规则
    • 比如A给B打钱,转账前后A和B账户总和不变,且各自账户余额>=0,而在事务执行的过程中则不一定满足一致性
  • 隔离性(Isolation):事务之间的执行互不影响
  • 持久性(Durability):事务结果一旦提交,就得保存起来,不管之后db是否崩溃

再探隔离性

理想情况下,希望事务的执行是独立互不影响的,也就是希望在当前整个事务的执行过程中涉及到的数据只会被当前事务所影响。最直观的情况下,就是各个事务挨个执行,线性调度。

事务的隔离性就是说不论事务之间执行的并发调度顺序,最后的结果得在串行化执行的结果集里。概括为一句话就是事务的隔离性即并发调度的正确性。

串行化调度

由一个例子来引入串行化调度:有两个事务T1与T2,初值A=10,B=20

  • T1:读B;A=B+1;
  • T2:读A;B=A+1;
  1. 先执行T1,后执行T2,结果为A=21,B=22。
  2. 先执行T2,后执行T1,结果为A=12,B=11。

以上两种结果均是正确。除此以外的结果,都是错误的调度结果。

串行/线性(serial)

  各个事务挨个执行,没有了并发,其调度正确性是显然的

可串行化(serializable)

  跟字面意思一样,如果事务的调度执行的结果完全等价于某个线性调度,这种调度就被认为是可串行化的,于是这就允许了更多并发调度的组合情况

  跟上面的例子一样,可串行化的调度结果都是正确的,那么其余的不可串行化的调度就都是错误的调度结果

冲突可串行化(conflict serializable)

  因为可串行化调度(serializable)难以验证其正确性,所以引入限制条件:如果事务的调度通过调换不同事务间的非冲突行为(非“读写”和“写写”冲突),可以变成线性调度(serial),这种调度就是冲突可串行化的。

冲突可串行化的判定

  如果调度序列可以通过变换不同事务之间的非冲突行为,变成线性调度,则这个调度就是冲突可串行化的。

例如:Sc=r1(A)w1(A)r2(A)w2(A)r1(B)w1(B)r2(B)w2(B)(rw代表读写,下标代表不同事务)
=> r1(A)w1(A)r2(A)r1(B)w1(B)w2(A)r2(B)w2(B)
=> r1(A)w1(A)r1(B)w1(B)r2(A)w2(A)r2(B)w2(B)
=> r1(A)w1(A)r1(B)w1(B)r2(A)w2(A)r2(B)w2(B) 线性调度

另外冲突可串行化调度还可以用“优先图”来快速判定,由于不是本文重点,就不详细展开。

  从上图也可以看出,冲突可串行化一定是可串行化的,但是可串行化的一些调度序列,通过上述的调度变换未必能变成线性调度,但其结果也是正确的。

MySQL如何做到可串行化

2PL 二段锁

  • 锁膨胀:这个阶段可以上S锁或者X锁,或者把S锁升级为X锁,如果上锁失败则阻塞等待
  • 锁释放:这个阶段只能释放锁

  相比于一段锁,二段锁存在死锁问题,但是一段锁的方法是事先对涉及到的数据加锁,全部运行完之后再解锁,实际情况是我们往往不知道事务在开始阶段会涉及哪些数据。
在没有死锁的前提下,可以证明2PL是冲突可串行化的。因此隔离级别中的可串行化也常指冲突可串行化。

向性能妥协

  为了达到串行化的效果,事务往往需要对操作的数据读写进行锁定等操作,比如可串行化的读写加锁就会伴随大量的阻塞等待,而越是严格的加锁操作,读写的效率就越低,相反所实现的隔离性效果就越好。为了提供很好的并发性能,于是就有了不同的隔离级别。

  这其中低于可串行化的隔离级别,就表明事务之间会互相影响不再独立,从而导致调度结果可能出错,下文将会讲述事务隔离性中存在的问题,以及不同隔离级别下确保这些问题不会发生,这都是向性能的妥协。


事务隔离级别

隔离性中的经典问题

读取问题

  • 脏读(Dirty Read):可以读到其他事务没有commit的脏数据
  • 不可重复读(NonRepeatable/Fuzzy Read):读到的数据会受到其他事务update、delete的操作的影响
  • 幻读(Phantom Read):读到其他事务insert的数据

写入问题

丢失更新(Lost Update):一个事务的更新操作被另一个事务对该数据的更新所影响,导致这个事务的更新没有生效。常见为以下的两类情况:

回滚丢失
T1T2
begin
read(A)=10
begin
read(A)=10
write(A=30)
write(A=20)
commit(A=20)
rollback(A=10)
写入丢失

这类问题在比如转账场景下就非常重要,比如用户本金10块,支付10块,同时又被汇入10块,按照下面的调度就会导致用户损失10块

T1T2
begin
read(A)=10
begin
read(A)=10
write(A=A-10)
write(A=A+10)
commit(A=20)
commit(A=0)

这里列举了这两种丢失更新类型的情况,具体分析和解决会在下文继续讨论。

隔离级别

按照以上这三种读取现象的容忍程度不同,定义出了4个不同的隔离级别

  • 未提交读:..
  • 已提交读:只会读到其他事务的已经提交数据
  • 可重复读:一个事务中,反复读取某行数据都保持前后一致,不会被其他事务修改
  • 可串行化:可转化为串行事务的执行
隔离级别脏读不可重复读幻读
未提交读
已提交读x
可重复读xx
串行化xxx

可以看到针对不同的隔离性读取问题,定义了不同的隔离级别。针对写入的更新丢失问题,会在下文隔离级别原理部分探讨。

隔离级别的实现

可串行化

基于悲观锁的思想,读加共享锁,写加排它锁。
在这个模式下select也是会自动加读锁的,不用显式加for update或者lock in share mode。

当前读 vs 快照读

  比如在RR级别中,基于MVCC机制读到的并不是及时的数据,而是某个历史数据,这在实时性要求比较高的场景下会存在严重的问题,这种读取称为快照读,而读取当前数据的方式成为当前读。

  • 快照读:即直接读取 select * from table ..
  • 当前读:需要加读锁
    • 共享锁:select * from table .. lock in share mode
    • 排它锁:select * from table .. for update

事务的隔离级别实际上都是定义了当前读的级别,MySQL为了减少锁处理(包括等待其它锁)的时间,提升并发能力,引入了快照读的概念,使得select不用加锁。

MVCC 在InnoDB的实现

  MVCC 多版本并发控制:通过数据数据快照的方式来代替读锁,生成当前事务的一致性视图,各个数据库的实现方式都不同,InnoDB以undo log数据版本链和read view判断数据可见性的方式来实现。

读提交 RC 和可重复读 RR

RC和RR都是基于MVCC机制来实现的,不同点在于RC每次select都会生成一个一致性视图,而RR只会在第一次select的时候生成。这两种当前读的方式不同也就决定了“不可重复读”问题的是否解决。

传说在RR级别解决了幻读问题
针对InnoDB在RR级别下是否解决幻读问题,网上各路众说纷纭。分歧在于对于SQL92规范已经十分古老,是针对隔离性级别的三大读取问题所提出的隔离级别,这些定义没有准确表达出隔离级别以及对应的标准实现,详细可以参考下面的论文

ANSI SQL-92 [MS, ANSI] defines Isolation Levels in terms of phenomena: Dirty Reads, Non-Repeatable Reads, and Phantoms. This paper shows that these phenomena and the ANSI SQL definitions fail to characterize several popular isolation levels, including the standard locking implementations of the levels.
https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-95-51.pdf

  • Part1: 幻读问题的讨论
    先抛出结论:在RR级别下没有严格解决幻读问题,对于Read-Only的事务来说,RR级别是解决了幻读问题,而事务中存在其他更新操作反而会使读取的结果降级到RC。
    对于只读的事务而言,基于MVCC一致性视图,不难理解幻读问题的解决。
    对于存在又读又写的事务而言,比如在RR级别下,可以尝试一下:事务A开启了ReadView视图后,事务B插入了一条数据,然后事务A可以更新到这条数据,并且此后都可以读到。这不仅存在幻读问题,还存在不可重复读问题!

  • Part2: 另外还隐藏的幻写问题
    因为写入操作伴随着锁,修改的是已提交的数据,因此写操作可以感知到其他事务insert的幻行。在RR级别下,对于读操作来说,如果每次在select的情况下都显式加锁,那么它的效果和 serializable 隔离级别一样,写入的时候会通过Next-Key锁防止其他事务在where操作的数据范围以及附近进行写操作。

再看隔离性问题

幻读/幻写问题(Phantom Problem)

  通过上文的讨论,为了避免幻象问题,在RR级别下,每次的写操作或者在前后select的时候都显式加锁的方式是Next-Key锁;而在RC级别下,加锁的方式仅为Record-Lock。

  关于Next-Key锁是Gap锁和行锁的结合,Gap锁用来防止其他事务的新增,行锁则用来防止其他事务的修改,这两种锁共同作用下解决了幻象问题。

  这里引用美团博客《Innodb中的事务隔离级别和锁的关系》的例子:

事务A事务B事务C
beginbeginbegin
select id,class_name,teacher_id from class_teacher;

– row1 –
class_name: 初三一班, teacher_id: 5

– row2 –
class_name: 初三二班, teacher_id: 30
update class_teacher set class_name=’初一一班’ where teacher_id=20;
insert into class_teacher values (null,’初三五班’,10);

waiting …..
insert into class_teacher values (null,’初三五班’,40);
commit
事务A commit之后,这条语句才插入成功
commit
commit

  假设teacher_id是二级索引,Innodb将teacher_id这段数据分成几个个区间: (-inf, 5] (5,30] (30, +inf),对于事务A的update teacher_id=20来说,InnoDB会在(5,30]加上Gap锁,因此会影响事务B的写入操作,而不影响事务C的写入。

  如果使用的是没有索引的字段,比如teacher_name,则会给全表加上Gap锁,因为没有索引就没有排序,也就没有区间。

丢失更新(Lost Update)

  丢失更新的现象是一个事务的更新操作,被另一个事务所覆盖,分为上文提及的两类问题:回滚丢失和写入丢失。

  目前来说,由于2PL+写锁机制的存在,在当前任何隔离级别下,都不会产生数据库层面的丢失更新问题(产生冲突的操作会陷入阻塞,直到锁被事务释放),而丢失更新往往发生在应用层面上。

  拿常见的购物为例,应用程序一般会先检查商品库存是否足够用户本次的购买,然后再进行update操作,一旦并发量上来很容易由于丢失更新存在超卖情况。在默认RR级别的举例如下:

T1T2
begin;
select num from inventory where sku_id = 1001;

– row –
num: 1
begin

用户欲购买1件1001商品,application check pass
select num from inventory where sku_id = 1001
–row–
num: 1
update inventory set num_A=num_A-1 where sku_id = 1001;
commit;

–row–
num:0

用户欲购买1件1001品,application check pass
update inventory set num_A=num_A-1 where sku_id = 1001;
commit;

–row–
num:-1

  因此对于这种先读后写的情况,需要事务在select的时候就加上一把x锁,使用select … for update,来防止其他事务基于select到的数据中间结果作为业务判断依据。

小结

  文章由隔离级别中“可串行化”的概念引入,讨论了何谓可串行化,并由此因为性能的需要引入了一系列的隔离级别,本质上是事务一致性和实现性能之间的权衡。
  第二部分从耳熟能详的八股文隔离性问题出发,描述了隔离级别的锁和非锁(MVCC)的读写情况,重点讨论了部分的隔离性问题。

-------------本文结束-------------
0%