【分布式】高可用:限流、降级、熔断

高可用手段

高可用系统,常用的保护手段有限流、降级和熔断。
限流 Rate Limit 是针对服务请求数量的一种自我保护机制,当请求数量超出服务的处理能力时,会自动丢弃新来的请求。限流是为了避免被大流量请求消耗掉系统资源,避免不可用。

在这里插入图片描述


熔断 Circuit 来源于电路保险丝的自我保护机制。在服务的依赖调用中,被调用方出现故障时,出于自我保护的目的,调用方会主动停止调用,并根据业务需要进行相应处理。调用方这种主动停止调用的行为称之为熔断。

在这里插入图片描述


降级 Degrade 是在不影响业务核心链路的情况下,屏蔽某些不重要的业务功能。这样可以节省系统的处理时间,提供系统的响应能力,在有限的服务器资源下处理更多的请求。降级就是为了解决资源不足和访问量增加的矛盾,保障核心功能的正常使用。
在这里插入图片描述

三者区别

  • 限流:只允许部分请求得到响应和服务,超过的部分将被拒绝服务、排队或等待、降级等处理。
  • 熔断:对向服务不能使用时开启自我保护停止调用,根据业务需要进行相应处理。
  • 降级:有限的服务资源下保留系统核心需求,关闭非核心功能,保障核心正常使用。

一个服务既可以充当 Server 也可以充当 Client,充当 Server 的时候发生对其他 Server 的调用时,对向服务异常时可以使用熔断;充当 Client 在接收其他 Server 的请求时,可以使用限流阻断部分请求。因为限流和熔断两者可能同时处于同一个服务中,所以这两个概念才容易被混淆。
现在的系统对限流的支持各有不同,但是存在一些标准。在 HTTP RFC 6585 标准中规定了,429 状态码表示用户在给定时间内发送了太多的请求,需要进行限流/速率限制,同时包含一个 Retry-After 响应头用于告诉客户端多长时间后可以再次请求服务。
降级因为需要在使用时根据场景预先决定或者紧急情况下开启。降级的实现一般有两种策略。一种是系统配置降级,为每一个可降级服务提供一个业务开关配置,在业务出现故障后通过切换业务开关配置进行手动降级。主要缺点是如果服务器数量多,需要一台一台去操作,效率比较低,这在故障处理争分夺秒的场景下是比较浪费时间的。另一种是独立降级系统,为了解决系统后门降级方式的缺点,将降级操作独立到一个单独的系统中,可以实现复杂的权限管理、批量操作等功能,但引入独立系统,运维集成等复杂度会相应提高,Hystrix、Sentinel 等都有相应功能实现。

限流器实现

常用的限流算法有五种,分别是:计数器限流、固定窗口限流、滑动窗口限流、令牌桶限流、漏桶限流。
在对限流算法进行实现之前,首先需要规定限流器的实现需要哪些接口。

public interface RateLimiter {

    /**
     * 实现一个限流器,每秒只能通过 max 个请求;
     * 如果超过,那么acquire返回false;需要考虑并发性能问题
     *
     * @return boolean
     */

    default boolean acquire() {
        return acquire(1);
    }

    /**
     * 申请资源
     *
     * @param count 计数
     * @return boolean 是否通过
     */

    boolean acquire(int count);

}

计数器

当前接口限定 1s 只可以提供 n 次服务。对于此需求,可以记录当前是第几秒,并且记录这一秒内请求了多少次。在指定周期内累加访问次数,当访问次数达到设定的阈值时,触发限流策略,当进入下一个时间周期时会将访问次数清零。

public class CountRateRateLimiter implements RateLimiter {

    /**
     * 一秒可以接受多少个请求
     */
    private final int numAcceptablePerSecond;

    /***
     * 版本号对应秒数
     * 里面的 AtomicInteger 记录这一秒范围内的请求数量
     */
    private final AtomicStampedReference<AtomicInteger> helper;

    public CountRateRateLimiter(int numAcceptablePerSecond) {
        this.numAcceptablePerSecond = numAcceptablePerSecond;
        this.helper = new AtomicStampedReference<>(new AtomicInteger(numAcceptablePerSecond), -1);
    }


    @Override
    public boolean acquire(int n) {
        if (n > numAcceptablePerSecond) {
            return false;
        }

        // 上一次请求是多少秒的请求
        int oldSeconds = helper.getStamp();
        // 当前多少秒
        int currentSeconds = currentSeconds();

        // 不是同一秒的请求
        // 如果和当前不是一个版本(意味着不是同一秒) 那么cas 修改版本并重置许可数量
        if (oldSeconds != currentSeconds) {
            // 原剩余的许可数量
            AtomicInteger oldPermits = helper.getReference();
            // cas 修改 同时修改版本,并且扣减数量;新许可的数量为 numAcceptablePerSecond - n,避免为负数
            if (helper.compareAndSet(oldPermits, new AtomicInteger(numAcceptablePerSecond - n), oldSeconds, currentSeconds)) {
                // cas 成功 那么说明成功 拿到令牌
                return true;
            }
        }

        // 到这里说明 是同一秒(oldSeconds == currentSeconds)
        // 或者上面的 if 存在多线程竞争当前线程竞争失败 其他线程重置了计数器 ==> 那么 cas 减少许可数量

        // 这里判断了一下 当前秒还是相等的,避免由于 gc 在第一个if中停留太久
        // 比如第一秒线程A和B进入到第一个if,线程B成功了,但是线程A失败了,并且暂停了2s,出来的时候时间已经是3s了,我们不能让1s的请求占用3s时候的令牌数
        return currentSeconds() == currentSeconds
                // 最后这里存在问题 如果在0.99999s的请求来到这里,但是时间来到1s,这个cas才成功,那么0.99999s的请求将打过来。导致1s的qps大于max
                && helper.getReference().addAndGet(-n) >= 0;
    }

    private static int currentSeconds() {
        return (int) ((System.currentTimeMillis() / 1000) % Integer.MAX_VALUE);
    }

}

需要注意的是,限流器的使用应该创建出对应速率的容器,然后一直沿用该限流器。

在这里插入图片描述

固定窗口

固定窗口限流算法(Fixed Window Rate Limiting Algorithm)是一种最简单的限流算法,其原理是在固定时间窗口(单位时间)内限制请求的数量。该算法将时间分成固定的窗口,并在每个窗口内限制请求的数量。具体来说,算法将请求按照时间顺序放入时间窗口中,并计算该时间窗口内的请求数量,如果请求数量超出了限制,则拒绝该请求。
在这里插入图片描述

固定窗口算法非常简单,易于实现和理解,但是存在明显的临界问题
在这里插入图片描述

public class FixedWindowRateLimiter implements RateLimiter {

    public static int counter = 0;
    public static long lastAcquireTime = 0L;

    /**
     * 窗口时间长度,单位 ms
     */
    private final long windowUnit;
    /**
     * 可以接受的请求次数
     */
    private final long canAcceptRequestTimes;

    public FixedWindowRateLimiter(long windowUnit, long canAcceptRequestTimes) {
        this.windowUnit = windowUnit;
        this.canAcceptRequestTimes = canAcceptRequestTimes;
    }

    @Override
    public boolean acquire(int count) {
        long currentTimeMillis = System.currentTimeMillis();
        if (currentTimeMillis - lastAcquireTime > windowUnit) {
            counter = 0;
            lastAcquireTime = currentTimeMillis;
        }

        if (counter < canAcceptRequestTimes) {
            counter++;
            return true;
        }

        return false;
    }
}

固定窗口的测试结果如下:
在这里插入图片描述

滑动窗口

滑动窗口限流算法是一种常用的限流算法,用于控制系统对外提供服务的速率,防止系统被过多的请求压垮。它将单位时间周期分为 n 个小周期,分别记录每个小周期内接口的访问次数,并且根据时间滑动删除过期的小周期。它可以缓解固定窗口临界值的问题
滑动窗口分的细了,就要统计每个窗口中的请求数。但是统计总的请求数,需要将每个窗口中当前版本的请求数做累加,这个操作是要耗时的。要尽可能达到瞬时一致性,否则可能限流算法还没走完,时间都下一个单元了。这样会造成限流的不准确。
在这里插入图片描述

滑动窗口的限流实现如下:

/**
 * 滑动窗口限流器
 * <p>
 * 假设指定窗口总时长 为 1s 可以接受 10个请求,窗口分为5格
 * 说明单格时间长度为200毫秒
 * |_____|_____|_____|_____|_____|
 * 0    200   400  600    800   1000
 * <p>
 * 当前时间为 500毫秒 那么落在 (500/200)%5 也就是第二格
 * 那么500 毫秒是否可以接受请求 需要统计所有格子中的数量
 * <p>
 * 当时间来到 1500 毫秒,落在 (1500/200)%5 也是第二格
 * |_____|_____|_____|_____|_____|_____|_____|_____|
 * 0    200   400  600    800  1000   1200  1400  1600
 * 从500到1500才是我们需要记录的,窗口数组大小是不变的
 * <p>
 * 500的窗口版本是 500/1000 = 0
 * 1500的窗口版本是 1500/1000 = 1
 * <p>
 * 根据窗口版本来统计 哪些格子我们是要统计的,如果旧窗口版本小于当前窗口版本 不要计数
 * (这里的版本 可以理解为没过 1000秒 版本加1,版本不同意味着是一秒前的数据)
 *
 * @author Real
 * @date 2023/08/16 23:25
 */
public class SlidingWindowRateLimiter implements RateLimiter {

    /**
     * 滑动窗口元素
     *
     * @author Real
     * @date 2023/08/16 23:26
     */
    private static class WindowElement {
        /***
         * 版本
         */
        private volatile long version;
        /**
         * 计数
         */
        private final AtomicInteger counter;

        private WindowElement(long version, AtomicInteger counter) {
            this.version = version;
            this.counter = counter;
        }

        private void changeVersion(long newVersion) {
            this.version = newVersion;
        }

        private void reset(int n) {
            counter.set(n);
        }

        void add(int n) {
            counter.addAndGet(n);
        }
    }

    /**
     * 整个窗口的大小,比如一秒 只能接受100个请求 那么此值设置为1000(毫秒)
     */
    private final long windowTimeMillions = 1000;
    /***
     * 窗口的长度,窗口的长度,窗口越长,越能防止临界问题
     */
    private final int windowLength;
    /***
     * 窗口数组
     */
    private final AtomicReferenceArray<WindowElement> slidWindow;
    /***
     * 一秒接受 100个请求 那么此值设置为 100
     */
    private final int canAcceptRequestTimes;
    /**
     * 记录 窗口每一个元素  对应的时间跨度
     * 1秒接受100个请求 那么此处为 1000(毫秒)/100 = 10毫秒
     */
    private final int millionsEachOne;

    /**
     * @param windowLength          指定窗口数量
     * @param canAcceptRequestTimes 在 1s 内可以接受多少个请求
     */
    public SlidingWindowRateLimiter(int windowLength,
                                    int canAcceptRequestTimes) {
        this.windowLength = windowLength;
        this.canAcceptRequestTimes = canAcceptRequestTimes;
        slidWindow = new AtomicReferenceArray<>(new WindowElement[windowLength]);
        millionsEachOne = (int) (windowTimeMillions / windowLength);
    }

    @Override
    public boolean acquire(int n) {
        // 1s分为5格 那么 一格200ms
        // 当前时间为 500毫秒 那么落在 (500/200)%5 也就是第二格
        long currentTimeMillis = System.currentTimeMillis();
        // 这次请求 落在 哪个桶
        int index = (int) ((currentTimeMillis / millionsEachOne) % windowLength);
        // 当前这次请求的 version 即当前是多少秒
        long version = currentTimeMillis - currentTimeMillis % windowTimeMillions;
        // 1. 拿到当前当前的计数
        // 1.1 如果计数为空 说明从来没有其他请求设置元素,这时,我们需要cas初始化结束计数
        // 1.2 如果计数不为空
        // 1.2.1 是相同的版本 那么自增计数
        // 1.2.3 如果不是相同的版本(之前版本小于当前版本),那么更新版本
        // 1.2.4 如果不是相同的版本(之前版本大于当前版本),基本上不可能,因为时间是不会倒流的

        //操作这次请求落下的桶
        WindowElement currentIndex = slidWindow.accumulateAndGet(index,
                new WindowElement(version, new AtomicInteger(n)), (old, now) -> {
                    // 计数为空 说明从来没有其他请求设置元素,这时,我们需要cas初始化结束计数
                    if (old == null) {
                        return now;
                    }

                    // 当前请求的次数
                    int currentRequest = now.counter.get();

                    // 是同一秒 那么自增
                    if (old.version == now.version) {
                        old.add(currentRequest);
                    } else {
                        // 如果不是相同的版本(之前版本小于当前版本),那么更新版本 更新计数
                        old.reset(currentRequest);
                        old.changeVersion(now.version);
                    }
                    return old;
                });

        // 大于最大数量返回false 这一瞬间对应的元素 就已经超出了我们的预期 那么返回 false
        if (currentIndex.counter.get() > canAcceptRequestTimes) {
            return false;
        }

        // 统计窗口内所有请求数
        long sum = 0;
        // 下面这一段 不具备瞬时一致性;需要考虑计数耗费的时间,理想情况下需要达到 O(1)
        for (int i = 0; i < windowLength; i++) {
            WindowElement e = slidWindow.get(i);
            // 要注意版本的一致性,版本不一致时,表示不是在一个时间周期内,为历史数据
            if (e != null && e.version == version) {
                sum += e.counter.get();
                if (sum > canAcceptRequestTimes) {
                    return false;
                }
            }
        }
        // 小于等于才通过
        return sum <= canAcceptRequestTimes;
    }

}

最后在统计总窗口耗时的时候,因为版本的不确定性,所以需要统计相同版本下的所有窗口的流量数。这里要注意瞬时一致性,也就是限流的效果。
格子周期划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确
在这里插入图片描述

漏桶

漏桶限流算法(Leaky Bucket Algorithm)是一种流量控制算法,用于控制流入网络的数据速率,以防止网络拥塞。它的思想是将数据包看作是水滴,漏桶看作是一个固定容量的水桶,数据包像水滴一样从桶的顶部流入桶中,并通过桶底的一个小孔以一定的速度流出,从而限制了数据包的流量。
漏桶限流算法的基本工作原理是:对于每个到来的数据包,都将其加入到漏桶中,并检查漏桶中当前的水量是否超过了漏桶的容量。如果超过了容量,就将多余的数据包丢弃。如果漏桶中还有水,就以一定的速率从桶底输出数据包,保证输出的速率不超过预设的速率,从而达到限流的目的。
在这里插入图片描述

漏桶的实现上,可以简化流出的水流速率,而是每次请求的时候,再去判断和设置当前桶内的容量。

public class LeakBucketRateLimiter implements RateLimiter {

    // 桶的大小
    private static final long CAPACITY = 10;
    // 流出速率,每秒两个
    private static final long RATE = 2;
    //开始时间
    private static long startTime = System.currentTimeMillis();
    //桶中剩余的水
    private static final AtomicLong WATER = new AtomicLong();

    @Override
    public synchronized boolean acquire(int count) {
        // 如果桶里是空的,表示第一次使用,判断是否能直接通过
        if (WATER.get() == 0 && count <= CAPACITY) {
            startTime = System.currentTimeMillis();
            WATER.set(count);
            return true;
        }

        long currentTimeMillis = System.currentTimeMillis();
        // 判断过往时间内流出的水量,得出现在桶内的剩余量
        long currentBucketSize = WATER.get() - (currentTimeMillis - startTime) / 1000 * RATE;
        // 防止出现 <0 的情况
        WATER.set(Math.max(0, currentBucketSize));

        // 设置新的开始时间
        startTime += (currentTimeMillis - startTime) / 1000 * 1000;

        // 如果申请的数量 + 现有的数量 < 容量,直接放行
        if (WATER.get() + count <= CAPACITY) {
            WATER.getAndAdd(count);
            return true;
        }

        return false;
    }
}

在这里插入图片描述

令牌桶

令牌桶算法是一种常用的限流算法,可以用于限制单位时间内请求的数量。该算法维护一个固定容量的令牌桶,每秒钟会向令牌桶中放入一定数量的令牌。当有请求到来时,如果令牌桶中有足够的令牌,则请求被允许通过并从令牌桶中消耗一个令牌,否则请求被拒绝。
优点:

  • 稳定性高:令牌桶算法可以控制请求的处理速度,可以使系统的负载变得稳定。
  • 精度高:令牌桶算法可以根据实际情况动态调整生成令牌的速率,可以实现较高精度的限流。
  • 弹性好:令牌桶算法可以处理突发流量,可以在短时间内提供更多的处理能力,以处理突发流量。

Guava 的 RateLimiter 限流组件,就是基于令牌桶算法实现的。
缺点:

  • 实现复杂:相对于固定窗口算法等其他限流算法,令牌桶算法的实现较为复杂。对短时请求难以处理:在短时间内有大量请求到来时,可能会导致令牌桶中的令牌被快速消耗完,从而限流。这种情况下,可以考虑使用漏桶算法。
  • 时间精度要求高:令牌桶算法需要在固定的时间间隔内生成令牌,因此要求时间精度较高,如果系统时间不准确,可能会导致限流效果不理想。

令牌桶算法具有较高的稳定性和精度,但实现相对复杂,适用于对稳定性和精度要求较高的场景。
在这里插入图片描述

令牌桶的实现,在思路上漏桶差不多,都需要注意用 synchronized 修饰。在进入到方法的时候,判断这段时间内生成的 Token 数,无论后面是否成功,都应该在进入方法的时候首先更新桶内的 Token 数,这样才能尽可能保证准确性。

public class TokenBucketRateLimiter implements RateLimiter {

    /**
     * 当前令牌数
     */
    private int tokens;
    /**
     * 令牌桶容量
     */
    private final int capacity;
    /**
     * 令牌生成速率,单位:令牌/秒
     */
    private final int rate;
    /**
     * 上次令牌生成时间戳
     */
    private long lastRefillTimestamp;

    public TokenBucketRateLimiter(int capacity, int rate) {
        this.capacity = capacity;
        this.rate = rate;
        this.tokens = capacity;
        this.lastRefillTimestamp = System.currentTimeMillis();
    }

    @Override
    public synchronized boolean acquire(int count) {
        // 每次进入同统计一下这段时间内生成的令牌数量
        long now = System.currentTimeMillis();
        if (now > lastRefillTimestamp) {
            int generatedTokens = (int) ((now - lastRefillTimestamp) / 1000 * rate);
            tokens = Math.min(tokens + generatedTokens, capacity);
            lastRefillTimestamp = now;
        }

        // 判断令牌数是否满足要求
        if (tokens - count >= 0) {
            tokens -= count;
            return true;
        }
        return false;
    }
}

在这里插入图片描述

其他

  1. 之前说的滑动和固定窗口解决边界的问题,其实思维上已经站在不同的纬度。对滑动进行了多个拆分,而固定又是一整个范围,不公平,当固定也拆分到一样的小窗口,其实和滑动一样,两个其实真正意义上都没有解决临界值问题,但是固定窗口实现上简单的更多。
  2. 当拆分的窗口太小太细时间超过了统计的时间,其实也就是因为拆分的不合理,需要扩大,不然这个时候的统计是没有作用的。
  3. 限流在分布式中,有业务统一网关的情况下可以在业务网关做整体统计;没有业务网关的情况就每台机器自己统计自己的。假设需要支持1000TPS,那么 10 台标准机器时,每台统计 100TPS 就行。

前面的限流方式都是针对单体架构,无法进行跨 JVM 限流。在分布式、微服务架构下,需要利用一些其他的限流方式。

中间件限流

可以借助一些中间件进行限流。Sentinel 是 Spring Cloud Alibaba 中常用的熔断限流组件,为我们提供了开箱即用的限流方法。
使用起来也非常简单,在service层的方法上添加 @SentinelResource 注解,通过 value 指定资源名称,blockHandler 指定一个方法,该方法会在原方法被限流、降级、系统保护时被调用。

@Service
public class QueryService {
    public static final String KEY = "query";

    @SentinelResource(value = KEY, blockHandler = "blockHandlerMethod")
    public String query(String name) {
        return "begin query, name = " + name;
    }

    public String blockHandlerMethod(String name, BlockException e) {
        e.printStackTrace();
        return "blockHandlerMethod for Query : " + name;
    }
    
}

网关限流

网关限流也是目前比较流行的一种方式,通常使用 Spring Cloud 的网关 Gateway 组件进行限流。
在项目中引入依赖,Gateway 的限流实际使用的是 Redis + Lua 脚本的方式实现的令牌桶,因此还需要引入 Redis 的相关依赖。

spring:
  application:
    name: gateway-test
  cloud:
    gateway:
      routes:
        - id: limit_route
          uri: lb://sentinel-test
          predicates:
          - Path=/sentinel-test/**
          filters:
            - name: RequestRateLimiter
              args:
                # 令牌桶每秒填充平均速率
                redis-rate-limiter.replenishRate: 1
                # 令牌桶上限
                redis-rate-limiter.burstCapacity: 2
                # 指定解析器,使用spEl表达式按beanName从spring容器中获取
                key-resolver: "#{@pathKeyResolver}"
            - StripPrefix=1
  redis:
    host: 127.0.0.1
    port: 6379

使用请求的路径作为限流的键,编写对应的解析器。

@Slf4j
@Component
public class PathKeyResolver implements KeyResolver {
    public Mono<String> resolve(ServerWebExchange exchange) {
        String path = exchange.getRequest().getPath().toString();
        log.info("Request path: {}", path);
        return Mono.just(path);
    }
}

在被限流的情况下,Http 请求会返回 429 状态码。
除了上面的根据请求路径限流外,还可以灵活设置各种限流的维度,例如根据请求 header 中携带的用户信息、或是携带的参数等等。如果不想用 Gateway 自带的这个 Redis 的限流器的话,我们也可以自己实现 RateLimiter 接口来实现一个自己的限流工具。
Gateway 实现限流的关键是 spring-cloud-gateway-core 包中的 RedisRateLimiter 类,以及 META-INF/scripts 中的 request-rate-limiter.lua 这个脚本,这就是 Gateway 里面对于限流的具体实现。

参考

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值