You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

172 lines
9.6 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 43 | 灵活实现动态限流限速,其实没有那么难
你好,我是温铭。
前面的课程中,我为你介绍了漏桶和令牌桶算法,它们都是应对突发流量的常用手段。同时,我们也学习了如何通过 Nginx 配置文件的方式,来实现对请求的限流限速。不过很显然,使用 Nginx 配置文件的方式,仅仅停留在可用的层面,距离好用还是有不小的距离的。
第一个问题便是,限速的 key 被限制在 Nginx 的变量范围内,不能灵活地设置。比如,根据不同的省份和不同的客户端渠道,来设置不同的限速阈值,这种常见的需求用 Nginx 就没有办法实现。
另外一个更大的问题是,不能动态地调整速率,每次修改都需要重载 Nginx 服务,这一点我们在上节课的最后也提到过。这样一来,根据不同的时间段限速这种需求,就只能通过外置的脚本来蹩脚地实现了。
要知道,技术是为业务服务的,同时,业务也在驱动着技术的进步。在 Nginx 诞生的时代,并没有什么动态调整配置的需求,更多的是反向代理、负载均衡、低内存占用等类似的需求,在驱动着 Nginx 的成长。在技术的架构和实现上并没有人能够预料到在移动互联网、IoT、微服务等场景下对于动态和精细控制的需求会大量爆发。
而 OpenResty 使用 Lua 脚本的方式,恰好能够弥补 Nginx 在这方面的缺失,形成了有效的互补。这也是 OpenResty 被广泛地用于替换 Nginx 的根源所在。在后面几节课中,我会为你继续介绍更多 OpenResty 中动态的场景和示例。今天,就让我们先来看下,如何使用 OpenResty 来实现动态限流和限速。
在 OpenResty 中,我们推荐使用 [lua-resty-limit-traffic](https://github.com/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-conn``limit-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.count` 和 `limit.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.rate`、`limit.conn` 和 `limit.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` 不仅支持今天所讲的这三种限速器,实际上,只要某个限速器有 `incoming``uncommit` 接口,都可以被 `limit.traffic``combine` 函数管理。
最后,给你留一个作业题。你可以写一个例子,把之前我们介绍过的基于令牌桶的[限速器](https://github.com/upyun/lua-resty-limit-rate)组合起来吗?欢迎在留言区写下你的答案与我讨论,也欢迎你把这篇文章分享给你的同事朋友,一起学习和交流。