手撸RPC-----异常重试、熔断限流、流量隔离

本文介绍了yrpc框架中的异常重试机制,强调了服务业务逻辑需幂等性,以及如何通过注解和策略设计模式进行控制。此外,文章还讨论了熔断限流和流量隔离的重要性,包括服务端自我保护、调用端自我保护以及分组逻辑的应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、异常重试

1、为什么需要异常重试?

我们可以考虑这样一个场景。我们发起一次 rpc 调用,去调用远程的一个服务,比如用户的登录操作,我们会先对用户的用户名以及密码进行验证,验证成功之后会获取用户的基本信息。当我们通过远程的用户服务来获取用户基本信息的时候,恰好网络出现了问题,比如网络突然抖了一下,导致我们的请求失败了,而这个请求我们希望它能够尽可能地执行成功,那这时我们要怎么做呢?

我们需要重新发起一次 rpc 调用,那我们在代码中该如何处理呢?是在代码逻辑里 catch 一下,失败了就再发起一次调用吗?这样做显然不够优雅吧。这时我们就可以考虑使用 rpc 框架的重试机制。

2、yrpc 框架的重试机制

那什么是 yrpc 框架的重试机制呢?

这其实很好理解,就是当调用端发起的请求失败时,yrpc 框架自身可以进行重试,再重新发送请求,用户可以自行设置是否开启重试以及重试的次数

q:那如果这个时候发起了重试,业务逻辑是否会被执行呢?会的。

那如果这个服务业务逻辑不是幂等的,比如插入数据操作,那触发重试的话会不会引发问题呢?会的。

综上,我们可以总结出:在使用 yrpc 框架的时候,我们要确保被调用的服务的业务逻辑是幂等的,这样我们才能考虑根据事件情况开启 yrpc 框架的异常重试功能。这一点你要格外注意,这算是一个高频误区了

通过上述讲解,我相信你已经非常清楚 yrpc 框架的重试机制了,这也是现在大多数 yrpc 框架所采用的重试制。当然为了解决以上的问题我们也提供了以下方案:

1、手动指定可重试的接口,可以通过注解的形式进行标记,有特定注解的接口才能重试。

2、设置重试白名单。

q:如果因为机房的网络问题导致了大量的请求被重试,而且是同时进行,会不会产生问题?会的

为了避免因网络抖动导致的重试风暴,可以采用以下策略:

  1. 指数退避算法:在连续的重试中,每次重试之间的等待时间呈指数级增长。这样可以降低在短时间内发起大量重试请求的可能性,从而减轻对系统的压力。
  2. 随机抖动:在指数退避算法的基础上,引入随机抖动,使得重试之间的等待时间变得不那么规律。这样可以避免多个客户端在相同时间点发起重试请求,进一步减轻服务器压力。
  3. 限制重试次数和超时时间:限制单个请求的最大重试次数,以及整个重试过程的总超时时间,防止无限制地发起重试请求。
  4. 请求结果缓存:如果有些请求的结果可以缓存,可以考虑在客户端或服务器端缓存请求结果。当发生重试时,直接从缓存中获取结果,以减轻服务器压力。
  5. 服务熔断:在客户端或服务器端实现熔断机制,当连续失败达到一定阈值时,触发熔断,暂时阻止后续请求。熔断器在一段时间后会自动恢复,允许新的请求通过。

下面是一个使用指数退避和随机抖动的重试策略示例,实时上我们的工程并没有使用这个:

public class RetryPolicy {
    private int maxRetries; // 最大重试次数
    private int initialInterval; // 初始重试间隔
    private double backoffMultiplier; // 退避系数
    private double jitterFactor; // 抖动因子

    // 构造方法
    public RetryPolicy(int maxRetries, int initialInterval, double backoffMultiplier, double jitterFactor) {
        this.maxRetries = maxRetries;
        this.initialInterval = initialInterval;
        this.backoffMultiplier = backoffMultiplier;
        this.jitterFactor = jitterFactor;
    }

    // 获取最大重试次数
    public int getMaxRetries() {
        return maxRetries;
    }

    // 获取下一次重试的间隔时间
    public long getNextRetryInterval(int retryCount) {
        double backoff = initialInterval * Math.pow(backoffMultiplier, retryCount); // 计算指数退避的时间间隔
        double jitter = backoff * jitterFactor * (Math.random() * 2 - 1); // 计算抖动时间间隔
        return (long) (backoff + jitter); // 返回指数退避和抖动的总和
    }
}

我们不妨思考一下,能不能使用策略设计模式将重试机制进行抽象,提供不同的重试机制。

本项目中,我们偷了个懒,实现了最简单的重试。

定义重试接口:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TryTimes {
    
    int tryTimes() default 3;
    int intervalTime() default 2000;
    
}

重试的核心逻辑:

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

    // 从接口中获取判断是否需要重试
    TryTimes tryTimesAnnotation = method.getAnnotation(TryTimes.class);

    // 默认值0,代表不重试
    int tryTimes = 0;
    int intervalTime = 0;
    if (tryTimesAnnotation != null) {
        tryTimes = tryTimesAnnotation.tryTimes();
        intervalTime = tryTimesAnnotation.intervalTime();
    }


    while (true) {
        try {
            // 执行逻辑
            break;
        } catch (Exception e) {
            // 次数减一,并且等待固定时间,固定时间有一定的问题,重试风暴
            tryTimes--;
            try {
                Thread.sleep(intervalTime);
            } catch (InterruptedException ex) {
                log.error("在进行重试时发生异常.", ex);
            }
            if (tryTimes < 0) {
                log.error("对方法【{}】进行远程调用时,重试{}次,依然不可调用",
                          method.getName(), tryTimes, e);
                break;
            }
            log.error("在进行第{}次重试时发生异常.", 3 - tryTimes, e);
        }
    }
    throw new RuntimeException("执行远程方法" + method.getName() + "调用失败。");
}

二、熔断限流

1、服务端的自我保护

先看服务端,举个例子,假如要发布一个 rpc 服务,作为服务端接收调用端发送过来的请求,这时服务端的某个节点负载压力过高了,该如何保护这个节点?

这个问题还是很好解决的,既然负载压力高,那就不让它再接收太多的请求就好了,等接收和处理的请求数量下来后,这个节点的负载压力自然就下来了。

那么就是限流吧?是的,在 rpc 调用中服务端的自我保护策略就是限流,那你有没有想过我们是如何实现限流的呢?是在服务端的业务逻辑中做限流吗?有没有更优雅的方式?

限流是一个比较通用的功能,我们可以在 rpc 框架中集成限流的功能,让使用方自己去配置限流阈值;我们还可以在服务端添加限流逻辑,当调用端发送请求过来时,服务端在执行业务逻辑之前先执行限流逻辑,如果发现访问量过大并且超出了限流的阈值,就让服务端直接抛回给调用端一个限流异常,否则就执行正常的业务逻辑。

那服务端的限流逻辑又该如何实现呢?

方式有很多,比如最简单的计数器,还有可以做到平滑限流的滑动窗口、漏斗算法以及令牌桶算法等等。其中令牌桶算法最为常用。上述这几种限流算法我就不一一讲解了,资料很多,不太清楚的话自行查阅下就可以了。

我们在项目中也写了一个限流器,基于令牌桶算法:

public class TokenBuketRateLimiter implements RateLimiter {
    // 思考,令牌是个啥?令牌桶是个啥?
    // String,Object?  list? ,map?
    
    // 代表令牌的数量,>0 说明有令牌,能放行,放行就减一,==0,无令牌  阻拦
    private int tokens;
    
    // 限流的本质就是,令牌数
    private final int capacity;
    
    // 令牌桶的令牌,如果没了要怎么办? 按照一定的速率给令牌桶加令牌,如每秒加500个,不能超过总数
    // 可以用定时任务去加--> 启动一个定时任务,每秒执行一次 tokens+500 不能超过 capacity (不好)
    // 对于单机版的限流器可以有更简单的操作,每一个有请求要发送的时候给他加一下就好了
    private final int rate;
    
    // 上一次放令牌的时间
    private Long lastTokenTime;
    
    public TokenBuketRateLimiter(int capacity, int rate) {
        this.capacity = capacity;
        this.rate = rate;
        lastTokenTime = System.currentTimeMillis();
        tokens = capacity;
    }
    
    /**
     * 判断请求是否可以放行
     * @return true 放行  false  拦截
     */
    public synchronized boolean allowRequest() {
        // 1、给令牌桶添加令牌
        // 计算从现在到上一次的时间间隔需要添加的令牌数
        Long currentTime = System.currentTimeMillis();
        long timeInterval = currentTime - lastTokenTime;
        // 如果间隔时间超过一秒,放令牌
        if(timeInterval >= 1000/rate){
            int needAddTokens = (int)(timeInterval * rate / 1000);
            System.out.println("needAddTokens = " + needAddTokens);
            // 给令牌桶添加令牌
            tokens = Math.min(capacity, tokens + needAddTokens);
            System.out.println("tokens = " + tokens);
    
            // 标记最后一个放入令牌的时间
            this.lastTokenTime = System.currentTimeMillis();
        }
        
        // 2、自己获取令牌,如果令牌桶中有令牌则放行,否则拦截
        if(tokens > 0){
            tokens --;
            System.out.println("请求被放行---------------");
            return true;
        } else {
            System.out.println("请求被拦截---------------");
            return false;
        }

    }
}

2、调用端的自我保护

服务端如何进行自我保护,最简单有效的方式就是限流。那么调用端呢?调用端是否需要自我保护呢?

举个例子,假如我要发布一个服务 B,而服务 B 又依赖服务 C,当一个服务 A 来调用服务 B 时,服务 B 的业务逻辑调用服务 C,而这时服务 C 响应超时了,由于服务 B 依赖服务 C,C 超时直接导致 B 的业务逻辑一直等待,而这个时候服务 A 在频繁地调用服务 B,服务 B 就可能会因为堆积大量的请求而导致服务宕机。

由此可见,服务 B 调用服务 C,服务 C 执行业务逻辑出现异常时,会影响到服务 B,甚至可能会引起服务 B 宕机。这还只是 A->B->C 的情况,试想一下 A->B->C->D->……呢?在整个调用链中,只要中间有一个服务出现问题,都可能会引起上游的所有服务出现一系列的问题,甚至会引起整个调用链的服务都宕机,这是非常恐怖的。

所以说,在一个服务作为调用端调用另外一个服务时,为了防止被调用的服务出现问题而影响到作为调用端的这个服务,这个服务也需要进行自我保护。而最有效的自我保护方式就是熔断。

我们可以先了解下熔断机制。

熔断器的工作机制主要是关闭、打开和半打开这三个状态之间的切换。

  1. 在正常情况下,熔断器是关闭的。
  2. 当调用端调用下游服务出现异常时,熔断器会收集异常指标信息进行计算,当达到熔断条件时熔断器打开,这时调用端再发起请求是会直接被熔断器拦截,并快速地执行失败逻辑;
  3. 当熔断器打开一段时间后,会转为半打开状态,这时熔断器允许调用端发送一个请求给服务端,如果这次请求能够正常地得到服务端的响应,则将状态置为关闭状态,否则设置为打开。

了解完熔断机制,你就会发现,在业务逻辑中加入熔断器其实是不够优雅的。那么在 rpc 框架中,我们该如何整合熔断器呢?

熔断机制主要是保护调用端,调用端在发出请求的时候会先经过熔断器。

想到这里我们就自然而然想到在动态代理中加入熔断逻辑就再合理不过了

我的建议是动态代理,因为在 yrpc 调用的流程中,动态代理是 rpc 调用的第一个关口。在发出请求时先经过熔断器,如果状态是闭合则正常发出请求,如果状态是打开则执行熔断器的失败策略。

写的断路器代码如下,并没有添加半打开的状态,有兴趣的朋友可以自己实现一下看看:

public class CircuitBreaker {

    // 理论上:标准的断路器应该有三种状态  open close half_open,我们为了简单只选取两种
    private volatile boolean isOpen = false;

    // 需要搜集指标  异常的数量   比例
    // 总的请求数
    private AtomicInteger requestCount = new AtomicInteger(0);

    // 异常的请求数
    private AtomicInteger errorRequest = new AtomicInteger(0);

    // 异常的阈值
    private int maxErrorRequest;
    private float maxErrorRate;

    public CircuitBreaker(int maxErrorRequest, float maxErrorRate) {
        this.maxErrorRequest = maxErrorRequest;
        this.maxErrorRate = maxErrorRate;
    }


    // 断路器的核心方法,判断是否开启
    public boolean isBreak(){
        // 优先返回,如果已经打开了,就直接返回true
        if(isOpen){
            return true;
        }

        // 需要判断数据指标,是否满足当前的阈值
        if( errorRequest.get() > maxErrorRequest ){
            this.isOpen = true;
            return true;
        }

        if( errorRequest.get() > 0 && requestCount.get() > 0 &&
           errorRequest.get()/(float)requestCount.get() > maxErrorRate
          ) {
            this.isOpen = true;
            return true;
        }

        return false;
    }

    // 每次发生请求,获取发生异常应该进行记录
    public void recordRequest(){
        this.requestCount.getAndIncrement();
    }

    public void recordErrorRequest(){
        this.errorRequest.getAndIncrement();
    }

    /**
     * 重置熔断器
     */
    public void reset(){
        this.isOpen = false;
        this.requestCount.set(0);
        this.errorRequest.set(0);
    }

}

三、流量隔离

rpc 中常用的保护手段“熔断限流”,熔断是调用方为了避免在调用过程中,服务提供方出现问题的时候,自身资源被耗尽的一种保护行为;而限流则是服务提供方为防止自己被突发流量打垮的一种保护行为。虽然这两种手段作用的对象不同,但出发点都是为了实现自我保护,所以一旦发生这种行为,业务都是有损的。

那说起突发流量,限流固然是一种手段,但其实面对复杂的业务以及高并发场景时,我们还有别的手段,可以最大限度地保障业务无损,那就是流量隔离

1、为什么需要分组?

举一个例子,有一条街道,很宽但是街道上没有画任何的辅助线,人和车可以随便行驶,在人少车少的情况下我们必然可以畅通无阻,但是车辆一旦变多,就很难控制,有向前开的车,有向后开的车,有横穿马路的人......

所以马路是需要划分区域的,有向东的车道、向西的车道,在快速路和高速还要将道路隔离,还有非机动车道、人行道等等,立下这样的规矩后,有很多好处,高速上向东的车道堵了,不会影响向西行驶的车辆,人、非机动车、机动车的行驶互不影响。

同样的道理,用在rpc 治理上也是一样的。假设你是一个服务提供方应用的负责人,在早期业务量不大的情况下,应用之间的调用关系并不会复杂,请求量也不会很大,我们的应用有足够的能力扛住日常的所有流量。我们并不需要花太多的时间去治理调用请求过来的流量,我们通常会选择最简单的方法,就是把服务实例统一管理,把所有的请求都用一个共享的“大池子”来处理。这就类似于“简单道路时期”,服务调用方跟服务提供方之间的调用拓扑如下图所示:

后期业务发展了,调用你接口的调用方就会越来越多,流量也会渐渐多起来。可能某一天,一个“爆炸式惊喜”就来了。其中一个调用方的流量突然激增,让你整个集群瞬间处于高负载运行,进而影响到其它调用方,导致整体的业务可用性降低。

怎么样杜绝这样的事情发生呢?最好的办法就是隔离流量,将多个rpc服务进行分组,一个调用方只能访问一个分组的服务,就是一个调用方流量爆炸也只会影响一个分组的服务,整体还是可用的。

2、怎么实现分组?

那我们在yrpc项目中怎么实现分组呢?分组的逻辑的就是让调用方可以发现一个分组的服务,那实现的逻辑就一定是服务发现的时候只能拉取同一个分组的服务,我们需要在服务发现中做一些改造。

原本服务调用方是通过接口名去注册中心找到所有的服务节点来完成服务发现的,那换到这里的话,这样做其实并不合适,因为这样调用方会拿到所有的服务节点。因此为了实现分组隔离逻辑,我们需要重新改造下服务发现的逻辑,调用方去获取服务节点的时候除了要带着接口名,还需要另外加一个分组参数,相应的服务提供方在注册的时候也要带上分组参数。

通过改造后的分组逻辑,我们可以把服务提供方所有的实例分成若干组,每一个分组可以提供给单个或者多个不同的调用方来调用。那怎么分组好呢,有没有统一的标准?

坦白讲,这个分组并没有一个可衡量的标准,但可按照应用重要级别划分。

非核心应用不要跟核心应用分在同一个组,核心应用之间应该做好隔离,一个重要的原则就是保障核心应用不受影响。比如提供给电商下单过程中用的商品信息接口,我们肯定是需要独立出一个单独分组,避免受其它调用方污染的。有了分组之后,服务调用方跟服务提供方之间的调用拓扑就如下图所示:

通过分组的方式隔离调用方的流量,从而避免因为一个调用方出现流量激增而影响其它调用方的可用率。对服务提供方来说,这种方式是我们日常治理服务过程中一个高频使用的手段,那通过这种分组进行流量隔离,对调用方应用会不会有影响呢?

回到我们的项目中,我们的实现方案就是,给服务加上一个@RpcApi(group = "primary")通过group属性对服务进行编组:

以后在服务注册和发现时,将组名作为一个参数携带上即可。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值