01

總覽與定位

HTTP 篇與 QUIC 篇一路把「請求/回應」做到極致;本篇處理的是它從頭到尾沒有碰的那一類需求——伺服器想在沒有人問的時候開口。

QUIC 篇的最後一句是:把 transport 換掉,換到的仍然是同一種通訊模式的更好版本——客戶端問、伺服器答,只是問得更快、答得更不容易被卡住。而 HTTP 篇在談完 Server Push 的失敗史時,也留下了同一個缺口:push 推的仍然是「客戶端接下來會請求的東西」,不是「伺服器現在想說的話」。

推播、行情跳動、聊天訊息、協作游標——這四個場景有一個共同特徵:訊息的時機由伺服器決定,客戶端沒有辦法事先問。 你可以用輪詢去猜(每秒問一次「有沒有新的?」),可以讓伺服器把回應 hold 住直到有東西可說(Long Polling),但這些都是在用請求/回應的語法模仿一件它不擅長的事。WebSocket(RFC 6455) 的作法乾脆得多:借 HTTP 開一次門,然後把這條 TCP 連線整個換掉,換成一個雙向、對稱、以訊息為單位的協定。

請求/回應
HTTP/1.1 · 2 · 3
客戶端問一次、伺服器答一次。伺服器沒有「主動說話」的位置——連 Server Push 都只能推「你等一下會要的東西」。
單向串流
SSE · Long Polling
仍然是一個 HTTP 回應,只是不結束。伺服器可以持續講,但客戶端要說話得另開一個請求。
全雙工訊息通道
WebSocket · WebTransport
交握完成後兩端地位對稱,任一方都可以在任何時候送出一則訊息,不需要對方先問。
座標WebSocket 在堆疊裡的位置:一次性借用 HTTP,之後就自立門戶

WebSocket 是應用層協定,跑在 TCP 之上,與 HTTP 平級而非依附於 HTTP。它唯一用到 HTTP 的地方是開場那一個請求:借 HTTP/1.1 的 101 Switching Protocols 換協定(HTTP 篇談狀態碼與標頭的章節已經把這個機制與 Connection: Upgrade 的 hop-by-hop 性質定調,本篇把細節補完)。交握結束後,這條連線上跑的是 WebSocket 自己的二進位 framing,再也沒有 HTTP 標頭、狀態碼或方法。
加密版本沿用 TLS 篇的說法:WSS = 先跑 HTTPS,再在同一條 TLS 通道上做 Upgrade(port 443)——不是另一套加密機制,只是把交握與後續 frame 都塞進既有的 TLS 通道。

把一條 WebSocket 連線的一生拆開,剛好就是本篇的章節地圖。值得先注意的是:右邊那一欄幾乎沒有一項是協定幫你做好的——WebSocket 給的是一條乾淨的雙向訊息管道,管道以外的事全部是你的責任。

生命週期階段協定提供什麼你必須自己做什麼本篇章節
建立Upgrade 交握 / Extended CONNECT認證、Origin 驗證、子協定版本協商第 2 節 · 交握、第 6 節 · 認證
傳輸frame 與訊息邊界、text/binary訊息格式、路由、多路復用(協定不提供)第 3 節 · Framing
保活Ping/Pong control frame心跳週期、逾時判定、half-open 偵測第 4 節 · 保活
擁塞bufferedAmount(就這個)全部的 backpressure 策略第 5 節 · Backpressure
斷線與重連Close frame 與 close code退避重連、狀態恢復、訊息補發第 4 節 · close code、第 5 節 · 重連

這張表也是本篇與 MQTT 篇的分界線:MQTT 會把右欄的一半搬到協定裡(QoS、離線訊息、Last Will)。WebSocket 刻意不做,因為它想當的是通道而不是訊息系統。

一句話答法WebSocket 用一次 HTTP Upgrade 把 TCP 連線換成全雙工的訊息通道,讓伺服器不必等人問就能說話;代價是保活、backpressure、重連與狀態恢復全部變成應用層的責任。
02

交握:101 Switching Protocols 與它的兩個替身

同一件事在 HTTP/1.1、HTTP/2、HTTP/3 上有三種寫法——而第三種之外還有一個「乾脆不要隧道」的選項。

1 · HTTP/1.1:借 101 換掉這條連線

WebSocket 交握是一個貨真價實的 HTTP GET 請求——這一點在第 6 節談認證時會變成關鍵。它多帶了幾個標頭,告訴伺服器「我想把這條連線換成 WebSocket」:

GET /ws HTTP/1.1                              # 就是一個普通的 GET
Host: chat.example.com
Upgrade: websocket                            # 想換到哪個協定
Connection: Upgrade                           # hop-by-hop:路徑上每個 proxy 都要看得懂
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==   # 16 bytes 隨機值的 base64
Sec-WebSocket-Version: 13                     # RFC 6455 定案的版本號
Sec-WebSocket-Protocol: chat.v2               # 可選:應用層子協定協商
Origin: https://app.example.com               # 瀏覽器自動帶上,伺服器必須驗(見第 6 節)

HTTP/1.1 101 Switching Protocols              # 不是 200;之後這條連線不再說 HTTP
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=   # SHA-1(key + 固定 GUID) 的 base64
Sec-WebSocket-Protocol: chat.v2               # 必須是客戶端提議清單裡的其中一個

注意回應是 101 而不是 200。收到 101 的那一刻,兩端都把這條 TCP 連線的「解析器」從 HTTP 換成 WebSocket——已經讀進來但還沒處理的位元組要一併轉交,這是自己實作 WebSocket 伺服器時最常見的 bug。

關鍵Sec-WebSocket-Key 不是安全機制,它是「你真的懂 WebSocket 嗎」的測驗

常見誤解:以為 Key/Accept 是某種身分驗證或防重放。完全不是。 Key 是 16 bytes 隨機值的 base64,明文出現在請求裡;Accept 的算法(把 Key 字串接上一個寫死在 RFC 裡的 GUID 258EAFA5-…-C5AB0DC85B11、做 SHA-1、再 base64)是公開的,任何人都能算出來。它防的是另一種東西:協定混淆。
真正的攻擊情境是這樣:一個不懂 WebSocket 的中間設備(快取 proxy、老舊反向代理)如果看到一個像 GET 的請求就照舊處理,惡意腳本就能誘導它把後續的二進位 frame 誤讀成一個新的 HTTP 請求/回應,進而污染它的快取。要求伺服器回一個「只有真的實作了 RFC 6455 才算得出來」的值,就能確保對面不是一個被騙進二進位對話的一般 HTTP 伺服器或代理。同樣的思路在第 3 節的 masking 會再出現一次。

因為 Connection: Upgrade 是逐跳(hop-by-hop)標頭(HTTP 篇談狀態碼與標頭時已列),路徑上每一個反向代理、負載均衡器都必須顯式支援才能把 Upgrade 轉發下去——這也是實務上「本機好好的、上了測試環境就 400/連上馬上斷」的頭號原因(Nginx 要設 proxy_set_header Upgrade 與 Connection,並把 proxy_read_timeout 拉長)。

2 · HTTP/2:Connection 系列標頭被禁掉之後怎麼辦——RFC 8441

HTTP 篇留了一個尾巴:HTTP/2 直接禁止整組 Connection 系列標頭,因為連線管理已經下沉到 frame 層,用 SETTINGS/GOAWAY 而不是用標頭表達。這代表上面那套交握在 HTTP/2 上一個字都不能用:沒有 Upgrade、沒有 Connection、也沒有 101(HTTP/2 的回應只有一組 :status,不走協定切換那條路)。

RFC 8441(Extended CONNECT) 給的答案是換一個思路:不要「把整條連線換掉」,而是把單一條 stream 變成隧道。

sequenceDiagram
    participant C as 客戶端
    participant S as 伺服器
    S->>C: SETTINGS 宣告能力
    Note over C: 沒收到就不准送
    C->>S: Extended CONNECT
    S-->>C: status 200
    Note over C,S: 這條 stream 之後承載 WebSocket frame

三個回合的順序是強制的:伺服器沒有在 SETTINGS 裡宣告,客戶端連送都不准送——這是與 HTTP/1.1 交握最大的差別(那邊是客戶端先開口,伺服器只能答應或拒絕)。而且成功不再是換掉整條連線的 101,只是這條 stream 上的一個 200。

# 1. 伺服器在 SETTINGS frame 裡宣告能力,缺這一行後面全部不成立
SETTINGS_ENABLE_CONNECT_PROTOCOL = 1

# 2. 客戶端送出的 Extended CONNECT:四個 pseudo-header 缺一不可
:method   = CONNECT
:protocol = websocket     # RFC 8441 新增,一般 CONNECT 沒有這一個
:scheme   = https         # 必須保留
:path     = /ws           # 必須保留——隧道另一端是「端點」不是「host:port」

# 3. 成功的回應
:status   = 200

與一般 CONNECT(給 HTTP proxy 建 TCP 隧道用)的差別在 :protocol 這個新的 pseudo-header,以及必須保留 :scheme 與 :path——因為隧道的另一端不是「某個主機的某個 port」,而是「這台伺服器上的某個 WebSocket 端點」。成功的回應是 200 而不是 101;之後這條 stream 的 DATA frame payload 就是 WebSocket frame。

這個設計有一個很好的副作用:WebSocket 從此共用同一條 HTTP/2 連線,不再各自佔一條 TCP。對瀏覽器來說,這解掉了 HTTP 篇提過的 per-origin 6 連線上限——在 HTTP/1.1 上開六條 WebSocket 就把整個網域的 HTTP 請求餓死了。

3 · HTTP/3:同一套機制換一個載體——RFC 9220

QUIC 篇已經點名這條線的終點。RFC 9220 做的事幾乎是把 8441 原封不動搬到 HTTP/3 上:一樣用 SETTINGS_ENABLE_CONNECT_PROTOCOL 宣告能力、一樣用帶 :protocol 的 Extended CONNECT、一樣拿一條 stream 當隧道。差別在腳下換成了 QUIC,於是這條 WebSocket 隧道順便繼承了 QUIC 的 stream 獨立性與 connection migration——同一條連線上的其他 stream 丟包不會卡住它,手機從 Wi-Fi 切到 4G 也不必重連。

HTTP/1.1(RFC 6455)HTTP/2(RFC 8441)HTTP/3(RFC 9220)
交握方法GET + UpgradeCONNECT + :protocolCONNECT + :protocol
成功回應101 Switching Protocols200200
換掉的粒度整條 TCP 連線一條 stream一條 QUIC stream
能力協商無(試了才知道)SETTINGS_ENABLE_CONNECT_PROTOCOL同左
與其他請求共用連線不行,獨佔一條 TCP可以可以
部署現實到處都通,事實標準中間層支援參差,需逐一驗證最新,生態仍在補

最後一列才是選型重點:8441/9220 在協定上很漂亮,但只要路徑上任何一層(CDN、企業 proxy、舊版 LB)不支援,就會安靜地退回 HTTP/1.1 的老路。所以 2026 年絕大多數 production 的 WebSocket 仍然是「WSS over HTTP/1.1 Upgrade」。

4 · 或者根本不要隧道:WebTransport

把 WebSocket 塞進 HTTP/2 或 HTTP/3 的 stream 裡,解掉的是「連線數」問題,但沒有解掉 WebSocket 自己的一個結構性限制:一條 WebSocket 就是一條有序、可靠的訊息串流。你在上面同時傳一個 10 MB 的檔案和每秒 20 次的游標位置,兩者會互相排隊——這其實就是 HoL Blocking 在應用層又演一次。

WebTransport 的作法是直接把 QUIC 的能力暴露給應用:一個 WebTransport session(也是用 Extended CONNECT 建立,:protocol = webtransport)之下可以開多條雙向/單向 stream,彼此獨立不互相阻塞;此外還提供不可靠的資料報(Datagram)——送出去不保證到、不重傳、不排序,正好對應遊戲狀態與音視訊那種「舊資料寧可丟掉」的需求。這兩件事都是 WebSocket 在設計上給不了的。

陷阱「WebTransport 會取代 WebSocket」現在還不能這樣講

規格仍在標準化收尾階段,瀏覽器與伺服器端生態都還在補;企業網路對 UDP 的封鎖(QUIC 篇算過的那筆帳)在這裡一樣要付。務實的定位是:WebSocket 是通用預設值,WebTransport 是「你確實需要多 stream 或不可靠投遞」時的升級選項,而且必須保留 WebSocket fallback。

一句話答法HTTP/1.1 用 Upgrade+101 換掉整條 TCP;HTTP/2 因為禁用 Connection 系列標頭,改用 RFC 8441 的 Extended CONNECT(:protocol=websocket)把單一 stream 變隧道,回 200;RFC 9220 是同一套機制在 HTTP/3 上的版本。Sec-WebSocket-Key/Accept 不是安全機制,是防止中間設備被騙進二進位對話。
03

Framing:TCP 拿掉的訊息邊界,WebSocket 自己補回來

TCP/UDP 篇定調過「TCP 不保證訊息邊界,framing 是應用的事」。WebSocket 就是那句話的一個具體交卷。

TCP/UDP 篇談可靠性機制時已經論證過:TCP 給的是位元組串流,兩次 send() 可能被合併成一次 recv()、一次 send() 也可能被拆成三次收到——「黏包」不是 bug,是抽象本身如此。所以任何跑在 TCP 上的協定都得自己劃邊界:長度前綴、分隔符,或固定長度。WebSocket 選的是長度前綴,而且把長度欄位設計成變長的:

1 bitFIN
3 bitsRSV1-3
4 bitsopcode
1 bitMASK
7 bitsPayload len
0 / 16 / 64 bits擴充長度(len=126 → 16 bits;len=127 → 64 bits)
0 / 32 bitsMasking-key(客戶端→伺服器必填)
Payload Data(若 MASK=1,逐位元組與 Masking-key 做 XOR)

最小的一個 frame 只有 2 bytes 標頭(伺服器→客戶端、payload ≤ 125 bytes);客戶端送出時加上 4 bytes 的 masking key,最小 6 bytes。對照 HTTP 篇介紹過的 HTTP/2 固定 9-byte 標頭——兩者的設計目標不同:HTTP/2 的 frame 要攜帶 Stream ID 做多路復用,長度固定換解析簡單;WebSocket 沒有多路復用要做,於是把每個 byte 都省下來,因為它預期的負載是「每秒好幾十則的小訊息」。

互動 · frame 標頭拆解器 改訊息、方向、opcode 與分片,看標頭實際長成幾個 byte
訊息內容(CLOSE 時是理由文字)UTF-8 逐字算
批量倍數:同一則重複幾份× 1
分片大小(打開「切成分片」才生效)1,024 B
close code1000–4999

這一行是算出來的標頭位元組(單一 frame 視角,不含 payload):每個區段下方的括線標出它是哪個欄位、佔幾個 byte。手機寬度下這一行可以左右捲動——它不會折行,因為位元組順序就是線上順序。masking key 在真實情況每個 frame 都重抽一次密碼學亂數,這裡固定值只是為了讓你看清楚它佔掉的 4 bytes。

兩條直虛線就是 126 與 65536 這兩個門檻:上半張的階梯在這裡各跳一階(粗線是你目前選的方向,淡線是另一個方向,兩者永遠差 4 bytes 的 masking key),下半張是同一條路徑上的開銷比,縱軸是對數刻度。紅色垂直線是你當下這一則訊息落在哪裡——把批量倍數推過門檻,兩個點會同時跳。

FIN / RSV / opcode MASK / 7-bit 長度 擴充長度 masking key close 狀態碼

三個值得自己推一次的地方:(1) 把批量倍數從 5 調到 6,payload 從 105 跨到 126 bytes,標頭立刻從 6 跳到 8——「最小 2 bytes」是下限不是常態。(2) 切到 CLOSE 再把批量倍數往上推,理由文字超過 123 bytes 就違規,因為 control frame 的 125 bytes 上限要先扣掉 2 bytes 狀態碼。(3) 打開分片:payload 沒變,標頭卻被乘上片數——分片是為了不佔死連線,不是為了省位元組。

1 · opcode:資料 frame 與 control frame

規則資料 frame(0x0/0x1/0x2)control frame(0x8/0x9/0xA)
可否分片可以:首片 FIN=0+真實 opcode,後續片 opcode 0x0,末片 FIN=1不可分片,FIN 必為 1
payload 大小最大 263-1(實務由應用設上限)≤ 125 bytes
能否插隊同一方向的訊息必須依序完成可以插在分片訊息中間送
內容約束TEXT 必須是合法 UTF-8(違反 → close 1007);BINARY 不限CLOSE 帶 2 bytes 狀態碼+UTF-8 理由

「control frame 可以插隊」這一列很容易被略過,但它是保活能成立的前提:正在傳一個 8 MB 的分片訊息時,Ping 仍然可以插進去、Pong 仍然可以回來,否則大訊息一送就會被誤判為連線已死。

2 · 為什麼客戶端必須 mask,伺服器卻不必

RFC 6455 規定:客戶端送往伺服器的每一個 frame 都必須 masked(帶 4 bytes 隨機 key,payload 逐位元組 XOR),伺服器送往客戶端的則一律不 mask。這個不對稱的規定常被誤讀成「加密」——它跟機密性一點關係都沒有,key 就明文躺在同一個 frame 的標頭裡,任何人都能還原。

關鍵masking 防的是 cache poisoning,而且只在「攻擊者能控制送出內容」時才有意義

威脅模型是這樣:瀏覽器裡的惡意 JavaScript 可以呼叫 ws.send() 送出任意位元組。如果不 mask,它就能精心構造一段看起來完全像 GET /jquery.js HTTP/1.1\r\nHost: cdn.example.com\r\n\r\n 的內容。路徑上某個沒有正確實作 WebSocket 的透明快取 proxy,若把這段位元組當成一個新的 HTTP 請求來解析,就會去抓、並把伺服器接下來送回的內容當成 jquery.js 存進快取——同一個網路下所有使用者從此拿到被污染的腳本。
強制 masking 讓攻擊者無法預測實際流過線上的位元組(key 每個 frame 都不同、且必須來自密碼學亂數源),這條路就被封死了。伺服器不必 mask,是因為伺服器送出的內容不受攻擊者任意控制,而且省下的是下行頻寬與 CPU——fan-out 場景下下行流量遠大於上行。

3 · frame 之上:訊息,以及協定沒有給你的東西

應用看到的單位是訊息(message)而不是 frame:瀏覽器的 onmessage 只會在一則完整訊息(可能由多個分片組成)到齊後才觸發一次。這是 WebSocket 相對於裸 TCP 最大的便利——你不必自己算長度、不必處理黏包。

但也就到此為止了。有三件在 HTTP/2 上習以為常的事,WebSocket 完全不提供:

沒有的東西後果實務怎麼補
多路復用一條連線=一條有序訊息流,大訊息會擋住小訊息應用層自訂 channel/topic 欄位並限制單則大小;或改用 WebTransport
請求/回應關聯送出去的訊息沒有「回應是哪一則」的概念自己加 id 欄位做 correlation(MCP、GraphQL over WS 都是這樣做)
壓縮預設不壓縮,JSON 全文上線permessage-deflate 擴充(RFC 7692);注意它會吃記憶體,每條連線一份壓縮上下文

結論很一致:WebSocket 提供的是「有邊界的雙向位元組管道」,不是「訊息系統」。上面這三格外加投遞保證,正是 MQTT 選擇放進協定裡的東西(MQTT 篇會展開)。

一句話答法WebSocket 用長度前綴 framing 把 TCP 缺的訊息邊界補回來,標頭最小 2 bytes(變長長度);control frame ≤125 bytes、不可分片但可插隊,所以大訊息傳輸中 Ping/Pong 仍然通;客戶端強制 masking 不是加密,是防止中間快取被構造出來的假 HTTP 請求污染。
04

保活、斷線偵測與 close code

長連線最大的敵人不是斷線,而是「它明明已經斷了,兩端卻都以為還活著」。

TCP/UDP 篇談 Socket API 的章節已經把前提立好:因為「外往內」預設不通,即時系統一律採用客戶端主動建立並長期維持一條連線的模型;而那裡的 NAT callout 也定調了為什麼這條連線不能安靜——心跳既是活性偵測,也是「餵養」NAT 映射的手段。NAT 表項與有狀態防火牆的連線紀錄都有 idle timeout,一條完全沒有流量的長連線會在你毫無察覺的情況下被中間設備拆掉。

1 · 三層心跳,各測不同的東西

層級機制證明了什麼限制
傳輸層SO_KEEPALIVE(TCP keepalive)路徑通、對端核心還在Linux 預設閒置 2 小時才開始探測,對即時系統形同不存在;且對端行程卡死時核心仍會回應
協定層WebSocket PING / PONG(0x9/0xA)對端的 WebSocket 實作還活著、路徑通瀏覽器 JS 無法主動送 Ping——規範沒有暴露這個 API,瀏覽器只會自動回 Pong
應用層自訂 heartbeat 訊息(一則 TEXT)對端的應用邏輯還活著(事件迴圈沒被卡死)成本較高(要過完整訊息路徑);但可以順便攜帶 lastSeq、伺服器版本等資訊

典型分工:伺服器每 20–30 秒送 Ping(瀏覽器會自動回 Pong,不需要前端寫任何程式碼),用來清掉殭屍連線並餵 NAT;瀏覽器端則送應用層 heartbeat,因為它沒有別的選擇。兩個方向都要做——只有一方偵測,另一方的資源就會洩漏。

2 · half-open:最難查的那一種斷線

Half-open 指的是一端已經消失(行程被 kill、機器斷電、路徑被中間設備靜默拆掉),但另一端沒有收到任何 FIN 或 RST。此時它的 socket 仍是 ESTABLISHED、readyState 仍是 OPEN、事件迴圈一片安靜——沒有任何錯誤事件會被觸發。它會一直維持這個狀態,直到你嘗試寫入並等到 TCP 重傳耗盡(可能是好幾分鐘),或是永遠。

陷阱「沒有 onclose 就代表連線正常」是即時系統最貴的假設

唯一可靠的偵測方式是主動探測 + 計時:送出 Ping 後啟動計時器,連續 N 次(常見 2–3 次)在週期內沒收到 Pong,就主動判定連線已死、呼叫 close() 並進入重連流程。
伺服器端更不能省:每條連線記一個 lastPongAt,掃描逾時的直接踢掉。否則行動端使用者進電梯、關螢幕、切飛航模式留下的殭屍連線會持續佔用 fd、記憶體與訂閱表——這是長連線服務記憶體慢性成長的頭號原因。

3 · 關閉交握與 close code 體系

乾淨的關閉是雙向的:一方送出 CLOSE frame(payload = 2 bytes 狀態碼 + UTF-8 理由,因為 control frame ≤125 bytes,理由最多 123 bytes),對方回一個 CLOSE,然後由伺服器先關閉底層 TCP。兩邊都收到 CLOSE 才算 clean close——瀏覽器的 CloseEvent.wasClean 就是在說這件事。

Code意義典型觸發客戶端該不該重連
1000Normal Closure應用主動關閉、任務完成不要——這是「談完了」
1001Going Away使用者關分頁;伺服器重啟/下線要,而且必須帶 jitter(見第 5 節)
1002 / 1007Protocol Error/非法 UTF-8對端實作有 bug 或版本不合重連也會再撞——先告警
1006Abnormal Closure(保留)沒有收到 CLOSE frame:網路斷、行程死、被中間設備拆要,這是最常見的重連來源
1008Policy Violation認證失敗、越權訂閱看原因——憑證問題重連無用
1009 / 1011Message Too Big/Internal Error超過伺服器單則上限;伺服器例外1011 要,1009 先修訊息
1012 / 1013Service Restart/Try Again Later優雅重啟、過載保護要,且 1013 應退避更久
4000–4999應用自訂區段自己定義:4001 token 過期、4002 被踢下線…由你的語義決定

重連策略應該按 close code 分流,而不是「一律重連」。把 1000 也拿去重連,就會做出一個永遠關不掉的連線。

關鍵1005 與 1006 從來不會出現在線上

這兩個是保留碼,規範明文禁止放進 CLOSE frame——它們是本地端自己產生的:1005=收到了 CLOSE 但對方沒帶狀態碼;1006=根本沒收到 CLOSE,連線就沒了。
所以在日誌裡看到 1006,意思永遠是「關閉交握沒有發生」——不要去伺服器程式碼裡找哪裡送了 1006,該查的是網路路徑(LB 的 idle timeout 是不是比你的心跳短?)、行程是不是 OOM 被殺、或中間有沒有設備靜默拆連線。1006 佔比是長連線服務最值得監控的一個指標。

一句話答法TCP keepalive 預設 2 小時、對即時系統無用,所以保活要靠協定層 Ping/Pong(伺服器發、瀏覽器自動回)加應用層 heartbeat;half-open 只能靠「送 Ping 後計時、N 次無 Pong 就主動斷」偵測;close code 要分流處理,1006 代表根本沒發生關閉交握。
05

Backpressure 與重連:連線撐不住的兩種樣子

一種是連線還在但送不出去,一種是連線沒了。兩種都不能靠「等一下應該就好了」處理。

1 · Backpressure:WebSocket 只給你一個數字

TCP/UDP 篇談 Backpressure 的章節已經把因果鏈講完:接收端應用讀得慢 → 接收端核心 buffer 滿 → rwnd 縮到 0 → 發送端不能再送 → 發送端 send buffer 滿 → write() 回 EAGAIN。那裡也列了四種處理策略(暫停生產/丟棄與合併/有界佇列+斷線/降級取樣),並下了結論:把 backpressure 吃掉,等於把問題換成 OOM。這裡不重講機制,只補 WebSocket 這一層的三個特殊之處。

特殊之處細節後果
send() 永遠不會失敗瀏覽器的 ws.send() 立刻回傳,不會回 EAGAIN、不會丟例外沒有任何訊號告訴你「慢一點」——資料靜靜堆進瀏覽器的內部緩衝,直到分頁 OOM
唯一的儀表是 bufferedAmount已經呼叫 send() 但還沒真正寫進 socket 的位元組數必須每次送之前主動檢查;它持續上升就是對端或鏈路跟不上
沒有 receive backpressure瀏覽器端沒有「暫停接收」的 API,onmessage 會一直被呼叫收得比處理得快時,壓力只會累積在你的 JS heap 裡;只能在應用層丟棄或取樣

伺服器端情況好一些:多數 runtime 允許暫停讀取 socket,讓壓力真的反壓到 TCP rwnd。但要小心副作用——停止讀取會把整條連線上的所有訊息一起卡住,包含對方的 Ping,於是你可能被對端判定為 half-open 而斷線。

let ws, attempt = 0;
function connect() {
  ws = new WebSocket("wss://chat.example.com/ws?ticket=" + ticket, ["chat.v2"]);
  ws.onopen    = () => { attempt = 0; send({ type: "resume", lastSeq }); };
  ws.onclose   = (e) => {                    // 1000/1001 有交握;1006 = 沒收到 CLOSE frame
    if (e.code === 1000 || e.code === 4001) return;      // 正常關閉/憑證失效:不重連
    const delay = Math.min(30000, 500 * 2 ** attempt++) * (0.5 + Math.random() * 0.5);
    setTimeout(connect, delay);              // exponential backoff + full-ish jitter
  };
}
function send(msg) {                         // send() 不會回 EAGAIN,只會無聲堆記憶體
  if (ws.readyState !== WebSocket.OPEN) return queue(msg);
  if (ws.bufferedAmount > 1_000_000) return drop(msg);   // 有界:寧可丟也不要 OOM
  ws.send(JSON.stringify(msg));
}

十五行裡塞了本節的全部重點:按 close code 決定要不要重連、退避要帶 jitter、重連後第一件事是宣告 lastSeq、送出前檢查 bufferedAmount。至於 drop() 要丟最舊的、還是把同一支股票的報價合併成最新一筆,就是 TCP/UDP 篇那張策略表的選擇題。

2 · 重連:退避、抖動,以及為什麼一定要抖

偵測到斷線之後立刻重連,是每個人第一版都會寫的錯誤。正確的作法是指數退避(exponential backoff)——每次失敗把等待時間加倍,並設一個上限:

delay = min(cap, base * 2 ^ attempt) * rand(0.5, 1.0)

前半段(min(cap, base * 2 ^ attempt))讓失敗成本隨次數遞減;真正關鍵的是後面那個乘上隨機數的 jitter(抖動)。理由是:造成大量客戶端同時斷線的事件,通常是同一個事件——一次伺服器重啟、一次 LB 切換、一次網路抖動。這些客戶端的計時器因此完全同相,沒有 jitter 的話它們會在同一毫秒一起回來,把剛起來的伺服器再打掛一次,然後同步得更整齊地再來一輪。把重連時刻隨機攤平,是唯一能打破這個相位鎖定的手段。(這個現象叫重連風暴(Reconnect Storm),它在單機視角只是一個係數,到了多節點叢集才會真正露出獠牙——連線擴充篇會完整處理。)

3 · 重連 ≠ 恢復:連線狀態要怎麼接回去

新的 TCP 連線建起來、onopen 觸發了,但這只代表管道通了。斷線那段時間伺服器推的訊息去哪了?客戶端的訂閱列表在伺服器那邊還在不在?這些都不是協定管的事。

恢復策略作法代價適合
全量重載重連後丟掉本地狀態,重新拉一次完整快照伺服器要扛「重連即打 API」的尖峰狀態小、可重算:儀表板、行情看板
序號補發每則訊息帶遞增 seq;重連時客戶端送 lastSeq,伺服器補發之後的伺服器必須為每條連線保留一段訊息視窗(有上限)不可漏訊息:聊天、通知、訂單事件
Resume token伺服器發一個不透明 token 代表「你上次讀到哪」,重連時原樣帶回token 要有時效與撤銷機制;語義由伺服器完全掌握需要跨節點恢復(token 裡可編碼分區資訊)
最終一致快照不保證補齊中間過程,只保證重連後很快收到當前狀態中間狀態永久遺失只關心最新值:游標位置、線上狀態、行情

共同前提:伺服器願意為斷線的客戶端保留多久的狀態,決定了你能給出多強的恢復保證。保留視窗一定有上限,所以每一種策略都要設計「太舊了,請走全量重載」這條退路。這也是為什麼 resume 訊息的回應必須明確告訴客戶端是補發還是請重來。

還有一件事在單機視角看不出殺傷力:slow consumer(TCP/UDP 篇已定義)。一個手機在地鐵裡的使用者,會讓 gateway 上屬於他的那條連線的發送佇列持續堆積;在 fan-out 場景下,每一則廣播訊息都要為他複製一份,於是一個慢客戶端的成本會隨廣播頻率放大。單機上的答案就是那張策略表裡的「有界佇列 + 斷線」——犧牲他一個,保全其他人。至於當這件事發生在幾十台 gateway 上、而訊息還要跨節點分發時會變成什麼樣子,留給連線擴充篇。

一句話答法WebSocket 的 send() 不會失敗,唯一的 backpressure 訊號是 bufferedAmount,而且瀏覽器沒有暫停接收的 API;斷線後必須用「指數退避 + jitter」重連以打破相位鎖定,並且要另外設計序號補發或 resume token,因為重連只恢復管道、不恢復狀態。
06

認證與連線狀態:交握是唯一的那個 HTTP 請求

整條連線的一生裡,只有第一個封包是 HTTP。你所有的認證機會都擠在那一瞬間——而瀏覽器還綁住了你的手。

好消息:交握是一個真正的 HTTP 請求,所以 Cookie 會自動帶上,既有的 session 機制可以直接沿用。壞消息有兩個,而且都很尖銳。

陷阱WebSocket 交握不受同源政策保護——CSWSH

瀏覽器對 fetch/XMLHttpRequest 施加 CORS,但 WebSocket 沒有 CORS:任何網站的 JavaScript 都可以對你的 wss:// 端點發起連線,而且瀏覽器會把使用者的 Cookie 一起帶上。這叫 Cross-Site WebSocket Hijacking(CSWSH)——攻擊者的頁面建立一條「以受害者身分登入」的連線,然後在上面收發訊息。
唯一的防線是伺服器在交握時檢查 Origin 標頭,只放行白名單。這不是可選的加固項,而是只要你用 Cookie 認證 WebSocket 就必須做的事。(非瀏覽器客戶端可以偽造 Origin,所以它防的是「利用受害者瀏覽器」這個特定攻擊面,不是通用授權。)

1 · 瀏覽器不讓你設自訂標頭

第二個壞消息:瀏覽器的 WebSocket 建構子只有兩個參數——new WebSocket(url, protocols)。沒有地方放 Authorization: Bearer …。(伺服器端與行動端 SDK 沒有這個限制,可以自由設標頭;這純粹是瀏覽器 API 的約束。)於是實務上出現四條路:

作法怎麼做風險 / 限制
Cookie交握自動帶,伺服器照既有 session 驗必須驗 Origin(見上);受 SameSite 限制,跨網域部署(前端與 WS 端點不同站)會失效
Token 放 query stringwss://…/ws?token=eyJ…最常見也最糟:URL 會進 LB/proxy/伺服器的 access log、進 APM 追蹤、進瀏覽器歷史,長效 token 一旦落地就等於外洩
Ticket 模式先用一般 HTTPS API(帶 Authorization)換一張一次性、數十秒有效、綁使用者的 ticket,再放進 query string多一次往返;但 ticket 進了日誌也幾乎沒有價值——這是瀏覽器場景的推薦解
Sec-WebSocket-Protocol 夾帶把 token 當成一個「子協定」送出:["chat.v2", "bearer.eyJ…"]算是把標頭欄位挪作他用;伺服器必須回選其中一個(回 token 那個會很怪,通常回 chat.v2)。避開了 URL 落日誌,但可讀性差、與 8441 路徑上的中間層互動要實測

選擇順序通常是:同站部署用 Cookie + Origin 驗證;跨站或行動端用 Ticket 模式。把長效 JWT 直接掛在 query string 上是最省事、也最容易在資安檢查時被抓出來的做法。

2 · 連線活了八小時,token 三十分鐘就過期了

請求/回應模型裡,權杖過期是自然的:下一個請求帶新的就好。長連線把這個假設打破了——交握時驗過一次,不等於接下來八小時都該被允許。使用者可能已經登出、被停權、權限被收回,而那條連線還在推訊息給他。三種常見處理:

策略作法取捨
到期即關伺服器記下 token 的 exp,時間到就主動送 CLOSE(自訂碼如 4001)最簡單、語義清楚;客戶端收到 4001 應先去刷新 token 再重連,而不是盲目退避
連線內續期定義一則 auth.refresh 訊息,客戶端在到期前送新 token 上來不中斷連線,體驗最好;但你等於在應用層自己實作了一套認證協定,要處理續期失敗與競態
每則訊息授權交握只認 你是誰,每則訊息的 能不能做 另外查(訂閱時查一次、寫入時再查)最安全也最貴;通常只對高風險操作(下單、轉帳)這樣做

共同原則:認證發生在交握,授權必須持續。把兩者混為一談,就會做出「踢不掉的已停權使用者」。

3 · 伺服器端的 connection state:本篇最後一個伏筆

每接受一條 WebSocket 連線,伺服器就得在記憶體裡掛一份狀態:

user / device id交握時認證得到
訂閱的 topic 集合應用自訂的語義
lastSeq / resume token補發缺口的依據
lastPongAt死亡判定的輸入
心跳計時器掛在這個行程上
發送佇列(有界)佔的是本機記憶體
× 每一條連線

六項的來源各不相同——認證給的、應用給的、保活給的、緩衝給的——但存放位置只有一種:持有這條 socket 的那一個行程的記憶體。真正該盯著看的是最後一列的乘數:這整份清單要乘上連線數。

單機上這完全不是問題——十萬條連線各配幾 KB 也就是幾百 MB,TCP/UDP 篇的 epoll 早就解決了「同時等很多連線」。真正的問題是這份狀態只存在於那一台機器的記憶體裡:它不能被別台機器讀到,也不會在行程重啟後留下來。一旦你要放兩台機器在負載均衡器後面,「這則訊息該送給誰」就變成「持有那條連線的是哪一台」——這是本篇刻意不處理的問題。

一句話答法交握是整條連線唯一的 HTTP 請求,所以 Cookie 可用但必須驗 Origin(WebSocket 沒有 CORS,會被 CSWSH);瀏覽器不能設自訂標頭,長效 token 別放 query string,改用一次性 ticket;而認證只發生在交握、授權必須持續,token 到期要主動關或在連線內續期。
07

互動 · 即時傳輸選型決策器

把需求逐條換算成分數與帳單:同一組規則同時回答「該選誰」與「這個選擇每天要付多少位元組」。

前面六節談的都是「WebSocket 怎麼運作」。但在真實專案裡,你要先回答的是更前面那一題:這個需求到底該不該用 WebSocket。下面這台決策器把第 8 節的對照表拆成 12 條加權規則,逐條套在你描述的需求上,算出 WebSocket/SSE/長輪詢/HTTP/2 串流/WebTransport 五個候選的分數走勢;同一組輸入還會被拿去算四筆帳——每則訊息的框架開銷比、全體連線的每日流量、建議的 ping 間隔,以及重連風暴的峰值。其中「需要斷線續傳」那條規則為什麼給 SSE 加分,SSE 篇談 Last-Event-ID 自動續接的那一節已經解釋過:那是規範內建的行為,而 WebSocket 這一側要靠第 5 節整節手工重建。

互動 · 即時傳輸選型 描述需求,看 12 條規則把五個候選拉開
伺服器推送頻率5 則/s
客戶端發話頻率2 則/s
平均訊息大小180 B
同時連線數50,000
路徑上最短的 idle timeout60 s
延遲容忍端到端

看的不是終點分數,是領先權在第幾條規則易手:五條線從同樣的 50 分起跑,每一欄是一條規則吃進你的參數後給出的加減分。底色那一欄就是決定性規則——冠亞軍的差距在那一步變動最大,也就是整個選型真正由它拍板的地方。x 軸標籤轉紅的欄位代表那條規則上出現了矛盾或風險,詳情列在圖下方。

WebSocket SSE 長輪詢 HTTP/2 串流 WebTransport
申報:標頭成本用四個估計常數——長輪詢每則一組完整 HTTP 請求 480 B + 回應 250 B;HTTP/2 串流 9 B frame 標頭 + 25 B HPACK 增量;SSE「data: 」6 B +「\n\n」2 B +「id: 999999\n」11 B;WebTransport 4 B QUIC STREAM 標頭。WebSocket 的標頭則是真算的(2 + 擴充長度 + 4 bytes masking key)。規則權重是常數,但每條規則是否觸發、乘上多少量值,全部由上面的參數推導。

三個值得自己動手翻面的地方:(1) 只把方向性切成「伺服器單向推送」,勝出者就從 WebSocket 翻成 SSE——這正是第 8 節那句「客戶端需不需要低延遲地說話」的量化版本。(2) 在單向狀態下再打開「需要二進位」,兩個候選會擠到 8 分以內、結論條改口說「兩者都可行」,因為 SSE 只送 UTF-8 文字,二進位得先 base64,膨脹幅度 demo 會直接算給你看。(3) 把訊息大小拉到 16 bytes:分數幾乎不動,但長輪詢的開銷比會衝到 97% 以上——選型的代價常常不在分數,在帳單。

說明這台決策器簡化了什麼

它只算協定層面的成本與適配度:不計團隊熟悉度、既有基礎設施、函式庫生態與營運成熟度——而這四項在真實決策裡的權重往往不低於任何一條規則。分數本身沒有絕對意義,有意義的是差距與它是在哪一條規則上拉開的;差距小於 8 分時 demo 會拒絕給單一答案,因為那個範圍內的差異已經小於上面那四項未被建模的因素。
另外,它假設所有候選都跑在同一條路徑上。實務上「企業 proxy 會不會擋 Upgrade」「CDN 支不支援長連線」得逐家確認,demo 只能把它當成一個開關;打開之後若勝出的仍是 WebSocket,它會提醒你準備 fallback,而不是替你決定。

一句話答法選型不是「這個功能聽起來即不即時」,而是把方向性、發話頻率、訊息大小、路徑限制與連線規模逐條換算成分數與帳單:只要是伺服器單向推送,SSE 幾乎總是贏在重連內建與 proxy 友善;客戶端也要高頻發話時 WebSocket 才划算,而它的代價是你得自己付心跳、退避與補發這三筆帳。
08

面試折衷與實務選型

最該記住的一句是:不是所有即時需求都需要 WebSocket,而且多數不需要。

1 · 四種「即時」的對照

Long PollingSSEWebSocketWebTransport
方向性雙向(但每則一次往返)單向 server → client全雙工全雙工 · 多 stream
資料型別任意僅 UTF-8 文字(二進位要 base64)text / binarybinary + 不可靠資料報
重連是否內建天然(本來就一直重發)內建:瀏覽器自動重連 + Last-Event-ID 續接要自己寫(退避、jitter、狀態恢復)要自己寫
經過 proxy 的友善度最高,就是普通 HTTP高,就是一個不結束的回應需中間層支援 Upgrade;企業 proxy 常擋需要 UDP 通行
連線數成本高(不斷重建)HTTP/1.1 上受 per-origin 6 連線限制一條 TCP(8441 之後可共用)共用一條 QUIC 連線
伺服器成本最高:每則訊息一組完整標頭低低(2 bytes 標頭)低

第三列常被低估:SSE 的自動重連與 Last-Event-ID 是規範內建的——瀏覽器斷線後自己回來、並把「我上次收到哪一則」帶給伺服器。這正是本篇花了整整一節(第 5 節)在 WebSocket 上手工重建的東西。而第四列的 HTTP/1.1 六連線限制,在 HTTP/2 的多路復用之後就消失了,這是近年 SSE 明顯復活的主因。

關鍵選型只要問一個問題:客戶端需不需要低延遲地說話?

絕大多數所謂「即時」需求——通知、進度條、儀表板、LLM 逐字輸出、訂單狀態更新——都是純粹的伺服器單向推送;客戶端偶爾要說的話,用一個普通的 POST 就夠了。這種情況 SSE 幾乎總是更好的選擇:省掉整套重連與心跳程式碼、對中間層透明、除錯時 curl 就看得到。
真正需要 WebSocket 的是客戶端也高頻發話的場景:協作編輯與游標、多人遊戲、互動白板、需要低延遲雙向的交易終端。判準是「客戶端每秒要說幾次話」,不是「這個功能聽起來即不即時」。

2 · 選型表

決策點選項Trade-off
協定選擇SSE vs WebSocket只有 server→client 就選 SSE(重連內建、proxy 友善);客戶端高頻發話才需要 WebSocket。混用也很常見:SSE 推、REST 寫
要不要上 RFC 8441WS over HTTP/1.1 vs over HTTP/28441 省連線數、與其他請求共用連線;但中間層支援參差,一層不支援就整條退回。多數團隊維持 HTTP/1.1 Upgrade
心跳週期短(10–20s) vs 長(60s+)短:斷線偵測快、確保 NAT 與 LB idle timeout 不會先到;代價是行動端耗電與流量。心跳週期必須小於路徑上最短的 idle timeout
訊息格式JSON(TEXT) vs 二進位(BINARY)JSON 好除錯、生態成熟;二進位(protobuf/MessagePack)省頻寬與解析 CPU,高頻小訊息差距明顯。先量再換
壓縮開 permessage-deflate vs 不開重複性高的 JSON 壓縮率很好;代價是每條連線一份壓縮上下文,十萬連線時記憶體是實打實的帳單
送不出去時緩衝 / 合併 / 斷線沿用 TCP/UDP 篇那張策略表:可丟的資料合併成最新值,不可丟的用有界佇列 + 斷線。沒有明確答案的系統,預設答案是 OOM
重連後的狀態全量重載 vs 序號補發全量簡單但重連尖峰會打 API;補發體驗好但伺服器要保留訊息視窗,而且一定要有「太舊請重來」的退路
認證Cookie vs Ticket同站用 Cookie(必須驗 Origin);跨站或行動端用一次性 ticket。長效 token 放 query string 是最常見的資安缺失

共同結構:WebSocket 把「通道」做得很好,但把所有「訊息系統」的問題原封不動留給你——保活、退避、補發、限流,每一項都要自己決定犧牲什麼。

面試高頻追問怎麼答

Q:WebSocket 跟 HTTP 是什麼關係? 答:只有交握那一個請求是 HTTP(Upgrade + 101)。之後這條 TCP 上跑的是 WebSocket 自己的二進位 framing,沒有標頭、沒有狀態碼。WSS 則是先跑 HTTPS,再在同一條 TLS 通道上做 Upgrade。
Q:Sec-WebSocket-Key 是幹嘛的? 答:不是安全機制。算法公開、Key 明文可見,目的是確認對面真的實作了 WebSocket,避免把不懂這個協定的快取 proxy 騙進二進位對話。
Q:為什麼客戶端一定要 mask? 答:防止惡意 JS 構造出「看起來像 HTTP 請求」的位元組去污染中間快取。它不是加密——key 就在同一個 frame 的標頭裡。
Q:心跳應該放在哪一層? 答:TCP keepalive 預設兩小時太慢;協定層 Ping/Pong 適合伺服器主動探測(瀏覽器會自動回 Pong 但不能主動發 Ping);應用層 heartbeat 才能證明對端的事件迴圈沒卡死,也才能順便帶 lastSeq。
Q:日誌裡一堆 1006 代表什麼? 答:關閉交握根本沒發生。它是本地產生的保留碼,不可能出現在線上。該查的是 LB 的 idle timeout 是不是短於心跳、行程有沒有被 OOM kill、路徑上有沒有設備靜默拆連線。
Q:瀏覽器怎麼在 WebSocket 上帶 token? 答:建構子不能設標頭。同站用 Cookie 加 Origin 驗證;否則先用 HTTPS API 換一張一次性短效 ticket 放 query string。長效 JWT 直接掛 URL 會進所有人的 access log。
Q:WebSocket 在 HTTP/2 上怎麼跑? 答:HTTP/2 禁用了 Connection 系列標頭,所以改用 RFC 8441 的 Extended CONNECT:先靠 SETTINGS_ENABLE_CONNECT_PROTOCOL 宣告能力,再用 :method=CONNECT 加上 :protocol=websocket 把一條 stream 變成隧道,回 200 不是 101。HTTP/3 上是 RFC 9220,同一套機制。
Q:什麼時候不該用 WebSocket? 答:只有伺服器單向推的時候。SSE 內建自動重連與 Last-Event-ID、對 proxy 透明、能省下你整套心跳與退避程式碼;HTTP/2 之後連六連線限制也沒了。

把本篇收攏成一句:WebSocket 用一次 HTTP Upgrade 買到了一條對稱的雙向訊息管道,而它把管道以外的一切都留給了你。framing 幫你補回 TCP 拿掉的訊息邊界,Ping/Pong 幫你戳一下對面還在不在,close code 幫你分辨「談完了」與「線斷了」——除此之外,保活週期、退避曲線、補發視窗、限流策略,全部是應用層的設計題。

這套設計在瀏覽器與伺服器之間非常合身。但把同一套東西搬到一顆靠電池活著的感測器上,帳單就翻過來了:WebSocket 預設你有完整的 TLS 與 HTTP 堆疊、一顆可以常時醒著跑心跳的 CPU,以及不計較每個位元組的頻寬。更關鍵的是,它從頭到尾只負責「把位元組送到對面」——訊息會不會重複、掉線那段時間的訊息去了哪裡、裝置突然斷電時誰替它說最後一句話,協定一概不管。當場景變成幾十萬顆在不穩定網路上時醒時睡的裝置,需要的是一個把投遞保證(QoS)與 pub/sub 語義直接做進協定裡的東西——下一篇進入 MQTT。

而即使場景就是瀏覽器與 WebSocket,本篇談的一切都建立在一個隱含前提上:連線的另一端只有一台伺服器。使用者是誰、訂了哪些 topic、最後收到第幾則——這些狀態全都活在那一台機器的記憶體裡(第 6 節的最後一小節)。一旦要放到多台機器後面,Sticky Session 要不要做、訊息怎麼跨節點 fan-out 到「持有那條連線的那一台」、發版時怎麼 Graceful Drain(優雅排空)而不引爆重連風暴,就是完全另一個層級的問題了——連線擴充篇會處理它。

一句話答法WebSocket 適合客戶端也需要低延遲發話的場景(協作、遊戲、交易終端),純推播用 SSE 幾乎總是更划算;選了它就要自己負責心跳週期、退避與 jitter、bufferedAmount 上限與重連後的狀態恢復——協定只給你一條管道,訊息系統的部分是你的。