常见限流算法介绍

172 阅读4分钟

背景

在互联网应用中,系统通常会面临一个重大挑战:大流量高并发访问。比如秒杀抢购等。短时间内巨大的访问流量下,如何让系统在处理高并发的同时还能保证自身系统的稳定?

机器不能无限制的加,在有限的资源下面对大流量访问,不得已采取其他措施来保护我们的后端系统,比如缓存,异步,降级,限流,静态化等。

限流

限流指的是:在高并发场景下对于请求进行限速来保护我们的系统,一旦达到限制的速度,我们可以

  1. 拒绝服务(提示友好信息或者跳转错误页面)
  2. 配对或者等待处理
  3. 降级(返回默认数据)

算法

  1. 计数器法

最简单粗暴的一种算法,比如说一个接口一分钟内的请求不能超过60次

  1. 划分时间窗口
  2. 窗口内请求,计数器加一
  3. 超过限制,本窗口内拒绝请求,等到下个窗口,计数器重置,再执行请求

应用场景:短信限流,比如一个用户一分钟内一条短信,可以使用计数器限流

问题:临界问题,两个时间窗口交界处,可能会执行任务,近似于通知执行两倍量的任务

  1. 漏桶算法

内部维护一个容器,以恒定的速度出水,不管上游的速度多快。请求到达时直接放入到漏桶中,如果容量达到上限(阈值),则进行丢弃(执行限流策略)。

漏桶算法无法短时间处理突发流量,是一种恒定的限流算法。

  1. 令牌算法(rateLimter)

令牌桶是网络流量整形(Traffic Shaping)和速度限制(Rate Limting)中最常使用的算法,对于每个请求,都需要从令牌桶中获得一个令牌,如果没有令牌则触发限流策略。

系统以一个恒定的速度往固定容量的容器放入令牌,如果有请求,则需要从令牌桶中取出令牌才能放行。

由于令牌桶有固定大小,当请求速度<生成令牌速度,令牌桶会被填满,因此令牌桶可以处理突发流量。

  1. 滑动窗口算法

在固定窗口划分多个小窗口,分别在小时间窗口里记录访问次数,随着时间推动窗口滑动并删除过期窗口,即去除之前的访问次数,最终统计所有小窗口的可访问总数。

demo:将1min分为4个小窗口,每个小窗口能处理25个请求。通过虚线表示窗口大小(当前窗口大小为2,所以能处理50个请求)。同时窗口可以随时间滑动,比如15s后,窗口滑动到(15s-45s)这个范围内,然后在新的窗口里进行统计

当滑动窗口的小时间窗口划分越多,那么滑动窗口的滑动就越平滑,限流的统计就更精确。

实现

分布式RateLimter

  1. QuickStart

    @Autowired

    private RedissonClient redissonClient;

    

    //声明限流器

    RRateLimiter myRateLimiter = redissonClient.getRateLimiter("myRateLimiter");

    //设置速率

    myRateLimiter.setRate(RateType.OVERALL,10,1, RateIntervalUnit.SECONDS);

    //尝试获得1个令牌,成功返回true

    myRateLimiter.tryAcquire(1);
  1. 流程

暂时无法在文档外展示此内容

  1. 源码分析

  2. 声明限速器


public class RedissonRateLimiter extends RedissonExpirable implements RRateLimiter {

    public RedissonRateLimiter(CommandAsyncExecutor commandExecutor, String name) {
        super(commandExecutor, name);
    }

}
  1. 设置速率

public class RedissonRateLimiter extends RedissonExpirable implements RRateLimiter {

    /**

    * Initializes RateLimiter's state and stores config to Redis server.

     * 初始化限流器状态,存储配置到redis服务器

    *

    *  @param mode - rate mode

    *  @param rate - rate 速率

    *  @param rateInterval - rate time interval 时间间隔

    *  @param rateIntervalUnit - rate time interval unit 时间单位

    *  @return 成功返回true  否则返回false

    *

    */

    @Override

    public boolean trySetRate(RateType type, long rate, long rateInterval, RateIntervalUnit unit) {

        return get(trySetRateAsync(type, rate, rateInterval, unit));

    }

    

    @Override

    public RFuture<Boolean> trySetRateAsync(RateType type, long rate, long rateInterval, RateIntervalUnit unit) {

        return commandExecutor.evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,

                "redis.call('hsetnx', KEYS[1], 'rate', ARGV[1]);"

              + "redis.call('hsetnx', KEYS[1], 'interval', ARGV[2]);"

              + "return redis.call('hsetnx', KEYS[1], 'type', ARGV[3]);",

                Collections.singletonList(getRawName()), rate, unit.toMillis(rateInterval), type.ordinal());

    }

}
  1. 获得令牌




 /**

 * 所有许可证都可用时,才获取给定数量的许可证

 */

@Override

public boolean tryAcquire(long permits) {

    return get(tryAcquireAsync(RedisCommands.EVAL_NULL_BOOLEAN, permits));

}



private <T> RFuture<T> tryAcquireAsync(RedisCommand<T> command, Long value) {

    return commandExecutor.evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,            

            //获得之前设置的限流请求数,时间窗口大小,类型等信息

            "local rate = redis.call('hget', KEYS[1], 'rate');"

          + "local interval = redis.call('hget', KEYS[1], 'interval');"

          + "local type = redis.call('hget', KEYS[1], 'type');"

          //断言判断属性是否为空,为空则抛出异常:限流器未初始化

          + "assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized')"

          //valueName对应的是当前阈值的key

          + "local valueName = KEYS[2];"

          //permitsName 是一个zset, 记录了请求的令牌数,score为请求的时间戳

          + "local permitsName = KEYS[4];"

          //如果是单机模式

          + "if type == '1' then "

              + "valueName = KEYS[3];"

              + "permitsName = KEYS[5];"

          + "end;"

          // 总数超出了阈值,报错

          + "assert(tonumber(rate) >= tonumber(ARGV[1]), 'Requested permits amount could not exceed defined rate'); "

          //当前的可用值

          + "local currentValue = redis.call('get', valueName); "

          // 第一次请求currentValue是null, 所以进入else, else中会给currentValue赋值

          + "if currentValue ~= false then "

                  //滑动时间窗口:zset移除(-00, now - interval] 范围中中所有元素,重置周期  ARGV[2] = System.currentTimeMillis() 

                 + "local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval); "

                 + "local released = 0; "

                 + "for i, v in ipairs(expiredValues) do "

                      // unpack 时获得过期请求的permits数

                      + "local random, permits = struct.unpack('fI', v);"

                      //累加到要刷新的令牌数

                      + "released = released + permits;"

                 + "end; "

                //released大于0,表示有可以恢复的令牌

                 + "if released > 0 then "

                     //滑动时间窗口:zset移除(-00, now - interval] 范围中中所有元素,重置周期  ARGV[2] = System.currentTimeMillis() 

                      + "redis.call('zremrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval); "

                     //增加当前令牌数

                      + "currentValue = tonumber(currentValue) + released; "

                      + "redis.call('set', valueName, currentValue);"

                 + "end;"

                // 当前令牌数<请求令牌数,满足不了

                 + "if tonumber(currentValue) < tonumber(ARGV[1]) then "

                     //找到最近那个请求

                     + "local nearest = redis.call('zrangebyscore', permitsName, '(' .. (tonumber(ARGV[2]) - interval), '+inf', 'withscores', 'limit', 0, 1); "

                     // 返回需要等待的时间,还需要多久才能有足够的令牌

                     + "return tonumber(nearest[2]) - (tonumber(ARGV[2]) - interval);"

                 + "else "

                     //当前令牌数够用,更新zset

                     + "redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1])); "

                     + "redis.call('decrby', valueName, ARGV[1]); "

                     + "return nil; "

                 + "end; "

          + "else "

                 //第一次请求

                 + "redis.call('set', valueName, rate); "

                 //pack时,将permits 请求数 也打包进去了

                 + "redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1])); "

                 + "redis.call('decrby', valueName, ARGV[1]); "

                 + "return nil; "

          + "end;",

            Arrays.asList(getRawName(), getValueName(), getClientValueName(), getPermitsName(), getClientPermitsName()),

            value, System.currentTimeMillis(), ThreadLocalRandom.current().nextLong());

}

使用了滑动窗口算法

参考

github.com/oneone1995/…