解決策
Rampは、それぞれ独自のクォータを課す数十のサードパーティAPI(Finicity、Teller、Zendesk、Salesforce)を呼び出し、全ユーザーベースにわたって定期的なCeleryタスクを実行しており、受信トラフィックをIP単位ではなく認証済みユーザー単位でレート制限したいと考えていた。これら3つのニーズはすべて、同じ欠けている要素を指し示していた。すなわち、単一のワーカー内だけでなく、すべてのワーカーとサーバーにわたって正確なレートリミッターである。
Rampの既存のアプローチは、Celery組み込みのタスク単位のレート制限に依存していた。この制限はワーカー単位で適用され、グローバルではなかったため、真にシステム全体のレートを適用するには、エンジニアがキューのワーカー数とオートスケーリングの挙動を把握し、希望する制限をワーカー間に手動で分割する必要があった。その数値はワーカー数が変わった瞬間に陳腐化した。特定のCeleryプリフェッチ設定では、レート制限されたタスクがワーカーによる新しい作業の取得を妨げ、キュー全体を停止させることさえあった。
Rampは固定ウィンドウ、スライディングウィンドウ、リーキーバケットの各アルゴリズムを評価した末に、Generic Cell Rate Algorithm(GCRA)を採用した。これはリーキーバケットの変種で、リクエストログ全体ではなく、キーごとに計算された単一のタイムスタンプを保存するだけで済む。RampはこれをRedisに対して実装し、参照実装にあった競合状態の隙間を塞ぐために分散ロックを追加し、マシン間のずれを避けるために各アプリサーバーの時計ではなくRedisのサーバー時刻を使用した。
その結果、Rampは3つのユースケースすべて——サードパーティAPI呼び出し、グローバルなCeleryタスクのスロットリング、ユーザー単位の受信API制限——に向けられる単一のレート制限フレームワークを手に入れ、エンジニアがワーカー数について一切考える必要がなくなった。
達成したこと
Redisに支えられたグローバルなGCRAレートリミッター
うまくいった理由
キーごとに単一のTAT値を保存するのは、スライディングウィンドウのリクエストログ全体を保存するよりもはるかに低コストであるため、Rampのリクエスト量でもリミッターは高速なままである。
リクエストを処理するアプリサーバーからではなくRedis自体から現在時刻を読み取ることで、クロックスキューが適用の不整合の原因となることを排除している。
チェックアンドセットの周囲にRedisロックを置くことで、2つの同時リクエストがどちらも古いTATを読み取り、両方が承認されてしまう競合状態を塞いでいる。
一回限りのリミッターではなく汎用フレームワークを1つ構築したことで、同じコードがロジックの重複なしにサードパーティのクォータ、Celeryタスクのスロットリング、ユーザー単位のAPI制限に対応できた。
応用できること
プロセス単位でのみ適用されるレート制限は、グローバルな制限を近似するにすぎない。カウンターを共有されロック可能なストアに集約することによって初めて、その数値は文字通りに意味を持つようになる。
その後
Rampは、Redis上のGCRAリミッターを記事で説明した3つのユースケース——グローバルなサードパーティAPIクォータ、Celeryタスクのスロットリング、受信のユーザー単位API制限——に、単体テストと手動検証を伴って展開した。また、新たなユースケースが生じるたびに、同じフレームワークを新たなレート制限アルゴリズムへ拡張する予定だと述べた。
出典をたどる
出典
- Rate limiting with Redis Ramp Engineering (Builders Blog) · builders.ramp.com ·