限流器

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

  • 固定
  • 滑动
  • 令牌桶

一 固定窗口:

当前请求是否在最后一次请求时间点+设置的窗口的时间范围内,然后累计请求次数,判断是否达到上限,如果不在范围会重新初始化请求数(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,简单封装

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

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

  1--- @param key 令牌的唯一标识
  2--- @param permits  请求令牌数量
  3--- @param curr_mill_second 当前时间
  4--- 0 没有令牌桶配置;-1 表示取令牌失败,也就是桶里没有令牌;1 表示取令牌成功
  5local function acquire(key,  permits, curr_mill_second)
  6    local local_key =  key --- 令牌桶key ,使用 .. 进行字符串连接
  7    if tonumber(redis.pcall("EXISTS", local_key)) < 1 then --- 未配置令牌桶
  8        return 0
  9    end
 10
 11    --- 令牌桶内数据:
 12    ---             last_mill_second  最后一次放入令牌时间
 13    ---             curr_permits  当前桶内令牌
 14    ---             max_permits   桶内令牌最大数量
 15    ---             rate  令牌放置速度
 16    local rate_limit_info = redis.pcall("HMGET", local_key, "last_mill_second", "curr_permits", "max_permits", "rate")
 17    local last_mill_second = rate_limit_info[1]
 18    local curr_permits = tonumber(rate_limit_info[2])
 19    local max_permits = tonumber(rate_limit_info[3])
 20    local rate = rate_limit_info[4]
 21
 22    --- 标识没有配置令牌桶
 23    if type(max_permits) == 'boolean' or max_permits == nil then
 24        return 0
 25    end
 26   --- 若令牌桶参数没有配置,则返回0
 27    if type(rate) == 'boolean' or rate == nil then
 28        return 0
 29    end
 30
 31    local local_curr_permits = max_permits;
 32
 33    --- 令牌桶刚刚创建,上一次获取令牌的毫秒数为空
 34    --- 根据和上一次向桶里添加令牌的时间和当前时间差,触发式往桶里添加令牌,并且更新上一次向桶里添加令牌的时间
 35    --- 如果向桶里添加的令牌数不足一个,则不更新上一次向桶里添加令牌的时间
 36    --- ~=号在Lua脚本的含义就是不等于!=
 37    if (type(last_mill_second) ~= 'boolean'  and last_mill_second ~= nil) then
 38        if(curr_mill_second - last_mill_second < 0) then
 39            return -1
 40        end
 41      --- 生成令牌操作
 42        local reverse_permits = math.floor(((curr_mill_second - last_mill_second) / 1000) * rate) --- 最关键代码:根据时间差计算令牌数量并匀速的放入令牌
 43        local expect_curr_permits = reverse_permits + curr_permits;
 44        local_curr_permits = math.min(expect_curr_permits, max_permits);  --- 如果期望令牌数大于桶容量,则设为桶容量
 45        --- 大于0表示这段时间产生令牌,则更新最新令牌放入时间
 46        if (reverse_permits > 0) then
 47            redis.pcall("HSET", local_key, "last_mill_second", curr_mill_second)
 48        end
 49    else
 50        redis.pcall("HSET", local_key, "last_mill_second", curr_mill_second)
 51    end
 52  --- 取出令牌操作
 53    local result = -1
 54    if (local_curr_permits - permits >= 0) then
 55        result = 1
 56        redis.pcall("HSET", local_key, "curr_permits", local_curr_permits - permits)
 57    else
 58        redis.pcall("HSET", local_key, "curr_permits", local_curr_permits)
 59    end
 60    return result
 61end
 62
 63--- 初始化令牌桶
 64local function initTokenBucket(key, max_permits, rate)
 65    if(key == nil or string.len(key) < 1) then
 66        return 0
 67    end
 68    local local_max_permits = 100
 69    if(tonumber(max_permits) > 0) then
 70        local_max_permits = max_permits
 71    end
 72
 73    local local_rate = 100
 74    if(tonumber(rate) > 0) then
 75        local_rate = rate
 76    end
 77    redis.pcall("HMSET", key, "max_permits", local_max_permits, "rate", local_rate)
 78    return 1;
 79end
 80
 81--- 获取当前时间,单节点获取,避免集群模式下(无论业务系统集群,还是redis集群)获取的时间不同,导致桶不匀速
 82local function currentTimeMillis()
 83    local times = redis.pcall("TIME")
 84    return tonumber(times[1]) * 1000 + tonumber(times[2]) / 1000
 85end
 86
 87--- key,即redis中的key。
 88local key = KEYS[1]
 89--- args第一个参数即要调用的方法名。
 90local method = ARGV[1]
 91--- 请求令牌
 92if method == 'acquire' then
 93    return acquire(key, ARGV[2], ARGV[3])
 94--- 请求时间
 95elseif method == 'currentTimeMillis' then
 96    return currentTimeMillis()
 97--- 初始化令牌桶
 98elseif method == 'initTokenBucket' then
 99    return initTokenBucket(key, ARGV[2], ARGV[3])
100end