原文地址:Laravel源码解析系列——频率限制器throttle
限流顾名思义就是限制流量,主要是为了防止访问量超过预估流量,从而导致系统不能瘫痪或不能及时响应用户需求
laravel框架从5.2版本开始加入了throttle
频率限制器中间件实现限流服务
基本调用
通过middleware
方法调用,参数: throttle:{限制次数},{限制时间(分钟)},
Route::get('/', function () {
return view('welcome');
})->middleware('throttle:10,1'); # 同一个ip地址限制1分钟内只能请求首页10次
页面显示
请求成功
当页面请求成功时会看到响应头信息:
Status Code: 200 OK
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
请求拒绝
当页面请求被拒绝时会看到页面返回429 | Too Many Requests并且响应头信息
Status Code: 429 Too Many Requests
retry-after: 31
x-ratelimit-limit: 10
x-ratelimit-remaining: 0
x-ratelimit-reset: 1572849058
接下来让我们通过源码来了解laravel是如何实现限流服务throttle
源码解析
核心流程
接下来让我们来看看laravel
是如何实现的限流
首先当请求过来的时候会执行app/Http/kernel.php
文件下的限制器中间件throttle
protected $routeMiddleware = [
...
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
...
];
可以看到中间件的调用到了ThrottleRequests类(该类主要实现了限流服务的核心流程代码),文件位于vendor/laravel/framework/src/Illuminate/Routing/Middleware/ThrottleRequests.php
构造函数
当类被实例化时会执行__construct
构造函数,可以看到ThrottleRequests
实例化了一个RateLimiter对象(该对象主要是实现了限流服务的核心方法
)
/**
* Create a new request throttler.
*
* @param \Illuminate\Cache\RateLimiter $limiter
* @return void
*/
public function __construct(RateLimiter $limiter) # 实例化限制器对象
{
$this->limiter = $limiter;
}
执行入口
laravel所有的中间件执行入口函数都是从handle
开始, 参数:handle({请求信息}, {回调}, {最大请求限制次数}, {限制时间})
从下面代码可以看到throttle限流流程大致分为:①解析、②获取当前最大请求限制数、③校验、④计数、
⑤返回,接下来我们来看看每一个流程步骤究竟做了什么
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param int|string $maxAttempts
* @param float|int $decayMinutes
* @return \Symfony\Component\HttpFoundation\Response
*
* @throws \Illuminate\Http\Exceptions\ThrottleRequestsException
*/
public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1)
{
// ① 解析
$key = $this->resolveRequestSignature($request); # 解析url生成key
// ② 获取当前最大请求限制数
$maxAttempts = $this->resolveMaxAttempts($request, $maxAttempts); # 解析请求限制数
// ③ 校验
if ($this->limiter->tooManyAttempts($key, $maxAttempts)) { # 判断是否超过限制
throw $this->buildException($key, $maxAttempts); # 抛出异常响应 429
}
// ④ 计数
$this->limiter->hit($key, $decayMinutes * 60); # 计数
$response = $next($request);
// ⑤ 返回
return $this->addHeaders( # 添加响应头
$response, $maxAttempts,
$this->calculateRemainingAttempts($key, $maxAttempts) # 计算剩余限制次数
);
}
流程详解
①解析
调用resolveRequestSignature
方法,将域名与ip地址拼接并做hash加密生成特定key(作为缓存计数器的key使用)
/**
* Resolve request signature.
*
* @param \Illuminate\Http\Request $request
* @return string
*
* @throws \RuntimeException
*/
protected function resolveRequestSignature($request)
{
if ($user = $request->user()) {
return sha1($user->getAuthIdentifier()); # 针对用户key
}
if ($route = $request->route()) {
return sha1($route->getDomain().'|'.$request->ip()); # 取域名和ip地址做hash加密
}
throw new RuntimeException('Unable to generate the request signature. Route unavailable.');
}
②获取最大限制请求次数
调用resolveMaxAttempts
方法获取用户设置的最大请求次数(兼容处理其他限制形式),如在web.php
页面设置的throttle:10
/**
* Resolve the number of attempts if the user is authenticated or not.
*
* @param \Illuminate\Http\Request $request
* @param int|string $maxAttempts
* @return int
*/
protected function resolveMaxAttempts($request, $maxAttempts)
{
if (Str::contains($maxAttempts, '|')) {
$maxAttempts = explode('|', $maxAttempts, 2)[$request->user() ? 1 : 0];
}
if (! is_numeric($maxAttempts) && $request->user()) {
$maxAttempts = $request->user()->{$maxAttempts};
}
return (int) $maxAttempts;
}
③判断是否超过限制
当请求过来的时候校验是否满足条件;
调用构造函数实例化的限流对象limiter
方法tooManyAttempts({第一步生成的key}, {第二步获取到的最大限制次数})
/**
* Determine if the given key has been "accessed" too many times.
*
* @param string $key
* @param int $maxAttempts
* @return bool
*/
public function tooManyAttempts($key, $maxAttempts)
{
if ($this->attempts($key) >= $maxAttempts) { # 取缓存key判断是否大于限制值
if ($this->cache->has($key.':timer')) { # 检测key缓存计时器是否存在
return true;
}
$this->resetAttempts($key); # 移除key相当于重置
}
return false;
}
可以看到程序会带着key去缓存里面查询并判断请求是否已超过最大限制次数,如果超过则返回false,没有则返回true
PS: 值得注意的是其中的{$key}:timer
(该缓存保存key被销毁的最后时间戳),为什么会需要额外的缓存对象?请继续往下看
④计数
调用限流对象limiter
方法hit
,对缓存{key}
与计时器{key}:timer
,进行计数或重置并设置过期时间为$deccaySeconds
/**
* Increment the counter for a given key for a given decay time.
*
* @param string $key
* @param int $decaySeconds
* @return int
*/
public function hit($key, $decaySeconds = 60)
{
$this->cache->add(
$key.':timer', $this->availableAt($decaySeconds), $decaySeconds # 添加计时器并设置过期衰变时间
);
$added = $this->cache->add($key, 0, $decaySeconds); # 添加key并设置过期衰变时间
$hits = (int) $this->cache->increment($key); # 计数器+1
if (! $added && $hits == 1) {
$this->cache->put($key, 1, $decaySeconds);
}
return $hits;
}
⑤返回
返回分为两种情况,一种请求限制通过,另一种请求限制拒绝
1. 请求限制拒绝
当第三步tooManyAttempts
返回false时则代表请求失败,此时会调用buildException
抛出请求拒绝429
状态码
/**
* Create a 'too many attempts' exception.
*
* @param string $key
* @param int $maxAttempts
* @return \Illuminate\Http\Exceptions\ThrottleRequestsException
*/
protected function buildException($key, $maxAttempts)
{
$retryAfter = $this->getTimeUntilNextRetry($key); # 计算多久秒后才可以再次请求
$headers = $this->getHeaders( # 添加头部信息
$maxAttempts,
$this->calculateRemainingAttempts($key, $maxAttempts, $retryAfter),
$retryAfter
);
return new ThrottleRequestsException(
'Too Many Attempts.', null, $headers
);
}
其中我们来看下字段$retryAfter
是如何计算的:
# 直接调用限制器对象方法availableIn
protected function getTimeUntilNextRetry($key)
{
return $this->limiter->availableIn($key);
}
# 位于/vendor/laravel/framework/src/Illuminate/Cache/RateLimiter.php
public function availableIn($key)
{
return $this->cache->get($key.':timer') - $this->currentTime(); # 获取计时器-当前时间
}
调用执行了方法$this->getTimeUntilNextRetry
,里面再调用实例RateLimiter的方法availableIn
,计算缓存计时器与当前时间的差值,在这里是不是突然明白了为什么需要{$key}:timer
缓存字段,其实就是为了获取几秒之后可再次请求Retry-After参数值
2.请求限制通过
当第三步tooManyAttempts
返回true时则代表请求成功,此时会直接调用addHeaders
添加头部信息
$this->limiter->hit($key, $decayMinutes * 60); # 计数
$response = $next($request); # 传递
return $this->addHeaders( # 添加响应头
$response, $maxAttempts,
$this->calculateRemainingAttempts($key, $maxAttempts) # 计算剩余限制次数
);
3.header信息
不难看出,不管次数校验是否通过都会将信息写入到header,让我们来看看限制器具体写入了头部信息
protected function addHeaders(Response $response, $maxAttempts, $remainingAttempts, $retryAfter = null)
{
$response->headers->add( # 计算获取响应值
$this->getHeaders($maxAttempts, $remainingAttempts, $retryAfter)
);
return $response;
}
结合请求的两种状态可以得知不管请求成功或者请求失败都会调用$this->getHeaders
方法返回的信息参数
/**
* Get the limit headers information.
*
* @param int $maxAttempts
* @param int $remainingAttempts
* @param int|null $retryAfter
* @return array
*/
protected function getHeaders($maxAttempts, $remainingAttempts, $retryAfter = null)
{
$headers = [
'X-RateLimit-Limit' => $maxAttempts,
'X-RateLimit-Remaining' => $remainingAttempts,
];
if (! is_null($retryAfter)) {
$headers['Retry-After'] = $retryAfter; # 几秒之后可再次请求
$headers['X-RateLimit-Reset'] = $this->availableAt($retryAfter); # 重置限制的最后时间戳
}
return $headers;
}
$retryAfter
是当限制拒绝情况下的参数,限制通过为null,并且可以看到往头部信息写入了以下参数:
- X-RateLimit-Limit 表示限制的最大请求数
- X-RateLimit-Remaining 表示剩余请求次数
- Retry-After 表示几秒之后可再次请求
- X-RateLimit-Reset 表示重置限制的最后时间戳
其中Retry-After
与X-RateLimit-Reset
当请求被限制时才会返回。
这些参数就是当我们访问页面时候查看network面板看到的Response Headers参数
扩展
上面的所有就是laravel
实现throttle
最最最核心的算法,与此同时laravel还提供了Redis特定限流,文件位于/vendor/laravel/framework/src/Illuminate/Routing/Middleware/ThrottleRequestsWithRedis.php(跟ThrottleRequest.php同级)
查看下构造函数__construct
直接实例化了一个Redis对象
,接着看执行入口handle
方法可以看到流程基本跟上面的一致,唯一的区别在于第三步判断是否超出限制$this->tooManyAttempts,此时调用的limiter限制器跟前面的不同(实例化DurationLimiter)
protected function tooManyAttempts($key, $maxAttempts, $decayMinutes)
{
$limiter = new DurationLimiter(
$this->redis, $key, $maxAttempts, $decayMinutes * 60
);
return tap(! $limiter->acquire(), function () use ($limiter) {
[$this->decaysAt, $this->remaining] = [
$limiter->decaysAt, $limiter->remaining,
];
});
}
其中$limiter->acquire()
函数里面调用到redis eval
方法指定执行lua脚本文件
public function acquire()
{
$results = $this->redis->eval(
$this->luaScript(), 1, $this->name, microtime(true), time(), $this->decay, $this->maxLocks
);
$this->decaysAt = $results[1];
$this->remaining = max(0, $results[2]);
return (bool) $results[0];
}
protected function luaScript()
{
return <<<'LUA'
local function reset()
redis.call('HMSET', KEYS[1], 'start', ARGV[2], 'end', ARGV[2] + ARGV[3], 'count', 1)
return redis.call('EXPIRE', KEYS[1], ARGV[3] * 2)
end
if redis.call('EXISTS', KEYS[1]) == 0 then
return {reset(), ARGV[2] + ARGV[3], ARGV[4] - 1}
end
if ARGV[1] >= redis.call('HGET', KEYS[1], 'start') and ARGV[1] <= redis.call('HGET', KEYS[1], 'end') then
return {
tonumber(redis.call('HINCRBY', KEYS[1], 'count', 1)) <= tonumber(ARGV[4]),
redis.call('HGET', KEYS[1], 'end'),
ARGV[4] - redis.call('HGET', KEYS[1], 'count')
}
end
return {reset(), ARGV[2] + ARGV[3], ARGV[4] - 1}
LUA;
}
核心区别在于前者使用缓存
(可以是文件缓、redis等其他方式)进行限流,而后者使用的是redis+lua
实现限流;使用lua嵌入redis的优势基本有减少网路开销、原子操作、复用等,在此就不再一一赘述了
总结
-
laravel限流其实采用的是最简单的限流方法——计数器
- 一个完整的限流服务应该包含诸如:X-RateLimit-Limit 、X-RateLimit-Remaining等限流响应参数
- 没有最好的限流方法只有最合适的限流方法
转载请标注原文地址, 谢谢!
happy coding!