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倍の連打を許す | 単純な INCR + EXPIRE |
| Leaky Bucket (漏れバケツ) | △ 中程度 | ◯ 一定速度に平滑化するが即時処理を遅延 | キュー(Queue)による順次処理 |
| Sliding Window Log | ◯ 極めて高い | ◯ どの1分間をとっても正確に上限内へ制限 | ZSET (スコア=タイムスタンプ) |
実際に起こる障害:アトミック性(Race Condition)欠損によるレート突破事故
高並行アクセス(ミリ秒間に数百リクエスト)が発生するAPIサーバーにおいて、ZREMRANGEBYSCORE や ZCARD をアトミック処理(MULTI/EXEC または Lua スクリプト)を行わずに個別のコマンドとして順次発行した場合、競合状態(Race Condition)が発生します。
他スレッドの書き込みが割り込むことで ZCARD の件数判定が正常に機能せず、本来10回までに制限すべきAPIに対して数十〜数百回のリクエストが制限をくぐり抜けてバックグラウンドDBへ直撃し、DBが過負荷ダウンする障害 が発生します。
実装手順
- 識別子(IPアドレスまたはユーザーID)を含む Redis ZSET キー(
rate:user_123)を作成する - 同一ミリ秒での一意性を保つため、メンバー値に
現在時刻:乱数のユニーク文字列を生成する redis.multi()トランザクションブロック内で、削除(ZREMRANGEBYSCORE) → 追加(ZADD) → 件数取得(ZCARD) → TTL設定(EXPIRE)を不可分に実行する- レート制限を超過した場合は
429 Too Many RequestsとRetry-Afterヘッダーをクライアントへ返却する