輪詢、長輪詢與 WebSocket
伺服器要主動通知用戶端時,輪詢太浪費,改用 WebSocket 長連線:連線本身成了要管理的狀態,系統得知道每個使用者連在哪一台閘道上。
① 一直問、等著問、還是一直連著
- 每秒請求
- 400
- 空回應
- 85%
- 平均延遲
- 2.5 s
- 開著的連線
- 0
- 每秒請求
- 109
- 空回應
- 40%
- 平均延遲
- 50 ms
- 開著的連線
- 2,000
- 每秒請求
- 6.7
- 空回應
- 0%
- 平均延遲
- 50 ms
- 開著的連線
- 2,000
2,000 個用戶端每 5 秒輪詢一次,伺服器每秒要回 400 個請求,其中 85% 什麼新東西都沒有,而一個事件平均還是要等 2.5 s。WebSocket 送出同樣的 19,787 個事件只要 50 ms,握手之後一個請求都不用——代價是伺服器上要一直開著 2,000 條連線。
假設:往返 100 ms;事件隨機(Poisson)發生;長輪詢的請求最多被伺服器留 30 秒,沒事就回空的;模擬 5 分鐘。長輪詢每秒 109 個請求:大致是每個事件一個,加上 30 秒逾時的那些。
② 一台閘道掛掉,所有人一起重連
沒有 jitter,5,000 個用戶端在完全相同的時間點重試:一個 100 ms 湧進 5,000 次嘗試、只接得下 100 個,其他的退避一樣久之後又撞在一起。被拒絕 32,200 次;全部連回來:60 秒內沒有恢復。有 jitter 的話:19 s。
退避:第一次最多等 500 ms,每被拒絕一次上限加倍,最多 16 秒。
const BASE_MS = 500, CAP_MS = 16_000; type Delivery = { via: "socket"; gateway: string } | { via: "push-notification" }; class ConnectionRegistry { private byUser = new Map<string, string>(); // user -> gateway private byGateway = new Map<string, Set<string>>(); // gateway -> users connect(user: string, gateway: string): void { this.disconnect(user); this.byUser.set(user, gateway); const users = this.byGateway.get(gateway) ?? new Set<string>(); users.add(user); this.byGateway.set(gateway, users); } disconnect(user: string): void { const gateway = this.byUser.get(user); if (gateway === undefined) return; this.byUser.delete(user); this.byGateway.get(gateway)?.delete(user); } send(user: string): Delivery { const gateway = this.byUser.get(user); if (gateway === undefined) return { via: "push-notification" }; return { via: "socket", gateway }; } // A gateway died: its users must reconnect somewhere else. gatewayDown(gateway: string): string[] { const users = [...(this.byGateway.get(gateway) ?? [])].sort(); for (const user of users) this.byUser.delete(user); this.byGateway.delete(gateway); return users; }} // How long a client waits before reconnect attempt number `attempt`.function backoffMs(attempt: number, random: number | null): number { const ceiling = Math.min(CAP_MS, BASE_MS * 2 ** attempt); if (random === null) return ceiling; return random * ceiling; // full jitter: anywhere below the ceiling}模型假設與範圍
- 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
- 比較輪詢、長輪詢與長連線的事件送達和重連成本;未包含真實 socket、TCP 壅塞、代理 timeout 與移動網路。
什麼時候用
- 伺服器要主動、馬上通知用戶端:聊天訊息、通知、多人協作、即時比分、股價。
- 事件很稀疏又要求即時:輪詢不是太慢(間隔長)就是太浪費(間隔短)。
- 只是伺服器單向推送、不需要用戶端回傳時,SSE(Server-Sent Events)更簡單,還能走一般的 HTTP。
和其他主題的關係
- 由這些組成
- 資料結構 · 雜湊表(dict/set)
出現在這些架構裡
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 送訊息給一個人 查登錄表找到他的閘道 | O(1) | O(1) |
| 送給一個群組 m 是成員數;每人一次推送 | O(m) | O(m) |
| 閘道掛掉,找出要重連的人 k 是那台閘道上的人;靠反向索引,不用掃全部使用者 | O(k) | O(k) |
| 輪詢的伺服器負擔 n 個用戶端、間隔 T,和事件多少無關 | O(n / T) | O(n / T) |
空間:O(n),每條開著的連線都佔閘道的記憶體和一個檔案描述子
Big O 實測:n 變大時步數怎麼長
數的是:伺服器每秒處理的請求或推送(n 是用戶端數;每人每分鐘 1 個事件,輪詢每 10 秒)
| Big O | n = 200 | n = 2,000 | n = 20,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 輪詢:請求 | O(n) | 20 | 200 | 2,000 | ×100 (×100) |
| 長輪詢:請求 | O(n) | 8.8 | 88.4 | 883 | ×101 (×100) |
| WebSocket:推送的事件 | O(n) | 3.2 | 33.2 | 333 | ×104 (×100) |
三種都跟用戶端數成正比,差的是常數:輪詢的常數由間隔決定,就算沒事也照付;WebSocket 只為真正發生的事件付錢。送訊息給某一個人則和人數無關:查一次登錄表就知道他連在哪台閘道。
和其他做法比
| 每秒請求 | 空回應 | 平均延遲 | 伺服器上開著的 | |
|---|---|---|---|---|
| 輪詢 | 1,000 | 85% | 5.0 s | 0 |
| 長輪詢 | 441 | 62% | 50 ms | 10,000 |
| WebSocket | 33.3 | 0% | 50 ms | 10,000 |
10,000 個用戶端、每人每分鐘 1 個事件、輪詢每 10 秒一次,同一串事件給三種做法。輪詢的請求數跟事件多寡無關,只看用戶端數和間隔;WebSocket 把請求數換成了一直開著的連線,每條連線都佔著閘道的記憶體和檔案描述子。
| 單一 100 ms 最多嘗試 | 總嘗試次數 | 全部連回來 | |
|---|---|---|---|
| 固定加倍退避 | 10,000 | 65,800 | 60 秒內沒有 |
| 加倍+full jitter | 2,855 | 32,378 | 19 s |
10,000 個用戶端同時斷線,存活的閘道每 100 ms 接得下 200 條新連線。兩種退避的上限完全一樣,差別只在要不要隨機:同步的重試會一波一波撞牆,隨機的重試把同一批人攤平在時間軸上。
真實世界裡的它
- Slack、Discord、LINE 的桌面版都靠長連線收訊息;手機 App 在背景時改走 APNs(Apple Push Notification service)/FCM(Firebase Cloud Messaging)推播。
- Google Docs、Figma 的多人編輯,每個游標移動都經過一條 WebSocket。
- AWS 和 Google 的 SDK(Software Development Kit,軟體開發套件)預設就用指數退避加 jitter 重試,正是為了避免重連風暴。
取捨與陷阱
- 連線是狀態:負載平衡不能隨便把同一個人的請求送到別台,閘道重啟就會斷掉一大片人,部署時要慢慢排空。
- 重連沒有 jitter,所有人會在同一瞬間撞上來,把剛恢復的伺服器再打掛一次(thundering herd)。
- 連線斷了不一定會馬上知道:要定期送 ping/pong,否則閘道會以為死掉的連線還活著,訊息送進黑洞。