《凤凰架构》第三章——事务处理

前言

由于一些地方原文感觉不太清楚,有些地方用小林coding的文章代替。

总结

事务处理主要的目的就是要让数据在各种条件下,最终的运行结果都能符合你的期望。要达成这个目标有三点需要满足:原子性(业务要么同时成功,要么同时被撤销)、隔离性(业务之间不会彼此影响)、持久性(提交的数据修改都能成功存入数据库中)。这些被称为事务的ACID特性。事务的处理分为以下4种:

第一,本地事务,单个服务使用单个数据源的场景。它的事务的实现是完全依赖于数据源本身的事务本领的,并不会深入参与到事务的逻辑运作过程当中。它必然会面临事务的三个问题,原子性,未提交事务时,也就是程序还没修改完数据,但数据库已经将其中一个或两个数据的变动写入磁盘,结果发生问题,写入终止,破坏原子性;持久性,已提交事务,也就是程序已经修改完数据,但数据库还未将全部数据的变动都写入到磁盘,发生问题,写入终止,破坏持久性;隔离性,在并发的情况下,可能出现脏读、不可重复读、幻读的问题。

针对前两个问题,提出了Commit Logging,只有在事务提交以后,其操作的日志记录全部完成后,才会根据日志上的信息对真正的数据进行修改,也就是数据库的一切行为都有日志备份,有"反悔药"。但缺陷也是明显的,所有对数据的修改都必须发生在事务提交以后,这段时间都将被浪费。为了解决这个问题,提出了Write-Ahead Logging,也就是在没有提交事务时就开时持久化数据,另一方面需要依赖于Redo Log(已经提交的事务,重做还没执行完的)和Undo Log(是针对Write-Ahead这种策略里提前写入的数据,进行回滚),当然这两者底层之间还是很复杂的。

而对于隔离性,在并发的背景下,可能会遇到,脏读:读到其他事务未提交的数据;不可重复读:前后读取的数据不一致;幻读:前后读取的记录数量不一致。对于不可重复读和幻读,前者侧重于修改,后者侧重于增删,其实可以理解为"幻读"是"不可重复读"的一种特殊情况,但是从数据库管理的角度来看二者是有区别的,解决"不可重复读"只要加行级锁就可以了。而解决"幻读"则需要加表级锁,或者采用其他更复杂的技术,总之代价要大许多。

要解决这些问题需要依赖于锁的实现,包括,写锁:数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作,并且其他事务不能写入数据,也不能施加读锁;读锁:多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,其他事务不能对该数据进行写入,但仍然可以读取,并且对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,允许直接将其升级为写锁,然后写入数据;范围锁:对于某个范围直接加写锁,在这个范围内的数据不能被写入。根据锁可以达到以下事务级别,读未提交(脏读,不可重复度,幻读):指一个事务还没提交时,它做的变更就能被其他事务看到;读已提交(不可重复读、幻读),禁止写时读:指一个事务提交之后,它做的变更才能被其他事务看到;可重复读(幻读),禁止读时写:指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的;串行化,把整个表给锁住:后访问的事务必须等前一个事务执行完成,才能继续执行。但隔离级别越高,并行度越低,付出的代价越大。当然锁的内容不仅仅如此,很复杂,这里不详细说明了。

除了都以锁来实现外,还可以通过无锁的方式来实现,MVCC,多版本并发控制。它的基本思路是是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。MVCC是只针对"读+写"场景的优化,如果是两个事务同时修改数据,即"写+写"的情况,还是得靠加锁的方式,此时讨论的余地可能就是加乐观锁还是悲观锁。总之锁非常复杂,不做详细讨论了。

第二,全局事务,单个服务使用多个数据源场景。所面临的问题,比如说下面正文的代码里,从代码上可看出,程序的目的是要做三次事务提交,try三个commit,但如果第二个有了问题,进入catch,但是第一个已经commit了,再去调用rollback方法已经无济于事,这将导致一部分数据被提交,另一部分被回滚。为了解决这个问题,XA将事务提交拆分成为两阶段过程,也就是"两段式提交"、2PC。大致内容为,首先是准备阶段,协调者询问事务的所有参与者是否准备好提交,都准备好了就进入提交阶段,如果有人有人发生意外就开始回滚。但这有明显的缺点,单点问题,性能问题,而且成立的前提还必须有,网络可靠,宕机可恢复。

为了解决2PC的问题,提出了3PC,三段式提交把原本的两段式提交的准备阶段再细分为两个阶段,分别称为CanCommit、PreCommit。一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成,也就是进一步增加了完成的可能性,这也意味着因某个参与者提交时发生崩溃而导致大家全部回滚的风险相对变小。因此,在事务需要回滚的场景中,三段式的性能通常是要比两段式好很多的,但在事务能够正常提交的场景中,两者的性能都依然很差,甚至三段式因为多了一次询问,还要稍微更差一些。并且依然没能解决网络可靠性的问题。

第三,共享事务,多个服务共用同一个数据源。这种情况在现实中见到的不多,因为该方案与实际生产系统中的压力方向相悖的,在一个系统中,数据库的压力才是最大的,才是最不容易伸缩的区域。

第四,分布式事务,多个服务同时访问多个数据源。首先要讲到CAP理论,就是在同一个系统中最多只能满足一致性(数据在任何时刻、任何分布式节点中所看到的都是符合预期的)、可用性(系统不间断地提供服务的能力)、分区容忍性(分布式环境中部分节点因网络原因而彼此失联后,系统仍能正确地提供服务的能力)中的2个。

譬如,如果放弃分区容忍性,意味着我们将假设节点之间通信永远是可靠的,比如传统的关系数据库集群,这样的集群虽然依然采用由网络连接的多个节点来协同工作,但数据却不是通过网络来实现共享的;如果放弃可用性,意味着一旦网络发生分区,节点之间的信息同步时间可以无限制地延长,比如,在对数据质量要求很高的场合中,可以通过2PC/3PC等手段,来实现,一台发生了问题,其它都等它;如果放弃一致性,意味着一旦发生分区,节点之间所提供的数据可能不一致,这种是目前系统的主流方案,因为网络本身就是不可靠的,而一致性通常是建设分布式的目的,而对于可用性,如果随着节点数量增加反而降低的话,很多分布式系统可能就失去了存在的价值,除非银行这类涉及金钱交易的服务,宁可中断也不能出错,比如,如果某个节点出现网络分区,那仍不妨碍各个节点以自己本地存储的数据对外提供缓存服务,但这时有可能出现请求分配到不同节点时返回给客户端的是不一致的数据。

事务的目的就是获得一致性,而在分布式环境中,上文提到一致性却不得不成为通常被牺牲、被放弃的属性。其实,我们会把CAP、ACID中讨论的一致性称为"强一致性",这是很难做到的,而我们追求的是"最终一致性",也就是允许数据在中间过程不一致,但应该在输出时被修正过来。

第一种方式为可靠事件队列,根据出错概率的大小来安排不同操作的顺序,然后不断重试各个操作来保证可靠性,也有了专门的名字叫作"最大努力交付"。

第二种方式为TCC,前面介绍的可靠消息队列虽然能保证最终的结果是相对可靠的,过程也简单,但是毫无隔离性可言,每个服务有先后性,完全有可能两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。而TCC是一种业务侵入式较强的事务方案,不像2PC是位于基础设施层面,它是在用户代码层面,更加灵活。要求业务处理过程必须拆分为"预留业务资源"和"确认/释放消费资源"两个子过程,这样就避免了以上情况,它也有一些优势,TCC在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用。

第三种方式为SAGA。TCC对业务有较强的侵入性,这不仅指代码上的侵入,还有它在技术可控性上的约束,比如你的电商要从银行取钱,银行可能并不会支持你的Try和Confirm阶段。SAGA的大致思路是把一个大事务分解为可以交错运行的一系列子事务集合。比如将整个分布式事务T分解为n个子事务,命名为T1,T2,...,Ti,...,Tn,每个子事务都应该是原子行为;为每一个子事务设计对应的补偿动作,命名为C1,C2,...,Ci,...,Cn。Ti与Ci必须满足以下条件:都具备幂等性;满足交换律,即先执行Ti还是先执行Ci,其效果都是一样的;Ci必须能成功提交。

如果T1到Tn均成功提交,那事务顺利完成,否则,要采取以下两种恢复策略之一:正向恢复,如果Ti事务提交失败,则一直对Ti进行重试,直至成功为止,正向恢复的执行模式为:T1,T2,...,Ti(失败),Ti(重试)...,Ti+1,...,Tn;反向恢复:如果Ti事务提交失败,则一直执行Ci对Ti进行补偿,直至成功为止,这里要求Ci必须(在持续重试后)执行成功,反向恢复的执行模式为:T1,T2,...,Ti(失败),Ci(补偿),...,C2,C1。基于此,与TCC 相比,SAGA不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。但同时这也会在设计上非常麻烦。

正文

事务处理存在的意义是为了保证系统中所有的数据都是符合期望的,且相互关联的数据之间不会产生矛盾,即数据状态的一致性(Consistency)。

要达成这个目标,需要三方面共同努力来保障:

  • 原子性(Atomic):在同一项业务处理过程中,事务保证了对多个数据的修改,要么同时成功,要么同时被撤销。
  • 隔离性(Isolation):在不同的业务处理过程中,事务保证了各自业务正在读、写的数据互相独立,不会彼此影响。
  • 持久性(Durability):事务应当保证所有成功被提交的数据修改都能够正确地被持久化,不丢失数据。

以上四种属性即事务的"ACID"特性,A、I、D 是手段,C 是目的,前者是因,后者是果。

事务的概念虽然最初起源于数据库系统,但今天已经有所延伸,不再局限于数据库本身,包括但不限于数据库、事务内存 、缓存、消息队列、分布式存储,等等。

1 本地事务

本地事务是最基础的一种事务解决方案,只适用于单个服务使用单个数据源的场景。从应用角度看,它是直接依赖于数据源本身提供的事务能力来工作的,在程序代码层面,最多只能对事务接口做一层标准化的包装(如JDBC接口),并不能深入参与到事务的运作过程当中,事务的开启、终止、提交、回滚、嵌套、设置隔离级别,乃至与应用代码贴近的事务传播方式,全部都要依赖底层数据源的支持才能工作,这一点与后续介绍的XA、TCC、SAGA等主要靠应用程序代码来实现的事务有着十分明显的区别。

1.1 实现原子性和持久性

数据必须要成功写入磁盘、磁带等持久化存储器后才能拥有持久性,只存储在内存中的数据,一旦遇到应用程序忽然崩溃,或者数据库、操作系统崩溃,甚至是机器突然断电宕机等情况就会丢失。实现原子性和持久性的最大困难是"写入磁盘"这个操作并不是原子的,不仅有"写入"与"未写入"状态,还存在着"正在写"的中间状态。所以可能出现以下情形:

  • 未提交事务,写入后崩溃:程序还没修改完三个数据,但数据库已经将其中一个或两个数据的变动写入磁盘,此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次不完整的购物操作,将已经修改过的数据从磁盘中恢复成没有改过的样子,以保证原子性。
  • 已提交事务,写入前崩溃:程序已经修改完三个数据,但数据库还未将全部三个数据的变动都写入到磁盘,此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次完整的购物操作,将还没来得及写入磁盘的那部分数据重新写入,以保证持久性。

由于写入中间状态与崩溃都是无法避免的,为了保证原子性和持久性,就只能在崩溃后采取恢复的补救措施,这种数据恢复操作被称为"崩溃恢复"。

主要的解决方式是"Commit Logging"(提交日志),只有在日志记录全部都安全落盘,数据库在日志中看到代表事务成功提交的"提交记录"(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条"结束记录"(End Record)表示事务已完成持久化。

但是,Commit Logging存在一个巨大的先天缺陷:所有对数据的修改都必须发生在事务提交以后,即日志写入了Commit Record之后。在此之前,即使磁盘I/O有足够空闲、即使某个事务修改的数据量非常庞大,占用了大量的内存缓冲区,无论有何种理由,都决不允许在事务提交之前就修改磁盘上的数据,这对提升数据库的性能十分不利。

为了解决这个问题,提出了"Write-Ahead Logging"提前写入。根据事务的提交和写入的情况可以分为两种情况,FORCE和STEAL:

  • FORCE:当事务提交后,要求变动数据必须同时完成写入则称为FORCE,如果不强制变动数据必须同时完成写入则称为NO-FORCE。现实中绝大多数数据库采用的都是NO-FORCE策略,因为只要有了日志,变动数据随时可以持久化,从优化磁盘I/O性能考虑,没有必要强制数据写入立即进行。
  • STEAL:在事务提交前,允许变动数据提前写入则称为STEAL,不允许则称为NO-STEAL。从优化磁盘I/O性能考虑,允许数据提前写入,有利于利用空闲I/O资源,也有利于节省数据库缓存区的内存。

Commit Logging允许NO-FORCE,但不允许STEAL。Write-Ahead Logging允许NO-FORCE,也允许STEAL,它给出的解决办法是,当变动数据写入磁盘前,必须先记录Undo Log(回滚日志),注明修改了哪个位置的数据、从什么值改成什么值,等等。以便在事务回滚或者崩溃恢复时根据Undo Log对提前写入的数据变动进行擦除(Undo Log是针对Write-Ahead这种策略里提前偷偷写入的数据)。此前记录的用于崩溃恢复时重演数据变动的日志就相应被命名为Redo Log(重做日志)(正常执行的内容,也就是回滚执行没执行的,然后接着把这个根据commit log里的执行完)。Write-Ahead Logging在崩溃恢复时会执行以下三个阶段的操作。

  • 分析阶段(Analysis):该阶段从最后一次检查点(Checkpoint,可理解为在这个点之前所有应该持久化的变动都已安全落盘)开始扫描日志,找出所有没有End Record的事务,组成待恢复的事务集合。
  • 重做阶段(Redo):该阶段依据分析阶段中产生的待恢复的事务集合来重演历史,具体操作为:找出所有包含Commit Record的日志,将这些日志修改的数据写入磁盘,写入完成后在日志中增加一条End Record,然后移除出待恢复事务集合。
  • 回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务,根据Undo Log中的信息,将已经提前写入磁盘的信息重新改写回去。

下面是大概的流程图,崩溃恢复时先根据Redo Log恢复。然后根据Undo Log恢复,因为这些提前做的并没有走到Commit Log这步,所以Redo Log也不会记录什么,不冲突。

1.2 实现隔离性

隔离性保证了每个事务各自读、写的数据互相独立,不会彼此影响。只从定义上就能嗅出隔离性肯定与并发密切相关,因为如果没有并发,所有事务全都是串行的,那就不需要任何隔离。要在并发下实现串行的数据访问该怎样做?现代数据库均提供了以下三种锁。

  • 写锁(Write Lock,也叫作排他锁,eXclusive Lock,简写为 X-Lock):如果数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。

  • 读锁(Read Lock,也叫作共享锁,Shared Lock,简写为 S-Lock):多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,允许直接将其升级为写锁,然后写入数据。

  • 范围锁(Range Lock):对于某个范围直接加排他锁,在这个范围内的数据不能被写入。如下语句是典型的加范围锁的例子:

    sql 复制代码
    SELECT * FROM books WHERE price < 100 FOR UPDATE;

首先为什么需要锁呢,并发事务会带来什么问题呢?在同时处理多个事务的时候,可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题。

  • 脏读:读到其他事务未提交的数据。
  • 不可重复读:前后读取的数据不一致。
  • 幻读:前后读取的记录数量不一致。
    那根据锁,可以达到的事务的隔离级别有哪些呢?由隔离水平由低到高为:
  • 读未提交(read uncommitted),指一个事务还没提交时,它做的变更就能被其他事务看到;
  • 读已提交(read committed),指一个事务提交之后,它做的变更才能被其他事务看到;
  • 可重复读(repeatable read),指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB引擎的默认隔离级别;
  • 串行化(serializable);会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;

    串行化访问提供了最高强度的隔离性,但隔离程度越高,并发访问时的吞吐量就越低。现代数据库一定会提供除可串行化以外的其他隔离级别供用户使用,让用户调节隔离级别的选项,根本目的是让用户可以调节数据库的加锁方式,取得隔离性与吞吐量之间的平衡。

除了都以锁来实现外,以上四种隔离级别还有另一个共同特点,就是幻读、不可重复读、脏读等问题都是由于一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性,近年来有一种名为"多版本并发控制"(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。MVCC是一种读取优化策略,它的"无锁"是特指读取时不需要加锁。MVCC的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。在这句话中,"版本"是个关键词,可将版本理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION和DELETE_VERSION,这两个字段记录的值都是事务ID,事务ID是一个全局严格递增的数值,然后根据以下规则写入数据。

  • 插入数据时:CREATE_VERSION记录插入数据的事务ID,DELETE_VERSION为空。
  • 删除数据时:DELETE_VERSION记录删除数据的事务ID,CREATE_VERSION为空。
  • 修改数据时:将修改数据视为"删除旧数据,插入新数据"的组合,即先将原有数据复制一份,原有数据的DELETE_VERSION 记录修改数据的事务 ID,CREATE_VERSION为空。复制出来的新数据的CREATE_VERSION记录修改数据的事务 ID,DELETE_VERSION为空。

此时,如有另外一个事务要读取这些发生了变化的数据,将根据隔离级别来决定到底应该读取哪个版本的数据。

  • 隔离级别是可重复读:总是读取CREATE_VERSION小于或等于当前事务ID的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务ID最大)的。
  • 隔离级别是读已提交:总是取最新的版本即可,即最近被Commit的那个版本的数据记录。

另外两个隔离级别都没有必要用到MVCC,因为读未提交直接修改原始数据即可,其他事务查看数据的时候立刻可以看到,根本无须版本字段。可串行化本来的语义就是要阻塞其他事务的读取操作,而MVCC是做读取时无锁优化的,自然就不会放到一起用。

MVCC是只针对"读+写"场景的优化,如果是两个事务同时修改数据,即"写+写"的情况,那就没有多少优化的空间了,此时加锁几乎是唯一可行的解决方案,稍微有点讨论余地的是加锁的策略是"乐观加锁"(Optimistic Locking)还是"悲观加锁"(Pessimistic Locking)。悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。不过,没有必要迷信什么乐观锁要比悲观锁更快的说法,这纯粹看竞争的剧烈程度,如果竞争剧烈的话,乐观锁反而更慢。

2 全局事务

与本地事务相对的是全局事务(Global Transaction),有一些资料中也将其称为外部事务(External Transaction),在本节里,全局事务被限定为一种适用于单个服务使用多个数据源场景的事务解决方案。请注意,理论上真正的全局事务并没有"单个服务"的约束,本节所讨论的内容是一种在分布式环境中仍追求强一致性的事务处理方案,对于多节点而且互相调用彼此服务的场合(典型的就是现在的微服务系统)是极不合适的,今天它几乎只实际应用于单服务多数据源的场合中,为了避免与后续介绍的放弃了ACID的弱一致性事务处理方式相互混淆,所以这里的全局事务所指范围有所缩减,后续涉及多服务多数据源的事务,作者将称其为"分布式事务"。

为了解决分布式事务的一致性问题,X/Open组织提出了一套名为X/Open XA (XA 是eXtended Architecture的缩写)的处理事务架构,其核心内容是定义了全局的事务管理器(Transaction Manager,用于协调全局事务)和局部的资源管理器(Resource Manager,用于驱动本地事务)之间的通信接口。XA接口是双向的,能在一个事务管理器和多个资源管理器(之间形成通信桥梁,通过协调多个数据源的一致动作,实现全局事务的统一提交或者统一回滚,现在我们在Java代码中还偶尔能看见的XADataSource、XAResource这些名字都源于此。

什么是"单个服务使用多个数据源",我们对本章的场景事例做另外一种假设:如果书店的用户、商家、仓库分别处于不同的数据库中,其他条件仍与之前相同,那情况会发生什么变化呢?以下是执行代码:

java 复制代码
public void buyBook(PaymentBill bill) {
    userTransaction.begin();
    warehouseTransaction.begin();
    businessTransaction.begin();
	try {
        userAccountService.pay(bill.getMoney());
        warehouseService.deliver(bill.getItems());
        businessAccountService.receipt(bill.getMoney());
        userTransaction.commit();
        warehouseTransaction.commit();
        businessTransaction.commit();
	} catch(Exception e) {
        userTransaction.rollback();
        warehouseTransaction.rollback();
        businessTransaction.rollback();
	}
}

从代码上可看出,程序的目的是要做三次事务提交,但实际上代码并不能这样写,试想一下,如果在businessTransaction.commit()中出现错误,代码转到catch块中执行,此时userTransaction和warehouseTransaction已经完成提交,再去调用rollback()方法已经无济于事,这将导致一部分数据被提交,另一部分被回滚,整个事务的一致性也就无法保证了。为了解决这个问题,XA 将事务提交拆分成为两阶段过程:

  • 准备阶段:又叫作投票阶段,在这一阶段,协调者询问事务的所有参与者是否准备好提交,参与者如果已经准备好提交则回复Prepared,否则回复Non-Prepared。
  • 提交阶段:协调者如果在上一阶段收到所有事务参与者回复的Prepared消息,则先自己在本地持久化事务状态为Commit,在此操作完成后向所有参与者发送Commit指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了Non-Prepared消息,或任意一个参与者超时未回复,立即执行回滚操作。

    以上这两个过程被称为"两段式提交"(2 Phase Commit,2PC)协议,而它能够成功保证一致性还需要一些其他前提条件。
  • 必须假设网络在提交阶段的短时间内是可靠的,即提交阶段不会丢失消息。同时也假设网络通信在全过程都不会出现误差,即可以丢失消息,但不会传递错误的消息。
  • 必须假设因为网络分区、机器崩溃或者其他原因而导致失联的节点最终能够恢复,不会永久性地处于失联状态。

两段式提交但有几个非常显著的缺点:

  • 单点问题:协调者在两段提交中具有举足轻重的作用,协调者等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等待协调者指令时无法做超时处理。一旦宕机的不是其中某个参与者,而是协调者的话,所有参与者都会受到影响。
  • 性能问题:两段提交过程中,所有参与者相当于被绑定成为一个统一调度的整体,期间要经过两次远程服务调用,整个过程将持续到参与者集群中最慢的那一个处理操作结束为止,这决定了两段式提交的性能通常都较差。
  • 一致性风险:前面已经提到,两段式提交的成立是有前提条件的,当网络稳定性和宕机恢复能力的假设不成立时,仍可能出现一致性问题。宕机恢复能力这一点不必多谈,1985年Fischer、Lynch、Paterson提出了"FLP 不可能原理",证明了如果宕机最后不能恢复,那就不存在任何一种分布式协议可以正确地达成一致性结果。该原理在分布式中是与"CAP 不可兼得原理"齐名的理论。

为了缓解两段式提交协议的一部分缺陷,具体地说是协调者的单点问题和准备阶段的性能问题,后续又发展出了"三段式提交 "(3 Phase Commit,3PC)协议。三段式提交把原本的两段式提交的准备阶段再细分为两个阶段,分别称为CanCommit、PreCommit,把提交阶段改称为 DoCommit阶段。其中,新增的CanCommit是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。将准备阶段一分为二的理由是这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,它们所涉及的数据资源即被锁住,如果此时某一个参与者宣告无法完成提交,相当于大家都白做了一轮无用功。所以,增加一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,这也意味着因某个参与者提交时发生崩溃而导致大家全部回滚的风险相对变小。因此,在事务需要回滚的场景中,三段式的性能通常是要比两段式好很多的,但在事务能够正常提交的场景中,两者的性能都依然很差,甚至三段式因为多了一次询问,还要稍微更差一些。

从以上过程可以看出,三段式提交对单点问题和回滚时的性能问题有所改善,但是它对一致性风险问题并未有任何改进,在这方面它面临的风险甚至反而是略有增加了的。譬如,进入 PreCommit 阶段之后,协调者发出的指令不是Ack而是Abort,而此时因网络问题,有部分参与者直至超时都未能收到协调者的Abort指令的话,这些参与者将会错误地提交事务,这就产生了不同参与者之间数据不一致的问题。

3 共享事务

共享事务(Share Transaction)是指多个服务共用同一个数据源。"数据源"与"数据库"的区别为,数据源是指提供数据的逻辑设备,不必与物理设备一一对应。在部署应用集群时最常采用的模式是将同一套程序部署到多个中间件服务器上,构成多个副本实例来分担流量压力。它们虽然连接了同一个数据库,但每个节点配有自己的专属的数据源。这种情况下,所有副本实例的数据访问都是完全独立的,并没有任何交集,每个节点使用的仍是最简单的本地事务。

而在共享事务中,比如假设用户账户、商家账户和商品仓库都存储于同一个数据库之中,但用户、商户和仓库每个领域都部署了独立的微服务,此时一次购书的业务操作将贯穿三个微服务,它们都要在数据库中修改数据。

一种理论可行的方案是直接让各个服务共享数据库连接,在同一个应用进程中的不同持久化工具(JDBC、ORM、JMS 等)间共享数据库连接并不困难。某些中间件服务器会内置有"可共享连接"功能来专门给予这方面的支持。对于后者这种共享的前提是数据源的使用者都在同一个进程内,由于数据库连接的基础是网络连接,它是与IP地址和端口号绑定的,字面意义上的"不同服务节点共享数据库连接"很难做到,所以为了实现共享事务,就必须新增一个"交易服务器"的中间角色,无论是用户服务、商家服务还是仓库服务,它们都通过同一台交易服务器来与数据库打交道。在日常开发中,上述方案还存在一类更为常见的变种形式:使用消息队列服务器来代替交易服务器。

之所以强调理论可行,是因为该方案是与实际生产系统中的压力方向相悖的,一个服务集群里数据库才是压力最大而又最不容易伸缩拓展的重灾区,现实中没有会代理一个数据库为多个应用提供事务协调的交易服务代理,如果你有充足理由让多个微服务去共享数据库,就必须找到更加站得住脚的理由来向团队解释拆分微服务的目的是什么才行。

笔者把共享事务列为本章四种事务类型之一只是为了叙述逻辑的完备,尽管拆分微服务后仍然共享数据库的情况在现实中并不少见,但笔者个人不赞同将共享事务作为一种常规的解决方案来考量。

4 分布式事务

分布式事务(Distributed Transaction)特指多个服务同时访问多个数据源的事务处理机制。

4.1 CAP与ACID

CAP定理描述了一个分布式的系统中,涉及共享数据问题时,以下三个特性最多只能同时满足其中两个:

  • 一致性(Consistency):代表数据在任何时刻、任何分布式节点中所看到的都是符合预期的。
  • 可用性(Availability):代表系统不间断地提供服务的能力,理解可用性要先理解与其密切相关两个指标:可靠性(Reliability)和可维护性(Serviceability)。可靠性使用平均无故障时间(Mean Time Between Failure,MTBF)来度量;可维护性使用平均可修复时间(Mean Time To Repair,MTTR)来度量。可用性衡量系统可以正常使用的时间与总时间之比,其表征为:A=MTBF/(MTBF+MTTR),即可用性是由可靠性和可维护性计算得出的比例值,譬如 99.9999%可用,即代表平均年故障修复时间为 32 秒。
  • 分区容忍性(Partition Tolerance):代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成"网络分区"时,系统仍能正确地提供服务的能力。

以上面这套系统为例,在这套系统中,每一个单独的服务节点都有自己的数据库(这里是为了便于说明问题的假设,在实际生产系统中,一般应避免将用户余额这样的数据设计成存储在多个可写的数据库中),假设某次交易请求分别由"账号节点 1"、"商家节点 2"、"仓库节点 N"联合进行响应。当用户购买一件价值100元的商品后,账号节点1首先应给该用户账号扣减 100 元货款,它在自己数据库扣减100元很容易,但它还要把这次交易变动告知本集群的节点2到节点N,并要确保能正确变更商家和仓库集群其他账号节点中的关联数据,此时将面临以下可能的情况。

  • 如果该变动信息没有及时同步给其他账号节点,将导致有可能发生用户购买另一商品时,被分配给到另一个节点处理,由于看到账号上有不正确的余额而错误地发生了原本无法进行的交易,此为一致性问题。
  • 如果由于要把该变动信息同步给其他账号节点,必须暂时停止对该用户的交易服务,直至数据同步一致后再重新恢复,将可能导致用户在下一次购买商品时,因系统暂时无法提供服务而被拒绝交易,此为可用性问题。
  • 如果由于账号服务集群中某一部分节点,因出现网络问题,无法正常与另一部分节点交换账号变动信息,此时服务集群中无论哪一部分节点对外提供的服务都可能是不正确的,整个集群能否承受由于部分节点之间的连接中断而仍然能够正确地提供服务,此为分区容忍性。

以上还仅仅涉及了账号服务集群自身的CAP问题,对于整个Fenix's Bookstore站点来说,它更是面临着来自于账号、商家和仓库服务集群带来的CAP问题,譬如,用户账号扣款后,由于未及时通知仓库服务中的全部节点,导致另一次交易中看到仓库里有不正确的库存数据而发生超售。又譬如因涉及仓库中某个商品的交易正在进行,为了同步用户、商家和仓库的交易变动,而暂时锁定该商品的交易服务,导致了的可用性问题,等等。

由于CAP定理已有严格的证明,证明其不可兼得,下面分析如果舍弃C、A、P时所带来的不同影响。

  • 如果放弃分区容忍性(CA without P),意味着我们将假设节点之间通信永远是可靠的。永远可靠的通信在分布式系统中必定不成立的,这不是你想不想的问题,而是只要用到网络来共享数据,分区现象就会始终存在。在现实中,最容易找到放弃分区容忍性的例子便是传统的关系数据库集群,这样的集群虽然依然采用由网络连接的多个节点来协同工作,但数据却不是通过网络来实现共享的。以Oracle的RAC集群为例,它的每一个节点均有自己独立的SGA、重做日志、回滚日志等部件,但各个节点是通过共享存储中的同一份数据文件和控制文件来获取数据的,通过共享磁盘的方式来避免出现网络分区。因而Oracle RAC虽然也是由多个实例组成的数据库,但它并不能称作是分布式数据库。
  • 如果放弃可用性(CP without A),意味着我们将假设一旦网络发生分区,节点之间的信息同步时间可以无限制地延长,此时,问题相当于退化到前面"全局事务"中讨论的一个系统使用多个数据源的场景之中,我们可以通过2PC/3PC等手段,同时获得分区容忍性和一致性。在现实中,选择放弃可用性的CP系统情况一般用于对数据质量要求很高的场合中,除了DTP模型的分布式数据库事务外,著名的HBase也是属于CP系统,以HBase集群为例,假如某个RegionServer宕机了,这个RegionServer持有的所有键值范围都将离线,直到数据恢复过程完成为止,这个过程要消耗的时间是无法预先估计的。
  • 如果放弃一致性(AP without C),意味着我们将假设一旦发生分区,节点之间所提供的数据可能不一致。选择放弃一致性的AP系统目前是设计分布式系统的主流选择,因为P是分布式网络的天然属性,你再不想要也无法丢弃;而A通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就失去了存在的价值,除非银行、证券这些涉及金钱交易的服务,宁可中断也不能出错,否则多数系统是不能容忍节点越多可用性反而越低的。目前大多数NoSQL库和支持分布式的缓存框架都是AP系统,以Redis集群为例,如果某个 Redis 节点出现网络分区,那仍不妨碍各个节点以自己本地存储的数据对外提供缓存服务,但这时有可能出现请求分配到不同节点时返回给客户端的是不一致的数据。

本章讨论的话题"事务"原本的目的就是获得"一致性",而在分布式环境中,"一致性"却不得不成为通常被牺牲、被放弃的那一项属性。但无论如何,我们建设信息系统,终究还是要确保操作结果至少在最终交付的时候是正确的,这句话的意思是允许数据在中间过程出错(不一致),但应该在输出时被修正过来。将前面我们在CAP、ACID中讨论的一致性称为"强一致性"(Strong Consistency)。而把牺牲了C的AP系统又要尽可能获得正确的结果的行为称为追求"弱一致性"。不过,如果单纯只说"弱一致性"那其实就是"不保证一致性"的意思。在弱一致性里,人们又总结出了一种稍微强一点的特例,被称为"最终一致性"(Eventual Consistency),它是指:如果数据在一段时间之内没有被另外的操作所更改,那它最终将会达到与强一致性过程相同的结果。

4.2 可靠事件队列

人们把使用ACID的事务称为"刚性事务",而把笔者下面将要介绍几种分布式事务的常见做法统称为"柔性事务"。

最早的起源为"可靠事件队列",下图表示的非常清楚。

Fenix's Bookstore首先应对用户账号扣款、商家账号收款、库存商品出库这三个操作有一个出错概率的先验评估,根据出错概率的大小来安排它们的操作顺序,这种评估一般直接体现在程序代码中,有一些大型系统也可能会实现动态排序。譬如,根据统计,最有可能的出现的交易异常是用户购买了商品,但是不同意扣款,或者账号余额不足;其次是仓库发现商品库存不够,无法发货;风险最低的是收款,如果到了商家收款环节,一般就不会出什么意外了。那顺序就应该安排成最容易出错的最先进行,即:账号扣款 → 仓库出库 → 商家收款。

以上这种靠着持续重试来保证可靠性的解决方案谈不上是首创或者独创,它在计算机的其他领域中已被频繁使用,也有了专门的名字叫作"最大努力交付"(Best-Effort Delivery)。

4.3 TCC事务

前面介绍的可靠消息队列虽然能保证最终的结果是相对可靠的,过程也足够简单(相对于TCC来说),但整个过程完全没有任何隔离性可言(账号服务、仓库服务、商家服务不是同时进行的,有先后性),有一些业务中隔离性是无关紧要的,但有一些业务中缺乏隔离性就会带来许多麻烦。譬如在本章的场景事例中,缺乏隔离性会带来的一个显而易见的问题便是"超售":完全有可能两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和却超过了库存。如果这件事情处于刚性事务,且隔离级别足够的情况下是可以完全避免的,譬如,以上场景就需要"可重复读"(Repeatable Read)的隔离级别,以保证后面提交的事务会因为无法获得锁而导致失败,但用可靠消息队列就无法保证这一点。如果业务需要隔离,那架构师通常就应该重点考虑TCC方案,该方案天生适合用于需要强隔离性的分布式事务中。

在具体实现上,TCC较为烦琐,它是一种业务侵入式较强的事务方案,要求业务处理过程必须拆分为"预留业务资源"和"确认/释放消费资源"两个子过程。如同TCC的名字所示,它分为以下三个阶段。

  • Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。
  • Confirm:确认执行阶段,不进行任何业务检查,直接使用Try阶段准备的资源来完成业务处理。Confirm阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。
  • Cancel:取消执行阶段,释放Try阶段预留的业务资源。Cancel阶段可能会重复执行,也需要满足幂等性。

首先创建事务,生成事务ID,记录在活动日志中,进入Try阶段:

用户服务:检查业务可行性,可行的话,将该用户的100元设置为"冻结"状态,通知下一步进入 Confirm 阶段;不可行的话,通知下一步进入 Cancel 阶段。

仓库服务:检查业务可行性,可行的话,将该仓库的1本《深入理解 Java 虚拟机》设置为"冻结"状态,通知下一步进入Confirm阶段;不可行的话,通知下一步进入Cancel阶段。

商家服务:检查业务可行性,不需要冻结资源。

如果这步都反馈可行,则进入Confirm阶段,开始执行,如果第 3 步中任何一方出现异常,不论是业务异常或者网络异常,都将根据活动日志中的记录,重复执行该服务的Confirm操作,即进行最大努力交付。

如果上面这步有任意一方反馈业务不可行,或任意一方超时,将活动日志的状态记录为 Cancel,进入 Cancel 阶段:

用户服务:取消业务操作(释放被冻结的100元)。

仓库服务:取消业务操作(释放被冻结的1本书)。

商家服务:取消业务操作。

由上述操作过程可见,TCC其实有点类似2PC的准备阶段和提交阶段,但TCC是位于用户代码层面,而不是在基础设施层面,这为它的实现带来了较高的灵活性,可以根据需要设计资源锁定的粒度。TCC在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,具有很高的性能潜力。但是TCC并非纯粹只有好处,它也带来了更高的开发成本和业务侵入性,意味着有更高的开发成本和更换事务实现方案的替换成本,所以,通常我们并不会完全靠裸编码来实现 TCC,而是基于某些分布式事务中间件(譬如阿里开源的Seata)去完成,尽量减轻一些编码工作量。

4.4 SAGA事务

TCC事务具有较强的隔离性,避免了"超售"的问题,而且其性能一般来说是本篇提及的几种柔性事务模式中最高的,但它仍不能满足所有的场景。TCC的最主要限制是它的业务侵入性很强,这里并不是重复上一节提到的它需要开发编码配合所带来的工作量,而更多的是指它所要求的技术可控性上的约束。譬如,把我们的场景事例修改如下:由于中国网络支付日益盛行,现在用户和商家在书店系统中可以选择不再开设充值账号,至少不会强求一定要先从银行充值到系统中才能进行消费,允许直接在购物时通过U盾或扫码支付,在银行账号中划转货款。这个需求完全符合国内网络支付盛行的现状,却给系统的事务设计增加了额外的限制:如果用户、商家的账号余额由银行管理的话,其操作权限和数据结构就不可能再随心所欲的地自行定义,通常也就无法完成冻结款项、解冻、扣减这样的操作,因为银行一般不会配合你的操作。所以TCC中的第一步Try阶段往往无法施行。我们只能考虑采用另外一种柔性事务方案:SAGA 事务。大致思路是,把一个大事务分解为可以交错运行的一系列子事务集合。它由两部分组成。

  • 大事务拆分若干个小事务,将整个分布式事务T分解为n个子事务,命名为T1,T2,...,Ti,...,Tn。每个子事务都应该是或者能被视为是原子行为。如果分布式事务能够正常提交,其对数据的影响(最终一致性)应与连续按顺序成功提交Ti等价。
  • 为每一个子事务设计对应的补偿动作,命名为 C1,C2,...,Ci,...,Cn。Ti与 Ci必须满足以下条件:
    • Ti与Ci都具备幂等性。
    • Ti与Ci满足交换律,即先执行Ti还是先执行Ci,其效果都是一样的。
    • Ci必须能成功提交,即不考虑Ci本身提交失败被回滚的情形,如出现就必须持续重试直至成功,或者要人工介入。

如果T1到Tn均成功提交,那事务顺利完成,否则,要采取以下两种恢复策略之一:

  • 正向恢复(Forward Recovery):如果Ti事务提交失败,则一直对Ti进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,譬如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T1,T2,...,Ti(失败),Ti(重试)...,Ti+1,...,Tn。
  • 反向恢复(Backward Recovery):如果Ti事务提交失败,则一直执行Ci对Ti进行补偿,直至成功为止(最大努力交付)。这里要求Ci必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,...,Ti(失败),Ci(补偿),...,C2,C1。

与TCC相比,SAGA不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。譬如,前面提到的账号余额直接在银行维护的场景,从银行划转货款到 Fenix's Bookstore系统中,这步是经由用户支付操作(扫码或U盾)来促使银行提供服务;如果后续业务操作失败,尽管我们无法要求银行撤销掉之前的用户转账操作,但是由Fenix's Bookstore系统将货款转回到用户账上作为补偿措施却是完全可行的。

SAGA必须保证所有子事务都得以提交或者补偿,但SAGA系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log)以保证系统恢复后可以追踪到子事务的执行情况,譬如执行至哪一步或者补偿至哪一步了。另外,尽管补偿操作通常比冻结/撤销容易实现,但保证正向、反向恢复过程的能严谨地进行也需要花费不少的工夫,譬如通过服务编排、可靠事件队列等方式完成,所以,SAGA事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成,前面提到的 Seata 就同样支持 SAGA 事务模式。

相关推荐
o(╥﹏╥)4 分钟前
linux(ubuntu )卡死怎么强制重启
linux·数据库·ubuntu·系统安全
阿里嘎多学长18 分钟前
docker怎么部署高斯数据库
运维·数据库·docker·容器
Yuan_o_21 分钟前
Linux 基本使用和程序部署
java·linux·运维·服务器·数据库·后端
Sunyanhui125 分钟前
牛客网 SQL36查找后排序
数据库·sql·mysql
老王笔记39 分钟前
MHA binlog server
数据库·mysql
快乐非自愿1 小时前
分布式系统架构2:服务发现
架构·服务发现
2401_854391081 小时前
SSM 架构中 JAVA 网络直播带货查询系统设计与 JSP 有效实现方法
java·开发语言·架构
264玫瑰资源库1 小时前
从零开始C++棋牌游戏开发之第二篇:初识 C++ 游戏开发的基本架构
开发语言·c++·架构
神一样的老师1 小时前
面向高精度网络的时间同步安全管理架构
网络·安全·架构