はじめに
大規模なリアルタイムサービスでは、クライアント側の自動処理が特定条件で一斉に発火し、同時多発的な API リクエストが発生することがある。このような状況では、上流サービスだけでなく、その先の下流サービスにも同数のリクエストが伝播し、想定以上の負荷を引き起こす。
本記事では、サービス間通信における不要な重複リクエストを削減するために Redis の SET NX を用いた制御を導入し、下流サービスおよび中間サービス双方の負荷削減を実現した事例を紹介する。また、Go 言語における singleflight などの代替手法や、分散環境でのリクエスト集約における制約についても整理する。
背景
ある条件下で、クライアント側の自動処理が発火し、サービスAへリクエストが送信される仕組みになっていた。
しかし、この処理には次の特徴があった。
- 多数のクライアントが同時に条件を満たすと、同時に大量のリクエストがサービスAへ送信される
- サービスAは各リクエストごとにサービスBへ問い合わせを行う
- 実際には、サービスBの処理結果は「どれか一つのリクエストが成功すればよい」性質のものだった
つまり、N人のクライアントから同時に同じ目的のリクエストが送信されるにもかかわらず、サービスBには N 回の同一問い合わせが発生していた。
この構造により、
- サービスBへの不要なリクエストが大量発生する
- サービスAもサービスB呼び出し前に DB アクセス等の処理を行うため、A 自体の負荷も増大する
という問題が発生していた。
課題
問題の本質は次の点にある。
- サービスBへの問い合わせは 1 回成功すれば十分である
- しかしシステムはクライアント数分の問い合わせを実行してしまう
- その結果、下流サービスBがボトルネックになる
- さらにサービスA側でも無駄な前処理が発生する
つまり、「重複して意味のない処理」がシステム全体の負荷を増加させていた。
対策:Redis SET NX によるリクエスト集約
この問題に対し、Redis の SET key value NX EX ttl を利用して簡易ロックを導入した。
処理の流れは次の通り。
-
サービスAがサービスBを呼び出す前に Redis へロック取得を試みる
-
SET NXに成功したリクエストのみがサービスBへアクセスする -
ロック取得に失敗したリクエストは処理をスキップする
-
TTL により一定時間後にロックは自動解除される
これにより、同一タイミングで発生したリクエストのうち、最初の一つだけがサービスBへ問い合わせを行う構造となった。
※ Redisがクラスターやレプリケーション構成を組んでいる場合、RedisのSET NXでは厳密に1つのリクエストしか通らないとは限らないが、今回はリクエスト数を減らして負荷を下げることが目的だったため、この設計で許容した
結果
この対策により次の効果が得られた。
- サービスBへのリクエスト数が 1/N(Nはクライアント数)に減少
- サービスBの負荷が大幅に削減された
- ロック取得に失敗したリクエストでは DB アクセス等の前処理も実行されなくなり、サービスAの負荷も低減
- ピーク時の負荷スパイクが緩和された
今回の目的は「完全な一意処理」ではなく「負荷削減」であったため、このアプローチで十分な効果を得られた。
ここから下はこの設計で問題点や代替案などについて記述する。
Redis SET NX の問題点について
上でも少し触れたように、この方式には次の問題がある。
- Redisがクラッシュした場合、昇格したSecondaryでロックが取得できてしまう可能性がある(非同期複製の遅延)
- TTL 管理に依存するため、処理時間と TTL のバランス調整が必要
つまり、「完全に1つのリクエストに集約する」ことは保証できない。
しかし今回のように、
・負荷削減が目的
・多少の重複は許容可能
というケースでは十分に実用的な手法となる。
厳密な一意性が必要な場合(Redlock)
繰り返しにはなるが、Redis の SET NX を用いたロックは、通常時のリクエスト重複を抑制するには十分有効だが、Redis のレプリケーションは基本的に非同期であるため、フェイルオーバー時にロック情報が引き継がれず、結果としてロックが二重取得される可能性がある。
この問題をより厳密に防ぐ手法として Redlock がある。Redlock は複数の独立した Redis ノードに対してロックを取得し、過半数で成功した場合のみロック成立とみなす分散ロック方式で、障害時でもロックの重複取得が起きにくくなる。
ただし、本件では目的が「完全な排他制御」ではなく「サービスの負荷削減」であり、多少の重複実行は許容可能であった。そのため、運用コストや実装複雑性を考慮し、今回は単純な SET NX による制御を採用した。
Go における代替手法:singleflight
Go 環境では golang.org/x/sync/singleflight を使うことで、同一キーの処理を1つに集約できる。
概念的には次のような動作になる。
// 同じキーで同時に呼ばれた処理を1つだけ実行し、他は結果を共有する
var g singleflight.Group
v, err, _ := g.Do("key", func() (interface{}, error) {
return callServiceB()
})
メリット:
- 同一プロセス内では確実に1回だけ実行される
- 実装が簡単
- 結果共有も可能
デメリット:
- プロセスを跨いだ集約はできない
- マルチインスタンス環境では効果が限定的
そのため、分散環境では Redis ロックとの併用や別方式が必要になる。
※ 今回はサービスAがECS/Fargateで複数台稼働しておりLBが振り分けているので、この手法はあまり意味がないと考え採用しなかった
まとめ
今回の設計は、
- 厳密な一意性より負荷削減を優先
- 実装コストを抑えつつ即効性を重視
という判断のもと採用した。
対策を入れたことで、Redis の SET NX を用いた簡易ロックにより重複したリクエストを削減し、サービスA/B 双方の負荷を軽減できた
結果として、ピーク時のリクエストスパイクを抑制し、システム安定性を向上させることができた。