レートリミットとは?

**レートリミット(Rate Limiting)**は、APIへのリクエスト数を一定時間内に制限する仕組みです。サーバーの過負荷防止、不正利用の防止、公平なリソース配分のために不可欠です。

なぜレートリミットが必要か?

理由説明
サーバー保護DDoS攻撃や異常なリクエストからサーバーを守る
公平性1ユーザーがリソースを独占するのを防ぐ
コスト管理外部API呼び出しやDBクエリのコストを制御
SLA遵守サービスの応答時間を保証する
ビジネスプラン別に利用制限を設ける(無料/有料)

レートリミットのアルゴリズム

1. 固定ウィンドウ(Fixed Window)

最もシンプルなアルゴリズムです。

時間軸: [00:00 - 01:00] [01:00 - 02:00] [02:00 - 03:00]
制限:   100リクエスト/分

00:00〜01:00: ████████ 80リクエスト → OK
01:00〜02:00: ██████████ 100リクエスト → OK
02:00〜02:30: ██████████ 100リクエスト → 以降拒否
async function fixedWindowRateLimit(key, limit, windowSec) {
  const now = Math.floor(Date.now() / 1000);
  const windowKey = `rate:${key}:${Math.floor(now / windowSec)}`;

  const count = await redis.incr(windowKey);
  if (count === 1) {
    await redis.expire(windowKey, windowSec);
  }

  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
    resetAt: (Math.floor(now / windowSec) + 1) * windowSec,
  };
}

メリット: 実装がシンプル、メモリ効率が良い

デメリット: ウィンドウの境界で2倍のリクエストが通る可能性がある(バースト問題)

2. スライディングウィンドウログ(Sliding Window Log)

各リクエストのタイムスタンプを記録し、直近N秒間のリクエスト数をカウントします。

async function slidingWindowLogRateLimit(key, limit, windowSec) {
  const now = Date.now();
  const windowStart = now - windowSec * 1000;
  const redisKey = `rate:${key}`;

  // パイプラインで効率化
  const pipeline = redis.multi();

  // 期限切れのエントリを削除
  pipeline.zRemRangeByScore(redisKey, 0, windowStart);

  // 現在のリクエストを追加
  pipeline.zAdd(redisKey, { score: now, value: `${now}-${Math.random()}` });

  // 現在のウィンドウ内のリクエスト数をカウント
  pipeline.zCard(redisKey);

  // TTLを設定
  pipeline.expire(redisKey, windowSec);

  const results = await pipeline.exec();
  const count = results[2];

  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
  };
}

メリット: 正確なレートリミット

デメリット: メモリ使用量が多い(リクエストごとにログを保存)

3. スライディングウィンドウカウンター

固定ウィンドウとスライディングウィンドウのハイブリッドです。

async function slidingWindowCounterRateLimit(key, limit, windowSec) {
  const now = Math.floor(Date.now() / 1000);
  const currentWindow = Math.floor(now / windowSec);
  const previousWindow = currentWindow - 1;
  const elapsedRatio = (now % windowSec) / windowSec;

  const currentKey = `rate:${key}:${currentWindow}`;
  const previousKey = `rate:${key}:${previousWindow}`;

  const [currentCount, previousCount] = await Promise.all([
    redis.get(currentKey).then(Number),
    redis.get(previousKey).then(Number),
  ]);

  // 前のウィンドウの重み付きカウント + 現在のウィンドウのカウント
  const estimatedCount =
    previousCount * (1 - elapsedRatio) + currentCount;

  if (estimatedCount >= limit) {
    return { allowed: false, remaining: 0 };
  }

  await redis.incr(currentKey);
  await redis.expire(currentKey, windowSec * 2);

  return {
    allowed: true,
    remaining: Math.max(0, Math.floor(limit - estimatedCount - 1)),
  };
}

メリット: メモリ効率が良く、精度も高い

デメリット: 推定値なので完全に正確ではない

4. トークンバケット(Token Bucket)

バケツにトークンが一定間隔で補充され、リクエスト時にトークンを消費します。

バケット容量: 10トークン
補充レート: 1トークン/秒

[████████░░] 8トークン残り → リクエスト可能(1トークン消費)
[███████░░░] 7トークン残り → リクエスト可能
...
[░░░░░░░░░░] 0トークン → リクエスト拒否(補充を待つ)
async function tokenBucketRateLimit(key, capacity, refillRate) {
  const redisKey = `bucket:${key}`;
  const now = Date.now();

  // Luaスクリプトでアトミックに処理
  const luaScript = `
    local key = KEYS[1]
    local capacity = tonumber(ARGV[1])
    local refill_rate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])

    local data = redis.call('HMGET', key, 'tokens', 'last_refill')
    local tokens = tonumber(data[1]) or capacity
    local last_refill = tonumber(data[2]) or now

    -- トークンを補充
    local elapsed = (now - last_refill) / 1000
    tokens = math.min(capacity, tokens + elapsed * refill_rate)

    local allowed = tokens >= 1
    if allowed then
      tokens = tokens - 1
    end

    redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
    redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) * 2)

    return {allowed and 1 or 0, math.floor(tokens)}
  `;

  const [allowed, remaining] = await redis.eval(luaScript, {
    keys: [redisKey],
    arguments: [capacity.toString(), refillRate.toString(), now.toString()],
  });

  return { allowed: !!allowed, remaining };
}

メリット: バースト処理を許容しつつ平均レートを制限できる

デメリット: 実装がやや複雑

5. リーキーバケット(Leaky Bucket)

バケツから一定レートで処理が流れ出します。入力が多すぎるとバケツが溢れてリクエストが拒否されます。

メリット: 出力レートが一定になる(トラフィックの平滑化)

デメリット: バーストトラフィックに柔軟でない

アルゴリズムの比較

アルゴリズムメモリ効率精度バースト対応実装難度
固定ウィンドウ
スライディングログ
スライディングカウンター
トークンバケット
リーキーバケット

HTTPレスポンスヘッダー

レートリミット情報はHTTPヘッダーでクライアントに伝えます。

HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1711900800

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711900800
Retry-After: 30

Express ミドルウェアの実装

function rateLimitMiddleware(options = {}) {
  const {
    limit = 100,
    windowSec = 60,
    keyGenerator = (req) => req.ip,
  } = options;

  return async (req, res, next) => {
    const key = keyGenerator(req);
    const result = await slidingWindowCounterRateLimit(key, limit, windowSec);

    // ヘッダーを設定
    res.set('X-RateLimit-Limit', limit);
    res.set('X-RateLimit-Remaining', result.remaining);
    res.set('X-RateLimit-Reset', result.resetAt);

    if (!result.allowed) {
      res.set('Retry-After', windowSec);
      return res.status(429).json({
        error: 'Too Many Requests',
        message: `Rate limit exceeded. Try again in ${windowSec} seconds.`,
      });
    }

    next();
  };
}

// 使い方
app.use('/api/', rateLimitMiddleware({ limit: 100, windowSec: 60 }));

// エンドポイントごとに異なる制限
app.use('/api/search', rateLimitMiddleware({ limit: 10, windowSec: 60 }));
app.use('/api/upload', rateLimitMiddleware({ limit: 5, windowSec: 300 }));

レートリミットの設計指針

キーの設計

キー用途
IPアドレス未認証のリクエスト
APIキーサービス間通信
ユーザーID認証済みユーザー
エンドポイント + ユーザーIDエンドポイント別の制限

プラン別の制限例

プランリクエスト/分リクエスト/日
Free101,000
Pro10050,000
Enterprise1,000無制限

ベストプラクティス

  1. 段階的な制限: 注意→警告→ブロックの段階を設ける
  2. 適切なエラーメッセージ: 429レスポンスに Retry-After ヘッダーを含める
  3. バイパス条件の設定: ヘルスチェックや内部通信は除外
  4. モニタリング: レートリミットの発動回数を監視
  5. グレースフルデグラデーション: Redisがダウンした場合はリミットをスキップ

まとめ

用途推奨アルゴリズム
シンプルなAPI保護固定ウィンドウ or スライディングカウンター
正確な制限が必要スライディングログ
バーストを許容したいトークンバケット
トラフィック平滑化リーキーバケット

まずは固定ウィンドウで始め、要件に応じてアルゴリズムをアップグレードしていくのがおすすめです。


APIのレスポンス確認には、AssistyのJSONフォーマッターでJSON を見やすく整形できます。