# 跨產品餘額同步：用 WebSocket 即時顯示另一個產品的餘額

> 產品 A 把使用者餘額轉入產品 B、A 的客戶端又要顯示 B 的餘額時，經由 A 的後端輪詢會讓 A 的處理能力受制於 B 的延遲。附流程圖說明 B 透過 WebSocket 推送每次變動如何解決。

Source: https://hibira.io/zh-tw/topics/balance-sync-websocket
Updated: 2026-11-09

兩個產品之間常見的做法：使用者把一部分餘額從產品 A 轉入產品 B，再到 B 裡使用。使用者在 B 裡花掉或贏得金額時，A 的客戶端仍然要即時顯示 B 裡還剩多少。第一版通常用 HTTP 去問：A 的客戶端輪詢 A 的後端，A 的後端再去問 B。每一次輪詢都變成橫跨兩個產品的請求鏈，流量一上來，限制你能服務多少使用者的就不是任何一個產品，而是這條鏈。

## 請求鏈是怎麼疊起來的

```text
flow: 改之前：每個使用者每隔幾秒，A 的客戶端問 A 的後端，A 的後端再問 B
== 轉入餘額
A 的客戶端 --POST /transfer--> A 的後端|等待 B --POST /deposit--> B 的後端
== 在 A 的客戶端顯示 B 的餘額
A 的客戶端|每 5 秒 --GET /b-balance--> A 的後端|等待 B --GET /balance--> B 的後端|每個請求佔一個 worker
A 的客戶端|每 5 秒 --GET /b-balance--> A 的後端|等待 B --GET /balance--> B 的後端|每個請求佔一個 worker
```

每個 HTTP 請求在等待時都會佔住資源：一個 worker 執行緒、用戶端連線池裡的一條連線，通常還有一條資料庫連線。A 的請求要等 B 回應後才能回應，所以佔住這些資源的時間是自己的處理時間，加上 B 花掉的全部時間。[Little 定律](https://en.wikipedia.org/wiki/Little%27s_law)可以算出這個數字：處理中的請求數 = 每秒請求數 × 每個請求花的秒數。而且只要有 A 的使用者開著頁面，B 每隔幾秒就會收到一個請求，不管餘額有沒有變。

## 要付出多少 worker

以下是推算範例，不是壓測結果（撰寫於 2026-11-09）：A 有 5,000 名使用者的畫面上顯示著 B 的餘額，A 的客戶端每 5 秒輪詢一次，所以每秒有 1,000 個請求經過 A 送到 B。

| | 經由 A 的後端輪詢 | B 透過 WebSocket 推送每次變動 |
| --- | --- | --- |
| 每秒到達 A 和 B 的餘額請求 | 各 1,000 個，大多數沒有變化 | 沒有；客戶端連線時讀一次 |
| 每個請求佔住 A 的 worker 的時間 | 自身 10 ms + 呼叫 B 的 60 ms = 70 ms | 完全不經過 A |
| A 忙著轉送 B 餘額的 worker 數 | 1,000 × 0.07 秒 = 70 | 0 |
| B 變慢到 600 ms 時 | 1,000 × 0.61 秒 = 610，是 200 個 worker 連線池的三倍 | 仍然是 0：只是下一次更新晚一點到 |

痛點在最後一列。假設連線池有 200 個 worker，B 一時變慢就足以把 A 的用光：A 自己的請求，包括登入和付款，都在佇列裡排隊、逾時，用戶端再重試，又加進更多請求。A 的併發量正好在流量高峰時下滑，原因卻是一個 A 根本不經營的產品。同時，B 也把自己的處理能力花在回應大多是「沒變」的輪詢上。

## 改之後：B 每次變動只發佈一次

```text
flow: 改之後：B 發佈使用者餘額的每次變動，A 的客戶端直接收到
A 的客戶端 --POST /transfer--> A 的後端 --POST /deposit--> B 的後端|寫入 B 的帳本
B 的後端|每次變動後 --發佈 balance.updated--> *Hibira|private-b-balance-42 --WebSocket--> A 的客戶端|以及 B 自己的客戶端
```

```text
sequence: 使用者轉 1,000 到 B，之後在 B 花了 120；A 的客戶端兩次都立刻看到
A 的客戶端 | A 的後端 | B 的後端 | *Hibira
1 > 2: 轉 1,000 到 B
2 > 3: POST /deposit | 冪等鍵
3 >> 2: 200 | B 餘額 1,000，版本 1
3 > 4: balance.updated | private-b-balance-42
4 > 1: balance.updated | 1,000，版本 1
note: 之後使用者在 B 花了 120
3 > 4: balance.updated | 880，版本 2
4 > 1: balance.updated | 880，版本 2
```

B 每寫入一次變動（包括轉入）就發佈一次。A 的客戶端保持一條 WebSocket 連線，一趟網路往返就收到新餘額。B 的餘額不再經過 A 的後端，所以變慢或忙碌的 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 的餘額，例如讓使用者把錢轉回來之前，可以用 Pusher 用戶端函式庫靠同樣的事件維護一份副本，不必每次都呼叫 B。

## 不變的部分

從 A 轉到 B 仍然是帶冪等鍵的 HTTP 請求，重試也絕不會把錢轉兩次；B 每次寫入時也仍會自己檢查餘額。WebSocket 傳送的是「B 的餘額變了」這個消息，不是變動本身。如果另一端的產品是外部 API 供應商，轉帳本身也可能塞爆佇列：請看[與 API 供應商的錢包轉帳](https://hibira.io/zh-tw/topics/wallet-transfers-api-providers)。

## 常見問題

### 為什麼不乾脆降低輪詢頻率？

間隔拉長，請求變少，但餘額也變舊：在 B 贏得的金額要晚一整個間隔才出現在 A 的客戶端，而且每次輪詢仍然要等 B。推送一次變動只要一趟網路往返，沒有變動時什麼都不送。

### A 的客戶端斷線時漏掉事件怎麼辦？

pusher-js 和其他用戶端函式庫會自動重新連線。重新連線後先用 HTTP 讀一次 B 的餘額，只套用版本更高的事件。版本號也讓重複或遲到的事件無害。

### A 需要改 B 的程式嗎？

B 只要加一件事：寫入餘額後發佈 `balance.updated`，如果 B 有自己的應用，再加上簽署訂閱。它的轉帳 API 維持不變。

### 這些事件在 Hibira 上怎麼計算？

每次發佈算一則訊息。付費方案不計算送給訂閱者的份數，所以同一個餘額同時顯示在 A 和 B 的客戶端，費用和只顯示在一個客戶端相同。請看[價格](https://hibira.io/zh-tw/pricing)。
