账户系统作为涉及真金白银的基础服务,对数据的一致性要求很高。信也账户系统从公司成立之初的本地事务,到后来的分布式事务,从TCC事务到Sagas长事务,期间遇到了不少问题,走了不少弯路。一次次推倒重来的改变,就好像是一代代王朝的更替,铸就了信也账户系统演进的发展史。
2007年6月,公司刚刚成立,当时还没有独立的账户系统,使用的是关系型数据库的一张表来存储账户余额数据,同一个库的另一张表来存储账户的流水数据,使用数据库本地事务来保证数据的强一致性。由于当时用户数和访问量都不多,这样做完全可以满足需求,并且支撑了公司很长一段时间的成长和发展。
从2014年开始,公司业务迎来爆发式增长,单库强一致性方案开始出现性能瓶颈。当时做了很多优化,在硬件层面,对数据库服务器按业务做了拆分,并且升级了硬件。在软件层面,对本地事务的处理方式做了调整,将减钱的部分依然放在本地事务里执行,加钱的部分则通过可靠消息来异步执行。经过努力,性能瓶颈问题有所缓解,但一直没有得到彻底解决,成为了悬在众人头上的达摩克利斯之剑。
直到2016年3月,我们上线了基于TCC事务的独立账户系统,对账户数据库表做了分片处理,解决了困扰我们多年的数据库单点瓶颈问题。但是,在使用过程中,TCC事务方案逐渐显露出一些问题,使我们不得不做出改变。
2017年12月,我们切换上线了基于Sagas长事务版本的账户系统。这个版本将之前TCC事务版本存在的问题悉数解决,获得了更合理的架构和更好的性能,并一直延续至今。
接下来,我们将重点介绍信也账户系统中分布式事务实现方案的相关内容。
首先,举个例子假如有一个从账户A转账给账户B的操作,在单库单表时,我们可以使用关系型数据库的本地事务来保证一致性,也就是账户A与账户B的操作必须同时成功或同时失败。
随着业务的发展,我们把单库单表按照分片规则拆分成了多库多表。这时,如果账户A与账户B被分配到了不同数据库实例上不同分库的不同分表中,那么强一致性的数据库本地事务已经不再适用,需要采用分布式事务方案来保证数据的一致性。
那么,在分布式场景下,要如何实现分布式事务呢?在选择实现方案之前,需要了解一些分布式事务的基本概念。
刚性事务[ACID]原则保证了在事务过程中数据的正确性,包括原子性、一致性、隔离性和持久性,关系型数据库中的事务机制遵循了ACID原则。
数据分片后,如何在多个数据库节点间保证本地事务的ACID特性成为一个技术难题,并且由此而衍生出了CAP和BASE经典理论。
帽子理论[CAP]是针对分布式事务而言的,它是指在一个分布式系统中,一致性、可用性和分区容错性三者不可能同时满足,必须有所取舍。
由于网络分区是分布式应用的基本要素,这样只能在一致性和可用性之间进行权衡,其权衡的结果就是柔性事务BASE原理。
柔性事务[BASE]原理包括基本可用、软状态和最终一致性,它的核心思想是无法做到强一致性,但每个应用都可以根据自身的特点,采用适当方式达到最终一致性。
对于大部分的分布式应用而言,只要数据在规定的时间内达到最终一致性即可。最终一致性是BASE原理的核心,通过弱化一致性,提高系统的可伸缩性、可靠性和可用性。而且对于大多数应用,其实并不需要强一致性,因此牺牲一致性而换取高可用性,是多数分布式事务方案的方向。
隔离的本质是控制并发,需要保证同一笔请求在同一时间只会有一个线程在处理,防止在事务处理过程中发生错乱。
对于基于BASE原理实现的分布式事务方案,本身并不保证隔离性,需要单独实现。
一般使用分布式锁来保证分布式事务的隔离性,如果对分布式锁的具体实现原理感兴趣,敬请期待拍码场推出的后续文章。
幂等性是指对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次请求而产生副作用。
幂等性在账户系统中尤为重要,它保证了对账户重复操作的安全性,便于在分布式场景下做重试和补偿。
在一般情况下,我们希望每个子事务的粒度越小越好,最小可以做到每个账户一个子事务。这种方式对于一般场景的简单交易是没有问题的,比如从账户A到账户B的一笔转账。
但是,在当时的p2p时代,信也的业务中存在一种极端场景,有的借款人发标金额比较大,而投资人投标的金额比较小,这样就可能会出现几千个人投标给一个人的情况,对账户系统来说就会存在包含几千笔明细涉及上千个账户的大交易。
在这个时候,按账户来拆分子事务显然不太合适,而对账户系统划分合理的分表,然后按表来分组和拆分事务则是一种还不错的方案。
关于实现分布式事务的方案,市面上主要有以下几种:
2PC(两阶段提交)
3PC(三阶段提交)
异步确保(本地消息表)
可靠事件(MQ事务消息)
TCC事务
Sagas长事务
2PC方案原理简单,实现方便,尽量保证了数据的强一致,但是存在同步阻塞,牺牲了可用性,对性能影响较大,不适合高并发高性能场景,且存在单点问题。3PC方案相对于2PC方案增加了准备阶段,并引入了超时机制,降低了参与者的阻塞范围,并且能够在出现单点故障后继续达成一致,但是极端情况下仍然会产生数据一致性问题。
使用本地消息表的异步确保方案是一种非常经典的实现,避免了分布式事务,实现了最终一致性,但是消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。使用MQ事务消息的可靠事件方案通过可靠事件替换了本地消息表,实现了最终一致性,不需要依赖本地数据库事务,但是实现难度较大,主流MQ不支持。
TCC事务方案和Sagas长事务方案都可以解决复杂场景跨应用业务操作的最终一致性问题,但是如果自己实现,开发成本较高。
针对以上方案各项指标的横向比较结果:
2PC和3PC方案由于吞吐量问题首先被淘汰,异步确保和可靠事件方案虽然在指标层面没有太大问题,但是不太适用于账户这种执行步骤可能较多的场景,这样就只剩下了TCC事务和Sagas长事务两种方案。
虽然TCC事务方案在吞吐量上的表现并不是最好的,但是当时在其他公司有成功落地的经验,所以为了稳妥起见,我们刚开始还是选择了TCC事务方案。
2016年3月,我们成功上线了基于TCC事务方案的独立账户系统,解决了困扰我们多年的数据库单点瓶颈问题。
TCC事务主要包含三个阶段:Try阶段、Confirm阶段和Cancel阶段。
Try阶段负责检查并预留资源,Confirm阶段做确认提交,Try阶段的所有分支事务都执行成功后开始执行Confirm,Cancel阶段是在业务执行错误需要回滚的状态下,执行分支事务的业务取消和预留资源释放。
业务应用作为上层应用,负责触发事务的启动、注册、提交和回滚操作,以及调用下层服务的Try接口。
服务A和服务B在账户系统中实际上是同一个服务,这里可以看成是同一个服务的不同实例,执行具体对账户加减金额的操作,这些操作都应该保证幂等性。
事务协调器负责事务的调度和补偿,当提交事务时,会依次调用注册过的所有操作的Confirm接口,当回滚事务时,则依次调用注册过的所有操作的Cancel接口,同时它还承担了TCC事务补偿器的职责。
信也账户系统的主要执行步骤如下:
业务应用收到账户交易请求,根据具体请求参数编排TCC事务流程,确定该交易请求需要操作的步骤,然后调用事务协调器启动分布式事务;
根据TCC事务流程编排的结果,依次执行Try操作,注意这里的每一步Try操作实际都包含两个调用,一个是调用事务协调器执行注册操作,另一个是调用下层服务的Try接口来锁定资源;
当所有Try操作都成功后,业务应用调用事务协调器执行提交事务操作,如果Try操作中有失败,则调用事务协调器执行回滚事务操作;
当事务协调器收到提交事务请求后,依次调用下层服务的Confirm接口,当事务协调器收到回滚事务请求或者长时间没有收到提交和回滚事务的请求时,依次调用下层服务的Cancel接口来回滚整个事务,当调用Confirm接口或Cancel接口有失败时,会继续补偿直到成功。
TCC事务协调器根据集成方式的不同可以划分为两种:集中式事务协调器和分布式事务协调器。
集中式事务协调器是指协调器与业务应用共用一个服务,启动、注册、提交和回滚操作都是本地调用,拥有更好的性能,但与业务代码存在耦合。
分布式事务协调器则是单独部署的事务协调器服务,它有着低耦合、易复用的优点,但存在一定的网络交互开销。
为了降低耦合和方便以后复用,信也账户系统的TCC事务协调器采用的是独立部署的分布式事务协调器。
根据账户系统自身的业务特性,同一笔交易请求应该有着固定的流程,账户TCC事务的流程编排应该遵循以下原则:
减钱为Try操作,加钱为Confirm操作,Cancel操作为反向的Try操作;
同一个分表的Try-Confirm-Cancel操作为一组;
如果没有对应的操作,就是一个空操作;
同一个子操作的本地事务按账户ID排序(避免死锁)。
空操作指的是在TCC过程中的某个步骤什么也不做的情况,有两种情况会产生空操作:
当一笔交易在同一个分表中只有加钱没有减钱时,这时Try和Cancel阶段什么也不做,为空操作;
当一笔交易在同一个分表中只有减钱没有加钱时,这时Confirm操作什么也不做,为空操作。
通过TCC事务协调器来补偿未完成的事务,确保其达到最终一致性。其中包含两种补偿方式:
1. 当TCC事务长时间没有收到提交和回滚事务的请求时,会依次调用下层服务的Cancel接口来回滚整个事务。
2. 当TCC事务协调器调用Confirm接口或者Cancel接口失败后,会继续等待下次补偿,触发补偿时再次重试调用Confirm接口或者Cancel接口。
补偿的前提条件是每一个子操作都是幂等的,可以重复执行而不会产生副作用。
在上线并使用了TCC事务一段时间后,我们发现TCC事务方案存在以下几个问题:
在账户场景下,TCC本身的特性决定了会存在额外空操作的情况,浪费资源;
TCC事务协调器采用的是独立部署的分布式事务协调器,网络交互频繁,影响性能;
当时实现方案有问题,上层服务需要知道下层服务的数据库分片情况,属于强耦合;
TCC事务协调器需要额外的维护成本。
为了解决主要的性能问题,我们当时做了大量的优化工作,例如:
增加了异步处理;
把TCC事务中的Try操作由单线程顺序执行,改成了多线程并发执行;
优化了子操作的性能,把操作数据库的方式由单笔改为批量等。
但是,这些优化都是基于当前TCC框架的基础上来做的,并没有解决根本问题。
为了彻底解决TCC事务方案存在的问题,我们开始转向了Sagas长事务方案。
2017年12月,我们切换上线了Sagas长事务版本的账户系统。这个版本将之前TCC事务版本存在的问题悉数解决,获得了更合理的架构和更好的性能,原来使用TCC事务时,交易平均耗时超过200ms,切换为Sagas长事务后,平均耗时降低到了50ms左右,有了质的提升。
Sagas起源于1987年 Hector & Kenneth 发表的论文。Sagas是一个长活事务(Long Live Transaction = LLT),可被分解成可以交错运行的子事务集合。其中每个子事务都是一个保持数据库一致性的真实事务(LLT = T1 + T2 + T3 + ... + Tn)。每个本地事务Tx 有对应的补偿 Cx。
Sagas模型把一个分布式事务拆分为多个子事务,每个子事务都有相应的执行模块和补偿模块。当Sagas长事务中任意一个子事务出错时,可以通过调用相关的补偿方法恢复之前的事务,达到事务的最终一致性。
Sagas事务协调器与TCC事务协调器一样,同样有集中式事务协调器和分布式事务协调器的区分。之前TCC事务方案为了降低耦合和方便以后复用,选择了性能较差的分布式事务协调器。而信也账户系统对性能要求较高,所以这次Sagas长事务直接改用了更加高效的集中式事务协调器。
在账户场景下,TCC会存在空操作的问题,如果采用Sagas长事务方案的话,就可以有效避免这个的问题,在分表数不变以及事务粒度不变的情况下可以最大限度地减少执行步骤。
信也账户系统Sagas长事务的流程编排应该遵循以下原则:
先减钱,后加钱(避免资损);
先减钱多的,再减钱少的(失败尽早回滚);
如果减钱金额相同,按数据库表编号排序(保证编排结果唯一);
只有余额不够时才会触发回滚(尽可能成功);
加钱操作不可回滚(避免余额不足);
同一个子Saga操作的本地事务按账户ID排序(避免死锁)。
参照Sagas原理初步设计一个执行流程:
其中每一个方块代表一个本地事务,蓝色箭头表示正常流程,红色箭头表示回滚流程,每一步操作都是幂等的。正常流程的步骤可以描述为:
服务收到账户交易请求,根据具体请求参数编排Sagas事务流程,确定该交易请求需要操作的步骤,协调器将Sagas的log状态置为初始,相当于开启事务;
在同一个本地事务里,执行账户的增减金额操作,同时将当前子Saga的log状态置为成功;
依次按顺序执行所有的子Saga操作,直到将所有流程走完;
回到协调器,将Sagas的log状态置为成功,事务结束。
在描述回滚流程前,仔细思考会发现这里面有两个问题:
为了避免回滚时余额不足,我们规定加钱操作不可回滚,这也就意味着图中的CSagaC实际上永远都不会发生,加钱操作所对应的回滚步骤可以省略;
回滚应该只发生在余额不够的情况下,如果是数据库超时等其他原因,则不应该回滚,而应该尽可能重试,所以最后一次减钱所对应的回滚步骤也可以省略。
上面的用例可以优化如下:
此时,只有TSagaA和TSagaB是减钱操作,可能会失败进而触发回滚。如果是TSagaA失败就直接回到协调器,没有CSaga的步骤了。如果是TSagaB失败,那么要执行CSagaA这个回滚动作,然后再回到协调器。
以执行TSagaB余额不足发生回滚为例,执行步骤如下:
服务收到账户交易请求,根据具体请求参数编排Sagas事务流程,确定该交易请求需要操作的步骤,协调器将Sagas的log状态置为初始,相当于开启事务;
在本地事务里执行TSagaA,账户A减少100元,同时将当前SagaA的log的状态置为成功;
在本地事务里执行TSagaB,账户B减少50元,但是此时账户B的余额不足50元,事务执行失败,当前本地事务回滚;
在本地事务里执行CSagaA,账户A反向增加100元,同时将当前SagaA的log的状态置为失败;
回到协调器,将Sagas的log状态置为失败,事务结束。
为了方便演示,这里的示例对操作做了简化。实际情况下,在增减账户余额的同时还会记录账户流水,Sagas的长度可能会根据实际业务的情况变得很长,每个子Saga的事务操作也可能会包含多个账户,但基本的执行步骤没有变化。
Sagas事务在执行过程中会有小概率的情况会中断,比如上例中CSagaA操作数据库超时,此时会暂时出现一致性问题,需要等待补偿。
出现一致性问题时有两种补偿方式:
正向补偿:向前恢复,重试所有失败或未完成的事务,目标是整个事务达成成功的一致性;
反向回滚:向后恢复,回滚所有已完成的事务,目标是整个事务达成失败的一致性。
补偿的原则是尽可能地去做正向补偿,当正向补偿无法成功时,才考虑做反向回滚。
改用Sagas方案后获得以下优势:
Sagas的操作步骤数量少于TCC,效率更高;
采用集中式事务协调器,利用本地事务简化了实现逻辑的同时,减少了网络交互,大大加快了处理速度;
与上层业务解耦;
不用单独维护一套协调器。
缺点主要是事务与业务耦合,看起来不利于未来的维护。但实际情况是,信也账户系统在改造成Sagas长事务后运行稳定,基本不需要再花费较大精力去维护。总体来说,这次改造还是非常值得的。
虽然我们替换掉了TCC事务方案,但这并不是TCC事务本身的问题,而是具体应用场景的问题,TCC事务依然是一种非常优秀的分布式事务解决方案。
在分布式系统中,我们不能一味地追求强一致性,必须有所取舍,刚柔并济才是更加合理的设计方案。同样,在分布式事务方案选型方面,是选择性能更好的方案,还是选择耦合更低的方案,是更加倾向于有落地经验的成熟方案,还是热衷于理论上可能更好的方案。这中间如何权衡,需要结合业务特点和具体诉求来决定,是十分考验架构师与开发者的设计功力的。
https://en.wikipedia.org/wiki/Eventual_consistency
https://en.wikipedia.org/wiki/CAP_theorem
https://en.wikipedia.org/wiki/ACID
https://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf
Herbert,信也研发资深专家,主要负责账户、会计系统的设计和研发工作。