速率限制器
「速率限制器」控制允許事件(例如 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