💪

Go及びRedisによる厳格なリクエスト数制限へのレートリミット対応

2023/08/29に公開

はじめに

こんにちは!
株式会社CastingONEでHR領域のSaaS開発を行っている @hiroaki です。
最近会社のサウナ好き達でプライベートサウナに行ってきました。控えめに言って最高でしたね!

さて、今回は厳しいリクエスト数制限に対してレートリミットを扱ったときの話です。
Goでは、rateパッケージにてレートリミットの制御をかけることができます。
しかし今回は、この制御だけではうまくいきませんでした。
どのような課題があったのか、それに対してどのようなアプローチをとっていったのか、具体的な実装を交えて説明していきますので、ぜひ最後まで読んでいただけると嬉しいです!

レートリミット制御の背景

弊社のサービス(CastingONE)では採用管理等するために、求人や候補者の情報を扱っています。
今回は、同じく人材系のサービスを扱っている企業とデータ連携することになりました(以下データ連携する先の企業は、パートナー企業と記載)。
例えば「CastingONEの求人が作成されたら、パートナー企業のシステムにも同じ求人が作成される」というものです。

今回データ連携するにあたり、1つだけパートナー企業からリクエストに関する制限が提示されました。
リクエストは1秒に1回までというものです。

基本的にはrateパッケージによるアプローチのみでも良さそうですが、今回はもう少し検討が必要でした。

課題を深掘りするために、少し具体的に説明します。
今回のデータ連携は以下のような流れで行われます。

  1. CastingONEの求人が作成(or 更新, 削除)される
  2. 「求人の作成」等のイベントを作成し、RDBに保存する
  3. パートナー企業にリクエストを送信するためのAPIを叩く
  4. 作成されたイベントの数だけパートナー企業にリクエストを送信する

3のAPI(以下バッチ処理APIと記載)を叩いている箇所が、今回の課題のポイントになります。
データ連携はできるだけリアルタイムに行いたいため、バッチ処理APIは1分に1回実行するようにしました。
※ リアルタイム性を求める場合はPub/Sub等を使うのが良さそうですが、今回は理由があって使用しませんでした。詳細は補足 Pub/Subを使用しなかった理由参照。

リクエストの制限が1秒に1回であるため、作成されたイベントが60個よりも多い時、バッチ処理実行中にも関わらず1分後に再度バッチ処理APIが叩かれてしまいます。
それによって、バッチ処理APIが並列に実行されてしまい、結果的に1秒に1回のリクエスト制限を超えてしまう可能性があります。
そのため、バッチ処理APIが実行されている時は、追加でバッチ処理APIが叩かれても、追加のリクエストを送らないようする必要がありました。

このような課題に対して、どのようなアプローチをとっていったのか、具体的な実装を交えて説明していきます!

補足 Pub/Subを使用しなかった理由

Pub/Subを使用しなかった理由について補足します。
その主な理由は、同じデータのCRUD処理に関するリクエストをまとめたかったためです。
例えば、求人1が作成され、その直後に更新された場合、「求人1の作成」と「求人1の更新」というイベントが作成されます。
1秒に1回というりクエスト制限がある中で、このようにリクエスト数が増加すると結果的に効率が落ちてリアルタイム性がよりなくなります。
そこで、同じデータに対するリクエストを一括で送信する方法を採用しました。
このような重複の排除は、Pub/Subを使用すると実現が難しくなるため、今回はPub/Subの使用を避ける決定をしました。

GoとRedisを使ったレートリミット制御

タイトルにもなっていますが、今回はGoとRedisでレートリミットの制御をすることで、課題解決していきました。

全体の処理の流れを図示すると以下のようになります。

integration-infra

改めて流れを箇条書きにすると以下のようになります。

  1. Cloud Runに対してバッチ処理のリクエストが送信
  2. Redis(Memory Store)に処理フラグがあるかを確認
    処理フラグがなければ、Redisに処理フラグを保存(あれば処理を終了)
  3. Cloud SQLからイベント情報を取得
  4. イベントを元にパートナー企業のサーバーに対して、1秒に1回リクエストを実行
  5. 処理が全て完了したら、Redisに保存した処理フラグを削除

上記の流れをシーケンス図にて表現しました。
integration-sequence

今回バッチ処理中の重複実行を避けるために、Redisにて処理フラグを保持する方式を採用しました。
Redisでのレートリミットの実装は、incrとexpireの組み合わせを利用するのが一般的です。
しかし、このアプローチは今回のケースには適していません。

incrとexpireを使ったレートリミットの主な目的は、特定の期間内のリクエスト回数を制限することにあります。
例えば、1分間に10回までのリクエストといった制約には効果的です。
しかし、今回の主な目的はバッチ処理の重複実行を防ぐことだけで、expireによる自動削除の機能は不要です。

それでは、次に具体的な実装を確認していこうと思います。

具体的な実装

Redisのフラグ制御処理

// useCaseにてRedisに依存しないために、interfaceを定義
type Locker interface {
	Lock(key string, duration time.Duration) error
	Unlock(key string) error
}

type locker struct{}

func NewLocker() domain.Locker {
	return &locker{}
}

// ロックの具体的な処理
func (l *locker) Lock(key string, duration time.Duration) error {
	conn := pool.Get()
	defer conn.Close()

    // Redisに制御するためのkeyをセット
	reply, err := conn.Do("SET", key, "1", "NX", "EX", duration.Seconds())
	if err != nil {
		return err
	}
    // replyがなければ、すでにロックがあるとしてエラーを返す
	if reply == nil {
		return ErrAlreadyLocked
	}
	return nil
}

// ロック解除処理
func (l *locker) Unlock(key string) error {
	conn := pool.Get()
	defer conn.Close()

    // 設定したロックを削除
	_, err := conn.Do("DEL", key)
	return err
}

ここまでが、Redisによる制御になります。Lockメソッドにて連携用のフラグをセットしており、Unlockメソッドにより連携用のフラグを削除しています。
Lock処理にてNXを指定することで、キーが存在しない場合のみキーをsetするようにしました。

これらをuseCaseにて使っていくイメージです。useCaseの処理も見ていきましょう!

useCaseの処理

const (
    // パートナー企業へのrequest制限時間
    limitInterval = 1000 * time.Millisecond
    // Redisのロック有効期間
    LockDuration = 3600 * time.Second
    // Redisに保存する際のkey名
    LockKey = "lock:integration"
)

type useCase struct {
  Locker Locker
}

func (uc *useCase) Run(ctx context.Context) error {
    // Redisによるバッチ処理重複の制御
    err = uc.Locker.Lock(LockKey, LockDuration)
    if err != nil {
        // すでにロックがかけられている(すでにバッチ処理実行中)場合は、処理を実行しない
        if errors.Is(err, ErrAlreadyLocked) {
            logger.Info("integration is locked")
            return nil
        }
        return err
    }
    defer uc.Locker.Unlock(LockKey)

    // rateパッケージによる秒間リクエストの制御
    limiter := rate.NewLimiter(rate.Every(limitInterval), 1)
    // eventsには「求人作成」等の情報が入っている
    for _, e := range events {
        if err := limiter.Wait(ctx); err != nil {
            return fmt.Errorf("rate limit Error")
        }
        // イベント情報から連携先にリクエストを送信する処理
    }
}

まず、LockerによってLockをかけており、メソッドの処理が完了したらUnLockするようにしました。ここでバッチ処理APIの重複実行の制御をかけています。
すでにロックがかけられていれば、早期リターンしています。

そのあとrateパッケージにより、秒間リクエストの制限をかけています。
実装としては非常にシンプルですね!

まとめ

今回は1秒に1回のリクエストという厳しい制限に対応するために、GoとRedisを使ったレートリミット制御を実装をしました。
Redisを使うことでバッチ処理が何回叩かれても、実際には1回しかリクエストが送信されないようになりました。
他にも色々なソリューションが案として出ていましたが、人的/時間的リソース等の制限もあったので今回はこのような設計や実装にしました。結果的にシンプルに実装を終えられて良かったです!

おわりに

ここまで読んでいただきありがとうございました!最終的に今回の記事のような形に落ち着きましたが、それまではいくつかの案をbackendメンバーであれやこれやと議論していました。
ややこしい問題もみんなで一緒になって課題解決していくの楽しいですよね!
弊社では一緒に議論しながら課題を解決してくれる仲間を募集しています!ご興味ある方はカジュアルにお声掛けください〜

https://casting-one.jp/recruit/engineer/

Discussion