限流策略 | 青训营笔记

138 阅读8分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 6 天

在高并发的情境下,为了应对巨大的流量,会使用 限流、熔断、降级 等方法

  • 根据作用范围

    • 单机限流
    • 分布式限流
  • 根据限流方式

    • 计数器
    • 滑动窗口
    • 漏桶限令
    • 令牌桶限流

常见限流方法

计数器

计数器有两个特点:

  1. 只作用于一段时间内,不同时间段互不干扰
  2. 限量限频

说是计数器是因为程序中内含一个计数器变量,记录一段时间内的流量,每出现一个请求则 +1

值得注意的是,该算法不仅会使用计数器记录请求数量,还会记录请求时间,用以限制请求频率

程序执行逻辑:

  • 可以在程序中设置一个变量 count,当过来一个请求我就将这个数 +1,同时记录请求时间。
  • 当下一个请求来的时候判断 count 的计数值是否超过设定的频次,以及当前请求的时间和第一次请求时间是否在 1 分钟内。
  • 如果在 1 分钟内并且超过设定的频次则证明请求过多,后面的请求就拒绝掉。
  • 如果该请求与第一个请求的间隔时间大于计数周期,且 count 值还在限流范围内,就重置 count。

重置的工作,可以推测出是将计数器清零,并且将新请求时间设置为间隔内第一次请求

不过还是会有问题:

如图,第一秒最后一点时间内发送 100 次请求,第二秒最开始一点时间内再次发送 100 次请求,最后在短时间内就有了高达 200 次的并发,远高于我们 100 次的初衷,最可怕的是,这是方法本身的逻辑漏洞,他的请求就方法本身而言是符合其逻辑的

这种方法虽然简单,但也有个大问题就是没有很好的处理单位时间的边界。

code

github.com/lml20070115…

滑动窗口

最早见到滑动窗口应该是在计算机网络 TCP 传输的拥塞控制中,这里我们把时间段细化,每个时间段对应一个窗口,每个窗口独立计数

5755d987c19740f1a693f7db5294e2a1_tplv-k3u1fbpfcp-zoom-in-crop-mark_4536_0_0_0.webp

每次一个时间段经过后,就将滑动窗口右移

上图中我们用红色的虚线代表一个时间窗口(一分钟),每个时间窗口有 6 个格子,每个格子是 10 秒钟。每过 10 秒钟时间窗口向右移动一格,可以看红色箭头的方向。我们为每个格子都设置一个独立的计数器 Counter,假如一个请求在 0:45 访问了那么我们将第五个格子的计数器 +1(也是就是 0:40~0:50),在判断限流的时候需要把所有格子的计数加起来和设定的频次进行比较即可。

那么滑动窗口如何解决我们上面遇到的问题呢?来看下面的图:

当用户在 0:59 秒钟发送了 200 个请求就会被第六个格子的计数器记录 +200,当下一秒的时候时间窗口向右移动了一个,此时计数器已经记录了该用户发送的 200 个请求,所以再发送的话就会触发限流,则拒绝新的请求。

实际上,计数器可以看作只有一个窗口的特殊滑动窗口。不过,该方法也仍旧被时间片的概念束缚,无法做到对临界情况的完全解决

code

github.com/RussellLuo/…

漏桶

联想一下,小时候看过的故宫的滴漏(草

当桶下方有水流出时,在接满后装水的速率只取决于漏水的速率

漏桶 —>请求队列

水滴—>待处理请求

漏出—>接收请求,准备送去处理

一个固定容量的桶,有水流进来,也有水流出去。对于流进来的水来说,我们无法预计一共有多少水会流进来,也无法预计水流的速度。但是对于流出去的水来说,这个桶可以固定水流出的速率(处理速度),从而达到流量整形和流量控制的效果。

漏桶算法有以下特点:

  • 漏桶具有固定容量,出水速率是固定常量(流出请求)
  • 如果桶是空的,则不需流出水滴
  • 可以以任意速率流入水滴到漏桶(流入请求)
  • 如果流入水滴超出了桶的容量,则流入的水滴溢出(新请求被拒绝)

事实上,之前写机器人 ai 聊天使用的就是漏桶算法,不过容积为 1

漏桶限制的只是流出速率,所以最大的速率就是出水的速率,不能出现突发流量。

与之后的令牌桶相比,由于限制了漏出(响应请求)的速率,所以突发请求只可能装满桶,但处理仍旧需要等待依次漏出

code

github.com/lml20070115…

令牌桶

令牌桶算法(Token Bucket)是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一种算法。典型情况下,令牌桶算法用来控制发送到网络上的数据的数目,并 允许突发数据的发送。

68b7fe323e9e4fe0a83eb14897f018ba_tplv-k3u1fbpfcp-zoom-in-crop-mark_4536_0_0_0.webp

跟银行家算法思路有点像,都是先假设拿走,如果不够就拒绝操作

令牌桶有以下特点:

  • 令牌按固定的速率被放入令牌桶中
  • 桶中最多存放 B 个令牌,当桶满时,新添加的令牌被丢弃或拒绝
  • 如果桶中的令牌不足 N 个,则不会删除令牌,且请求将被限流(丢弃或阻塞等待)

令牌桶限制的是平均流入速率 (允许突发请求,只要有令牌就可以处理,支持一次拿3个令牌,4个令牌...),并允许一定程度突发流量,所以也是非常常用的限流算法。

github.com/lml20070115…

Redis + Lua 分布式限流

单机版限流仅能保护自身节点,但无法保护应用依赖的各种服务,并且在进行节点扩容、缩容时也无法准确控制整个服务的请求限制。

而分布式限流,以集群为维度,可以方便的控制这个集群的请求限制,从而保护下游依赖的各种服务资源。

分布式限流最关键的是要将限流服务做成原子化,我们可以借助 Redis 的计数器,Lua 执行的原子性,进行分布式限流,大致的 Lua 脚本代码如下:

local key = "rate.limit:" .. KEYS[1--限流KEY
local limit = tonumber(ARGV[1])        --限流大小
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then --如果超出限流大小
  return 0
else  --请求数+1,并设置1秒过期
  redis.call("INCRBY", key,"1")
   redis.call("expire", key,"1")
   return current + 1
end

限流逻辑(Java 语言):

public static boolean accquire() throws IOException, URISyntaxException {
    Jedis jedis = new Jedis("127.0.0.1");
    File luaFile = new File(RedisLimitRateWithLUA.class.getResource("/").toURI().getPath() + "limit.lua");
    String luaScript = FileUtils.readFileToString(luaFile);

    String key = "ip:" + System.currentTimeMillis()/1000; // 当前秒
    String limit = "5"; // 最大限制
    List<String> keys = new ArrayList<String>();
    keys.add(key);
    List<String> args = new ArrayList<String>();
    args.add(limit);
    Long result = (Long)(jedis.eval(luaScript, keys, args)); // 执行lua脚本,传入参数
    return result == 1;
}

前面的理解了这个应该不难,实际上就是一个利用 redis 过期和自增实现的计数器

其它

上面的限流方式,主要是针对服务器进行限流,我们也可以对容器进行限流,比如 Tomcat、Nginx 等限流手段。

Tomcat 可以设置最大线程数(maxThreads),当并发超过最大线程数会排队等待执行;而 Nginx 提供了两种限流手段:一是控制速率,二是控制并发连接数。

对于 Java 语言,我们其实有相关的限流组件,比如大家常用的 RateLimiter,其实就是基于令牌桶算法,大家知道为什么唯独选用令牌桶么?

对于 Go 语言,也有该语言特定的限流方式,比如可以通过 channel 实现并发控制限流,也支持第三方库 httpserver 实现限流,详见这篇 《Go 限流的常见方法》

在实际的限流场景中,我们也可以控制单个 IP、城市、渠道、设备 id、用户 id 等在一定时间内发送的请求数;如果是开放平台,需要为每个 appkey 设置独立的访问速率规则。

限流对

下面我们就对常用的线程策略,总结它们的优缺点,便于以后选型。

计数器:

  • 优点:固定时间段计数,实现简单,适用不太精准的场景;
  • 缺点:对边界没有很好处理,导致限流不能精准控制。

滑动窗口:

  • 优点:将固定时间段分块,时间比“计数器”复杂,适用于稍微精准的场景;
  • 缺点:实现稍微复杂,还是不能彻底解决“计数器”存在的边界问题。

漏桶:

  • 优点:可以很好的控制消费频率;
  • 缺点:实现稍微复杂,单位时间内,不能多消费,感觉不太灵活。

令牌桶:

  • 优点:可以解决“漏桶”不能灵活消费的问题,又能避免过渡消费,强烈推荐;
  • 缺点:实现稍微复杂,其它缺点没有想到。

Redis + Lua 分布式限流:

  • 优点:支持分布式限流,有效保护下游依赖的服务资源;
  • 缺点:依赖 Redis,对边界没有很好处理,导致限流不能精准控制。

(实际上就是计数器的缺点,本质就是一个分布式计数器)