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

这是数据库事务分享的第二篇,上一篇讲解数据库事务并发会产生的问题,这篇会详细讲数据库如何避免这些问题,也就是如何实现隔离,主要是讲两种主流技术方案——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 因而可知,不一样事务间,读-写、写-写都是冲突的,不加控制的写操做,会致使并发调度不可串行化。

1、基于锁实现可串行化

(本节以MySQL InnoDB为基本模型)高并发

1. 读锁与写锁

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

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

读的时候不能写,写的时候不能读也不能写,可是读的时候能够读,由于读不冲突,因而数据库须要两种锁:编码

  • 排它锁(exclusive lock) 又称X锁,这是最好理解的锁,在通常的并发编程中,咱们为资源加上的通常都是排它锁,要获取锁,必须是资源处于未被加锁状态,若是有人已经为资源加锁,则须要等待锁释放才能获取锁,这种锁可以保证并发时也可以串行处理某个资源,实现排队的目的。
  • 共享锁(share lock) 又称S锁,这是比排它锁更加宽松的锁,当一个资源没有被加锁或者当前加锁为共享锁时,能够为它加上共享锁,也就是一个资源能够同时被加无限个共享锁。此时因为资源已经被加锁,虽然能够继续加共享锁,可是不能加排它锁,须要等待资源的锁被彻底释放才能获取排它锁。共享锁的目的是为了提升非冲突操做的并发数,同时可以保证冲突操做的排队执行。
    兼容性

这两种锁和读、写是什么关系呢? 读写都会加锁,可是读-读能够并发,写则须要与任何操做排队,因此:设计

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

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

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语句,发现结果变为了“张三”+“李四”,这就是幻读同一个事务进行的两次相同条件的读取,却读取到了以前没有读到的记录。 有了记录锁虽然能够实现对已存在记录进行并发控制,也就是对于更新、删除操做,不再会有并发问题,可是没法对插入作并发控制,由于插入操做是对不存在的记录,而还不存在的记录,咱们没法为其加记录锁,所以可能会产生幻读现象。 为了解决这个问题,出现了间隙锁间隙锁也是加在某一条记录上,但是它并不锁住记录自己,它只锁住这条记录与它的上一条记录之间的间隙,防止插入。 以下图所示,若是一张表有主键为一、二、5的三条记录,若是5被加上间隙锁,只会锁住开区间(2,5)间隙,而不会锁住5这条记录自己。

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

Next-Key Lock

不少时候须要锁住多个间隙以及记录自己,好比执行“SELECT name FROM students WHERE id >= 1”,须要锁住(1,3)、(3,5)、(六、7)以及一、三、五、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就是个很好的替代品。

2、锁的替代——使用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为一、二、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并不能防止丢失更新,须要配合其余机制。

3、事务更佳实践

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

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表,这个事务是个业务不安全的事务。 所以,要先了解事务,再去使用,不然容易用错。

相关文章
相关标签/搜索