谈谈限流算法,以及Redisson实现

1. 限流的意义

今天谈谈限流。很早之前,在接触像 hystrix、resilience4j、sentinel 这类的熔断器组件时,就了解过其关于限流的功能。在实际开发应用中,超时、错误熔断用的挺多,但限流熔断用的到不多。

究其原因,在公司内部微服务调用时,就算服务调用的上下游服务,不是同一个项目团队的服务,但至少是同一个公司的研发团队。当防止下游方被频繁调用,完全可以和上游方约定好协同方案,而不是通过限流的策略给上游方抛错。

但如果上下游方比较独立,则有必要通过限流来进行约束,和自我保护。

例如:我们产品对外提供的服务端开放API,如果不在文档中约定好调用频率限制,并做好自我保护,很容易就被人恶意攻击。

再例如:我们在对接钉钉、微信等生态服务时,也需要调用它们在开放平台的API,同样也有限流要求。可参考 钉钉服务端API限流文档。因为一旦调用钉钉API频率超限,会触发至少5分钟的限流熔断,这5分钟内任何API调用都会报错。所以我们作为服务调用方,更要将调用服务的请求限流。

2. 限流算法

在网上看了些限流算法,主要有4种。

2.1. 固定窗口算法

首先维护一个计数器,将单位时间段当做一个窗口,计数器记录这个窗口接收请求的次数。

  • 当次数少于限流阀值,就允许访问,并且计数器+1
  • 当次数大于限流阀值,就拒绝访问。
  • 当前的时间窗口过去之后,计数器清零。

假设单位时间是1秒,限流阀值为3。在单位时间1秒内,每来一个请求,计数器就加1,如果计数器累加的次数超过限流阀值3,后续的请求全部拒绝。等到1s结束后,计数器清0,重新开始计数。

问题 1:窗口临界值,导致双倍阈值

假设限流阀值为5个请求,单位时间窗口是1s,如果我们在单位时间内的前0.8-1s和1-1.2s,分别并发5个请求。虽然都没有超过阀值,但是如果算0.8-1.2s,则并发数高达10,已经超过单位时间1s不超过5阀值的定义啦,通过的请求达到了阈值的两倍。

为了解决问题2中窗口临界值的问题,引入了滑动窗口限流。滑动窗口限流解决固定窗口临界值的问题,可以保证在任意时间窗口内都不会超过阈值。

问题 2(两面性):集中流量,打满阈值,后续服务不可用

比如窗口大小为1s,限流大小为100,然后恰好在某个窗口的第1ms来了100个请求,然后第2ms-999ms的请求就都会被拒绝,这段时间用户会感觉系统服务不可用。

但这是两面性的问题,有缺点,也有优点,后面会说。

2.2. 滑动窗口算法

相对于固定窗口,滑动窗口除了需要引入计数器之外,还需要记录时间窗口内每个请求到达的时间点,因此对内存的占用会比较多。

规则如下,假设时间窗口为 1 秒:

  • 记录每次请求的时间。
  • 统计每次请求的时间 至 往前推1秒这个时间窗口内请求数,并且 1 秒前的数据可以删除。
  • 统计的请求数小于阈值就记录这个请求的时间,并允许通过,反之拒绝。

滑动窗口算法就是固定窗口的升级版。将计时窗口划分成一个小窗口,滑动窗口算法就退化成了固定窗口算法。而滑动窗口算法其实就是对请求数进行了更细粒度的限流,窗口划分的越多,则限流越精准。

但是滑动窗口和固定窗口都无法解决短时间之内集中流量的突击,就和前面介绍的一样。

接下来再说说漏桶,它可以解决时间窗口类的痛点,使得流量更加的平滑。

2.3. 漏桶算法

漏桶算法面对限流,就更加的柔性,不存在直接的粗暴拒绝。

它的原理很简单,可以认为就是注水漏水的过程。往漏桶中以任意速率流入水,以固定的速率流出水。当水超过桶的容量时,会被溢出,也就是被丢弃。因为桶容量是不变的,保证了整体的速率。

  • 流入的水滴,可以看作是访问系统的请求,这个流入速率是不确定的。
  • 桶的容量一般表示系统所能处理的请求数。
  • 如果桶的容量满了,就达到限流的阀值,就会丢弃水滴(拒绝请求)
  • 流出的水滴,是恒定过滤的,对应服务按照固定的速率处理请求。

看到这想到啥,是不是 消息队列思想有点像,削峰填谷。经过漏洞这么一过滤,请求就能平滑的流出,看起来很像很挺完美的?实际上它的优点也即缺点。

问题 3: 无法应对流量突发

面对突发请求,服务的处理速度和平时是一样的,这其实不是我们想要的,在面对突发流量我们希望在系统平稳的同时,提升用户体验即能更快的处理请求,而不是和正常流量一样,循规蹈矩的处理(看看,之前滑动窗口说流量不够平滑,现在太平滑了又不行,难搞啊)。

而接下来我们要谈的令牌桶算法能够在一定程度上解决流量突发的问题。

2.4. 令牌桶算法

令牌桶算法是对漏斗算法的一种改进,除了能够起到限流的作用外,还允许一定程度的流量突发。

令牌桶算法原理:

  • 有一个令牌管理员,根据限流大小,定速往令牌桶里放令牌。
  • 如果令牌数量满了,超过令牌桶容量的限制,那就丢弃。
  • 系统在接受到一个用户请求时,都会先去令牌桶要一个令牌。如果拿到令牌,那么就处理这个请求的业务逻辑;
  • 如果拿不到令牌,就直接拒绝这个请求。

可以看出令牌桶在应对突发流量的时候,桶内假如有 100 个令牌,那么这 100 个令牌可以马上被取走,而不像漏桶那样匀速的消费。所以在应对突发流量的时候令牌桶表现的更佳。

2.5. 个人理解

按照我的理解,对这4种算法做下面的分类比较。

2.5.1. 滑动窗口算法 > 固定窗口算法

固定窗口算法实现简单,性能高。但是会有临界突发流量问题,瞬时流量最大可以达到阈值的2倍。

为了解决临界突发流量,可以将窗口划分为多个更细粒度的单元,每次窗口向右移动一个单元,于是便有了滑动窗口算法。

从算法效果上来讲

滑动窗口算法要优于固定窗口算法,毕竟能避免窗口临界值问题。

从实施性能上来讲

固定窗口算法实现起来要更简单,对性能资源要求更低。滑动窗口只需要引入计数器,但滑动窗口还需要记录时间窗口内每个请求到达的时间点,因此对内存的占用会比较多。

总结:滑动窗口算法优先

不过限流算法就是为了保护线上服务器资源,避免被流量击溃。与这代价相比,滑动窗口算法的那些性能资源消耗算得了什么。所以目前市场上,几乎看不到以固定窗口算法实现的限流组件。

2.5.2. 漏桶算法(MQ消息队列)

想要达到限流的目的,又不会掐断流量,使得流量更加平滑?可以考虑漏桶算法。

我为啥在漏桶算法这节加上 MQ 消费队列呢?因为在我的理解中,这种限流算法,就是 MQ 消费队列的应用方法。无论生产消息的频率如何,MQ的消费者的消费频率上限是固定的。

有差别吗?有。漏桶算法中定义的是“桶容量固定。当水超过桶的容量时,会被溢出丢弃”。而 MQ 的常规用法是“削峰填谷”,消息可以在队列中积压,然后满满消费,但不会轻易丢弃。其实这也符合通常的实际应用场景。真要实现漏桶算法的要求也行,完全给队列设置为固定长度。

总结,如果要用漏桶算法限流,用 MQ 消息队列就是了。

2.5.3. 令牌桶算法、滑动窗口算法 相似

1. 相似

在我看来,这两种算法很相似。

滑动窗口算法,是在使用时,按速率(窗口单位时间内的最大通过数量)计算计数器,没超过就放行。

令牌桶算法,是按照速率往固定容量桶内投放令牌,在使用时,只要桶内还有令牌就可以放行。

从这个角度来看,令牌桶算法是将统计令牌数(计数器),和判断是否可以放行,这两个环节 “解耦” 了。

2. 共同的优点
  • 都没有窗口临界值问题。
  • (两面性问题)都能应对流量突发。像滑动窗口算法,突发流量进来时,窗口时间内不超过计数器阈值即可。
3. 共同的缺点
  • (两面性问题)突发流量会占据大量令牌(计数器计数),导致后续流量进入受限。

2.5.4. 按照需求选型

在我看来,限流算法选型有两种:

  • 滑动窗口算法、令牌桶算法,二者属于同一类
  • 漏桶算法

而二者的区别就在于前面一直提到的两面性问题。

两面性问题:突发流量

限流算法中,对于应对突发流量,在我看来是个两面性问题。

  • 优点:针对突发场景也有有效响应。
  • 缺点:当突发流量进来后,必然会对后续进来的其他流量造成影响,流量不够平滑。

并没有一种最好的限流算法,到底选择哪种限流算法,还是要看实际需求场景,结合已有的资源,综合考虑。

3. 限流组件探索

3.1. Ratelimiter

在做对调用钉钉API限流时,有看到钉钉文档上推荐的限流方式,就是 Guava 的 RateLimiter。

RateLimiter 是基于令牌桶算法限流的。但 RateLimiter 对于持续生成令牌,采用的不是定时任务的方式(过于耗费资源,不适合高并发),而是使用延迟计算的方式。即在获取令牌时计算上一次时间 nextFreeTicketMicros 和当前时间之间的差值,计算这段时间之内按照用户设定的速率可以生产多少令牌。

void resync(long nowMicros) {
    // if nextFreeTicket is in the past, resync to now
    if (nowMicros > nextFreeTicketMicros) {
      double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
      storedPermits = min(maxPermits, storedPermits + newPermits);
      nextFreeTicketMicros = nowMicros;
    }
}

针对令牌桶算法的这种实现方式比较常见,后面在 Redisson 中也会见到。

我不理解为啥钉钉文档里面只官方推荐 RateLimiter,其实它是无法满足需求的。因为它是基于 Java 线程实现的,是基于单机的限流。我们的服务基本都是多节点服务器的,明显无法实现总体限流。因此需要找一个能失效分布式限流的组件。

3.2. sentinel

再接着,我就想到了阿里自己的限流组件 sentinel,我们部门也有现成的 sentinel 服务可以用。

我记得在 sentinel dashboard 上配置限流规则时,可以基于集群配置,于是我就动手试了一下。发现的确可以基于多个机器节点创建集群,然后基于集群来创建配置限流规则,以实现在整个集群的维度实现限流。

但问题来了,dashboard 上集群是需要手动选择机器(ip+port)创建的。我们单个微服务的服务器节点ip、端口非固定的,而且支持弹性伸缩。一旦服务器发生变化,如何自动同步到 sentinel 集群信息上,就又是一个需要攻克的问题。

限流的算法不复杂,要不干脆自己写一个吧。基于 redis 存储,这样就能满足分布式限流。

3.3. Redisson

可当我开始基于 redis 自己写限流方法时,无意中发现 Redisson 自己就提供了封装好的限流方法 RRateLimiter

之前一直在用 Redisson 封装的分布式锁方法,都忘了看看其他的功能了。下面就详细介绍自己的使用,以及源码逻辑。

4. Redisson限流算法

4.1. 示例

1. pom.xml
引入 redisson 的依赖:
        
            org.redisson
            redisson-spring-boot-starter
            3.15.5
        
2. application

配置文件中申明一些简单的 redisson 的连接信息。我这边是本地起了一个 redis 库,没设置密码。

spring:
  redis:
    redisson:
      config:
        singleServerConfig:
          address: redis://127.0.0.1:6379
          database: 0
3. controller
@RestController
@RequestMapping("")
@Slf4j
public class DemoController {
    private final RedissonClient redissonClient;

    public DemoController(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }


    @GetMapping("require")
    public void hello(Integer num) {
        RRateLimiter rateLimiter = redissonClient.getRateLimiter("LIMITER_NAME");
        rateLimiter.trySetRate(RateType.OVERALL, 5, 10, RateIntervalUnit.SECONDS);
        rateLimiter.tryAcquire(num,1,TimeUnit.MINUTES);
        log.info("get!");
    }
}

这边写了一个demo示例,定义了一个叫 "LIMITER_NAME" 的限流器,设置每10秒钟生成5个令牌。然后根据接口传入参数 num,看看请求多少个令牌。当请求不到时阻塞,最大阻塞时间为1分钟。

4.2. redis 数据结构

RRateLimiter 接口的实现类几乎都在 RedissonRateLimiter 上,我们看看前面调用 RRateLimier 方法时,这些方法的对应源码实现。

1. setRate

对应实现类中的源码是:

    public RFuture trySetRateAsync(RateType type, long rate, long rateInterval, RateIntervalUnit unit) {
        return this.commandExecutor.evalWriteAsync(this.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(this.getRawName()), new Object[]{rate, unit.toMillis(rateInterval), type.ordinal()});
    }

核心是其中的 lua 脚本,摘出来看看:

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]);

发现基于一个 hash 类型的redis key 设置了3个值。下面就着重讲讲这里限流算法中,一共用到的 3个 redis key。

2. key 1:Hash 结构

就是前面 setRate 设置的 hash key。按照之前限流器命名“LIMITER_NAME”,这个 redis key 的名字就是 LIMITER_NAME。一共有3个值:

  1. rate:代表速率
  2. interval:代表多少时间内产生的令牌
  3. type:代表单机还是集群
3. key 2:ZSET 结构

ZSET 记录获取令牌的时间戳,用于时间对比,redis key 的名字是 {LIMITER_NAME}:permits。下面讲讲 ZSET 中每个元素的 member 和 score:

  • member: 包含两个内容:(1)一段8位随机字符串,为了唯一标志性当次获取令牌;(2)数字,即当次获取令牌的数量。不过这些是压缩后存储在 redis 中的,在工具上看时会发现乱码。
  • score:记录获取令牌的时间戳,如:1667025166312(对应 2022-10-29 14:32:46)
4. key 3: string 结构

记录的是当前令牌桶中剩余的令牌数。redis key 的名字是 {LIMITER_NAME}:value

4.3. 算法源码分析

前面是铺垫,下面就着重讲讲获取令牌的源码吧。

对应方法是:

    private  RFuture tryAcquireAsync(RedisCommand command, Long value) {
        return this.commandExecutor.evalWriteAsync(this.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')local valueName = KEYS[2];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); if currentValue ~= false then local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval); local released = 0; for i, v in ipairs(expiredValues) do local random, permits = struct.unpack('fI', v);released = released + permits;end; if released > 0 then 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 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); redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1])); redis.call('decrby', valueName, ARGV[1]); return nil; end;", Arrays.asList(this.getRawName(), this.getValueName(), this.getClientValueName(), this.getPermitsName(), this.getClientPermitsName()), new Object[]{value, System.currentTimeMillis(), ThreadLocalRandom.current().nextLong()});
    }

我们先看看执行 lua 脚本时,所有要传入的参数内容:

  • KEYS[1]:hash key name
  • KEYS[2]:全局 string(value) key name
  • KEYS[3]:单机 string(value) key name
  • KEYS[4]:全局 zset(permits) key name
  • KEYS[5]:单机 zset(permits) key name
  • ARGV[1]:当前请求令牌数量
  • ARGV[2]:当前时间
  • ARGV[3]:8位随机字符串

然后,我们再将其中的lua部分提取出来,我再根据自己的理解,在其中各段代码加上了注释。

-- rate:间隔时间内产生令牌数量
-- interval:间隔时间
-- type:类型:0-全局限流;1-单机限
local rate = redis.call('hget', KEYS[1], 'rate');
local interval = redis.call('hget', KEYS[1], 'interval');
local type = redis.call('hget', KEYS[1], 'type');
-- 如果3个参数存在空值,错误提示初始化未完成
assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized')
local valueName = KEYS[2];
local permitsName = KEYS[4];
-- 如果是单机限流,在全局key后拼接上机器唯一标识字符
if type == '1' then
    valueName = KEYS[3];
    permitsName = KEYS[5];
end ;
-- 如果:当前请求令牌数 < 窗口时间内令牌产生数量,错误提示请求令牌不能超过rate
assert(tonumber(rate) >= tonumber(ARGV[1]), 'Requested permits amount could not exceed defined rate');
-- currentValue = 当前剩余令牌数量
local currentValue = redis.call('get', valueName);
-- 非第一次访问,存储剩余令牌数量的 string(value) key 存在,有值(包括 0)
if currentValue ~= false then
    -- 当前时间戳往前推一个间隔时间,属于时间窗口以外。时间窗口以外,签发过的令牌,都属于过期令牌,需要回收回来
    local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
    -- 统计可以回收的令牌数量
    local released = 0;
    for i, v in ipairs(expiredValues) do
        -- lua struct的pack/unpack方法,可以理解为文本压缩/解压缩方法
        local random, permits = struct.unpack('fI', v);
        released = released + permits;
    end ;
    -- 移除 zset(permits) 中过期的令牌签发记录
    -- 将过期令牌回收回来,重新更新剩余令牌数量
    if released > 0 then
        redis.call('zremrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
        currentValue = tonumber(currentValue) + released;
        redis.call('set', valueName, currentValue);
    end ;
    -- 如果 剩余令牌数量 < 当前请求令牌数量,返回推测可以获得所需令牌数量的时间
    -- (1)最近一次签发令牌的释放时间 = 最近一次签发令牌的签发时间戳 + 间隔时间(interval)
    -- (2)推测可获得所需令牌数量的时间 = 最近一次签发令牌的释放时间 - 当前时间戳
    -- (3)"推测"可获得所需令牌数量的时间,"推测",是因为不确定最近一次签发令牌数量释放后,加上到时候的剩余令牌数量,是否满足所需令牌数量
    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
        redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1]));
        redis.call('decrby', valueName, ARGV[1]);
        return nil;
    end ;
    -- 第一次访问,存储剩余令牌数量的 string(value) key 不存在,为 null,走初始化逻辑
else
    redis.call('set', valueName, rate);
    redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1]));
    redis.call('decrby', valueName, ARGV[1]);
    return nil;
end ;

你可能感兴趣的:(限流redisson)