1 速率限制

OpenResty 官方的 lua-resty-limit-traffic 的模块,里面有三种限速的策略。

1.1 resty.limit.req 模块

基于 漏桶 算法实现的请求速率限制。

1.2 resty.limit.count 模块

基于 固定窗口 实现请求的速率限制,如 单位时间内确保累计的请求数量不超过一个极限值。

1.3 resty.limit.conn 模块

提供请求并发级别限制并根据额外延迟进行调整。

2 跨机器速率限制

lua-resty-redis-ratelimit 通过把状态保存到 Redis,可实现多个Nginx示例状态共享,与 resty.limit.req 模块类似,也是基于漏桶算法对平均请求速率进行限制。

2.1 原理

借助于 Redis Lua Script 机制 ,Redis 有一个支持写 Lua 脚本的功能,这个脚本能够让一些操作在 Redis 执行的时候保证原子性,依赖这个机制,把一次状态的变更用 Lua Script 就能够完全原子性地在 Redis 里面做完。
同时,该模块支持在整个集群层⾯禁⽌某个非法⽤用户一段时间,可实现全局自动拉⿊功能。因为是全局共享,一旦全网有一个客户触发了设置的请求频率限制,我们可以在整个集群内瞬间把他拉黑几个小时。
当然这个模块是有代价的,而且代价也比较大,因为 Nginx 和 Redis 交互需要网络 IO,会带来一定延迟开销,仅适合请求量不大,但需要非常精确限制全局请求速率或单位统计时间跨度非常大的场景。
当然,这个模块也可以做一些自己的优化,不一定所有的状态都需要跟 Redis 同步,可以根据自己的业务情况做一些局部计算,然后定时做全局同步,牺牲一些精确性和及时性,这些都可以去抉择,这边只是多提供了一个手段。

漏桶算法示意图:


如上图,一个水桶,水滴一滴一滴往下滴,我们希望水往下滴的速度尽可能是恒定的,这样下游能够承载的处理能力是比较健康的。

2.2 代码实现

现在是每次来一个请求时在Redis中执行的计算,包括的传入参数如下:

  • KEYS[1] :请求唯一标识(如 客户端IP)
  • ARGV[1]:限制速率 r/s
  • ARGV[2]:允许突发流量(延迟)
  • ARGV[3]:当前时间
  • ARGV[4]:恢复正常状态之前的延迟时间,在此期间,请求始终被拒绝(封禁时长)
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
local key = KEYS[1]
local rate, burst = tonumber(ARGV[1]), tonumber(ARGV[2])
local now, duration = tonumber(ARGV[3]), tonumber(ARGV[4])

local excess, last, forbidden = 0, 0, 0

local res = redis.pcall('GET', key)
if type(res) == "table" and res.err then
return {err=res.err}
end

if res and type(res) == "string" then
local v = cjson.decode(res)
if v and #v > 2 then
excess, last, forbidden = v[1], v[2], v[3]
end

if forbidden == 1 then
return {3, excess} -- FORBIDDEN
end

local ms = math.abs(now - last)
excess = excess - rate * ms / 1000 + 1000

if excess < 0 then
excess = 0
end

if excess > burst then
if duration > 0 then
local res = redis.pcall('SET', key,
cjson.encode({excess, now, 1}))
if type(res) == "table" and res.err then
return {err=res.err}
end

local res = redis.pcall('EXPIRE', key, duration)
if type(res) == "table" and res.err then
return {err=res.err}
end
end

return {2, excess} -- BUSY
end
end

local res = redis.pcall('SET', key, cjson.encode({excess, now, 0}))
if type(res) == "table" and res.err then
return {err=res.err}
end

local res = redis.pcall('EXPIRE', key, 60)
if type(res) == "table" and res.err then
return {err=res.err}
end

return {1, excess}

摘取以上实现中的核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
local ms = math.abs(now - last)
excess = excess - rate * ms / 1000 + 1000

if excess < 0 then
excess = 0
end

if excess > burst then
if duration > 0 then
local res = redis.pcall('SET', key,
cjson.encode({excess, now, 1}))
if type(res) == "table" and res.err then
return {err=res.err}
end

local res = redis.pcall('EXPIRE', key, duration)
if type(res) == "table" and res.err then
return {err=res.err}
end
end

return {2, excess} -- BUSY
end

excess 表示上一次超出的水滴数(延迟通过),一开始是 0 。先计算 上次请求到当前请求的时间间隔 ms,单位为毫秒,ms/1000转换为 秒,rate * ms / 1000 表示上次请求到当前时间间隔水滴往下滴的数量。excess - rate * ms / 1000 就是当前剩下的水滴数量,如果大于 0,表示超过速率限制,如果 设置了burst(突发流量),则返回此请求的等待处理时间 excess / rate ,若 excess 超过了 burst 则直接返回 BUSY。
excess = excess - rate * ms / 1000 + 1000+ 1000 是为了定义一个极小速率 0.001r/s,即极小的请求刻度是 0.001 个请求。