跳到内容

速率限制器

编辑此页

“速率限制器”控制着某个事件(例如 HTTP 请求或登录尝试)允许发生的频率。速率限制通常用作一种防御措施,以保护服务免受过度使用(有意或无意)并维持其可用性。它对于控制你的内部或出站流程也很有用(例如,限制同时处理的消息数量)。

Symfony 在内置功能(如 登录节流)中使用这些速率限制器,后者限制用户在给定时间内可以进行的失败登录尝试次数,但你也可以将它们用于你自己的功能。

危险

根据定义,Symfony 速率限制器需要 Symfony 在 PHP 进程中启动。这使得它们对于防御 DoS 攻击 没有用处。此类保护措施必须消耗尽可能少的资源。考虑使用 Apache mod_ratelimitNGINX 速率限制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 个请求,但前提是它们均匀分布。

令牌桶速率限制器

此技术实现了 令牌桶算法,该算法定义了持续更新资源使用预算。它大致像这样工作

  1. 创建一个桶,其中包含一组初始令牌;
  2. 以预定义的频率(例如,每秒)向桶中添加一个新令牌;
  3. 允许一个事件会消耗一个或多个令牌;
  4. 如果桶中仍然包含令牌,则允许该事件;否则,拒绝该事件;
  5. 如果桶已满,则丢弃新令牌。

下图显示了一个大小为 4 的令牌桶,以每 15 分钟 1 个令牌的速率填充

此算法处理更复杂的退避突发管理。例如,它可以允许用户尝试密码 5 次,然后每 15 分钟只允许 1 次(除非用户等待 75 分钟,他们将再次被允许尝试 5 次)。

安装

在首次使用速率限制器之前,运行以下命令以在你的应用程序中安装相关的 Symfony 组件

1
$ composer require symfony/rate-limiter

配置

以下示例为 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 seconds10 hours1 day 等)

anonymous_api 限制器中,在发出第一个 HTTP 请求后,你可以在接下来的 60 分钟内发出最多 100 个请求。在那之后,计数器重置,你在接下来的 60 分钟内又有 100 个请求。

authenticated_api 限制器中,在发出第一个 HTTP 请求后,你总共可以发出最多 5,000 个 HTTP 请求,并且这个数字以每 15 分钟 500 个请求的速度增长。如果你没有发出那么多请求,未使用的请求不会累积(limit 选项阻止该数字高于 5,000)。

提示

所有速率限制器都使用 rate_limiter 标签进行标记,因此你可以使用 标记迭代器定位器 找到它们。

7.1

自动添加 rate_limiter 标签是在 Symfony 7.1 中引入的。

速率限制实战

在安装和配置速率限制器之后,将其注入到任何服务或控制器中,并调用 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
本作品,包括代码示例,根据 Creative Commons BY-SA 3.0 许可获得许可。
TOC
    版本