系列文章
在高併發場景下有三把利器保護系統:快取、降級、和限流。快取的目的是提升系統的訪問你速度和增大系統能處理的容量;降級是當服務出問題或影響到核心流程的效能則需要暫時遮蔽掉。而有些場景則需要限制併發請求量,如秒殺、搶購、發帖、評論、惡意爬蟲等。
限流演算法
常見的限流演算法有:計數器,漏桶、令牌桶。
計數器
顧名思義就是來一個記一個,然後判斷在有限時間視窗內的數量是否超過限制即可
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 * 1000); //移除時間視窗之前的行為記錄,剩下的都是時間視窗內的
$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, 5)); //執行可以發現只有前5次是透過的
}
//返回當前的毫秒時間戳
function msectime() {
list($msec, $sec) = explode(' ', microtime());
$msectime = (float)sprintf('%.0f', (floatval($msec) + floatval($sec)) * 1000);
return $msectime;
}
漏桶
漏桶(Leaky Bucket)演算法思路很簡單,水(請求)先進入到漏桶裡,漏桶以一定的速度出水(介面有響應速率),當水流入速度過大會直接溢位(訪問頻率超過介面響應速率),然後就拒絕請求,可以看出漏桶演算法能強行限制資料的傳輸速率.示意圖如下:
具體程式碼實現如下
<?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(); //上一次漏水時間
}
public function makeSpace()
{
$now = time();
$deltaTs = $now-$this->leakingTs; //距離上一次漏水過去了多久
$deltaQuota = $deltaTs * $this->leakingRate; //可騰出的空間
if($deltaQuota < 1) {
return;
}
$this->leftQuote += $deltaQuota; //增加剩餘空間
$this->leakingTs = time(); //記錄漏水時間
if($this->leftQuota > $this->capacaty){
$this->leftQuote = $this->capacity;
}
}
public function watering($quota)
{
$this->makeSpace(); //漏水操作
if($this->leftQuote >= $quota) {
$this->leftQuote -= $quota;
return true;
}
return false;
}
}
$funnels = [];
global $funnel;
function isActionAllowed($userId, $action, $capacity, $leakingRate)
{
$key = sprintf("%s:%s", $userId, $action);
$funnel = $GLOBALS['funnel'][$key] ?? '';
if (!$funnel) {
$funnel = new Funnel($capacity, $leakingRate);
$GLOBALS['funnel'][$key] = $funnel;
}
return $funnel->watering(1);
}
for ($i=0; $i<20; $i++){
var_dump(isActionAllowed("110", "reply", 15, 0.5)); //執行可以發現只有前15次是透過的
}
核心邏輯就是makeSpace,在每次灌水前呼叫以觸發漏水,給漏斗騰出空間。
funnels我們可以利用Redis中的hash結構來儲存對應欄位,灌水時將欄位取出進行邏輯運算後再存入hash結構中即可完成一次行為頻度的檢測。但這有個問題就是整個過程的原子性無法保證,意味著要用鎖來控制,但如果加鎖失敗,就要重試或者放棄,這回導致效能下降和影響使用者體驗,同時程式碼複雜度也升高了,此時Redis提供了一個外掛,Redis-Cell出現了。
Redis-Cell
Redis 4.0提供了一個限流Redis模組,名稱為redis-cell,該模組提供漏斗演算法,並提供原子的限流指令。
該模組只有一條指令cl.throttle,其引數和返回值比較複雜。
> cl.throttle tom:reply 14 30 60 1
1) (integer) 0 # 0表示允許,1表示拒絕
2) (integer) 15 # 漏斗容量capacity
3) (integer) 14 # 漏斗剩餘空間left_quota
4) (integer) -1 # 如果拒絕了,需要多長時間後再重試,單位秒
5) (integer) 2 # 多長時間後,漏斗完全空出來,單位秒
該指令意思為,允許使用者tom的reply行為的頻率為每60s最多30次,漏斗初始容量為15(因為是從0開始計數,到14為15個),預設每個行為佔據的空間為1(可選引數)。
如果被拒絕,取返回陣列的第四個值進行sleep即可作為重試時間,也可以非同步定時任務來重試。
令牌桶
令牌桶演算法(Token Bucket)和 Leaky Bucket 效果一樣但方向相反的演算法,更加容易理解.隨著時間流逝,系統會按恆定1/QPS時間間隔(如果QPS=100,則間隔是10ms)往桶裡加入Token(想象和漏洞漏水相反,有個水龍頭在不斷的加水),如果桶已經滿了就不再加了.新請求來臨時,會各自拿走一個Token,如果沒有Token可拿了就阻塞或者拒絕服務.
令牌桶的另外一個好處是可以方便的改變速度. 一旦需要提高速率,則按需提高放入桶中的令牌的速率. 一般會定時(比如100毫秒)往桶中增加一定數量的令牌, 有些變種演算法則實時的計算應該增加的令牌的數量.
具體實現可參考php 基於redis使用令牌桶演算法實現流量控制
<?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 = array(
'host' => 'localhost',
'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());
}
?>
本文亦在微信公眾號【小道資訊】釋出,歡迎掃碼關注!
本作品採用《CC 協議》,轉載必須註明作者和本文連結