Webアプリで、同じ更新リクエストがほぼ同時に複数回送信されることがある。
イメージとしてはこんな感じだ。
リクエストA → DB更新成功 リクエストB → エラー リクエストC → エラー
最初のリクエストでDBの更新自体は成功している。
そのためユーザーから見ると処理は正常に完了しており、大きな実害は発生していない。
しかし、本来1回しか送信されないはずのリクエストが複数回送信されている以上、何らかの問題が潜んでいる。
しかも厄介なのは、毎回ではなく「たまに」発生することだ。
今回は、このような問題について原因として何が考えられるのか、そしてどのような順番で調査するのがよいのかを整理してみる。
まず疑うのはフロント側
今回のような
- 同じAPIがほぼ同時に呼ばれる
- 1回目は成功する
- 2回目以降はエラーになる
- 毎回ではなく、ときどき発生する
という現象では、まずフロント側の多重実行を疑いたい。
Webアプリでは、同じ処理が意図せず複数回実行されることはそれほど珍しくない。
特にAngularのようにイベントと非同期処理を多用するフレームワークでは、複数の処理経路から同じAPIに到達してしまう可能性がある。
よくある原因
ボタンの連打
最も単純なのがこれ。
<button (click)="save()">保存</button>
API通信中もボタンが有効であれば、ユーザーが短時間に複数回クリックすることで複数リクエストが発生する。
通信開始時にボタンを無効化するなどの対策が考えられる。
clickとsubmitの両方から呼んでいる
フォームでは、イベントの組み合わせによって同じ処理が複数経路から呼ばれることがある。
たとえば、
<form (ngSubmit)="save()"> <button type="submit" (click)="save()">保存</button> </form>
のような実装では、クリックとsubmitの両方からsave()が実行される。
これは比較的分かりやすい例だが、実際のアプリでは親子コンポーネントや共通処理を経由するため、もっと見つけにくい形になっていることもある。
Observableの多重subscribe
AngularではRxJSを利用するため、Observable周辺も確認したい。
同じObservableを複数箇所でsubscribe()していたり、コンポーネントが生成されるたびにsubscriptionが追加されていたりすると、1つのイベントから複数回処理が実行される可能性がある。
特に、
valueChanges router.events Subject EventEmitter
などを起点としてAPIを実行している場合は注意したい。
複数のイベントから同じ処理へ到達している
個人的には今回のような「たまに発生する」問題では、ここを特に疑いたい。
たとえば、
ユーザー操作 ├─ イベントA ─┐ │ ├─ update() └─ イベントB ─┘
という構造になっている場合だ。
通常はイベントAだけが発生するが、特定のタイミングではイベントBも発生する。
すると、ほぼ同時に同じAPIが呼ばれる。
単純な実装ミスなら毎回発生する可能性が高い。
「たまにしか発生しない」ということは、タイミングや状態によって処理経路が重なるタイプの問題である可能性がある。
retryやバックエンド側の再実行
もちろんフロント以外の可能性もある。
- HTTP通信のretry
- タイムアウト後の再送
- バックエンド内部での多重実行
- プロキシやインフラによる再試行
なども候補になる。
そのため最初から「フロントのバグだ」と決めつけるのではなく、まず「どこでリクエストが増えたのか」を特定する必要がある。
調査では最初にフロントかバックかを切り分ける
重要なのは、いきなりコードを細かく読むことではない。
まず、
本当にフロントから複数リクエストが送信されているのか
を確認する。
ブラウザのDeveloper ToolsのNetworkタブで再現できれば分かりやすい。
ただし「たまにしか発生しない」問題では、開発環境で簡単に再現できない可能性も高い。
そこでログを利用する。
リクエストIDを付ける
調査をしやすくする方法として、フロントでリクエストごとにIDを発行する方法がある。
const requestId = crypto.randomUUID(); console.log('update start', requestId); this.service.update(data).subscribe({ next: () => console.log('update success', requestId), error: error => console.log('update error', requestId, error), });
さらに、このIDをバックエンドにも送る。
するとログ上で、
12:00:00.100 requestId=A 12:00:00.112 requestId=B 12:00:00.118 requestId=C
のように確認できる。
A、B、Cという異なるIDがフロントで生成されていれば、
フロント側でAPI呼び出し処理そのものが3回実行された
可能性が高い。
逆にフロントでは1回しか記録されていないのに、バックエンドでは複数回処理されているのであれば、調査対象を通信経路やバックエンド側へ移せる。
まずこの境界を明確にすることが重要だ。
APIを呼んでいる場所から逆方向に追う
フロント側が怪しいと分かったら、APIを呼び出している箇所を検索する。
たとえば、
this.xxxService.update(...)
を検索する。
ただし、それだけでは不十分なこともある。
重要なのは、
HTTP API ↑ Service ↑ update() ↑ イベント処理 ↑ ユーザー操作
というように、APIから逆方向に処理経路を追うことだ。
そして、
「このAPIへ到達できる経路はいくつあるのか」
を確認する。
1本だと思っていた経路が、実は2本、3本存在していることがある。
今回ならこの順番で調査する
今回のような現象なら、私は次の順番で確認する。
- Networkやログで、本当に同一APIが複数回呼ばれていることを確認
- フロントでAPI呼び出し直前にログを出す
- リクエストIDを付けてバックエンドログと突き合わせる
- API呼び出し箇所を検索する
- そこへ到達するイベントを逆方向に追う
- click、submit、親子イベントなどの二重発火を確認する
- ObservableのsubscribeやRxJSの処理を確認する
- コンポーネントのライフサイクルを確認する
- retry設定を確認する
- フロントで説明できなければバックエンドやインフラを調べる
闇雲にコードを読むより、
どのレイヤーで処理が複数になったのか
を先に切り分けた方が調査範囲を大幅に狭められる。
フロントを直すだけで終わらせない
仮に原因がフロントの二重送信だったとしても、もう一つ考えておきたいことがある。
それがAPIの「冪等性」だ。
冪等性とは簡単に言えば、
同じ操作が複数回来ても、システムの状態がおかしくならない性質
のことである。
Webでは、二重クリックだけでなく、通信障害やretryなどによって同じリクエストが複数回届く可能性がある。
そのため重要な更新処理では、
同じリクエストが2回来た ↓ 1回目は処理 ↓ 2回目は「すでに処理済み」と判断 ↓ 安全に終了
という設計も検討する価値がある。
つまり、
フロントでは多重送信を防ぐ。
そして、
バックエンドでは多重送信されても壊れないようにする。
この二段構えが理想になる。
まとめ
同じAPIリクエストがほぼ同時に複数回送信される問題は、Webアプリではそれほど珍しいものではない。
特に「毎回ではなく、たまに発生する」という場合は、複数イベントや非同期処理が特定のタイミングで重なっている可能性を疑いたい。
ただし、最初からフロントのバグだと決めつけるべきではない。
まず確認すべきなのは、
「どのレイヤーで1回が複数回になったのか」
である。
フロント、通信経路、バックエンド、DBと順番に境界を切り分けていけば、調査範囲はかなり小さくできる。
そして原因を修正した後も、バックエンド側で冪等性を確保できないか検討する。
「1回しか来ないはず」と考えるのではなく、
「複数回来ることはあり得る。それでも安全に処理できる」
という設計にしておくことが、Webアプリを堅牢にする上では重要なのだと思う。