1

Brief description of current limit

Current limiting algorithms are a frequently mentioned topic in the distributed field. When the processing power of the system is limited, how to prevent unplanned requests from continuing to put pressure on the system is a problem that needs attention.

In addition to controlling traffic, current limiting has another application purpose to control user behavior and avoid spam requests. For example, in the UGC community, users' behaviors such as posting, replying, and liking must be strictly controlled. Generally, it is necessary to strictly limit the number of times a certain behavior is allowed within a specified time. If the number exceeds the number, it is an illegal behavior. For illegal acts, the business must stipulate appropriate punishment strategies.

# 指定用户 user_id 的某个行为 action_key 在特定的时间内 period 只允许发生一定的次数 max_count
def is_action_allowed(user_id, action_key, period, max_count):
    return True
# 调用这个接口 , 一分钟内只允许最多回复 5 个帖子
can_reply = is_action_allowed("110", "reply", 60, 5)
if can_reply:
    do_reply()
else:
    raise ActionThresholdOverflow()

Counting current limit

pipe and zset

image.png

As shown in the figure, a zset structure is used to record the user's behavior history, and each behavior will be saved as a key in the zset. The same behavior of the same user is recorded with a zset.

To save memory, we only need to keep the behavior records in the time window. At the same time, if the user is a cold user and the behavior in the sliding time window is an empty record, then this zset can be removed from the memory and no longer takes up space.

By comparing the number of behaviors in the sliding window with the threshold max\_count, it can be concluded whether the current behavior is allowed. The code is as follows:

function isActionAllowed($userId, $action, $period, $maxCount)
{
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);
    $key = sprintf('hist:%s:%s', $userId, $action);
    $now = msectime();   # 毫秒时间戳

    $pipe=$redis->multi(Redis::PIPELINE); //使用管道提升性能
    $pipe->zadd($key, $now, $now); //value 和 score 都使用毫秒时间戳
    $pipe->zremrangebyscore($key, 0, $now - $period); //移除时间窗口之前的行为记录,剩下的都是时间窗口内的
    $pipe->zcard($key);  //获取窗口内的行为数量
    $pipe->expire($key, $period + 1);  //多加一秒过期时间
    $replies = $pipe->exec();
    return $replies[2] <= $maxCount;
}
for ($i=0; $i<20; $i++){
    var_dump(isActionAllowed("110", "reply", 60*1000, 5)); //执行可以发现只有前5次是通过的
}

//返回当前的毫秒时间戳
function msectime() {
    list($msec, $sec) = explode(' ', microtime());
    $msectime = (float)sprintf('%.0f', (floatval($msec) + floatval($sec)) * 1000);
    return $msectime;
 }

Use lua script

It can only be accessed 3 times in 10 seconds. The subsequent script can be used directly in nginx or program running script to determine whether the return value is 0, then 0 will not allow it to continue to access.

-- redis-cli --eval ratelimiting.lua rate.limitingl:127.0.0.1 , 10 3

-- rate.limitingl + 1
local times = redis.call('incr',KEYS[1])

-- 第一次访问的时候加上过期时间10秒(10秒过后从新计数)
if times == 1 then
    redis.call('expire',KEYS[1], ARGV[1])
end

-- 注意,从redis进来的默认为字符串,lua同种数据类型只能和同种数据类型比较
if times > tonumber(ARGV[2]) then
    return 0
end
return 1

Leaky bucket current limit

The capacity of the funnel is limited. If you block the spout and fill it with water, it will become full until it can no longer be put in. If you let go of the leaking spout, the water will flow down, and after a part of it has flowed away, you can continue to pour water into it. If the rate of flow from the spout is greater than the rate of filling, then the funnel will never be filled. If the rate of flow from the spout is less than the rate of irrigation, once the funnel is full, the irrigation needs to be paused and wait for the funnel to empty.

Therefore, the remaining space of the funnel represents the amount of current behavior that can continue, and the flow rate of the leak represents the maximum frequency that the system allows for the behavior. Below we use code to describe the single-machine funnel algorithm.

<?php

class Funnel {

    private $capacity;
    private $leakingRate;
    private $leftQuote;
    private $leakingTs;

    public function __construct($capacity, $leakingRate)
    {
        $this->capacity = $capacity;    //漏斗容量
        $this->leakingRate = $leakingRate;//漏斗流水速率
        $this->leftQuote = $capacity; //漏斗剩余空间
        $this->leakingTs = time(); //上一次漏水时间
    }

    /**
     * 漏斗算法的核心
     * 其在每次灌水前都会被调用以触发漏水,给漏斗腾出空间来。
     * 能腾出多少空间取决于过去了多久以及流水的速率。
     * Funnel 对象占据的空间大小不再和行为的频率成正比,它的空间占用是一个常量。
     */
    public function makeSpace()
    {
        // 500毫秒
        usleep(1000*1000);
        $now = time();
        //距离上一次漏水过去了多久
        $deltaTs = $now-$this->leakingTs;
        //可腾出的空间 = 上次时间 * 流水速率
        $deltaQuota = $deltaTs * $this->leakingRate;
        var_dump('deltaQuota ' . $deltaQuota);
        if($deltaQuota < 1) {
            return;
        }
        $this->leftQuote += $deltaQuota;   //增加剩余空间
        $this->leakingTs = time();         //记录漏水时间
        if($this->leftQuote > $this->capacity){
            $this->leftQuote - $this->capacity;
        }
    }

    /**
     * @param $quota 漏桶的空间
     * @return bool
     */
    public function watering($quota=1)
    {
        $this->makeSpace(); //漏水操作
        if($this->leftQuote >= $quota) {
            $this->leftQuote -= $quota;
            return true;
        }
        return false;
    }
}


function isActionAllowed($userId, $action, $capacity, $leakingRate)
{
    static $funnels = [];
    $key = sprintf("%s:%s", $userId, $action);
    $funnel = $funnels[$key] ?? '';
    if (!$funnel) {
        $funnel  = new Funnel($capacity, $leakingRate);
        $funnels[$key] = $funnel;
    }
    return $funnel->watering();
}

for ($i=0; $i<20; $i++){
    var_dump(isActionAllowed("110", "reply", 5, 0.5));
}

Use redis leaky bucket plugin

Redis 4.0 provides a current limiting Redis module called redis-cell. The module also uses the funnel algorithm and provides atomic current limiting instructions. With this module, the current limiting problem is very simple.

The module has only one instruction cl.throttle , and its parameters and return values are slightly complicated. Next, let's take a look at how to use this instruction.

> cl.throttle key 15(漏斗容量) 30  60(30 operations / 60 seconds 这是漏水速率) 1(need 1 quota,可选参数默认是1)

The frequency is up to 30 times per 60s (water leakage rate), and the initial capacity of the funnel is 15, which means that you can reply to 15 posts continuously at the beginning, and then it starts to be affected by the water leakage rate. We see that the water leakage rate in this instruction has become two parameters, replacing the previous single floating point number. The result of dividing the two parameters to express the leakage rate is more intuitive than a single floating point number.

> cl.throttle key:reply 15 30 60
1) (integer) 0   # 0 表示允许,1表示拒绝
2) (integer) 15  # 漏斗容量capacity
3) (integer) 14  # 漏斗剩余空间left_quota
4) (integer) -1  # 如果拒绝了,需要多长时间后再试(漏斗有空间了,单位秒)
5) (integer) 2   # 多长时间后,漏斗完全空出来(left_quota==capacity,单位秒)

Token bucket current limit

The Token Bucket algorithm (Token Bucket) and the Leaky Bucket algorithm have the same effect but the opposite direction, which is easier to understand. As time goes by, the system will go to the bucket at a constant 1/QPS time interval (if QPS=100, the interval is 10ms) Add Token to it (imagine that there is a faucet that is constantly adding water to the leak). If the bucket is full, no more will be added. When a new request comes, one Token will be taken away, and if there is no Token available, it will be blocked. Or denial of service.

Another advantage of the token bucket is that it can easily change the speed. Once the rate needs to be increased, the rate of tokens put into the bucket will be increased as needed. Generally, a certain number of tokens will be added to the bucket regularly (for example, 100 milliseconds) , Some variant algorithms calculate the number of tokens that should be increased in real time.

For specific implementation, please refer to PHP to implement flow control based on redis using token bucket algorithm

<?php

class TrafficShaper
{
    private $_config; // redis设定
    private $_redis;  // redis对象
    private $_queue;  // 令牌桶
    private $_max;    // 最大令牌数

    /**
     * 初始化
     * @param Array $config redis连接设定
     */
    public function __construct($config , $queue , $max)
    {
        $this->_config = $config;
        $this->_queue = $queue;
        $this->_max = $max;
        $this->_redis = $this->connect();
    }

    /**
     * 加入令牌
     * @param Int $num 加入的令牌数量
     * @return Int 加入的数量
     */
    public function add($num = 0)
    {
        // 当前剩余令牌数
        $curnum = intval($this->_redis->lSize($this->_queue));
        // 最大令牌数
        $maxnum = intval($this->_max);
        // 计算最大可加入的令牌数量,不能超过最大令牌数
        $num = $maxnum >= $curnum + $num ? $num : $maxnum - $curnum;
        // 加入令牌
        if ($num > 0) {
            $token = array_fill(0 , $num , 1);
            $this->_redis->lPush($this->_queue , ...$token);
            return $num;
        }
        return 0;
    }

    /**
     * 获取令牌
     * @return Boolean
     */
    public function get()
    {
        return $this->_redis->rPop($this->_queue) ? true : false;
    }

    /**
     * 重设令牌桶,填满令牌
     */
    public function reset()
    {
        $this->_redis->delete($this->_queue);
        $this->add($this->_max);
    }

    private function connect()
    {
        try {
            $redis = new Redis();
            $redis->connect($this->_config['host'] , $this->_config['port'] , $this->_config['timeout'] , $this->_config['reserved'] , $this->_config['retry_interval']);
            if (empty($this->_config['auth'])) {
                $redis->auth($this->_config['auth']);
            }
            $redis->select($this->_config['index']);
        } catch (\RedisException $e) {
            throw new Exception($e->getMessage());
            return false;
        }
        return $redis;
    }
}

$config = [
    'host'           => '172.28.3.157' ,
    'port'           => 6379 ,
    'index'          => 0 ,
    'auth'           => '' ,
    'timeout'        => 1 ,
    'reserved'       => NULL ,
    'retry_interval' => 100 ,
];
// 令牌桶容器
$queue = 'mycontainer';
// 最大令牌数
$max = 5;
// 创建TrafficShaper对象
$oTrafficShaper = new TrafficShaper($config , $queue , $max);
// 重设令牌桶,填满令牌
$oTrafficShaper->reset();
// 循环获取令牌,令牌桶内只有5个令牌,因此最后3次获取失败
for ($i = 0;$i < 8;$i ++) {
    var_dump($oTrafficShaper->get());
}
// 加入10个令牌,最大令牌为5,因此只能加入5个
// 这里可以使用异步脚本来添加令牌。
$add_num = $oTrafficShaper->add(10);
var_dump($add_num);


// 循环获取令牌,令牌桶内只有5个令牌,因此最后1次获取失败
for ($i = 0;$i < 6;$i ++) {
    var_dump($oTrafficShaper->get());
}
[info] Analyze the counter using lua script, the simplest and most convenient.

菜问
625 声望132 粉丝

10年后端开发,常用编程语言PHP,java,golang,python。