限流器

我们项目是新闻类的站点,每天需要限制各种爬虫,保证服务在可承受范围内,需要保护后台服务正常。需要使用限流器。

  • 固定
  • 滑动
  • 令牌桶

一 固定窗口:

当前请求是否在最后一次请求时间点+设置的窗口的时间范围内,然后累计请求次数,判断是否达到上限,如果不在范围会重新初始化请求数(0)并记录当前请求时间为最后一次请求时间。

例如当程序启动的时候设置最后一次请求时间为当前系统是,当有一个请求到来是,判断请求时刻在(最后一次请求时间+固定的窗口是否)之间,如果在之间说明需要判断是否允许请求,即累加当前窗口的次数并判断是否达到最大的请求数

缺点是:时间窗口会承受2倍的请求。比如窗口时间每秒允许10个请求, 当第一秒的后半秒请求了10,在下一秒的上半秒也请求了10个,这样也相当于1秒内承受了20个请求。如果每秒允许请求的数量过大会导致服务器需要承受2倍的压力。可能会搞垮服务

二 滑动窗口:

基于固定窗口出现的半秒问题,滑动窗口主要解决了这个半秒问题,但是毛刺现象还会出现。出现一种改进版的滑动窗口限流

整体思路是将固定串口等分若干份,基于当前的请求时间点向前推算一个窗口并找到对应的时间点,计算这段时间点产生请求数是否达到上限。

缺点是:

  1. 需要分多少份, 如果分多了,需要记录每个时间点请求的个数。如果分少了,当请求时间推算一个时间窗口,可能出现时间窗口的起始时间被包含在某个等份中不利于精确计算。
  2. 提高精度需要多份,越多越好。
  3. 但是份数越多需要对每一份都需要记录。这个是非常耗CPU

如果未读越来越多比如基于ip、榜单id、话题id、这个存储和计算都会非常高的。

三 令牌桶:

主要解决请求毛刺(请求不均匀,有突发请求)的问题

以固定的速率向一定大小的桶中产生令牌,当请求获取令牌是,如果桶中存在令牌返回即可,如果桶中不存在会触发计算当前请求时间与最后一次请求时间

为了防止在单点采用redis+lua脚本实现, 主要参数 last_request_time: 最后一次请求的时间,用于计算与上一次时间产生的令牌数,注意如果大于上线去total_token数。 total_token: 总的令牌个数 rate: 令牌的产生速率,例如 5/s,每秒5个 current_token: 当前token的个数

如果不能保证原子的操作,这是会出现放入令牌,获取令牌协同的问题。 还有分布式单点问题,故在用lua,简单封装

1. 根据key获取当前的token是否存在
2. 如果存在可用的token则直接返回
3. 如果不存在此时需要计算
  3.1 根据上一次的时间点与当前时间点,与token产生的速率计算一共生成的token
  3.2 与total_token和计算后的token总数,如果超过允许最大的,只取最大的
  3.3 必须设置当前计算的时间点,以为是lua,在redis中是原子顺序的执行(单线程的)保证数据正常的设置

当前业务,如果出现爬虫请求过频繁,会影响正常用户。所以基于请求用户的http头标识将爬虫请求到配置相对低一点的服务并限制请求上限

--- @param key 令牌的唯一标识
--- @param permits  请求令牌数量
--- @param curr_mill_second 当前时间
--- 0 没有令牌桶配置;-1 表示取令牌失败,也就是桶里没有令牌;1 表示取令牌成功
local function acquire(key,  permits, curr_mill_second)
    local local_key =  key --- 令牌桶key ,使用 .. 进行字符串连接
    if tonumber(redis.pcall("EXISTS", local_key)) < 1 then --- 未配置令牌桶
        return 0
    end

    --- 令牌桶内数据:
    ---             last_mill_second  最后一次放入令牌时间
    ---             curr_permits  当前桶内令牌
    ---             max_permits   桶内令牌最大数量
    ---             rate  令牌放置速度
    local rate_limit_info = redis.pcall("HMGET", local_key, "last_mill_second", "curr_permits", "max_permits", "rate")
    local last_mill_second = rate_limit_info[1]
    local curr_permits = tonumber(rate_limit_info[2])
    local max_permits = tonumber(rate_limit_info[3])
    local rate = rate_limit_info[4]

    --- 标识没有配置令牌桶
    if type(max_permits) == 'boolean' or max_permits == nil then
        return 0
    end
   --- 若令牌桶参数没有配置,则返回0
    if type(rate) == 'boolean' or rate == nil then
        return 0
    end

    local local_curr_permits = max_permits;

    --- 令牌桶刚刚创建,上一次获取令牌的毫秒数为空
    --- 根据和上一次向桶里添加令牌的时间和当前时间差,触发式往桶里添加令牌,并且更新上一次向桶里添加令牌的时间
    --- 如果向桶里添加的令牌数不足一个,则不更新上一次向桶里添加令牌的时间
    --- ~=号在Lua脚本的含义就是不等于!=
    if (type(last_mill_second) ~= 'boolean'  and last_mill_second ~= nil) then
        if(curr_mill_second - last_mill_second < 0) then
            return -1
        end
      --- 生成令牌操作
        local reverse_permits = math.floor(((curr_mill_second - last_mill_second) / 1000) * rate) --- 最关键代码:根据时间差计算令牌数量并匀速的放入令牌
        local expect_curr_permits = reverse_permits + curr_permits;
        local_curr_permits = math.min(expect_curr_permits, max_permits);  --- 如果期望令牌数大于桶容量,则设为桶容量
        --- 大于0表示这段时间产生令牌,则更新最新令牌放入时间
        if (reverse_permits > 0) then
            redis.pcall("HSET", local_key, "last_mill_second", curr_mill_second)
        end
    else
        redis.pcall("HSET", local_key, "last_mill_second", curr_mill_second)
    end
  --- 取出令牌操作
    local result = -1
    if (local_curr_permits - permits >= 0) then
        result = 1
        redis.pcall("HSET", local_key, "curr_permits", local_curr_permits - permits)
    else
        redis.pcall("HSET", local_key, "curr_permits", local_curr_permits)
    end
    return result
end

--- 初始化令牌桶
local function initTokenBucket(key, max_permits, rate)
    if(key == nil or string.len(key) < 1) then
        return 0
    end
    local local_max_permits = 100
    if(tonumber(max_permits) > 0) then
        local_max_permits = max_permits
    end

    local local_rate = 100
    if(tonumber(rate) > 0) then
        local_rate = rate
    end
    redis.pcall("HMSET", key, "max_permits", local_max_permits, "rate", local_rate)
    return 1;
end

--- 获取当前时间,单节点获取,避免集群模式下(无论业务系统集群,还是redis集群)获取的时间不同,导致桶不匀速
local function currentTimeMillis()
    local times = redis.pcall("TIME")
    return tonumber(times[1]) * 1000 + tonumber(times[2]) / 1000
end

--- key,即redis中的key。
local key = KEYS[1]
--- args第一个参数即要调用的方法名。
local method = ARGV[1]
--- 请求令牌
if method == 'acquire' then
    return acquire(key, ARGV[2], ARGV[3])
--- 请求时间
elseif method == 'currentTimeMillis' then
    return currentTimeMillis()
--- 初始化令牌桶
elseif method == 'initTokenBucket' then
    return initTokenBucket(key, ARGV[2], ARGV[3])
end