iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0

Day 11 我們做出了最陽春的 Notification,Day 12 用 Message Queue 把它從同步流程裡拆了出去:

  • Like API 只負責 Create Like + Publish Message
  • Notification Worker 負責背景消化這些 Message

這解決了「同步處理拖累主流程」的問題,但留下一個新的使用者體驗問題:

Notification Worker 把通知寫進 Database 之後呢?

Pikachu 現在要收到這則通知,還是得自己重整頁面或是重打 App,讓前端再呼叫一次 GET /notifications 才會看到。

對一個社群平台來說,這種「使用者要自己去問,系統才回答」的方式,體驗上明顯不夠即時。

簡單的實作:Polling

最簡單的做法是讓前端定期主動問

每 5 秒:
前端 -> GET /notifications -> Server

這就是 Polling(輪詢),有點像餐廳點餐,然後顧客一直問「好了沒?」。

實作起來非常簡單,但顯而易見的缺點是大多時候根本沒有新通知,這些 Request 都是白問。

使用者一多,Polling 等於一直在浪費 Server 資源做「沒事」的查詢,而且就算真的有新通知,使用者最多也要等到下一次週期(例如 5 秒)才會看到,稱不上真正即時。

一個折衷做法是 Long Polling(長輪詢),Server 收到 Request 後先不馬上回應,而是「掛著」這個連線,直到真的有新通知或等超過某個時間上限才回傳。

Long Polling 比單純 Polling 即時一些,但本質上還是「Client 主動問、Server 被動答」,而且大量掛著的連線本身也是一種資源負擔。

真正的推播:WebSocket 與 SSE

如果我們想要的是「有新通知的當下,Server 直接推給 Client」,就需要一個能讓 Server 主動說話的管道。

WebSocket

WebSocket 建立的是一條雙向的持久連線,Client 跟 Server 都可以隨時主動傳資料給對方,很適合頻繁互動的場景,例如即時訊息、線上狀態、打字提示等。

但對 Notification 這種單向推播的情境來說,WebSocket 的雙向特性其實用不到,而且要在現有的 HTTP 基礎設施上支援 WebSocket,通常也需要額外的 Proxy 或 Load Balancer 設定。

Server-Sent Events(SSE)

SSE 則是單向的,Server 可以隨時往 Client 推資料,但 Client 沒辦法透過同一條連線回傳資料給 Server,如果要送資料回去,還是走一般的 HTTP Request。

對 Notification 這個情境來說,Client 只需要「被動接收」通知,不需要透過同一個管道回傳任何東西,SSE 已經完全夠用,而且比 WebSocket 更輕量、更容易跟現有的 HTTP 基礎設施相容。

如果之後 PokeThreads 想做更即時互動的功能(例如打字提示、線上狀態),才會有更強的理由整套換成 WebSocket。

新問題:Stateless 架構下,連線要掛在哪?

這裡出現一個有趣的衝突,我們已把架構設計成 Stateless,任何一台 Server 都應該能處理任何使用者的 Request,好處是可以自由地增加、移除 Server。

但 SSE/WebSocket 的連線,本質上是長時間掛在某一台特定 Server 上的。

Pikachu 打開 App 的那一刻,他的裝置會跟某一台 Server(假設是 Server B)建立一條持續開著的連線,這條連線本身就是一種「State」,而且是釘死在 Server B 上的 State。

所以問題就來了,Notification Worker 處理完一則要通知 Pikachu 的訊息時,它並不知道 Pikachu 現在的連線是掛在哪一台 Server 上。

解法:用 Pub/Sub 當作連線的路由層

解法跟 Day 6 處理 Session 的邏輯很像:把「誰的連線掛在哪裡」這件事,變成一個所有 Server 都能查詢的共用資訊

具體做法是引入一個 Pub/Sub 機制(例如 Redis Pub/Sub),每台 Server 在建立好某個使用者的連線時,就訂閱一個屬於這個使用者的頻道:

Server B 訂閱頻道 `user:Pikachu:notifications`

當 Notification Worker 產生一則要通知 Pikachu 的訊息時,他不需要知道 Pikachu 到底連在哪台 Server,只要對這個頻道發布訊息:

PUBLISH user:Pikachu:notifications "你有一則新通知"

透過 Pub/Sub 即時推送通知的架構圖

整條路徑串起來就是:

Notification Worker -> Pub/Sub -> Server B(訂閱了 Pikachu 的頻道)-> 推送給 Pikachu 的連線

哪一台 Server 訂閱了這個頻道,Pub/Sub 就會把訊息交給它,那台 Server 再透過自己手上實際持有的那條 SSE 連線,把訊息推送到 Pikachu 的裝置上。

這樣一來,Notification Worker 完全不需要知道連線的實體位置,Server 之間也不需要互相知道彼此持有哪些連線。

這正是 Stateless 架構下處理連線問題的典型解法:把跟特定使用者綁定的 State,一樣搬到 Server 外部的共用元件裡

Trade-off:連線本身也是一種成本

即時推播不是沒有代價的,每一條長時間開著的 SSE/WebSocket 連線,都會佔用 Server 的記憶體與檔案描述符(File Descriptor)。

使用者一多,單台 Server 能同時撐住的連線數就是一個要盯緊的指標。這也代表如果某台 Server 需要重啟或下線,上面掛著的連線會全部斷開,Client 端需要有自動重新連線的機制,重新連上任何一台可用的 Server,再重新訂閱自己的頻道。

小結

今天把 Notification 從「Worker 把資料寫進 Database 就結束」,往前推進到「使用者幾乎即時收到通知」:

  • Day 11:同步寫入,使用者要自己刷新才看得到
  • Day 12:透過 Queue 非同步處理,但仍要使用者主動查詢
  • Day 24:透過 SSE + Pub/Sub,Worker 完成後直接推播給正確的 Server

而這整個推播機制之所以行得通,關鍵在於用 Pub/Sub 把「連線掛在哪一台 Server」這個 State,從單一 Server 手上解放出來,讓整個系統依然維持 Day 6 建立起來的 Stateless 精神。


上一篇
Day 23 Counter System 如何 Scaling
系列文
系統設計就像九頭蛇:打造社群網站的 30 天24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言