原文地址: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-AfterX-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的优势基本有减少网路开销原子操作复用等,在此就不再一一赘述了

总结

  1. laravel限流其实采用的是最简单的限流方法——计数器

  2. 一个完整的限流服务应该包含诸如:X-RateLimit-Limit 、X-RateLimit-Remaining等限流响应参数
  3. 没有最好的限流方法只有最合适的限流方法

转载请标注原文地址, 谢谢!

happy coding!