背景
目前在做的系统有提供Open API接入,即客户通过API Key接入使用系统功能。所允许接入的API根据功能种类不同所需系统开销有所差异,从技术角度希望对系统增加一些保护措施,避免用户接入使用过程有意或无意高频调用一些API对系统构成压力,从而影响整个系统稳定性。
限制主要考虑两个维度:
1.限频,限制单位时间内调用次数,关注调用速度
2.限流,限制时间窗口内调用次数,关注调用总量
如上描述,限频、限流本质是同一件事:“限制一定时间内的调用次数”,但此处特意使用两个不同的名词用于体现差异性(后文会多次提及):
1.单位时间,粒度小,常量,通常用秒描述
2.时间窗口,粒度大,变量,如一分钟、五分钟、十五分钟或一小时等
在目标系统中针对特定API(POST /api/order),期望实现:“每秒调用不能超过20次,每分钟调用不能超过200次”,前者称之为限频,后者称为限流。
方案
限频限流可以选择在应用层实现,早期在一些项目中的确有借助Redis实践过,结论是只能满足前期需要,后期系统QPS过高时应用层与Redis的开销不容小觑。
当前项目的Open API网关是OpenResty,选择把限频限流做在网关这一层是个不错的选择,重点依赖OpenResty官方提供的几个Lua扩展:
--> resty.limit.req, 用于限制单位时间(秒)的请求数
--> resty.limit.conn, 用于限制并发连接数
--> resty.limit.count, 用于限制时间窗口内的请求数量限制,时间窗口可自定义
--> resty.limit.traffic, 用于对三者进行组合,以实现更丰富的限制策略
借助上述OpenResty Lua扩展,除实现限频限流功能外,还期望能对用户提供一些“反馈”信息:
--> 当前API的限频限流的限制信息,如每秒允许请求数,限流的时间窗口大小以及请求数
--> 触发限频时,请求被系统延迟了多久处理(当某请求触发限频时有两个选择:Pending到下一秒处理、拒绝)
--> 针对限流,当前时间窗口剩余可请求次数
1.限制ip并发连接数
lua_shared_dict limit_conn_store 100m;
location /limit/conn {
access_by_lua_block {
local limit_conn = require "resty.limit.conn"
-- 限制一个 ip 客户端最大 1 个并发请求
-- burst 设置为 0,如果超过最大的并发请求数,则直接返回503,
-- 如果此处要允许突增的并发数,可以修改 burst 的值(漏桶的桶容量)
-- 最后一个参数其实是你要预估这些并发(或者说单个请求)要处理多久,以便于对桶里面的请求应用漏桶算法
-- 如果我设置local lim, err = limit_conn.new("limit_conn_store", 50, 25, 0.5)
-- 则表示限制50个并发请求,和一个25个并发额外的突发请求, 也就是一个客户端访问50 +25 次之后就会抛出503
local lim, err = limit_conn.new("limit_conn_store", 1, 0, 0.5)
if not lim then
ngx.log(ngx.ERR, "failed to instantiate a resty.limit.conn object: ", err)
return ngx.exit(500)
end
local key = ngx.var.binary_remote_addr
-- commit 为true 代表要更新shared dict中key的值,
-- false 代表只是查看当前请求要处理的延时情况和前面还未被处理的请求数
local delay, err = lim:incoming(key, true)
if not delay then
if err == "rejected" then
return ngx.exit(503)
end
ngx.log(ngx.ERR, "failed to limit req: ", err)
return ngx.exit(500)
end
-- 如果请求连接计数等信息被加到shared dict中,则在ctx中记录下,
-- 因为后面要告知连接断开,以处理其他连接
if lim:is_committed() then
local ctx = ngx.ctx
ctx.limit_conn = lim
ctx.limit_conn_key = key
ctx.limit_conn_delay = delay
end
local conn = err
-- 其实这里的 delay 肯定是上面说的并发处理时间的整数倍,
-- 举个例子,每秒处理100并发,桶容量200个,当同时来500个并发,则200个拒掉
-- 100个正在被处理,然后200个进入桶中暂存,被暂存的这200个连接中,0-100个连接其实应该延后0.5秒处理,
-- 101-200个则应该延后0.5*2=1秒处理(0.5是上面预估的并发处理时间)
-- ngx.say("delay: ", delay)
if delay >= 0.001 then
-- ngx.sleep(delay)
end
}
log_by_lua_block {
local ctx = ngx.ctx
local lim = ctx.limit_conn
if lim then
local key = ctx.limit_conn_key
-- 这个连接处理完后应该告知一下,更新shared dict中的值,让后续连接可以接入进来处理
-- 此处可以动态更新你之前的预估时间,但是别忘了把limit_conn.new这个方法抽出去写,
-- 要不每次请求进来又会重置
local conn, err = lim:leaving(key, 0.5)
if not conn then
ngx.log(ngx.ERR,
"failed to record the connection leaving ",
"request: ", err)
return
end
end
}
default_type text/html;
content_by_lua_block {
ngx.say("hello world(/limit/conn)");
}
proxy_set_header Host $host;
proxy_redirect off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 60;
proxy_read_timeout 600;
proxy_send_timeout 600;
}
单纯限制并发,超过允许并发量就会抛出403.
2.限制接口时间窗请求数
限制ip每分钟只能调用120次,允许在开始时候一次性放过120
lua_shared_dict limit_count_store 100m;
location /limit/count {
default_type text/html;
content_by_lua_block {
ngx.say("hello world");
}
#限制ip每分钟只能调用120次,允许在开始时候一次性放过120
access_by_lua_block {
local limit_count = require "resty.limit.count"
-- local lim, err = limit_count.new("limit_count_store", 120, 60)
-- 10秒内只能调用5次
local lim, err = limit_count.new("limit_count_store", 5, 10)
if not lim then
ngx.log(ngx.ERR, "failed to instantiate a resty.limit.count object: ", err)
return ngx.exit(500)
end
-- 获取当前请求的uri
-- local uri = ngx.var.uri
-- local key = "req:uri:"..uri
local key = ngx.var.binary_remote_addr
local delay, err = lim:incoming(key, true)
-- 如果请求数在限制范围内,则当前请求被处理的延迟和将被处理的请求的剩余数(这种场景下始终为0,因为要么被处理要么被拒绝)
-- 请求被拒绝,err返回rejected
ngx.log(ngx.INFO, "请求剩余数:", err);
if not delay then
if err == "rejected" then
return ngx.exit(503)
end
ngx.log(ngx.ERR, "failed to limit count: ", err)
return ngx.exit(500)
end
}
}
3.平滑限制接口请求数
限制ip每分钟调用120次,进行平滑处理请求,每秒放过2个请求
lua_shared_dict limit_req_store 100m;
location /limit/req {
default_type text/html;
content_by_lua_block {
ngx.say("hello world(/limit/req)");
}
#进行平滑处理请求,每秒放过4个请求
access_by_lua_block {
local limit_req = require "resty.limit.req"
-- 这里设置rate=4/s,漏桶桶容量设置为0(也就是来多少水就留多少水)
local lim, err = limit_req.new("limit_req_store", 4, 0)
if not lim then
ngx.log(ngx.ERR, "failed to instantiate a resty.limit.req object: ", err)
return ngx.exit(500)
end
-- 获取当前请求的uri
-- local uri = ngx.var.uri
-- local key = "req:uri:"..uri
local key = ngx.var.binary_remote_addr
local delay, err = lim:incoming(key, true)
-- 如果请求数在限制范围内,则当前请求被处理的延迟和将被处理的请求的剩余数(这种场景下始终为0,因为要么被处理要么被拒绝)
-- 请求被拒绝,err返回rejected
if not delay then
if err == "rejected" then
return ngx.exit(503)
end
ngx.log(ngx.ERR, "failed to limit req: ", err)
return ngx.exit(500)
end
ngx.say("delay: ", delay)
}
}
4.漏桶算法限流
限制ip每分钟稚嫩调用120次接口(平滑处理请求,每秒2个请求),超过部分进入桶中等待,(桶容量为60),如果桶也满了,则进行限流
lua_shared_dict limit_req_store 100m;
location /limit/req2 {
default_type text/html;
content_by_lua_block {
ngx.say("hello world(/limit/req2)");
}
#进行平滑处理请求,每秒放过4个请求,超过部分进入桶中等待,(桶容量为6),如果桶也满了,则进行限流
access_by_lua_block {
local limit_req = require "resty.limit.req"
local lim, err = limit_req.new("limit_req_store", 4, 6)
if not lim then
ngx.log(ngx.ERR, "failed to instantiate a resty.limit.req object: ", err)
return ngx.exit(500)
end
-- 获取当前请求的uri
-- local uri = ngx.var.uri
-- local key = "req:uri:"..uri
local key = ngx.var.binary_remote_addr
local delay, err = lim:incoming(key, true)
-- 如果请求数在限制范围内,则当前请求被处理的延迟和将被处理的请求的剩余数(这种场景下始终为0,因为要么被处理要么被拒绝)
-- 请求被拒绝,err返回rejected
ngx.log(ngx.INFO, "请求剩余数:", err);
if not delay then
if err == "rejected" then
return ngx.exit(503)
end
ngx.log(ngx.ERR, "failed to limit req: ", err)
return ngx.exit(500)
end
ngx.say("delay: ", delay)
}
}
5.令牌桶算法限流
令牌桶其实可以看是漏桶的逆操作,看我们对把超过请求速率而进入桶中的请求如何处理,如果是我们把这部分请求放入到等待队列中去,那么其实就是用了漏桶算法,但是如果我们允许直接处理这部分的突发请求,其实就是使用了令牌桶算法。
限制 ip 每分钟只能调用 120 次 接口(平滑处理请求,即每秒放过2个请求),但是允许一定的突发流量(突发的流量,就是桶的容量(桶容量为60),超过桶容量直接拒绝
这边只要将上面漏桶算法关于桶中请求的延时处理的代码修改成直接送到后端服务就可以了,这样便是使用了令牌桶
lua_shared_dict limit_req_store 100m;
location /limit/req3 {
default_type text/html;
content_by_lua_block {
ngx.say("hello world(/limit/req3)");
}
#进行平滑处理请求,每秒放过4个请求,超过部分进入桶中等待,(桶容量为6),如果桶也满了,则进行限流
access_by_lua_block {
local limit_req = require "resty.limit.req"
local lim, err = limit_req.new("limit_req_store", 4, 3)
if not lim then
ngx.log(ngx.ERR, "failed to instantiate a resty.limit.req object: ", err)
return ngx.exit(500)
end
-- 获取当前请求的uri
-- local uri = ngx.var.uri
-- local key = "req:uri:"..uri
local key = ngx.var.binary_remote_addr
local delay, err = lim:incoming(key, true)
-- 如果请求数在限制范围内,则当前请求被处理的延迟和将被处理的请求的剩余数(这种场景下始终为0,因为要么被处理要么被拒绝)
-- 请求被拒绝,err返回rejected
ngx.log(ngx.INFO, "请求剩余数:", err);
if not delay then
if err == "rejected" then
return ngx.exit(503)
end
ngx.log(ngx.ERR, "failed to limit req: ", err)
return ngx.exit(500)
end
-- 此方法返回,当前请求需要delay秒后才会被处理,和他前面对请求数
-- 此处忽略桶中请求所需要的延时处理,让其直接返送到后端服务器,
-- 其实这就是允许桶中请求作为突发流量 也就是令牌桶桶的原理所在
ngx.say("delay: ", delay)
if delay >= 0.001 then
ngx.sleep(delay)
end
}
}