从0到1理解数据库事务(下):隔离级别实现——MVCC与锁

5,498 阅读22分钟

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

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

  • 脏写(Dirty Write)
  • 脏读(Dirty Read)
  • 不可重复读(Unrepeatable Read)
  • 幻读(Phantom)
  • 读偏差(Read Skew)
  • 写偏差(Write Skew)
  • 丢失更新(Lost Updates)

并发面临的问题

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

在进行下面的讨论下,先定义好我们描述事务的模型: 我们用 R 表示读(read),用 W 表示写(Write),在操作后跟数字,代表哪个事务在进行操作,在数字后跟括号,代表操作哪个元素 用 R1(A) 表示事务1读元素A,用 R2(A)表示事务2读A

看一下写操作如何造成并发调度与串行执行的结果不符合:

事务1读A的值,并且在此基础上增加50并回写A,但是在回写之前,事务2将A修改为了200,这两个事务按照此调度执行后,A的最终值为150,不符合任何串行调度的结果。 如果串行调度为 事务1 => 事务2,那么A最终应该是200 如果串行调度为 事务2 => 事务1,那么A最终应该是250 由此可见,不同事务间,读-写、写-写都是冲突的,不加控制的写操作,会导致并发调度不可串行化。

一、基于锁实现可串行化

(本节以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. 避免幻读(Phantom)

间隙锁(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),如果需要插入的间隙有间隙锁,则获取插入意向锁会失败必须进行锁等待,从而实现了阻塞插入。 在可串行化隔离级别,使用锁住间隙去防止插入,从而避免了幻读。

Next-Key Lock

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

Students

间隙锁和记录锁是两种锁结构,因此不能合并,如果为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的一种简化。

5. 举一反三:并发问题之解

1. 脏写(Dirty Write)

方案:事务写记录必须获取排它锁

原理:事务写记录之前获取它的排它锁,同时由于严格两阶段加锁,在事务提交前都不会释放锁,因此完全避免了脏写。

2. 脏读(Dirty Read)

方案:事务写记录必须获取排它锁

原理:当记录被加上排它锁后,是不允许再被加任何锁的,因此任何事务都无法读到其他事务写入还未提交的数据。

3. 不可重复读(Unrepeatable Read)

方案:事务读记录必须加锁(S或X锁均可)

原理:由于事务在读记录时已经为记录上锁,因此其他事务无法再为这条记录上排它锁,因此根本无法修改这条记录,也不会出现不可重复读。

4. 幻读(Phantom)

方案:间隙锁

原理:间隙锁阻塞了插入,因此也不会出现幻读问题。

5. 读偏差(Read Skew)

读偏差需要再稍微解释下,还是用上一篇提到的例子:比如X、Y两个账户余额都为50,他们总和为100,事务A读X余额为50,然后事务B从X转账50到Y然后提交,事务A在B提交后读Y发现余额为100,那么它们总和变成了150,此时事务A读到的数据违反业务一致性,为读偏差。 可以发现,读偏差是由于业务一致性是由多条记录的总状态保证的,在事务A开启并读取了其中某一部分记录后,事务B对A还没有读到的记录进行了修改并且B提交了,此时数据库已经进入了新的一致状态,但是A在B提交后再去读那部分记录,读到了B修改后的数据,虽然此时数据库事实上依旧处于一致状态,但是A却发现多条记录的总状态不符合业务一致性,产生读偏差读偏差的本质是因为事务A有一部分是陈旧数据,另一部分是新数据,总状态不一致。

方案:读数据必须获取锁,写数据必须加排它锁

原理:由于事务在读记录时已经加上了锁,那么任何事务都不能再获取排它锁,也就不能更新这条已经被读过的数据,那么对于事务自然不可能存在“陈旧数据”一说,任何被读到的数据,在它提交前都不可能被修改,因此读到的都是最新数据。

6. 写偏差(Write Skew)

上一篇有详细讲到写偏差,这里就不多说,它与读偏差本质相同,都是因为读到的某一部分数据成为了陈旧数据,写偏差使用陈旧数据作为写前提,因此作出了错误判断,写入了业务不一致的结果,因此解决写偏差需要解决陈旧数据问题。

方案:读数据必须获取锁,写数据必须加排它锁

原理:它与写偏差的解决原理完全相同,都是因为加锁强制避免了事务读取过的数据被修改,防止了陈旧数据的出现。

7. 丢失更新(Lost Updates)

丢失更新也在上一篇中有讲到,大概就是事务A先读X,对X进行计算后再写X,但是在写X之前,已经被事务B修改了X的值并提交了,而A不知道,将它认为正确的X值写入,覆盖了事务B的值,此为丢失更新。 丢失更新的本质也是基于陈旧数据做出修改决策,只不过陈旧记录与被修改记录为同一条记录,这是和写偏差的唯一区别。

方案:读数据必须获取锁,写数据必须加排它锁

原理:它与避免读、写偏差完全相同的原理,避免记录成为陈旧记录。

可见,InnoDB中的可串行化隔离级别,基于锁,避免了所有并发问题,是最安全的事务隔离级别,但是在业务开发中并不是每个并发问题我们都可能遇到,由于业务的独特性,可能只会面临某一些并发问题或者可以用其他方式去规避这些并发问题带来的业务损害,而为了避免所有的并发问题去使用锁,明显是个收益很低的选择,有时可以允许某些并发问题,减少锁的使用,提高并发效率,下面会讲到的MVCC就是个很好的替代品。

二、锁的替代——使用MVCC提高并发度

可串行化虽然保证了事务的绝对安全,但是并发度很低,很多操作都需要排队进行,为了提高效率,SQL标准在隔离级别上进行了妥协,由此有了可重复读、读提交的隔离级别,它们都允许部分并发问题,这里先讲可重复读隔离级别。 SQL标准中,可重复读仅仅需要完全避免脏写、脏读、不可重复读三种异常,此时如果再用加锁实现,读-写排队未免效率太低,于是MVCC诞生了。 MVCC全称Multiple Version Concurrency Control,也就是多版本并发控制,重点在多版本,简单来说,它为每个事务生成了一个快照,保证每个事务只能读到自己的快照数据,不论其他事务如何更新一条记录,这个事务所读到的数据都不会产生变化,也就是说,会为一条记录保留多个版本,多个事务读到的版本不同,MVCC代替了读锁,实现了读-写不阻塞。 MVCC的意义只是替代读锁,写依旧是加锁的,这样避免了脏写,下面先讲一下MVCC的实现思路,认识MVCC如何避免并发问题,最后讨论MVCC在并发中的局限性。

1. MVCC实现原理

版本链(Undo Log)

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

一条记录的版本链

  • trx_id代表这条记录版本是被哪个事务创建的,数据库有一个全局的事务ID分配器,它一定是递增的,新的事务ID一定不会和旧的事务ID重复。
  • roll_pointer是连接版本链的指针。
Read View

MVCC中最常听到的概念就是快照,其实快照只是最终结果,而不是实现方式,快照 = 版本链 + Read View。 MVCC并不是将表中所有的记录都为这个事务冻结了一份快照,而是在事务执行第一条语句时时生成了一个叫做Read View的数据结构,注意,Read View是事务执行语句时才会生成的,仅仅执行start transaction是不会生成Read View的。 Read View保存着以下信息:

Read View

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不改变,每次读到的版本一定相同。MySQL中可重复读和读提交级别都基于MVCC,区别只是生成Read View的时机不同,可重复读级别是在事务执行第一个SQL时生成Read View,而读提交级别是在事务每执行一条SQL时都会重新生成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表,这个事务是个业务不安全的事务。 因此,要先了解事务,再去使用,否则容易用错。