🈲

Goとredisで簡易レートリミッタを作ろう

2024/10/07に公開

はじめに

Webアプリケーションにおいて、レートリミッターは主に内部から外部へ、または外部から内部へのリクエスト頻度を制御するメカニズムです。

レートリミッターには複数のアルゴリズムが存在しますが、本記事で紹介するのは「Fixed Window Counter」(固定ウィンドウカウンター)アルゴリズムです。このアルゴリズムは、指定された期間(例:1秒)内に、処理やリクエストの数が設定された制限を超えるかどうかを判断する非常にシンプルな方式です。

実装

シングルサーバーシステムでは、Goのgolang.org/x/timeパッケージを導入するだけで十分なレート制限が実現できます。しかし、分散システムの場合、レート制限の情報を共有するためにRedisなどの外部ストレージが必要となります。

Redisの公式ドキュメントによると、RedisコマンドのINCREXPIREを組み合わせることで、簡易的なレートリミッターを実装できます。

以下に、Goを使用してこの実装を行う例を示します:

import (
    "errors"
    "fmt"
    "time"

    "github.com/garyburd/redigo/redis"
)

var ErrTooManyRequests = errors.New("too many requests per second")

func LimitAPICall(conn redis.Conn, basekey string, limit int) error {
    const ttl = 10 // 秒
    ts := time.Now().Unix()
    keyname := fmt.Sprintf("%s:%d", basekey, ts)
    _ = conn.Send("MULTI")
    _ = conn.Send("INCR", k)
    _ = conn.Send("EXPIRE", k, ttl)
    r, err := redis.Ints(conn.Do("EXEC"))
    if err != nil {
        return err
    }
    if r[0] > limit {
        return ErrTooManyRequests
    }
    return nil
}

上記のコードについて、以下のように説明します:

  1. MULTIコマンドでトランザクションを開始します。
  2. INCRコマンドでキーに格納されている値を増加させます。キーがキャッシュに存在しない場合、INCRは1を返します。
  3. EXPIREコマンドでキーの有効期限を10秒に設定します。
  4. EXECコマンドでトランザクション内のすべてのコマンドを実行します。

LimitAPICall関数を実行するたびに、INCRコマンドにより同じキーのカウントが増加します。INCRの戻り値はr[0]に格納され、1秒内にこの値がlimit変数を超えた場合はエラーを返し、そうでない場合はnilを返します。

レートリミッター用のキーは、ベースキーとUNIX時刻の組み合わせで生成されます。UNIX時刻が変わると新しいキーが生成され、これによりレートリミッターのカウンターが1にリセットされます。なお、古いキーはEXPIREコマンドにより設定された10秒後に自動的に削除されます。

使い方

先ほど紹介した関数は、以下のように使用することができます。

const rateLimit = 10

func fetchArticles(conn redis.Conn, url string) {
    for {
        // エラーが発生した場合、1秒後に再試行します。
        if err := LimitAPICall(conn, "articlefetch", rateLimit); err != nil {
            if errors.Is(err, ErrTooManyRequests) {
                time.Sleep(1)
                continue
            }
            // エラーハンドリング
        }
        res, err := http.Get(url)
        if err != nil {
            // エラーハンドリング
        }
        body, err := io.ReadAll(res.Body)
        res.Body.Close()
        if res.StatusCode > 299 {
            // エラーハンドリング
        }
        if err != nil {
            // エラーハンドリング
        }
        fmt.Printf("%s", body)
        return
    }
}

fetchArticles関数は外部APIに接続し、そのレスポンスを出力します。この関数は、外部APIに接続する前にレート制限をチェックします。もしレート制限に引っかかった場合、1秒間スリープした後にリトライを行います。

おわりに

「Fixed Window Counter」アルゴリズムは理解しやすく実装も容易ですが、いくつかの課題があります:

  1. バースト問題:
    制御期間の終わりと次の期間の始まりにリクエストが集中すると、短期間でサーバーの負荷が急増する可能性があります。例えば、5秒の制御期間で4秒目と6秒目に大量のリクエストが発生した場合、2秒間で処理されるリクエスト数が倍増してしまいます。

  2. 長期制限の問題:
    制御期間が長い場合(例:1時間に100リクエスト)、早期に制限に達すると、次のリクエストが処理可能になるまで長時間待機する必要があります。

  3. 優先順位付けの欠如:
    すべてのリクエストが同等に扱われるため、リクエストの種類や緊急性に基づいた優先順位付けができません。

結論として、レート制限の期間が短く、制御したい処理頻度が一定である場合には、「Fixed Window Counter」アルゴリズムの使用が適していると言えるでしょう。

Discussion