• 分布式限流利器,手撕&redisson实现



    前言

    限流,是网站防止流量洪峰的必要手段,尤其是一些重要资源的处理,甚为重要。限流的核心目的自然是保障网站的正常运行,避免处理超过网站自身的流量,压死骆驼的最后一个稻草,你懂得。

    常见的限流算法有 计数器漏桶算法令牌算法。在之前的文章已经做过分析,有兴趣可以详情查看

    本文主要分析令牌桶算法,令牌桶有哪些特点?

    • 一定速度(一般是恒速)生产
    • 非恒定速度消费,只要令牌桶中存在可用令牌,可直接使用,能满足一些特定场景下流量冲击
    • 成功获取令牌则继续执行,反之,拒绝处理

    从这些特点可以看出,令牌桶也类似于队列,有生产者、消费者,还有固定大小容器,从实现角度考虑, 可能你还得写一个生产者,并指定生产速率。

    不过,我们还有更简单的方法,以时间为移动轴,并以消费者请求为处理时机,触发特定的令牌生产逻辑,这样一来便少了生产者角色。

    我画了张图,你可以参考下:
    在这里插入图片描述


    一、手撕令牌限流

    1. 构思

    如果让你来设计一个令牌限流算法,你会如何思考?

    首先,令牌算法的本质是以指定(恒定)速度生产,因此,肯定要有时间,以及这段内需要生产的数量;换句话说,在给定的时间间隔 (interval)内,生产 permits 个令牌。

    另外,恒定速度生产,当消耗速度低于生产速度,令牌就会累加,那会一直累加吗?什么时候到头?

    当然不会,你一定要牢记,在给定 interval 内,最多只有 permits 个令牌。你可以把这个时间间隔 interval 理解成滑动窗口,即,窗口大小不会变,且窗口会沿着时间方向滑动。
    在这里插入图片描述

    好,有了这个理论,我们开始设计令牌限流:

    我们假设 1s 限制 10 个令牌,即这个窗口大小为 1s,并且这个窗口内最多只能装 10 个令牌。然后我们写下,第 1s 有 10 个令牌,第 2s 也有 10个, … 第 N s 也有10个令牌。

    好,你开始有思路了,用 redis 来定义一个 key 作为令牌桶的名字,然后设置间隔和频率,每消耗一个令牌就减一,然后窗口随时间移动,该新增令牌就新增,该淘汰就淘汰。

    然后,写下代码大概是这样:

      // cursor 窗口的起始点时间
      // now 当前时间
      // rateInterval 窗口大小(时间间隔)
      
      if (now - cursor > rateInterval) { 
          // 重置
          jedis.hset(name, CURSOR, String.valueOf(now));
          jedis.hset(name, CURRENT_RATE, String.valueOf(rate));
      } else {
          // 可能还有 token 余额,尝试获取
          String current = jedis.hget(name, CURRENT_RATE);
          int currentRate = Integer.parseInt(Strings.isNullOrEmpty(current) ? "0" : current);
          if (currentRate < permits) {
              return false;
          }
      }
      // 到这说明能获取成功了, 减去相应令牌数
      jedis.hincrBy(name, CURRENT_RATE, -permits);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    搞好了,原来这么简单?

    别急,这里还有个严重的问题,假设这里在 0.9s 的时候来了 10 个请求,1.1s 的时候又来了 10 个请求,通过以上限流器,这 20 个请求都会被处理,发现问题了吗?

    0.9 到 1.1 s 才 0.2 s 间隔就放过了 20 个请求,是不是违背了初衷,为啥会这样呢?

    原因在于刻度选的太大,以上默认选的 1 s 刻度,即 前10个请求属于第 1 s,后 10 个请求属于第 2 s,而这 20 请求集中在 0.9s -1.1s 过来,刚好横跨两个窗口。

    在这里插入图片描述

    如何解决?滑动窗口,本质来说,要记录整个窗口的请求明细,每次新请求尝试获取令牌时,要减去已经消耗的令牌数。

    这里我们使用 redis 的 zset 来实现,用 score 保留时间戳、value 记录每个请求要获取的令牌数,然后清空窗口以外的数据:

     String windowLimitName = name + "_windows";
     List<String> permitsList = jedis.zrangeByScore(windowLimitName, now - rateInterval, now);
     // 当前窗口已经使用令牌总和
     int usedPermits = permitsList.stream().map(Integer::parseInt).mapToInt(Integer::intValue).sum();
     
     // 窗口向前移动了一个刻度
     if (now - cursor > rateInterval) {
         // 当前滑动窗口内余额不够了
         if (rate - usedPermits < permits) {
             return false;
         }
         
         jedis.hset(name, CURSOR, String.valueOf(now));
         jedis.hset(name, CURRENT_RATE, String.valueOf(rate));
     } else {
         // 可能还有 token 余额,尝试获取
         String current = jedis.hget(name, CURRENT_RATE);
         int currentRate = Integer.parseInt(Strings.isNullOrEmpty(current) ? "0" : current);
         if (currentRate < permits) {
             return false;
         }
     }
     // 删除指定数量令牌
     jedis.hincrBy(name, CURRENT_RATE, -permits);
     // 新增此次获取窗口明细
     jedis.zadd(windowLimitName, now, String.valueOf(permits));
     // 删除窗口之外的数据
     jedis.zremrangeByScore(windowLimitName, 0, now - rateInterval);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    好,这样就差不多了,当然,还有些小优化,你可以找找在哪里。

    2. 实现

    有了上面的两步,我们很快写出 redis 令牌版的限流算法,这里,我们看看完整的实现,并验证其正确性。

    Case 1:

    在这里插入图片描述
    然后验证下,这里可以看到 200ms 内消耗令牌超过 10个:

    在这里插入图片描述

    根据以上问题,我们再看改进版,Case 2:
    在这里插入图片描述
    继续测试验证,你可以观察到,加了窗口限制之后,任何 1s 的窗口期内,最多只能消耗 10 个令牌:
    在这里插入图片描述

    好,到目前为止,一个借助于 redis 实现的令牌桶限流算法基本完成了。可能你也注意到了,以上获取令牌的方式不是原子操作,因此,在实际生产中,我们可以通过 lua 脚本原子性的封装以上所有操作。

    二、redisson 实现

    到这里,你可能会问了,实际工作中不想重复造轮子,有没有开箱即用的组件?

    如果你是单机限流,推荐你使用 Guava 的 RateLimiter,之前写了一篇文章详细分析过其实现,感兴趣可以点击详情查看

    另外就是,分布式限流算法,这是我们接下来分析的重点。这里以 redisson 的 RedissonRateLimiter 展开,该组件笔者也是在生产环节经常使用。

    1. 设计

    这是 redisson 3.15.x 版本:
    在这里插入图片描述

    这是截止目前(2022/07/31)最新版本 3.17.5 ,可以看到,代码似乎更复杂了:在这里插入图片描述
    当然,不要被以上代码吓退,最开始这个功能也只有几行代码,也是经过后面长期迭代、完善功能、修复 bug,到现在呈现出来,就稍显有些复杂。

    本质来说,和我们自己写的思路上差不多:

    • 首先,获取 rate、rateInterval 等固定参数
    • 检验参数是否合法,比如 请求参数大于 rate 肯定就不行了。
    • 检查窗口内目前已经使用了多少令牌,并判断当前请求是否够用。
    • 随着时间推移,窗口在移动,有一部分令牌肯定会过期,尝试将这些过期的补上。
    • 判断能成功获取的话,存取此次获取明细,并从总数减去相应令牌数

    2. 原理

    接下来我们将以 redisson 3.17.5 进行拆解分析,先看调用的方法定义:

    <T, R> RFuture<R> evalWriteAsync(String key, Codec codec, RedisCommand<T> evalCommandType, String script, List<Object> keys, Object... params);
    
    • 1

    调用方法时传递的 keys:

    Arrays.asList(getName(), getValueName(), getClientValueName(), getPermitsName(), getClientPermitsName())
    
    • 1

    其次是 params (args):

    value, System.currentTimeMillis(), ThreadLocalRandom.current().nextLong()
    
    • 1

    值得注意的是,lua 中列表下标是从 1 开始的,有了这些,我们继续分析这段复杂的 lua 代码:

    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');
    assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized')
    
    • 1
    • 2
    • 3
    • 4

    2)计算过期令牌

    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('Bc0I', v);
         released = released + permits;
    end; 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    这里 ARGV[2] 就是我们传递的参数 System.currentTimeMillis(),通过 zrangebyscore 范围查找到有效窗口之外的明细列表,即过期的列表。

    当我们淘汰这部分过期的访问列表之后,也就意味着我们新窗口可以容纳新的令牌了,后面将尝试补充。

    lua 方法 tonumber 表示将参数转换成 number 类型。

    另外,struct.unpack() 方法是和 struct.pack() 配套使用,本质就是将多个参数压缩成一个,或者解压成多个,方法第一个参数可以指定格式。这种方式,方便存储多个字段,同时也可以让值变得唯一。

    3)补充令牌:

    if released > 0 then 
         redis.call('zremrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval); 
         if tonumber(currentValue) + released > tonumber(rate) then 
              currentValue = tonumber(rate) - redis.call('zcard', permitsName); 
         else 
              currentValue = tonumber(currentValue) + released; 
         end; 
         redis.call('set', valueName, currentValue);
    end;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    首先,这里通过 zremrangebyscore 会删除过期令牌明细(窗口之外),这时,zset 列表中只剩下有效窗口内的数据,可以通过 zcard 计算目前总消耗令牌量。

    4)扣减令牌

    // 当前可用令牌数量小于请求令牌数量,将请求失败
    if tonumber(currentValue) < tonumber(ARGV[1]) then 
        local firstValue = redis.call('zrange', permitsName, 0, 0, 'withscores'); 
        res = 3 + interval - (tonumber(ARGV[2]) - tonumber(firstValue[2]));
    else // 可以获取令牌
        redis.call('zadd', permitsName, ARGV[2], struct.pack('Bc0I', string.len(ARGV[3]), ARGV[3], ARGV[1])); 
        redis.call('decrby', valueName, ARGV[1]); 
        res = nil; 
    end; 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    这里 ARGV[1] 表示我们的请求参数 value,即,此次请求令牌数量。通过 zadd 记录令牌获取明细,decrby 执行扣减令牌。

    以下 lua 指令的的意思是,将三个参数 string.len(ARGV[3]), ARGV[3], ARGV[1] 揉成指定格式 Bc0I 的一个值,该方式可逆(即反解析)

    struct.pack('Bc0I', string.len(ARGV[3]), ARGV[3], ARGV[1])
    
    • 1

    其中 ARGV[3] 表示 随机数 random,ARGV[1] 表示 请求令牌数 value。

    5)设置过期时间

    local ttl = redis.call('pttl', KEYS[1]);
    if ttl > 0 then
        redis.call('pexpire', valueName, ttl);
        redis.call('pexpire', permitsName, ttl);
    end;
    return res;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    当然,这是可选操作。此逻辑是后期加上的,之前的版本没有出现过。

    3. 动手试试

    先写个 case 验证下:
    在这里插入图片描述
    可以看到,结果符合预期。再看看 redis 存了什么:

    1)预设参数:
    在这里插入图片描述
    2)令牌访问明细:
    在这里插入图片描述
    3)令牌剩余量:
    在这里插入图片描述


    总结

    限流,是网站常用的安全手段之一,通常有 计数器、漏桶和令牌桶三种限流方式。本文主要分析了其中最使用的令牌桶算法。

    令牌桶算法,以指定速度生产令牌,并放置于固定容量的令牌桶中,然后,消费端以非恒定速度消费令牌;如果能成功获取则执行后续操作,反之等待或者拒绝处理。

    另外,通常实现中并没有特定生产者来生产令牌,而是以时间为轴,惰性触发的方式来维护令牌桶的平衡性。即,客户端请求时触发相应令牌逻辑。

    令牌桶设计中,维持时间窗口的正确性十分重要,同时,也需要尽可能提供原子性的保障,可以考虑使用 lua 处理。

    随后,我们一览 redisson 最新版的令牌桶实现,在实际生产中,你可以开箱即用,无需重复造轮子。




    相关参考:
  • 相关阅读:
    opencv中的图像操作
    linux 使用 navicat 报错的各种问题
    springBoot依赖管理机制
    进程间通信——套接字通信(socket)
    web测试之功能测试常用的方法有哪几种?有什么要点要注意?
    HUST网络攻防实践|5_二进制文件补丁技术|实验三 热补丁
    【学懂数据结构】顺序表?链表?我全都要(入门学习)
    AlmaLinux 经济收益增加,红帽 RHEL 源码限制不成威胁
    excel功能区(ribbonx)编程笔记--5 菜单
    基于Java ME的俄罗斯方块游戏免费LW+源代码
  • 原文地址:https://blog.csdn.net/ldw201510803006/article/details/126045180