速率限制器
“速率限制器”控制着某个事件(例如 HTTP 请求或登录尝试)允许发生的频率。速率限制通常用作一种防御措施,以保护服务免受过度使用(有意或无意)并维持其可用性。它对于控制你的内部或出站流程也很有用(例如,限制同时处理的消息数量)。
Symfony 在内置功能(如 登录节流)中使用这些速率限制器,后者限制用户在给定时间内可以进行的失败登录尝试次数,但你也可以将它们用于你自己的功能。
危险
根据定义,Symfony 速率限制器需要 Symfony 在 PHP 进程中启动。这使得它们对于防御 DoS 攻击 没有用处。此类保护措施必须消耗尽可能少的资源。考虑使用 Apache mod_ratelimit、NGINX 速率限制、Caddy HTTP 速率限制模块(FrankenPHP 也支持)或代理(如 AWS 或 Cloudflare)来防止你的服务器被压垮。
速率限制策略
Symfony 的速率限制器实现了最常见的策略来强制执行速率限制:固定窗口、滑动窗口、令牌桶。
固定窗口速率限制器
这是最简单的技术,它基于为给定的时间间隔设置限制(例如,每小时 5,000 个请求或每 15 分钟 3 次登录尝试)。
在下图中,限制设置为“每小时 5 个令牌”。每个窗口从第一次命中开始(即 10:15、11:30 和 12:30)。一旦在一个窗口中有 5 次命中(蓝色方块),所有其他命中将被拒绝(红色方块)。
其主要缺点是资源使用在时间上分布不均匀,并且可能会在窗口边缘使服务器过载。在此示例中,在 11:00 到 12:00 之间接受了 6 个请求。
对于更大的限制,这更加显著。例如,对于每小时 5,000 个请求,用户可以在某个小时的最后一分钟发出 4,999 个请求,并在下一个小时的第一分钟发出另外 5,000 个请求,在两分钟内总共发出 9,999 个请求,并可能使服务器过载。这些过度使用的时间段称为“突发”。
滑动窗口速率限制器
滑动窗口算法是旨在减少突发的固定窗口算法的替代方案。这是与上面相同的示例,但随后使用在时间轴上滑动的 1 小时窗口
正如你所看到的,这消除了窗口的边缘,并将阻止 11:45 的第 6 个请求。
为了实现这一点,速率限制是基于当前窗口和前一个窗口近似计算的。
例如:限制是每小时 5,000 个请求;用户在上一个小时发出了 4,000 个请求,在这个小时发出了 500 个请求。当前小时的 15 分钟(窗口的 25%)命中计数将计算为:75% * 4,000 + 500 = 3,500。此时,用户只能再发出 1,500 个请求。
数学计算表明,最后一个窗口越接近,最后一个窗口的命中计数对当前限制的影响就越大。这将确保用户每小时可以发出 5,000 个请求,但前提是它们均匀分布。
配置
以下示例为 API 服务创建了两个不同的速率限制器,以强制执行不同的服务级别(免费或付费)
1 2 3 4 5 6 7 8 9 10 11 12
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
anonymous_api:
# use 'sliding_window' if you prefer that policy
policy: 'fixed_window'
limit: 100
interval: '60 minutes'
authenticated_api:
policy: 'token_bucket'
limit: 5000
rate: { interval: '15 minutes', amount: 500 }
注意
interval
选项的值必须是一个数字,后跟 PHP 日期相对格式 接受的任何单位(例如 3 seconds
、10 hours
、1 day
等)
在 anonymous_api
限制器中,在发出第一个 HTTP 请求后,你可以在接下来的 60 分钟内发出最多 100 个请求。在那之后,计数器重置,你在接下来的 60 分钟内又有 100 个请求。
在 authenticated_api
限制器中,在发出第一个 HTTP 请求后,你总共可以发出最多 5,000 个 HTTP 请求,并且这个数字以每 15 分钟 500 个请求的速度增长。如果你没有发出那么多请求,未使用的请求不会累积(limit
选项阻止该数字高于 5,000)。
速率限制实战
在安装和配置速率限制器之后,将其注入到任何服务或控制器中,并调用 consume()
方法来尝试消耗给定数量的令牌。例如,此控制器使用之前的速率限制器来控制对 API 的请求数量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
// src/Controller/ApiController.php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactory;
class ApiController extends AbstractController
{
// if you're using service autowiring, the variable name must be:
// "rate limiter name" (in camelCase) + "Limiter" suffix
public function index(Request $request, RateLimiterFactory $anonymousApiLimiter): Response
{
// create a limiter based on a unique identifier of the client
// (e.g. the client's IP address, a username/email, an API key, etc.)
$limiter = $anonymousApiLimiter->create($request->getClientIp());
// the argument of consume() is the number of tokens to consume
// and returns an object of type Limit
if (false === $limiter->consume(1)->isAccepted()) {
throw new TooManyRequestsHttpException();
}
// you can also use the ensureAccepted() method - which throws a
// RateLimitExceededException if the limit has been reached
// $limiter->consume(1)->ensureAccepted();
// to reset the counter
// $limiter->reset();
// ...
}
}
注意
在实际应用中,与其在所有 API 控制器方法中检查速率限制器,不如为 kernel.request 事件 创建一个 事件监听器或订阅器,并为所有请求检查一次速率限制器。
等待令牌可用
当达到限制时,你可能希望等待直到新令牌可用,而不是丢弃请求或进程。这可以使用 reserve()
方法来实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
// src/Controller/ApiController.php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactory;
class ApiController extends AbstractController
{
public function registerUser(Request $request, RateLimiterFactory $authenticatedApiLimiter): Response
{
$apiKey = $request->headers->get('apikey');
$limiter = $authenticatedApiLimiter->create($apiKey);
// this blocks the application until the given number of tokens can be consumed
$limiter->reserve(1)->wait();
// optional, pass a maximum wait time (in seconds), a MaxWaitDurationExceededException
// is thrown if the process has to wait longer. E.g. to wait at most 20 seconds:
//$limiter->reserve(1, 20)->wait();
// ...
}
// ...
}
reserve()
方法能够在将来预留令牌。仅当你计划等待时才使用此方法,否则你将通过预留未使用的令牌来阻止其他进程。
注意
并非所有策略都允许在将来预留令牌。当调用 reserve()
时,这些策略可能会抛出 ReserveNotSupportedException
。
在这些情况下,你可以将 consume()
与 wait()
一起使用,但是不能保证在等待之后令牌可用
1 2 3 4 5
// ...
do {
$limit = $limiter->consume(1);
$limit->wait();
} while (!$limit->isAccepted());
公开速率限制器状态
当在 API 中使用速率限制器时,通常在响应中包含一些标准的 HTTP 标头,以公开限制状态(例如,剩余令牌、新令牌何时可用等)。
使用 consume()
方法返回的 RateLimit 对象(也可以通过 reserve()
方法返回的 Reservation 对象的 getRateLimit()
方法获得)来获取这些 HTTP 标头的值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
// src/Controller/ApiController.php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactory;
class ApiController extends AbstractController
{
public function index(Request $request, RateLimiterFactory $anonymousApiLimiter): Response
{
$limiter = $anonymousApiLimiter->create($request->getClientIp());
$limit = $limiter->consume();
$headers = [
'X-RateLimit-Remaining' => $limit->getRemainingTokens(),
'X-RateLimit-Retry-After' => $limit->getRetryAfter()->getTimestamp() - time(),
'X-RateLimit-Limit' => $limit->getLimit(),
];
if (false === $limit->isAccepted()) {
return new Response(null, Response::HTTP_TOO_MANY_REQUESTS, $headers);
}
// ...
$response = new Response('...');
$response->headers->add($headers);
return $response;
}
}
存储速率限制器状态
所有速率限制器策略都需要存储它们的状态(例如,当前时间窗口中已发出的命中次数)。默认情况下,所有限制器都使用使用 Cache 组件 创建的 cache.rate_limiter
缓存池。这意味着每次清除缓存时,速率限制器都会被重置。
你可以使用 cache_pool
选项来覆盖特定限制器使用的缓存(甚至 为其创建一个新的缓存池)
1 2 3 4 5 6 7 8
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
anonymous_api:
# ...
# use the "cache.anonymous_rate_limiter" cache pool
cache_pool: 'cache.anonymous_rate_limiter'
注意
除了使用 Cache 组件之外,你还可以实现自定义存储。创建一个实现 StorageInterface 的 PHP 类,并使用每个限制器的 storage_service
设置为此类的服务 ID。
使用锁来防止竞争条件
当多个并发请求使用相同的速率限制器时(例如,一家公司的三台服务器同时访问你的 API),可能会发生 竞争条件。速率限制器使用 锁 来保护其操作免受这些竞争条件的影响。
默认情况下,Symfony 使用 framework.lock
配置的全局锁,但是你可以通过 lock_factory
选项(或根本不使用)使用特定的 命名锁
1 2 3 4 5 6 7 8 9 10 11
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
anonymous_api:
# ...
# use the "lock.rate_limiter.factory" for this limiter
lock_factory: 'lock.rate_limiter.factory'
# or don't use any lock mechanism
lock_factory: null