数据库事务:隔离级别实现——MVCC与锁

这是数据库事务分享的第二篇,上一篇讲解了数据库事务并发会产生的问题,这篇会详细讲数据库如何避免这些问题,也就是如何实现隔离,主要是讲两种主流技术方案——MVCC与锁,理解了MVCC与锁,就可以举一反三地看各种数据库并发控制方案,并理解每种实现能解决的问题以及需要开发者自己注意的并发问题,以更好支撑业务开发。

先回顾一下上一篇讨论过的,如果没有隔离或者隔离级别不足,会带来的问题:

1、脏读(Dirty Read):事务T1修改完数据之后写回磁盘,但是还没有提交,事务T2这时读取了T1修改的数据,事务T1最后由于某种原因回滚了,此时事务T2就读取到了脏数据。

2、不可重复读(Unrepeatable Read):事务T1按某一条件读取数据之后,事务T2执行了更新或删除操作,导致事务T1按照相同条件读取到的数据不一致,不可重复读主要有三种情况:

  • (1)事务T1按照某一条件读取数据之后,T2进行更新操作,当事务T1再使用相同条件查询时发现结果的值与之前不一样;
  • (2)事务T1按照某一条件读取数据之后,T2进行删除操作,删除了部分记录,当事务T1使用相同条件查询的时候,发现某些记录神秘的”消失“了;
  • (3)事务T1按照某一条件读取数据之后,T2进行插入操作,增加了一些记录,当事务T1使用相同条件查询的时候,发现神秘的多了一些记录;

其中后两种不可重复读又被称为幻读

3、丢失更新(Lost Updates):两个事务T1和T2同时修改一个数据,T2提交的结果把T1修改的结果覆盖了,这就使得事务T1的修改丢失了。

可见,所有问题本质上都是由写造成的,根源都是数据的改变。 读是不改变数据的,因此无论多少读并发,都不会出现冲突,如果所有的事务都只由读组成,那么无论如何调度它们,它们都是可串行化的,因为它们的执行结果,都与某个串行执行的结果相同,但是写会造成数据的改变,稍有不慎,这个并发调度的结果就会与串行调度的结果不符合。

为了解决数据库并发事务带来的问题,就提出了数据库隔离级别的概念。

1、读未提交(Read Uncommited):这个隔离级别中允许一个事务读取另一个事务未提交的数据。这个级别下会导致脏读、不可重复读和丢失更新。

2、读已提交(Read Commited):这个隔离级别允许读取一个事务已经提交的数据,可以解决脏读,但是不可重复读和幻读仍然会发生。

3、可重复读(Repeatable Read):这个是InnoDB数据库引擎的默认隔离级别。它可以保证同一事务内多次查询的结果是一致的,可以防止脏读和不可重复读,但是幻读仍有可能发生(MySQL在该级别下已经解决了幻读问题)。

4、串行化(Serializable):最高的隔离级别,完全服从事务的ACID特性,可以确保防止脏读、不可重复读、幻读、丢失跟新等并发问题。他完全通过读写都加锁,锁定事务相关数据库表来实现,效率不高。

一、基于锁实现可串行化

(本节以MySQL InnoDB为基本模型)

1. 读锁与写锁

实现可串行化的基石是控制冲突,强行保证冲突操作的串行化,那么应该遵循以下原则:

  • 读-写应该排队
  • 写-写应该排队

读的时候不能写,写的时候不能读也不能写,但是读的时候可以读,因为读不冲突,于是数据库需要两种锁:

  • 排它锁(exclusive lock) 又称X锁,这是最好理解的锁,在一般的并发编程中,我们为资源加上的一般都是排它锁,要获取锁,必须是资源处于未被加锁状态,如果有人已经为资源加锁,则需要等待锁释放才能获取锁,这种锁能够保证并发时也能够串行处理某个资源,实现排队的目的。

  • 共享锁(share lock)又称S锁,这是比排它锁更加宽松的锁,当一个资源没有被加锁或者当前加锁为共享锁时,可以为它加上共享锁,也就是一个资源可以同时被加无限个共享锁。此时由于资源已经被加锁,虽然可以继续加共享锁,但是不能加排它锁,需要等待资源的锁被完全释放才能获取排它锁。共享锁的目的是为了提高非冲突操作的并发数,同时能够保证冲突操作的排队执行。

兼容性

这两种锁和读、写是什么关系呢? 读写都会加锁,但是读-读可以并发,写则需要与任何操作排队,所以:

  • 获取记录的共享锁(S锁),则仅允许事务读取它,简单来说共享锁只是读锁,记录被加读锁后,其他记录也可以往上加读锁,也就是大家都可以读。
  • 获取记录的排它锁(X锁),则允许这个事务更新它,排它锁让事务既可以读也可以写,是读写通用的锁,记录被加排他锁后,其他事务不论是想加排它锁还是共享锁,都需要排队等待目前的排它锁释放才能加锁。由于强行排队的特性导致效率比较低,读-读不冲突所以大多数读取都不会加排它锁,不过在MySQL中可以使用SELECT FOR UPDATE语句指定为记录加上排它锁。

通过读写操作加锁,实现了读写、写写的排队,但是靠简单加锁保证的排队,但排队粒度太小,仅仅是操作与操作之间的排队,不足以解决上面图中的不可串行化问题,因为如果事务1读A后马上释放读锁,则事务2可以马上获取到A的写锁,改变A的值,还是会出现上面的不可串行化问题,因此事务需要保证更大粒度的排队——如果一个记录被某个事务读取或者写入,则直到这个事务提交,才能被别的事务修改严格两阶段加锁(Strict Two-Phase Locking) 由此诞生。

2. 严格两阶段加锁(Strict Two-Phase Locking)

首先提一句什么是两阶段加锁协议(2PL),它规定事务的加锁与解锁分为2个独立阶段,加锁阶段只能加锁不能解锁,一旦开始解锁,则进入解锁阶段,不能再加锁严格两阶段加锁(S2PL)在2PL的基础上规定事务的解锁阶段只能是执行commit或者rollback后,因此S2PL保证了一个事务曾经读取或写入的记录,在此事务commit或rollback前都不会被释放锁,因此不能被其他记录加锁,不会造成记录的改变,由此实现了可串行化。

3. 多粒度加锁与意向锁(Intention Lock)

InnoDB中不止支持行级锁,还支持表级锁,为了兼容多粒度的锁,设计了一种特殊的锁——意向锁(Intention Lock),它本身不具备锁的功能,只承担“指示”功能。 如果要加表级锁,则必须保证行级锁已完全释放,整张表都没有任何锁时,才能为表加上表锁。那么问题来了,怎么判断是否整张表的每一条记录都已经释放锁? 如果通过遍历每条记录的加锁状态,未免效率太低,因此需要意向锁,它只是一个指示牌,告诉数据库,在此粒度之下有没有被加锁,被加了什么锁。就像停车场会在门口立一个牌子指示“车位已满”还是“内有空余”,不需要开车进去一个个车位检查,提高了效率。 InnoDB如果要对一条记录进行加锁,它需要先向表加上意向锁,然后才能对记录加普通锁,获取意向锁失败,则不能继续向下获取锁。

意向锁兼容性矩阵

意向锁之间是完全兼容的,很好理解,因为意向锁只代表事务想向下获取锁,具体是哪条记录不确定,因此意向锁是完全兼容的,即使表上已经被其他事务加了某种意向锁,事务还是能够成功为表加意向锁。

一般我们不会在事务中加表锁,表锁效率太低,我们加的一般是行级锁,行级锁是加在某条特定的记录上,我们称之为记录锁。 这一节的内容主要是对多粒度加锁有个概念,现实中很少用表锁。 上面说的共享锁、排它锁是按照锁兼容性定义,表锁、记录锁(Record Lock)则是按加锁范围定义,根据加锁范围不同,还有其他N种锁,下面会提到一些。

4. MySQL是如何加行锁的?

首先要清楚一点,对于MySQL只有InnoDB支持行锁,并且即使是使用对InnoDB存储引擎,它也只会针对索引去添加行锁。

在RR隔离级别下的加锁机制

记录锁(Record Lock)

记录锁是最简单的一种行锁类型,记录锁封锁的是记录,例如:

SELECT * FROM `test` WHERE `id`=1 FOR UPDATE;

它会在 id=1 的记录上加上记录锁,以阻止其他事务插入,更新,删除 id=1 这一行

间隙锁(Gap Lock)

考虑一个例子: 事务1执行“SELECT name FROM students WHERE age = 18”返回结果为“张三”,而事务2马上插入一行记录“INSERT INTO students VALUES(“李四”,18)”并提交,事务1再次执行相同的SELECT语句,发现结果变为了“张三”+“李四”,这就是一种幻读现象,同一个事务进行的两次相同条件的读取,却读取到了之前没有读到的记录。 有了记录锁虽然可以实现对已存在记录进行并发控制,也就是对于更新、删除操作,再也不会有并发问题,但是无法对插入做并发控制,因为插入操作是对不存在的记录,而还不存在的记录,我们无法为其加记录锁,因此可能会产生幻读现象。

为了解决这个问题,出现了间隙锁间隙锁也是加在某一条记录上,可是它并不锁住记录本身,它只锁住这条记录与它的上一条记录之间的间隙,防止插入。 如下图所示,如果一张表有主键为1、2、5的三条记录,如果5被加上间隙锁,只会锁住开区间(2,5)间隙,而不会锁住5这条记录本身。

Gap Lock不锁记录,只向前锁间隙

如果事务要插入记录,需要获取插入意向锁(Insert Intention Lock),如果需要插入的间隙有间隙锁,则获取插入意向锁会失败必须进行锁等待,从而实现了阻塞插入。 在可串行化隔离级别或MySQL的RR级别,使用锁住间隙去防止插入,从而避免了幻读

产生间隙锁是有条件的条件(RR事务隔离级别下)

  1. 使用普通索引锁定;
  2. 使用多列唯一索引;
  3. 使用唯一索引锁定多行记录。

临键锁(Next-Key Lock)

很多时候需要锁住多个间隙以及记录本身,比如执行“SELECT name FROM students WHERE id >= 1”,需要锁住(1,3)、(3,5)、(6、7)以及1、3、5、7四条记录本身:

间隙锁和记录锁是两种锁结构,因此不能合并,如果为3个间隙分别加间隙锁,4条记录分别加记录锁,则会产生7条锁记录,很占用内存,因此MySQL有一种锁称为Next-Key Lock,如果在小红的记录上面加Next-Key Lock,则会锁住(1,3]这个前开后闭的区间,也就是锁住了记录本身+记录之前的间隙,可以发现,Next-Key Lock其实就是Gap Lock + Record Lock。此时锁结构就可以简化成为ID为1的记录加上记录锁+后面连续的3个Next-Key Lock,由于Next-Key Lock类型相同并且连续,可以将它们放入同一个锁记录,最后只有ID为1的记录锁+1个Next-Key Lock。 Next-Key Lock并没有什么特别之处,只是对Record Lock + Gap Lock的一种简化。

执行语句后的加锁情况图示:

二、使用MVCC实现事物的隔离性

可串行化虽然保证了事务的绝对安全,但是并发度很低,很多操作都需要排队进行,为了提高效率,SQL标准在隔离级别上进行了妥协,由此有了可重复读、读已提交的隔离级别,它们都允许部分并发问题。 MySQL中RC和RR两个隔离级别就是通过MVCC机制实现的。MVCC全称Multiple Version Concurrency Control,也就是多版本并发控制,实现的逻辑就是读不加锁,写加锁,读写不冲突。

1、MVCC实现原理
(1)版本链(undo log)

根据行为的不同,undo log可以分为两种:insert undo log 和 update undo log

insert undo log

是在insert操作中产生的undo log。因为insert操作的记录只对事务本身可见,对于其他事务此几率是不可见的,所以insert undo log可以在事务提交之后而不需要进行purge操作

update undo log

是update或delete操作中产生的undo log,因为会对已经存在的记录产生影响,为了提供MVCC机制,因此update undo log不能在事务提交时就直接删除,而是在事务提交时放入history list上,等待purge线程进行最后的删除操作。

是不是没明白啥意思,别着急,我画图给你看:

在MySQL中每条记录除了有我们定义的字段之外,他还为我们默认生成了是三个隐式字段: trx_id、roll_pointe和row_id

  • trx_id代表这条记录版本是被哪个事务创建的,数据库有一个全局的事务ID分配器,它一定是递增的,新的事务ID一定不会和旧的事务ID重复。
  • roll_pointer是连接版本链的指针。
  • row_id不是必须的,他的作用和数据记录的主键一样。当数据库字段有指定主键时,Mysql会选取唯一字段作为主键,如果没有唯一字段,那就生成一个6字节的row_id来作为主键。

在MVCC中,每条记录都有多个版本,串成了一个版本链,也就是说,记录被UPDATE时并不是In Place Update,而是将记录复制然后修改存一份到版本链,被DELET时,也不是马上从文件删除,而是将记录标记为被删除,它也是版本链的一环。

接下来,我们通过一个一个例子来感受一下这个过程:

比如,我们有一个表产品表(product),有三个字段:ID,product_name,price

我们开启一个事务插入一条数据:

#Transcation 50
BEGIN:
INSERT INTO product(ID,product_name,price) VALUE(1001,"笔记本",5000);
#未提交

上面这条语句执行时候,就会在undo log中产生一个版本记录,重点说明一下后面的几个隐式字段,trx_id它记录的是执行操作的事物ID,roll_pointer对于插入语句是null,这个表有指定记录id,因此也是null

之后我们对这条数据进行一些更新操作:

#Transcation 100
BEGIN:
UPDATE product(ID,product_name,price) VALUE(1001,"笔记本",6000);
UPDATE product(ID,product_name,price) VALUE(1001,"笔记本",4000);
#未提交

上面这个事务就是一个更新操作了,执行后undo log如下:

(2)Read View

MVCC中最常听到的概念就是快照,其实快照只是最终结果,而不是实现方式,快照 = 版本链 + Read View首先要明确只有SELECT操作需要ReadView,换句话说进行读操作时SELECT语句读取到的到底是哪个版本的数据,取决于ReadView。Read View保存着当前活跃事务的ID,具体有以下信息:

Read View

ReadView什么时候产生?

这个问题对于RC和RR的实现是不同的:

  • RC:在每个事务中的每个SELECT语句执行的时候实时产生一个全新的ReadView

  • RR:在一个事务中,只会在第一个SELECT语句执行的时候产生一个ReadView,之后的SELECT语句复用这个ReadView

从上面描述的ReadView生成时机就可以知道,RC每执行一个SELECT语句,就会产生一个ReadView,因此ReadView就可能会发生改变,上面说过,使用MVCC机制,查询结果完全取决于ReadView,因此即使在同一个事务中,SELECT查询的结果当然会不一致;而RR在一个事务中值有一个ReadView,无论你如何修改,在执行第一个SELECT那一刻,ReadView已经定型了,自然多次读取结果不会改变了。

ReadView可不可用如何判断?

Read View结合版本链使用,当事务读取某条记录时,会根据此事务的Read View判断此记录的哪个版本是这个事务可见的:

  1. 如果记录的trx_id与creator_trx_id相同,则代表这个版本是此事务创建的,可以读取
  2. 如果记录的trx_id小于min_trx_id,代表这个版本是此事务生成Read View之前就已经创建的,可以读取
  3. 如果记录的trx_id大于等于max_trx_id,代表这个版本是此事务生成Read View之后开启的事务创建的,一定不能被读取
  4. 如果记录的trx_id处于min_trx_id与max_trx_id之间,则判断trx_id是否在m_ids中,如果不在,则代表这个版本是此事务生成Read View时已经提交的,可以读取,否则就不可以读取

有了版本链和Read View,即使其他事务修改了记录,先生成Read View的事务也不会读到,只要Read View不改变,每次读到的版本一定相同。

2. MVCC的局限性

MVCC取代了读锁的位置,它不阻塞写入虽然有提高效率的优势,但是同时也无法防止所有并发问题。

(1)MVCC能避免幻读吗

事务是无法读到Read View生成后别的事务产生的记录版本,因此可以在不加间隙锁的情况下也不会读到别的事务的插入,那MVCC能避免幻读吗? 先说结论:MVCC不可以避免幻读。 导致这个问题的根本原因是:InnoDB将Update、Insert、Delete都视为特殊操作,特殊操作对记录进行的是当前读(Current Read),也就是会读取最新的记录,也就是说Read View只对SELECT语句起作用。 如果users表中有id为1、2、3共3条记录,事务A先读,事务B插入一条记录并提交,事务A更新被插入的记录是可以成功的,因为UPDATE是进行当前读,更新时可以读到id为4的记录存在,因此可以成功更新,事务A成功更新id为4的记录后,将在id为4的记录版本链上新增一条事务A的版本,因此事务A再次SELECT,就可以名正言顺地读到这条记录,符合Read View规则,但产生了幻读。

幻读

如果要避免幻读,可以使用MVCC+间隙锁的方式。

(2)无法避免Read Skew与Write Skew

由于MVCC中读-写互不阻塞,因此事务读取的快照可能已经过期,读到的可能已经成为陈旧数据,因此可能出现Read Skew与Write Skew。

(3)无法避免丢失更新

还是由于读-写不阻塞的特性: R1(A) => R2(A) => W2(A) => W1(A) 事务1读出的A值已经过期,但是它不知道,还是根据旧的A值去更新A,最后覆盖了事务2的写入。 在Postgrel中,Repeatable Read级别就已经避免了丢失更新,因为它使用MVCC+乐观锁,如果事务1去写入A,存储引擎检测到A值已经在事务1开启后被别的事务修改过,则会报错,阻止事务1的写入。单纯的MVCC并不能防止丢失更新,需要配合其他机制。

三、事务更佳实践

在进行业务开发时应该先了解项目使用的数据库的事务隔离级别以及其原理、表现,然后根据事务实现原理去思考更好的编码方式。

1. 避免死锁
语句顺序不同导致死锁

这种情况大家一定很熟悉了:

死锁

因此建议在不同的业务中,尽量统一操作相同记录语句的顺序

索引顺序不同导致死锁

锁都是加在索引上的(这里最好先理解一下B+Tree索引),所以一条SQL如果涉及多个索引,会为每个索引加锁,比如有一张users表(id,user_name,password),主键为id,在user_name上有一个唯一索引(Unique Index),以下语句:

UPDATE users SET user_name = ‘j.huang@aftership.com’ WHERE id = 1;

这条语句中涉及到了id与user_name两个索引,InnoDB是索引组织表,主键是聚簇索引,因此记录是存在主键聚簇索引结构中的,那么这条SQL的加锁顺序为:

  1. 为表加上IX锁
  2. 为主键加上X锁
  3. 为索引user_name加上X锁

此时如果另一条事务执行如下语句:

UPDATE users SET password = ‘123’ WHERE user_name = ‘j.huang@aftership.com’;

则可能产生死锁。 原因大家可以先思考一下。 这条语句的加锁顺序是:

  1. 找到user_name为'j.huang@aftership.com’的索引,加X锁
  2. 为表加IX锁
  3. 为主键加X锁

他们都会对同一个主键索引加锁和同一个二级索引,但是加锁顺序不同,因此可能造成死锁,这种情况很难避免,MySQL中可以通过SHOW ENGINE INNODB STATUS查看InnoDB的死锁检测情况。

2. 避免不必要的事务

其实很多业务场景并不需要事务,比如说领取优惠券,并不需要开启一个Serializable级别的事务去SELECT优惠券剩余数量,判断是否有余量,再UPDATE领取优惠券,完全可以一条语句解决:

UPDATE coupons SET balance = balance - 1 WHERE id = 1 and balance >= 1;

语句返回后判断更新行数,如果更新行数为1,则代表领取成功,更新行数为0,代表没有符合条件的记录,领取失败。 (注意:这里只考虑领取优惠券的场景,如果业务还需要将优惠券写入users表等其他一系列操作,就需要根据业务需求放入事务)

3. 避免将不必要的SELECT放入事务

首先应该理解将SELECT放入事务的意义是什么?

  1. 需要读取事务自己的版本,则必须将SELECT放入事务
  2. 需要依赖SELECT结果作为其他语句的前提,此时不止要把SELECT放入事务,还必须保证事务是Serializable级别的

如果不是以上两个原因,则SELECT是没有必要放入事务的,比如下单一件产品,如果只是SELECT它的product_name去写入orders表,这种非强一致要求的数据,没有必要放入事务,因为product_name即使被改变了,写入order的product_name是1秒前的旧数据,也是可以接受的。

4. 不要迷信事务

很多开发者误以为将SELECT放入事务,将结果作为判断条件或者写入条件是安全的,其实根据隔离级别不同,是不一定的,举个例子:

  1. SELECT users表某个用户等级信息,如果是钻石会员,则为他3倍积分
  2. 将算出的积分UPDATE到user_scores表

将这两条语句放入事务也不一定是安全的,这取决于事务的实现,如果是InnoDB的Repeatable Read级别,那么这个事务是不安全的,因为SELECT读到的是快照,在UPDATE之前,其他事务可能就已经修改了user的等级信息,他可能已经不满足3倍积分条件,而此时再去UPDATE user_scores表,这个事务是个业务不安全的事务。 因此,要先了解事务,再去使用,否则容易用错。

留言区

还能输入500个字符