作者介绍
梁阳鹤,乐视网BOSS平台技术部架构师,主要负责乐视集团支付、乐视会员系统、商业运营平台等系统架构工作。开源数据访问层框架Mango作者。
一、概述
在支付、交易、订单等强一致性系统中,我们需要使用分布式事务来保证各个数据库或各个系统之间的数据一致性。
举个简单的例子来描述一下这里数据一致性的含义。
程序员小张向女友小丽转账100人民币,转账过程是:先扣除小张100元,再为小丽的账户添加100元。
如果在转帐过程中,扣款操作和打款操作要么同时执行,要么同时都不执行,我们就认为转帐过程保证了数据一致性。
上面的例子中,如果我们不使用分布式来保证转账过程中数据的一致性,就有可能出现小张账户上的钱被扣除,而小丽账户上的钱却没被添加的情况,其结果大家可以自行脑补。
事务是数据库特有的概念,分布式事务最初起源于处理多个数据库之间的数据一致性问题,但随着IT技术的高速发展,大型系统中逐渐使用SOA服务化接口替换直接对数据库操作,所以如何保证各个SOA服务之间的数据一致性也被划分到分布式事务的范畴。
本文将从一个最为简单的交易系统出发,由浅入深地讲述分布式事务架构的演进过程,希望对大家理解分布式事物架构有所帮助。
二、单数据库事务
先来看看我们需要实现的交易系统:游戏中的玩家使用金币购买道具,交易系统需要负责扣除玩家金币并为玩家添加道具。
我们把交易系统的一次交易流程归纳为两步:
-
扣除玩家金币
-
为玩家添加道具
需求并不复杂,我们为金币系统在数据库中添加金币表,为道具系统在数据库中添加道具表,扣除金币与添加道具的操作只需执行相应的SQL即可。
这里我们假设金币表与道具表都在同一个数据库中,于是可以简单地使用单数据库事务来保证数据的一致性。
下面是使用单数据库事务进行一次正常交易的时序图:
上图演示了一次正常交易的流程,一般情况下正常的交易流程不会产生数据不一致问题。
下面讨论当出现异常时,如何使用单数据库事务保证数据一致性:
-
在步骤[2]执行SQL扣除金币时出现异常,回滚事务即可保证数据一致;
-
在步骤[4]执行SQL添加道具时出现异常,回滚事务即可保证数据一致;
-
在步骤[6]提交事务时出现异常,回滚事务即可保证数据一致。
通过上面三种异常的处理方式,我们不难看出,其实使用单数据库事务保证数据一致性特别简单,只需没有异常提交事务而出现异常回滚事务即可。
三、基于后置提交的多数据库事务
随着玩家数量激增,金币表与道具表的总行数与访问量都急剧扩大,单台数据库不足以支撑起这两张表的读写请求,这时将金币表与道具表放在不同的数据库中是个不错的选择。
这里我们假设金币表被放入了金币数据库中,而道具表被放入了道具数据库中,通常我们将这种按不同业务拆分数据库的方式称之为数据库垂直拆分。
数据库垂直拆分能大大缓解数据库的压力问题,但多个数据库的存在意味着我们不能通过简单的单数据库事务来保证数据的一致性,如何保证多数据库之间数据的一致性,也就是分布式事务需要解决的问题。
回到我们的交易系统,先不考虑多数据库之间的数据一致性问题,简单的交易流程为:
正常情况下,上面的流程不会产生数据一致性问题,但如果在步骤[7]执行SQL添加道具时出现异常,由于扣除金币的事务已经在步骤[5]提交无法回滚,就会出现扣除玩家金币后没有为玩家添加道具的数据不一致情况。
上面问题产生的原因其实是过早地向金币数据库提交事务,所以我们可以采取后置提交事务策略来解决此问题,即先在金币数据库与道具数据库上执行SQL,最后再提交金币数据库与道具数据库上的事务,这样当执行SQL出现异常时,我们就能通过同时回滚两个数据库上事务的方式,来保证数据一致性。
下面是使用后置提交事务进行一次正常交易的时序图:
结合上图,我们讨论当出现异常时,后置提交事务如何避免数据不一致问题:
-
在步骤[3]执行SQL扣除金币时出现异常,回滚金币数据库上的事务即可保证数据一致;
-
在步骤[5]执行SQL添加道具时出现异常,同时回滚金币数据库与道具数据库上的事务即可保证数据一致;
-
在步骤[7]提交扣除金币事务时出现异常,同时回滚金币数据库与道具数据库上的事务即可保证数据一致;
-
在步骤[9]提交添加道具事务时出现异常,由于扣除金币事务已提交无法回滚,会出现扣除玩家金币后没有为玩家添加道具的数据不一致情况。
通过上面四种异常的处理方式,我们可以看出,使用后置提交事务的策略,虽然能避免SQL执行异常导致的数据不一致,但在最后提交事务遇到异常时却无能为力,所以我们需要引入新的事务提交方式。
四、两段式事务
如前面所述,将不同数据库上的事务放在最后一起提交能解决SQL执行异常导致的数据不一致问题,但如若在最后提交事务时,前面的事务提交成功,最后的事务提交失败,由于那些已经提交成功的事务无法回滚,同样会产生数据不一致问题。
由于传统的事务提交机制无法保证多个数据库之间的数据一致性,于是计算机科学家们引入了两段式事务。
两段式事务将事务提交操作拆分成了两步:prepare预提交与commit确认提交。
在两段式事务中,预提交是一个很“重”的操作,他几乎执行了整个事务提交的所有操作,而最后的确认提交则是一个很“轻”的操作,用于最终确认事务完成。
假设我们有A、B、C共3台数据库,下面我们使用后置事务提交策略与两段式事务来实现跨A、B、C数据库的分布式事务:
-
分别获取到A、B、C数据库的连接并开启事物;
-
分别在A、B、C数据库上执行SQL;
-
分别在A、B、C数据库上执行事务预提交;
-
分别在A、B、C数据库上执行事务确认提交。
如上面讨论,步骤3事务预提交处理了整个事务提交的大部分操作,所以一般情况下,如果步骤3事务预提交执行成功,我们可以认为步骤4事务确认提交一定会执行成功,而如果在步骤3事务预提交过程中出现异常,我们则只需回滚所有事务即可保证数据的一致性。
当然在极端情况下,会出现在步骤3事务预提交成功,而在步骤4事务确认提交失败的情况,不过这种情况发生的概率极低,我们可以先记录错误日志,后续使用定时任务修复数据或直接人工修复数据。
我们将购买道具的交易流程改为两段提交,时序图如下:
其实上面的两段式事务也就是著名的XA事务,XA是由X/Open组织提出的分布式事务的规范,也是使用最为广泛的多数据库分布式事务规范,目前市面上主流的数据库MySQL,Oralce,SQLServer等都支持XA事务。
一般情况下,我们在使用XA规范编写多数据库分布式事务代码时,不用自己去实现两段提交代码,而是使用atomikos等开源的分布式事务工具。
下面是一个使用atomikos实现简单分布式事务(XA事务)的源码:
github.com/liangyanghe/xa-transaction-demo
五、TCC事务
之前我们的交易系统在进行购买道具时,都是直接操作金币表与道具表,下面我们对交易系统的架构进行升级:
将与金币相关的操作独立成一套金币服务,将与道具相关的操作独立成一套道具服务,交易系统在扣除金币与添加道具时,不再直接操作数据库表,而是调用相应服务的SOA接口。
基于SOA接口的最简交易时序图如下:
上图中,我们的交易系统不再直接操作数据库表,而是通过调用SOA接口的方式扣除金币与添加道具。
我们考虑在步骤[3]调用SOA接口添加道具时出现异常,由于之前已经调用SOA接口扣除金币成功,于是就会出现扣除玩家金币后,没有为玩家添加道具的不一致情况。
为保证各个SOA服务之间的数据一致性,我们需要设计基于SOA接口的分布式事务。
目前比较流行的SOA分布式事务解决方案是TCC事务,TCC事务的全称为:Try-Confirm/Cancel,翻译成中文即:尝试、确定、取消。
简单来说,TCC事务是一种编程模式,如果SOA接口的提供者与调用者都遵从TCC编程模式,那么就能最大限度的保证数据一致性。
下面我们以扣除金币这一操作,来说明一下TCC编程模式。
非TCC模式的扣除金币操作,接口提供者只需要提供一个SOA接口即可,接口的作用就是扣除金币。
而TCC模式的扣除金币操作,接口提供者针对扣除金币这一操作需要提供三个SOA接口:
-
扣除金币Try接口,尝试扣除金币,这里只是锁定玩家账户中需要被扣除的金币,并没有真正扣除金币,类似于信用卡的预授权;假设玩家账户中100金币,调用该接口锁定60金币后,锁定的金币不能再被使用,玩家账户中还有40金币可用
-
扣除金币Confirm接口,确定扣除金币,这里将真正扣除玩家账户中被锁定的金币,类似于信用卡的确定预授权完成刷卡
-
扣除金币Cancel接口,取消扣除金币,被锁定的金币将返还到玩家的账户中,类似于信用卡的撤销预授权取消刷卡
SOA接口调用者如何使用这三个接口呢?
调用者先执行扣除金币Try接口,再去执行其他任务(比如添加道具),当其他任务执行成功,调用者执行扣除金币Confirm接口确认扣除金币,而当其他任务执行异常,调用者则执行扣除金币Cancel接口取消扣除金币。
这里我们假设添加道具的SOA接口也满足TCC模式,下图是使用TCC事务进行道具购买的时序图:
对照上图,我们分析一下TCC事务如何在各种异常情况下,保证数据的一致性:
-
在步骤[1]调用扣除金币Try接口时出现异常,调用扣除金币Cancel接口即可保证数据一致
-
在步骤[3]调用添加道具Try接口时出现异常,调用扣除金币Cancel接口与添加道具Cancel接口即可保证数据一致
-
在步骤[5]调用扣除金币Confirm接口时出现异常,调用扣除金币Cancel接口与添加道具Cancel接口即可保证数据一致
-
在步骤[7]调用添加道具Confirm接口时出现异常,由于扣除金币操作已经确定不能再取消,所以这里会引发数据不一致
通过上面四种异常,我们可以看出,即使我们使用了TCC事务,也无法完美的保证各个SOA服务之间的数据一致性。
但TCC事务为我们屏蔽了大多数异常导致的数据不一致,同时一般情况下,进行Confirm或Cancel操作时产生异常的概率极小极小,所以对于一些强一致性系统,我们还是会使用TCC事务来保证多个SOA服务之间的数据一致性。
六、最终一致性
有了TCC事务,我们能够保证多个SOA服务之间的数据一致性,但细心的朋友可能已经发现,TCC事务存在不小的性能问题。
为了描述性能问题的产生,我们将交易系统的需求略作修改:游戏中的玩家使用金币购买道具A,系统将自动赠送给玩家道具B,道具C与道具D。
这里我们假设我们到道具服务不支持批量添加道具,而只有基于TCC模式的添加单个道具的接口。
为保证数据一致性,交易系统需要先调用扣除金币Try接口,然后再依次调用添加道具A、B、C、D的Try接口,最后再依次调用对应的Confirm接口。
由于TCC事务是先Try再Confirm的模式,接口调用量会翻倍,这在接口调用量小时性能影响并不明显,但上面的需求中我们执行扣除金币,添加道具A、B、C、D共有5个接口调用,翻倍后变为10个,系统性能会大大降低。
那么是否有既能保证数据一致性,又能保证性能的分布式事务方案?
在回答这个问题之前,我们先将事务一致性划分为两类:
-
强一致性事务,请求结束后,数据就已经一致
-
最终一致性事务,请求结束后,数据没有一致,但一段时间后数据能保持一致
其实我们使用的基于后置提交的多数据库事务与TCC事务都属于强一致性事务,使用强一致性事务能保证事务的实时性,但却很难在高并发环境中保证性能。
再来看最终一致性事务,最终一致性事务这几个字看起来很牛逼,但说白了就是异步数据补偿,即在核心流程我们只保证核心数据的实时数据一致性,对于非核心数据,我们通过异步程序来保证数据一致性。
由于最终一致性事务引入了异步数据补偿机制,主流程的执行流程被简化,性能自然得到提高。
目前主流触发异步数据补偿的方式有两种:
-
使用消息队列实时触发数据补偿,核心流程在保证核心数据的一致性后,使用消息队列的方式通知异步程序进行数据补偿,这种方式能近乎实时的使数据达到最终一致性,但如果消息队列或异步程序出现异常,数据一致性也将不能保证
-
使用定时任务周期性触发数据补偿,核心流程在保证核心数据的一致性后直接返回,由定时任务周期性触发数据补偿程序,这种方式虽不能像消息队列那样能近乎实时的使数据达到最终一致性,但数据补偿程序出现异常时,我们能比较容易在下个周期对数据进行修复,能最大限度的保证数据的一致性
上面两种异步数据补偿的方式各有利弊,消息队列方式实时性强,但在异常情况下一致性弱,而定时任务方式实时性弱,但在异常情况下一致性强。
其实最优的策略是同时使用消息队列与定时任务触发数据补偿。
正常情况下,我们使用消息队列近乎实时的异步触发数据补偿,而针对那些极少发生的异常,我们使用定时任务周期性的修补数据。
这样在正常情况下,我们能近乎实时的使数据达到最终一致性,而对于一些异常数据则按照定时任务的执行周期,周期性的达到最终一致性。
回到上面的新版交易系统:游戏中的玩家使用金币购买道具A,系统将自动赠送给玩家道具B,道具C与道具D。
下图是使用消息队列实时触发数据补偿实现最终一致性的时序图(如看不清楚可以点击图片放大):
上图中,我们使用TCC事务保证了扣除金币与添加道具A数据一致,然后发送赠送消息并结束请求,赠送系统收到消息后负责添加道具B、C、D,最终保证数据一致。
这里如果消息队列或赠送服务出现异常我们的最终一致性将难以保证,所以我们可以再引入一个定时任务,周期性的触发异常数据补偿。
这样我们就实现了一个既能保证最终数据一致,又能保证性能的道具买赠系统。