Redisでスライディングウィンドウ方式のレートリミッターを構築した方法
私たちのビデオAPIは、毎日UTCの午後8時になるとクラッシュしていました。
それは実際のトラフィックではありませんでした。数台のスクレイパーがトレンドのエンドポイントを見つけ出し、そこを猛烈に攻撃していたのです。データベースのクエリが積み上がり、実際のユーザーは動画の代わりにローディングスピナーを見ることになりました。
レート制限(Rate Limiting)が必要でした。しかし、ほとんどの単純な手法では、かえって問題を悪化させてしまいました。
固定ウィンドウ(Fixed Window)カウンタの問題点
多くの人はまず固定ウィンドウから始めます。これは、設定された時間ブロック内でリクエストをカウントする手法です。もし制限が1分間に100回だとしたら、ユーザーは11:59:59に100回、12:00:00にさらに100回のリクエストを送ることができます。つまり、わずか1秒間に200回のリクエストが届くことになります。スクレイパーは、こうした境界線を突いて制限を回避します。
解決策:スライディングウィンドウ(Sliding Window)
スライディングウィンドウは、「今」を基準として、直近のN秒間のリクエストをカウントします。これなら、悪用できる境界線が存在しません。
私たちはニーズに応じて、2つの異なる手法を使用しています。
1. スライディングウィンドウ・ログ(精度重視)
認証済みユーザーには、RedisのSorted Sets (ZSET) を使用します。すべてのリクエストに対してタイムスタンプを保存します。
ZREMRANGEBYSCOREを使用して古いエントリを削除します。ZCARDを使用して現在のリクエスト数をカウントします。ZADDを使用して新しいリクエストを記録します。
レースコンディション(競合状態)を防ぐため、このロジックは単一のLuaスクリプト内で実行します。これにより、操作がアトミック(不可分)になります。1つのスクリプトがRedisへの1回の通信ですべてを処理するため、2つのリクエストが同時に「自分たちは制限内である」と判断してしまう事態を防げます。
2. スライディングウィンドウ・カウンタ(拡張性重視)
匿名トラフィックの場合、キーの数が数百万にのぼります。すべてのタイムスタンプを保存することは不可能です。代わりに、現在のウィンドウと前のウィンドウに重み付けを行うカウンタを使用します。これにより、メモリ消費を大幅に抑え、高い効率を実現できます。
本番環境から得た教訓
- Luaスクリプトを使用すること。 これにより、チェックと書き込みが単一のアクションとして確実に実行されます。
- 標準的なヘッダーを出力すること。 常に
X-RateLimit-RemainingとRetry-Afterを送信してください。これにより、行儀の良いクライアントが自動的にバックオフ(リクエストの間隔を空けること)するのに役立ちます。 - フェイルオープン(Fail open)にすること。 Redisがダウンした場合は、リクエストを許可します。APIが過負荷になる方が、APIが完全に停止してしまうよりはマシだからです。
- クライアントを正しく識別すること。 ユーザーにはAPIキーを、ゲストにはハッシュ化されたIPアドレスを使用します。
- エンドポイントに合わせて制限を設定すること。 負荷の高い検索エンドポイントには、単純なリスト取得エンドポイントよりも厳格な制限が必要です。
結果はすぐに現れました。夕方のスパイクは消失し、データベースのパフォーマンスは正常に戻りました。
