限流算法
计数器
判断有限时间内的数量是否超过限制上线
<?php
date_default_timezone_set('PRC');
class limit{
const BMH_LIMITING_TIME = 'bmh_limiting_time';//限流时间段
const BMH_LIMITING_NUM = 'bmh_limiting_num';//限流时间段限流次数
const BMH_LIMITING_KEY = 'bmh_limiting_key';//限流时间段限流key
public static function getLimiterBMH(){
/*
* 设置单位时间段内访问的频次
* 默认设置时间为3秒
* 默认设置次数为3000
* 进来是否有设置的限流的key,有的话说明存在时间控制和次数值,判断次数值和限流次数大小
* 如果没有说明,第一次进来,这是要设置次数和时间,并次数减1
*/
//获取控制时间段
$limit_args['detail_id'] = self::BMH_LIMITING_TIME;
$time_limit = Promo::getInventory($limit_args)['num'];
$time_limit = $time_limit ? $time_limit : 3;
//获取控制的次数
$limit_options['detail_id'] = self::BMH_LIMITING_NUM;
$limit_num = Promo::getInventory($limit_options)['num'];
$limit_num = $limit_num ? $limit_num : 3000;
//获取当前是否有限制的key
$limit_params['detail_id'] = self::BMH_LIMITING_KEY;
$limit_key = Promo::getInventory($limit_params);
if(!isset($limit_key['num']) && 0 == $limit_key['data']){
$params['detail_id'] = self::BMH_LIMITING_KEY;
$params['num'] = $limit_num -1;
$params['time'] = $time_limit;
$result = Promo::setNXInventory($params);
if($result){
return true;
}else{
self::$_code = '9999';
self::$_msg = '当前办理人数较多,请稍后再试!';
return false;
}
}else{
//说明存在key
if($limit_key['num'] >= 1){
$params['detail_id'] = self::BMH_LIMITING_KEY;
$params['num'] = 1;
$result1 = Promo::decrInventory($params);
if($result1){
return true;
}else{
self::$_code = '9999';
self::$_msg = '当前办理人数较多,请稍后再试!';
return false;
}
}else{
self::$_code = '9999';
self::$_msg = '当前办理人数较多,请稍后再试!';
return false;
}
}
}
}
有问题,并发上来,一直就允许5个,但是利用 pipeline 保证了各个client之间的原子性
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;
}
漏斗算法
漏桶(Leaky Bucket)算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率.示意图如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OHoOeaWs-1579075258180)(redis异步队列&延时队列.assets/640.webp)]
<?php
class Funnel {
private $capacity;
private $waterRate;
private $freeSpace;
private $lastLeaking;
public function __construct($capacity, $waterRate)
{
$this->capacity = $capacity; //漏斗容量
$this->waterRate = $waterRate;//漏斗流水速率
$this->freeSpace = $capacity; //漏斗剩余空间
$this->lastLeaking = time(); //上一次漏水时间
}
public function makeSpace()
{
$now = time();
$deltaTs = $now-$this->lastLeaking; //距离上一次漏水过去了多久
$deltaQuota = $deltaTs * $this->waterRate; //可腾出的空间
if($deltaQuota < 1) {
return;
}
$this->freeSpace += $deltaQuota; //增加剩余空间
$this->lastLeaking = time(); //记录漏水时间
if($this->leftQuota > $this->capacaty){
$this->freeSpace - $this->capacity;
}
}
public function watering($quota)
{
$this->makeSpace(); //漏水操作
if($this->freeSpace >= $quota) {
$this->freeSpace -= $quota;
return true;
}
return false;
}
}
$funnels = [];
global $funnel;
function isActionAllowed($userId, $action, $capacity, $waterRate)
{
$key = sprintf("%s:%s", $userId, $action);
$funnel = $GLOBALS['funnel'][$key] ?? '';
if (!$funnel) {
$funnel = new Funnel($capacity, $waterRate);
$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,其参数和返回值比较复杂。
漏斗算法
初始化漏斗数量,以一定速率往漏斗加水,一定速率谁流出
安装下载
https://github.com/brandur/redis-cell/releases
安装
进入 redis-cli,执行命令
module load /path/to/libredis_cell.so;
命令
CL.THROTTLE test 100 400 60 3
▲ ▲ ▲ ▲ ▲
| | | | └───── apply 1 token (default if omitted)
| | └──┴─────── 30 tokens / 60 seconds
| └───────────── 15 max_burst
└─────────────────── key "user123"
- test 就是key
- 100 官方叫
max_burst
,没理解什么意思,其值为令牌桶的容量 - 1, 首次执行时令牌桶会默认填满 - 400: 与下一个参数一起,表示在指定时间窗口内允许访问的次数
- 60: 指定的时间窗口,单位:秒
- 3: 表示本次要申请的令牌数,不写则默认为 1
以上命令表示从一个初始值为100的漏斗中流出速度为3,该漏斗的加水的速率限制为400次/60秒。
返回值说明
127.0.0.1:6379> CL.THROTTLE test 100 400 60 3
1) (integer) 0
2) (integer) 101
3) (integer) 98
4) (integer) -1
5) (integer) 0
- 是否成功,0 成功 1:拒绝
2) 漏斗的初始水量 +1
3)当前 漏斗中的剩余水量
4)若请求被拒绝,这个值表示多久后漏斗中会有水量,单位 秒可作为尝试时间
5)表示多久漏斗中水量会满
示例
127.0.0.1:6379> CL.THROTTLE test1 10 5 60 3
1) (integer) 0
2) (integer) 11
3) (integer) 8
4) (integer) -1
5) (integer) 36
127.0.0.1:6379> CL.THROTTLE test1 10 5 60 3
1) (integer) 0
2) (integer) 11
3) (integer) 5
4) (integer) -1
5) (integer) 71
127.0.0.1:6379> CL.THROTTLE test1 10 5 60 3
1) (integer) 0
2) (integer) 11
3) (integer) 2
4) (integer) -1
5) (integer) 106
127.0.0.1:6379> CL.THROTTLE test1 10 5 60 3
1) (integer) 1
2) (integer) 11
3) (integer) 2
4) (integer) 10
5) (integer) 106
令牌桶
令牌桶算法(Token Bucket)和 Leaky Bucket 效果一样但方向相反的算法,更加容易理解.随着时间流逝,系统会按恒定1/QPS时间间隔(如果QPS=100,则间隔是10ms)往桶里加入Token(想象和漏洞漏水相反,有个水龙头在不断的加水),如果桶已经满了就不再加了.新请求来临时,会各自拿走一个Token,如果没有Token可拿了就阻塞或者拒绝服务.
令牌桶的另外一个好处是可以方便的改变速度. 一旦需要提高速率,则按需提高放入桶中的令牌的速率. 一般会定时(比如100毫秒)往桶中增加一定数量的令牌, 有些变种算法则实时的计算应该增加的令牌的数量.
<?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());
}