前言: 上一遍文章介绍了 OpenResty单机限流的规则存储与限流基本流程,在生产情况下,大部分稍微大点的项目,都会进行集群部署,所以nginx也会部署多个,本文继续介绍 OpenResty集群限流。
限流规则保存和限流执行逻辑和单机限流一样(这个可以参考上一篇文章),只是限流算法使用了redis的一些数据结构来实现。
分布式限流规则原理
滑动窗口
-
时间窗口设定:首先,定义一个时间窗口,表示限流器对请求进行计数的时间段,通常是一段固定长度的时间,比如 1 秒、1 分钟等。
-
计数请求:在每个时间窗口内,限流器会统计进入系统的请求数量。这里使用了 Redis 的有序集合(Sorted Set)来记录请求的时间戳和唯一 ID。时间戳表示请求进入系统的时间,唯一 ID 用于区分不同的请求。
-
限制请求数:在每个时间窗口内,限流器会统计请求的数量。如果请求的数量超过了预先设定的阈值(即 limit),则认为系统已经达到了限流状态,拒绝后续请求。
-
清理旧数据:为了避免数据无限增长,需要定期清理旧的请求数据。这里使用了 zremrangebyscore 命令来删除时间窗口之前的数据。
-
过期时间设置:通过设置键的过期时间,确保在一段时间后限流器自动重置。这里使用了 EXPIRE 命令来设置键的过期时间。
令牌桶
-
获取上次生成令牌时间:通过 Redis 的哈希表数据结构,获取存储在键 中的上次生成令牌的时间和当前令牌数量。如果之前没有生成过令牌,则默认上次生成令牌时间为 0。
-
初始化令牌桶:如果上次生成令牌时间为 0,则说明需要初始化令牌桶,此时将当前令牌数量设置为开始令牌数量,并将当前时间戳存储为上次生成令牌时间。
-
更新令牌数量:如果上次生成令牌时间不为 0,则根据当前时间戳与上次生成令牌时间之间的时间间隔,计算出期间应该生成的令牌数量。然后将当前令牌数量更新为当前令牌数量加上期间应该生成的令牌数量,但不超过令牌桶的最大容量 。如果本次生成的令牌数量大于 0,则更新上次生成令牌的时间为当前时间戳。
-
申请令牌:根据请求需要的令牌数量 ,判断当前令牌数量是否足够。如果不够,则表示资源不足,返回 -1;如果足够,则更新令牌数量,将当前令牌数量减去申请的令牌数量,并将其存储回 Redis。
-
返回结果:根据申请令牌的结果,返回相应的标志。如果成功申请到了足够的令牌,则返回 1;如果资源不足,则返回 -1。
限制接口指定时间内并发连接数
请求开始:
-
获取参数:从传入的参数中获取键名 、允许的最大并发数 和每个并发访问的过期时间 。
-
检查当前并发数:通过 redis.call(‘get’, key) 获取当前记录的并发访问数。如果获取到的值不为 false(说明键存在),并且当前并发数已经达到或超过了允许的最大并发数,则直接返回 -1,表示无法继续访问,否则继续执行下一步。
-
增加并发计数器:如果当前并发数未达到限制,通过 redis.call(‘incr’, key) 命令将计数器增加 1,表示有一个新的并发访问。同时,通过 redis.call(‘expire’, key, concurrentTime) 设置键的过期时间,以防止并发计数器一直增长,这样可以确保一段时间后自动重置并发计数。
-
返回结果:如果成功增加了并发计数器,则返回 0,表示可以继续访问;否则返回 -1,表示已达到最大并发数,无法继续访问。
请求结束:
- 减少当前请求缓存的value值
关键部分代码实现
滑动窗口
local cjson = require "cjson"
local redisUtils = require "redisUtils"
local baseUtil = require "BaseUtil"
-- 定义 Lua 脚本
local lua_script = [[
local key = KEYS[1]
local rangTime = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
-- 获取当前时间
--local now = redis.call('TIME')[1] * 1000 + redis.call('TIME')[2] / 1000
--local start = now - rangTime * 1000
local now = redis.call('TIME')[1]
local start = now - rangTime
local count = tonumber(redis.call('zcount', key, start, now))
--是否超出限流值
if count >= limit then
return -1
end
-- 不需要限流
-- 窗口数据写入
local unique_id = redis.call('INCR', 'request_id')
-- 添加当前访问时间戳到zset
redis.call('zadd', key, now, unique_id)
-- 移除时间区间以外不用的数据,不然会导致zset过大
redis.call('zremrangebyscore', key, 0, start)
redis.call('EXPIRE', key, rangTime)
return 1
]]
local function time_range_request_count(rule_detail)
local request_uri = "timeRange_"..ngx.var.uri
local limitTimeRange = rule_detail.limitTimeRange
local limitNum = rule_detail.limitNum
if not limitTimeRange or not limitNum then
ngx.log(ngx.ERR, "limitTimeRange or limitNum is nil")
return
end
-- 检查 limitTimeRange 和 limitNum 的类型
if type(limitTimeRange) ~= "number" or type(limitNum) ~= "number" then
ngx.log(ngx.ERR, "limitTimeRange 或 limitNum 的类型不是数字","rule_detail: "..cjson.encode(rule_detail))
return
end
local redis_conn = redisUtils.get_con()
--ngx.log(ngx.ERR, "time_range_request_count New limitTimeRange: "..limitTimeRange.." limitNum: "..limitNum)
local keys = {request_uri}
local argv = {limitTimeRange, limitNum}
local keys_and_argv = {table.unpack(keys), table.unpack(argv)}
local result, err = redis_conn:eval(lua_script, #keys, unpack(keys_and_argv))
if not result then
ngx.log(ngx.ERR, "Failed to run the script: ", err)
return
end
if result == -1 then
baseUtil.api_rate_limit_res(429,429,"服务器异常")
end
end
return {
time_range_request_count = time_range_request_count
}
令牌桶
local cjson = require "cjson"
local baseUtil = require "BaseUtil"
local redisUtils = require "redisUtils"
-- 定义 Lua 脚本
local lua_script = [[
-- 获取上次生成令牌时间
local last_gen_token_time = tonumber(redis.call('HGET', KEYS[1], 'time') or '0')
-- 当前令牌数量
local current_token_num
-- 如果上次生成令牌时间是0,则需要初始化令牌桶,并设置令牌数量和时间
if last_gen_token_time == 0 then
current_token_num = tonumber(ARGV[1])
redis.call('HMSET', KEYS[1], 'count', current_token_num, 'time', ARGV[2])
else
-- 当前时间戳
local now_time = tonumber(ARGV[2])
current_token_num = tonumber(redis.call('HGET', KEYS[1], 'count') or '0')
-- 期间应该生成的令牌数 = 距离上一次生成令牌的秒数 * 每秒钟应该放入的令牌数
local duration_token_num = math.floor((now_time - last_gen_token_time) / 1000) * ARGV[3]
-- 当前令牌数量 = min(当前令牌剩余数量 + 期间应该生成的令牌数,令牌桶最大长度)
current_token_num = math.min(current_token_num + duration_token_num, tonumber(ARGV[1]))
-- 如果本次生成的令牌数量大于0,则更新时间
if duration_token_num > 0 then
redis.call('HSET', KEYS[1], 'time', ARGV[2])
end
end
-- 本次申请的令牌个数
local acquire_num = tonumber(ARGV[4])
local rt
if current_token_num < acquire_num then
-- 申请资源失败
rt = -1
else
-- 更新令牌数量
current_token_num = current_token_num - acquire_num
redis.call('HSET', KEYS[1], 'count', current_token_num)
-- 申请资源成功
rt = 1
end
return rt
]]
-- 令牌桶算法限流
local function token_bucket_algorithm(rule_detail)
local request_uri = "tokenBucket_"..ngx.var.uri
-- 限流速率 r/s
local rate = rule_detail.rate
-- 令牌数量
local burst = rule_detail.burst
-- 检查 rate 和 burst 的类型
if type(rate) ~= "number" or type(burst) ~= "number" or rate == 0 or burst == 0 then
ngx.log(ngx.ERR, "rate 或 burst 的类型不是数字","rule_detail: "..cjson.encode(rule_detail))
return
end
local redis_conn = redisUtils.get_con()
local now_time = os.time()*1000
local acquire_num = 1
ngx.log(ngx.ERR, "token_bucket_algorithm New rate: "..rate.." burst: "..burst.." now_time: "..now_time)
local keys = {request_uri}
local argv = {burst,now_time,rate,acquire_num}
local keys_and_argv = {table.unpack(keys), table.unpack(argv)}
local result, err = redis_conn:eval(lua_script, #keys, unpack(keys_and_argv))
if not result then
ngx.log(ngx.ERR, "Failed to run the script: ", err)
return
end
ngx.log(ngx.ERR, "token_bucket_algorithm New result: ",result)
if result == -1 then
baseUtil.api_rate_limit_res(429,429,"服务器异常")
end
ngx.log(ngx.ERR, "token_bucket_algorithm New success")
end
return {
token_bucket_algorithm = token_bucket_algorithm
}
限制接口指定时间内并发连接数
local limit_conn = require "resty.limit.conn"
local cjson = require "cjson"
local baseUtil = require "BaseUtil"
local redisUtils = require "redisUtils"
local lua_script = [[
local key = KEYS[1]
local concurrentNum = tonumber(ARGV[1])
local concurrentTime = tonumber(ARGV[2])
local num = redis.call('get', key)
if num ~= false and tonumber(num) >= concurrentNum then
return -1
end
redis.call('incr', key)
redis.call('expire', key, concurrentTime)
return 0
]]
-- 限制最大并发连接数
local function concurrent_connections(rule_detail)
local request_uri = "concurrentCon_"..ngx.var.uri
local concurrentNum = rule_detail.concurrentNum
local concurrentTime = rule_detail.concurrentTime
-- 检查 concurrentNum、concurrentExtraNum、concurrentTime 的类型
if type(concurrentNum) ~= "number" or type(concurrentTime) ~= "number"then
ngx.log(ngx.ERR, "concurrentNum 或 concurrentExtraNum 或 concurrentTime 的类型不是数字","rule_detail: "..cjson.encode(rule_detail))
return
end
ngx.log(ngx.ERR, "concurrent_connections concurrentNum: "..concurrentNum.." concurrentTime: "..concurrentTime)
-- 使用 eval 命令执行脚本
local keys = {request_uri}
local argv = {concurrentNum, concurrentTime}
local keys_and_argv = {table.unpack(keys), table.unpack(argv)}
local redis_conn = redisUtils.get_con()
local result, err = redis_conn:eval(lua_script, #keys, unpack(keys_and_argv))
-- 检查脚本执行结果
if result == -1 then
baseUtil.api_rate_limit_res(500,500,"服务器异常")
return
end
-- 如果请求没有超过并发限制,那么将连接信息添加到ngx.ctx中
local ctx = ngx.ctx
ctx.limit_type = "concurrent_connections"
ctx.limit_conn_key = request_uri
end
local function concurrent_connections_leaving()
local ctx = ngx.ctx
local lim = ctx.limit_conn
if lim then
local key = ctx.limit_conn_key
local limit_type = ctx.limit_type
if not limit_type then
return
end
if limit_type ~= "concurrent_connections" then
return
end
local redis_conn = redisUtils.get_con()
redis_conn:decr(key)
end
end
return {
concurrent_connections = concurrent_connections,
concurrent_connections_leaving = concurrent_connections_leaving
}
上面就是几种限流算法用lua实现的关键部分逻辑,代码仅供参考学习,欢迎留言讨论。后续会将完整的项目代码更新到远程仓库,欢迎关注我的公众号,后续会在公众号提供获取地址。下一篇将介绍一下使用sentinel进行限流