本地事务相对比较简单,很容易实现。但是上升到全局事务再到分布式事务就比较麻烦,对保证数据一致性就需要做很多额外的处理。

从全局事务(Global Transactions)谈起

全局事务即外部事务(External Transaction):一种适用于单个服务使用多个数据源场景的事务解决方案。

实际上DTP(Distributed Transaction Processing)中没有上边这种限定。本篇分为两大类:

  1. 在分布式(单服务多个数据源)环境中追求强一致性。
  2. 在分布式(微服务)中放弃ACID追求弱一致性。

XA(Extended Architecture)协议

定义了全局事务管理器(Transaction Manager):用于协调全局事务局部的资源管理器(Resources Manager):用于驱动本地事务之间的通讯接口。

XA接口是双向的,一个事务管理器和多个资源管理器之间通信的桥梁,协调多个数据源保持一致,来实现全局事务的统一提交或者统一回滚。XADataSource XAResource。XA并不是java规范,是一套通用技术。 Java后来专门定义了一套全局事务处理标准JTA

JTA(Java Transaction API)

Java定义的一套全局事务处理标准。

  • 事务管理器接口: 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 容器来提供支持,但现在Bittronix、Atomikos和JBossTM(以前叫 Arjuna)都以 JAR 包的形式实现了 JTA 的接口,也就是 JOTM(Java Open Transaction Manager)。有了 JOTM 的支持,我们就可以在 Tomcat、Jetty 这样的 Java SE 环境下使用 JTA 了。

XA 和 JTA的关系

XA refers to eXtended Architecture, which is a specification for distributed transaction processing. The goal of XA is to provide atomicity in global transactions involving heterogeneous components.

XA specification provides integrity through a protocol known as a two-phase commit. Two-phase commit is a widely-used distributed algorithm to facilitate the decision to commit or rollback a distributed transaction.

Java Transaction API (JTA) is a Java Enterprise Edition API developed under the Java Community Process. It enables Java applications and application servers to perform distributed transactions across XA resources.

JTA is modeled around XA architecture, leveraging two-phase commit. JTA specifies standard Java interfaces between a transaction manager and the other parties in a distributed transaction.

XA指的是eXtended Architecture,是一种用于分布式事务处理的规范。XA的目标是在涉及异构组件的全局事务中提供原子性。

XA规范通过一个称为两阶段提交的协议提供了一致性。两阶段提交是一种广泛使用的分布式算法,用于促进决定是提交还是回滚分布式事务。

Java事务API(JTA)是在Java社区流程下开发的Java企业版API。它使Java应用程序和应用服务器能够跨XA资源执行分布式事务。

JTA围绕XA架构建模,利用两阶段提交。JTA规定了分布式事务中事务管理器与其他参与方之间的标准Java接口。

两阶段提交(2 Phase Commit 2PC)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

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();
}
}

如上开启了三个事务,业务处理完成后做了三次事务提交。但是如果三个commit中第二个和第三个commit出现了Exception那么已经提交的事务rollback不了,这样就破坏了全局事务的一致性。为了解决这种问题提出了两阶段提交。

  • 准备阶段:投票阶段,协调者询问所有事务参与者是否已经准备好,准备好:Prepared 否则:Non-Prepared。对于数据库来讲,准备操作是是在重做日志中记录全部事务提交操作所要做的内容,与本地事务主要区别是:暂时不写入最后一条Commit Record。这意味着做完数据持久化后暂时不会释放隔离性,也就是依然持有锁。
  • 提交阶段:协调者受到所有事务参与者恢复的Prepared消息,就会首先在本地持久化事务状态未Commit,然后向所有参与者发送Commit指令。否则任意一个参与者回复Non-Prepared消息,协调者都会将自己事务状态持久化未Abort并且发送给所有参与者。

因为提交阶段相对轻量级,仅仅是持久化一条指令 Commit Record能够快速完成。回滚阶段则相对耗时,收到Abort时需要根据Undo log清理已经提交的数据。

缺点:

  • 单点问题:协调者单点
  • 性能问题: 两阶段提交过程中,所有参与者相当于被绑定成为一个统一调度整体,期间要经历两次远程服务调用,三次数据持久化(准备阶段写Redo Log,协调者做状态持久化,提交阶段在日志写入 Commit Record),整个过程将持续到参与者集群中最慢的处理操作结束为止。
  • 一致性风险: 网络稳定性带来的一致性风险。尽管提交阶段时间很短,但仍是明确存在的危险期。如果协调者在发出准备指令后,根据各个参与者发回的信息确定事务状态是可以提交的,协调者就会先持久化事务状态,并提交自己的事务。如果这时候网络忽然断开了,无法再通过网络向所有参与者发出 Commit 指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)既未提交也没办法回滚,导致数据不一致。

java中如何使用两阶段提交

Java-atomikos: https://www.baeldung.com/java-atomikos

三段式提交

将两阶段提交的准备阶段再细分两个阶段:

  1. CanCommit: 询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务释放有可能顺利完成。
  2. PreCommit:

提交阶段改为:

  1. DoCommit

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

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

如果协调者在 PreCommit 阶段开始之后发生了宕机,参与者没有能等到 DoCommit 的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待。你看,这就相当于避免了协调者的单点问题。

image-20220715092405378

共享事务

  1. 全局事务是单个服务使用多个数据源,共享事务是指多个服务共用一个数据源。

  2. “数据源”与“数据库”的区别:数据源是指提供数据的逻辑设备,不必与物理设备一一对应。

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

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

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

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

分布式事务

多个服务同时访问多个数据源的处理机制。

CAP理论

  1. 一致性(Consistency):代表在任何时刻,任何分布式节点中,我们所看到的数据都是没有矛盾的。与ACID中的C单词相同含义不同。
  2. 可用性(Available):代表系统不间断提供服务。
  3. 分区容忍性(Partition Tolerance):代表在分布式环境中,当部分节点因为网络原因而彼此失联(即与其他节点形成“网络分区”)时,系统仍然能够正常的工作。

放弃分区容忍性:

CA: 这意味着,我们将假设节点之间的通讯永远是可靠的。可是永远可靠的通讯在分布式系统中必定是不成立的,这不是你想不想的问题,而是网络分区现象始终会存在。

在现实场景中,主流的 RDBMS(关系数据库管理系统)集群通常就是采用放弃分区容错性的工作模式。以 Oracle 的 RAC 集群为例,它的每一个节点都有自己的 SGA(系统全局区)、重做日志、回滚日志等,但各个节点是共享磁盘中的同一份数据文件和控制文件的,也就是说,RAC 集群是通过共享磁盘的方式来避免网络分区的出现。

放弃可用性:

CP: 这意味着,我们将假设一旦发生分区,节点之间的信息同步时间可以无限制地延长,那么这个问题就相当于退化到了上一讲所讨论的全局事务的场景之中,即一个系统可以使用多个数据源。我们可以通过 2PC/3PC 等手段,同时获得分区容错性和一致性。

在现实中,除了 DTP 模型的分布式数据库事务外,著名的 HBase 也是属于 CP 系统。以它的集群为例,假如某个 RegionServer 宕机了,这个 RegionServer 持有的所有键值范围都将离线,直到数据恢复过程完成为止,这个时间通常会是很长的。

放弃一致性:

这意味着,我们将假设一旦发生分区,节点之间所提供的数据可能不一致。

AP : 系统目前是分布式系统设计的主流选择,大多数的 NoSQL 库和支持分布式的缓存都是 AP 系统。因为 P 是分布式网络的天然属性,你不想要也无法丢弃;而 A 通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就没有存在的价值了(除非银行这些涉及到金钱交易的服务,宁可中断也不能出错)。

以 Redis 集群为例,如果某个 Redis 节点出现网络分区,那也不妨碍每个节点仍然会以自己本地的数据对外提供服务。但这时有可能出现这种情况,即请求分配到不同节点时,返回给客户端的是不同的数据。

ACID和CAP

把前面我们在 CAP、ACID 中讨论的一致性称为“强一致性”(Strong Consistency),有时也称为“线性一致性”(Linearizability),而把牺牲了 C 的 AP 系统,又要尽可能获得正确的结果的行为,称为追求“弱一致性”。

弱一致性:最终一致性

强一致性:刚性事务

可靠消息队列

eBay系统架构师:丹.普利切特(Dan Pritchett)提出了BASE理论提出了最终一致性概念。

系统建立一个消息服务,定时轮询消息表,将状态是“进行中”的消息同时发送到下游关联系统。

有一些支持分布式事务的消息框架,如 RocketMQ,原生就支持分布式事务操作,这时候前面提到的情况 2、4 也可以交给消息框架来保障。

最大努力交付(Best-Effort Delivery)

靠着持续重试来保证可靠性的操作。比如 TCP 协议中的可靠性保障,就属于最大努力交付。

最大努力一次交付(Best-Effort 1PC)

把可能出错的业务,以本地事务的方式完成后,经过不断的重试(不限于消息系统)来促使同个事务的关联业务完成。

TCC(Try-Confirm-Cancel)和SAGA

可靠消息队列的缺点就是没有隔离性。

TCC

TCC方案,它天生适用于需要强调隔离性的分布式事务中。它是一中业务侵入性比较强的事务方案,要求处理过程必须拆分为:“预留业务资源”和“确认/释放消费资源”两个子过程。

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

实际的业务请求如下:

  1. 业务发出更新请求。
  2. 创建事务,生成事务ID,记录在活动日志中,进入Try阶段。
  3. 如果第二步中所有的业务都反馈业务可行,就将活动日志中的记录为Confirm,进入Confirm阶段
  4. 如果第三步的操作全部完成了,事务就会宣告正常结束。而如果第三步中的任何一方出现了异常,不论是业务异常还是网络异常,都将会根据活动日志中的记录,来重复执行该服务的 Confirm 操作,即进行“最大努力交付”。
  5. 如果是在第二步,有任意一方反馈业务不可行,或是任意一方出现了超时,就将活动日志的状态记录为 Cancel,进入 Cancel 阶段:
  6. 如果第五步全部完成了,事务就会宣告以失败回滚结束。而如果第五步中的任何一方出现了异常,不论是业务异常还是网络异常,也都将会根据活动日志中的记录,来重复执行该服务的 Cancel 操作,即进行“最大努力交付”。

优点:

  • TCC 其实有点类似于 2PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面,这就为它的实现带来了较高的灵活性,我们可以根据需要设计资源锁定的粒度。

  • TCC 在业务执行的时候,只操作预留资源,几乎不会涉及到锁和资源的争用,所以它具有很高的性能潜力。

缺点: TCC 最主要的限制是它的业务侵入性很强,但并不是指由此给开发编码带来的工作量,而是指它所要求的技术可控性上的约束。

SAGA

TCC 在业务执行的时候,只操作预留资源,几乎不会涉及到锁和资源的争用,所以它具有很高的性能潜力。通常我们并不会完全靠裸编码来实现 TCC,而是会基于某些分布式事务中间件(如阿里开源的Seata)来完成,以尽量减轻一些编码工作量。

来源:SAGA 事务模式的历史十分悠久,比分布式事务的概念提出还要更早。SAGA 的意思是“长篇故事、长篇记叙、一长串事件”,它起源于 1987 年普林斯顿大学的赫克托 · 加西亚 · 莫利纳(Hector Garcia Molina)和肯尼斯 · 麦克米伦(Kenneth Salem)在 ACM 发表的一篇论文《SAGAS》(这就是论文的全名)

文中提出了一种如何提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务的集合。原本提出 SAGA 的目的,是为了避免大事务长时间锁定数据库的资源,后来才逐渐发展成将一个分布式环境中的大事务,分解为一系列本地事务的设计模式。

SAGA的组成部分
  1. 把大事务T拆分成为若干小事务 T1…Tn,每个子事务都能被看作是原子行为。如果分布式事务T能够正常提交,那么它对数据的影响(最终一致性)就应该与连续按顺序成功提交子事务 Ti 等价。

    T - commit == T1 - commit + T2 - commit …Ti - commit… Tn - commit

  2. 每个子事务T1…Tn涉及对应的补偿动作,命名为:C1,C2,…, Ci, …,Cn

    • Ti 与 Ci 都具备幂等性;
    • Ti 与 Ci 满足交换律(Commutative),即不管是先执行 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 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。

SAGA 必须保证所有子事务都能够提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log),以保证系统恢复后可以追踪到子事务的执行情况,比如执行都到哪一步或者补偿到哪一步了。

SAGA 事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成。

AT 事务

AT 事务是参照了 XA 两段提交协议来实现的,但针对 XA 2PC 的缺陷,即在准备阶段,必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应(所有涉及到的锁和资源,都需要等到最慢的事务完成后才能统一释放),AT 事务也设计了针对性的解决方案。

  1. 自动拦截所有 SQL,分别保存 SQL 对数据修改前后结果的快照,生成行锁,通过本地事务一起提交到操作的数据源中,这就相当于自动记录了重做和回滚日志。
  2. 如果分布式事务成功提交了,那么我们后续只需清理每个数据源中对应的日志数据即可;而如果分布式事务需要回滚,就要根据日志数据自动产生用于补偿的“逆向 SQL”。
  3. 基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。AT 事务这种异步提交的模式,相比 2PC 极大地提升了系统的吞吐量水平。而使用的代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚不一定总能成功。
  4. 当在本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Wirte),而这个时候一旦出现分布式事务需要回滚,就不可能再通过自动的逆向 SQL 来实现补偿,只能由人工介入处理了。
  5. 一般来说,对于脏写我们是一定要避免的,所有传统关系数据库在最低的隔离级别上,都仍然要加锁以避免脏写。因为脏写情况一旦发生,人工其实也很难进行有效处理。
  6. GTS 增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,而在没有获得全局锁之前就必须一直等待。
  7. 这种设计以牺牲一定性能为代价,避免了在两个分布式事务中,数据被同一个本地事务改写的情况,从而避免了脏写。
  8. 在读隔离方面,AT 事务默认的隔离级别是读未提交(Read Uncommitted),这意味着可能会产生脏读(Dirty Read)。读隔离也可以采用全局锁的方案来解决,但直接阻塞读取的话,我们要付出的代价就非常大了,一般并不会这样做。

Comment and share

  • page 1 of 1
Author's picture

Topsion

Fullstack Developer


Coder


Xi'an China