プロダクト間の残高同期:別プロダクトのリアルタイム残高を WebSocket で表示
プロダクト A がユーザーの残高をプロダクト B に移し、A のアプリが B の残高を表示するとき、A のバックエンド経由のポーリングは A の処理能力を B の遅延に縛ります。B が変更ごとに WebSocket でプッシュして解決する方法をフローチャートで解説。
二つのプロダクトの間でよくある構成です。ユーザーが残高の一部をプロダクト A からプロダクト B に移し、B で使います。ユーザーが B で使ったり獲得したりしている間も、A のアプリは B にいくら残っているかをリアルタイムで表示しなければなりません。最初の実装はたいてい HTTP で尋ねる形です。A のアプリが A のバックエンドをポーリングし、A のバックエンドが B に尋ねます。ポーリングのたびに二つのプロダクトをまたぐリクエストの連鎖ができ、トラフィックが増えると、何人のユーザーに対応できるかを決めるのはどちらのプロダクトでもなく、この連鎖になります。
リクエストの連鎖はこうして積み上がる
残高を移す
A のアプリに B の残高を表示する
HTTP リクエストは待っている間、何かを占有します。worker スレッド、クライアントのコネクションプールの接続、多くの場合はデータベース接続もです。A のリクエストは B が応答するまで応答できないので、自分の処理時間に加えて B にかかる時間すべての間それらを占有します。リトルの法則で数字にできます。処理中のリクエスト数 = 毎秒のリクエスト数 × 1 件あたりの秒数です。しかも B には、ページを開いている A のユーザーごとに数秒おきにリクエストが届きます。残高が変わったかどうかに関係なくです。
worker がどれだけ必要か
ベンチマークではなく試算の例です(2026-11-09 作成)。A のユーザー 5,000 人の画面に B の残高が表示され、A のアプリは 5 秒ごとにポーリングするので、毎秒 1,000 件のリクエストが A を通って B に届きます。
| A のバックエンド経由のポーリング | B が変更ごとに WebSocket でプッシュ | |
|---|---|---|
| 毎秒 A と B に届く残高リクエスト | それぞれ 1,000 件、ほとんどは変化なし | なし。アプリの接続時に一度読むだけ |
| 1 件が A の worker を占有する時間 | 自身の処理 10 ms + B への呼び出し 60 ms = 70 ms | A を一切通らない |
| B の残高の中継に使われる A の worker 数 | 1,000 × 0.07 秒 = 70 | 0 |
| B が 600 ms に遅くなったとき | 1,000 × 0.61 秒 = 610、200 のプールの 3 倍 | 変わらず 0:次の更新が少し遅れて届くだけ |
痛いのは最後の行です。プールが 200 worker なら、B が一時的に遅くなるだけで A のプールを使い切ります。ログインや決済を含む A 自身のリクエストがキューで待ち、タイムアウトし、クライアントが再試行してさらにリクエストが増えます。A の同時処理数はちょうどトラフィックのピークで落ち、その原因は A が運営していないプロダクトです。そのあいだ B も、ほとんどが「変化なし」のポーリングに自分の処理能力を使っています。
変更後:B が変更ごとに一度だけ発行する
B は変更を書き込むたびに、入金も含めて一度だけ発行します。A のアプリは WebSocket 接続を一つ保ち、ネットワーク 1 往復の時間で新しい残高を受け取ります。B の残高はもう A のバックエンドを通らないので、遅い B や混んでいる B が A の worker を占有することはなく、B が何千件ものポーリングに応える必要もなくなります。
設定のしかた
- B の残高は B が持つ。 B にいくら残っているかの唯一の正は B の台帳です。コミットのたびに、B は任意の Pusher サーバーライブラリの HTTP API で、新しい残高とバージョン番号を付けた
balance.updatedを発行します。 - ユーザーごとにプライベートチャンネルを一つ。 たとえば
private-b-balance-42です。A と B が同じアプリを使うなら、A の認可エンドポイントがサインイン中のユーザーが 42 であることを確かめてから購読に署名します。B が自分のアプリを持つなら、A のアプリは B のアプリキーで接続し、B の認可エンドポイントが署名します。中継側に暗号文しか見せたくないならprivate-encrypted-b-balance-42を使います。ほかのプライベートチャンネルは通信中は TLS で保護され、保存時に暗号化されます。 - バージョン番号を使う。 画面の表示より古いバージョンのイベントは無視します。A のアプリが接続や再接続をしたら、HTTP で一度 B の残高を読み、それからイベントを適用します。
- A のバックエンドも受け取れる。 たとえばユーザーがお金を戻す前など、A がサーバー上で B の残高を必要とするなら、毎回 B を呼ぶ代わりに Pusher クライアントライブラリで同じイベントを受けて写しを最新に保てます。
変わらないこと
A から B への移動はこれまでどおり冪等キー付きの HTTP リクエストで、再試行しても二重に移ることはありません。B も書き込むたびに自分で残高を確認します。WebSocket が運ぶのは B の残高が変わったという知らせで、変更そのものではありません。相手側のプロダクトが外部の API プロバイダーなら、送金そのものもキューをあふれさせることがあります。API プロバイダーとのウォレット送金をご覧ください。
よくある質問
ポーリングの間隔を長くするだけではだめですか?
間隔を長くすればリクエストは減りますが、残高は古くなります。B で獲得した分が A のアプリに出るのは丸々一間隔あとで、しかも毎回のポーリングは B を待ちます。プッシュならネットワーク 1 往復で届き、何も変わらなければ何も送りません。
切断中に A のアプリがイベントを取りこぼしたら?
pusher-js などのクライアントライブラリは自動で再接続します。再接続後に HTTP で一度 B の残高を読み、それより新しいバージョンのイベントだけを適用します。バージョン番号があれば、重複や遅れて届いたイベントも害になりません。
A は B のコードを変える必要がありますか?
B に必要な追加は一つです。残高を書き込んだら balance.updated を発行すること、そして自分のアプリを持つなら購読に署名することです。送金 API はそのままです。
Hibira ではこれらのイベントをどう数えますか?
1 回の発行が 1 メッセージです。有料プランでは購読者への配信は数えないので、同じ残高を A と B の両方のアプリに表示しても、片方だけに表示するのと同じコストです。料金をご覧ください。
製品名は各所有者の商標です。