說到限流器,大家可能腦袋中浮現的就是三種方案,計數器-基於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 協議》,轉載必須註明作者和本文連結