分布式对外高可用,对内如何让憋出的内伤消化消化。
一、补偿机制的意义
举例一个常见场景:
客户端->购物车微服务->订单微服务->支付微服务
为什么要考虑补偿机制呢?
因为一次跨机器的请求通信可能会通过DNS、网卡、交换机、路由机、负载均衡等设备,这些设备都不是一直稳定的,在数据传输的过程中只要一个问题出错,就会有问题的产生。
在分布式里面,一次完整的业务流程是由多次跨机器的通信构成,那么产生问题的概率就会成倍的增加。
但是这个并不表示真正的系统无法处理请求,所以我们应该尽可能的消化这些异常。
补偿、事务补偿或者重试之间有什么关系?
不需要太纠结这些名字,因为目的都是一样的:消化掉某个操作产生的异常,通过内部机制将这些异常产生的不一致消化掉。
不管通过什么方式,只要通过额外的方式解决问题,都可以理解成补偿的操作。所以事务补偿和重试都是补偿的子集,前者都是逆向操作,后者是一个正向操作。
只是从结果来看,两者的意义不同,事务的补偿意味着放弃,
而 「重试」 则还有处理成功的机会。这两种方式分别适用于不同的场景。
因为[补偿]已经是一个额外的流程,既然能够走额外的流程,说明时效性并不是第一考虑的因素,所以做补偿的核心要点是:宁可慢,不可错。
因此,不要草率的就确定补偿的实施方案,需要谨慎的评估,虽然说错误没有办法100%避免,但是要抱着这样的少发生错误的心态。
二、补偿应该怎么做?
主流的补偿方式就是前文提到的两种:回滚(事务的补偿),和重试
1. 回滚
回滚分为两种形式:
-
显示回滚(逆向调用接口)
-
隐式回滚(无需逆向调用接口)
最常见的显示回滚就是做两件事:
首先是确定失败的步骤和状态,从而确定回滚范围。一个业务流程,往往是在设计之初就制定好了,所以确定回滚的范围比较容易,但这唯一需要注意的一点是:如果在一个业务处理中涉及到的服务并不是都提供了 「回滚接口」 ,那么在编排服务时应该把提供 「回滚接口」 的服务放在前面,这样当后面的工作服务错误时还有机会 「回滚」 。
简单的来说就是让回滚接口有被调用到的机会。最优的选择就是放在第一个。
其次就是要能提供回滚操作使用到的业务数据。回滚时提供的数据越多,越有利于程序的健壮性,因为程序可以在收到回滚操作的时候做业务检查,比如检查账户是否相等,金额是否一致。
在这个中间态的数据结构和数据大小并不确定。所以最好是将相关数据序列化成一个JSON,然后存到nosql里面。
[隐式回滚] 相对来说使用的场景比较少。它意味着这个回滚的动作不需要我们额外的处理,下游服务内部有类似"预占"并且"超时失效"的机制。
例如:
在电商的场景里面。会将订单中的商品预占库存,等待用户在多少分钟内支付。如果没有支付,就释放库存。
2. 重试
这个操作最大的好处就是不需要提供额外的接口[逆向接口]。这对于代码的维护和长期开发的成本有优势,而且业务是变化的。逆向接口也需要变化。所以更多时候可以考虑重试。
不过相对于回滚来说,重试使用的场景要少一些。
-
下游系统返回请求超时,被限流中等临时状态的时候,我们就可以考虑重试了。
-
而如果是返回余额不足,无权限的明确业务错误,就不需要重试。
-
一些中间件或者RPC框架,返回503,404这种没有预期恢复时间的错误,也不需要重试了。
为了进行重试,我们还需要指定一个重试的策略,主流的重试策略主要是以下几种:
1.立即重试: 有时候故障是暂时性的,可能因为网络数据包冲突或者硬件组件高峰流量等事件造成的,在这种情况下,适合立即重试的操作。不过立即重试的操作不应该超过一次,如果立即重试失败,应该改用其他策略。
2.固定间隔: 这个很好理解,比如每隔5分钟重试一次。PS:策略1和策略2多用于前端系统的交互操作中。
3.增量间隔: 这个也很好理解,比如间隔15分钟重试一次。
return (retryCount-1)*incrementInterval;
这个的主要使用目的是让重试失败的优先级往后面排。让新的重试入队。
4.指数间隔: 和增量没有什么大区别,不过就是增长的幅度大一些。
5.全抖动: 在递增的基础上,增加随机性,适用于某一时刻产生的大量请求进行压力分散的场景。
return random(0,2^retryCount);
6.等抖动: 在指数间隔和全抖动之间寻找一个中庸的方案,降低随机性的使用。
var baseNum = 2 ^ retryCount; return baseNum + random(0,baseNum);
3、4、5、6策略的表现情况大致是这样。(x轴为重试次数)
为什么说重试有坑呢?
正如前面说到的那样。出于对开发成本考虑,如果重试对接口的调用,就需要考虑到 幂等性 的问题。
幂等性是一个数学概念,后引申为程序概念。
这就意味着可能多次调用,如果没有保证幂等性的话,就会产生错误的操作。
所以,一旦某个功能支持重试,那么整个链路上的解耦都需要考虑幂等性的问题,不能因为多次调用而导致业务数据的变化。
满足幂等性的实现思路就是将其过滤掉:
1.给每一个请求定一个唯一的标识。
2.在进行重试的时候,判断整个请求是否已经执行过,或者正在执行。如果是就抛弃请求。
第一点:可以使用全局ID生成器或者ID生成服务。或者粗暴一些,使用Guid,或者UUID。给每一个请求赋值。
第二点:
使用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没法继续进行)。
转自:https://zhuanlan.zhihu.com/p/258741780