Request Deduplication(リクエスト重複排除)は、同一の並列リクエストを1つにまとめるメカニズムであり、データソースは数十の代わりに1つの呼び出しのみを受け取ります。モバイルアプリケーションでは、重複排除が特に重要です。複数の画面が同時に同じユーザープロファイルや商品リストを要求する可能性があります。Square Engineering(2024年)によると、重複排除の導入により、サーバーロジックを変更することなくAPI負荷が30%削減されました。
重要なポイント
Request Deduplication(リクエスト重複排除)は、同じ時間枠内で同一のデータソースに対する複数の同一リクエストの実行を防ぐ技術です。10個の同一HTTPリクエストを送信する代わりに、システムは1つを送信し、残りの9つはその結果を待ちます。
重複リクエストの問題は、状態ベースのアーキテクチャ(MVVM、MVI、Redux)を使用するモバイルアプリケーションで特に深刻です。複数のオブザーバーが短時間に同じデータを購読すると、それぞれが独自のリクエストをトリガーし、冗長な負荷を生成します。Uber Engineering(2024年)によると、Uberのモバイルクライアントにおける全リクエストの最大18%が重複しており、クライアントサイドの重複排除によりその数は4分の1に削減されました。
重複排除はキャッシュと同じではありません。キャッシュは実行後にリクエスト結果を保存します。重複排除は、リクエストの実行前および実行中に冗長なリクエストを防ぎます。リクエストが完了した後は、キャッシュが機能します。
class DeduplicatorT(
private val source: suspend () -> T
) {
private val inFlight = ConcurrentHashMap<String, Deferred<T>>()
suspend fun get(key: String): T = inFlight.getOrPut(key) {
async {
source().also { inFlight.remove(key) }
}
}.await()
}
このKotlinクラスは、キーごとに1つのコルーチンのみが実行されることを保証します。同じキーを持つすべての同時呼び出しは、1つのDeferredを待機します。完了後、キーは削除され、次のリクエストは正常に実行されます。
サーバー負荷の軽減は、最初で最も明白な理由です。重複リクエストはそれぞれサーバーリソース(CPU、メモリ、データベース接続)を消費します。数百万台のデバイス規模では、10~15%の重複リクエストでも重大な負荷が生じ、追加のサーバーが必要になります。
バッテリーとデータ使用量の削減 — モバイルデバイス上の各HTTPリクエストは無線モジュールのエネルギーを消費します。Google I/O(2025年)によると、1つの失敗または重複リクエストは、1回のネットワークセッションのエネルギーの最大15%を消費する可能性があります。重複排除は無線モジュールの起動回数を減らし、デバイスのバッテリー寿命を延ばします。
データ競合の回避 — 2つの重複リクエストがローカルストレージにデータを書き込む場合、競合状態が発生する可能性があります。2番目のリクエストが最初の結果を古いデータで上書きする可能性があります。重複排除により、ローカルストレージへの書き込みが1回のみ行われ、競合が排除されます。
UXの向上 — ユーザーは同じデータに対して複数のローディングインジケーターを表示しません。UI状態(ローディング/成功/エラー)は、複数の競合するリクエストではなく、単一の信頼できる情報源によって管理されます。
Memoization(メモ化)とは、関数の実行中にその結果をキャッシュすることです。関数が同じ引数で既に実行中の場合、新しい呼び出しは2番目のプロセスを開始せず、最初の結果を受け取ります。これはインプロセスシナリオにおける最も単純な重複排除の形式です。
モバイルアプリケーションでの典型的な実装は、DeferredまたはPromiseへのキーのHashMapです。キーは通常、リクエストURL文字列またはパラメータの連結です。エントリの有効期間は、最初のリクエストから応答が完了するまでです。Dropbox Engineering(2024年)によると、Dropboxモバイルクライアントでのメモ化により、重複APIリクエストが40%削減されました。
欠陥のある重複排除 — 危険な誤りです。エラー後にキーを削除しないと、後続のすべてのリクエストが永久に同じエラーを返します。正しい実装では、ErrorとFailureを処理し、キャッシュをクリアして再試行を許可する必要があります。
class MemoizedLoaderT(
private val loader: suspend () -> T
) {
private var cachedResult: Result<T>? = null
suspend fun get(): T = cachedResult ?.getOrThrow() ?: run {
loader().let {
Result.success(it)
}.also { cachedResult = it }
}.await()
}
MemoizedLoaderは、Result<T>を使用してエラーを正しく処理します。成功時はキャッシュし、エラー時は再試行を許可します。このアプローチにより、一時的なネットワーク障害が後続のリクエストをブロックしないことが保証されます。
Request Merging(リクエストマージ)は、同じソースへの複数の異なるリクエストをグループ化し、1つのバッチリクエストとして送信する技術です。重複排除とは異なり、ここでのリクエストは同一ではありません。パラメータは異なりますが、同じリソースを宛先とします。
典型的なシナリオ:アプリケーションの5つの画面が異なるユーザーのプロファイルを要求します。/api/users/1、/api/users/2などへの5つの個別リクエストの代わりに、システムは20ミリ秒待機し、すべてのIDを収集して、1つのリクエスト/api/users?ids=1,2,3,4,5を送信します。ウィンドウタイムアウトが重要なパラメータです。長すぎるウィンドウはUXを損ない、短すぎると十分なリクエストを収集できません。
Netflix Engineering(2023年)によると、GraphQLアグリゲーターBFF(Backend for Frontend)では、リクエストマージによりレイヤー間のHTTP呼び出し数が65%削減され、余分なRTTを排除することで平均応答時間が120ミリ秒短縮されました。非同期ウィンドウ(デバウンス)は、コルーチンまたはRxJavaを介した標準的な実装です。
class BatchMergerT {
private val pending = ConcurrentLinkedQueue<Pair<String, CompletableDeferred<T>>>()
suspend fun get(id: String): T = suspendCoroutine { cont ->
pending.add(Pair(id, cont))
scheduleFlush()
}
}
このミックスインは、各リクエストを一時停止するためにsuspendCoroutineを、グループを収集するために30ミリ秒のウィンドウを使用します。タイマーが切れると、収集されたすべてのIDが1つのバッチリクエストで送信され、各コルーチンが結果を受け取ります。
DataLoaderは(元々はJavaScript/GraphQL用の)ライブラリで、サーバーサイドでバッチ処理とメモ化を実装します。1つのイベントループティック内で同じデータソースへのすべてのリクエストをグループ化し、1回の呼び出しで実行します。DataLoaderはGraphQLで広く使用されていますが、任意のRESTアプリケーションにも適用できます。
仕組み:1つのマイクロタスク内のすべてのloader.load(id)呼び出しがIDの配列に収集され、バッチ関数に渡されます。結果を受け取った後、各IDは配列要素を取得します。DataLoaderのキャッシュは1つのHTTPリクエスト内でのみ機能します。次のリクエストではキャッシュがクリアされ、データの新鮮さが保証されます。
Meta Engineering(2024年)によると、FacebookのGraphQLレイヤーへのDataLoaderの導入により、N+1問題が解消され、典型的なページあたりのデータベースクエリが200から10に削減されました。バッチスケジューリング — DataLoaderの主要な革新 — は、グループ化を最適化するためにprocess.nextTick(Node.js)またはDispatchQueue.main(iOS)を使用します。
Memoization(メモ化)は、単一プロセス(モバイルアプリ、マイクロサービス)に最適です。実装が簡単で、同一の並列呼び出しに効果的です。欠点は、プロセス間やデバイス間では機能しないことです。
Request Merging(リクエストマージ)は、BFFレイヤーまたはアグリゲーターサービスに適しています。サーバー側でのバッチエンドポイントのサポートが必要です。フロントエンドが同じタイプの異なるデータに対して多数の小さなリクエストを行う場合に最適な選択です。
DataLoaderは、GraphQLサーバーの標準です。N+1問題を自動的に解決し、手動のキャッシュ設定を必要としません。GraphQLレイヤーを持つ任意のサーバーに推奨されます。
重複排除付きHTTPキャッシュ — OkHttp(Android)またはURLSession(iOS)レベルで、インターセプターまたはデリゲートを介して重複排除を設定できます。OkHttp CacheInterceptorは、同じURLのリクエストが既に実行中かどうかを確認し、それらをマージするカスタムインターセプターです。このメソッドはビジネスロジックレベルより下で動作し、機能コードを変更せずにアプリケーションのすべてのリクエストをカバーします。
よくある質問
重複排除は、最初のリクエストがまだ実行中に重複リクエストの実行を防ぎます。キャッシュは実行後に結果を保存します。これらは相互に補完します。重複排除はローディング中の繰り返しリクエストから保護し、キャッシュはその後の繰り返しリクエストから保護します。
重複排除キーが誤って選択された場合です。例えば、すべてのユーザーが1つのキーを使用する場合、最初のリクエストが他のすべてをブロックします。キーは具体的でなければなりません:URL、パラメータ、ユーザーIDを含める必要があります。また、重複排除はメトリクス内の実際のリクエスト頻度を隠すことで、サーバーの問題を隠蔽する可能性もあります。
ユーザー向けシナリオの最適なウィンドウは20~50ミリ秒です。これはリクエストのグループを収集するのに十分ですが、ユーザーが遅延に気づくほどではありません。バックグラウンド操作(ログ、分析)の場合、ウィンドウは200~500ミリ秒まで増やすことができます。経験則:ウィンドウは1つのリクエストの実行時間の10%を超えてはなりません。
はい、同じ原則が適用されます。アプリの複数の部分が同じWebSocketチャンネルを購読する場合、重複排除器は1つの接続を開き、すべての購読者にメッセージを配信します。クライアントでのWebSocketメッセージの重複排除には、RxJava ShareまたはKotlin SharedFlowが理想的なツールです。
Androidの場合はMockWebServer(OkHttp)、iOSの場合はOHHTTPStubsを使用します。同一のパラメータで10個の並列リクエストを実行し、サーバーが正確に1回の呼び出しを受け取ったことを確認します。CountDownLatchまたはcoroutineScopeは、テスト内で並列呼び出しを同期するのに役立ちます。
まとめ
ターンキー方式のモバイルアプリケーションを開発します
IT Sectrは2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。