v-taiwan #3:從 socket storm 到 Realtime Owner——前端也是系統成本的一部分

Fortes Huang 在 v-taiwan Meetup #3 的演講:當使用者開了好幾個即時交易 app 的分頁時,要怎麼馴服「socket storm」。改變一切的前提:

前端不只是 UI——它也是系統成本與資料流治理的一部分。

用數字看問題

每開一個分頁,就是一個完整的 runtime:一個 Vue instance、一個 Pinia store、TanStack Query 的 observer、一個 Socket.IO client、一張蠟燭圖、還有一堆 watcher。開三個分頁,每一筆市場 tick 就會被處理三次。

capacity thinking 的練習(這是量級估算,不是 benchmark):如果 70% 的使用者只開 1 個分頁、25% 開 2 個、5% 開 5 個,平均值是每人 1.45 個分頁。10 萬名在線使用者就是 14.5 萬個 runtime——如果限制成每位使用者 1 條 realtime 連線,就能省下 4.5 萬條 stream(約 31%)。每省下一條 stream,背後省的不只是一條連線:還有 JSON parse、computed/watch 的失效、schema 驗證、圖表的 callback,以及原本會發生但現在不會發生的 GC 壓力。

Realtime Owner:誰有資格拿到 tick

選定的策略:只有一個分頁(Leader/Owner)連上 /quote socket 並消費 realtime tick;其他分頁(Follower)只透過 REST 看歷史資料,並在需要時申請成為 leader。協調的訊息走 BroadcastChannel——但只傳控制訊息:

  • 它「該」負責的:leader election(heartbeat、release、stale takeover)、ownership 的申請/釋放、CRUD 之後的失效訊號
  • 它「不該」負責的:傳輸市場 tick——Follower 完全不會收到、也不會渲染 tick;只有 Leader 會消費它

這個切分方式,其實就是字面意義上的 data plane / control plane:高頻的通道(Binance → 後端 → leader 分頁)永遠不會跟低頻的通道(協調訊息)混在一起。

講者對「Policy-Based」多 owner 版本有個誠實的補充:在架構圖上它最完整,但「好看不好用」——leader election 有一堆 edge case、要處理 heartbeat、還得除錯「為什麼這個分頁不是 leader」。先用 maxRealtimeOwners=1 從簡單開始,只有在產品真的需要時才加上 policy。這是白板架構 vs. 實際要 ship 的東西之間,那個經典的張力。

沒有 refetch storm 的 CRUD

當管理員修改資料時,callUpdate 是以訊號的形式傳遞——它不是資料本身,只是在說「這個資源變了」。本地的失效是立即的,但網路上的 refetch 會帶一個 jitter,避免 QPS 出現尖峰:以 9 個畫面顯示同一個交易對的 grid 為例,1 次管理員的 mutation 不應該觸發 9 次 refetch——leader 只發出 1 次(帶 dedupe + jitter),再把 snapshot 分發給所有 follower。

為什麼上限要放在後端

我最喜歡的部分,因為這是純粹的後端思維:BroadcastChannel 只能協調同一個瀏覽器、同一個 origin 底下的分頁——它看不到使用者的其他裝置。只有後端才擁有以 userId/sessionId 為準的全局視角,而且這個上限還跟方案有關(手機 1 條、桌面 3-5 條、專業交易者 10 條):這是一條商業規則,而不是 UI 規則。在我熟悉的世界裡,直接對應的就是 Spring Security 的 maximumSessions——由伺服器決定允許多少個並行 session,而不是瀏覽器。

我另外記下兩個跟 Java 技術棧的類比:

  • 精度邊界:後端(Rust)傳遞價格的方式是 Binance string → BigDecimal(DB) → string(API)——在前端決定要怎麼顯示之前,絕不使用 f64/number。這正是 Java 裡「金額一律用 BigDecimal」的規則。
  • committed_broadcast_failed:區分「根本沒發生」跟「發生了但沒人知道」。如果 commit 已經完成,但 broadcast 失敗,不會重試那次 write——只會重新同步。這正是在 Kafka + 交易型資料庫的系統裡,outbox pattern 要解決的問題。

關於前端的資料驗證:在 Spring Boot 裡,@Valid + Jackson 免費保護了 HTTP 邊界;但前端的 socket 沒有這種容器——收到的東西字面上就是 anyZod 就是那道邊界:在 runtime 驗證資料形狀,失敗得又快又清楚(如果後端把某個欄位從 string 改成 number,你會在第一筆 tick 就發現,而不是三個 component 之後才變成一個無聲的 NaN),而且透過 z.infer,TS 的型別是直接從 schema 產生的——單一事實來源。

Demo 的 repo:前端(Vue 3 + TS)後端(Rust + PostgreSQL)