分布式理解:如何保证分布式数据的最终一致性
摘要:CAP 理论中的强一致性与可用性的告诉我们两者不可兼得,并由此催生出了 BASE 理论,将强一致性和可用性弱化为最终一致性和基本可用性。本文主要叙述笔者对最终一致性实现的理解,希望对大家有帮助。
1 - 分布式事务
在单机应用上,我们使用事务是很方便的,因为所有的业务逻辑都在本地,数据库事务就能解决 ACID 问题,特别是使用一些J2EE的框架,每一层的业务逻辑都给我们安排得妥妥当当的。
当系统已经被拆分部署到多个服务器实例上时,一般每个服务器都只负责维护一个子系统一张/数张表。与单机相比,业务还是那个业务,但从直接调用本地的下层服务变成了一个远程的RPC调用。
在分布式环境下,一个远程调用是不可靠的(因为网络是不可靠的),我们无法保证在一台服务器上发出的请求一定能在另一台服务器上执行成功,也无法保证执行结果能够准确/准时地返回。有可能被调用的服务执行失败,也有可能执行成功,只是报文丢失。因此导致数据不一致,这些实际上也是分布式事务的问题。
2 - 最终一致性
最终一致性不要求系统的数据实时一致,允许数据同步之间存在时延,只要保证最终返回给用户的一定是最新的数据即可。
我们解决分布式事务的核心是 将分布式事务转化为本地事务。(划重点)
我们需要根据业务来制定具体的策略,但实现的核心组件是消息队列。
2.1 不需要等待各事务参与方的同步
当我们请求的服务不影响主要业务流程的时候,我们可以通过消息队列异步调用这些服务。
比如下单成功一般会有手机短信提醒,假设手机短信提醒的服务是一个远程调用,而且这个服务是次要的,那么我们可以将它放到消息队列中,等待本地事务提交后再把消息发送出去,调用短信提醒的服务。同时,短信提醒的 ACID 会在自己的服务器上得到保证,成为另一个本地事务。
*消息队列放在本地(如库表)或者使用消息中间件都是可行的。
问:为什么要等待本地事务提交后才发出消息队列的消息?
答:这样的好处是确保一个服务器上的事务的提交再开启下一个服务器上的事务,避免回滚多个服务器上的事务。在这里就是确保下单成功,再开启短信提醒的事务。
问:请求的服务一定会执行成功吗?如果请求的服务事务失败,前面的事务不也需要回滚吗?比如短信提醒事务失败。
答:接收方的业务执行是否成功是未知的,但是假如前面请求了很多服务,仅仅因为一个服务的失败就回滚所有事务,这个开销是难以接受的。我们可以使用消息队列的ACK机制(消息被消费,消息队列会接收到ACK回传),确保接收方的业务成功才回传ACK,否则消息队列会重复发送消息,直到业务成功。可以理解为强制要求服务必须成功。
问:消息队列尝试重复发送消息,怎么处理幂等问题?
答:正如我们上面所说的,网络是不可靠的,有可能接受方的业务执行成功了,但是消息队列并没有接收到回传的ACK,就会导致消息队列再次发送消息,而接收方此时会接收到重复的业务请求。下面一节就简单介绍一些我对幂等性的理解。
2.2 消息的幂等性
保证消息的幂等性一般有两种方法:
- 在业务逻辑中保证,有些业务逻辑天生就是幂等性的,比如 Redis 的 set,重复 set 一条记录并没有影响。但业务数据库的库表里重复插入一条记录就有可能有问题,我们可以使用主键,重复插入相同的主键数据库会报错。库表字段的更新一般来说也能保证幂等性,重复更新同一个字段影响不大。
- 如果无法在业务中保证幂等性,可以增加一个更新记录表来记录已经处理过的消息。
2.3 需要等待各事务参与者的同步
上面我们介绍的业务场景是不需要等待各事务参与者的同步的,其他服务的执行不会影响核心业务,我们允许通过消息队列来异步调用这些次要服务,但很多时候我们的核心业务必须多个参与者同步,需要所有数据同步这个业务才能确定是成功的。比如,订单下单后,包括订单创建、库存减少、用户积分增加等子服务,这些都是非常重要的核心子业务,怎么保证这些服务上的数据是最终一致的呢?
我们还是通过消息队列的方式,但是我们这次不需要在本地事务提交后才发送消息,而是选择实时发送消息,实时调用其他子服务,缩短响应时间。相比于次要业务,每个核心子业务都关系到完整业务的完成,间接对用户获取响应造成影响,不适合在本地积存太久。
那么我们怎么设计这个系统呢?以订单创建、库存减少、用户积分增加三个服务为例。
我们不关心业务逻辑主要部署在哪个服务器的上(可能有一个统一的分发中心,也可能用户直接请求的就是订单创建所在的服务器),在系统的任意节点请求一个服务我们就向消息队列添加一条消息,业务的完成必须所有消息都被消费,并且执行成功。
*消息队列放在本地(如库表)或者使用消息中间件都是可行的。
问:那么此时还是会有我们之前提到的问题,子服务的事务失败怎么办?部分成功部分失败怎么办?
答:首先还是使用消息队列的 ACK 机制,不断重试,直到成功。注意,有些业务是无论怎样重试都注定失败的,比如,库存突然变为 0,这是可能存在的,因为受网络影响有可能后面的其他用户请求反而提前到达了。显然怎么重试,该子业务都不可能成功。所以,即使此时订单创建、用户积分增加的本地事务都提交了,我们还是需要回滚所有本地事务,因为整个业务的事务已经不可能完成了。
问:幂等性问题?
答:既然使用了消息队列,那么还是会存在消息的幂等的问题,怎么处理就不再赘述了。
2.4 不使用消息队列
在参考 这篇博文 的时候,里面部分提到使用数据库表的方式存放事务,确保整个事务的一致性。
简单来说就是每个服务器的数据库实例存放一张事务执行的表,发起 RPC 调用的时候,就向该服务所在的服务器的事务表插入一条记录,表内记录事务状态、一些运行参数等。这样可以通过查询这个事务表来确定事务是否完成,如果事务没有完成,也可以选择重试或者回滚。
*事务表有点像消息队列,但维持在被调用者本地,而事务表的记录其实也有点类似消息。
3 - 总结
- RPC 都不是单纯地直接调用,而是先放在消息队列,或者保存在某个数据结构里(库表/内存queue)自己写个消息发送服务。
- 次要业务的消息可以积压一段时间,而核心业务的消息最好短时间内发送出去。
- 某个子事务失败后一般需要重试,很少直接回滚所有事务。
- 所有消息被消费,并且子服务都成功执行时,数据才能保证最终一致性。否则,应该回滚。
4 - 参考
正文结束,欢迎留言。