标签:结合 guid 进入 异常 均衡 决定 验证 static comment
也许你对降级已经有了一些认识,这次,我们来聊一聊在保证对外高可用的同时,憋出的“内伤”该如何通过「补偿」机制来自行消化。
以电商的购物场景为例:
客户端 ----> 购物车微服务 ----> 订单微服务 ----> 支付微服务。
这种调用链非常普遍。
那么为什么需要考虑补偿机制呢?
正如之前几篇文章所说,一次跨机器的通信可能会经过 DNS 服务,网卡、交换机、路由器、负载均衡等设备,这些设备都不一定是一直稳定的,在数据传输的整个过程中,只要任意一个环节出错,都会导致问题的产生。
而在分布式场景中,一个完整的业务又是由多次跨机器通信组成的,所以产生问题的概率成倍数增加。
但是,这些问题并不完全代表真正的系统无法处理请求,所以我们应当尽可能的自动消化掉这些异常。
可能你会问,之前也看到过「补偿」和「事务补偿」或者「重试」,它们之间的关系是什么?
你其实可以不用太纠结这些名字,从目的来说都是一样的。就是一旦某个操作发生了异常,如何通过内部机制将这个异常产生的「不一致」状态消除掉。
题外话:在笔者看来,不管用什么方式,只要通过额外的方式解决了问题都可以理解为是「补偿」,所以「事务补偿」和「重试」都是「补偿」的子集。前者是一个逆向操作,而后者则是一个正向操作。
只是从结果来看,两者的意义不同。「事务补偿」意味着“放弃”,当前操作必然会失败。
▲事务补偿
「重试」则还有处理成功的机会。这两种方式分别适用于不同的场景。
▲重试
因为「补偿」已经是一个额外流程了,既然能够走这个额外流程,说明时效性并不是第一考虑的因素,所以做补偿的核心要点是:宁可慢,不可错。
因此,不要草率的就确定了补偿的实施方案,需要谨慎的评估。虽说错误无法 100% 避免,但是抱着这样的一个心态或多或少可以减少一些错误的发生。
做「补偿」的主流方式就前面提到的「事务补偿」和「重试」,以下会被称作「回滚」和「重试」。
我们先来聊聊「回滚」。相比「重试」,它逻辑上更简单一些。
「回滚」
回滚分为 2 种模式,一种叫「显式回滚」(调用逆向接口),一种叫「隐式回滚」(无需调用逆向接口)。
最常见的就是「显式回滚」。这个方案无非就是做 2 个事情:
首先要确定失败的步骤和状态,从而确定需要回滚的范围。一个业务的流程,往往在设计之初就制定好了,所以确定回滚的范围比较容易。但这里唯一需要注意的一点就是:如果在一个业务处理中涉及到的服务并不是都提供了「回滚接口」,那么在编排服务时应该把提供「回滚接口」的服务放在前面,这样当后面的工作服务错误时还有机会「回滚」。
其次要能提供「回滚」操作使用到的业务数据。「回滚」时提供的数据越多,越有益于程序的健壮性。因为程序可以在收到「回滚」操作的时候可以做业务的检查,比如检查账户是否相等,金额是否一致等等。
由于这个中间状态的数据结构和数据大小并不固定,所以 Z 哥建议你在实现这点的时候可以将相关的数据序列化成一个 json,然后存放到一个 nosql 类型的存储中。
「隐式回滚」相对来说运用场景比较少。它意味着这个回滚动作你不需要进行额外处理,下游服务内部有类似“预占”并且“超时失效”的机制的。例如:
电商场景中,会将订单中的商品先预占库存,等待用户在 15 分钟内支付。如果没有收到用户的支付,则释放库存。
下面聊聊可以有很多玩法,也更容易陷入坑里的「重试」。
「重试」
「重试」最大的好处在于,业务系统可以不需要提供「逆向接口」,这是一个对长期开发成本特别大的利好,毕竟业务是天天在变的。所以,在可能的情况下,应该优先考虑使用「重试」。
不过,相比「回滚」来说「重试」的适用场景更少一些,所以我们第一步首先要判断,当前场景是否适合「重试」。比如:
如果确定要进行「重试」,我们还需要选定一个合适的「重试策略」。主流的「重试策略」主要是以下几种。
策略 1. 立即重试。有时故障是候暂时性,可能是因网络数据包冲突或硬件组件流量高峰等事件造成的。在此情况下,适合立即重试操作。不过,立即重试次数不应超过一次,如果立即重试失败,应改用其它的策略。
策略 2. 固定间隔。应用程序每次尝试的间隔时间相同。 这个好理解,例如,固定每 3 秒重试操作。(以下所有示例代码中的具体的数字仅供参考。)
策略 1 和策略 2 多用于前端系统的交互式操作中。
策略 3. 增量间隔。每一次的重试间隔时间增量递增。比如,第一次 0 秒、第二次 3 秒、第三次 6 秒,9、12、15 这样。
return (retryCount - 1) * incrementInterval;
使得失败次数越多的重试请求优先级排到越后面,给新进入的重试请求让道。
策略 4. 指数间隔。每一次的重试间隔呈指数级增加。和增量间隔“殊途同归”,都是想让失败次数越多的重试请求优先级排到越后面,只不过这个方案的增长幅度更大一些。
return 2 ^ retryCount;
策略 5. 全抖动。在递增的基础上,增加随机性(可以把其中的指数增长部分替换成增量增长。)。适用于将某一时刻集中产生的大量重试请求进行压力分散的场景。
return random(0 , 2 ^ retryCount);
策略 6. 等抖动。在「指数间隔」和「全抖动」之间寻求一个中庸的方案,降低随机性的作用。适用场景和「全抖动」一样。
var baseNum = 2 ^ retryCount;
|
|
return baseNum + random(0 , baseNum);
|
|
3、4、5、6 策略的表现情况大致是这样。(x 轴为重试次数)
为什么说「重试」有坑呢?
正如前面聊到的那样,出于对开发成本考虑,你在做「重试」的时候可能是复用的常规调用的接口。那么此时就不得不提一个「幂等性」问题。
如果实现「重试」选用的技术方案不能 100% 确保不会重复发起重试,那么「幂等性」问题是一个必须要考虑的问题。哪怕技术方案可以确保 100% 不会重复发起重试,出于对意外情况的考量,尽量也考虑一下「幂等性」问题。
幂等性:不管对程序发起几次重复调用,程序表现的状态(所有相关的数据变化)与调用一次的结果是一致的话,就是保证了幂等性。
这意味着可以根据需要重复或重试操作,而不会导致意外的影响。对于非幂等操作,算法可能必须跟踪操作是否已经执行。
所以,一旦某个功能支持「重试」,那么整个链路上的接口都需要考虑幂等性问题,不能因为服务的多次调用而导致业务数据的累计增加或减少。
满足「幂等性」其实就是需要想办法识别重复的请求,并且将其过滤掉。思路就是:
第 1 点,我们可以使用一个全局唯一 id 生成器或者生成服务。 或者简单粗暴一些,使用官方类库自带的 Guid、uuid 之类的也行。
然后通过 rpc 框架在发起调用的客户端中,对每个请求增加一个唯一标识的字段进行赋值。
第 2 点,我们可以在服务端通过 Aop 的方式切入到实际的处理逻辑代码之前和之后,一起配合做验证。
大致的代码思路如下。
【方法执行前】
if(isExistLog(requestId)){ //1. 判断请求是否已被接收过。 对应序号 3
|
|
var lastResult = getLastResult(); //2. 获取用于判断之前的请求是否已经处理完成。 对应序号 4
|
|
if(lastResult == null){
|
|
var result = waitResult(); // 挂起等待处理完成
|
|
return result;
|
|
}
|
|
else{
|
|
return lastResult;
|
|
}
|
|
}else{
|
|
log(requestId); //3. 记录该请求已接收
|
|
}
|
|
//do something..
|
【方法执行后】
logResult(requestId, result); //4. 将结果也更新一下。
|
如果「补偿」这个工作是通过 MQ 来进行的话,这事就可以直接在对接 MQ 所封装的 SDK 中做。在生产端赋值全局唯一标识,在消费端通过唯一标识消重。
再聊一些最佳实践吧,都是针对「重试」的,的确这也是工作中最常用的方案。
「重试」特别适合在高负载情况下被「降级」,当然也应当受到「限流」和「熔断」机制的影响。当「重试」的“矛”与「限流」和「熔断」的“盾”搭配使用,效果才是最好。
需要衡量增加补偿机制的投入产出比。一些不是很重要的问题时,应该「快速失败」而不是「重试」。
过度积极的重试策略(例如间隔太短或重试次数过多)会对下游服务造成不利影响,这点一定要注意。
一定要给「重试」制定一个终止策略。
当回滚的过程很困难或代价很大的情况下,可以接受很长的间隔及大量的重试次数,DDD 中经常被提到的「saga」模式其实也是这样的思路。不过,前提是不会因为保留或锁定稀缺资源而阻止其他操作(比如 1、2、3、4、5 几个串行操作。由于 2 一直没处理完成导致 3、4、5 没法继续进行)。
这篇我们先聊了下做「补偿」的意义,以及做补偿的 2 个方式「回滚」和「重试」的实现思路。
然后,提醒你要注意「重试」的时候需要考虑幂等性问题,并且 z 哥也给出了一个解决思路。
最后,分享了几个针对「重试」的最佳实践。
希望对你有所帮助。
本地事务
的时候,知道自己执行的事务是成功还是失败,但是无法知道其他服务节点的事务执行情况,因此需要引入协调者TM
,负责协调参与者RM
的行为,并最终决定这些参与者是否把事务进行提交。随着微服务架构的流行,让分布式事务问题日益突出, 那么常见的分布式事务解决方案有哪些呢? 如何理解最终一致性和它的事务补偿机制呢?
如上图,这是个标准的全局事务,事务管理器
控制着全局事务,管理事务的生命周期,并通过XA协议与资源管理器
协调资源;资源管理器负责控制和管理实际的资源 (这里的资源管理器,可以是一个DBMS,或者消息服务管理系统)
它是XA用于在全局事务中协调多个资源的机制,常用于事务管理器
和资源管理器
之间,解决一致性问题,分两阶段:
增加了超时机制
, 主要解决单点故障问题,并减少资源锁定时间,一旦RM无法及时收到来至TM的信息之后,它会默认执行Commit操作, 而不会一直持有事务资源并处于阻塞状态。但是这种机制同样会导致数据不一致的问题,由于网络的原因,TM发送的回滚动作,没有被RM及时的收到,那么RM等待超时后就执行了提交操作,这样就和收到回滚操作并执行的RM之间存在了数据不一致的情况。
在2008年,eBay公布了基于BASE
准则的最终一致性解决方案,它主要采用了消息队列来辅助实现事务控制流程,其核心通过消息队列的方式来异步执行分布式处理的任务,如果事务失败,则可以发起人工重试的纠正流程(比如对账系统,对处于dead letter queue
的问题进行处理)
微服务架构下,需要通过网络进行通信,就自然引入了数据传输的不确定性,也就是CAP原理中的P-分区容错,而这里的消息发送一致性
是可靠消息的保证。
生成消息的业务动作与消息发送的一致(e.g: 如果业务操作成功,那么由这个业务操作所产生的消息一定会成功投递出去,否则就丢失消息)
如上图,保证消息发送一致性的一般流程如下:
待确认
,这个状态并不会被Consumer消费,对于长期待确认
的消息,消息中间件会调用Producer的查询接口,查看最新状态,根据结果决定是否删除消息。待发送(可发送)
ACK
,确认消息已经收到(消息中间件服务将从队列中删除该消息)消息的ACK确认流程中,任何一个环节都可能会出问题!
对未ACK
的消息,采用按规则重新投递的方式进行处理(很多MQ都提供at least once的投递,持久化和重试机制),一般还会设置重发
的次数, 超过次数的消息会进入dead letter queue
,等待人工干预或者延后定时处理。
消息的重复发送会导致业务接口出现重复调用的问题,主要原因就是消息没有及时收到ACK确认导致的, 那如何实现幂等性设计呢?
在实际的业务场景中, 业务接口的幂等性设计,常结合查询操作一起使用,
比如根据唯一标识
查询消息是否被处理过, 或者根据消费日志表,来维护消息消费的记录。
修正
操作中出现的问题,或许是重新执行,或许取消已经完成的操作,通过修复是的整个分布式系统达到最终一致。
因为一直学习与尝试负责公司的推送相关业务,包括整个应用的实现,其中就采用了基于消息队列的异步事件驱动模型来做解耦异步处理,所以就要去做了解一些相关的知识点,这边稍作总结,并整理一下消息补偿机制的一套简单实现的代码设计图。
采用基于消息队列的异步事件驱动模型来解决问题的时候,一个计较棘手的问题就是事务的一致性。
案例:现在用户发起一个创建订单的请求,如果我们是单系统架构,那么修改订单表,修改库存表可能都是在同一个事务中完成,所以轻而易举就达到了事物一致性原则,但是这不是我们要讨论的,所以就带过。现在微服务架构在互联网公司大火特火,热度未减,分布式是事务也成为了一个亟待解决的问题,阿里云GTS标榜如何让分布式事务更简单。
比如,用户发起一个创建订单的请求,首先在订单服务上生成了新的订单,同时还要去库存服务中减去库存,因为是分布式架构,所以库存扣减与订单创建可能是在两个遥远的机器上,如果想要通过本地事务来解决那几乎是不可能的,保证两个事务之间的状态一致性——订单创建成功,库存扣减失败,如何回滚订单?一直都是分布式架构中绕不开的挑战。
分布式架构中如何解决事务问题,在很多技术群都上都在讨论,比如dubbo , spring cloud等等。目前还没有接触到这方面相关知识,后续如果有幸参与,可做分享,本次想要聊的是假基于消息队列的异步事件驱动是如何解决如上的分布式问题,以及如何保证事务一致性。
事务一致性原则(ACID):
Atomicity - 原子性,改变数据状态要么是一起完成,要么一起失败
Consistency - 一致性,数据的状态是完整一致的
Isolation - 隔离线,即使有并发事务,互相之间也不影响
Durability - 持久性, 一旦事务提交,不可撤销
订单创建完成之后,发送一个createOrderEvent到消息队列中,由消息队列负责转发给订阅该消息的消费者进行处理。
好,这个时候如果消息消费 成功,但是库存不足,库存扣减失败,订单创建则不能成功,这个时候很好处理,由库存服务推送一个subInventoryFail给到订单服务,订单服务根据消息将订单转为失败状态。
1、从用户体验的角度来说,整个过程是异步的,所以对于用户的体验来说,就做不到“立马成功或立马失败”的效果。
2、从技术的角度来说,整个过程你不再关注同一个事物的问题,而是关注最终订单的状态是否一致。【注:从分布式事务<-->最终一致性】保证事务最终一致性,但是基于这种事件驱动达到最终一致,解耦事务的成功实施需要依赖几个因素。
a、消息的投递是否可靠。
b、消息的可靠性,例如订单服务已经成功创建订单,但是还没来得及发送消息就宕机或者各种原因,导致订单的状态不一致。
基于以上两点的考虑,我们使用了一种基于本地事务的方案来保证消息最终的一致性。
创建订单与创建消息事件都在本地事务中,属于同一个事务,可以保证订单表与消息事件表的数据一致性。发送消息到消息中间件,在事务提交之后发送。到了库存服务的时候,启动一个定时任务去扫描消息事件表,将未投递失败/消费 失败的消息进行消费,即补偿事务一致性。
定时任务的方案可能不是最佳的,可以稍作改定,比如采用阿里巴巴开源的Canal。
公司目前也是采用这种架构来解决订单与库存问题。有网友的做法是保证消息投递的可靠性,我们则是保证消费的一致性,具体的文章点我>>
可以将消息队列的进行封装,做成了一个starter,代码设计上大致如图下:
@山茶果
标签:结合 guid 进入 异常 均衡 决定 验证 static comment
原文地址:https://www.cnblogs.com/heroinss/p/11883482.html