レートリミットとは?
**レートリミット(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 | エンドポイント別の制限 |
プラン別の制限例
| プラン | リクエスト/分 | リクエスト/日 |
|---|---|---|
| Free | 10 | 1,000 |
| Pro | 100 | 50,000 |
| Enterprise | 1,000 | 無制限 |
ベストプラクティス
- 段階的な制限: 注意→警告→ブロックの段階を設ける
- 適切なエラーメッセージ: 429レスポンスに
Retry-Afterヘッダーを含める - バイパス条件の設定: ヘルスチェックや内部通信は除外
- モニタリング: レートリミットの発動回数を監視
- グレースフルデグラデーション: Redisがダウンした場合はリミットをスキップ
まとめ
| 用途 | 推奨アルゴリズム |
|---|---|
| シンプルなAPI保護 | 固定ウィンドウ or スライディングカウンター |
| 正確な制限が必要 | スライディングログ |
| バーストを許容したい | トークンバケット |
| トラフィック平滑化 | リーキーバケット |
まずは固定ウィンドウで始め、要件に応じてアルゴリズムをアップグレードしていくのがおすすめです。
APIのレスポンス確認には、AssistyのJSONフォーマッターでJSON を見やすく整形できます。