43 | 灵活实现动态限流限速,其实没有那么难

你好,我是温铭。

前面的课程中,我为你介绍了漏桶和令牌桶算法,它们都是应对突发流量的常用手段。同时,我们也学习了如何通过 Nginx 配置文件的方式,来实现对请求的限流限速。不过很显然,使用 Nginx 配置文件的方式,仅仅停留在可用的层面,距离好用还是有不小的距离的。

第一个问题便是,限速的 key 被限制在 Nginx 的变量范围内,不能灵活地设置。比如,根据不同的省份和不同的客户端渠道,来设置不同的限速阈值,这种常见的需求用 Nginx 就没有办法实现。

另外一个更大的问题是,不能动态地调整速率,每次修改都需要重载 Nginx 服务,这一点我们在上节课的最后也提到过。这样一来,根据不同的时间段限速这种需求,就只能通过外置的脚本来蹩脚地实现了。

要知道,技术是为业务服务的,同时,业务也在驱动着技术的进步。在 Nginx 诞生的时代,并没有什么动态调整配置的需求,更多的是反向代理、负载均衡、低内存占用等类似的需求,在驱动着 Nginx 的成长。在技术的架构和实现上,并没有人能够预料到,在移动互联网、IoT、微服务等场景下,对于动态和精细控制的需求会大量爆发。

而 OpenResty 使用 Lua 脚本的方式,恰好能够弥补 Nginx 在这方面的缺失,形成了有效的互补。这也是 OpenResty 被广泛地用于替换 Nginx 的根源所在。在后面几节课中,我会为你继续介绍更多 OpenResty 中动态的场景和示例。今天,就让我们先来看下,如何使用 OpenResty 来实现动态限流和限速。

在 OpenResty 中,我们推荐使用 lua-resty-limit-traffic 来做流量的限制。它里面包含了 limit-req(限制请求速率)、 limit-count(限制请求数) 和 limit-conn (限制并发连接数)这三种不同的限制方式;并且提供了limit.traffic ,可以把这三种方式进行聚合使用。

限制请求速率

让我们先来看下 limit-req,它使用的是漏桶算法来限制请求的速率。

在上一节中,我们已经简要介绍了这个 resty 库中漏桶算法的关键实现代码,现在我们就来学习如何使用这个库。我们来看下面这段示例代码:

resty --shdict='my_limit_req_store 100m' -e 'local limit_req = require "resty.limit.req"
local lim, err = limit_req.new("my_limit_req_store", 200, 100)
local delay, err = lim:incoming("key", true)
if not delay then
    if err == "rejected" then
        return ngx.exit(503)
    end
    return ngx.exit(500)
end

 if delay >= 0.001 then
    ngx.sleep(delay)
end'

我们知道,lua-resty-limit-traffic 是使用共享字典来对 key 进行保存和计数的,所以在使用 limit-req 前,我们需要先声明 my_limit_req_store 这个 100m 的空间。这一点对于 limit-connlimit-count 也是类似的,它们都需要自己单独的共享字典空间,以便区分开。

limit_req.new("my_limit_req_store", 200, 100)

上面这行代码,便是其中最关键的一行代码。它的含义,是使用名为 my_limit_req_store 的共享字典来存放统计数据,并把每秒的速率设置为 200。这样,如果超过 200 但小于 300(这个值是 200 + 100 计算得到的) 的话,就需要排队等候;如果超过 300 的话,就会直接拒绝。

在设置完成后,我们就要对终端的请求进行处理了,lim: incoming("key", true) 就是来做这件事情的。incoming这个函数有两个参数,我们需要详细解读一下。

第一个参数,是用户指定的限速的 key。在上面的示例中它是一个字符串常量,这就意味着要对所有终端都统一限速。如果要实现根据不同省份和渠道来限速,其实也很简单,把这两个信息都作为 key 即可,下面是实现这一需求的伪代码:

local  province = get_ province(ngx.var.binary_remote_addr)
local channel = ngx.req.get_headers()["channel"]
local key = province .. channel
lim:incoming(key, true)

当然,你也可以举一反三,自定义 key 的含义以及调用 incoming 的条件,这样你就能收到非常灵活的限流限速效果了。

我们再来看incoming 函数的第二个参数,它是一个布尔值,默认是 false,意味着这个请求不会被记录到共享字典中做统计,这只是一次 演习。如果设置为 true,就会产生实际的效果了。因此,在大多数情况下,你都需要显式地把它设置为 true。

你可能会纳闷儿,为什么会有这个参数的存在呢?我们不妨考虑一下这样的一个场景,你设置了两个不同的 limit-req 实例,针对不同的 key,一个 key 是主机名,另外一个 key 是客户端的 IP 地址。那么,当一个终端请求被处理的时候,会按照先后顺序调用这两个实例的 incoming 方法,就像下面这段伪码表示的一样:

local limiter_one, err = limit_req.new("my_limit_req_store", 200, 100)
local limiter_two, err = limit_req.new("my_limit_req_store", 20, 10)

limiter_one :incoming(ngx.var.host, true)
limiter_two:incoming(ngx.var.binary_remote_addr, true)

如果用户的请求通过了 limiter_one 的阈值检测,但被 limiter_two 的检测拒绝,那么 limiter_one:incoming 这次函数调用就应该被认为是一次 演习,不应该真的去计数。

这样一来,上述的代码逻辑就不够严谨了。我们需要事先对所有的 limiter 做一次演习,如果有 limiter 的阈值被触发,可以 rejected 终端请求,就可以直接返回:

for i = 1, n do
    local lim = limiters[i]
    local delay, err = lim:incoming(keys[i], i == n)
    if not delay then
        return nil, err
    end
end

这其实就是 incoming 函数第二个参数的意义所在。刚刚这段代码就是 limit.traffic 模块最核心的一段代码,专门用作多个限流器的组合所用。

限制请求数

再来看下 limit.count 这个限制请求数的库,它的效果和 GitHub API 的 Rate Limiting 一样,可以限制固定时间窗口内有多少次用户请求。老规矩,我们先来看一段示例代码:

local limit_count = require "resty.limit.count"

local lim, err = limit_count.new("my_limit_count_store", 5000, 3600)

local key = ngx.req.get_headers()["Authorization"]
local delay, remaining = lim:incoming(key, true)

你可以看到,limit.countlimit.req 的使用方法是类似的,我们先在 Nginx.conf 中定义一个字典:

lua_shared_dict my_limit_count_store 100m;

然后 new 一个 limiter 对象,最后用 incoming 函数来判断和处理。

不过,不同的是,limit-count 中的incoming 函数的第二个返回值,代表着还剩余的调用次数,我们可以据此在响应头中增加字段,给终端更好的提示:

ngx.header["X-RateLimit-Limit"] = "5000"
ngx.header["X-RateLimit-Remaining"] = remaining

限制并发连接数

第三种方式,也就是limit.conn ,是用来限制并发连接数的库。它和前面提到的两个库有所不同,有一个特别的 leaving API,这里我来简单介绍下。

前面所讲的限制请求速率和限制请求数,都是可以直接在 access 这一个阶段内完成的。而限制并发连接数则不同,它不仅需要在 access 阶段判断是否超过阈值,而且需要在 log 阶段调用 leaving 接口:

log_by_lua_block {
    local latency = tonumber(ngx.var.request_time) - ctx.limit_conn_delay
    local key = ctx.limit_conn_key

    local conn, err = lim:leaving(key, latency)
}

不过,这个接口的核心代码其实也很简单,也就是下面这一行代码,实际上就是把连接数减一的操作。如果你没有在 log 阶段做这个清理的动作,那么连接数就会一直上涨,很快就会达到并发的阈值。

local conn, err = dict:incr(key, -1)

限速器的组合

到这里,这三种方式我们就分别介绍完了。最后,我们再来看看,怎么把 limit.ratelimit.connlimit.count 组合起来使用。这就需要用到 limit.traffic 中的 combine 函数了:

local lim1, err = limit_req.new("my_req_store", 300, 200)
local lim2, err = limit_req.new("my_req_store", 200, 100)
local lim3, err = limit_conn.new("my_conn_store", 1000, 1000, 0.5)

local limiters = {lim1, lim2, lim3}
local host = ngx.var.host
local client = ngx.var.binary_remote_addr
local keys = {host, client, client}

local delay, err = limit_traffic.combine(limiters, keys, states)

有了刚刚的知识基础,这段代码你应该很容易看明白。combine 函数的核心代码,在我们上面分析 limit.rate 的时候已经提到了一部分,它主要是借助了演习功能和 uncommit 函数来实现。这样组合以后,你就可以为多个限流器设置不同的阈值和 key,实现更复杂的业务需求了。

写在最后

limit.traffic 不仅支持今天所讲的这三种限速器,实际上,只要某个限速器有 incominguncommit 接口,都可以被 limit.trafficcombine 函数管理。

最后,给你留一个作业题。你可以写一个例子,把之前我们介绍过的基于令牌桶的限速器组合起来吗?欢迎在留言区写下你的答案与我讨论,也欢迎你把这篇文章分享给你的同事朋友,一起学习和交流。

精选留言

  • jackstraw

    2019-12-20 07:34:25

    老师,这里的限流都是针对一台NGINX的,我们的业务是N台NGINX同时提供服务的,这种情况下可以怎么做限流啊?
  • FF

    2019-09-03 09:19:29

    请教个问题。如果把 lua 模块用 lua_by_* 的方式引入 ngx 的 location 指令块里面,那每次请求,都会执行一遍 lua 模块中的 top level 变量和那些不是定义在函数中的全局代码块吗 ?


    我测了一下好像每次请求都执行一遍 ?那全局变量不是每次请求都要初始化一次啊 ?这样的话如果想在函数执行的上下文中再将一些数据保存到全局变量里面,那不是没办法用全局变量实现了 ?


    盼温老师解惑。
    作者回复

    如果你要使用全局变量, 可以写到 init 阶段,这样就只会执行一遍了。但是全局变量是不推荐使用的,在最新的OpenResty 1.15.8 版本中,增加了全局变量的检测,如果发现就会打印一条 warn 级别的日志。
    如果你希望保持函数上下文的全局数据,可以试试模块的 top level 变量。
    同时,在你测试的时候,记得保持 lua_code_cache on,并且不要在 Windows 下测试。

    2019-09-04 16:28:22

  • wusiration

    2019-09-02 22:53:40

    老师,限制器的组合小节里面的代码,limit_req重复了,应该有一个limit_count。
    课后习题:
    local limit_rate = require "resty.limit.rate"
    local limit_count = require "resty.limit.count"
    local limit_conn = require "resty.limit.conn"

    local lim_rate, err = limit_rate.new("my_rate_store", 100, 6000, 2) -- 将resty.limit.req替换成resty.limit.rate即可
    local lim_count, err = limit_count.new("my_count_store", 200, 100)
    local lim_conn, err = limit_conn.new("my_conn_store", 1000, 1000, 0.5)

    local limiters = {lim_rate, lim_count, lim_conn}
    local host = ngx.var.host
    local client = ngx.var.binary_remote_addr
    local keys = {host, client, client}

    local delay, err = limit_traffic.combine(limiters, keys, states)
    作者回复

    多谢指正,看的真仔细👍

    2019-09-04 16:29:26

  • 温暖岁月

    2020-12-29 16:13:37

    老师,请教个问题,对于limit_rate限速配置,如果是动态去改这个值,对于已经在下载的连接来说是没有作用的,该如何让已经连上的也生效?
  • 张靳

    2022-02-08 17:06:27

    之前有用过limit-req方法,tps比较高的时候不是很准,老师有遇到过吗
  • 春夏秋冬

    2019-11-10 23:26:07

    老师,我想要将限速限流参数放置在memcached中,每次请求时通过读取相关的配置来达到动态配置限流限速的功能(代码如下),当我扩大memcached中limit_num值后,发现返回的剩余的调用次数(remaining)并未发生变化,其中原理并不清楚,期望老师解惑或者提供更好的动态修改限流配置的方向

    local memcached = require "resty.memcached"
    local memc, err = memcached:new()
    memc:set_timeout(1000)
    local ok, err = memc:connect("127.0.0.1", 11211)
    local limit_num, flags, err = memc:get("limit_num")
    local limit_time, flags, err = memc:get("limit_time")

    local limit_count = require "resty.limit.count"
    local lim, err = limit_count.new("global_count_limit", tonumber(limit_num), tonumber(limit_time))
    local delay, remaining = lim:incoming('global', true)
    if not delay then
    return ngx.exit(503)
    end
    ngx.say(remaining)
  • Sir

    2019-09-18 23:33:44

    老师好,我把这段代码也在location中,是不是相当于我每次请求都new了一次,比较迷惑
    local lim, err = limit_req.new("my_limit_req_store", 200, 100)
    作者回复

    这种对象的 new 一般都是放在 module 的 top level 中,避免重复创建

    2019-09-27 17:44:10

  • 许童童

    2019-09-02 14:49:08

    跟着老师一起精进。