当前位置: 首页 > 科技观察

说说数据库的事务隔离

时间:2023-03-20 02:21:29 科技观察

写的这两年,分布式数据库技术加速发展。但是由于金融行业技术生态的局限性,周围很多同学对它的了解并不深刻,所以高性能、高可靠这一利器在系统设计中往往是缺失的。Ivan希望通过一系列的文章与大家交流和探讨,加深我们对分布式数据库的理解。本文是该系列文章的第一篇,主要讨论事务管理中的隔离,厘清相关概念和关键技术,为后面讲解分布式数据库的事务管理做铺垫。我们就称它为前传吧。正文我们首先从定义开始,事务管理包括原子性、一致性、隔离性和持久性四个方面,即ACID。所有的数据库专着都会给出这四个特性的定义。在本文中,我们引用了JimGray的定义。JimGray是交易处理方面的大师,本文的大部分内容来自他的专着和论文。为了避免翻译带来的歧义,这里直接引用原文。原子性:要么发生事务的所有更改(写入和发送的消息),要么什么都不发生。一致性:事务保持存储信息的完整性。隔离:并发执行的事务看到存储的信息,就好像它们是连续运行的(一个接一个)。持久性:一旦事务提交,它所做的更改(写入和发送的消息)将在任何系统故障中幸存下来。在上面隔离(Isolation)的定义中,我们可以发现它的目标是让并发事务的执行效果与串行一致性一致,但是在具体的技术实现上,往往需要在并发能力和序列化效果之间进行平衡,两者很难兼顾。平衡的结果就是会出现违反串行效应的现象,即异常(Phenomenon)。一般来说,隔离级别的提高伴随着并发能力的下降,两者呈负相关。当谈到隔离级别时,各种数据库都参考ANSISQL-92标准隔离级别。我们来看看它的具体内容。ANSISQL-92IsolationLevelsANSISQL-92可能是第一个根据异常现象来定义隔离级别的方法,并没有将隔离级别绑定到具体的实现机制上。隔离的实现可以是基于锁的,也可以是无锁的(lock-free),兼容后续的技术发展。该标准根据三个异常将隔离分为四个级别,如下所示。脏读,事务(T1)中修改的数据项在没有提交的情况下被其他事务(T2)读取,T1进行Rollback操作,T2刚刚读取的数据实际上并不存在。不可重复读,T1读取数据项,T2修改或删除其中的数据,Commit成功。如果T1再次尝试读取数据,会得到T2修改后的数据,或者发现数据已经被删除。这样T1在一次事务中在相同的条件下读取了两次,结果集的内容发生了变化或者结果集的数量减少了。幻读,T1使用特定的查询条件得到一个结果集,T2插入新的数据,这些数据满足T2刚刚操作的查询条件。T2提交成功后,T1再次执行同样的查询,此时得到的结果集增加。很多文章都结合数据库产品来说明上述异常现象的例子和处理机制,本文不再赘述。有兴趣的同学可以参考文末链接[1]。ANSISQL-92标准早在1992年就发布了,但无论是当时还是后来,都没有被主要的数据库制造商严格遵循。部分原因可能是标准过于简化,与实际应用有一定程度的脱节。JimGray等人在1995年发表了论文《ACritiqueofANSISQLIsolationLevels》(本文简称Critique[2]),对隔离级别进行更全面的阐述可以帮助我们加深理解。CritiqueIsolationLevelsCritique在ANSISQL-92中提出了两个问题。第一,自然语言方法定义的异常不严格,导致遗漏了一些同质异常;二是一些典型的异常情况没有涵盖,导致明显缺乏隔离级别。因此,文章扩展了ANSISQL-92(编号为A1/A2/A3)的三个异常(编号为P1/P2/P3),并增加了其他五个常见异常。限于篇幅,这里只说明两种异常现象。丢失更新丢失更新(LostUpdate)是一个典型的数据库问题。因为太重要了,所有主流数据库都解决了这个问题。这里我们将使用稍微修改的操作作为示例。我们使用MySQL进行演示,创建表并初始化数据:createtableaccount(balanceint,namevarchar(20))ENGINE=InnoDB;insertintoaccountvalues(50,'Tom');T1T2开始;开始;从name='Tom'的帐户中选择余额到@bal--------------------@bal=50从name='Tom'的帐户中选择余额到@bal------------------@bal=50updateaccountsetbalance=@bal-40wherename='Tom';犯罪;更新账户设置余额=@bal-1wherename='Tom';犯罪;以上操作中,T1和T2串行执行效果是扣了两次余额,分别是40和1,最终值为9,但是并行最终值为49,丢失了对T2的修改。我们可以发现Lostupdate的本质是T1事务读取数据,然后T2事务修改并提交数据。T1根据过期数据再次修改,导致T2的Modifications被覆盖。ReadSkewRead偏序(ReadSkew)是在RC层面遇到的问题。如果数据项x和y之间存在一致性约束,则T1先读取x,然后T2修改x和y然后提交,然后T1再次读取y。T1得到的x和y不满足原来的一致性约束。MySQL默认隔离级别为RR,我们需要手动设置为RC并初始化数据:setsessiontransactionisolationlevelreadcommitted;insertintoaccountvalues(70,'Tom');insertintoaccountvalues(30,'Kevin');T1T2开始;开始;select*fromaccountwherename='Tom';------------------余额名称70Tomselect*fromaccountwherename='Tom';----------------------balancename70Tomupdateaccountsetbalance=balance-30wherename='Tom';更新账户设置余额=余额+30其中name='Kevin';犯罪;select*fromaccountwherename='Kevin';----------------------余额名称60Kevin提交;初始数据Tom和Kevin的总账户为100,在T1事务中两次读取得到的总账户为130,显然不满足前面的一致性约束。在补充了这些异常之后,Critique给出了一个新的矩阵,比ANSI更完整,更适合真实的数据库产品。考虑到序列化效果和并发性能的平衡,主流数据库一般都有一个默认的RC和RR之间的隔离级别,有的提供了Serializable。特别是,ASNISQL-92和Critique隔离级别都不能确保直接映射到实际数据库中的同名隔离级别。SI&MVCC快照隔离(SI,SnapshotIsolation)是讨论隔离时常用的术语。它可以用两种方式来解释。一种是具体的隔离级别,由SQLServer和CockroachDB直接定义;另一个是隔离机制。用于实现相应的隔离级别,常用在Oracle、MySQLInnoDB、PostgreSQL等主流数据库中。多版本并发控制(MVCC,multiversionconcurrencycontrol)是通过记录数据项的历史版本来提高系统对多事务访问的并发处理能力,比如避免单值情况下写操作对读操作的锁(单值)存储排斥。MVCC和锁都是实现SI的重要手段,当然也有无锁的SI实现。以下是Critique描述的SI运行过程。在事务开始的时刻(标记为T1)获取一个时间戳StartTimestamp(标记为ST),数据库中所有数据项的每个历史版本记录对应的时间戳CommitTimestamp(标记为CT)。T1读取的快照由CT小于ST的所有数据项版本和最近的历史版本组成。由于这些数据项的内容只是历史版本,不会被再次写操作锁定,所以不会出现读写冲突。快照操作中的读者永远不会被阻塞。ST之后其他事务的修改对T1是不可见的。T1提交的那一刻,会得到一个CT,保证大于此刻数据库中存在的任何时间戳(ST或CT),这个CT会作为数据项的版本时间戳在坚持期间。T1的写操作也体现在T1的快照中,可以通过T1中的读操作再次读取。在T1commit之后,修改将对那些持有ST大于T1CT的事务可见。如果有另一个事务(T2),其CT在T1的操作区间[ST,CT]之间,并且T1写了相同的数据项,则T1中止,T2提交成功。这个特性叫做First-committer-wins,可以保证不会出现Lostupdate。实际上有些数据库会调整为First-write-wins,将冲突判断提前到写操作,以降低冲突成本。这个过程不是某个数据库的具体实现。事实上,不同的数据库有非常不同的SI实现。比如PostgreSQL会将历史版本和当前版本保存在一起,并通过时间戳区分,而MySQL和Oracle都将历史版本保存在回滚段中。MySQL的RC和RR级别都使用SI。如果当前事务(T1)读操作的数据被其他事务的写操作锁定,则T1转向回滚段读取快照数据,防止读操作被阻塞。但RC的快照定义与上述描述不同,还包含了T1执行过程中其他事务提交的最新版本[6]。此外,我们还有另一个重要发现,即时间戳是生成SI的关键要素。在单机系统中,唯一的时间戳比较容易实现,但是如何在跨节点、跨数据中心甚至跨城市部署的分布式系统中建立唯一的时钟就成了一个非常复杂的问题。我们先留个伏笔。这将在以后的专题文章中讨论。SerializableVSSSISI非常有效,即使在TPC-C基准测试[5]中也没有异常现象,但实际上SI并不能保证完整的序列化效果。Critique指出SI无法处理A5B(WriteSkew,写偏序),如下图:WriteSkew写偏序(WriteSkew)也是一致性约束下的异常现象,即两个并行事务是根据自己读取到的数据集覆盖另一部分数据集。在序列化的情况下,无论两个事务的顺序如何,最终都会达到一致的状态,但这在SI隔离级别下是做不到的。下图中的“黑白球”经常被用来说明写偏序的问题。如何实现真正的连载效果?事实上,早期的数据库已经通过严格两阶段锁定协议(S2PL,StrictTwo-PhaseLocking)实现了完全可序列化隔离(SerializableIsolation),即读操作中的数据块对应写操作,而写operationblock所有操作(包括读写操作)。如果阻塞导致循环构成死锁,则需要进行回滚操作。S2PL的问题很明显。在竞争激烈的场景中,阻塞和死锁会导致数据库吞吐量下降和响应时间增加,所以这种序列化无法应用到实际的生产环境中。直到SSI的出现,人们终于找到了具有实用价值的序列化隔离方案。序列化快照隔离(SSI,SerializableSnapshotIsolation,又译为序列化快照)是在SI改进的基础上实现Serializable级别的隔离。SSI由MichaelJamesCahill在其论文“SerializableIsolationforSnapshotDatabases”[3]中提出(该论文获得2008年Sigmod最佳论文奖,文末提供2009年论文完整版[4]文章.有兴趣的同学可以深入研究)。SSI保留了SI的很多优点,特别是读不阻塞任何操作,写不阻塞读。事务仍然在快照中运行,但增加了对事务之间读写冲突的监控,以识别事务图中的危险结构。当一组并发事务可能产生异常时,系统会进行干预,通过回滚部分事务来消除异常的可能性。虽然这个过程会导致部分事务被错误回滚(不会导致异常事务被误杀),但可以保证异常的消除[3]。从理论模型来看,SSI的性能接近于SI,远优于S2PL。2012年,PostgreSQL在9.1版本实现了SSI[7],可能是第一个支持SSI的商用数据库,验证了SSI的实现效果。CockroachDB还从Cahill的论文中获得灵感,将SSI作为其默认隔离级别。随着技术的发展,SI/SSI已经成为主流数据库的隔离技术,尤其是后者的出现,不需要开发人员通过代码显式加锁来避免异常,从而降低了人为错误的概率。在分布式数据库的相关章节中,我们会进一步深入探讨SSI的实现机制。作者:王雷(Ivan),目前在中国光大银行领域担任架构师,曾在IBM全球咨询服务部从事技术咨询工作,在数据领域有十余年的研发和咨询经验场地。目前负责全行数据域系统的日常架构管理、关键系统架构设计和内部研发。个人公众号:金融专家。