【從一台 Server 到分散式架構】第 17 篇:即時通知——WebSocket 與 Pub/Sub
課程平台做大之後,小明他們的需求開始變得「不只是查資料」。
某天,產品經理小雯丟來一個新需求:
「學生上傳作業後,老師要立刻收到通知;老師回覆後,學生也要馬上看到。不要再像現在一樣,過幾分鐘才在頁面上刷新出來。」
小明的第一個反應是:
「那就每 5 秒打一次 API 問『有沒有新通知』?」
小傑搖頭:「這就是輪詢(Polling)。一開始能用,但用戶多了會把後端打爆,而且『即時』的體驗也很假。」
這篇,小明要學兩件事:
- WebSocket:讓伺服器能主動把訊息推給瀏覽器(不是等瀏覽器來問)。
- Pub/Sub:當後端不只一台、長連線分散在多台機器上時,如何把通知送到「連著該用戶的那一台」。
為什麼輪詢不適合「即時」?
輪詢的做法很簡單:
- 前端每隔一段時間呼叫一次
GET /notifications?since=... - 後端回傳最新通知
問題在於,它會把「偶爾才有」的事件,變成「永遠不停」的流量。
假設:
- 10 萬個同時在線使用者
- 每個人每 5 秒輪詢一次
那就是 (100,000 / 5 = 20,000) QPS 的「純詢問」流量——就算大多數回傳都是空的,後端、快取、DB、log 都要為此付出成本。
更糟的是「即時感」也不真的即時:你輪詢間隔是 5 秒,平均延遲就是 2.5 秒;要更即時就得更頻繁輪詢,QPS 又更高。
所以輪詢的本質是:用資源換時間,但換不到真正的即時。
生活化的比喻:等外送 vs 店家打電話叫你取餐
輪詢像什麼?
像你每 30 秒打電話問店家:「我的餐好了嗎?」大部分時間店家都會回:「還沒。」
WebSocket 像什麼?
像你下單後就把電話留給店家,餐一做好店家直接打給你:「可以來拿了。」你不用一直問。
核心差異:誰主動。
WebSocket:讓伺服器主動推送
HTTP 的典型互動是「請求 → 回應」:客戶端不問,伺服器就不會說話。
WebSocket 則建立一條長連線(long-lived connection):
- 一開始仍然走 HTTP(Upgrade 握手)
- 之後升級成 WebSocket
- 兩端可以隨時互相傳訊息(full-duplex)
對「即時通知」而言,這代表:
- 使用者打開網站後建立 WebSocket 連線
- 後端有新事件(作業上傳、老師回覆)時,可以直接 push 給該使用者
只用 WebSocket 還不夠:多台後端時通知會「迷路」
小明他們早就不是單台後端了(第 03、09 篇的水平擴充與狀態問題)。
長連線的世界裡,有一個很現實的問題:
一個使用者的 WebSocket 連線,只會連到某一台 WebSocket Server。
假設:
- 老師 A 的 WebSocket 連在
WS-1 - 學生 B 的 WebSocket 連在
WS-2 - 學生 B 上傳作業,這個請求被負載平衡打到
API-3去處理
此時 API-3 想要通知老師 A,但老師 A 的連線在 WS-1,API-3 根本不知道該找誰推送,甚至連推送的「管道」都不在自己身上。
這就是多機環境下「通知路由」的本質困難:事件產生在某台機器,連線卻在另一台機器。
Pub/Sub:把事件廣播出去,讓握有連線的那台去推送
解法是把「事件」和「推送」拆開:
- 事件:作業上傳、老師回覆、留言新增……由任何服務產生
- 推送:由握有 WebSocket 連線的伺服器負責送出
中間需要一個「把事件送到該去的地方」的機制:Pub/Sub(Publish/Subscribe)。
概念很直覺:
- Publisher:發佈事件(例如:
homework_submitted,comment_replied) - Subscriber:訂閱事件並做處理(例如:WebSocket Server 收到後推送給在線使用者)
兩種常見做法:廣播頻道 vs 用戶頻道
做法一:廣播(所有 WS Server 都訂閱同一個 Topic)
事件發出後,所有 WebSocket Server 都會收到:
- 如果這台 WS Server 剛好握有該用戶連線 → 推送
- 沒有握有 → 忽略
用圖來看,會像這樣(事件「廣播」到所有 WS,再由握有連線的那台負責推送):
優點:簡單。
缺點:事件量大時很浪費(所有 WS Server 都吃到全部事件)。
做法二:以用戶/群組為 Topic(只讓「可能有連線」的那群 WS Server 看到)
例如:
user:{user_id}(個人通知)course:{course_id}(課程聊天室 / 群組通知)
用圖來看,會像這樣(事件發到「用戶/群組 Topic」,只有訂閱者會收到):
優點:效率高。
缺點:Topic 數量多、需要更有系統的設計(尤其是群組/聊天室的訂閱關係)。
小明他們一開始會用廣播起步;等事件量大到浪費明顯,再進化成用戶/群組 Topic。
架構長什麼樣子?
🏗️ 本篇結束後的架構 — API 產生事件,Pub/Sub 分發,WS Server 推送給使用者
你可以把 WebSocket Server 想成「外送員」:它手上握著地址(連線),負責把通知送到正確的客戶端;Pub/Sub 則是「廣播電台」,事件一出現就通知所有外送員。
Redis Pub/Sub、Kafka、還是其他?
小明他們前面已經有 Message Queue(第 08 篇)。但 Pub/Sub 和 MQ 在「需求特性」上有差異:
- 即時推送更像「事件流」:事件一來就要立刻發出去
- 通知事件通常可接受「至少一次(at-least-once)」:偶爾重複推送可以用去重或前端 idempotency 扛住
實務上常見選擇:
Redis Pub/Sub(最快上手)
- ✅ 延遲低、設定簡單
- ✅ 適合「通知型」即時事件(聊天室、在線狀態、提醒)
- ❌ 不是持久化訊息:訂閱者離線時容易漏訊(如果你用的是純 Pub/Sub,而不是 Streams)
Kafka(更工程化、更重)
- ✅ 持久化、可回放、可水平擴充
- ✅ 訂閱者可以自己決定從哪個 offset 開始
- ❌ 成本與複雜度高,不是每個團隊都需要
Redis Streams / 其他(折衷)
- 兼具「延遲低」與一定程度的「可回放」
- 但要管理 consumer group、ack、重試等機制
小明的選型原則仍然是那一句:
只在現有工具真的無法勝任時,才引入新的工具。
因此「第一版」通常會是:WebSocket + Redis Pub/Sub。如果後來遇到「不能漏通知」或「要回放」的強需求,再評估 Streams 或 Kafka。
真正的坑:連線狀態、登入態、以及「這個用戶到底連在哪?」
做即時通知時,常見的麻煩不是 WebSocket 本身,而是周邊配套:
1) 連線要驗證身分(Authentication)
HTTP 有 Cookie / Header;WebSocket 也是一樣要驗證,只是驗證通常發生在「握手」或「連線建立後的第一個訊息」。
小明他們會採用一個簡化規則:
- 連線建立後,客戶端立刻送上 token(或握手時帶 token)
- WS Server 驗證 token,取得 user_id
- 後續推送都以 user_id 為索引
2) 多開分頁、手機/電腦同時登入
同一個 user_id 可能有多條連線(多裝置、多分頁)。推送時要送到「全部」還是「最新」?這是產品決策:
- 私訊/通知:通常送到全部裝置
- 直播聊天室:通常每個裝置都要收到
3) 使用者離線時怎麼辦?
WebSocket 只能推給「在線」的人。離線的人通常要靠:
- 進站後拉取未讀通知(回到 HTTP API)
- 或者走 push notification / email(第 07、08 篇的背景任務/佇列)
也就是:即時推送不是取代通知中心,而是加速在線通知。
小明的落地版本:先把「通知」拆成兩條路
小傑幫小明把需求拆得很清楚:
- 可靠儲存(Notification Inbox):每個通知都要寫入資料庫(或快取 + DB),讓使用者「之後」能看到,不會漏。
- 即時推送(Realtime Push):如果使用者在線,就透過 WebSocket 立刻提醒。
這兩條路可以同時做,彼此互補。
這樣就算 Pub/Sub 有一瞬間抖動或 WebSocket Server 重啟,通知也不會消失:最多是「沒即時跳出」,但使用者一刷新通知中心就看得到。
小結與預告
這篇小明學到的重點是:
- 輪詢的天花板:用戶一多,空回應也會變成巨量 QPS,而且不可能真正即時。
- WebSocket 的價值:建立長連線,讓伺服器主動推送,打造「現在就要知道」的體驗。
- 多機環境需要 Pub/Sub:事件產生在哪台不重要,重要的是「握有連線的那台」要能收到事件並完成推送。
- 可靠通知與即時通知要分層:通知一定要落庫(收件匣),WebSocket 只是加速在線體驗。
做到這裡,小明的系統開始出現另一個「分散式必考題」:當有很多台服務一起跑背景任務、一起做排程、一起處理同一種事件時,怎麼保證某些事情「只會被做一次」?怎麼避免重複發信、重複結算、重複推送?
下一篇,我們來談多機世界裡的「值日生」問題——分散式協調與領導者選舉(Zookeeper / etcd 的直覺)。