聊聊分布式中的补偿机制

baojunzh
发布于 2022-10-31 16:26
浏览
0收藏

分布式对外高可用,对内如何让憋出的内伤消化消化。

一、补偿机制的意义

举例一个常见场景:
客户端->购物车微服务->订单微服务->支付微服务

为什么要考虑补偿机制呢?
因为一次跨机器的请求通信可能会通过DNS、网卡、交换机、路由机、负载均衡等设备,这些设备都不是一直稳定的,在数据传输的过程中只要一个问题出错,就会有问题的产生。

在分布式里面,一次完整的业务流程是由多次跨机器的通信构成,那么产生问题的概率就会成倍的增加。

但是这个并不表示真正的系统无法处理请求,所以我们应该尽可能的消化这些异常。

另:补偿、事务补偿或者叫做重试,他们之间的关系是什么?

不需要太纠结这些名字,因为目的都是一样的:消化掉某个操作产生的异常,通过内部机制将这些异常产生的不一致消化掉。

不管通过什么方式,只要通过额外的方式解决问题,都可以理解成补偿的操作。所以事务补偿和重试都是补偿的子集,前者都是逆向操作,后者是一个正向操作。

只是从结果来看,两者的意义不同,事务的补偿意味着放弃,

聊聊分布式中的补偿机制-鸿蒙开发者社区


而「重试」则还有处理成功的机会。这两种方式分别适用于不同的场景。

聊聊分布式中的补偿机制-鸿蒙开发者社区


因为[补偿]已经是一个额外的流程,既然能够走额外的流程,说明时效性并不是第一考虑的因素,所以做补偿的核心要点是:宁可慢,不可错。

因此,不要草率的就确定补偿的实施方案,需要谨慎的评估,虽然说错误没有办法100%避免,但是要抱着这样的少发生错误的心态。

二、补偿应该怎么做?

主流的补偿方式就是前文提到的两种:回滚(事务的补偿),和重试

回滚

回滚分为两种形式:

•显示回滚(逆向调用接口)

•隐式回滚(无需逆向调用接口)

最常见的显示回滚就是做两件事:

首先是确定失败的步骤和状态,从而确定回滚范围。一个业务流程,往往是在设计之初就制定好了,所以确定回滚的范围比较容易,但这唯一需要注意的一点是:

如果在一个业务处理中涉及到的服务并不是都提供了「回滚接口」,那么在编排服务时应该把提供「回滚接口」的服务放在前面,这样当后面的工作服务错误时还有机会「回滚」。

简单的来说就是让回滚接口有被调用到的机会。最优的选择就是放在第一个。

其次就是要能提供回滚操作使用到的业务数据。回滚时提供的数据越多,越有利于程序的

健壮性,因为程序可以在收到回滚操作的时候做业务检查,比如检查账户是否相等,金额是否一致。

在这个中间态的数据结构和数据大小并不确定。所以最好是将相关数据序列化成一个JSON,然后存到nosql里面。

[隐式回滚]相对来说使用的场景比较少。它意味着这个回滚的动作不需要我们额外的处理,下游服务内部有类似"预占"并且"超时失效"的机制。

例如:
在电商的场景里面。会将订单中的商品预占库存,等待用户在多少分钟内支付。如果没有支付,就释放库存。

重试

这个操作最大的好处就是不需要提供额外的接口[逆向接口]。这对于代码的维护和长期开发的成本有优势,而且业务是变化的。逆向接口也需要变化。所以更多时候可以考虑重试。

不过相对于回滚来说,重试使用的场景要少一些。

•下游系统返回请求超时,被限流中等临时状态的时候,我们就可以考虑重试了。

•而如果是返回余额不足,无权限的明确业务错误,就不需要重试。

•一些中间件或者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​


本文转载自公众号BiggerBoy

分类
标签
已于2022-10-31 16:26:36修改
收藏
回复
举报
回复
    相关推荐