事务作为传统关系型数据库支持的重要特性,诚然一部分NoSQL数据库为了在分布式场景下可用性和性能的考虑都不在支持事务,但是代表未来趋势的NewSQL数据库在分布式场景下依然保留事务并支持ACID特性,这说明事务在数据库实现中依然是一个重要的功能特性。作为简化业务开发的重要模型工具,事务依然值得我们深入的研究、借鉴。
事务的ACID属性,即原子性、一致性、隔离性和持久性是保证事务正确可靠执行的基础。本文不会涉及事务的方方面面,而是重点关注ACID中的隔离性特征。内容较多我会分别用两篇文章讲解。第一篇我会深入浅出的说清楚什么是事务的隔离性?隔离性的目的是什么?数据库为什么有多种的事务隔离级别的实现?各种的隔离级别有什么不同?不同的隔离级别会带来哪些问题?这些问题的又如何解决?下一篇文章中我会以常见的数据库为例,针对上篇中提到的各种问题场景和相应解决方案做具体示范,在实践中加深理解。通过本文,我希望你不仅能在理论层面详细理解事务隔离性的来龙去脉,做到心中有数,也希望你能举一反三,结合文中场景用例解决现实开发中的遇到的问题。文章有略长,希望你能耐心读完。
什么是事务
我们将一组对数据的操作封装为一个原子单元。事务中的操作要么全部成功(事务提交),要么全部失败(事务中止或回滚),不存在部分成功部分失败的情况。 为什么要有这样的定义呢?数据库作为一个软件系统和其他软件一样也会面临诸多异常场景:数据库的进程随时可能崩溃,数据库所在机器随时可能宕机,网络连接也随时可能延时或失效(随时不等于任何时刻,只是强调故障发生的可能性)。如果没有事务性的保障,上层应用的开发人员需要自己处理异常情况。假如我们希望一次性向数据库插入5条数据,插入过程正数据库出现了异常,在没有事务保证的情况下我们不知道哪些数据插入成功哪些失败,就没有办法通过简单的重试进行处理异常。
事务的ACID特性
上面的定义只侧重了事务的原子性保证,完整的事务特性就是人们常说的ACID属性。ACID就是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)的首字母简写,ACID是保证事务正确可靠的必备条件。
原子性
如事务定义所述,原子性将事务中多个操作组成一个原子单元,不可被拆分,所有操作要么全部成功,要么全部失败,不存在部分成功部分失败的状态。
隔离性
通常情况下数据库为多个客户端提供服务,当不同的客户端同时访问相同的数据的时候就可能产生并发问题。当然并发访问不一定都会导致并发问题,关键还要看是否触发了特定的边界条件。有时这种不确定性恰恰就是导致我们上层应用代码bug的原因。隔离性的目的就是消除这种不确定性,给我们一个简化的编程模型方便开发人员实现业务逻辑。隔离性的目的是为了解决多个事务并发访问相同数据相互影响的问题。严格的隔离性保证多个事务运行的结果像事务串行化一个一个执行一样(不是不允许底层实现并发运行,而是运行结果要符合串行化要求)。这种串行化的确定性保证有助于开发人员预判并发事务执行顺序和结果,方便开发人员正确的实现自己的业务需求。
严格的隔离性保证虽好,但是一味阻止事务并行执行对性能的影响我们也不能小窥。所以数据库在实现隔离性的时候除了严格的隔离性之外还提供了不同的级别,就是我们常说的事务隔离级别。不同的事务隔离级别允许一定的并发操作,也带来了一定程度的性能提升,但是也引入了特定边界条件下的并发问题。实际落地中我们需要再在事务隔离级别、性能要求和业务逻辑实现难易程度等各个方面权衡利弊。正确决策的前提是我们要充分理解不同隔离级别下特定的边界条件和他它所引发的并发问题并能正确处理,这也是本文的重点。
一致性
一致性是指数据库处于应用程序所期望的预期状态。 更进一步说明,事务执行前是一个有效的状态,事务的操作如果没有违背约束,那么事务执行之后也应该是一个有效的状态。这里的关键是一致性是应用程序所期望的状态,所以理应由应用程序自行保证。数据库只能保证很少的通用约束:例如外键约束、唯一主键约束、唯一性约束等。
用我们最常见的转账场景举例,小明和小红两个账户进行转账操作,转账前小明、小红的账户金额总和要和转账后两人的账户金额总和一致。显然这样业务逻辑数据库无法保证,只能通过业务代码有由开发人员自己实现,实现的方法就是将转出和转入两个操作放入同一个事务中。虽然事务无法直接提供业务逻辑一致性,但是我们可以通过事务的原子性和隔离性保证在异常场景和并发场景下事情依然像我们想象的方式进行,前提是我们处理好异常并正确运用隔离性。
持久性
持久性保证一旦事务提交成功,即使发生软件或者硬件故障,事务写入的任何数据也不会丢失。这是一个针对数据库的基本功能的可用性保障,毕竟数据库就是用来存储数据的,我们都不希望已经存入的数据丢失。
总结来说,事务就是将多条操作组成一个原子单元,事务的原子性、隔离性和持久性分别从事务异常处理、事务并发运行和数据存储保证三个方面给出了一个逻辑简化的确定性的模型,应用这些特性开发人员可以简单、方便的实现各种业务场景中的一致性需求。
虽然原子性和持久性底层实现可能颇为复杂的,但是它们的定义很好理解。但是隔离性理解起来就有一定难度,应用上需要多加小心,否则一不留神就会出问题。
标准事务隔离级别
接下来我们就开始本文的重点内容。ANSI/ISO SQL92定义的事务隔离级别有四种,他们分别为读未提交(Read uncommitted)、读已提交(Read committed)、可重复读(Repeatable read)和序列化(Serializable)。数据库厂商也参照标注做了自己的实现,但并不是所有数据库都支持上文所述四种隔离级别,例如Oracle只支持读已提交和序列化两种。MySQL和PostgreSQL虽然支持这四种隔离级别,但是由于底层实现方式差异,相同隔离级别的行为也略有不同,这点我们在实践篇在深入研究。所以,在一个新数据库使用之前一定要详细阅读相关的说明文档,切勿将以往数据库的使用经验平移到不同数据库。这也从另一个角度说明了事务隔离性的复杂性。虽然数据库有不同的实现方式,但是都是以标准隔离级别的定义作为理论基础和实现依据,万变不离其宗,一旦我们彻底理解了标准隔离级别,其他的具体实现也就不难理解了。
如果按照允许并发的程度给事务隔离级别排序的话,大致顺序如下:读未提交>读已提交>可重复读>序列化。前三个隔离级别都是弱隔离级别,允许一定程度的并发行为,当然也会出现各种边界条件下的并发问题,而最后一个是严格隔离级别,遵循串行化要求。下面我们将按照这个顺序讲解不同隔离级别在什么样的边界条件下产生哪些并发问题。
读未提交隔离级别
隔离性保证:一个事务可以读取另一个事务未提交的数据。看似什么都没有保证,其实也做了基本保证,我们后面介绍。
脏读
在当前隔离级别下,引入了我们第一个并发问题”脏读“,脏读的定义其实就是”一个事务读取到另一个事务还未提交的数据“。脏读听上去就有一种“坏味道”,感觉上我们不应该读取到事务执行过程中的临时数据,一来这种数据有可能在事务回滚的时候全盘作废,二来即使事务正常提交,这样的数据也有可能破坏应用希望的一致性需求。
我们用经典的转账场景说明,小明账户金额100元,小红账户金额100元,小明给小红转账50元,转账操作的一致性要求任何时刻小明和小红账户总金额保持一致均是200元。
假设有两个转账操作事务事务分别按照如下顺序并行操作:
时间线 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
事务1 | 小明账户转出50元 | 小红账户转入50元 | ||
事务2 | 读取小明账户余额为50元 | 读取小红账户余额为100元 |
注:示例中省略了事务开始和提交标记,所有事务的均在第一条语句执行前开启事务并在最后一条语句执行完成后提交事务,后续示例也做同样的处理。
从事务2角度来说,小明和小红账户总金额为150元(小红100元,小明50元),违反了应用希望的一致性要求。问题的关键在于事务2在时间线2的操作(加粗)读取到了事务1尚未提交的数据,发生了脏读。
这里我们再深入思考几点:
- 脏读是诱因,是它导致在两个事务以某些特定顺序(边界条件)并发执行的时候产生了超出预期的结果(两人账户总金额不等于200元)
- 破坏一致性的边界条件不只有示例中的一种情况,只要允许脏读,我们就能找到某种两个事务的并发执行的顺序(边界条件),从而导致执行结果超出预期。
- 允许脏读也不一定总会导致破坏一致性的结果,关键还要看两个事务执行的顺序是否达到了边界条件。
以上这些思考也可以扩展到后续的隔离级别、并发问题和边界条件的分析上。
如果我们不允许脏读,那么因它而生的各种破坏不一致性的边界条件(事务间特定的并发执行顺序)就不复存在了,事务再以相同的边界条件运行也不会产生破坏一致性的情况了。事务隔离级别就是采用这个思路来解决并发问题并减少事务并发运行过程中各种边界条件。理论上边界条件越少上层应用处理逻辑就越简单,越不容易出问题,但是性能影响就越大,反之亦然。
读已提交隔离级别
隔离性保证:一个事务只能读取另一个事务已经提交的数据。 相较于读未提交隔离级别,读已提交隔离级别只能读取已经提交的数据,这就从根本上杜绝了脏读问题。我们用上文中的脏读的边界条件示例验证一下。
时间线 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
事务1 | 小明账户转出50元 | 小红账户转入50元 | ||
事务2 | 读取小明账户余额为100元 | 读取小红账户余额为100元 |
因为修改小明账户的事务1未提交,所以事务2在读取的小明账户余额的时候不能看到最新的值而是旧值100元,两人账户总金额为200元,符合一致性需求。你也可以用你自己的脏读边界条件验证一下,看问题是否解决了(如果你的边界条件下依然有问题。别担心,那可能是你找的不是脏读的边界条件而是其他并发问题的边界条件,这些边界问题我们会在后面讲解)。
你可能好奇这种延时读取能力是如何实现的。因为这已经超出了本文的重点,我不做过多讲解,只给出简单的思路,感兴趣的同学可以自行研究。
- 通过数据加锁方式实现:在修改数据的时候加锁,一个事务修改的数据如果其他事务想读取,必须等到该事务提交之后,这样其他事务读取的数据一定是该事务提交之后的数据。显然这种方法写操作会阻塞读操作,本质上是阻止并发发生,性能损失较大,和弱隔离级别实现初衷相悖,故大多数数据库不采用这种方式。
- 通过写时复制方式实现:事务修改数据的时候产生一个副本,副本即为数据最新版本。该事务提交之前,其他事务读取旧数据值,该事务提交之后用副本数据替换旧数据。这样就可以在不阻塞其他事务读操作的情况下,实现只能读取到已提交数据的要求。本质上这种方法是一种空间换时间的思路,也是大多数数据库采用的方式。
不可重复读
有了读已提交这个隔离性保证,我们解决了脏读问题,看似万事大吉了,但不要高兴的太早,接下来我们介绍一个新的并发问题——不可重复读。顾名思义不可重复读的表现就是事务进行的过程中,如果对同一个值多次读取,每次读取的不一样,像不能重复读取一样。为什么多次每次读取的数据不同呢,显然就是在这个事务多次读取数据的过程中,有其他事务对相同数据进行了修改并且完成了提交,其实这也是不可重复度的边界条件。
举例说明:\
假设有一张用户表表中有一列用户名数据,两个事务按照如下顺序执行:
时间线 | 1 | 2 | 3 |
---|---|---|---|
事务1 | 读取某条数据的用户名值为小明 | 再次读取该条数据的用户名值为小红 | |
事务2 | 修改该条数据(和事务1读取数据相同)用户名值为小红 |
示例中对用户名两次读取行为完全符合读已提交事务隔离级别的要求。事务1的第二次读取时因为事务2已经提交了,所以读取到了最新的值。你是不是觉得这样也没什么问题,的确,现实业务很少有对一条数据读取两遍的情况,即使有的话也不一定会出什么问题。所以这里又说明了一个问题,即使代码遇到了边界条件上的并发问题,也不一定就是bug,关键要看业务逻辑,代码的执行逻辑是否符合我们的业务预期。
接下来我们继续使用转账场景来举例说明,背景与前文相同,为了方便你阅读我粘贴过来:小明账户金额100元,小红账户金额100元,小明给小红转账50元,转账操作的一致性要求任何时刻小明和小红账户总金额应该一致,均是200元。
两个事务按照如下顺序执行:
时间线 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
事务1 | 读取小明账户余额为100元 | 读取小红账户余额为150元 | ||
事务2 | 小明账户转出50元 | 小红账户转入50元 |
从事务1的角度看两用户账户的余额总和为250元,不符合一致性需求。看似我们可以通过再读一次小明的账户余额来解决:
时间线 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
事务1 | 读取小明账户余额为100元 | 读取小红账户余额为150元 | 再次读取小明账户余额为50元 | ||
事务2 | 小明账户转出50元 | 小红账户转入50元 |
但是我们犯了因果错误,两个事务是的并发顺序是随机的、不可预测的,我们无法预判每次事务执行的并发顺序,我们也无法确定哪条数据需要重读?重读的位置在哪?我们只是站在了上帝视角通过回溯的方式解决问题,但是我们只是解决了过去已经发生的问题而不是未来将要发生的问题。
在实际业务场景中,在一个事务中对一张大表的全表扫描或者聚合操作在备份和统计场景中比较普遍。这种需要一次读取大量数据的事务(只读长事务)会形成一个较长的窗口期,如果其他事务在这个窗口期内对读取的数据进行了修改就很有可能触发不可重复读的边界条件,产生数据不一致的问题。
可重复读隔离级别
隔离性保证:首先继承了读已提交隔离级别的隔离性保证(也就是说可重复读不存在脏读问题)。然后增加了自身保证,一个事务对一条数据进行多次读取,无论是否有其他事务对相同数据更新并提交,每次读取的值都一样。
可重复读的隔离性保证似乎就是为不可重复读问题量身制造的。我们再重放上文中的示例:
时间线 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
事务1 | 读取小明账户余额为100元 | 读取小红账户余额为100元 | ||
事务2 | 小明账户转出50元 | 小红账户转入50元 |
按照可重复读的保证,即使在事务2已经完成了对小红的转账操作并提交,但是小红的账户余额依然为事务开始之初的100元,符合一致性要求。
从表面上看小红的账户金额就像是在事务1开始的时候就被打了快照一般,无论其他事务如何更改,在事务1任何时刻读取到的都是同一个值。其实这也是大部分数据库对可重复读隔离级别的实现方式,数据库对数据的每次修改保留快照,每个数据都从数据库的一致性快照读取,开始的时候读最新提交的快照,即使事务执行过程中数据被另一个事务更改,但是事务只能看到特定时间点(事务开始的时间点)生成的快照数据(旧数据)。以上就是大名鼎鼎的多版本并发控制(MVCC)的实现原理。同读已提交隔离级别的实现原因相同,之所以放弃加锁的方式而采用数据副本的方式,也是一种空间换时间的思路,既提供了隔离性保证又允许一定程度的并发以降低对性能的影响。
讲到这里细心的同学可能发现了一个问题,上面提到的所有示例中有一个规律,都是一个只读数据事务搭配一个修改数据事务,我们所有的并发问题都出现在数据读取事务和数据写入事务操作相同数据的场景下。那么多个数据写入事务操作相同数据的时候会不会产生并发问题呢?答案是的肯定的,我们继续讲解。
覆盖更新和写倾斜
如果你熟悉多线程编程,你一定会对接下来的介绍似曾相识。类似于a+=1、a++等操作并非原子操作(多线程中的原子性和事务机制中的隔离性类似,为了防止多个线程同时操作共享变量而造成操作结果与多线程串行执行不同),而是由读取-设置-写回这三个操作组合构成。多线程递增计数器的代码示例经常作为用来演示多线程并发问题的场景,产生并发问题的根本原因就是多个线程在执行共享变量的读取-设置-写回操作时任意穿插组合。数据库中的数据可以类比为共享变量,而并发运行的事务可以类比为多个线程,所以读取-设置-写回模式的事务同样可能出现并发问题。
还用转账场景作为示例,小明账户金额200元,小红账户金额100元,小明给小红分两次转账,每次转出50元,转出操作的一致性要求两次转账后小明的账户余额应该为200元-50元✖️2=100元。
时间线 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
事务1(第一次转账操作) | 读取小明账户余额为200元 | 计算出转出后小明账户金额为200元-50元=150元,将小明账户金额设置为150元 | ... | ||
事务2(第二次转账操作) | 读取小明账户余额为200元 | 计算出转出后小明账户金额为200元-50元=150元,将小明账户金额设置为150元 | ... |
注:1.通常转账操作都不会采用读取再写回的方式实现,示例只是用于说明并发问题。2.两个事务都省略的给小红转入的操作。
如果按照上面是事务执行顺序,两次转账完成后,小明账户的余额为150元而不是200元,似乎有一次转账行为没有发生,它的结果丢失了。造成这个问题的关键是转账操作要先读取小明账户的金额后,根据这个金额计算出小明转账后的金额再写回。从事务1读取数据到写回数据的时间窗口内,事务2已经完成同样的转账行为,更改了小明的账户余额。这样事务1中小明的账户余额数据已经不是最新了,基于过期的条件去计算并写回的数据就破坏了应用的一致性,造成其事务2的转账行为被覆盖的结果。
我们在举一个示例进一步说明,这次我们用创建账户场景举例说明,假设银行允许一个用户有多个账户,小明在银行里已经有了两个账户——账户1和账户2,小明想删除账户,但是银行规定一个用户最少保留一个账户。所以删除账号操作的一致性要求就是无论小明如何操作,小明都有一个账户保留。假设小明同时执行了两次删除操作,事务执行顺序如下:
时间线 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
事务1(删除账户1) | 读取小明账户总数为2条 | 账户数量大于1,符合删除条件,删除账户1 | ||
事务2(删除账户2) | 读取小明账户总数为2条 | 账户数量大于1,符合删除条件,删除账户2 |
两个删除操作最终的执行结果是小明的账户1、2均被删除,违背了至少保留一个账户的一致性原则。原因和上一个示例相同,事务首先要读取数据最为下一步操作的前提条件,然后根据前提条件执行下一步操作并将数据写回数据库,而在数据读取和写回的时间窗口期,首次读取的数据已经被其他事务修改,作为判断条件的数据已经改变了,所以后续进行的操作就有可能破坏了应用的一致性保证。与上第一个示例不同的是,第一个示例两个事务操作的是相同的数据(小明账户金额),而本示例两个事务操作的是不同的数据(账户1和账户2)。
本节第一个示例展现的并发问题和边界条件我们称之为覆盖更新,第二个示例我们称之为写倾斜。其实他们都是由幻读这种现象引起的,我们把一个事务的写入改变了另一个事务的查询结果的现象称之为幻读。更具体一点,如果两个事务读取相同的一组数据,然后更新其中的一部分(此时在特定边界条件下可能发生幻读现象),如果两个事务更新不同的数据,有可能发生写倾斜(第二个示例),如果两个事务更新相同的数据,有可能发生覆盖更新(第一个示例)。这么看来,可重复隔离级别只能应对只读事务的幻读现象,碰到读-写事务就无能为力了。
这样解释可能过于理论化,不方便日常应用,在这里我简化一下。如果发现事务中出现读取-写回的模式,具体就是说我们遇到先读取数据并据此做出判断后再将相关数据写回的场景就要加倍小心,考虑是否会发生覆盖更新或者写倾斜问题了。如果按照这个原则去判断,日常的业务场景中还是比较常见的,例如新建用户时用户名唯一、会议室预定(同一时段同一个会议室只能被一个人预定)、电影票选座(同一场电影同一个座位只能被售卖一次)、银行创建用户账户(大部分银行只允许一个用户有一个一类账户)等场景。
解决方法
既然当前隔离级别无法解决这些问题。那我们如何应对呢?在这里我们只做一个简单介绍,细节部分交给实践篇。
原子写入操作
既然对于读-写事务问题出在读写操作不能保证原子性上,那如果将这两个操作合并为一个不能被分割的原子单元,就可以解决问题了。数据库提供类似的语法支持,如MySQL中执行update ... set value=value+1操作,因为写操作锁定的缘故(写操作加锁原因可以参照后面脏写的内容),value的读取和写入操作就变成一个原子单元,其他事务无法并行打搅了。Redis也提供incrby、decrby、setnx等方法支持原子递增、原子递减和原子赋值等操作。但是这种方式只适用于覆盖更新问题,对于写倾斜就无能为力了。
显示加锁
与原子写入的解决思路相同,都是通过阻读-写事务的并发来解决问题,不同的数据库提供不同的加锁机制,我们将在实践篇详细说明。我这里要补充一句,加锁不仅影响性能还有死锁的风险,需要小心处理。
原子比较和设置
作为一种无锁化编程的方式,Compare And Swap可以提供一种乐观的方式应对并发。我们可以在MySQL上采用如下方式模拟CAS操作:update ... set value='new value' where value='old value',只有当value没有变化才能执行更新出操作,如果在读取数据和写入数据的窗口期数据发生变化,更新操作会失效,我们可以选择给客户端抛出异常或者自己重试处理。
数据库自动检测覆盖更新
类似于死锁检测一样,某些数据库可以检测出覆盖更新问题并通知客户端处理。但是暂时还没有数据库可以检测出写倾斜异常。
使用序列化的事务隔离级别
序列化隔离级别作为事务隔离性的最强保证,完全满足事务隔离性定义——保证多个事务的执行结果同事务串行化一个个执行一致。串行化执行当然就覆盖更新和写倾斜问题。
序列化隔离级别
隔离性保证:保证多个事务的运行结果像串行执行一样。
作为理论最强事务隔离级别,可以规避一切并发问题,但是串行化带来的性能损失我们也不能小觑。不同的数据库有不同的序列化实现方式,像Redis真的采用单线程串行化执行事务的方式来实现(最新版本的Redis已经支持多线程),有些数据库采用悲观的方式通过加锁来阻止事务并发,而另一些采用乐观的方式实现,他们可以在事务运行过程中检测并发现破坏序列化隔离性的情况并报告给客户端处理。虽然不是所有的实现都采用严格串行化而不允许并发的方式,但是相较于其他弱隔离级别,性能损失还是无法忽视的,而且我们也不会为了解决业务需求中少量的并发问题就采用这个隔离级别使其他事务全部串行化,通常我们会使用其他弱隔离级别对有可能出现问题的事务进行特殊处理(见覆盖更新和写倾斜的解决方法)。
在当前隔离级别下覆盖更新和写倾斜两个并发问题的边界条件执行演示我们放在实践篇中,你现在只要认为两个事务是串行执行的,一个事务提交完才可以运行下一个事务就可以了。
隔离级别小结
我们来总结一下不同隔离级别下不同并发问题的发生情况:
隔离级别/并发问题 | 脏写 | 脏读 | 不可重复读 | 覆盖更新 | 写倾斜 |
---|---|---|---|---|---|
读未提交 | 否 | 是 | 是 | 是 | 是 |
读已提交 | 否 | 否 | 是 | 是 | 是 |
可重复读 | 否 | 否 | 否 | 是 | 是 |
序列化 | 否 | 否 | 否 | 否 | 否 |
随着隔离级别对并发能力的限制逐步增强,并发问题在逐步减少。虽然文中我们只介绍了读未提交下脏读问题,但是显然读已提交下的不可重复读问题也会存在于读未提交下,文中并没有列出每个隔离级别所有的并发问题的边界条件示例,只列出了每个隔离级别比较典型的并发问题,同学们可以按照文中思路自行补全。
脏写
这里我们看到了一个新的并发问题——脏写,所有隔离级别都不会允许出现脏写情况。因为脏写是一个共性的问题,我放在最后讲解。
和脏读类似脏写的定义就是一个事务修改了另一个事物没有提交的数据,脏写会造成什么样的影响呢,我们举例说明,假设有两张表,表1和表2,两张表都有一列姓名字段,我们将修改表1中某一条数据姓名的操作和修改表2中某一条数据姓名操作放入一个事务中,那么当前的一致性期望就是无论如何修改表1和表2的被修改数据的名称一定相同,但是如果两个事务以下面的顺序执行:
时间线 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
事务1 | 修改表1中某条数据的姓名为小明 | 修改表2中某条数据的姓名为小明 | ||
事务2 | 修改表1中某条数据(和事务1修改数据相同)姓名为小红 | 修改表2中某条数据(和事务1修改数据相同)姓名为小红 |
执行结果是表1中某条数据的姓名为小红,表2中某条数据的姓名为小明,两者姓名不同,不符合一致性要求。之所以会发生这样的情况,是因为脏写允许一个事务对另一个事务未提交的数据进行修改,这样事务并行执行的结果就是以同一条数据最后一次修改为准,表1的最后一次修改来自事务2修改为小红,而表2的最后一次修改为事务1修改为小明。显然只是一个非常严重的问题,故所有事务隔离级别都不允许发生。数据库通常都是用加锁的方式来避免脏读:保证一个事务不能修改另一个事务尚未提交的相同数据,修改行为将发生在另一个事务提交之后。所以上面执行顺序不可能发生,下表才是事务实际的执行的顺序。
时间线 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
事务1 | 修改表1中某条数据的姓名为小明 | 修改表2中某条数据的姓名为小明 | ||
事务2 | 修改表1中某条数据(和事务1修改数据相同)姓名为小红 | 修改表2中某条数据(和事务1修改数据相同)姓名为小红 |
事务1修改表1中的某条数据之后就锁定了这条数据,事务2对相同数据的修改只能等到事务1提交之后(事务1解锁了数据),这就从根本上避免的脏写的行为,所有隔离级别都提供了这个保证。
事务思想扩展
关于事务尤其是事务隔离性的内容我们已经介绍完了。相信你对事务的了解又丰富了不少。其实事务思想不只存在于数据库领域,在日常业务场景中也会有所涉及,接下来我介绍一个现实中业务场景应用事务思想的案例。
首先我先介绍一下业务背景和需求,我们借用一个公有云服务资源开通场景,相信大部分开发人员都很熟悉,如果不了解可以去某某云体验一下。通常我们开通资源流程是这样的,登录某某云网站,选择要购买的云资源,这里假设是云主机,选择云资源配置-下订单-支付-等待资源交付,资源交付完成就可以使用相应资源了。这就是一个最基本的资源开通流程。同时我们在选配页面可以选择开通云主机的数量,也就是说我们可以在一个订单内批量开通多个云主机资源,如果将一个订单中多个资源的交付操作组合进一个原子单元中,这就形成了最基本的事务的思想了。
接下来我们要思考的是有没有必要在这里应用事务的概念并提供事务保障。这里我们的依据是业务场景,用户是否需要事务性保障?或者说用户可从保障中得到什么好处?
首先,我们可以想象用户既然选择在一个订单开通多台主机,那多台主机就是用户的业务场景需求,例如5台主机部署数据库,3台主机部署一个负载均衡的微服务集群。用户希望批量开通后部署相应业务,如果只开通了部分云主机,用户业务也无法部署,还需要根据上次开通情况二次开通。增加了用户对于资源开通异常场景的处理复杂度。其次,资源下单支付的过程中涉及各种优惠策略和代金券逻辑,某些逻辑和订单维度绑定,如果资源开通有事务性保证,退款逻辑比较简单整体退款即可,优惠政策也做整体回退。而如果没有事务性保证,就必须处理部分退款和优惠政策拆分逻辑,对于不可拆分的政策,要么用户损失要么云服务厂商损失。从这两点考虑我们需要保证批量交付云资源的事务原子性,对系统内要求批量交付的订单中所有云资源必须全部开通成功才算交付完成,如果一个云资源开通失败,就停止后续资源开通并将已经开通资源释放后给用户返回交付异常。对用户呈现的就是一个订单中所有资源要么全部开通要么全部失败的场景。这样我们就保证了批量订单资源开通的事务原子性。既降低了用户异常处理难度又解决了退款逻辑互斥性问题。
除了原子性,事务还需要提供隔离性保证。对照我们示例中,批量的交付中云资源是一个一个开通的,也就是说和可能第1个开通了,第99个还是开通中状态,那么客户能否看到一个批量资源开通过程中的资源状态就是一个隔离性问题。假设用户可以看到这些已开通资源,用户就可以使用,但是不到最后一刻这些资源是否回滚还未可知,一旦交付过程发生异常已开通资源回滚,会给用户造成很大困扰。这场景就如同前面介绍脏读一般。所以我们的批量开通云资源场景的也需要提供隔离性保证,要求就是用户不可见还未交付完成的批量开通订单内资源的状态,这些资源只能在交付完成的时刻整体可见。
我们通过一个批量云资源交付的场景将事务的原子性和隔离性延伸了一下。只要你用心思考,事务的概念和思想可以应用到很多地方。
总结
最后我总结一下本文内容,我们先从事务的基本概念讲起。事务将多条操作组成一个原子单元,事务支持原子性、隔离性、一致性和持久性。原子性保证了多条操作共进退,隔离性保证了多个事务并发执行而不相互干扰,持久性作为数据存储数据的基本保证,而一致性由应用程序定义并保证,但是需要事务其他特性的支持。相对其他特性,原子性和隔离性我们需要更多关注,原子性在异常场景下给我们提供了一个确定的、简化的模型方便我们进行异常处理,而隔离性则解决了多个事务并发执行过程中可能出现的并发问题。
接下来我们重点介绍了标准定义的四种事务隔离级别和每个隔离级别中并发问题和边界条件。因为读未提交隔离级别并发问题较多而序列化隔离级别性能损耗较大所以现实场景中很少使用,读已提交和可重复读两个隔离级别在性能上相差不大(在基于数据快照实现的前提下),但可重复读可以解决只读事务的幻读问题,应该是我们大多数场景下的最优选(MySQL InnoDB引擎的默认隔离级别)。在可重复读事务隔离级别中,我们介绍了幻读所引发的并发问题及其边界条件,日常开发中需要认真对待读-写事务场景,判断当发生覆盖更新和写倾斜问题的时候是否破坏业务逻辑定义的一致性,如果有潜在问题需要选择合适的方式加以处理。两个事务在不同的隔离级别下并发运行的时候就有可能发生并发问题,而事务中所包含操作不同,发生的并发问题也不同,下面我将各种模式事务并发执行可能发生的问题总结出来。
事务模式 | 只读事务 | 只写事务 | 读-写事务 |
---|---|---|---|
只读事务 | 无 | 不可重复读 | 不可重复读 |
只写事务 | / | 脏写、覆盖更新、写倾斜 | 脏写、不可重复读、覆盖更新、写倾斜 |
读-写事务 | 不可重复读 | / | 脏写、不可重复读、覆盖更新、写倾斜 |
注:只读事务表示事务中只有数据读取操作,只写事务表示数据中只有数据写入操作,读-写事务表明事务中先读取数据然后再写回数据。
这里要说明几点:
- 表中表示的是两种模式事务并发执行下可能出现的并发问题。
- 表中的并发问题不是在一种隔离级别而是在多种隔离级别情况下发生的。
- 脏写虽然在所有隔离级别下都被禁止,但是为了场景完整,也列在其中。
- 之所以只写事务和只写事务之间也会发生覆更新和写倾斜问题是考虑了这样一个特殊场景,因为业务需要将一个读-写事务分成读事务和写事务两个事务分步完成,读事务和写事务前后关联。例如更新文章场景,用户先点击文章详情(读操作),随后在线编辑文章后提交更改(写操作),虽然第二个更新文章的操作是只写事务,但是多个用户同时对同一篇文章进行编辑的时候就有可能发生丢失更新问题(后一个用户操作覆盖了前一个用户对文章的更改的内容)。所以排查事务间是否会出现并发问题,不能只看当前事务操作,还要结合业务流程捋清楚整个操作步骤流程。不过你也不用担心,因为关联场景造成并发问题的情况并不多,本例是最常见的。
- 先执行只读事务然后执行只写事务和先执行只写事务然后执行只读事务可能产生的并发问题相同,所以效果相同的排列顺序使用"/"表示。
最后我以一个事务思想应用的示例收尾,借以抛砖引玉,引起思考。关于事务隔离级别的全部理论内容就介绍完了,虽然本文的标题是一文说透,但是我也知道事务所涉及的知识点浩如星海,我也只是挑选其中最重要的、最常用的内容讲解。虽然不一定是一文说透,但是希望你能一文读懂。如果有问题,欢迎给我留言,我们一起交流探讨。本文的姊妹篇更加偏向于实践,教你在不同数据库下解决本文中各种并发问题,敬请期待。
参考文档
- Martin Kleppmann.数据密集型应用系统设计[M].北京.中国电力出版社.2018