跳到內容

速率限制器

編輯此頁面

「速率限制器」控制允許事件(例如 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 授權條款授權。
目錄
    版本