RedisとSliding Window Logによる高精度なAPIレート制限実装

Redis Security Backend Architecture
結論

ZSET を使い、MULTI/EXEC トランザクションでアトミックにスライディングログを判定します。

// Redis公式仕様:MULTI / EXEC アトミック処理によるレート制限
import Redis from 'ioredis';
const redis = new Redis();

async function isRateLimited(userId: string, limit = 10, windowSec = 60): Promise<boolean> {
  const now = Date.now();
  const clearBefore = now - windowSec * 1000;
  const key = `rate:${userId}`;
  const requestId = `${now}:${Math.random()}`; // 同一ミリ秒重複防止

  // ⭕ MULTI / EXEC によるアトミック(原子)処理
  const tx = redis.multi();
  tx.zremrangebyscore(key, 0, clearBefore); // 1. 窓外の古いログを全削
  tx.zadd(key, now, requestId);            // 2. 今回のリクエストを追加
  tx.zcard(key);                           // 3. 現在の窓内件数をカウント
  tx.expire(key, windowSec);               // 4. キーの無制限残存を防止

  const results = await tx.exec();
  if (!results) throw new Error('Redis transaction failed');

  const count = results[2][1] as number; // 3番目 (ZCARD) の結果を取得
  return count > limit;
}

Redis公式仕様:レート制限アルゴリズム比較マトリクス

アルゴリズム精密度バースト攻撃耐性実装構造
Fixed Window (固定枠)❌ 低い時間境界(例: 59秒と00秒)で2倍の連打を許す単純な INCREXPIRE
Leaky Bucket (漏れバケツ)△ 中程度◯ 一定速度に平滑化するが即時処理を遅延キュー(Queue)による順次処理
Sliding Window Log◯ 極めて高い◯ どの1分間をとっても正確に上限内へ制限ZSET (スコア=タイムスタンプ)

実際に起こる障害:アトミック性(Race Condition)欠損によるレート突破事故

高並行アクセス(ミリ秒間に数百リクエスト)が発生するAPIサーバーにおいて、ZREMRANGEBYSCOREZCARD をアトミック処理(MULTI/EXEC または Lua スクリプト)を行わずに個別のコマンドとして順次発行した場合、競合状態(Race Condition)が発生します。

他スレッドの書き込みが割り込むことで ZCARD の件数判定が正常に機能せず、本来10回までに制限すべきAPIに対して数十〜数百回のリクエストが制限をくぐり抜けてバックグラウンドDBへ直撃し、DBが過負荷ダウンする障害 が発生します。


実装手順

  1. 識別子(IPアドレスまたはユーザーID)を含む Redis ZSET キー(rate:user_123)を作成する
  2. 同一ミリ秒での一意性を保つため、メンバー値に 現在時刻:乱数 のユニーク文字列を生成する
  3. redis.multi() トランザクションブロック内で、削除(ZREMRANGEBYSCORE) → 追加(ZADD) → 件数取得(ZCARD) → TTL設定(EXPIRE)を不可分に実行する
  4. レート制限を超過した場合は 429 Too Many RequestsRetry-After ヘッダーをクライアントへ返却する