分布式限流利器,小试牛刀

39 篇文章 27 订阅


前言

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

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

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

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

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

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

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


一、手撕令牌限流

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

搞好了,原来这么简单?

别急,这里还有个严重的问题,假设这里在 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);

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

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

调用方法时传递的 keys:

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

其次是 params (args):

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

值得注意的是,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')

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; 

这里 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;

首先,这里通过 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; 

这里 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])

其中 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;

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

3. 动手试试

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

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


总结

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

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

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

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

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




相关参考:
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
### 回答1: 分布式限流的本质是通过在分布式系统中限制请求数量或请求速率,来保证系统的可用性和稳定性。它的目的是防止系统被过度请求导致的资源耗尽,如内存溢出、网络阻塞或数据库瘫痪等。分布式限流的实现方式通常包括通过预先分配的令牌桶或漏桶等算法来控制请求的速率,从而达到限流的目的。 ### 回答2: 分布式限流的本质是通过分布式系统的协同工作来限制并发访问量,保证系统的稳定性和可靠性。 在传统的单机限流模式下,系统通过对单一服务节点进行限制访问数量,但随着互联网的发展和用户量的增加,单机限流往往无法满足需求。分布式限流采用了多台服务器协同工作的方式,将限流逻辑转移到分布式网关或代理层上。 分布式限流的本质是通过网关或代理层的协同工作来限制并发访问量。具体实现可以通过以下几个步骤: 1. 请求进入分布式网关或代理层:所有的请求都会首先进入分布式网关或代理层,这些网关或代理层可以是负载均衡器、反向代理、API网关等。 2. 限流策略设置:网关或代理层会根据预设的限流策略来判断是否允许请求通过。限流策略可以包括每秒允许通过的请求数、每分钟允许通过的请求数、每个用户允许的请求数等。 3. 限流算法实现:网关或代理层会根据限流策略实现相应的限流算法,例如漏桶算法、令牌桶算法等。这些算法可以根据当前系统的负载情况和预设的参数来动态地调整限制并发访问量。 4. 请求转发或拒绝:根据限流算法的结果,网关或代理层会将请求转发到后端的服务节点,或者直接拒绝请求。拒绝请求可以返回错误信息或者重定向到其他页面。 通过以上步骤,分布式限流可以实现对并发访问量的限制,保证系统的稳定性和可靠性。同时,分布式限流还可以根据系统负载情况动态调整限制参数,以适应不同规模和需求的系统。 ### 回答3: 分布式限流的本质是通过将请求的处理分散到多个节点中,从而实现对系统资源的控制和保护。在高并发的场景下,如果没有限制,大量请求同时涌入系统,容易导致系统资源耗尽,出现性能问题甚至系统崩溃。 分布式限流的本质是将限流操作从单个节点扩展到多个节点,通过集群间的协调和通信,实现对请求的限制和分配。其核心思想是通过集中式的限流策略和算法,将请求分配到不同的节点进行处理,确保每个节点的负载均衡,避免由于单节点处理过多请求而造成的性能问题。 分布式限流的关键点在于如何判断请求是否超出系统的承载能力,并如何合理地分配请求到各个节点。常见的限流算法包括令牌桶算法、漏桶算法等,通过设置合理的参数和规则,对请求进行限制和分配。此外,还可以通过流量控制、速率限制等手段进行限流操作。 分布式限流的本质是为了保证系统的稳定性和可靠性,避免由于并发量过大而导致的系统故障。通过将请求分散到多个节点中,可以降低单个节点的压力,提高系统的整体性能和吞吐量。同时,分布式限流也可以用于保护系统免受恶意攻击和异常请求的影响,提高系统的安全性。 综上所述,分布式限流的本质是通过多节点的协作和限制策略,实现对系统资源的控制和分配,以确保系统的稳定性、高性能和安全性。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

柏油

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值