13 _ 全局事务和共享事务是如何实现的? 你好,我是周志明。今天,我们一起来学习全局事务(Global Transactions)和共享事务(Share Transactions)的原理与实现。

其实,相对于我们前两节课学习的本地事务,全局事务和共享事务的使用频率已经很低了。但这两种事务类型,是分布式事务(下一讲要学习)的中间形式,起到的是承上启下的作用。

所以,我们还是有必要去理解它们的实现方式,这样才能更透彻地理解事务处理这个话题。

接下来,我们就从全局事务学起吧。

全局事务

与本地事务相对的是全局事务,一些资料中也会称之为外部事务(External Transactions)。在今天这一讲,我会给全局事务做个限定:一种适用于单个服务使用多个数据源场景的事务解决方案。

需要注意的是,理论上,真正的全局事务是没有“单个服务”这个约束的,它本来就是DTP(Distributed Transaction Processing)模型中的概念。那我为什么要在这一讲给它做个限定呢?

这是因为,我们今天要学习的内容,也就是一种在分布式环境中仍追求强一致性的事务处理方案,在多节点互相调用彼此服务的场景(比如现在的微服务)中是非常不合适的。从目前的情况来看,这种方案几乎只实际应用在了单服务多数据源的场景中。

为了避免与我们下一讲要学习的放弃了ACID的弱一致性事务处理方式混淆,所以我在这一讲缩减了全局事务所指的范围;对于涉及多服务多数据源的事务,我将其称为“分布式事务”。

XA协议

为了解决分布式事务的一致性问题,1991年的时候X/Open组织(后来并入了The Open Group)提出了一套叫做X/Open XA(XA是eXtended Architecture的缩写)的事务处理框架。这个框架的核心内容是,定义了全局的事务管理器(Transaction Manager,用于协调全局事务)和局部的资源管理器(Resource Manager,用于驱动本地事务)之间的通讯接口。

XA接口是双向的,是一个事务管理器和多个资源管理器之间通信的桥梁,通过协调多个数据源的动作保持一致,来实现全局事务的统一提交或者统一回滚。现在,我们在Java代码中还偶尔能看见的XADataSource、XAResource等名字,其实都是源于XA接口。

这里你要注意的是,XA并不是Java规范(因为当时还没有Java),而是一套通用的技术规范。Java后来专门定义了一套全局事务处理标准,也就是我们熟知的JTA(JSR 907 Java Transaction API)接口。它有两个最主要的接口:

  • 事务管理器的接口:javax.transaction.TransactionManager,这套接口是给Java EE服务器提供容器事务(由容器自动负责事务管理)使用的。另外它还提供了另外一套javax.transaction.UserTransaction接口,用于给程序员通过程序代码手动开启、提交和回滚事务。
  • 满足XA规范的资源定义接口:javax.transaction.xa.XAResource。任何资源(JDBC、JMS等)如果需要支持JTA,只要实现XAResource接口中的方法就可以了。

JTA原本是Java EE中的技术,一般情况下应该由JBoss、WebSphere、WebLogic这些Java EE容器来提供支持,但现在BittronixAtomikosJBossTM(以前叫Arjuna)都以JAR包的形式实现了JTA的接口,也就是JOTM(Java Open Transaction Manager)。有了JOTM的支持,我们就可以在Tomcat、Jetty这样的Java SE环境下使用JTA了。

我们在第11讲讲解本地事务的时候,设计了一个Fenix’s Bookstore在线书店场景。一份商品成功售出,需要确保以下三件事情被正确地处理:

  • 用户的账号扣减相应的商品款项;
  • 商品仓库中扣减库存,将商品标识为待配送状态;
  • 商家的账号增加相应的商品款项。

现在,我们对这个示例场景做另外一种假设:如果书店的用户、商家、仓库分别处于不同的数据库中,其他条件不变,那会发生什么变化呢?

如果我们以声明式事务来编码的话,那与本地事务看起来可能没什么区别,都是标个@Transactional注解而已,但如果是以编程式事务来实现的话,在写法上就有差异了。我们具体看看: 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。

这里的“准备”操作,其实和我们通常理解的“准备”不太一样:对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条Commit Record。这意味着在做完数据持久化后并不会立即释放隔离性,也就是仍继续持有锁,维持数据对其他非事务内观察者的隔离状态。

提交阶段,又叫做执行阶段,协调者如果在准备阶段收到所有事务参与者回复的Prepared消息,就会首先在本地持久化事务状态为Commit,然后向所有参与者发送Commit指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了Non-Prepared消息,或任意一个参与者超时未回复,协调者都会将自己的事务状态持久化为“Abort”之后,向所有参与者发送Abort指令,参与者立即执行回滚操作。

对于数据库来说,提交阶段的提交操作是相对轻量的,仅仅是持久化一条Commit Record而已,通常能够快速完成。回滚阶段则相对耗时,收到Abort指令时,需要根据回滚日志清理已提交的数据,这可能是相对重负载操作。

“准备”和“提交”这两个过程,被称为“两段式提交”(2 Phase Commit,2PC)协议。那么,使用了两阶段提交协议,就一定可以成功保证一致性吗?也不是的,它还需要两个前提条件

第一,必须假设网络在提交阶段这个短时间内是可靠的,即提交阶段不会丢失消息。同时也假设网络通讯在全过程都不会出现误差,即可以丢失后消息,但不会传递错误的消息,XA的设计目标并不是解决诸如拜占庭将军一类的问题。

两段式提交中投票阶段失败了可以补救(回滚),而提交阶段失败了无法补救(不再改变提交或回滚的结果,只能等崩溃的节点重新恢复),因而提交阶段的耗时应尽可能短,这也是为了尽量控制网络风险的考虑。

第二,必须假设因为网络分区、机器崩溃或者其他原因而导致失联的节点最终能够恢复,不会永久性地处于失联状态。由于在准备阶段已经写入了完整的重做日志,所以当失联机器一旦恢复,就能够从日志中找出已准备妥当但并未提交的事务数据,再向协调者查询该事务的状态,确定下一步应该进行提交还是回滚操作。

到这里,我还要给你澄清一个概念。我们前面提到的协调者和参与者,通常都是由数据库自己来扮演的,不需要应用程序介入,应用程序相对于数据库来说只扮演客户端的角色。

两段式提交的原理很简单,也不难实现,但有三个非常明显的缺点。

单点问题:协调者在两段提交中具有举足轻重的作用,协调者等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等待协调者指令时无法做超时处理。一旦协调者宕机,所有参与者都会受到影响。如果协调者一直没有恢复,没有正常发送Commit或者Rollback的指令,那所有参与者都必须一直等待。

性能问题:两段提交过程中,所有参与者相当于被绑定成为一个统一调度的整体,期间要经过两次远程服务调用、三次数据持久化(准备阶段写重做日志,协调者做状态持久化,提交阶段在日志写入Commit Record),整个过程将持续到参与者集群中最慢的那一个处理操作结束为止。这就决定了两段式提交的性能通常都比较差。

一致性风险:当网络稳定性和宕机恢复能力的假设不成立时,两段式提交可能会出现一致性问题。

宕机恢复能力这一点无需多说。1985年Fischer、Lynch、Paterson用定理(被称为FLP不可能原理,在分布式中与CAP定理齐名)证明了如果宕机最后不能恢复,那就不存在任何一种分布式协议可以正确地达成一致性结果。

我们重点看看网络稳定性带来的一致性风险。尽管提交阶段时间很短,但仍是明确存在的危险期。如果协调者在发出准备指令后,根据各个参与者发回的信息确定事务状态是可以提交的,协调者就会先持久化事务状态,并提交自己的事务。如果这时候网络忽然断开了,无法再通过网络向所有参与者发出Commit指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)既未提交也没办法回滚,导致数据不一致。

三段式提交

为了解决两段式提交的单点问题、性能问题和数据一致性问题,“三段式提交”(3 Phase Commit,3PC)协议出现了。但是三段式提交,也并没有解决一致性问题。

这是为什么呢?别着急,接下来我就具体和你分析下其中的缘由,以及了解三段式提交是否真正解决了单点问题和性能问题。

三段式提交把原本的两段式提交的准备阶段再细分为两个阶段,分别称为CanCommit、PreCommit,把提交阶段改为DoCommit阶段。其中,新增的CanCommit是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。

将准备阶段一分为二的理由是,这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,这时候涉及的数据资源都会被锁住。如果此时某一个参与者无法完成提交,相当于所有的参与者都做了一轮无用功。

所以,增加一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,也意味着因某个参与者提交时发生崩溃而导致全部回滚的风险相对变小了。

因此,在事务需要回滚的场景中,三段式的性能通常要比两段式好很多,但在事务能够正常提交的场景中,两段式和三段式提交的性能都很差,三段式因为多了一次询问,性能还要更差一些。

同样地,也是因为询问阶段使得事务失败回滚的概率变小了,所以在三段式提交中,如果协调者在PreCommit阶段开始之后发生了宕机,参与者没有能等到DoCommit的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待。你看,这就相当于避免了协调者的单点问题。

三段式提交的操作时序如下图所示。

可以看出,三段式提交对单点问题和回滚时的性能问题有所改善,但是对一致性风险问题并未有任何改进,甚至是增加了面临的一致性风险。为什么这么说呢?

我们看一个例子。比如,进入PreCommit阶段之后,协调者发出的指令不是Ack而是Abort,而此时因为网络问题,有部分参与者直至超时都没能收到协调者的Abort指令的话,这些参与者将会错误地提交事务,这就产生了不同参与者之间数据不一致的问题。

共享事务

与全局事务的单个服务使用多个数据源正好相反,共享事务是指多个服务共用同一个数据源。

这里,我要再强调一次“数据源”与“数据库”的区别:数据源是指提供数据的逻辑设备,不必与物理设备一一对应。

在部署应用集群时最常采用的模式是,将同一套程序部署到多个中间件服务器上,构成多个副本实例来分担流量压力。它们虽然连接了同一个数据库,但每个节点配有自己的专属数据源,通常是中间件以JNDI的形式开放给程序代码使用。

这种情况下,所有副本实例的数据访问都是完全独立的,并没有任何交集,每个节点使用的仍是最简单的本地事务。但是有些场景下,多个服务之间是有业务交集的,它们可能会共用一个数据源,共享事务也有可能成为专门针对这种业务场景的一种解决方案。

举个例子。在Fenix’s Bookstore的场景事例中,假设用户账户、商家账户和商品仓库都存储在同一个数据库里面,但用户、商户和仓库每个领域部署了独立的微服务。此时,一次购书的业务操作将贯穿三个微服务,而且都要在数据库中修改数据。

如果我们直接将不同数据源视为不同的数据库,那我们完全可以用全局事务或者下一讲要学习的分布式事务来实现。不过,针对每个数据源连接的都是同一个物理数据库的特例,共享事务可能是另一条可以提高性能、降低复杂度的途径,当然这也很有可能是一个伪需求。

一种理论可行的方案是,直接让各个服务共享数据库连接。同一个应用进程中的不同持久化工具(JDBC、ORM、JMS等)共享数据库连接并不困难,一些中间件服务器(比如WebSphere),就内置了“可共享连接”功能来专门支持共享数据库的连接。

但这种“共享”的前提是,数据源的使用者都在同一个进程内。由于数据库连接的基础是网络连接,它是与IP地址和端口号绑定的,字面意义上的“不同服务节点共享数据库连接”很难做到。所以,为了实现共享事务,就必须新增一个中间角色,也就是交易服务器。无论是用户服务、商家服务还是仓库服务,它们都要通过同一台交易服务器来与数据库打交道。

如果将交易服务器的对外接口实现为满足JDBC规范,那它完全可以看作一个独立于各个服务的远程数据库连接池,或者直接作为数据库代理来看待。此时,三个服务所发出的交易请求就有可能做到,由交易服务器上的同一个数据库连接,通过本地事务的方式完成。

比如,交易服务器根据不同服务节点传来的同一个事务ID,使用同一个数据库连接来处理跨越多个服务的交易事务。

之所以强调理论可行,是因为这个方案,其实是与实际生产系统中的压力方向相悖的。一个服务集群里,数据库才是压力最大、最不容易伸缩拓展的重灾区。

所以,现实中只有类似ProxySQLMaxScale这样用于对多个数据库实例做负载均衡的数据库代理,而几乎没有反过来代理一个数据库为多个应用提供事务协调的交易服务代理。

这也是为什么说它更有可能是个伪需求的原因。如果你有充足理由让多个微服务去共享数据库,那就必须找到更加站得住脚的理由,来向团队解释拆分微服务的目的是什么。

让多个微服务去共享一个数据库这个方案,其实还有另一种应用形式:使用消息队列服务器来代替交易服务器,用户、商家、仓库的服务操作业务时,通过消息将所有对数据库的改动传送到消息队列服务器,然后通过消息的消费者来统一处理,实现由本地事务保障的持久化操作。这就是“单个数据库的消息驱动更新”(Message-Driven Update of a Single Database)。

“共享事务”这种叫法,以及我们刚刚讲到的通过交易服务器或者通过消息驱动来更新单个数据库这两种处理方式,在实际应用中并不常见,也几乎没有相应的成功案例,能够查到的资料几乎都来源于十多年前Spring的核心开发者Dave Syer的文章“Distributed Transactions in Spring, with and without XA”。

正如我在这一讲的开头所说,我把共享事务和本地事务、全局事务、分布式事务并列成为四大事务类型,更多的考虑到事务演进过程的完备性,也是为了方便你理解这三种事务类型。同时,拆分微服务后仍然共享数据库的案例,我们经常会在实践中看到,但我个人仍旧不赞同将共享事务看作是一种常规的解决方案。

小结

这节课我们学习了全局事务和共享事务的实现方式。目前,共享事务确实已经很少见了,但是全局事务中的两段式提交和三段式提交模式仍然会在一些多数据源的场景中用到,Java的JTA事务也仍然有一定规模的用户群体。

两段式提交和三段式提交仍然追求ACID的强一致性,这个目标不仅给它带来了很高的复杂度,而且吞吐量和使用效果上也不佳。因此,现在系统设计的主流,已经变成了不追求ACID而是强调BASE的弱一致性事务,这就是我们要在下一讲学习的分布式事务了。

一课一思

你开发过的系统使用过全局事务和共享事务吗?你当时是如何实现这些事务的呢?

欢迎在留言区分享你的答案。如果你觉得有收获,欢迎你把今天的内容分享给更多的朋友。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e5%91%a8%e5%bf%97%e6%98%8e%e7%9a%84%e6%9e%b6%e6%9e%84%e8%af%be/13%20_%20%e5%85%a8%e5%b1%80%e4%ba%8b%e5%8a%a1%e5%92%8c%e5%85%b1%e4%ba%ab%e4%ba%8b%e5%8a%a1%e6%98%af%e5%a6%82%e4%bd%95%e5%ae%9e%e7%8e%b0%e7%9a%84%ef%bc%9f.md