分布式事务浅析
分布式事务主要有两个应用场景:
- 跨库,单服务多数据源。这通常意味着需要访问不同机器上的数据库实例,所以本地事务机制失效。
- 不同的服务,多服务多数据源。广义的分布式事务的概念不局限于数据库,我们有时候也会需要保证调用多个不同服务(比如订单下单、用户支付、减库存)要么全部成功,要么全部回滚。此时,分布式事务是相当于服务而言的。
在全局事务 | 凤凰架构 (icyfenix.cn)中,周志明老师将单个服务多个数据源的事务称为全局事务,但是在很多资料中这种就算是分布式事务的一种,只不过这样的“分布式事务”追求的是强一致性,而多服务多数据源(也就是现在的微服务)追求的是最终一致性。在本文中,我希望把它们还是统一称为分布式事务,只不过追求强一致性的是刚性分布式事务
,追求最终一致性的是柔性分布式事务
。
刚性分布式事务
XA模式
刚性分布式事务的典型代表就是XA模式,其定义了全局的事务管理器(协调者)和局部的资源管理器(参与者)之间的通信架构。XA的核心思想就是两阶段提交:
准备阶段
:这里的准备不是通常语义上的执行前动作,它和执行的区别只在于有没有写入最后一条Commit Record而已,这一阶段会记录Redo log和Undo log来为后续的提交或回滚做准备。所以,在这一阶段需要进行数据持久化,而且并不立即释放锁。提交阶段/执行阶段
:当上一阶段完成后协调者收到所有参与者回复的Prepare消息,则协调者会向所有参与者发送Commit指令,所有参与者立即执行提交操作。否则,任意一个参与者回复了Non-Prepared消息或者超时未回复,则协调者向所有参与者发送Abort指令,所有参与者立即执行回滚操作。
其实不仅是分布式事务中会用到两阶段提交,MySQL的本地事务中就已经存在两阶段提交的设计理念(redo log的写入过程)。分布式事务中的两阶段提交虽然原理简单,但是存在几个显著的缺点:
-
陷入阻塞。这是两阶段协议的最大缺点:在整个执行过程中(两次远程调用,三次持久化)节点处于阻塞状态,其他节点若要访问该节点占有的资源需要等待。
-
单点故障。两阶段的核心是协调者,允许参与者宕机(协调者等待参与者有超时机制), 一旦宕机的是协调者,所有参与者都需等待。
-
一致性风险。首先是网络问题,在提交阶段时,假如协调者只向一部分参与者发送提交指令,还没来得及向剩余参与者发送提交指令就网络中断,此时就会出现部分提交。假如网络一直不恢复,已提交的参与者也不会回滚,这样就出现了参与者不一致的情况。另外在宕机无法恢复时,就是著名的FLP定理:即使只有一个进程出现故障(中断或失败),也没有任何一种分布式算法能保证其他进程的一致性。
变体-三阶段提交
虽然两阶段在提交时所需的工作量很少,只是持久化一条 Commit Record 而已,但是回滚需要把之前提交的数据全部清除,这是相对比较重的操作。为了降低回滚的概率,三阶段提交就是在两阶段提交的准备阶段前先询问一下,如果各个参与者评估之后有一个发现资源不够不能执行,那么就不会进入到准备阶段,也避免了后续可能发生的回滚。
不过三阶段提交应用并不广泛,这主要是因为正常情况下回滚的概率不高,在没有发生回滚时,三阶段提交由于比两阶段提交多了一个阶段,性能反而是有所下降的。同样的,在一致性方面,三阶段也存在更多网络故障或者宕机导致的不一致风险。
实际应用
在seata与spring-boot中,XA事务模式只需要配置好数据源,在最终的业务方法标上@GlobalTransactional
即可。
柔性分布式事务
可靠事件队列
我们以一个例子来介绍:订单下单->用户支付->扣减库存->货物送达->商家收款。可靠事件队列的思想就是在第一步完成之后,剩下的后续步骤必须成功,这种保证是通过无限制的重试来实现。这种靠着持续重试来保证可靠性的解决方案有了专门的名字叫作“最大努力交付”(Best-Effort Delivery)。而刚才所说的这种形式被称为”最大努力一次提交”(Best-Effort
1PC),即将最有可能出错的业务完成后,采用不断重试的方式来促使同一个分布式事务中的其他关联业务全部完成。
可靠事件队列的缺点显而易见,由于第一步完成之后,剩下步骤失败只是不断重试,没有回滚操作,这导致某些时候会陷入永远无法成功的尴尬境地(例如用户银行卡余额不足,而用户实际上不可能负担起货物的价格了)。
TCC
TCC是一种常见的分布式事务机制,它是“Try-Confirm-Cancel”三个单词的缩写。它分为三个阶段:
- Try:参与者执行所有业务检查(保障一致性),尝试预留必须的资源,但不会真正提交事务。
- Confirm:假如Try阶段成功,参与者执行真正的提交操作,释放之前预留的资源,完成事务的提交。
- Cancel:假如响应超时或者Try阶段失败,参与者会执行回滚操作,释放之前预留的资源,恢复事务开始前的状态。
需要注意的是,Confirm 和 Cancel 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。虽然TCC分为三个阶段,但是我认为确认阶段和取消阶段其实是同一个操作的不同表现形式,应该算作同一个阶段,毕竟同一个阶段只会执行其中一种操作(确认或者回滚)。
对比XA
这么看的话,TCC和XA也有相似之处,一开始TCC的尝试阶段和XA的准备阶段收到任何参与者返回的失败响应都会回滚。只不过在TCC中是回滚已经锁定的预留资源,在XA中是数据库操作回滚。假如没有回滚,TCC和XA的提交阶段都会不断重试直至所有参与者返回成功。
实际应用
由于不再局限于数据库本身,所以区别于在 AT 模式直接使用数据源代理来屏蔽分布式事务细节,我们在使用seata的TCC模式时需要自行定义 TCC 资源的“准备”、“提交”和“回滚” 。比如在下方的例子:
public interface TccActionOne {
@TwoPhaseBusinessAction(name = "DubboTccActionOne", commitMethod = "commit", rollbackMethod = "rollback")
public boolean prepare(BusinessActionContext actionContext, @BusinessActionContextParameter(paramName = "a") String a);
public boolean commit(BusinessActionContext actionContext);
public boolean rollback(BusinessActionContext actionContext);
}
Seata 会把一个 TCC 接口当成一个 Resource,也叫 TCC Resource。在业务接口中核心的注解是 @TwoPhaseBusinessAction
,表示当前方法使用 TCC 模式管理事务提交,并标明了 Try,Confirm,Cancel 三个阶段。name属性,给当前事务注册了一个全局唯一的的 TCC bean name。同时 TCC 模式的三个执行阶段分别是:
- Try 阶段,预定操作资源(Prepare) 这一阶段所以执行的方法便是被
@TwoPhaseBusinessAction
所修饰的方法。如上面代码中的prepare
方法。 - Confirm 阶段,执行主要业务逻辑(Commit) 这一阶段使用
commitMethod
属性所指向的方法,来执行Confirm 的工作。 - Cancel 阶段,事务回滚(Rollback) 这一阶段使用
rollbackMethod
属性所指向的方法,来执行 Cancel 的工作。
Saga
TCC 的最主要限制是它的业务侵入性很强,这里的侵入性重点还不在于代码编写和设计,关键在于能否允许在其他模块中嵌入分布式事务相关的代码。比如银行支付肯定不会允许你预留资源(冻结货款),那TCC的第一步就无法完成。
与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,只需要设计对应的补偿动作。譬如前面提到的银行支付场景,如果用户支付成功后其他步骤失败,尽管我们无法要求银行直接回滚之前的用户支付操作,但是由平台通知银行将货款转回到用户账上作为补偿却是可行的。所以在 Saga 模式中,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者。
Saga的一个缺点就是以补偿代替回滚并不总是可行的,假设有一种场景是先给用户支付退款,用户收到退款后立马消费掉所有余额,这时想再从用户钱包扣款(对应的补偿措施)就是不可行的。这种情况一般还是需要根据业务场景来安排顺序,通常情况下给用户的钱可以晚一点,扣用户的钱可以早一点(平台本身信誉通常要比个人用户高)。
实际应用
SAGA 在英文中是“长篇故事、长篇记叙、一长串事件”的意思。所以在seata模式中,核心就是设计每一个子事务的正向和补偿操作。如下面示例:
public interface InventoryAction {
boolean reduce(String businessKey, BigDecimal amount, Map<String, Object> params);
boolean compensateReduce(String businessKey, Map<String, Object> params);
}
AT
AT 模式是 Seata 创新的一种非侵入式的分布式事务解决方案,本质是对两阶段提交(XA模式)的一种改进。由于两阶段提交的性能问题最为关键,所以AT就在准备阶段即提交本地事务并释放资源,避免了木桶效应(所有涉及的锁和资源都需要等待到最慢的事务完成后才能统一释放)。具体操作如下:
-
一阶段:AT会专门新建一张UNDO_LOG表,在每次执行SQL前后对应快照,组装相关信息记录在UNDO_LOG中,相当于自动记录了重做和回滚日志。这样业务数据和回滚日志记录在同一个本地事务中提交,随后就可以释放本地锁和连接资源。
-
二阶段:
- 正常提交。收到事务管理器的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给事务管理器。在请求中异步和批量地删除相应 UNDO LOG 记录。
- 需要回滚。根据 UNDO LOG 中的前快照和业务 SQL 的相关信息生成并执行回滚的语句。
由于AT采用补偿的方式来回滚,其实和Saga一样并不总是可行的,改进了XA的性能问题,会引入新的隔离性/一致性问题。
实际应用
由于AT是对两阶段提交的一种改进,所以使用方式上与XA差别不大。在Seata中同样是只需要配置好数据源,在最终的业务方法标上@GlobalTransactional
即可。
总结
- XA属于强一致性保证,但是容易因为木桶效应导致出现性能问题
- AT模式对XA做了改进,能够尽快释放锁和资源,但是代价就是牺牲了隔离性。一旦出现脏写(本地事务提交之后该数据被其他操作修改),就不能再通过逆向SQL语句来回滚了。
- TCC属于性能最好的柔性分布式事务模式,但是侵入性太强,其他微服务模块往往不能接受直达底层结构的侵入改造
- Saga以补偿代替回滚,可行性更高,但是这样会牺牲隔离性
- 综上所述,没有任何一种分布式事务模式是完美的,还需要因地制宜。而且任何分布式事务都需要其他微服务模块配合改造(Saga也需要对方微服务提高补偿操作),在其他微服务模块完全无法配合改造的情况下,最后反而还是需要通过类似可靠事件队列的模式不断重试(比如依赖MQ的重试机制)。
如今互联网上各类文章满天飞,但是大部分要不是寥寥数语,让人过目即忘;要不是过多细枝末节又没有实操,让人不知所云。我将从个人学习和工作经历出发,给大家带来深入浅出的技术解析。我的文章力求简短精悍,尽量结合实战,以便大家在碎片时间即可充分吸收,后续还能学以致用。
欢迎大家关注我的微信公众号,所有文章第一时间更新~