跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 輪詢、長輪詢與 WebSocket

輪詢、長輪詢與 WebSocket

伺服器要主動通知用戶端時,輪詢太浪費,改用 WebSocket 長連線:連線本身成了要管理的狀態,系統得知道每個使用者連在哪一台閘道上。

程式碼路徑

① 一直問、等著問、還是一直連著

2,000
2
5 s
輪詢
每秒請求
400
空回應
85%
平均延遲
2.5 s
開著的連線
0
長輪詢
每秒請求
109
空回應
40%
平均延遲
50 ms
開著的連線
2,000
WebSocket
每秒請求
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 秒逾時的那些。

② 一台閘道掛掉,所有人一起重連

5,000
100
退避
02,5005,0000 s10 s20 s30 s
接受被拒絕、稍後重試
單一 100 ms 最多嘗試
5,000
被拒絕的嘗試
32,200
全部連回來
60 秒內沒有恢復

沒有 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。

和其他主題的關係

出現在這些架構裡

時間與空間複雜度(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 On = 200n = 2,000n = 20,000成長倍數:實測(理論)
輪詢:請求O(n)202002,000×100 (×100)
長輪詢:請求O(n)8.888.4883×101 (×100)
WebSocket:推送的事件O(n)3.233.2333×104 (×100)

三種都跟用戶端數成正比,差的是常數:輪詢的常數由間隔決定,就算沒事也照付;WebSocket 只為真正發生的事件付錢。送訊息給某一個人則和人數無關:查一次登錄表就知道他連在哪台閘道。

和其他做法比

每秒請求空回應平均延遲伺服器上開著的
輪詢1,00085%5.0 s0
長輪詢44162%50 ms10,000
WebSocket33.30%50 ms10,000

10,000 個用戶端、每人每分鐘 1 個事件、輪詢每 10 秒一次,同一串事件給三種做法。輪詢的請求數跟事件多寡無關,只看用戶端數和間隔;WebSocket 把請求數換成了一直開著的連線,每條連線都佔著閘道的記憶體和檔案描述子。

單一 100 ms 最多嘗試總嘗試次數全部連回來
固定加倍退避10,00065,80060 秒內沒有
加倍+full jitter2,85532,37819 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,否則閘道會以為死掉的連線還活著,訊息送進黑洞。