聊聊数据库事务隔离级别(二)——如何定义隔离级别
1、说在前面
今天想和大家聊一聊数据库事务的隔离性到底想解决什么问题.我曾经对它的理解仅仅停留在事务有不同的隔离级别,而满足了这些隔离级别事务就能禁止不同的异常现象发生.也会有一种模糊的印象,事务隔离性是为了解决多个事务的并发问题(这里先就不纠结并发与并行的语意了),而且它也许用了锁来解决这个问题.然后就是一堆锁的概念,行锁、表锁、谓词锁等.又或者进一步去解了它的锁概念,发现还有共享锁、排他锁、U锁等等.又或许还了解过MySql的可重复读,发现MVCC是它的实现基础.但是即使堆积了这些概念,我还是不清楚隔离性到底想表达的是一个什么样的期望?同时为什么会定义这些隔离级别,而不是其他的隔离界别?而这就是这篇文章想讨论的问题,希望读完这片文章之后,你对隔离级别的定义有一个更好的认识.
2、或许你该先纠正对ACID的认识
在解释第一节(说在前面)中的问题之前,我们先来重新认识一下ACID.不知你是否有这种感觉,除了D(持久性)的概念是比较容易理解之外,A(原子性)、C(一致性)和I(隔离性)概念的边界似乎并不是很清晰(原子性,一致性,隔离性不都是为了解决并发的一致性问题吗?).这里的主要问题是我们时常将不同领域的概念混淆在了一起.因为如果你了解并发编程,很容易错误地将A和C的概念映射到与锁保护下的原子性和一致性概念.并发编程的锁原语有这么一个概念(这里更多的是java的并发编程,对与其他语言锁的原语概念应该也是一样的吧):锁具有原子性和可见性,线程对被锁(排它锁)保护的代码是独占的,并且代码中的数据变更(本地内存、缓存数据,对外设的数据不在它的管理范围内)对其他线程不可见,直到锁释放.而事务的A是如何解释的呢:A表示的是一个事务的执行是原子的,要不全部执行,要不全部不执行,不存在执行一部分.对比一下可以发现,事务的A其实并不关心可见性问题,而可见性恰恰是事务的I需要去解决的;同时事务A也不强调独占性(注1).事务的A关心的是当事务执行过程中失败了,需要进行回滚,将数据状态恢复到事务执行之前,就好像事务没有执行一样.这这里我们甚至可以说,事务的A表现了事务的可终止能力,数据库中往往用undo日志来实现该能力(注2).
上述说明了A强调的是事务的回滚能力,I强调的是事务的可见性问题.那么C呢?抱歉,C的概念真的很模糊,且不说一致性这个词被用在各种不同的领域(数据库事务一致性、并发编程锁一致性、分布式副本一致性等等),但事务的一致性其实想表达的是在数据库的支持下,从应用程序看到了预期的数据.所以说C不应该和A、I、D在同一层面的,而应该是A、I、D一起给应用程序提供了一致性的体验:(A、I、D)-->C.
D是一个比较容易理解的概念,即当事务提交后,数据被储存到持久性介质中,不会再丢失.不过这种保证也很难,比如磁盘损坏了.即使你用了RAID,甚至是分布式方案,都不可避免存在由于程序bug或者人为、自然灾害等原因导致数据丢失的概率,只不过概率太小或者有些问题本就不在数据库的能力范围之内了(比如地球被毁灭了.不过这样也很酷,毕竟数据库厂商可以说自己的产品和地球共存亡了).不过另一个问题却是很现实的,那就是内存数据刷入磁盘的性能问题.最可靠的方式就是每个事务提交之后都强制执行fsync,保证数据刷入磁盘后再回复用户事务提交成功,但是这样的性能却是不可接受的.当然我们也可以定期调用fsync,这样性能就大大提高了,但是带来了数据丢失的风险.设想事务写入缓存页并回复了客户端,但是在下一次执行fsync之前,操作系统崩溃了,那么在此期间的事务提交都将丢失.当然我们也可以用批量提交这种方式,事务写入缓存页后不回复用户,等待收集了足够多的事务后,一次性刷入磁盘,然后同时回复用户提交成功.这里可以增加更多的智能策略,更具系统当前的吞吐量,系统资源使用情况,用户超时情况来决策是否使用批量提交以及批量的数据量.
至此,ACID的概念算是介绍完了.接下来我们先了解一下事务怎么样才算满足隔离性.
3、你是否可以接受串行执行
在第2节中我们说过,锁具有独占性,线程就像排队经过被锁保护的代码;那么试想,事务如果是通过T1||T2||T3这种串行的方式被执行,那么事务之间并不存在并发竞争,也就不会出现脏读这样的并发可见性问题.在这样的事务执行(调度)方式下,事务是满足隔离性的.但是这样的性能却是很糟糕的(现在有些优秀的数据库也在采用串行的方式,如redis(注3)),完全发挥不了并发的优势.为了避免串行(注4)这种糟糕的方式,或许我们该仔细分析一下,是什么原因导致的并发问题的.
拆解事务,无非存在两种操作,读(r),写(w).而对同一个对象o的并发操作,除了r-r外,r-w,w-r,w-w都是会存在因果关系的(如果交换执行顺序,最终结果会不一样),也称为冲突.为了更加形式化地表现出这种冲突,我们采用对象版本变更的方式.<o,1>表示1号版本对象o.图1表示了对象经过事务的读写操作后的就像版本变更情况.图2通过对象版本变更说明事务在并发情况下会出现的异常现象(注5).
图1:对象版本变更
图2:对象版本变更解释事务异常现象
除了幻读外,图2基本已经涵盖了事务隔离界别需要解决的问题(至少ANSI SQL-92标准是这么定义的,当然后续文章会介绍这个标准存在问题).这里还有一个意外结论,异常现象的图中都存在环(这里可以给一个其他结论,只要图中存在环,那么就是存在异常现象的.可以这样解释:图中对象版本的流动其实是事务之间的因果关系,当存在环时,一个事务既是另一事务的因,又是它的果.这样的因果关系是混乱的).因此隔离性的任务是避免这种环产生,或者说是出现事务之间混乱的因果关系发生.而阻止这些问题最早期的方法就是二阶段锁.
4、SQL-92隔离级别的定义
根据我的理解,SQL-92标准中定义的隔离级别是通过逐级破坏两阶段锁来定义的.为了更好地说明这个问题,这里先在贴一下SQL-92的隔离级别定义.
4.1 异常现象
-
P1——脏读 事务T1写对象o, 但在T1还未提交时事务T2读对象o; 由于异常T1进行回滚, 最后. T2读到了不存在的对象o -
P2——不可重复读 事务T1读对象o, 事务T2写对象o完成提交, 此时T1再次读对象o, 最后在T1中, 先后两次读到的数据不一致; -
P3——幻读 事务T1查询集合满足条件P(where), 事务T2插入满足条件P的一条记录o并提交, 事务T1再次查询集合满足条件P; 最后导致T1先后两次读到的数据不一致;
4.2 隔离级别
图3:事务隔离级别
5、二阶段锁
二阶段锁可以实现隔离性(当然为了解决幻读,还需要引入谓词锁);二阶段锁的定义和其实现隔离性证明网上资料很多,这里不在赘述; 这里想重点介绍一下如何通过二阶段锁来定义隔离级别.为了说明问题, 这个还是简单介绍一下二阶段锁.
5.1 二阶段锁的定义
二阶段锁有两个概念,分别是合规事务和事务二阶段.
-
事务合规: 如果事务中所有的读写都有锁(lock)覆盖,并且每一个lock动作都有一个相应unlock操作, 那么该事务就是合规的. 图4说明了这是一个不合规事务
图4:不合规事务
-
事务二阶段: 如果所有的lock操作都在unlock操作之前, 那么该事务(封锁策略)是二阶段的. 因此整个事务可以分为加锁阶段和解锁阶段.
图5:锁的两个阶段
5.3 隔离级别
通过对二阶段锁的破坏,我们可以整理出图6.其中1级就是读未提交(在该级别下会存在脏读),2级是读已提交,3级是可重复读.而0级比1级存在更多隔离性问题,甚至存在脏写.注意4级要解决的是幻读问题,它是通过二阶段谓词锁实现的.原理和二阶段锁一样,只是谓词锁锁的不是单个对象,而是一个对象范围(SQL中满足WHERE条件的对象范围).
图6:通过二阶段锁定义隔离级别
至此,我想你对事务的隔离性以及隔离级别的划分应该会有一个更加感性的认识了.有任何问题欢迎留言一起探讨.
6、注
(1) 独占性其实是并发领域(或者分布式领域中)对系统可线性化的最简单模型,而这个简单性背后的代价是性能,这是数据库不能接受的.
(2)undo除了用于事务回滚,很多时候也被用于实现MVCC.
(3)单核并发的目的除了在人机交互中给你提供一种多应用同时运行的错觉之外,另一个更重要的原因是CPU计算能力与IO速率之间的严重不匹配.但是如果将数据全部加载到内存中,那么可以明显缓解这种不匹配.因此通过单线程也可以比较容易跑满CPU.而且还避免了线程上下文切换的成本.当然这种方式无法充分利用多核,不过却可以非常简化系统设计;
(4)对于一致性问题, 很多时候我们需要的是因果一致性,而不是线性化,而串行就是在时间上是线性化的;线性化一定满足因果一致性,而因果一致性一般不是线性化;例如User1和User2编辑个人信息,除了唯一Id外,其他信息一般都是不存在因果关系的,那么它们之间的完全可以并发进行;
(5)更新丢失和脏写需要区分一下,如果图中T1已经提交,那么属于更新丢失;如果未提交,那么应该叫做脏写;
7、参考资料
[1]《事务处理.概念与技术》
[2]《数据密集型应用系统设计》