GoとRedisを活用したスライディングウィンドウレートリミッターの実装
Grace Collins
Solutions Engineer · Leapcell

はじめに
分散システムとマイクロサービスの進化し続ける状況において、APIトラフィックの効果的な管理は、システム安定性の維持、悪用防止、リソースの公平な分配の確保にとって極めて重要です。スロットリングされていないリクエストはサーバーを圧倒し、サービス拒否攻撃につながり、ユーザーエクスペリエンスを低下させる可能性があります。そこで、レートリミッティングが重要な防御メカニズムとして登場します。さまざまなレートリミッティングアルゴリズムの中でも、スライディングウィンドウアプローチは、固定ウィンドウやトークンバケットのような単純な方法と比較して、より正確で応答性の高い方法でリクエストレートを制御できます。この記事では、並列処理機能とパフォーマンスで知られるGoと、レートリミッティングのような高性能ユースケースに最適な強力なインメモリデータストアであるRedisを使用して、スライディングウィンドウレートリミッターの実装を探ります。この堅牢な手法の仕組みを掘り下げ、その実装を示す実践的なGoコード例を提供します。
コアコンセプトの理解
実装に入る前に、関連する主要な概念について明確な理解を確立しましょう。
- レートリミッティング: 指定された時間枠内にユーザーまたはクライアントが行えるリクエストの数を制限する制御メカニズムです。その主な目標は、リソースの枯渇を防ぎ、悪意のあるアクティビティから保護し、公平な使用を保証することです。
- スライディングウィンドウアルゴリズム: これは、継続的な移動時間ウィンドウでリクエストを追跡するレートリミッティングアルゴリズムです。固定ウィンドウアルゴリズムはウィンドウ境界での「バースト」問題に悩まされる可能性がありますが、スライディングウィンドウはよりスムーズで正確なレート制御を提供します。通常、現在のウィンドウと前のウィンドウの2つの固定ウィンドウを組み合わせます。現在のウィンドウのカウントは、そのウィンドウのどの程度が経過したかで重み付けされ、前のウィンドウのカウントは、そのウィンドウのどの程度がまだ関連しているかで重み付けされます。
- Go (Golang): Googleで設計された静的型付けのコンパイル言語です。その強みは、優れた並列処理プリミティブ(ゴルーチンとチャネル)、ガベージコレクション、堅牢な標準ライブラリにあり、高性能ネットワークサービスの構築に理想的な選択肢となります。
- Redis: データベース、キャッシュ、メッセージブローカーとして使用されるオープンソースのインメモリデータ構造ストアです。その驚異的な読み書き速度と、ソート済みセットやハッシュマップなどのさまざまなデータ構造のサポートの組み合わせにより、レートリミッターの実装に非常に適しています。
スライディングウィンドウレートリミッターの説明
スライディングウィンドウレートリミッターの基本的な考え方は、リクエストをタイムスタンプで追跡することです。リクエストが到着すると、そのタイムスタンプがデータ構造に追加されます。次に、リクエストを許可するかどうかを判断するために、指定された時間ウィンドウ(例:過去60秒)内にタイムスタンプが収まるリクエストの数を数えます。重要なことに、時間が経過すると、ウィンドウ外にある古いリクエストは自動的に破棄されます。
Redisのソート済みセット (ZSETs) はこれに最適です。各リクエストのタイムスタンプをスコアとして、一意の識別子(例:UUIDまたは単にタイムスタンプ自体)をメンバーとして格納できます。これにより、以下を効率的に実行できます。
- 新しいリクエストのタイムスタンプを追加:
ZADD key timestamp timestamp - 古いリクエストを削除:
ZREMRANGEBYSCORE key -inf (now - windowDuration) - 現在のリクエストをカウント:
ZCARD keyまたはZCOUNT key (now - windowDuration) +inf
GoとRedisによる実装の詳細
GoとRedisの実装を段階的に見ていきましょう。
まず、Go用のRedisクライアントが必要です。go-redis/redis/v8パッケージは、人気があり堅牢な選択肢です。
package main import ( "context" "fmt" "log" "strconv" "time" "github.com/go-redis/redis/v8" ) // RateLimiterConfig はレートリミッターの設定を保持します type RateLimiterConfig struct { Limit int // 許可される最大リクエスト数 WindowSize time.Duration // スライディングウィンドウの期間 RedisClient *redis.Client } // NewRateLimiterConfig は新しいRateLimiterConfigインスタンスを作成します func NewRateLimiterConfig(limit int, windowSize time.Duration, rdb *redis.Client) *RateLimiterConfig { return &RateLimiterConfig{ Limit: limit, WindowSize: windowSize, RedisClient: rdb, } } // Allow はスライディングウィンドウアルゴリズムに基づいてリクエストが許可されるかどうかをチェックします func (rlc *RateLimiterConfig) Allow(ctx context.Context, key string) (bool, error) { now := time.Now().UnixNano() / int64(time.Millisecond) // 現在のタイムスタンプ(ミリ秒) // Redisトランザクション (MULTI/EXEC) を使用してアトミック性を確保 // これにより、すべての操作が単一のアトミックユニットとして扱われることが保証されます pipe := rlc.RedisClient.Pipeline() // 1. ウィンドウより古いタイムスタンプを削除 // ZREMRANGEBYSCORE key -inf (now - windowSizeInMilliseconds) // スコア前の `( ` は排他的であることを意味します。ウィンドウ開始より厳密に古い要素を削除したいのです。 minScore := now - rlc.WindowSize.Milliseconds() pipe.ZRemRangeByScore(ctx, key, "-inf", strconv.FormatInt(minScore, 10)) // 2. 現在のリクエストのタイムスタンプを追加 // ZADD key now now // ここではスコアとメンバーは同じ(タイムスタンプ)です pipe.ZAdd(ctx, key, &redis.Z{ Score: float64(now), Member: now, }) // 3. 現在のウィンドウ内のリクエスト数をカウント // ZCOUNT key (now - windowSizeInMilliseconds) +inf // 追加されたものを含む、現在のウィンドウ内のすべての要素をカウントします。 countCmd := pipe.ZCount(ctx, key, strconv.FormatInt(minScore, 10), "+inf") // 4. キーに有効期限を設定して、無制限に増加するのを防ぎます // これは、トラフィックの受信が停止したキーにとって重要です。 // ウィンドウ内に常にレコードが存在するように、ウィンドウサイズより少し長く設定します。 // 追加のバッファも確保します。 pipe.Expire(ctx, key, rlc.WindowSize+10*time.Second) // 安全のためにバッファを追加 // パイプラインのすべてのコマンドをアトミックに実行 _, err := pipe.Exec(ctx) if err != nil { return false, fmt.Errorf("redis transaction failed: %w", err) } // 実行されたコマンドからカウントを取得 currentRequests, err := countCmd.Result() if err != nil { return false, fmt.Errorf("failed to get request count from redis: %w", err) } return int(currentRequests) <= rlc.Limit, nil } func main() { // Redisクライアントの初期化 rdb := redis.NewClient(&redis.Options{ Addr: "localhost:6379", // Redisのアドレスに置き換えてください Password: "", // パスワードなし DB: 0, // デフォルトDBを使用 }) // 接続を確認するためにRedisにPing ctx := context.Background() _, err := rdb.Ping(ctx).Result() if err != nil { log.Fatalf("Redisに接続できませんでした: %v", err) } fmt.Println("Redisに接続しました!") // レートリミッターの設定:ユニークキーあたり10秒あたり5リクエスト limiter := NewRateLimiterConfig(5, 10*time.Second, rdb) // 特定のユーザーIDまたはAPIキーのリクエストをシミュレート userID := "user:123" fmt.Printf("%s のレート制限: %s あたり %d リクエスト\n", userID, limiter.WindowSize, limiter.Limit) for i := 1; i <= 10; i++ { allowed, err := limiter.Allow(ctx, userID) if err != nil { log.Printf("%s のレート制限チェックでエラーが発生しました: %v", userID, err) time.Sleep(500 * time.Millisecond) // エラー時にハンマーリングを避ける continue } if allowed { fmt.Printf("%s へのリクエスト %d: 許可\n", userID, i) } else { fmt.Printf("%s へのリクエスト %d: ブロック(レート制限超過)\n", userID, i) } time.Sleep(500 * time.Millisecond) // リクエスト間の遅延をシミュレート if i == 5 { fmt.Println("\n--- ウィンドウがスライドするのを待機中 ---") time.Sleep(6 * time.Second) // リクエストが再び許可されるのを見るためにしばらく待機 fmt.Println("--- リクエストを続行 ---”) } } }
コードの説明:
RateLimiterConfig構造体: この構造体は、Limit(許可される最大リクエスト数)とWindowSize(スライディングウィンドウの期間、例:10秒)、およびRedisClientインスタンスを保持します。Allow(ctx context.Context, key string) (bool, error)メソッド: これはコアロジックです。now: Redis Sorted Setメンバーのスコアとして使用される、ミリ秒単位の現在のタイムスタンプを取得します。- Redis Pipeline (トランザクション): 重要なのは、アトミック性のためにRedis
Pipelineを使用することです。これは、ZREMRANGEBYSCORE、ZADD、ZCOUNT、EXPIREコマンドをRedisサーバーへの単一のラウンドトリップにバンドルします。これにより、これらのすべての操作がRedisの観点から逐次的にアトミックに実行されることが保証され、読み取りと書き込みの間でカウントが間違っている可能性のある競合状態を防ぎます。 ZRemRangeByScore: このコマンドは、スコアがnow - windowSize以下であるソート済みセットkeyのすべてのメンバーを削除します。これにより、現在のスライディングウィンドウ内に存在しない古いリクエストが効果的に削除されます。ZAdd:nowタイムスタンプをソート済みセットのスコアとメンバーとして追加します。タイムスタンプをスコアにすることで、時間でソートおよびフィルタリングできます。ZCount: このコマンドは、スコアが(now - windowSize)から+infの範囲内にあるソート済みセットkeyのメンバーの数をカウントします。これにより、現在のスライディングウィンドウ内のリクエストの合計数が得られます。Expire: Redisキーに有効期限を設定します。これは、トラフィックの受信が停止する可能性のあるキーの重要な最適化です。有効期限がないと、使用されていないレートリミッターキーはRedisメモリに無期限に蓄積されます。ウィンドウの終わりに近いリクエストでも、ウィンドウがスライドしたときに正しくカウントされるのに十分な期間セット内に残るように、WindowSizeよりわずかに長く設定します。Exec: パイプライン内のすべてのコマンドを実行します。currentRequests <= rlc.Limit: 最後に、カウントされたリクエストを設定されたLimitと比較して、着信リクエストを許可するかブロックするかを判断します。
アプリケーションシナリオ
スライディングウィンドウレートリミッターは非常に用途が広く、さまざまなシナリオに適用できます。
- APIゲートウェイ保護: バックエンドサービスが過負荷にならないように、クライアントあたりのリクエスト数を制限します。
- ユーザー固有のスロットリング: 単一のユーザーが過剰なリクエスト(例:ログイン試行回数が多い、検索クエリが多い)を行うのを防ぎます。
- DDOS対策: 通常よりもはるかに多いリクエストを行うIPアドレスをブロックすることで、ボリュメトリック攻撃に対する最初の防御線として機能します。
- リソースの公平な使用: レート制限を遵守しているユーザーを優先することで、すべてのユーザーが限定されたリソースを公平に共有できるようにします。
- 請求と階層型サービス: 異なるサブスクリプションティアに対して異なるレート制限を実装します(例:無料ティアは60秒あたり100リクエスト、プレミアムティアは60秒あたり1000リクエスト)。
結論
GoとRedisでスライディングウィンドウレートリミッターを実装することは、APIトラフィックを管理するための非常に効果的で効率的な方法を提供します。Redisのソート済みセットとGoの並列処理機能を活用することで、継続的な時間ウィンドウでリクエストを正確に追跡し、リソースの枯渇を防ぎ、システム安定性を確保する堅牢なシステムを構築できます。このアプローチは、単純な方法と比較して優れた公平性と精度を提供するため、回復力のある分散アプリケーションを設計するための不可欠なツールとなります。

