月半談(二)基於 Redis 的限流器-簡單計數器

漫天風雨下西樓發表於2021-02-01

說到限流器,大家可能腦袋中浮現的就是三種方案,計數器-基於redis zset 結構的滑動視窗,思路類似但實現相反的漏斗與令牌桶 演算法。

我先說需求,就是實現類似於微信公眾號每日2000的獲取 access_token 的呼叫限制,無需統計與清空~

當然這種需求,第一反應就是 加個計時器,到 2000 的時候我就不讓呼叫了,基於這種樸質的觀念,就有了以下的簡單實現

class TestLimit
{
    private $redis;

    public function __construct()
    {
        $this->redis = new Redis();
        $this->redis->connect("192.168.0.111", 16379, 5, null, 3, 5);
    }

    function testTimesLimit(string $user, string $resource, int $maxCount)
    {
        $key = "{$user}:{$resource}";

        $expiredTime = strtotime(date('Y-m-d 23:59:59')) - time();
        $expiredTime = $expiredTime > 0 ? $expiredTime : 1;

        // 不存在時去設定
        if($this->redis->set($key, 1, ['NX', 'EX' => $expiredTime])){
            return true;
        }

        $limitTimes = (int)$this->redis->get($key);
        if ($limitTimes < $maxCount) {
            $this->redis->incr($key);
            return true;
        }
        return false;
    }
}

$obj = new  TestLimit();

$success = 0;
$err     = 0;
// 單程式測試
for ($i = 0; $i < 20; $i++) {
    $obj->TestTimesLimit("test_man", "test_resource", 10) ? $success++ : $err++;
}
echo "success{$success} times; fail {$err} times" . PHP_EOL;

看上去好像沒問題對吧,但是看過我上篇文章的朋友都知道,這樣寫在併發請求下絕對會超出限制,但是,你用 單程式測不出來,我們開多程式試試?

// 多程式測試
if (extension_loaded("pcntl")) {

    $pids = [];// 父級程式 Id
    // 建立 20個程式,同時去 跑併發測試
    for ($i = 0; $i < 20; $i++) {

        $pid = pcntl_fork();

        if ($pid === -1) {
            echo "failed to fork!" . PHP_EOL;
            exit;
        }

        if ($pid) {
            $pids[] = $pid;
        } else {
            $obj     = new  TestLimit();
            $success = 0;
            $err     = 0;
            // 子程式測試
            for ($t = 0; $t < 20; $t++) {
                $obj->TestTimesLimit("test_man", "test_resource", 150) ? $success++ : $err++;
            }
            $pid = posix_getpid();
            echo microtime(true) . "cid {$pid} " . "success {$success} times; fail {$err} times" . PHP_EOL;

            exit(); // 執行完要結束,不然就會走進建立子程式的死迴圈
        }
    }

    foreach ($pids as $pid) {
        pcntl_waitpid($pid, $status);// 等子程式測試完畢
    }

}

根據上圖的顯示 我們 花了大約230毫秒 跑完了 整個測試,結果就是 150的 限制 被輕易突破了。

併發那就加?吧

調整過後的程式碼如下

class TestLimit
{
    private $redis;

    public function __construct()
    {
        $this->redis = new Redis();
        $this->redis->connect("192.168.0.111", 16379, 5, null, 3, 5);
    }

    function testTimesLimit(string $user, string $resource, int $maxCount)
    {
        $key = "{$user}:{$resource}";

        $expiredTime = strtotime(date('Y-m-d 23:59:59')) - time();
        $expiredTime = $expiredTime > 0 ? $expiredTime : 1;

        // 不存在時去設定
        if ($this->redis->set($key, 1, ['NX', 'EX' => $expiredTime])) {
            return true;
        }

        $lockKey         = "testTimesLimit";
        $lockUniqueValue = time() . mt_rand(100000, 999999);
        try {
            if ($this->lock($lockKey, $lockUniqueValue,3,100)) {
                $limitTimes = (int)$this->redis->get($key);
                if ($limitTimes < $maxCount) {
                    $this->redis->incr($key);
                    return true;
                }
                return false;
            }
            return false;
        } catch (\Throwable $throwable) {
//             log exception info ...
            return false;
        } finally {
            $this->unLock($lockKey, $lockUniqueValue);
        }
    }

    /**
     * redis 鎖
     *
     * @param $key
     * @param $uniqueValue
     * @param int $times    嘗試獲取?的次數
     * @param int $time     每多少ms去獲取一次?
     * @return bool
     */
    function lock($key, $uniqueValue, $times = 3, $time = 100): bool
    {
        while ($times > 0) {
            if ($this->redis->set($key, $uniqueValue, ['NX', 'EX' => 10])) {
                return true;
            }
            $times--;
            usleep(1000 * $time); // $time ms 後繼續 嘗試獲取鎖
        }
        return false;
    }

    /**
     * redis 解鎖
     *
     * @param $key
     * @param $uniqueValue
     * @return bool
     */
    function unLock($key, $uniqueValue): bool
    {
        $script = 'if redis.call("get",KEYS[1]) == ARGV[1]
                  then
                      return redis.call("del",KEYS[1])
                  else
                      return 0
                  end';
        return $this->redis->eval($script, [$key, $uniqueValue], 1) ? true:false;
    }

}

加鎖之後,我們再進行嘗試,

雖然確實是沒有超出限制,但是這個效率 1480ms 差距差不多有10倍,而且後續的程式因為重試時間獲取到?的機率也很不均衡,很顯然這是因為?的嘗試次數跟嘗試時間設定不合理導致的,當我們將嘗試次數設定為1,嘗試時間設定為30ms 時候 耗時320ms ,效率對比之前 已經有了很大的提升。

以上就是關於計數器更為簡易版本的實現,當然簡易也不簡單,控制加鎖之後的嘗試時間跟嘗試次數可以將突發流量轉成平滑流量,?保證了資料精確度的需要。對時間上進行調整,其實我們這個也可以設定成滑動視窗的模式,可能粒度上會更大一些,對比與zset 每次都要插入與刪除不在視窗內的資料,這些也都能接受。

當然我們今天不詳細的對比另外三種,下次碰到有需要的時候,再進行對比。因為拖了一天,本週再寫一篇新的文章,內容大概是關於 trace 的設計~

這是月半談的第二篇,定時更新確實有點難,有收穫的話,請? 支援下吧~

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章