一、前言
公司因为战略问题将钉钉办公软件切换成飞书,对接飞书接口实现接口秒级限流。协助职能组开发,之前历史使用的Laravel框架和Redis缓存,钉钉是分钟级限流,飞书是秒级限流,限流颗粒度更细,我们使用的是滑动窗口实现限流,用到的是Redis的list类型来处理。
二、准备工具
Laravel框架、Redis服务、jmete(5.4.1)并发测试工具和Java(1.8.0)运行环境。大家自行百度大巴安装教程。
三、代码实现
① 新建文件 laravel框架 app/Http/Middleware/SlidingWindowGrantMiddleware.php
<?php
namespace App\Http\Middleware;
use App\Models\InterfaceList;
use App\Models\SystemConfig;
use Closure;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class SlidingWindowGrantMiddleware
{
protected $limit = 50; //默认限制次数
protected $interval = 15; //窗口秒数 因为下游代码接收的http请求是延迟在处理,所以时间颗粒度不能太细建议10秒以上
private $interFaceId = 'id';
private $interFaceConcurrent = 'concurrent';
private $interFaceStatus = 'status';
/**
* 基于redis的滑动窗口限流
* @url https://caihongtengxu.github.io/2019/20190611/index.html
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
$systemKey = $request->header('systemKey',''); //获取对接系统名
if($systemKey){
if (Redis::hexists($systemKey, 'bing_limit')) {
$limit = (int)Redis::hget($systemKey, 'bing_limit'); //应用最大限制次数
$systemStatus = (int)Redis::hget($systemKey, 'status');//应用限制是否开启
$systemInterFaceId = Redis::hget($systemKey, 'interface_id');//单个接口id
} else {
$system = SystemConfig::where('system_key', $systemKey)->first(); //获取应用限流配置
if (!empty($system)){
$limit = $system->bing_limit;
$systemStatus = $system->status;
$systemInterFaceId = $system->interface_id;
Redis::hset($systemKey, 'bing_limit', $system->bing_limit); //不存在就缓存进redis
Redis::hset($systemKey, 'status', $system->status);
Redis::hset($systemKey, 'interface_id', $system->interface_id);
} else {
return response()->json(['code'=> 400,'msg'=> '系统未配置,请联系管理员']);
}
}
if($systemStatus == 0){
return response()->json(['code'=> 400,'msg'=> '系统已禁用,不可以调用']);
}
if(empty($systemInterFaceId)){
return response()->json(['code'=> 400,'msg'=> '系统未配置接口权限,请联系管理员']);
}
// 没有设置就没有限制
if (empty($limit)){
return $next($request);
}
$path = 'window:'. $systemKey;
$result = $this->slidingWindowGrant($path, $limit);
if (!$result) {
return response()->json(['code'=> 400,'msg'=> '系统繁忙,请稍后重试'], 500); //响应代码为了jmter断言
}
}
// 接口限流逻辑
//获取接口请求路径
$interface_url = $request->path();
//获取接口信息
$interFaceKey = str_replace('/', '_', $interface_url);
if (Redis::hexists($interFaceKey, $this->interFaceId)) {
$interFaceId = Redis::hget($interFaceKey, $this->interFaceId);
$interFaceConcurrent = (int)Redis::hget($interFaceKey, $this->interFaceConcurrent);
$interFaceStatus = Redis::hget($interFaceKey, $this->interFaceStatus);
} else {
$interface = InterfaceList::where('interface_url', $interface_url)->first();
if (!$interface) {
return response()->json(['code'=> 400,'msg'=> '接口未配置,请联系管理员']);
}
//从接口表中获取接口信息
$interFaceId = $interface->id;
$interFaceConcurrent = $interface->concurrent;
$interFaceStatus = $interface->status;
//Redis存储接口信息
Redis::hset($interFaceKey, $this->interFaceId, $interface->id);
Redis::hset($interFaceKey, $this->interFaceConcurrent, $interface->concurrent);
Redis::hset($interFaceKey, $this->interFaceStatus, $interface->status);
}
//接口状态
if($interFaceStatus == 0){
return response()->json(['code'=> 400,'msg'=> '接口已禁用,不可以调用']);
}
//没有设置就没有限制
if (empty($interFaceConcurrent)){
return $next($request);
}
//对接系统是否配置接口权限,免登流程接口不验证系统的权限
if(isset($systemInterFaceId)){
$ids =explode(',', $systemInterFaceId);
if(!in_array($interFaceId, $ids)){
return response()->json(['code'=> 400,'msg'=> '没有调用权限,请联系管理员']);
}
}
$questionLocation = md5($interface_url);
$path = 'window:'. $questionLocation;
$result = $this->slidingWindowGrant($path, $interFaceConcurrent);
if (!$result) {
return response()->json(['code'=> 401,'msg'=> '接口繁忙,请稍后重试'], 500);
}
return $next($request);
}
/**
* 限流鉴权-基于redis的list
*
* @param $url
* @return boolean
*/
public function slidingWindowGrant($url, $limit)
{
try {
$interval = $this->interval; //毫秒
$size = (int)Redis::lLen($url); //获取list长度
// $now = $this->getMicroSecond(); //使用这个时间会有个问题 1秒钟打大量http请求会导致fpm进程延迟处理 php执行时间滞后最后判断时间大于设置时间,导致队列被清空重新统计
$now = isset($_SERVER['REQUEST_TIME']) ? $_SERVER['REQUEST_TIME'] : time();
if (! $size) {
return $this->insertSlidingWindow($url, $now);
}
$startTime = (float)Redis::lindex($url, 0);
$check = $size;
// file_put_contents('yu.log',$size.'__'.date('s',$now) .'__'.date('s',time())."\n", FILE_APPEND);
while ($check > 0) {
if ($now >= $startTime + $interval) {
$startTime = (int)Redis::lpop($url); //移出队列
$check--;
continue;
} else {
break;
}
}
if ($now <= $startTime + $interval) {
if ($size < $limit) {
return $this->insertSlidingWindow($url, $now);
} else {
Log::info("SlidingWindowGrantMiddleware|{$size}|{$limit}|{$startTime}");
return false;
}
} else {
return $this->insertSlidingWindow($url, $now);
}
} catch (\Exception $exception) {
Log::info('SlidingWindowGrantMiddleware|'.date('Y-m-d H:i:s', time()).'|slidingWindowGrant()方法捕获异常'.'|'.
$exception->getFile().'|'.$exception->getCode().'|'.$exception->getMessage());
return false;
}
}
/**
* 初始化滑动窗口
*
* @param $redis
* @param $url
* @param $timestamp
* @return boolean
*/
public function insertSlidingWindow($url, $timestamp)
{
Redis::rPush($url, $timestamp);
Redis::expire($url, 30);
return true;
}
}
② 载入中间件配置 app/Http/Kernel.php
③ 在laravel对应的路由处使用即可
四、开始测试
① 打开jmter安装包运行 jmeter.bat
② 打开后切换中文
③ 添加线程组
④ 添加Http请求组件
按照图中的填写接口请求配置信息
请求体参数
⑤ 添加请求头组件
点击添加填入请求头信息
⑥添加响应断言组件
按照步骤配置响应状态码
⑦ 添加同步定时器 不用这个会导致循环发送请求不是并行发送了。注意添加后要拖动到线程组上面。
⑧ 添加结果报表
添加表格统计观察吞吐
⑨ 设置同步定时器的并发等待数要与线程组一致,不能就无限等待不能发起请求哦。
设置线程组线程数与同步定时器数量要一致哦。
⑩ 选择好观察报表运行
一直点击保存和同意
运行结果
限流成功,在一秒的100毫秒内60次请求过来了。
五、总结
如果要达到秒级限流必须要在web上游去做控制,这时应该采用Hyperf这种swoole运行的框架才能在流量入口时做拦截处理,因为我们这种是通过用户手动配置的应用限流和接口限流也是历史项目上来做增量开发。限流简单的也可以直接在nginx进行限流 参考我另一篇文章 。
刚开始使用的是PHP的time()函数生成计数key发现并发请求过来程序花了几十秒才接入处理导致秒级计数不断重置,所以使用了$_SERVER['REQUEST_TIME'] 来获取http请求接收处理时间,这个误差下降了70%左右,可以满足基本的限流了。