固定ウィンドウに潜む罠
多くの開発者は「固定ウィンドウ(Fixed Window)」アルゴリズムを使用してレート制限を実装しています。ロジックは単純です。1分間に100リクエストを許可し、新しい1分が始まるごとにカウンターをゼロにリセットします。コードは簡単ですが、このアプローチには「境界問題(boundary problem)」と呼ばれる危険な欠陥があります。
例えば、あるユーザーが10:00:59に100リクエストを送り、10:01:01にさらに100リクエストを送ったとします。システムはこれらを別々の「1分間」と見なし、全200リクエストを許可してしまいます。実際には、サーバーはわずか2秒間に200ヒットという大規模なバーストにさらされたことになります。「1分間に100リクエスト」という制限が、本来ブロックされるべきスパイクを許してしまい、本番環境のデータベースがロックアップする場面を私は何度も見てきました。ローリングウィンドウをマスターすることは、こうした突然のトラフィック急増時にインフラを維持するために不可欠です。
なぜスライディングウィンドウ・ログが優れているのか
スライディングウィンドウ・ログ(Sliding Window Log)アルゴリズムは、個々のリクエストのタイムスタンプをすべて追跡することで境界問題を解消します。グローバルなカウンターを1つ持つ代わりに、ユーザーごとに履歴(ログ)を保持します。新しいリクエストがAPIに届くと、システムは次の3つのステップを実行します。
- 現在のウィンドウより古いタイムスタンプ(例:60秒以上前のもの)をすべて削除する。
- 現在のリクエストのタイムスタンプをログに追加する。
- 残りのエントリ数をカウントする。カウントが制限内であれば、リクエストを続行させる。
ウィンドウはリクエストの正確なミリ秒単位で計算されるため、固定のリセットポイントは存在しません。100リクエストの制限がある場合、ユーザーがいつクリックし始めたかに関係なく、どの60秒間を切り取っても厳密に制限されます。
前提条件と環境構築
分散環境では、すべてのAPIインスタンスの同期を保つために共有データストアが必要です。ここでは業界標準であるRedisを使用します。Redisのソート済みセット(ZSET)は、高パフォーマンスでタイムスタンプのログを管理するのに最適です。今回は Node.js と ioredis ライブラリを使用して実装します。
まず、プロジェクトディレクトリを作成しましょう。
mkdir redis-rate-limiter
cd redis-rate-limiter
npm init -y
npm install ioredis
ローカルにRedisがインストールされていない場合は、Dockerを使用して数秒でコンテナを起動できます。
レートリミッター・ロジックの実装
Redisのソート済みセットを活用します。各要素にはスコアが設定されます。Unixタイムスタンプを値とスコアの両方に使用することで、マイクロ秒の精度で古いデータポイントを照会・削除できます。このクラスベースのアプローチにより、ExpressやFastifyのルートにロジックを簡単に組み込むことができます。
const Redis = require('ioredis');
const redis = new Redis({
host: '127.0.0.1',
port: 6379,
});
class RateLimiter {
constructor(limit, windowInSeconds) {
this.limit = limit;
this.windowInMs = windowInSeconds * 1000;
}
async isAllowed(userId) {
const key = `rate_limit:${userId}`;
const now = Date.now();
const windowStart = now - this.windowInMs;
// パイプラインを使用することで原子性を確保し、ネットワーク遅延を削減します
const multi = redis.multi();
// 1. クリーンアップ:ローリングウィンドウより古いタイムスタンプを削除
multi.zremrangebyscore(key, 0, windowStart);
// 2. 現在の試行を記録
multi.zadd(key, now, now);
// 3. このユーザーの現在のカウントを取得
multi.zcard(key);
// 4. アイドル状態のユーザーがRedisメモリを無駄にしないようTTLを設定
multi.expire(key, Math.ceil(this.windowInMs / 1000) + 1);
const results = await multi.exec();
// ioredisは [[err, result], ...] の形式で結果を返します
const requestCount = results[2][1];
return {
allowed: requestCount <= this.limit,
count: requestCount
};
}
}
module.exports = RateLimiter;
Redisコマンドの解説
zremrangebyscore: スライディングウィンドウ의 エンジンです。現在時刻からウィンドウ期間を引いた時間より前に発生した「期限切れ」のリクエストを消去します。zadd: 現在のヒットを記録します。たとえリクエストが最終的にブロックされたとしても、試行をログに記録することで、ユーザーによる境界付近でのスパム行為を防ぎます。zcard: セットに残っている有効なタイムスタンプの総数を返します。multi.exec(): すべてをトランザクションにまとめます。これにより、2つの同時リクエストがカウントを誤計算するような競合状態を防ぎます。
検証とモニタリング
リミッターの動作を確認するために、シンプルなExpressサーバーに接続してみましょう。npm install expressでフレームワークをインストールし、server.jsファイルを作成します。
const express = require('express');
const RateLimiter = require('./limiter');
const app = express();
const limiter = new RateLimiter(5, 10); // 10秒間に5リクエストを許可
app.get('/api/resource', async (req, res) => {
const userId = req.query.user || 'anonymous';
const { allowed, count } = await limiter.isAllowed(userId);
if (!allowed) {
return res.status(429).json({
error: 'リクエストが多すぎます',
currentCount: count,
limit: 5
});
}
res.json({ message: '成功!', currentCount: count });
});
app.listen(3000, () => console.log('サーバーがポート3000で起動しました'));
スライディングロジックのテスト
このbashループを使用して、7回のリクエストを素早く実行してみてください。最初の5回は成功し、最後の2回は即座にブロックされるのが確認できます。
for i in {1..7}; do curl "http://localhost:3000/api/resource?user=dev_user"; echo ""; done
運用コストについて
スライディングウィンドウ・ログは優れた精度を提供しますが、その代償としてメモリを消費します。Redisの1つのZSETエントリは約60〜100バイトを消費します。10,000人のアクティブユーザーに対してそれぞれ1,000リクエストを追跡する場合、レート制限だけで最大1GBのRAMを使用する可能性があります。常にINFO memoryで利用状況を監視してください。
メモリがボトルネックになる場合は、スライディングウィンドウ・カウンター(Sliding Window Counter)を検討してください。これは、少ないRAMで済みますが、わずかな誤差が生じるハイブリッドなアプローチです。ただし、ほとんどの高セキュリティAPIにとって、ZSETメソッドはゴールドスタンダード(最良の基準)です。
まとめ
固定ウィンドウからスライディングウィンドウ・ログに切り替えることで、分単位の境界でユーザーがスループットを倍増させるリスクを排除できます。Redisを使用すれば、ロードバランサーの背後でNode.jsインスタンスを50個にスケールさせても、レート制限の一貫性は保たれます。このセットアップは、不慮のスパイクや悪意のある乱用からバックエンドを保護するためのプロフェッショナルグレードの基盤となります。

