积分机

最终一致性和实时一致性是什么我们应该选择

发布时间:2023/5/23 18:21:43   

前面我们聊了微服务的9个痛点,有些痛点没有好的解决方案,而有些痛点刚好有一些对策,后面的几篇文章我们就来聊聊某些痛点对应的解决方案。

本篇文章我们先解决数据一致性问题。

01一、业务场景

使用微服务时,很多时候我们往往需要跨多个服务去更新多个数据库的数据,类似下图所示的架构。

如果业务正常运转,3个服务的数据应该变为a2、b2、c2,此时数据才一致。但是如果出现网络抖动、服务超负荷或者数据库超负荷等情况,整个处理链条有可能在步骤二失败,这时数据就会变成a2、b1、c1,当然也有可能在步骤三失败,最终数据就会变成a2、b2、c1,这样数据就对不上了,即数据不一致。

在以往的架构经历中,因为项目非常赶,所以我们完全没有精力处理数据一致性的问题,最终业务系统会出现很多错误数据。然后业务部门会发工单告知数据有问题,经过一番检查后,我们发现是分布式更新的原因导致了数据不一致。

此时,我们不得不抽出时间针对数据一致性问题给出一个完美解决方案。于是,整个部门人员坐一起商量,并把数据一致性的问题归类为以下2种场景。

02二、第一种场景:实时数据不一致不要紧,保证数据最终一致性就行

因为一些服务出现错误,导致图1的步骤三失败,此时处理完请求后,数据就变成了a2、b2、c1,不过不要紧,我们只需保证最终数据是a2、b2、c2就行。

比如:零售下单时,一般需要实现在商品服务中扣除商品的库存、在订单服务中生成一个订单、在交易服务中生成一个交易单这三个步骤。假设交易单生成失败,就会出现库存扣除了、订单生成了、交易单没生成的情况,此时我们只需保证最终交易单成功生成就行,这就是最终一致性。

03三、第二种场景:必须保证实时一致性

如果上图中的步骤二和步骤三成功了,数据就会变成b2、c2,但是如果步骤三失败,那么步骤一和步骤二会立即回滚,保证数据变回a1、b1。

在以往的一个项目中,业务场景类似这样:使用积分换折扣券时,需要实现扣除用户积分、生成一张折扣券给用户这2个步骤。如果我们还是使用最终一致性方案的话,有可能出现用户积分扣除了而折扣券还未生成的情况,此时用户进入账户一看,积分没了也没有折扣券,立马就会投诉。

此时怎么办呢?我们直接将前面的步骤回滚,并告知用户处理失败请继续重试就行,这就是实时一致性。

针对以上两种具体的场景,其具体解决方案是什么呢?下面我们一起来看看。

04四、最终一致性方案

对于数据要求最终一致性的场景,实现思路是这样的:

每个步骤完成后,生产一条消息给MQ,告知下一步处理接下来的数据;

消费者收到这条消息后,将数据处理完成后,与步骤一一样触发下一步;

消费者收到这条消息后,如果数据处理失败,这条消息应该保留,直到消费者下次重试。

为了方便你理解这部分内容,我梳理了一个大概的流程图,如下图所示:

关于上图,详细实现逻辑如下:

调用端调用ServiceA;

ServiceA将数据库中的a1改为a2;

ServiceA生成一条步骤2(姑且命名为Step2)的消息给到MQ;

ServiceA返回成功给调用端;

ServiceB监听Step2的消息,拿到一条消息。

ServiceB将数据库中的b1改为b2;

ServiceB生成一条步骤3(姑且命名为Step3)的消息给到MQ;

ServiceB将Step2的消息设置为已消费;

ServiceC监听Step3的消息,拿到一条消息;

ServiceC将数据库中的c1改为c2;

ServiceC将Step3的消息设置为已消费。

接下来我们考虑下,如果每个步骤失败了该怎么办?

1、调用端调用ServiceA。

解决方案:如果这步失败,直接返回失败给用户,用户数据不受影响。

2、ServiceA将数据库中的a1改为a2。

解决方案:如果这步失败,利用本地事务数据直接回滚就行,用户数据不受影响。

3、ServiceA生成一条步骤2(姑且命名为Step2)的消息给到MQ。

解决方案:如果这步失败,利用本地事务数据将步骤2直接回滚就行,用户数据不受影响。

4、ServiceA返回成功给调用端。

解决方案:如果这步失败,不做处理。

5、ServiceB监听Step2的消息,拿到一条消息。

解决方案:如果这步失败,MQ有对应机制,我们无须担心。

6、ServiceB将数据库中的b1改为b2。

解决方案:如果这步失败,利用本地事务直接将数据回滚,再利用消息重试的特性重新回到步骤5。

7、ServiceB生成一条步骤3(姑且命名为Step3)的消息给到MQ。

解决方案:如果这步失败,MQ有生产消息失败重试机制。要是出现极端情况,服务器会直接挂掉,因为Step2的消息还没消费,MQ会有重试机制,然后找另一个消费者重新从步骤5执行。

8、ServiceB将Step2的消息设置为已消费。

解决方案:如果这步失败,MQ会有重试机制,找另一个消费者重新从步骤5执行。

9、ServiceC监听Step3的消息,拿到一条消息。

解决方案:如果这步失败,参考步骤5的解决方案。

10、ServiceC将数据库中的c1改为c2。

解决方案:如果这步失败,参考步骤6的解决方案。

11、ServiceC将Step3的消息设置为已消费。

解决方案:如果这步失败,参考步骤8的解决方案。

以上就是最终一致性的解决方案,如果你仔细思考了该方案,就会与当初的我一样存在以下2点疑问。

因为我们利用了MQ的重试机制,就有可能出现步骤6跟步骤10重复执行的情况,此时该怎么办?比如上面流程中的步骤8失败了,需要从步骤5重新执行,这时就会出现步骤6执行2遍的情况。为此,在下游(步骤6和步骤10)更新数据时,我们需要保证业务代码的幂等性(关于幂等性,我们在01讲提过)。

如果每个业务流程都需要这样处理,岂不是需要额外写很多代码?那我们是否可以将类似处理流程的重复代码抽取出来?答案是可以的,这里使用的MQ相关逻辑在其他业务流程中也通用,最终我们就是将这些代码进行了抽取并封装。关于重复代码抽取的方法比较简单,这里就不赘述了。

05五、实时一致性方案

实时一致性,其实就是我们常说的分布式事务。

MySQL其实有一个两阶段提交的分布式事务方案(MySQLXA),但是该方案存在严重的性能问题。比如,一个数据库的事务与多个数据库间的XA事务性能可能相差10倍。另外,在XA的事务处理过程中它会长期占用锁资源,所以一开始我们并不考虑这个方案。

那时,市面上比较流行的方案是使用TCC模式,下面我们简单介绍一下。

在TCC模式中,我们会把原来的一个接口分为Try接口、Confirm接口、Cancel接口。

Try接口用来检查数据、预留业务资源。

Confirm接口用来确认实际业务操作、更新业务资源。

Cancel接口是指释放Try接口中预留的资源。

比如积分兑换折扣券的例子中需要调用账户服务减积分、营销服务加折扣券这两个服务,那么针对账户服务减积分这个接口,我们需要写3个方法,如下代码所示:

publicbooleanprepareMinus(BusinessActionContextbusinessActionContext,finalStringaccountNo,finaldoubleamount){//校验账户积分余额//冻结积分金额}publicbooleanConfirm(BusinessActionContextbusinessActionContext){//扣除账户积分余额//释放账户冻结积分金额}publicbooleanCancel(BusinessActionContextbusinessActionContext){//回滚所有数据变更}

同样,针对营销服务加折扣券这个接口,我们也需要写3个方法,而后调用的大体步骤如下:

上图中绿色代表成功的调用路径,如果中间出错,就会先调用相关服务的回退方法,再进行手工回退。原本我们只需要在每个服务中写一段业务代码就行,现在需要拆成3段来写,而且还涉及以下5点注意事项:

我们需要保证每个服务的Try方法执行成功后,Confirm方法在业务逻辑上能够执行成功;

可能会出现Try方法执行失败而Cancel被触发的情况,此时我们需要保证正确回滚;

可能因为网络拥堵出现Try方法的调用被堵塞的情况,此时事务控制器判断Try失败并触发了Cancel方法,后来Try方法的调用请求到了服务这里,此时我们应该拒绝Try请求逻辑;

所有的Try、Confirm、Cancel都需要确保幂等性;

整个事务期间的数据库数据处于一个临时的状态,其他请求需要访问这些数据时,我们需要考虑如何正确被其他请求使用,而这种使用包括读取和并发的修改。

所以TCC模式是一个很麻烦的方案,除了每个业务代码的工作量X3之外,出错的概率也高,因为我们需要通过相应逻辑保证上面的注意事项都被处理。

后来,我们刚好看到了一篇介绍Seata的文章,了解到AT模式也能解决这个问题。

06六、Seata中AT模式的自动回滚

对于使用Seata的人来说操作比较简单,只需要在触发整个事务的业务发起方的方法中加入

GlobalTransactional标注,且使用普通的

Transactional包装好分布式事务中相关服务的相关方法即可。

在Seata内在机制中,AT模式的自动回滚往往需要执行以下步骤:

07(一)一阶段

解析每个服务方法执行的SQL,记录SQL的类型(Update、Insert或Delete),修改表并更新SQL条件等信息;

根据前面的条件信息生成查询语句,并记录修改前的数据镜像;

执行业务的SQL;

记录修改后的数据镜像;

插入回滚日志:把前后镜像数据及业务SQL相关的信息组成一条回滚日志记录,插入UNDO_LOG表中;

提交前,向TC注册分支,并申请相关修改数据行的全局锁;

本地事务提交:业务数据的更新与前面步骤生成的UNDOLOG一并提交;

将本地事务提交的结果上报给事务控制器。

08(二)二阶段-回滚

收到事务控制器的分支回滚请求后,我们会开启一个本地事务,并执行如下操作:

查找相应的UNDOLOG记录;

数据校验:拿UNDOLOG中的后镜像数据与当前数据进行对比,如果存在不同,说明数据被当前全局事务之外的动作做了修改,此时我们需要根据配置策略进行处理;

根据UNDOLOG中的前镜像和业务SQL的相关信息生成回滚语句并执行;

提交本地事务,并把本地事务的执行结果(即分支事务回滚的结果)上报事务控制器。

09(三)二阶段-提交

收到事务控制器的分支提交请求后,我们会将请求放入一个异步任务队列中,并马上返回提交成功的结果给事务控制器。

异步任务阶段的分支提交请求将异步地、批量地删除相应UNDOLOG记录。

以上就是Seata的AT模式的简单介绍。

10七、尝试Seata

当时,Seata虽然还没有更新到1.0,且官方也不推荐线上使用,但是最终我们还是使用了它,原因如下:

因为实时一致性的场景很少,而且发生频率低,因此并不会大规模使用,对我们来说影响面在可控范围内。如果实时一致性的场景发生频率高,并发量就高,业务人员对性能要求也高,此时我们就会与业务商量,采用最终一致性的方案。

SeataAT模式与TCC模式相比,它只是增加了一个

GlobalTransactional的工作量,因此两者的工作量实在差太多了,所以我们愿意冒这个险,这也是Seata发展很快的原因。

后面,我们就在线上环境使用了Seata。虽然它有点小毛病,但是瑕不掩瑜。

11八、总结

最终一致性与实时一致性的解决方案设计完后,不仅没有给业务开发人员带来额外工作量,也没有影响日常推进业务项目的进度,还大大减少了数据不一致的出现概率,因此数据不一致的痛点算是大大缓解了。

不过该方案存在一点不足,因为某个服务需要依赖其他服务的数据,使得我们需要额外写很多业务逻辑,关于此问题的解决方案我们已在前面的文章中详细说明,感兴趣的小伙伴可以去看看。



转载请注明:http://www.aideyishus.com/lkgx/4692.html
------分隔线----------------------------