Qiitaにログインして、便利な機能を使ってみませんか?

あなたにマッチした記事をお届けします

便利な情報をあとから読み返せます

0
0

記事を削除する

一度削除した記事は復旧できません。

この記事の編集中の下書きも削除されます。

削除してよろしいですか?

5 つのレートリミットアルゴリズムを同時に動かして比較する PHP サービスを作った

0
最終更新日 投稿日 2026年05月04日

なぜ作ったか

ほとんどのバックエンドはいずれレートリミットが必要になる。実際にはたいてい最初に見つけたアルゴリズム — だいたい固定ウィンドウ — をそのまま採用して、本番のバーストやクレームで欠点を知ることになる。「アルゴリズムの名前は知っている」と「実際のトラフィックに対してどう振る舞うか分かっている」の間にはかなりの距離がある。

Slim 4 で小さなサービスを作り、教科書に出てくる 5 つのアルゴリズムを同じ設定(10 リクエスト / 10 秒、またはバケット容量 10・1 トークン/秒リフィル)で同時に動かして、異なるエンドポイントに同じ curl ループを打ったときの判定の違いを観察できるようにした。/compare エンドポイントで N リクエストのシミュレーションを全アルゴリズム横並びで実行できる。

🔗 GitHub: https://github.com/sen-ltd/rate-limit-demo

Screenshot

5 つのアルゴリズム

エンドポイント アルゴリズム キーあたりメモリ
/fixed-window 固定ウィンドウカウンタ O(1)
/sliding-window-log タイムスタンプのスライディングログ O(limit)
/sliding-window-counter 2 バケット近似 O(1)
/token-bucket トークンバケット(遅延リフィル) O(1)
/leaky-bucket リーキーバケット O(1)

5 つすべてが同じインターフェースを共有:

interface LimiterInterface
{
    public function name(): string;
    public function check(string $key): Decision;
    public function peek(string $key): Decision;
    public function reset(?string $key = null): void;
    public function storedKeys(): int;
    public function exportState(): array;
    public function importState(array $state): void;
}

check() がミューテーション操作で、$key からのリクエストを許可するか判定し、試行を記録し、Decision を返す。peek() は読み取り専用の /compare 用。

設計: クロックを注入する

レートリミットは本質的に時間の問題。リミッタが microtime(true) を直接呼ぶとテストで実際にスリープが必要になり、遅くて不安定になる。全リミッタに Clock インターフェースを注入:

interface Clock
{
    public function now(): float;
}

テストでは FakeClock を注入して advance() で時間を進める。

固定ウィンドウ — 誰もが最初に選ぶやつ

壁時計を幅 $windowSeconds のウィンドウに分割し、ウィンドウごとにキー別のカウンタを持つ:

public function check(string $key): Decision
{
    $now    = $this->clock->now();
    $bucket = (int) floor($now / $this->windowSeconds);

    $entry = $this->state[$key] ?? ['bucket' => $bucket, 'count' => 0];
    if ($entry['bucket'] !== $bucket) {
        $entry = ['bucket' => $bucket, 'count' => 0];
    }

    $resetAt = (float) (($bucket + 1) * $this->windowSeconds);

    if ($entry['count'] >= $this->limit) {
        $this->state[$key] = $entry;
        return new Decision(false, $this->name(), $this->limit, 0, $resetAt, $entry['count']);
    }

    $entry['count']++;
    $this->state[$key] = $entry;
    return new Decision(true, $this->name(), $this->limit,
                        $this->limit - $entry['count'], $resetAt, $entry['count']);
}

キーあたり整数 1 つ。シンプルで安い。ただし有名な欠陥がある — ウィンドウ境界をまたいだクライアントは 2 × limit のリクエストを数百ミリ秒以内に通せる:

public function testEdgeBurstFlaw20RequestsIn10Seconds(): void
{
    $clock = new FakeClock(1_000_009.5); // 境界の 0.5 秒前
    $fw    = new FixedWindow($clock, 10, 10);

    $allowed = 0;
    for ($i = 0; $i < 10; $i++) {
        if ($fw->check('k')->allowed) $allowed++;
    }
    $clock->advance(0.6);
    for ($i = 0; $i < 10; $i++) {
        if ($fw->check('k')->allowed) $allowed++;
    }

    $this->assertSame(20, $allowed,
        'fixed window allows 20 requests inside a 10s span at the boundary');
}

10 req / 10 s のリミッタが 1.1 秒で 20 リクエスト許可する。バグではなく、固定ウィンドウの仕様通りの動作。

スライディングウィンドウログ — 正確だが高コスト

ウィンドウ境界を捨て、キーごとに直近 $windowSeconds 分のタイムスタンプを保持する:

public function check(string $key): Decision
{
    $now    = $this->clock->now();
    $cutoff = $now - $this->windowSeconds;
    $log    = $this->state[$key] ?? [];

    $drop = 0;
    foreach ($log as $t) {
        if ($t > $cutoff) break;
        $drop++;
    }
    if ($drop > 0) $log = array_slice($log, $drop);

    if (count($log) >= $this->limit) {
        $this->state[$key] = $log;
        return new Decision(false, ...);
    }

    $log[] = $now;
    $this->state[$key] = $log;
    return new Decision(true, ...);
}

任意の時点で過去 windowSecondslimit 以上のリクエストがないことを保証する。代わりにメモリは O(limit)。limit=10 なら些細だが、高レートのAPIゲートウェイでは問題になる。

スライディングウィンドウカウンタ — Cloudflare の近似式

固定ウィンドウの O(1) メモリと、エッジバースト耐性を両立する近似アルゴリズム。現バケットと前バケットの 2 カウンタで加重平均を取る:

$bucket      = (int) floor($now / $this->windowSeconds);
$bucketStart = (float) ($bucket * $this->windowSeconds);
$elapsed     = $now - $bucketStart;
$weight      = 1.0 - ($elapsed / $this->windowSeconds);

$estimate = (int) floor($entry['prev'] * $weight + $entry['count']);

if ($estimate >= $this->limit) {
    // deny
}

バケットに入って 1% の時点では weight = 0.99、99% の時点では weight = 0.01。前バケットのリクエストが均等分布だと仮定した近似で、実用上は正確な値から数パーセントの誤差。

トークンバケット — 通常の API に最適

容量 $capacity のバケットにトークンが入っている。$refillRate トークン/秒で補充され、リクエストごとに 1 トークン消費。遅延リフィルのトリック — バックグラウンドタイマーは不要で、次に参照するときに経過時間分を計算:

public function check(string $key): Decision
{
    $now   = $this->clock->now();
    $entry = $this->state[$key] ?? ['tokens' => (float) $this->capacity, 'last' => $now];

    $elapsed = $now - $entry['last'];
    if ($elapsed > 0) {
        $entry['tokens'] = min(
            (float) $this->capacity,
            $entry['tokens'] + $elapsed * $this->refillRate,
        );
    }
    $entry['last'] = $now;

    if ($entry['tokens'] >= 1.0) {
        $entry['tokens'] -= 1.0;
        // allowed
    }
    // denied
}

トークンが float なのには理由がある — 0.4 秒間隔のリクエストで 1 token/sec なら 0.4 トークンが貯まる。整数に丸めると持続レートが設定より低くなる。

トークンバケットは「しばらくアイドル → バースト → またアイドル」という人間の API 利用パターンにマッチする。

リーキーバケット — トークンバケットとの違い

一文で言うと: トークンバケットはクレジットカード、リーキーバケットは水道管。アイドル時間はトークンバケットではクレジットとして蓄積されるが、リーキーバケットでは単に水が抜けるだけ。バースト許容力が根本的に異なる。

public function check(string $key): Decision
{
    $now   = $this->clock->now();
    $entry = $this->state[$key] ?? ['size' => 0.0, 'last' => $now];

    $elapsed = $now - $entry['last'];
    if ($elapsed > 0) {
        $entry['size'] = max(0.0, $entry['size'] - $elapsed * $this->leakRate);
    }
    $entry['last'] = $now;

    if ($entry['size'] + 1.0 > (float) $this->capacity) {
        // queue full → deny
    }
    $entry['size'] += 1.0;
    // allow
}

SPA のページロードで 5 リクエストを並列に飛ばすと、トークンバケットなら余裕で通るのにリーキーバケットだと 429 になることがある。

/compare エンドポイントが本体

5 つの実装を読むのと、同じ入力で挙動が違うのを見るのは別物。/compare?n=20&delay=0.1 が N リクエストのシミュレーションを全リミッタ横並びで実行する:

{
  "input": { "key": "0.0.0.0", "n": 15, "delay": 0.1 },
  "results": {
    "fixed_window":           { "allowed": 10, "denied": 5, "sequence": [...] },
    "sliding_window_log":     { "allowed": 10, "denied": 5, "sequence": [...] },
    "sliding_window_counter": { "allowed": 10, "denied": 5, "sequence": [...] },
    "token_bucket":           { "allowed": 11, "denied": 4, "sequence": [...] },
    "leaky_bucket":           { "allowed": 11, "denied": 4, "sequence": [...] }
  }
}

内部では FakeClock で全リミッタが同じ時間軸を見る。リアルスリープなし。HTML ページでは sequence を色付きの矩形で描画して、どのリクエストがどのアルゴリズムに許可/拒否されたかが一目で分かる。

トレードオフ

  • インメモリのみ — シングルプロセス。2 台のアプリサーバがあれば実効レートは設定の 2 倍。本番では Redis が必要
  • IP ベースのみ — ほとんどの本番 API ではユーザー単位や API キー単位が正解
  • エンドポイント別設定なし — 全リミッタが 10 / 10 s 固定
  • スライディングウィンドウカウンタは近似 — 法的拘束力のあるレート制限(課金、コンプライアンス)にはスライディングログを使う

試してみる

git clone https://github.com/sen-ltd/rate-limit-demo
cd rate-limit-demo
docker build -t rate-limit-demo .
docker run --rm -p 8000:8000 rate-limit-demo

# 別シェルで:
for i in $(seq 1 12); do
  curl -sS -o /dev/null -w "%{http_code} " http://localhost:8000/fixed-window
done; echo
# → 200 200 200 200 200 200 200 200 200 200 429 429

curl -sS 'http://localhost:8000/compare?n=15&delay=0.1' | jq '.results | keys'

51 の PHPUnit テスト。全テストがフェイククロック上で動くのでスイート全体が 14 ミリ秒で完了する。

覚えておくべき 1 つ: アルゴリズムの選択基準はクライアントにバーストを許すかどうか。固定ウィンドウは許す。スライディングログとカウンタは許さない。トークンバケットはバケットサイズまで許す。リーキーバケットはまったく許さない。


SEN 合同会社100 超ポートフォリオシリーズ #165。

0
0
0

新規登録して、もっと便利にQiitaを使ってみよう

  1. あなたにマッチした記事をお届けします
  2. 便利な情報をあとで効率的に読み返せます
  3. ダークテーマを利用できます
ログインすると使える機能について
sen-ltd

@sen-ltd

SEN 合同会社。ソフトウェア開発を行っています。 小さな OSS ツールを継続的に公開しており、その実装判断・設計のトレードオフ・ハマりどころをこちらに記事として残しています。 https://sen.ltd https://github.com/sen-ltd

コメント

この記事にコメントはありません。

いいね以上の気持ちはコメントで

記事投稿キャンペーン開催中

0
0

記事を削除する

一度削除した記事は復旧できません。

この記事の編集中の下書きも削除されます。

削除してよろしいですか?

ログインして続ける

ソーシャルアカウントでログイン・新規登録

メールアドレスでログイン・新規登録