在网上的不少资料里,其实没有很好的解释为何咱们须要事务。其实咱们去学习一个东西以前,仍是应该了解清楚这个东西为何有用,硬生生的去记住事务的ACID特性、各类隔离级别我的认为没有太大意义。设想一下,若是没有事务,可能会遇到什么问题,假设你要对x和y两个值进行修改,在修改x完成以后,因为硬件、软件或者网络问题,修改y失败,这时候就出现了“部分失败”的现象,x修改为功,y修改失败,这个时候须要你本身在应用代码里去处理,你能够重试修改y,也能够把x设置成以前的值(回滚),无论你怎么作,这些因为底层系统的各类错误致使的问题,都须要你本身写应用代码去处理,而若是有了事务,你彻底不必关心这些底层的问题,只要提交成功了,全部的修改都是成功的,若是有失败的,事务会自动回滚回以前的状态;另外,在并发修改的场景中,若是没有事务,你须要本身去实现各类加锁的逻辑,繁琐并且容易出错,而若是有了事务,你能够经过选择事务的一个隔离级别,来伪装某些并发问题不会出现,由于数据库已经帮你处理好了。总之,事务是数据库为咱们提供的一层抽象,让咱们伪装底层的故障和某些并发问题并不存在,从而更加舒服的编写业务代码html
众所周知,事务有着ACID属性,分别是原子性(atomicity),一致性(consistency),隔离性(isolation)和持久性(durability),咱们分别展开说一下mysql
首先可能有人会混淆这里的原子性和多线程编程中的原子操做,多线程编程中的原子操做是指若是一个线程作了一个原子操做,其余线程没法看到这个操做的中间状态,这是一个有关并发的概念。而事务的原子性指的是一个事务中的多个操做,要么所有成功,要么所有失败,不会出现部分红功、部分失败的状况。redis
若是说原子性是一个有点容易混淆的概念,一致性这个概念就更加模糊了,可能不少人看到这个词都不知道他在说啥。一致性是什么,咱们看一下《数据库系统概论》这本书给的定义:算法
(一致性是指)事务执行的结果必须是使数据库从一个一致性状态变到另外一个一致性状态sql
那什么叫一致性状态,其实就是你对数据库里的数据有一些约束,例如主键必须是惟一的、外键约束这些(还记得数据库的完整性约束吗),固然,更多的是业务层面的一致性约束,好比在转帐场景中,咱们要求事务执行后全部人的钱总和没有改变。数据库能够帮咱们保证外键约束等数据库层面的一致性,可是对咱们业务层面的一致性是一无所知的,好比你能够给每一个人的钱都加100块,数据库并不会阻止你,这时你就轻松的违反了业务层面的一致性。因此咱们能够发现,一致性对咱们来讲是一个有点无关痛痒的属性,咱们实际上是经过事务提供的原子性和隔离性来保证事务的一致性,甚至你能够认为一致性不属于事务的属性,也有人说一致性之因此存在,只是为了让ACID这个缩略词更加顺口而已数据库
若是多个事务同时操做了相同的数据,那么就会有并发的问题出现,好比说多个事务同时给一个计数器(counter)加1,假设counter初始值为0,那么可能会出现这样的状况:编程
咱们作了两次加1操做,结果本应是2,可是最终可能会是1。固然,还会有其余的并发问题,隔离性就是为了屏蔽掉一些并发问题的处理,让咱们编写应用代码更加简单。咱们再来看一下《数据库系统概论》给隔离性的定义:网络
一个事务的执行不能被其余事务干扰。即一个事务的内部操做及使用的数据对其余并发事务是隔离的,并发执行的各个事务之间不能相互干扰多线程
课本上的定义是根据“可串行化”这个隔离级别来表述隔离性,就是说你能够认为事务之间彻底隔离,就好像并发的事务是顺序执行的。可是,咱们实际用的时候,为了更好的并发性能,基本不会把事务彻底隔离,因此就有了隔离级别的概念,sql 92标准定义了四种隔离级别:未提交读、提交读、可重复读、可串行化,你们通常会使用较弱的隔离级别,例如“可重复读”。关于各类隔离级别,咱们放到第三部分和第四部分再说并发
持久性是指一旦事务提交,即便系统崩溃了,事务执行的结果也不会丢失。为了实现持久性,数据库会把数据写入非易失存储,好比磁盘。固然持久性也是有个度的,例如假设保存数据的磁盘都坏了,那持久性显然没法保证
咱们前面提到,为了更好的并发性能,咱们搞出了各类弱隔离级别,那么隔离级别是怎么定义的呢?隔离级别是经过可能遇到的并发问题(异象)来定义的,选定一个隔离级别后,就不会出现某一类并发问题,那么咱们就来看看会有哪些并发问题,在每一小节,咱们会先讲讲这个并发问题是什么,而后讨论阻止他的隔离级别,最后说说实现这个隔离级别的方法,这里咱们只讨论加锁的实现,其余实现咱们放到第四章来说,因此咱们先简单说一下锁
S锁和X锁你们应该都很熟悉,S锁即共享锁,X锁即互斥锁
根据锁持有的时间,咱们把锁分为Short Duration Lock和Long Duration Lock,本文就简称为短锁和长锁,短锁即语句执行前加锁,执行完成后就释放;长锁则是语句执行前加锁,而到事务提交后才释放
另外根据锁的做用对象,咱们把锁分为记录锁(record lock)和谓词锁(predicate lock),谓词锁顾名思义锁住了一个谓词,而不是具体的数据记录,好比select * from table where id > 10,若是加谓词锁,就锁住了10到无限大这个范围,无论表里是否真的存在大于10的记录
一个事务对数据进行写操做以后,尚未提交,被另外一个事务对相同数据的写操做覆盖(你可能会看到有的资料称之为“第一类丢失更新”)
举个例子:(x初始值为0)
这里事务A将x写为1以后尚未提交,就被事务B覆盖为2。
脏写会致使什么问题呢?
第一个问题是没法回滚,假设在T4时刻事务A要回滚,这个时候x的值已经变成了2,若是把x回滚为事务A修改以前的值,也就是0,那么事务B的修改就丢失了;若是不回滚,那么当T5时刻,事务B也要回滚时,你仍是不能回滚x的值,由于事务B修改以前,x的值是1,因为事务A回滚,这个值已经变成了脏数据。这就致使事务没办法回滚,影响了事务的原子性
第二个问题是影响一致性,假如说咱们同时对x和y进行修改,要求x和y始终是相等的,看下面的例子
初始值 x=y=1
能够看到,最终x变成了3,y变成了2,违反了一致性
因为脏写致使不能回滚,严重影响原子性,因此无论是什么隔离级别,都要阻止这种问题,所以能够认为最弱的隔离级别“未提交读”须要阻止脏写
那怎么防止脏写呢,很简单,就是加锁,通常会在更新以前加行级锁(X锁),那何时释放锁呢,更新操做执行完以后释放锁显然不行,必须等到事务提交以后再释放锁,这样才不会出现脏写的状况,即写操做加长锁(X锁)
一个事务对数据进行写操做以后,尚未提交,被另外一个事务读取
脏读会致使什么问题呢?咱们看两个例子
第一个:
x初始值为100
能够看到因为事务A回滚,事务B读到的x值变成了脏数据
那若是事务A不回滚,事务B读到的不就不是脏数据了吗?
其实一样可能有问题,咱们看第二个例子:
假设x=50 y=50,x要给y转帐40,那咱们的一致性要求就是x+y在事务执行后仍然为100
能够看到事务B读到的结果是x+y=60,违反了咱们要求的一致性
SQL-92定义了“提交读”隔离级别来阻止脏读,不过SQL-92只提到了第一种状况,而其实无论有没有回滚,只要读到了其余事务未提交的数据,都应该认为是脏读,均可能会出现问题
提交读如何实现呢?咱们为了防止脏写,已经对写操做加了长锁,那么在此基础上,只要给读操做加短锁(S锁)就能解决脏读的问题,即读以前申请锁,读完后当即释放,注意,这里不只要给数据记录加短锁,还要加谓词锁,为何呢,试想假如只加记录锁,若是咱们作了一个范围查询,而在查询过程当中,正好另一个事务在这个范围插入了一条数据,咱们的范围查询仍然可以读到,即读到了其余事务未提交的数据,所以还须要加谓词锁(短锁,S锁)。总之,实现提交读,须要写操做加长锁,读操做加短锁(记录锁和谓词锁)
不可重复读(Non-Repeatable Read)也叫Fuzzy Read,指一个事务对数据进行读操做后,该数据被另外一个事务修改,再次读取数据和原来不一致(其实不读第二次也可能会有问题)
咱们仍是看两个例子:
第一个:
x初始值为1
能够看到事务A第二次读取x的值发生了变化,影响了一致性
那么若是我没有对相同数据作第二次读取呢?
咱们看第二个例子:
x初始值为50,y初始值为50,x给y转帐40,咱们的一致性要求时事务执行后x和y的总和不变
能够发现,事务A没有对任何数据读第二次,可是在事务A看来,x+y=140,而不是100,违反了一致性
SQL-92定义了“可重复读”隔离级别来阻止不可重复读的问题,可是它只提到了第一种状况,可是从第二个例子咱们能够发现,无论有没有作第二次读取,其实均可能会有问题,所以要想阻止不可重复读,事务读完数据后,就要阻止其余事务对该数据的写操做
在“提交读”中,咱们已经对写操做加了长锁,对读操做加了短锁(记录锁,谓词锁),为了阻止不可重复读的问题,须要给读操做中的记录锁也加长锁(S锁),所以“可重复读”隔离级别的实现就是读操做中记录锁加长锁(S锁),谓词锁加短锁(S锁),写操做加长锁(X锁)
这里在记录锁的角度来看,咱们其实已经在作两阶段锁(Two Phase Locking: 2PL)了。咱们简单讨论一下两阶段锁:
顾名思义,两阶段锁必定有两个阶段:
Expanding phase(也叫Growing phase),即加锁阶段,这个阶段能够加锁,可是不能释放锁
Shrinking phase(也叫Contracting phase),即解锁阶段,这个阶段能够解锁,可是不能再加锁了
两阶段锁有几种变体,比较常见的就是两种:
保守两阶段锁(Conservative two-phase locking),就是在开始以前一次性把要加的锁加上,也就是一些资料说的“一次封锁法”,能够防止死锁
严格两阶段锁(Strict two-phase locking),X锁在提交以后才能释放,S锁能够在解锁阶段释放
咱们这里,包括在不少资料提到的两阶段锁,实际上是指严格两阶段锁
幻读是指一个事务经过一些条件进行了读操做,好比select * from table where id > 1 and id < 10,而后另外一个事务的写操做改变了匹配该条件的数据(多是插入了新数据,多是删除了匹配条件的数据,也多是经过更新操做让其余数据也变得匹配该条件)
一样看两个例子:
假设学生表中有a,b,c三个学生
能够看到,事务A第二次读取全部学生列表,多了一个学生出来,影响了一致性
那么,从新问一下在不可重复读中问过的问题,若是我不作第二次读取呢?
答案是一样可能有问题,咱们看第二个例子:
仍是这个学生表,有a,b,c三个学生,同时为了不直接计数的性能问题,咱们还有一个count记录学生的总数,count初始值为3
此次咱们没有读取两次全部学生列表,可是能够看到两个有关联的数据发生了不一致,明明读学生列表后咱们计算出的总数是3,但是直接读count获得的倒是4,违反了一致性
SQL-92定义了“可串行化”隔离级别来阻止幻读的问题,不过对于幻读问题只说起了第一种状况,而其实无论有没有第二次读取,只要其余事务的写致使读取的结果集发生变化,均可能会发生一致性的问题
在“可重复读”隔离级别中,咱们已经给读操做加了记录锁(长锁)和谓词锁(短锁),为了防止幻读,谓词锁加短锁已经不行了,咱们须要把谓词锁也变成长锁。所以可串行化隔离级别的实现就是读操做加长锁(记录锁,谓词锁),写操做加长锁,也就是经过两阶段锁来实现可串行化。
由于SQL-92对异象的定义不够完整,后面要提到的三种异象可能稍微陌生一些
丢失更新是指一个事务的写被另外一个已提交事务覆盖(有些资料把它称为第二类丢失更新)
咱们看一个例子:
counter初始值为1,两个事务分别给counter值加1,counter最后的值应该变成3
咱们发现事务B提交以后counter值是2,也就是说即便事务A已经提交了,它对counter的更新却“丢失”了
因为SQL-92没有说起这种异象,因此对于哪一种隔离级别应该阻止丢失更新没有权威的定义,不过咱们能够看到上面会出现丢失更新的问题,是由于事务B读取counter后被事务A修改,这是上面的的“可重复读”隔离级别加锁实现所阻止的,所以咱们对于可重复读的加锁实现可以阻止“丢失更新”的发生,上面的例子中,因为对读操做加了长锁,因此两个事务的写操做会互相等待对方的读锁释放,造成死锁,若是有死锁检测机制,事务B会自动回滚,不会出现丢失更新的状况
最后两个异象Read Skew和Write Skew都是违反了数据原有的一致性约束
Read Skew即读违反一致性约束,本来多个数据存在一致性的约束,读取发现违反了该一致性
咱们直接用不可重复读中的第二个例子就好:
x初始值为50,y初始值为50,x给y转帐40,咱们的一致性要求时事务执行后x和y的总和不变
事务A发现x+y变成140了,这就出现了Read Skew,你能够把Read Skew当成不可重复读的一种状况
SQL-92一样没有说起这种异象,因为Read Skew能够视为不可重复读的一种状况,因此“可重复读”隔离级别应该阻止Read Skew(咱们对于可重复读的加锁实现可以阻止“Read Skew”的发生,上面的例子中,事务B的写操做会被事务A的读锁阻塞,所以事务A会读到x=y=50,不会出现Read Skew)
write skew即写违反一致性约束,一般发生在根据读取的结果进行写操做时,并发事务的写操做致使最终结果违反了一致性约束,可能很差理解,咱们看个例子
第一个问题:
假设x和y是一我的的两个信用卡帐户,咱们要求x + y不能小于0,而x或者y能够小于0,就是说你的一张信用卡能够是负的,可是所有加起来不能也是负的
下面事务A和事务B是两次并发的扣款,x初始值为20,y初始值为20
咱们发现两个事务提交以后,x+y变成了-20,违反了一致性约束
上面这个问题是严格意义上的Write Skew,另外还有因为幻读产生的Write Skew
问题2:
假设咱们要作一个注册用户的功能,要求用户名惟一,而且没有给用户名加惟一索引,也就是说惟一性咱们本身来保证
用户表已有用户名a,b,c,两个用户同时注册用户名d
咱们发现,两个事务提交以后,用户名d有了两个,违反了惟一性约束
这个问题因为是幻读引起的,因此有人把它归类在Write Skew里,也有人把它归类在幻读里,你能够按照本身的理解来分类
SQL-92也没有说起Write Skew,咱们上面提到了两个问题,一个是严格意义上的Write Skew,一个是幻读引起的Write Skew,若是是严格意义上的Write Skew,咱们上面的“可重复读”隔离级别加锁实现能够阻止(写操做会被读锁阻塞);而因为幻读引起的Write Skew,本质上已是幻读问题,因此只有“可串行化”隔离级别可以阻止(上面的例子中,因为谓词锁的存在,后面的插入操做被阻塞)
咱们最后对各类隔离级别的加锁实现汇总一下:
另外对于基于锁实现的隔离级别,咱们根据其避免的并发问题汇总一下
在上面讨论各类异象的过程当中,咱们也引入了一些隔离级别,包括:
未提交读
提交读
可重复读
可串行化
咱们也探讨了相关隔离级别的基于锁的实现,你会发现除了未提交读,咱们都须要对读加锁了,这可能会带来性能问题,一个执行时间稍长的写事务,会阻塞大量的读操做。所以,为了提升性能,不少数据库实现都是采用数据多版本的方式,即保留旧版本的数据,能够作到读操做没必要加锁,所以读不会阻塞写,写也不会阻塞读,能够得到很好的性能。所以就出现了另一种隔离级别——快照隔离(Snapshot Isolation),因为SQL-92标准制定时,快照隔离尚未出现,因此快照隔离没有出如今标准中,一些实现快照隔离的厂商也是按照可重复读来宣传本身的数据库产品。固然,除了快照隔离也有其余的隔离级别实现(例如Cursor Stability 游标稳定),咱们不会在这里讨论,感兴趣的同窗能够本身了解
快照隔离是指每一个事务启动时,数据库就为这个事务提供了这时数据库的状态,即快照(好像把数据库此时的数据都照下来了同样),后续其余事务对数据的新增、修改、删除操做,这个事务都看不到,它始终只能看到本身的一致性快照
快照隔离怎么实现呢,对于写操做仍是用长锁来防止脏写的问题,对于读操做,主要思想就是维护多版本的数据,也就是所谓的MVCC(multi-version concurrency control)
,MVCC不止是用来实现快照隔离这个级别,不少数据库也用它来实现“提交读”隔离级别,区别于快照隔离在事务开始时获得一个一致性快照,在“提交读”隔离级别,每一个语句执行时,都会有一个快照。咱们这里主要关注MVCC实现“快照隔离”的方式。
MVCC的实现方式主要有两种:
维护多版本数据,比较有表明性的是Postgresql
维护回滚日志(undo log),比较有表明性的是Mysql InnoDB
咱们后面会简单介绍一下这两种方式的实现思想,不过因为这是一篇面向小白的文章,因此咱们不会涉及具体的数据库实现
这种方式是实实在在的保留了多个版本的数据,例如假如我有这样一行数据:
若是我把年龄改成20,表中会添加一个不一样版本的数据(实际的存储结构多是B+树,不过为了简单,咱们把数据的存储结构简化为一个表格来描述)
也就是说,即便其余列的值没有变化,也会原样复制一份
那么,怎么实现MVCC呢?
首先,事务开始时数据库会分配一个事务id,咱们这里记做txid,数据库保证这个id是单调递增的(咱们这里不考虑整数回绕的状况)
另外,数据库会在每行数据添加两个隐藏字段:
create_by 表示建立这行数据的事务id
delete_by 表示删除这行数据的事务id
咱们分别看一下插入、更新和删除的过程:(使用用户表作例子)
假设当前事务id为3,插入一个叫liming的用户
该数据的create_by为3,delete_by为null
更新操做能够转换为:删除原数据+插入新数据
假设事务id为4的事务,将liming的年龄更新为20
假设事务id为5的事务,将liming删除
如上将delete_by修改成5
对于当前事务可以“看到”哪些数据,咱们用可见性规则来定义
在事务开始时,数据库会获取当前活跃(未提交的)的事务id列表,以及当前分配的最大事务id
这个事务能看到哪些数据遵循如下规则:
若是对数据作更改的事务id在活跃事务id列表中,那么这个更改不可见
若是对数据作更改的事务id大于当前分配的最大事务id,说明是后续的事务,更改不可见
若是对数据作更改的事务是回滚状态,更改不可见
上面咱们说的更改包括建立和删除(更新能够转化为删除+建立),更改是建立的话含义就是看不到这个数据,更改时删除的话含义就是仍然能看到这个数据
经过这样的可见性规则咱们能够保证事务永远从一个“一致性快照”中读取数据
这种方式保留了数据的回滚日志,而非全部版本的完整数据,须要查询旧版本数据时,经过在最新数据上应用回滚日志中的修改,构造出历史版本的完整数据,主要思想仍是和第一种方式同样,只是采用了另一种实现方式,其实理解了第一种方式也就理解了MVCC,所以这里咱们只简单介绍维护回滚日志的方式
例如这样一行数据
把年龄修改成20,则直接在数据中修改
同时在回滚日志中会记录相似“把age从20改回10”的回滚操做
这种方式怎么实现MVCC呢,和上面同样,事务开始时数据库会分配一个事务id,咱们这里记做txid,数据库保证这个id是单调递增的
相似上面的create_by和delete_by,数据中也会有一些隐藏字段(咱们这里只讨论和MVCC相关的隐藏字段)
txid,建立该数据的事务id
rollback_pointer,回滚指针,指向对应的回滚日志记录
delete_mark,删除标记,标记数据是否删除(咱们后面用1来表示已删除,0来表示未删除)
一样,咱们看一下插入、更新和删除的过程
假设当前事务id为3,插入一个叫liming的用户(下面用绿色表示插入对应的回滚日志)
如图,事务id设置为3,删除标记为0,同时在回滚日志中记录该数据的主键值,咱们这里主键是id,所以记录1就好,而且将回滚指针指向该回滚日志,这里记录主键值是为了回滚时经过主键值删除相关数据和索引
假设事务4要删除liming这条记录(下面用红色表示删除对应的回滚日志)
咱们会把liming这条记录的delete_mark设置为1,同时在回滚日志中记录删除前的事务id、回滚指针以及主键值
更新要分为不更新主键和更新主键两种状况(咱们这里假设主键是id)
首先看不更新主键的状况:
假设id为4的事务将以前插入的liming的age更新为20(下面用蓝色表示更新对应的回滚日志)
咱们会把原来的age直接更新成20,而且txid改成4,同时在回滚日志中记录更新列的信息,这里是age: 10,表示更新前age的旧版本数据是10,另外咱们也记录了原来的事务id和回滚指针,最终回滚日志中的数据会经过回滚指针造成一个链表,从而查找旧版本数据,好比若是事务id为5的事务接着把gender更新为female:
接下来看看更新主键的状况:
更新主键时,和第一种实现MVCC的方式相似,转换为删除+插入
为何这个时候和不更新主键不同呢,是由于更新主键时,数据的位置已经发生变化了,好比数据存储的结构是B+树,若是主键更新了,那么数据在B+树中的位置确定会变化,若是还在旧版本的数据上直接修改主键,那么查找的时候是找不到的(由于是根据主键值作查找),因此这个时候要转换为删除+插入
例如事务id为4的事务,将以前咱们插入的liming的id更新为2
这里的可见性规则其实和MVCC的第一种实现方式是相似的
一样是在事务开始时,数据库会获取当前活跃(未提交的)的事务id列表,以及当前分配的最大事务id
一样遵循如下规则:
若是对数据作更改的事务id在活跃事务id列表中,那么这个更改不可见
若是对数据作更改的事务id大于当前分配的最大事务id,说明是后续的事务,更改不可见
若是对数据作更改的事务是回滚状态,更改不可见
只是咱们以前使用create_by和delete_by来表示数据是由哪一个事务建立,被哪一个事务删除,如今变成了由txid和delete_mark来表示,当delete_mark为1是,表示数据由txid建立,当delete_mark为0时,表示数据被txid删除,同时经过rollback_pointer造成的链表来跟踪旧版本的数据,查找数据时,会在这条链表上向前追溯,直到数据的txid知足可见性规则。而且,由于咱们没有在回滚日志中保留所有的信息,因此在链表上追溯时,要依次应用回滚日志中记录的修改,好比咱们在更新操做中提到的,将年龄改成20,又将性别改成female
这时若是事务3想读取liming这行数据,就要在最新数据上,先把gender改回male,再把age改回10,而后才是知足事务3一致性快照的数据
那么快照隔离可以防止哪些并发问题呢?回顾一下咱们以前提到的并发问题
脏写:咱们以前提到了,快照隔离也是经过写加长锁来避免脏写,因此“脏写”不会出现
脏读:因为快照隔离的可见性规则限制了咱们只能从已提交的数据中读取数据,因此“脏读”不会出现
不可重复读:因为快照隔离使得事务始终从一个一致性的快照中读取数据,即便数据被其余事务修改了,也不会被读取到,因此显然是能够“重复读”的,所以“不可重复读”不会出现
幻读:在快照隔离中,假设当前事务作了一个条件读取操做,即便其余事务的插入、更新和修改使得该条件下的数据发生了变化,因为可见性规则的做用,这些数据对当前事务也不可见,那么快照隔离是否能防止幻读?对于严格意义上的幻读,好比对于只读事务来说,快照隔离是能够防止幻读的。可是若是根据查询结果作了写操做,例如咱们上面提到的幻读致使的Write Skew,快照隔离是没法避免的,由于他并无阻止其余事务的写操做,只是让这些写操做对当前事务不可见了
丢失更新:快照隔离能够避免丢失更新,咱们能够针对当前事务开始后到提交前这段时间提交的这些事务,记录他们修改的数据,若是发现当前事务写的数据和这些已提交事务修改的数据有冲突,那么当前事务应该回滚,从而避免丢失更新的现象,这种方法也叫First-commiter-wins,也就是说先提交的事务会修改数据成功。可是,实际的快照隔离是否能避免丢失更新取决于数据库的实现,好比Postgresql的快照隔离是防止丢失更新的,而Mysql InnoDB的快照隔离不会阻止丢失更新
Read Skew:和不可重复读同样,快照隔离显然能够避免Read Skew
Write Skew:能够回顾一下咱们在Write Skew中的两个例子,很明显无论是严格意义上的Write Skew,仍是幻读致使的Write Skew,快照隔离都没法避免
下面咱们来看看Mysql中的隔离级别,Mysql提供了四种隔离级别:
未提交读
提交读
可重复读
可串行化
未提交读很简单,只是对写操做加了长锁,和咱们上面说的基于锁实现未提交读隔离级别的方式是一致的,因此没啥好说的,Mysql的“未提交读”也是避免了脏写,其余问题都有可能出现
Mysql使用MVCC实现了快照隔离,这里的“提交读”隔离级别也经过MVCC进行了实现,只不过在快照隔离中,咱们是一个事务一个一致性快照,而在“提交读”隔离级别下,是一条语句一个一致性快照
Mysql的“可重复读”本质就是快照隔离,经过MVCC实现,具体的实现方式采用维护回滚日志的方式,即Mysql中的undo log
咱们在前面提到了在快照隔离中,幻读和Write Skew是没法避免的,另外因为Mysql的实现,丢失更新也没法避免,若是不想切换到“可串行化”隔离级别,咱们就须要手动加锁来解决这些问题,那么咱们分别来看看如何避免这几个问题
既然要手动加锁,咱们先了解一下Mysql中相关的锁:(下面全部的讨论都基于Mysql的“可重复读”隔离级别)
Mysql中的表锁包括:
普通的表锁
意向锁
自增锁(AUTO-INC Locks)
MDL锁(metadata lock)
咱们这里讨论前三种
表锁就是对表上锁,能够对表加S锁:
LOCK TABLES ... READ
LOCK TABLES ... WRITE
Mysql支持多粒度封锁,既能够锁表,也能够锁定某一行。那咱们若是要加表锁,就要检查全部的数据上是否有行锁,为了不这种开销,Mysql也引入了意向锁,要加行锁时,须要先在表上加意向锁,这样锁表时直接判断是否和意向锁冲突便可,不须要再检测全部数据上的行锁
意向锁的规则也很简单:IS和IX表示意向锁,要给行加S锁前,须要先加IS锁,要给行加X锁前,须要先加IX锁,意向锁之间不会相互阻塞
加上意向锁以后,表锁的兼容性其实也很简单:
自增锁很显然就是给自增id这种场景用的,也就是设置了AUTO_INCREMENT的列,插入数据时,经过加自增锁申请id,而后当即释放自增锁。自增锁跟事务关系不大,咱们再也不详细讨论
首先,Mysql的行锁并不必定是锁住某一行,也多是锁住某个区间
Mysql中有四种行锁
Next-Key Locks,也叫Ordinary locks,对索引项以及和上一个索引项之间的区间加锁,好比索引中有数据1,4,9,(4, 9]就是一个Next-Key Locks,Next-Key Lock是Mysql加锁的基本单位,会在一些状况下优化为下面的Record Locks或者Gap Locks
Record Locks,也叫rec-not-gap locks,就是Next-Key Locks优化去掉了区间锁,只须要锁索引项
Gap Locks,对两个索引项的区间加锁,好比索引中有数据1,4,9,(4, 9) 就是一个Gap Lock
Insert intention Locks,插入意向锁,insert操做产生的Gap锁,给要插入的索引区间加锁,好比索引中有数据1,4,9,要插入5时,加插入意向锁(4, 9)
咱们给出兼容性矩阵:(S锁和S锁永远是相互兼容的,下面的兼容或者互斥说的是S和X,X和S,X和X这种情形,而且锁住的行有交集)下面第一列表示已经存在的锁,第一行表示正在请求的锁
注意这个矩阵不是彻底对称的:
Gap lock只会阻塞插入意向锁,不会和其余的锁冲突
Next-key lock和Gap lock会阻塞插入意向锁,相反插入意向锁不会阻塞任何加锁请求
简单讨论一下各类操做会加的锁:
样例数据:表t,id为主键,c为二级索引
插入时加插入意向锁,并在要插入的索引项上加Record Lock
delete、update和select加X锁的状况类似,下面以delete为例说明
不加条件的delete/update/select ... for update:好比 delete from t 在表上加IX锁,全部的主键索引记录加Next-key lock,至关于锁表了,其余没法使用索引的条件删除都等同于这种状况
主键等值条件delete/update/select ... for update:好比 delete from t where id = 1 在表上加IX锁,id=1的索引记录上加Record lock
主键不等条件delete/update/select ... for update:好比 delete from t where id < 2 在表上加IX锁,全部访问到的索引记录(直到第一个不知足条件的值)加Next-key lock,这里就是在id=1和id=3上加Next-key lock,即锁住了(-∞, 1]和(1, 3]
二级索引等值条件delete/update/select ... for update:好比 delete from t where c = 10 在表上加IX锁,全部访问到的二级索引记录(直到第一个不知足条件的值)加Next-key lock,最后一个索引项优化为Gap lock,这里就是(-∞, 10]加Next-key lock,(10, 15)加Gap lock;对应的主键索引项加Record lock
二级索引不等条件delete/update/select ... for update:好比 delete from t where c < 10 在表上加IX锁,全部访问到的二级索引记录(直到第一个不知足条件的值)加Next-key lock,这里就是(-∞, 10]和(10, 15]加Next-key lock
加S锁时,覆盖索引的状况比较特殊,其余都和加X锁时相同
下面咱们讨论一下覆盖索引的状况:(覆盖索引是指,查询只须要使用索引就能够查到全部数据,没必要再去主键索引中查询)
假设作以下查询
select id from t where c = 10 lock in share mode
update t set d = d + 1 where id = 1
那若是想避免这种状况怎么办,能够修改查询,让索引覆盖不了;也能够把S锁换成X锁:
select c from t where c = 10 for update
看完了锁咱们再讨论一下如何经过加锁避免在“可重复读”隔离级别会出现的并发问题
咱们一个用户表users做为样例数据:
id为主键,name列有非惟一索引
好比下面的“丢失更新”的例子:
两个事务并发给liming的粉丝数加1
这里咱们给读加锁就能够避免丢失更新:
事务B会等到事务A提交
固然也能够经过乐观锁的方式:
事务B由于where条件不知足,不会更新成功,能够本身在应用代码里重试
咱们以前讨论过,只读事务不会有幻读的问题,这里取幻读致使Write Skew的例子来讨论:
假设咱们要求users表中name惟一,而且name上没有惟一索引
最终users表中会有两条wangwu的记录
一样,给读操做加锁就能够避免
这里事务A和事务B在读操做时会获取Gap-lock,事务A插入时请求插入意向锁被事务B的Gap lock阻塞,后面事务B插入时请求插入意向锁又被事务A的Gap lock阻塞,Mysql死锁检测机制会自动发现死锁,最终只有事务A可以插入成功
Write Skew 咱们仍是用以前信用卡帐户的例子:
cards表
id为主键,列name有非惟一索引
liming有两张卡,总共40块钱,事务A和事务B分别对这两张卡扣款
最后发现liming只有40块钱,却花出去60
解决方法一样很简单,给读加锁就好
读操做加锁以后,事务B须要阻塞到事务A提交才能完成读取,而且读到最新的数据,不会再出现liming超额花钱的状况
Mysql的可串行化实现方式就是咱们上面介绍的可串行化的加锁实现方式(即两阶段锁),能够避免上面全部的并发问题,不过两阶段锁也存在下面的问题:
性能差,这个很显然,加锁限制了并发,而且带来了加锁解锁的开销
容易死锁
另外,可串行化还有其余实现方式:
串行执行
可串行化快照隔离(Serializable Snapshot Isolation,SSI)
咱们简单介绍一下
避免代码bug最好的方式就是不写代码
避免并发问题最好的方式就是没有并发
这种实现可串行化的方式就是真的让事务串行执行,即在单线程中顺序执行,所以以这种方式实现,自己就不会有并发问题,直接实现了可串行化
这种方式有时候会比并发的方式性能更好,由于避免了加锁这种操做的开销。好比redis的事务就采用这种方式实现
这种方式也有几个明显的问题:
不支持交互式查询:咱们在使用事务时,不少场景都是发起查询,而后根据查询结果,发起下一次查询,若是串行执行,系统执行事务的吞吐量(单位时间执行的事务数量)会受到很大影响,由于不少时间都消耗在查询结果传输这种网络IO上。所以,采用串行方式的数据库都不支持这种交互式查询的方式,若是须要在事务中实现一些业务逻辑,只能使用数据库提供的存储过程,好比在Redis中能够经过编写Lua脚原本实现
吞吐量受限于单核CPU:因为是单线程执行,系统的性能受限于单核CPU,不能很好地利用多核CPU
对IO敏感:若是数据须要从磁盘中读取,那么性能会由于磁盘IO受到很大影响
可串行化快照隔离就是在快照隔离的基础上作到了可串行化,主要思想是在快照隔离基础上增长了一种检测机制,当发现当前事务可能会致使不可串行化时,会将事务回滚。Postgresql的“可串行化”隔离级别采用了这种实现方式
咱们简单介绍一下这种检测机制:
事务之间的关系咱们能够用图结构来表示,图中的顶点是事务,边是事务之间的依赖关系,这就是多版本可序列化图(Multi-Version Serialization Graph,MVSG)
其中边是有向边,经过事务对相同数据的读写操做来定义,好比T1将x修改成1以后,T2将x修改成2,T1和T2之间的关系能够这样表示:
边的方向由T1指向T2表示T1在T2以前发生
事务之间可能发生冲突的依赖关系有三种,也就是图中边的种类有三种:
写写依赖(ww-dependencies):T1为数据写入新版本,T2用更新的版本替换了T1,则T1和T2构成写写依赖:
写读依赖(wr-dependencies):T1为数据写入新版本,T2读取了这个版本的数据,或者经过谓词读(经过条件查询)的方式读取到这个数据,则T1和T2构成写读依赖:
读写反依赖(rw-dependencies):T2为数据写入新版本,而T1读取了旧版本的数据,或者经过谓词读(经过条件查询)的方式读取到这个数据,则T1和T2构成读写反依赖:
由于读读并发没有任何问题,因此咱们这里没有读读依赖
由事务为顶点,上面三种依赖关系为边,能够构成一个有向图,若是这个有向图存在环,则事务不可串行化,所以能够经过检测环的方式,来回滚相关事务,作到可串行化。可是这样作开销比较大,所以学术界提了一条定理:若是存在环,则图中必然存在这样的结构:
所以能够经过检测这种“危险结构”来实现,固然,这样实现的话可能会错误的回滚一些事务,这里关于定理的证实以及危险结构的检测算法咱们就再也不介绍了,感兴趣的话能够看看相关资料
【1】Designing Data-Intensive Applications
【2】A Critique of ANSI SQL Isolation Levels
【4】数据库事务处理的艺术