引言
在互联网系统中,理想的情况下,肯定是希望系统能够同时满足“一致性”、“可用性”和“分区容忍性”。 但是基于熟悉的CAP定律也好,还是BASE理论, 我们知道,在实际情况中是不可能实现的。而在金融领域,一致性是最为关注的特性,任何情况下都必须满足一致性。关于CAP定律和BASE理论,本文不再介绍,有兴趣的同学可以自行百度一下。本文重点来阐述下关于一致性的方案,包括强一致性和最终一致性。 而在互联网领域, 很多情况下都是牺牲强一致性,来达到高可用性, 系 统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。
数据库本地事务
数据库事务肯定是强一致性的方案,而且是一致性最简单的方案,因为一致性是数据库的事务来保证的,业务层不需要关心细节。比较典型的应用是在返现场景下,针对带有返现的交易的退款,需要一次性退两笔交易单,采用的就是通过数据库本地事务来完成的。具体如下:
用户A花了100元购买商户B的商品,购买结束后返现给用户A 2元。 这是两笔交易,原始交易是100元,返现交易是2元。 那么发生退款时,需要保证两笔交易同时都退款。这个就是直接采用数据库本地事务实现的,即一次退款请求,两笔交易同时退款。
总结: 数据库事务的优点是简单,业务层关心的很少。但是对于一个可用性很高的系统来说,所有的业务都揉在数据库事务执行,会让事务非常的复杂,不利于系统的扩展和维护。
两阶段提交
除了数据库能够保证本地的一致性,对于互联网系统来说,更多是分布式系统。提到分布式系统,必然提到分布式事务。而分布式事务中,就不得不介绍两阶段提交协议(2pc)。 而在核心系统,两阶段提交的方案主要应用在 分布式数据库NesioDB和交易账务分离的柔性事务中 。
分布式数据库NesioDB是由百度DBA和百度钱包联合开发的,支持分布式事务的数据库,目前已经应用在百度钱包的核心交易业务上,并稳定运行两年。该数据库的设计要求是让使用者能够像使用单机数据库一样的使用分布式数据库,因此实现的分布式事务,满足单机事务的ACID原则。关于分布式事务的一致性,采用的就是两阶段提交的方式来实现的,并且满足分布式事务模型。如下图所示。
***阶段是准备阶段。
DTM 通知所有参与事务的各个 RM,给每个 RM 发送 prepare 消息。RM 接收到消息后进入准备阶段后,要么直接返回失败,要么创建并执行本地事务,写本地事务日志(redo 和 undo 日志),但是 不提交(此处只保留***一步耗时最少的提交操作给第二阶段执行)。
第二阶段是提交/回滚阶段。
DTM 收到 RM 准备阶段的失败消息或者获取 RM 返回消息超时,则直接给 RM 发送回滚(rollback)消息,否则发送提交(commit)消息。RM 根据 TM 的指令执行提交或者回滚,执行完成后释放所有事务处理过程中使用的锁(***阶段释放锁)。
数据库层面的两阶段提交,可以用来保证分布式事务的一致性,使得使用者使用分布式事务和单机事务一样方便。而两阶段提交的另外一种实现,即TCC(Try-Confirm-Cancel), 也就是业务层面的柔性事务。 交易和账务分离的一致性实现,就是采用这种柔性事务来完成的。首先来说说柔性事务,它涉及 3 个模块, 主业务、从业务 和 活动管理器(协作者)。
下面这张图是有关柔性事务一张经典的图。
***阶段:主业务服务分别调用所有从业务服务的 try 操作,并在活动管理器中记录所有从业务服务。当所有从业务服务 try 成功或者某个从业务服务 try 失败时,进入第二阶段。
第二阶段:活动管理器根据***阶段从业务服务的 try 结果来执行 confirm 或 cancel 操作。如果***阶段所有从业务服务都 try 成功,则协作者调用所有从业务服务的 confirm 操作,否则,调用所有从业务服务的 cancel 操作。
在第二阶段中,confirm 和 cancel 同样存在失败情况,所以需要对这两种情况做 异常处理以保证数据一致性。
- Confirm 失败:则回滚所有 confirm 操作并执行 cancel 操作。
- Cancel 失败:从业务服务需要提供自动 cancel 机制,以保证 cancel 成功。
如果对应到交易和账务分离的项目中,流程如下:
***阶段: 主业务服务调用交易和账务执行try的操作,交易开启事务,做业务上的判断和写入,但是不提交事务。账务层面做资源的锁定。
第二阶段: 账务资源锁定成功,交易提交事务成功,然后发送confirm 给账务。 如果交易提交失败,则发送cancel对资源进行释放。如果在confirm或者cancel出现异常情况下,同样需要对异常进行处理来保证数据一致性。
总结: 这种方式实现难度不算太高,比较适合传统的单体应用,在同一个方法中存在跨库操作的情况。
回滚机制
在分布式架构中,功能 X,需要去协调后端的 A、B 甚至更多的原子服务。那么问题来了,假如 A 和 B 其中一个调用失败了,那可怎么办呢? 这个时候,可以用回滚机制来保证一致性。 该机制应用在 钱包配合信贷做的联合放贷项目 中。 该项目中总共有两个原子操作,如下图所示。
两个原子操作,分别是资金归集和资金到卡。所谓资金归集,是将商户A的钱和商户B的钱归集到中间商户C。而资金到卡,是将中间商户C的钱,通过银行系统打入到D用户的银行卡。这两个操作要满足一致性,即资金归集成功,然后打款到用户的卡成功。或者是商户A和B的钱没变化,资金到卡失败。 总而言之,是不允许资金停留在中间商户C的。
针对这种情况,通过回滚机制,提供一个强大的回滚操作来实现上述的一致性。比如资金归集成功,而资金到卡失败,那么对归集的资金操作做回滚处理,也就是资金从中间商户C分别回到商户A和B中。
总结: 这种方式缺点比较多,通常在复杂场景下是不推荐使用的,除非是非常简单的场景,非常容易提供回滚,而且依赖的服务也非常少的情况。这种实现方式会造成代码量庞大,耦合性高。而且非常有局限性,因为有很多的业务是无法很简单的实现回滚的,如果串行的服务很多,回滚的成本实在太高。
本地消息表
这种实现方式的思路,其实是源于 ebay,后来通过支付宝等公司的布道,在业内广泛使用。其基本的设计思想是将远程分布式事务拆分成一系列的本地事务。如果不考虑性能及设计优雅,借助关系型数据库中的表即可实现。本地消息的方式,在应用在 钱包非核心业务异步化改造项目 中。该项目当时改造的方案如下:
- 核心业务实时写入交易表
- 非核心业务非实时异步写入交易表按照用户维度的交易查询表。
交易表是交易维度的,而为了满足用户的查询性能,需要备份复制相同的按照用户维度的交易查询表。 从业务属性上来看,交易表是核心业务,交易查询表是非核心业务(查询使用)。而实现上,交易表是核心数据库,而查询表则属于非核心数据库。 但是, 这两者需要满足一致性。 关于这类一致性保障,如果有不丢消息的消息队列,则很容易解决。万一没有这类消息队列呢? 其实,使用本地消息表,也一样可以解决。
如图所示,是利用本地消息表保持最终一致性的应用。 具体如下:
- 业务A将本地消息和A业务数据以本地事务的方式写入DB1;
- 业务A写完本地事务后,发送消息给MQ。
- MQ推送消息给业务B,业务B执行消息,写入DB2.
- 由于MQ不能保证消息不丢,如果消息丢失了,则需要通过业务C,读取DB1的消息,然后rpc发送给业务B重新执行。
当然,如何判断DB1的消息已经消费,这个可以通过DB2的事务执行结果来判断。
总结: 上诉的方式是一种非常经典的实现,基本避免了分布式事务,实现了“最终一致性”。但是,关系型数据库的吞吐量和性能方面存在瓶颈,频繁的读写消息会给数据库造成压力。所以,在真正的高并发场景下,该方案也会有瓶颈和限制的。
补偿机制
补偿机制在分布式系统中,应用最为广泛。在钱包应用的场景比较多,比如 核心收银台和付款到卡 。 核心收银台中,当请求银行扣款,扣款成功后,自身系统挂掉了。这个时候就会有一个后台程序,我们也称作补单程序来开始处理这类流程,让原来中间断掉的流程继续走下去。
一般成熟的系统中,对于级别较高的服务和接口,整体的可用性通常都会很高。如果有些业务由于瞬时的网络故障或调用超时等问题,那么这种补偿机制其实是非常有效的。
总结
本文通过核心系统的几个具体实际项目,阐述了如何保证分布式系统的一致性。每一种方案都有一定的特征和应用场景。 其实分布式系统的事务一致性本身是一个技术难题,目前没有一种很简单很***的方案能够应对所有场景。具体还是要使用者根据不同的业务场景去抉择。