数据库分布式事务

SQL 分布式事务 distributed transaction 二段提交, 本地消息表, Saga 事务, 最大努力通知

分布式事务-本地消息表 (经典的ebay模式)

TCC Try-Confirm-Cancel 分布式事务

SQL 2PC-两阶段提交 SQL 分布式事务两阶段提交协议(2PC)是一种原子承诺协议(ACP)。

SQL 2PL-两阶段锁定

3pc Three-Phase Commit 三阶段提交 分布式事务

Compensating Transaction pattern 分布式锁事务

分布式事务

概念

分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。

简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。

本质上来说,分布式事务就是为了保证不同数据库的数据一致性

产生的原因

从本地事务来看,我们可以看为两块,一个是 service 产生多个节点,另一个是 resource 产生多个节点。

service 多个节点

随着互联网快速发展,微服务,SOA等服务架构模式正在被大规模的使用,

举个简单的例子,一个公司之内,用户的资产可能分为好多个部分,比如余额,积分,优惠券等等。

在公司内部有可能积分功能由一个微服务团队维护,优惠券又是另外的团队维护。

这样的话就无法保证积分扣减了之后,优惠券能否扣减成功。

resource 多个节点

同样的,互联网发展得太快了,我们的Mysql一般来说装千万级的数据就得进行分库分表,对于一个支付宝的转账业务来说, 你给的朋友转钱,有可能你的数据库是在北京,而你的朋友的钱是存在上海,所以我们依然无法保证他们能同时成功。

理论基础

BASE/ACID/CAP

SQL 基础及编程实现

Isolation

mvcc

Spring Transaction

解决方案

是否真的要分布式事务?

在说方案之前,首先你一定要明确你是否真的需要分布式事务?

上面说过出现分布式事务的两个原因,其中有个原因是因为微服务过多。

我见过太多团队一个人维护几个微服务,太多团队过度设计,搞得所有人疲劳不堪,而微服务过多就会引出分布式事务,这个时候我不会建议你去采用下面任何一种方案,而是请把需要事务的微服务聚合成一个单机服务,使用数据库的本地事务。

因为不论任何一种方案都会增加你系统的复杂度,这样的成本实在是太高了,千万不要因为追求某些设计,而引入不必要的成本和复杂度。

如果你确定需要引入分布式事务可以看看下面几种常见的方案。

2pc

二段提交

TCC

关于TCC(Try-Confirm-Cancel)的概念,最早是由Pat Helland于2007年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。

TCC 事务机制相比于上面介绍的 2pc,解决了其几个缺点:

  1. 解决了协调者单点,由主业务方发起并完成这个业务活动。业务活动管理器也变成多点,引入集群。

  2. 同步阻塞: 引入超时,超时后进行补偿,并且不会锁定整个资源,将资源转换为业务逻辑形式,粒度变小。

  3. 数据一致性,有了补偿机制之后,由业务活动管理器控制一致性。

Try-Confirm-Cancel

本地消息表

此方案的核心是将需要分布式处理的任务通过消息日志的方式来异步执行。

消息日志可以存储到本地文本、数据库或消息队列,再通过业务规则自动或人工发起重试。

人工重试更多的是应用于支付场景,通过对账系统对事后问题的处理。

对于本地消息队列来说核心是把大事务转变为小事务。

例子

还是举上面用100元去买一瓶水的例子。

  1. 当你扣钱的时候,你需要在你扣钱的服务器上新增加一个本地消息表,你需要把你扣钱和写入减去水的库存到本地消息表放入同一个事务(依靠数据库本地事务保证一致性。

  2. 这个时候有个定时任务去轮询这个本地事务表,把没有发送的消息,扔给商品库存服务器,叫他减去水的库存,到达商品服务器之后这个时候得先写入这个服务器的事务表,然后进行扣减,扣减成功后,更新事务表中的状态。

  3. 商品服务器通过定时任务扫描消息表或者直接通知扣钱服务器,扣钱服务器本地消息表进行状态更新。

  4. 针对一些异常情况,定时扫描未成功处理的消息,进行重新发送,在商品服务器接到消息之后,首先判断是否是重复的,如果已经接收,在判断是否执行,如果执行在马上又进行通知事务,如果未执行,需要重新执行需要由业务保证幂等,也就是不会多扣一瓶水。

本地消息队列是 BASE 理论,是最终一致模型,适用于对一致性要求不高的。实现这个模型时需要注意重试的幂等。

本地消息表-MQ 事务

MQ 事务

在 RocketMQ 中实现了分布式事务,实际上其实是对本地消息表的一个封装,将本地消息表移动到了 MQ 内部,下面简单介绍一下 MQ 事务。

基本流程

基本流程如下:

第一阶段Prepared消息,会拿到消息的地址。

第二阶段执行本地事务。

第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。消息接受者就能使用这个消息。

如果确认消息失败,在RocketMq Broker中提供了定时扫描没有更新状态的消息,如果有消息没有得到确认,会向消息发送者发送消息,来判断是否提交,在rocketmq中是以listener的形式给发送者,用来处理。

如果消费超时,则需要一直重试,消息接收端需要保证幂等。

如果消息消费失败,这个就需要人工进行处理,因为这个概率较低,如果为了这种小概率时间而设计这个复杂的流程反而得不偿失。

Saga 事务

其核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。

概念

  • 组成

每个 Saga 由一系列sub-transaction Ti 组成 每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果,这里的每个T,都是一个本地事务。

可以看到,和 TCC 相比,Saga没有“预留 try”动作,它的Ti就是直接提交到库。

  • 执行殊勋

Saga的执行顺序有两种:

T1, T2, T3, …, Tn T1, T2, …, Tj, Cj,…, C2, C1,其中0 < j < n

  • 恢复策略

Saga定义了两种恢复策略:

向后恢复,即上面提到的第二种执行顺序,其中j是发生错误的sub-transaction,这种做法的效果是撤销掉之前所有成功的sub-transation,使得整个Saga的执行结果撤销。

向前恢复,适用于必须要成功的场景,执行顺序是类似于这样的:T1, T2, …, Tj(失败), Tj(重试),…, Tn,其中j是发生错误的sub-transaction。该情况下不需要Ci。

这里要注意的是,在 saga 模式中不能保证隔离性,因为没有锁住资源,其他事务依然可以覆盖或者影响当前事务。

例子

还是拿100元买一瓶水的例子来说,这里定义

T1=扣100元 T2=给用户加一瓶水 T3=减库存一瓶水

C1=加100元 C2=给用户减一瓶水 C3=给库存加一瓶水

我们一次进行T1,T2,T3如果发生问题,就执行发生问题的C操作的反向。

上面说到的隔离性的问题会出现在,如果执行到T3这个时候需要执行回滚,但是这个用户已经把水喝了(另外一个事务),回滚的时候就会发现,无法给用户减一瓶水了。 这就是事务之间没有隔离性的问题。

可以看见saga模式没有隔离性的影响还是较大。

可以参照华为的解决方案: 从业务层面入手加入一 Session 以及锁的机制来保证能够串行化操作资源。

也可以在业务层面通过预先冻结资金的方式隔离这部分资源,最后在业务操作的过程中可以通过及时读取当前状态的方式获取到最新的更新。

servicecomb

最大努力通知(定期校对)

最大努力通知也被称为定期校对,其实在方案二中已经包含,这里再单独介绍,主要是为了知识体系的完整性。

这种方案也需要消息中间件的参与,其过程如下:

最大努力通知

上游系统在完成任务后,向消息中间件同步地发送一条消息,确保消息中间件成功持久化这条消息,然后上游系统可以去做别的事情了;

消息中间件收到消息后负责将该消息同步投递给相应的下游系统,并触发下游系统的任务执行;

当下游系统处理成功后,向消息中间件反馈确认应答,消息中间件便可以将该条消息删除,从而该事务完成。

上面是一个理想化的过程,但在实际场景中,往往会出现如下几种意外情况:

  1. 消息中间件向下游系统投递消息失败

  2. 上游系统向消息中间件发送消息失败

下游发送失败

对于第一种情况,消息中间件具有重试机制,我们可以在消息中间件中设置消息的重试次数和重试时间间隔,对于网络不稳定导致的消息投递失败的情况,往往重试几次后消息便可以成功投递,如果超过了重试的上限仍然投递失败,那么消息中间件不再投递该消息,而是记录在失败消息表中,消息中间件需要提供失败消息的查询接口,下游系统会定期查询失败消息,并将其消费,这就是所谓的“定期校对”。

如果重复投递和定期校对都不能解决问题,往往是因为下游系统出现了严重的错误,此时就需要人工干预。

上游发送失败

对于第二种情况,需要在上游系统中建立消息重发机制。

可以在上游系统建立一张本地消息表,并将任务处理过程向本地消息表中插入消息这两个步骤放在一个本地事务中完成。

如果向本地消息表插入消息失败,那么就会触发回滚,之前的任务处理结果就会被取消。

如果这量步都执行成功,那么该本地事务就完成了。

接下来会有一个专门的消息发送者不断地发送本地消息表中的消息,如果发送失败它会返回重试。

当然,也要给消息发送者设置重试的上限,一般而言,达到重试上限仍然发送失败,那就意味着消息中间件出现严重的问题,此时也只有人工干预才能解决问题。

对于不支持事务型消息的消息中间件,如果要实现分布式事务的话,就可以采用这种方式。

它能够通过重试机制+定期校对实现分布式事务,但相比于第二种方案,它达到数据一致性的周期较长,而且还需要在上游系统中实现消息重试发布机制,以确保消息成功发布给消息中间件,这无疑增加了业务系统的开发成本,使得业务系统不够纯粹,并且这些额外的业务逻辑无疑会占用业务系统的硬件资源,从而影响性能。

总结

还是那句话,能不用分布式事务就不用。

如果非得使用的话,结合自己的业务分析,看看自己的业务比较适合哪一种,是在乎强一致,还是最终一致即可。

上面对解决方案只是一些简单介绍,如果真正的想要落地,其实每种方案需要思考的地方都非常多,复杂度都比较大,所以最后再次提醒一定要判断好是否使用分布式事务。

拓展学习

消息的重试

补偿

幂等

JTA

参考资料

https://juejin.im/post/5aa3c7736fb9a028bb189bca

https://juejin.im/post/5b6c5be86fb9a04fb30a2bc7

https://www.roncoo.com/course/view/7ae3d7eddc4742f78b0548aa8bd9ccdb#boxTwo

https://kaimingwan.com/post/fen-bu-shi/fen-bu-shi-shi-wu-de-dian-xing-chu-li-fang-shi-2pc-tcc-yi-bu-que-bao-he-zui-da-nu-li-xing

https://juejin.im/post/5aa3c7736fb9a028bb189bca