【CAP】(转)分布式事务最佳实践方案汇总

说到分布式事务,就会谈到那个经典的”账号转账”问题:2个账号,分布处于2个不同的DB,对应2个不同的系统A,B。A要扣钱,B要加钱,如何保证原子性?

传统方案 – 2PC

(1)2PC的理论层面:

2pc涉及到2个阶段,3个操作:

阶段1:“准备提交”。事务协调者向所有参与者发起prepare,所有参与者回答yes/no。

阶段2:“正式提交”。如果所有参与者都回答yes,则向所有参与者发起commit;否则,向所有参与者发起rollback。

因此,要实现2pc,所有参与者,都得实现3个接口:prepare/commit/rollback。

(2)2PC的实现层面

对应的实现层面,也就是XA协议,通常的数据库都实现了这个协议。

有一个Atomikos开源库,提供了2PC的实现方案。有兴趣的可以去看一下如何使用。

(3)2PC的问题

问题1:阶段2,事务协调者挂了,则所有参与者接受不到commit/rollback指令,将处于“悬而不决”状态

问题2:阶段2,其中一个参与者超时或者出错,那其他参与者,是commit,还是rollback呢? 也不能确定

为了解决2pc的问题,又引入3pc。3pc有类似的挂了如何解决的问题,因此还是没能彻底解决问题,此处就不详述了。

问题3:2PC的实现,目前主要是用在数据库层面(数据库实现了XA协议)。但目前,大家基本都是微服务架构,不会直接在2个业务DB之间搞一致性,而是想如何在2个服务上面实现一致性。

正因为2PC有上面诸多问题和不便,实践中一般很少使用,而是采用下面将要讲的各种方案。

最终一致性

一般的思路都是通过消息中间件来实现“最终一致性”:A系统扣钱,然后发条消息给中间件,B系统接收此消息,进行加钱。

但这里面有个问题:A是先update DB,后发送消息呢? 还是先发送消息,后update DB?

假设先update DB成功,发送消息网络失败,重发又失败,怎么办?

假设先发送消息成功,update DB失败。消息已经发出去了,又不能撤回,怎么办?

所以,这里下个结论: 只要发送消息和update DB这2个操作不是原子的,无论谁先谁后,都是有问题的。

那这个问题怎么解决呢??

错误的方案

有人可能想到了,我可以把“发送消息”这个网络调用和update DB放在同1个事务里面,如果发送消息失败,update DB自动回滚。这样不就保证2个操作的原子性了吗?

这个方案看似正确,其实是错误的,原因有2:

(1)网络的2将军问题:发送消息失败,发送方并不知道是消息中间件真的没有收到消息呢?还是消息已经收到了,只是返回response的时候失败了?

如果是已经收到消息了,而发送端认为没有收到,执行update db的回滚操作。则会导致A账号的钱没有扣,B账号的钱却加了。

(2)把网络调用放在DB事务里面,可能会因为网络的延时,导致DB长事务。严重的,会block整个DB。这个风险很大。

基于以上分析,我们知道,这个方案其实是错误的!

方案1 – 最终一致性(业务方自己实现)

假设消息中间件没有提供“事务消息”功能,比如你用的是Kafka。那如何解决这个问题呢?

解决方案如下:

(1)Producer端准备1张消息表,把update DB和insert message这2个操作,放在一个DB事务里面。

(2)准备一个后台程序,源源不断的把消息表中的message传送给消息中间件。失败了,不断重试重传。允许消息重复,但消息不会丢,顺序也不会打乱。

(3)Consumer端准备一个判重表。处理过的消息,记在判重表里面。实现业务的幂等。但这里又涉及一个原子性问题:如何保证消息消费 + insert message到判重表这2个操作的原子性?

消费成功,但insert判重表失败,怎么办?关于这个,在Kafka的源码分析系列,第1篇, exactly once问题的时候,有过讨论。

通过上面3步,我们基本就解决了这里update db和发送网络消息这2个操作的原子性问题。

但这个方案的一个缺点就是:需要设计DB消息表,同时还需要一个后台任务,不断扫描本地消息。导致消息的处理和业务逻辑耦合额外增加业务方的负担。

方案2 – 最终一致性(RocketMQ 事务消息)

为了能解决该问题,同时又不和业务耦合,RocketMQ提出了“事务消息”的概念。

具体来说,就是把消息的发送分成了2个阶段:Prepare阶段和确认阶段。

具体来说,上面的2个步骤,被分解成3个步骤:

(1) 发送Prepared消息

(2) update DB

(3) 根据update DB结果成功或失败,Confirm或者取消Prepared消息。

可能有人会问了,前2步执行成功了,最后1步失败了怎么办?这里就涉及到了RocketMQ的关键点:RocketMQ会定期(默认是1分钟)扫描所有的Prepared消息,询问发送方,到底是要确认这条消息发出去?还是取消此条消息?

总结:对比方案2和方案1,RocketMQ最大的改变,其实就是把“扫描消息表”这个事情,不让业务方做,而是消息中间件帮着做了。

至于消息表,其实还是没有省掉。因为消息中间件要询问发送方,事物是否执行成功,还是需要一个“变相的本地消息表”,记录事物执行状态。

人工介入

可能有人又要说了,无论方案1,还是方案2,发送端把消息成功放入了队列,但消费端消费失败怎么办?

消费失败了,重试,还一直失败怎么办?是不是要自动回滚整个流程?

答案是人工介入。从工程实践角度讲,这种整个流程自动回滚的代价是非常巨大的,不但实现复杂,还会引入新的问题。比如自动回滚失败,又怎么处理?

对应这种极低概率的case,采取人工处理,会比实现一个高复杂的自动化回滚系统,更加可靠,也更加简单。

方案3:TCC

为了解决SOA系统中的分布式事务问题,支付宝提出了TCC。2PC通常都是在跨库的DB层面,而TCC本质就是一个应用层面的2PC。

同样,TCC中,每个参与者需要3个操作:Try/Confirm/Cancel,也是2个阶段。

阶段1:”资源预留/资源检查“,也就是事务协调者调用所有参与者的Try操作

阶段2:“一起提交”。如果所有的Try成功,一起执行Confirm。否则,所有的执行Cancel.

TCC是如何解决2PC的问题呢?

关键:Try阶段成功之后,Confirm如果失败(不管是协调者挂了,还是某个参与者超时),不断重试!!

同样,Cancel失败了,也是不断重试。这就要求Confirm/Cancel都必须是幂等操作。

下面以1个转账case为例,来说明TCC的过程:

有3个账号A, B, C,通过SOA提供的转账服务操作。A, B同时分别要向C转30, 50元,最后C的账号+80,A, B各减30, 50。

阶段1:A账号锁定30,B账号锁定50,检查C账号的合法性(比如C账号是否违法被冻结,C账号是否已注销。。。)。

所以,对应的“扣钱”的Try操作就是”锁定”,对应的“加钱”的Try操作就是检查账号合法性

阶段2:A, B, C都Try成功,执行Confirm。即A, B减钱,C加钱。如果任意一个失败,不断重试!

从上面的案例可以看出,Try操作主要是为了“保证业务操作的前置条件都得到满足”,然后在Confirm阶段,因为前置条件都满足了,所以可以不断重试保证成功。

方案4:事务状态表 + 调用方重试 + 接收方幂等 (同步 + 异步)

同样以上面的转账为例:调用方调系统A扣钱,系统B加钱,如何保证2个同时成功?

调用方维护1张事务状态表(或者说事务日志,日志流水),每次调用之前,落盘1条事务流水,生成1个全局的事务ID。

初始状态是Init,每调用成功1个系统更新1次状态(这里就2个系统),最后所有系统调用成功,状态更新为Success。

当然,你也可以不保存中间状态,简单一点,你也可以只设置2个状态:Init/Success,或者说begin/end。

然后有个后台任务,发现某条流水,在过了某个时间之后(假设1次事务执行成功通常最多花费30s),状态仍然是Init,那就说明这条流水有问题。就重新调用系统A,系统B,保证这条流水的最终状态是Success。当然,系统A, 系统B根据这个全局的事务ID,做幂等,所以重复调用也没关系。

这就是通过同步调用 + 后台任务异步补偿,最终保证系统一致性。

补充说明:

(1)如果后台任务重试多次,仍然不能成功,那要为状态表加1个Error状态,要人工介入干预了。

(2)对于调用方的同步调用,如果部分成功,此时给客户端返回什么呢?

答案是不确定,或者说暂时未知。你只能告诉用户,该笔转账超时,稍后再来确认。

(3)对于同步调用,调用方调用A,或者B失败的时候,可以重试3次。重试3次还不成功,放弃操作。再交由后台任务后续处理。

方案4的扩展:状态机 + 对账

把方案4扩展一下,岂止事务有状态,系统中的各种数据对象都有状态,或者说都有各自完整的生命周期。

这种完整的生命周期,天生就具有校验功能!!!我们可以很好的利用这个特性,来实行系统的一致性。

一旦我们发现系统中的某个数据对象,过了一个限定时间,生命周期仍然没有走完,仍然处在某个中间状态,那就说明系统不一致了,可以执行某种操作。

举个电商系统的订单的例子:一张订单,从“已支付”,到“下发给仓库”,到“出仓完成”。假定从“已支付”到“下发给仓库”,最多用1个小时;从“下发给仓库”到“出仓完成”,最多用8个小时。

那意味着:只要我发现1个订单的状态,过了1个小时之后,还是“已支付”,我就认为订单下发没有成功,我就重新下发,也就是上面所说的“重试”;

同样,只要我发现订单过了8个小时,还未出仓,我这个时候可能就会发报警出来,是不是仓库的作业系统出了问题。。。诸如此类。

更复杂一点:订单有状态,库存系统的库存也有状态,优惠系统的优惠券也有状态,根据业务规则,这些状态之间进行比对,就能发现系统某个地方不一致,做相应的补偿行为。

上面说的“最终一致性”和TCC、状态机+对账,都是比较“完美”的方案,能完全保证数据的一致性。

但是呢,最终一致性这个方案是异步的;

TCC需要2个阶段,性能损耗大;

事务状态表,或者状态机,每次要记事务流水,要更新状态,性能也有损耗。

如果我需要1个同步的方案,可以立马得到结果,同时又要有很高的性能,支持高并发,那怎么处理呢?

方案5:妥协方案 – 弱一致性 + 基于状态的补偿

举个典型场景:

电商网站的下单,扣库存。订单系统有订单的DB,订单的服务;库存系统有库存的DB,库存的服务。 如何保证下单 + 扣库存,2个的原子性呢?

如果用上面的最终一致性方案,因为是异步的,库存扣减不及时,会导致超卖,因此最终一致性的方案不可行;

如果用TCC的方案,性能可能又达不到。

这里,就采用了一种弱一致的方案,什么意思呢?

对于该需求,有1个关键特性:对于电商的购物来讲,允许少卖,但不能超卖。你有100件东西,卖给99个人,有1件没有卖出去,这个可以接受;但是卖给了101个人,其中1个人拿不到货,平台违约,这个就不能接受。

而该处就利用了这个特性,具体是这么做的:

先扣库存,再提交订单。

(1)扣库存失败,不提交订单了,直接返回失败,调用方重试(此处可能会多扣库存)

(2)扣库存成功,提交订单失败,返回失败,调用方重试(此处可能会多扣库存)

(3)扣库存成功,提交订单成功,返回成功。

反过来,你先提交订单,后扣库存,也是按照类似的这个思路。

最终,只要保证1点:库存可以多扣,不能少扣!!!

但是,库存多扣了,这个数据不一致,怎么补偿呢?

库存每扣1次,都会生成1条流水记录。这条记录的初始状态是“占用”,等订单支付成功之后,会把状态改成“释放”。

对于那些过了很长时间,一直是占用,而不释放的库存。要么是因为前面多扣造成的,要么是因为用户下了单,但不支付。

通过比对,库存系统的“占用又没有释放的库存流水“与订单系统的未支付的订单,我们就可以回收掉这些库存,同时把对应的订单取消掉。(就类似12306网站一样,过多长时间,你不支付,订单就取消了,库存释放)

方案6: 妥协方案 – 重试 + 回滚 + 监控报警 + 人工修复

对于方案5,我们是基于订单的状态 + 库存流水的状态,做补偿(或者说叫对账)。

如果业务很复杂,状态的维护也很复杂。方案5呢,就是1种更加妥协而简单的办法。

提交订单不是失败了嘛!

先重试!

重试还不成功,回滚库存的扣减!

回滚也失败,发报警出来,人工干预修复!

总之,根据业务逻辑,通过重试3次,或者回滚的办法,尽最大限度,保证一致。实在不一致,就发报警,让人工干预。只要日志流水记录的完整,人工肯定可以修复! (通常只要业务逻辑本身没问题,重试、回滚之后,还失败的概率会比较低,所以这种办法虽然丑陋,但蛮实用)

后话

其他的,诸如状态机驱动、1PC之类的办法,只是说法不一,个人认为本质上都是方案4/方案5的做法。

总结

在上文中,总结了实践中比较靠谱的6种方法:2种最终一致性的方案,2种妥协办法,2种基于状态 + 重试的方法(TCC,状态机 + 重试 + 幂等)。

实现层面,妥协的办法肯定最容易,TCC最复杂。