01

總覽與定位

WebSocket 篇與 MQTT 篇的結尾各留下一句話。仔細看,那其實是同一句話的兩個版本——本篇把它的主詞換掉。

WebSocket 篇談認證與連線狀態的章節,在盤點伺服器的 connection state 之後說:使用者是誰、訂了哪些 topic、最後收到第幾則,「這份狀態只存在於那一台機器的記憶體裡」——主詞是 WebSocket gateway。MQTT 篇的面試折衷章節在盤點 Broker 替你記住的東西之後說:retained store、每個 client id 的 session 與離線佇列、每條連線的 inflight window、整棵訂閱樹,「全部活在那一台 Broker 的記憶體裡」——主詞是 MQTT Broker。兩篇走了完全不同的路,卻在最後一句話上撞在一起。

WebSocket 篇 · gateway 的 per-node 狀態
每一條連線 × 六項
user / device id交握時認證得到
訂閱的 topic 集合應用自訂的語義
lastSeq / resume token補發缺口的依據
lastPongAt死亡判定的輸入
心跳計時器掛在這個行程上
有界發送佇列佔的是本機記憶體
MQTT 篇 · Broker 的 per-node 狀態
每一個 client id / 每一棵樹 × 四項
retained store訂閱者可能連在任何節點
session 與離線佇列協定承諾要留住
inflight window屬於這一條連線
整棵訂閱樹比對 wildcard 用

左邊是「應用自己扛的狀態」,右邊是「協定要求 Broker 扛的狀態」。要看的不是項目內容,是兩欄的長度差——六項對四項、來源不同、重量不同,但每一項的存放位置性質完全一樣:單一行程的記憶體,別台讀不到、重啟不留下。不同協定,同一個結構性問題。

所以本篇做的事很單純:把那句話的主詞從「那一台機器」換成「一群機器」。一旦主詞變成節點集合,六個新問題會同時冒出來——訊息怎麼找到持有連線的那一台、狀態怎麼在節點之間流動、發版時幾十萬條連線往哪裡去、大家一起回來時誰先倒。這些都不是協定問題(framing 與 QoS 在 WebSocket 篇與 MQTT 篇已經寫完,本篇一律引用結論),而是架構問題。

座標本篇在系列裡的位置:從「一條連線」到「一群連線」

TCP/UDP、TLS、HTTP、QUIC、WebSocket、MQTT 六篇的視角始終是一條連線上發生什麼:位元組怎麼可靠送達、怎麼加密、怎麼表達語義、怎麼不被 HoL Blocking 卡住、怎麼保持雙向、怎麼保證投遞。本篇的視角是幾十萬條連線放在幾十台機器上會發生什麼——負載均衡、狀態定位、跨節點分發、優雅下線、故障恢復、可觀測性。
這也是唯一一篇「換協定不會改變答案」的文章:WebSocket 與 MQTT 在這一層要解的題目高度重疊,差別只在誰扛狀態(第 6 節完整對照)。

MQTT 篇在面試折衷章節的結尾已經替本篇列好了議程,四題全部在下表對號入座:

MQTT 篇留下的問題本篇的答案在哪一句話預告
訊息怎麼跨節點找到「持有那條連線的那一台」第 2–4 節LB 決定落點、registry 記錄落點、fan-out 把訊息送過去
session 與 retained 要不要跨節點複製第 6 節四種狀態四種答案:retained 要、session 用 home node、inflight 不複製
誰判定 Keep Alive 逾時並代發 Last Will第 5 節只有持有連線的節點有資格,而它掛掉時要靠租約接手
發版時怎麼優雅趕走幾十萬條連線第 5 節先摘 LB、再分批帶抖動送關閉、最後才關行程
哪些工作該下沉 edge、哪些留中心第 6 節會消耗 CPU 與延遲的下沉,需要全域真相的留中心

另外兩個貫穿全篇的主題:backpressure 的系統級版本(第 4 節)與長連線服務的可觀測性(第 5 節末)。

一句話答法長連線水平擴充的全部難點,都來自「連線狀態綁在單一節點的記憶體裡」這一個事實:於是需要 registry 記住誰在哪、需要 fan-out 把訊息送過去、需要 graceful drain 讓節點能下線、需要退避與限流讓大家不要一起回來。
02

接入層:Load Balancer 眼中的一條長連線

LB 對短請求做的每一件事,在長連線上都只做一次——本章所有問題都是這句話的推論。

1 · L4 還是 L7:終止在哪裡,決定你能做什麼

HTTP 篇的面試折衷一節已經定下這條分界:L4 讀 SNI 分流、不解密、成本低;L7 終止 TLS、讀得到 Host 與 :authority,功能全開但要管憑證、且內網段是明文。放到長連線上,這條分界的意義會放大,因為每一個決定都要跟著連線活上好幾個小時。

面向L4(TCP/SNI 轉發)L7(終止 TLS,理解 HTTP)
每條連線的成本一組 socket 對 + 少量緩衝兩條完整 TCP 連線 + HTTP 解析狀態 +(若開壓縮)壓縮上下文——HTTP 篇提過的 per-connection 記憶體,在這裡要乘以連線數
看得到交握嗎看不到,101 只是一段密文看得到 Upgrade/101,可以按路徑分流、擋掉沒帶 Origin 的連線
RFC 8441 / HTTP/2透明轉發,不干涉必須明確支援 Extended CONNECT,否則整條退回 HTTP/1.1(WebSocket 篇)
MQTT幾乎只能走這條:TLS 之上是二進位協定,通用 L7 看不懂需要 MQTT-aware proxy 才能按 client id/topic 路由
優雅下線只能停止送新連線,既有連線它管不著可以參與:轉發 GOAWAY(HTTP 篇)、代送關閉、精確統計每後端連線數
QUIC五元組雜湊會在 connection migration 後送錯機器—

最後一列是 QUIC 篇的結論:正解是 CID 感知路由——把後端識別編進 Connection ID 並做加密混淆。那裡已經寫下一句話給本篇:「QUIC 的 sticky 需求不是靠 cookie 或來源 IP,而是靠連線識別本身。」這是整個接入層唯一一個「連線換了路徑也還黏得住」的方案。

2 · 只做一次的決策,與它必然造成的連線傾斜

短請求的世界裡,LB 每收到一個請求就重新選一次後端,所以 least-connection、weighted round-robin 這些演算法有機會持續修正不平衡。長連線的世界裡,LB 只在建連的那一瞬間做一次決策,之後這條連線的去向就凍結了。負載均衡演算法從「持續調節」退化成「一次性抽籤」。

後果就是連線傾斜(connection skew),而最常見的觸發不是流量而是發版。四台節點各三萬條連線,做一次滾動重啟:

重啟前 · 總計 120k
gw-130k
gw-230k
gw-330k
gw-430k
依序滾動重啟 gw-4 → gw-3 → gw-2 → gw-1 之後 · 總計仍是 120k
gw-158k
gw-240k
gw-322k
gw-40

兩組條的總長度完全相同——要看的是形狀不是總和。先重啟的節點會把後面每一台釋放出來的連線都吃進去,最後重啟的那台則是空的。沒有任何人做錯事,這是「一次性決策 + 依序重啟」的算術必然。傾斜的代價很實在:gw-1 的記憶體、fan-out 展開量、GC 停頓、以及它掛掉時的爆炸半徑都是 gw-3 的兩倍以上。上面這組數字是示意;本篇的互動節可以用你自己的節點台數、連線上限與分配策略把整個過程算一遍。

三個實務對策,通常一起用:(1) 每個節點設連線數上限,滿了就讓健康檢查回不健康、把自己從 LB 摘掉(比起被 OOM kill 好太多);(2) 主動 rebalance——超過平均值一定比例的節點,定期小批量地把連線趕走(作法就是第 5 節的 drain,只是規模小、用 1013 Try Again Later 讓客戶端退避久一點);(3) 別在尖峰滾動重啟,並且在發版後把「節點連線數的 max/min 比值」當成驗收指標看一眼。

3 · Sticky Session:長連線天生就黏,問題從來不在這裡

面試裡問到「長連線要不要做 Sticky Session」,最好的回答是先拆題:一條 TCP 連線本來就固定在一台機器上——它就是同一組 socket,不需要 cookie、不需要來源 IP 雜湊、不需要 consistent hash。長連線是天然 sticky 的,這個問題本身不成立。

真正的問題藏在下一句:黏住的是連線,不是狀態。連線一斷(手機進電梯、LB 換一台、發版),客戶端重連時 LB 會重新抽一次籤,落到一台對他一無所知的機器上:訂閱要重建、lastSeq 不知道、離線期間的訊息在別的節點手上。三條出路:

作法怎麼做取捨
狀態外部化連線狀態的「可共享部分」寫進外部儲存,任何節點接手都能還原最乾淨,節點真正變成可拋棄的;代價是每條連線都在打 Redis,寫入量與延遲要算清楚
resume token 編碼分區WebSocket 篇的四種恢復策略裡,token 是唯一能夾帶路由資訊的——把分區/節點世代編進去,重連時上游據此把他導回原節點或原分區需要 LB 或接入層看得懂 token(通常做成路徑或 query 參數);token 要有時效與撤銷
home node(歸屬節點)為 session 指定一個擁有者節點,落到別台的連線由該節點代理或轉交MQTT 叢集的主流作法(session 有明確身分 client id);代價是多一跳,且擁有者掛掉要有接手流程(第 5 節)

共同前提:先決定「重連後要恢復多少」,再決定要不要黏。如果答案是「重連後全量重載」(WebSocket 篇的第一種恢復策略),那你根本不需要任何 sticky 機制,只要能扛得住重連尖峰打快照 API(第 5 節)。

4 · idle timeout:責任鏈的第一環

LB 還帶著一個最容易踩的預設值:idle timeout。常見的預設落在 60 秒(Nginx 的 proxy_read_timeout、AWS ALB)到數百秒之間,通常可調;而某些雲的 LB 更兇——它把「後端回應逾時」直接套用在 WebSocket 上,變成連線的最長壽命,這時心跳救不了你,只能把那個值調大。

WebSocket 篇已經給了鐵律:心跳週期必須小於路徑上最短的 idle timeout,並且把 1006 佔比列為長連線服務最值得監控的指標——因為 LB 靜默拆連線的表現,就是客戶端一片沒有關閉交握的 1006。多節點環境把這件事變難的地方在於:路徑上的環節變多了(客戶端 → NAT → LB → gateway → bus),每一環都有自己的計時器。完整的責任鏈怎麼排,留給第 5 節。

一句話答法LB 對長連線只在建連時決策一次,所以 Sticky 是天然的、但負載均衡會退化成一次性抽籤,滾動重啟後必然傾斜;L4 便宜但參與不了優雅下線,L7 功能全開但每條連線的記憶體要乘以連線數,QUIC 則必須改用 CID 感知路由。
03

Connection Registry:找到持有那條連線的那一台

一張全叢集都看得到的表,回答唯一一個問題:這個使用者的連線現在在哪個節點上。

1 · 最小可行設計

核心資料結構簡單到讓人懷疑:一個從身分到節點的映射。真正的設計都在細節裡——

欄位例值為什麼需要它
key conn:{userId}:{deviceId}conn:U-7731:web一個 user 對多條連線是常態(手機 + 網頁 + 平板),所以 key 要帶裝置維度、或值是一個集合——否則第二台裝置上線就把第一台的路由資訊蓋掉,訊息從此只送給最後登入的那個
nodeIdgw-3這張表存在的唯一理由:訊息該投到哪一個節點的 channel
connIdc-91f2節點收到訊息之後,還要在自己的本地表從 connId 找到那個 socket
epoch(節點世代)7節點每次啟動遞增的世代號,用來讓「上一世代留下的殘骸」在比對時直接失效
connectedAt17:42:08條目的生命週期是「交握完成後寫、關閉時刪、隨心跳續期」,這一欄記的是第一步發生的時刻
TTL90s續期掛在心跳上(見下一小節);它是節點突然消失時唯一能保證垃圾被清掉的機制

六個欄位,每一個都對應一種故障:key 少了裝置維度會蓋掉路由、少了 nodeId 這張表就沒有意義、少了 connId 節點找不到 socket、少了 epoch 會讀到上一世代的殘骸、少了 TTL 則永遠清不掉突然消失的節點留下的條目。

選型適合代價 / 注意
Redis絕大多數情況的預設答案:原生 TTL、寫入便宜、水平分片容易AP 系統,故障轉移時可能丟寫入;registry 的寫入量 = 建連速率 + 續期速率,十萬連線/90 秒續期就是穩定 1.1k QPS 的續期底噪,還沒算重連尖峰
etcd / ZooKeeper需要強一致與 watch 的控制面資訊(節點成員、分區歸屬、租約)不要拿來存每條連線:寫入吞吐與 watch 數量都撐不住連線級的 churn
節點間 gossip / 複製表Erlang/Elixir 叢集與多數 MQTT Broker 的內建作法,查找是本地記憶體、零額外往返成員變更與腦裂要自己處理;節點數上限通常是幾十台
一致性雜湊(不查表)用 userId 直接算出節點,省掉 registry只在「連線落點可以被強制決定」時成立——需要接入層照算出來的結果導流;擴縮容時會搬動連線

前三種是查表,第四種是算的。混用也很常見:用一致性雜湊決定 session 的 home node(穩定、不用查),用 registry 記錄連線實際落點(會變、必須查)。

2 · TTL 與心跳同步:registry 記的是「上次還活著」

registry 條目一定要有 TTL,理由很直接:節點是會突然消失的。行程被 OOM kill、機器斷電、網路分區——這些情境下沒有人有機會執行「刪除條目」的程式碼。TTL 是唯一能保證垃圾最終被清掉的機制。

續期則掛在既有的心跳上:收到客戶端心跳 → 順手續期 registry TTL。這一步讓 registry 的存活判定與連線的存活判定變成同一件事,不必再多一套計時器。設定關係是 TTL 至少要是續期週期的兩到三倍,留給 GC 停頓與 Redis 抖動。

陷阱stale entry 的兩種後果,一種吵一種安靜

指向已死的節點:訊息投進一個沒人訂閱的 channel,靜靜消失。這是最危險的一種——沒有錯誤、沒有告警,只有使用者說「我沒收到」。
指向已被接手的舊節點:連線已經重連到 gw-2 了,registry 還寫著 gw-3。訊息送到 gw-3,它找不到那條連線——這時候絕對不能默默丟掉,正確作法是回報投遞失敗,由上游重查 registry 或走離線路徑。
三個清理機制要一起上:節點啟動時清掉自己上一世代(epoch)的所有條目、寫入時用 compare-and-set 只覆蓋較舊的 epoch、投遞失敗時回寫或刪除該條目。

3 · registry 是 hint,不是 truth

這是本章最重要的一句話。連線的真相永遠在持有 socket 的那台機器上;registry 只是一份用來加速查找的快取,而且它必然落後——連線遷移與訊息投遞之間存在天然競態,你查到的答案在使用它的那一毫秒可能已經過期了。

看清楚這件事之後,設計方向就會對:不要試圖用鎖或強一致儲存去消滅競態(成本高到不可行,而且解決不了網路分區),而是讓系統能容忍投錯——投遞失敗要能被偵測並重試、下游要冪等(HTTP 篇的冪等性、MQTT 篇的 QoS 去重)、客戶端要能靠 lastSeq 或 resume token 發現缺口並要求補發(WebSocket 篇)。這條路徑存在,registry 就可以是最終一致的;這條路徑不存在,你用 etcd 也救不了。

4 · 為什麼「訂閱」不能也塞進 registry

下一個誘惑是把訂閱關係也寫進去:topic:{room} → [conn1, conn2, ...]。算一下規模就知道不行——十萬條連線每條訂十個 topic,就是一百萬筆條目,而且每次訂閱/退訂都是一次寫入,廣播一則訊息要先撈出一份可能有幾萬筆的成員清單。registry 的條目數必須跟連線數同階,不能跟「連線數 × 訂閱數」同階。

正解是把粒度從「連線」抬高到「節點」:節點代表它上面所有連線去訂閱。這就是第 4 節(跨節點 Fan-out)要談的訂閱聚合。

一句話答法Connection registry 是一張「身分 → 節點」的映射,用 Redis 存、掛在心跳上續期 TTL、用 epoch 清殘骸;它本質上是最終一致的 hint 而非 truth,所以真正的設計重點不是讓它永遠正確,而是讓投遞失敗可被偵測、下游冪等、客戶端能補發。
04

跨節點 Fan-out 與系統級 Backpressure

找到那一台之後,訊息要怎麼過去;以及當它過不去時,壓力會往哪裡跑。

1 · 兩種拓撲:中心 bus 與節點互聯 mesh

中心 Pub/Sub bus
Redis Pub/Sub · NATS · Kafka
每個節點只跟 bus 說話,連線數 O(N)。多一跳延遲,bus 是明確的故障域;但成員變更零成本、跨機房友善、可觀測性好做。幾十台以上幾乎只有這條路。
節點互聯 mesh
Erlang distribution · 多數 MQTT Broker 叢集
節點兩兩相連,連線數 O(N²)。延遲最低(一跳直達)、沒有第三方依賴;但成員變更、腦裂、部分連通都要自己處理,規模上限通常是幾十台。
後端投遞語義適合放什麼要小心什麼
Redis Pub/Subfire-and-forget,不持久化,訂閱者不在就沒了presence、游標、行情這類「可丟且最新值才有意義」的訊息慢訂閱者會被 Redis 直接斷開(見本節 4);訂閱者重連期間的訊息全部遺失
NATS(core)同上,但主題比對與叢集路由更強同上,加上跨區的節點間路由要留存與重播必須上 JetStream,那就是另一套運維
Kafka有序、有留存、可重播不可丟的業務事件、以及需要被其他系統消費的資料partition 數限制 fan-out 彈性、延遲比前兩者高一個量級;把它擺在即時推播的關鍵路徑上通常是誤用

MQTT 篇已經論證過標準分層:MQTT 負責接入、bridge 到 Kafka 負責留存與重播。WebSocket 叢集的版本是同一張圖——即時路徑走輕量 bus,留存路徑走 Kafka,兩條路不要合併。合併的結果通常是為了「不丟訊息」而讓所有人的延遲一起變差。

2 · 訂閱聚合:節點代表它上面所有連線去訂閱

承接第 3 節最後那個規模問題。作法是在每個節點內部維護一張本地表 topic → 連線集合,而對 bus 只註冊節點層級的訂閱:

gw-3 本地表 room:42 → [c1, c9, c17] → 只向 bus 訂閱一次 → SUBSCRIBE room:42 第一個進房的人註冊、最後一個離開才退訂

效果是 bus 上的訂閱數從 O(連線數 × 訂閱數) 降到 O(節點數 × 活躍 topic 數),而且訊息在網路上每個節點只複製一份,展開成 N 份的動作發生在節點本地記憶體裡。每個節點通常固定訂三類 channel:node.{id}(點對點,配合 registry 查找)、room.{id}(本節點有人在才訂)、broadcast(全體公告)。

MQTT 叢集裡這張表叫 routing table,而且更貴——因為訂閱是 topic filter 帶 wildcard(MQTT 篇的 + 與 #),節點要交換的不是字串而是一棵可比對的樹,且必須全叢集可見(否則 publish 到某節點時不知道要轉發給誰)。這也是 MQTT Broker 叢集節點數普遍不高的原因之一。至於 MQTT 篇只點名的 Shared Subscription($share),在叢集裡就是「訂閱聚合 + 負載分配」的協定內建版:一個共享群組的訊息只投給群組裡的一個成員,跨節點的競爭消費由 Broker 協調。

3 · slow consumer 的放大效應:兩層佇列,兩種死法

WebSocket 篇已經指出這件事在單機的樣子與答案(有界佇列 + 斷線,犧牲一個保全其他人),並把多節點版本留給本篇。差別在於訊息現在要經過兩層佇列,而兩層都設錯的死法完全不同:

佇列位置作用只有它會怎樣
per-node 入口佇列節點從 bus 收訊息、準備展開分發一個慢客戶端會把整台節點的分發卡住——這就是節點層級的 HoL Blocking,一條連線的病傳染給幾萬條
per-connection 出口佇列每條連線各自的有界發送緩衝(WebSocket 篇的 bufferedAmount 是它的瀏覽器端對應物)記憶體是連線數的倍數:十萬條連線 × 1 MB 上限 = 100 GB。上限不能按「一條連線很小」來設,要按「乘以連線數還活得下去」來設

正確作法是兩層都要,但職責分開:入口佇列永遠不等待任何單一連線(展開時對慢的連線直接走出口佇列的丟棄策略,不阻塞迴圈);出口佇列設一個以「則數 + 位元組數」計的小上限,超過就依訊息類別合併成最新值、或直接關閉那條連線(WebSocket 用 1013/1009,MQTT 5.0 用 DISCONNECT reason 0x97 Quota exceeded)。

再往上一層是故障域隔離:把「可丟的」(presence、游標、行情跳動)與「不可丟的」(聊天、訂單狀態)分成兩條 fan-out 路徑,甚至兩組節點池。理由是這兩類訊息的正確策略互相矛盾——前者塞住時該丟、後者塞住時該等或斷線重來。混在同一條佇列裡,你只能選一種策略,然後在另一類訊息上做錯事。

4 · 系統級 backpressure:壓力該往哪裡跑

TCP/UDP 篇談 I/O 模型與 backpressure 的章節已經把因果鏈講完(接收端讀得慢 → rwnd 縮到 0 → 發送端 EAGAIN),WebSocket 篇則補完了 WebSocket 這一層只有 bufferedAmount 這一個儀表。這裡不重講機制,只談叢集才有的那一段:bus → gateway 這一跳,誰來擋。

兩種後端給的答案完全相反。Redis Pub/Sub 幫你決定:client-output-buffer-limit pubsub 預設 32 MB 硬上限/8 MB 持續 60 秒軟上限,超過就直接斷開這個訂閱者——丟掉的不是一則訊息,是那個節點的整條訂閱,而且它自己還不一定馬上知道。Kafka 與 JetStream 則是 pull 模型,天然有 backpressure,代價是壓力變成 consumer lag:訊息不會丟,但會遲到——而在即時系統裡,遲到十秒的行情跟丟掉沒有兩樣,還多佔了一份記憶體與頻寬。

丟棄發生的層級丟什麼 / 怎麼丟什麼時候該在這裡丟
源頭限流publisher 端限速、同一實體的更新合併成最新值再發最省的一層:一則沒發出去的訊息不會在下游被複製 N 次。需要業務配合,但投資報酬率最高
bus 層訊息 TTL、topic 分級、丟最舊的來源不受控(第三方行情)、且訊息本身有明確時效
gateway 入口整批捨棄可丟類別,保住不可丟類別單一節點過載(傾斜、GC、剛重啟正在灌連線)時的自保
gateway 出口per-connection:合併最新值,或斷線讓他重連重載問題只出在個別客戶端(WebSocket 篇的地鐵使用者)——不該讓其他人陪葬
客戶端取樣、降頻渲染WebSocket 篇已定調:瀏覽器沒有 receive backpressure,這是最後一道也是最弱的一道

分層原則:越靠近源頭丟越便宜,越靠近使用者丟越精準。所以策略通常是「源頭做粗粒度限流,出口做細粒度取捨」,中間兩層只作為過載時的保險絲。

關鍵即時系統的 backpressure,方向是「讓下游變粗糙」而不是「讓上游變慢」

請求/回應系統可以讓上游等——排隊、限流、429 重試,使用者只是慢一點。即時推播不行:讓上游變慢等於讓所有訂閱者一起變慢,只為了遷就最慢的那一個。正確的方向是降低精度而不是降低速度——把每秒 20 次的行情降成 5 次、把逐字元的游標移動合併成每 100 毫秒一次、把 10 則通知合併成一則摘要。
判準很簡單:問這則訊息「遲到還有沒有價值」。沒有價值的(狀態類)就合併成最新值;有價值的(事件類)就必須進有界佇列並準備好在滿的時候斷線重來——而不是無限緩衝,因為那個選項的名字叫 OOM。

一句話答法跨節點 fan-out 用中心 bus(規模大)或 mesh(延遲低),並靠訂閱聚合把 bus 上的訂閱壓到節點粒度;佇列必須分兩層——入口佇列不許為任何單一連線停下來,出口佇列設小上限並在滿時合併或斷線;而即時系統的 backpressure 方向是讓下游變粗糙,不是讓上游變慢。
05

心跳、Graceful Drain 與重連風暴

這三件事其實是同一件:叢集怎麼判定一條連線死了、怎麼讓它有秩序地死、以及怎麼承受它們一起回來。

1 · timeout 責任鏈:四層計時器要排出順序

單機時只有一個逾時值要設;多節點的路徑上有四層,而且它們互不知道彼此的存在。排序原則只有一條:希望由誰來偵測,就讓誰的計時器最短。你要的是「gateway 發現連線死了,然後執行清理、刪 registry、發 Last Will」,而不是「某個中間設備靜默拆掉連線,兩邊都以為對方還在」。

心跳週期 × 1.5 ≤ 節點的死亡判定門檻 < 路徑上最短的 idle timeout

左半邊是 MQTT 篇的規格值(Keep Alive × 1.5),也是應用層心跳的合理起手式:容忍一次丟包再宣告死亡。右半邊是 WebSocket 篇那條鐵律的完整版——不只是「心跳要比 idle timeout 短」,而是「連死亡判定都要比它短」,否則中間設備會搶在你前面動手。

層級誰在計時典型值設錯的症狀
客戶端心跳客戶端(也負責偵測 half-open)20–30 秒(行動端要權衡耗電,IoT 則以電池為上限,見 MQTT 篇)太長:NAT 映射先過期(TCP/UDP 篇),連線變殭屍
gateway 讀取逾時節點——這一層應該最先發現心跳 × 1.5(例如 45 秒)短於心跳:健康的連線被誤殺,客戶端無限重連
LB idle timeoutLB60 秒起跳,要調到大於上一列短於心跳:滿地 1006(WebSocket 篇),而且 gateway 完全不知道發生什麼事
bus / broker 連線bus最長,或直接關掉——這是節點對節點的長連線,本來就該一直活著誤設短值:fan-out 間歇性中斷,症狀是「有些人偶爾收不到」

驗收方式:把上表當成一條不等式鏈畫在圖上,任何一段沒有嚴格遞增就是設定錯誤。而唯一能證明你設對的證據是 1006 佔比夠低——它是這條責任鏈的體檢報告。

2 · 誰判定 Keep Alive 逾時、誰代發 Last Will

MQTT 篇留下的第二題。答案的第一半很直接:只有持有那條連線的節點有資格計時——封包只流經它,別的節點連「有沒有收到 PINGREQ」都不知道。這就是 home node 概念在保活上的具體意義:session 的擁有權必須明確,而擁有者就是判定者。

難的是第二半:那台節點自己掛了怎麼辦?此時沒有任何人在替那批連線計時,於是 Last Will 不會被發出、presence 永遠停在 online——這比裝置真的離線還糟,因為看板上顯示的是錯的。

關鍵「誰有資格宣告一條連線死了」是 failure detector 問題,答案只能是「持有租約的人」

正解是把 session 的擁有權做成租約(lease):節點在叢集狀態裡持有「我擁有這些 session」的租約並定期續期(這是少數該放 etcd/強一致儲存的東西,因為量級是節點數而不是連線數)。節點失聯後租約到期,叢集才允許另一個節點接手——接手者負責觸發 Last Will、把 session 標成離線、清理 registry 殘骸。MQTT 5.0 的 0x8E Session taken over 正是這件事的近親(client id 撞號時舊連線被踢)。
代價是 presence 的精度上限被拉長了:presence 延遲 = Keep Alive × 1.5 + 租約逾時 + 狀態複製延遲。所以永遠不要對外承諾秒級的離線偵測;要秒級,就得靠應用層的主動探測而不是連線層的逾時。

3 · Graceful Drain:把幾十萬條連線趕去別台

MQTT 篇留下的第三題,也是這一章最有實務含量的部分。完整的排空是四個步驟,順序不能換:

sequenceDiagram
    participant G as gw-4
    participant LB as LB
    participant C as 客戶端
    participant R as Registry
    G->>LB: 健康檢查回不健康
    LB-->>G: 不再送新連線
    Note over LB,G: 既有連線照常服務
    G->>C: 分批送 close 1012
    C->>LB: 重連
    LB-->>C: 導到 gw-1
    C->>R: 條目改指向 gw-1
    Note over G,R: 剩餘連線歸零才算排空完成
    G->>G: flush 與交棒後退出

四個步驟分屬四個角色,順序不能換。整張圖只有一處回覆需要盯著看:LB 只答應得了第一步——「不再送新連線」,它不會替你關掉既有的任何一條。第二步之後全部是 gateway 自己的工作,而第三步(registry 改指向)沒有完成就關行程,訊息會被投進一個已經沒有人的節點。

第一步是讓健康檢查回不健康、或從 target group 反註冊,目的只有一個:停止進新的連線。這裡有個普遍誤解——LB 的 connection draining/deregistration delay 只保證「不再送新請求給你」,它不會幫你關掉既有的長連線;對短請求那個設定就等於排空,對長連線它只完成了四步中的第一步。

第二步是主動關閉,而 WebSocket 篇已經把詞彙備好:1001 Going Away(節點要下線)、1012 Service Restart(重啟中,可以馬上重連)、1013 Try Again Later(請退避久一點,用在 rebalance 與過載)。MQTT 5.0 的對應物是 DISCONNECT 帶 reason code:0x8B Server shutting down,或更漂亮的 0x9C Use another server/0x9D Server moved——後兩者可以附上 Server Reference 直接告訴客戶端該去哪,是 MQTT 5.0 在這一題上比 WebSocket 精緻的地方。HTTP/2 上的語義相同的東西是 HTTP 篇的 GOAWAY:先宣告不再接受新 stream,再等既有的做完。

每批連線數 = 總連線數 ÷ (排空預算 ÷ 批間隔) ,每條連線的實際關閉時刻再乘上 jitter
# 排空 gw-4 的 32,000 條連線,預算 120s,批間隔 2s → 每批 ~534 條
deregister_from_lb(node)                 # 1. 先停新連線;等健康檢查確實摘除
wait_until(lb_reports_unhealthy, 15)     # 這 15s 內既有連線照常服務

for batch in chunks(connections, size=534):
    for c in batch:
        delay = random.uniform(0, 2.0)               # jitter:打散同批的重連時刻
        schedule(delay, lambda: c.close(1012, retry_after=jittered_hint()))
    sleep(2)
    if reconnect_success_rate() < 0.98:  # 排空必須可中止
        abort_drain(); alert("drain paused: 重連成功率下滑"); break

flush_pending_messages(); handoff_sessions()         # 4. 交棒後才真的退出

三個容易漏掉的點:批間隔要留給客戶端完成重連(否則你只是把瞬間斷線改成分五十次的瞬間斷線);drain 必須可中止——如果重連成功率掉下來,繼續排空只會把剩下的節點也打垮;Kubernetes 上 terminationGracePeriodSeconds 必須大於排空預算(預設只有 30 秒),並在 preStop 裡執行上面的流程,否則 SIGKILL 會把你精心排空的剩餘連線全部變成 1006。這是「明明寫了 drain 卻還是滿地 1006」的頭號原因。

最後一個選擇題:主動遷移 vs 等自然衰減。IoT 場景可以只做第一步(停新連線)然後等裝置自己因為網路波動重連過去,幾小時內自然清空——代價是發版週期被拉長、且要能同時跑兩個版本。互動式應用不能等:使用者會看到「連線中斷」的橫幅,所以必須主動趕,並且把整個過程壓在幾分鐘內。判準是「連線的自然壽命」:中位數幾分鐘的(瀏覽器分頁)可以等,中位數幾天的(感測器)等不起。

4 · 重連風暴的多節點版本:它打的不只是 gateway

WebSocket 篇已經給了退避公式與相位鎖定的成因(同一個事件讓所有客戶端的計時器同相),並明說「多節點叢集才會真正露出獠牙」。獠牙就是連鎖:三萬條連線同時回來,被打到的不只是接手的 gateway。

重連一條連線要花掉誰的資源尖峰時的樣子防禦
TLS 交握 CPU(TLS 篇)full handshake 的簽章運算是連線建立裡最貴的一項,三萬條同時來會直接吃滿 CPUsession resumption/0-RTT(TLS 篇已論證它對頻繁重連的客戶端最有價值);STEK 要跨機共享,否則換一台就退回 full handshake
認證服務每條連線一次 token 驗證,等於對 auth API 的一次 DDoSreconnect token:斷線時發一張短效、綁使用者與節點世代的票,重連時免走完整認證
registry 寫入三萬筆寫入擠在幾秒內,Redis 的 p99 跟著跳分批放行自然就攤平了;並確認 registry 不在同一個 Redis 上跟業務快取搶資源
快照 API最容易被低估的一項:WebSocket 篇的「全量重載」策略下,每個人重連後都要拉一份完整狀態快取 + 降級(重連尖峰時回粗糙版本);或改用序號補發,只送缺口
fan-out bus三萬條連線的訂閱關係要重新註冊,訂閱聚合會讓它好很多但不是零節點層級的訂閱本來就在(第 4 節),成本主要在本地表重建

這張表才是長連線服務真正的容量規劃單位:不是「能撐幾條連線」,而是「每秒能接受幾條新連線」。前者靠記憶體,後者靠 CPU 與所有下游依賴——而故障時要面對的永遠是後者。

伺服器端的四道防禦,按投資報酬率排序:(1) 連線層限流——每秒只接受 N 條新連線,超過的直接拒絕並要求退避(MQTT 5.0 有專用的 0x9F Connection rate exceeded,WebSocket 用 1013),這是唯一能保證「不會被打垮」的一招;(2) reconnect token 免掉 TLS full handshake 與認證往返;(3) 快照 API 的快取與降級;(4) 由伺服器下發退避參數——不要相信客戶端的 jitter 實作,關閉時明確告訴它「幾秒後再來」,並在那個值上自己加抖動。

陷阱最貴的重連不是 TCP 交握,是「每個人回來都要一份快照」

工程師直覺會去優化連線建立本身,但實測下來,重連尖峰壓垮的通常是快照 API 與它背後的資料庫——一條連線的成本是幾 KB,一份初始狀態可能是幾百 KB 而且要查三張表。先算清楚「一次重連的完整成本」再談容量,而且務必把它做成可降級的:寧可回一份三十秒前的快取,也不要回不出來。

互動 · 一次重連風暴的帳單 拖動五個旋鈕,看四項下游裡哪一項先爆
拖動旋鈕,看這一波重連會先壓垮哪一項下游。

四條是四項下游的容量利用率,紅色虛線是 100%——要看的是哪一條先越線,而不是總量。最下面那條尺上,藍點是目前的攤平時間、綠點是讓四項全部壓回容量內所需的最短攤平。

四項容量上限是本 demo 的假設值:TLS full handshake 3,000/s、認證服務 5,000 QPS、registry 20,000 writes/s、快照 API 400 MB/s。換成你自己叢集的數字再看一次,結論通常會換人。

把攤平時間拉到 2 秒(硬斷的樣子)、resumption 與 token 都歸零、快照維持 180KB,四項會同時爆掉三項,而先爆的是快照 API(703.1%)不是 TLS(533.3%);把快照降級成 20KB,瓶頸立刻換成 TLS。這就是上面那個陷阱說的事——工程師直覺會去優化連線建立,但壓垮系統的通常是「每個人回來都要一份完整狀態」。

5 · 可觀測性:上面每一個機制都要能被看見

長連線服務的黃金訊號跟 HTTP 服務完全不同。QPS 與錯誤率在這裡幾乎沒有意義——連線閒著的時候沒有 QPS,而真正的故障(訊息沒送到、連線被靜默拆掉)根本不會產生任何錯誤回應。要看的是這些:

指標它在回答什麼出事時的樣子
每節點連線數 · max/min 比值傾斜有多嚴重(第 2 節)發版後比值從 1.1 變成 3,接著最忙那台先 OOM
連線建立與關閉速率(churn)重連風暴的前兆與容量單位(第 5 節)底噪的十倍以上,且兩條曲線同步跳動
1006 佔全部 close code 的比例路徑上有沒有人靜默拆連線(WebSocket 篇)某個機房/某個 CDN 節點的佔比獨高=那條路徑上的 idle timeout 設錯了
心跳逾時率 · PINGREQ 遲到分佈網路劣化,或節點自己的 GC/事件迴圈卡住逾時率跳升但只集中在一兩台=是節點的問題不是網路的問題
fan-out 端到端延遲 p99從 publish 到客戶端收到——唯一真正代表「即時」的指標p50 正常但 p99 飆到數秒=某層佇列在堆積
佇列深度:per-node 入口 / per-connection p99 與 maxbackpressure 是否正在作用(第 4 節)入口佇列有深度=分發跟不上;出口佇列滿=有 slow consumer
registry 查找失敗率 · stale 命中率有沒有路由黑洞(第 3 節)投遞失敗率上升卻沒有任何錯誤日誌,是最典型的 stale entry 症狀
drain 進度:剩餘連線數 · 每批重連成功率發版本身可不可觀測成功率掉到 98% 以下就該中止排空
# 值得先建起來的八個指標(名稱示意,重點是維度切法)
rt_connections{node,region}                  # gauge:連線數,一定要有 node 維度才看得到傾斜
rt_connection_opens_total{node,result}       # counter:建連速率 = 容量規劃的真正單位
rt_connection_closes_total{node,code}        # counter:code 維度就是 1006 佔比的來源
rt_heartbeat_timeout_total{node}             # counter:除以連線數 = 心跳逾時率
rt_fanout_latency_seconds{stage}             # histogram:publish→bus→node→socket 分段打點
rt_queue_depth{node,queue="ingress|egress"}  # gauge / histogram:backpressure 的直接證據
rt_registry_ops_total{op,result}             # counter:lookup / write / stale 的比例
rt_drain_remaining{node}                     # gauge:發版時盯著它歸零

node 這個維度是全部指標的共同前提——叢集層級的平均值會把所有有趣的事情藏起來:一台傾斜、一台 GC 卡住、一台的 idle timeout 設錯,在總和曲線上全都看不出來。至於單條連線層級的細節,QUIC gateway 上可以用 QUIC 篇介紹的 qlog 補上(傳統的 tcpdump/ss 在 QUIC 上已經失效),但它是逐連線的除錯工具,取代不了上面這些聚合指標。

一句話答法四層 idle timeout 要排成嚴格遞增、讓自己的節點最先發現死亡(心跳 × 1.5 ≤ 死亡判定 < 最短 idle timeout),Keep Alive 逾時只能由持有連線的節點判定、它掛掉時靠租約接手代發 Last Will;graceful drain 是「先摘 LB → 分批帶抖動送 1012/0x8B → 確認 registry 改指向 → 才關行程」,而重連風暴打的是 TLS CPU、認證與快照 API,所以容量單位是「每秒能接受幾條新連線」。
06

兩種叢集:WebSocket gateway 與 MQTT Broker

同樣是幾十萬條長連線,一個把語義留給應用、一個把語義寫進協定——叢集因此長得完全不一樣。

1 · connection lifecycle 對照

階段WebSocket gateway 叢集MQTT Broker 叢集
建立Upgrade/Extended CONNECT,認證在交握完成(WebSocket 篇)。任何節點都能接,因為節點不持有這個身分的既有狀態CONNECT 帶 client id 與 Clean Start。節點必須先問「這個 client id 的 session 在哪」,可能要跨節點接手(0x8E Session taken over)
身分user/device id,同一人多條連線是常態client id 全叢集唯一,撞號要踢掉舊的——而舊的那條可能在別台
訂閱應用自訂語義,節點本地表 + bus 聚合(第 4 節)協定內建 SUBSCRIBE,帶 wildcard 的訂閱樹必須成為全叢集可見的 routing table
斷線期間的訊息協定不管;補不補由應用決定(lastSeq/resume token)persistent session 佇列是協定的承諾,必須存在某處,且重連可能落到別台
死亡判定應用層心跳,節點自己算Keep Alive × 1.5 寫死在規格,home node 算、並負責代發 LWT(第 5 節)
優雅關閉1001/1012/1013DISCONNECT reason 0x8B/0x9C(可附 Server Reference 指定去哪)
節點掛掉的損失使用者重連別台 + 拉快照,損失可控(前提是快照 API 撐得住)session、inflight、retained 可能一起消失——而這會違背 QoS 1/2 對應用做出的承諾

一句話總結這張表:WebSocket gateway 是「無語義通道 + 你自建的訊息層」——狀態輕、語義自己扛;MQTT Broker cluster 是「語義內建 + 狀態重」——協定替你承諾的每一件事,叢集都要想辦法兌現。前者的節點接近可拋棄,後者的節點是有身分的。

2 · 四種狀態,四種複製答案

MQTT 篇留下的第一題:session 與 retained 到底要不要跨節點複製。答案不是一個而是四個,取決於「誰會需要讀它」與「掉了會怎樣」:

狀態要跨節點嗎怎麼做代價
retained message要訂閱者可能連在任何節點上,所以 retained store 必須全叢集可讀:複製 KV 或共享儲存最終一致就夠——代價是「剛連上可能拿到幾百毫秒前的舊值」,對 presence 看板可接受
session 與離線佇列不要全複製home node + 持久化 + takeover:session 有明確擁有者,重連落到別台時轉交或代理。只有「Broker 掛掉也不能掉訊息」的場景才做真複製全複製的成本是「每則離線訊息寫 N 份」,多數專案付不起也不需要
訂閱路由表要,但只複製節點粒度複製的是「哪個節點上有人訂了這個 filter」,不是「哪個 client 訂了」——就是第 4 節訂閱聚合的 MQTT 版wildcard 讓比對變貴;訂閱變動頻繁的場景要注意複製流量
inflight window不要它屬於那一條連線,節點掛掉時由客戶端重送(帶 DUP flag)並靠應用層冪等收尾這就是 MQTT 篇那句「QoS 2 保證的是每一段 hop,端到端一次語義要自己做冪等」在叢集裡的具體後果

共同判準:會被「任何節點」讀到的狀態才需要複製;只被「那一條連線」用到的狀態不要複製。多數 Broker 叢集的效能差異,就出在這張表的第二列選了什麼。

3 · edge 與中心的分界線

MQTT 篇的最後一題。判準也只有一條:會消耗 CPU 與延遲的下沉到 edge,需要全域真相的留在中心。

裝置 / 瀏覽器
CLIENT
退避 + jitter
錯開重連時刻(WebSocket 篇)
lastSeq / resume token
重連後自己指出缺口
接收端取樣降頻
瀏覽器沒有 receive backpressure
Edge:接入層(就近部署、可拋棄、水平擴充)
GATEWAY / BROKER FRONTEND
TLS 終止與交握 CPU
純 CPU,加機器就能擴
心跳與死亡判定
只有持有連線的節點算得了
per-connection 佇列
緩衝必須貼著 socket
連線層限流 / DDoS 吸收
越早擋掉越便宜
訊息合併與降頻
貼著使用者才知道能丟什麼
地理就近
省的是每一則訊息的 RTT
中心:真相層(不接連線,只處理語義與持久化)
BUS / STORE / SERVICES
fan-out bus
讓每個節點只收到一份
registry 真相 / 租約
要全叢集看到同一份
授權決策
不能各節點各說各話
持久化與重播(Kafka)
留存路徑與即時路徑分開
業務邏輯與全域排序
排序需要單一權威

三層之間不是資料流而是責任歸屬——每一格的第二行都在回答同一個問題:它消耗的是 CPU 與延遲(下沉 edge),還是需要全域真相(留中心)。這張圖就是 MQTT 篇那條「MQTT 接入 → bridge → Kafka 留存」分層的一般化版本(該篇已論證,此處不重複):WebSocket 的版本是 gateway → bus → 業務服務,形狀完全一樣。差別只在 MQTT 的 edge 層被協定要求扛更多狀態,所以它比較不「可拋棄」。

跨區部署還有一條實務鐵律:連線只連最近的 edge,跨區複製的是訊息不是連線。理由是長連線跨區的成本是持續的(每一則訊息都多付一次跨區 RTT 與流量費),而訊息跨區複製的成本是一次性的(一則訊息跨區一次,再由當地 edge 展開給幾萬條連線)。把 fan-out 的展開動作留在使用者所在的區域,這是第 4 節「每個節點只複製一份」原則的地理版本。

一句話答法WebSocket gateway 是無語義通道、狀態輕、節點可拋棄,訊息層要自己建;MQTT Broker cluster 是語義內建、狀態重、節點有身分,retained 要複製、session 走 home node + takeover、路由表只複製到節點粒度、inflight 不複製;而 edge 扛 TLS、心跳、佇列與限流,中心扛真相、持久化與授權。
07

互動 · 連線佔用地圖

把叢集畫成「節點 × 容量」這個空間:點一台節點就排空它,看連線落到哪裡、傾斜怎麼一層層沉積出來、天花板在哪一步被撞到。

互動 · 連線佔用地圖 點任一根柱子=排空那一台(可連點多台,順序有意義)
4 台節點各 30.0k、上限 80k。點任一根柱子把它排空。

每一根柱子是一台節點的容量空間:柱高等於連線數上限,虛線就是上限本身,裡面的色帶是「這批連線是第幾波接手進來的」——要看的不是總高度,是分層。傾斜是一層層沉積出來的,不是一開始就歪。「一次性抽籤」以期望值模擬(釋放出的連線平均分給在線且未滿的節點,不看目前負載),所以同一組參數永遠得到同一張圖。

未達上限,還收得下 已達上限 · 自摘(柱子疊斜線帶)

重連峰值的分母是本 demo 的兩個假設值:graceful drain 把重連攤平在 120 秒內,硬斷則只有 2 秒的抖動窗。

四組對照值得自己推一次:(1) 預設 4 台各 30k、上限 80k,依序點 gw-4 → gw-3 → gw-2,gw-1 會長到 71.1k、是平均值的 2.37 倍——沒有任何一步做錯,這就是第 2 節說的算術必然;(2) 同一組操作切到 least-connection,除了剛排空的那台是空的,其餘三台都停在 40.0k、傾斜比掉到 1.33,證明傾斜是分配演算法的產物而不是宿命;(3) 把上限降到 50k 重做一次:排空 gw-3 的那一步會讓 gw-1 與 gw-2 同時撞頂自摘、剩餘的量被重新分配一輪,等你再排空 gw-2,最後落在 50.0 / 0 / 25.0 / 45.0(此時只剩 gw-1 還掛著自摘)——上限擋得住 OOM,擋不住傾斜;(4) 把下線方式切到硬斷——分佈一格都不會變,但重連峰值從 444/s 跳到 26,667/s、1006 從 0 變成 123.3k。後面那兩個數字才是使用者感受到的東西。

一句話答法LB 對長連線只決策一次,所以滾動重啟會讓先重啟的節點把後面每一台釋放出來的連線都吃進去——傾斜是分配策略的算術結果,不是誰做錯;而節點連線數上限只擋得住 OOM,擋不住傾斜,撞頂之後的自摘還會把壓力推給更少的節點。至於 graceful drain 與硬斷,差別從來不在總連線數,而在重連是被攤平的還是同相的。
08

面試折衷與實務選型

這一章同時是網路協定主線的收尾:先把本篇的選型攤開,最後用一張表回頭看從 TCP/UDP 篇到本篇走過的路。

決策點選項Trade-off
負載均衡層L4 vs L7L4 便宜、對協定無感,但參與不了優雅下線也擋不掉壞交握;L7 功能全開,代價是每條連線要兩條 TCP 與一份解析狀態——乘以連線數之後那是真的帳單。MQTT 幾乎只能 L4,QUIC 一定要 CID 感知路由
fan-out 拓撲中心 bus vs 節點 meshmesh 延遲最低但 O(N²) 且腦裂要自己處理,實務上限幾十台;中心 bus 多一跳、是故障域,但擴充與運維成本低得多。不確定就選中心 bus
registryRedis vs 強一致儲存 vs 一致性雜湊連線級的 churn 只有 Redis(或節點內建複製表)扛得住;etcd 留給節點成員與租約這類「量級是節點數」的資訊。能用算的(一致性雜湊)就不要查表,但要接受擴縮容會搬動連線
佇列放哪per-node vs per-connection只有 per-node 會讓一個慢客戶端卡住整台;只有 per-connection 則是記憶體乘以連線數。兩層都要,職責分開
節點下線主動遷移 vs 等自然衰減判準是連線的自然壽命:瀏覽器分頁可以等,感測器等不起。主動遷移要付出「批次 + 抖動 + 可中止」的工程量,換來的是可預測的發版時間
重連後的狀態全量快照 vs 序號補發沿用 WebSocket 篇那張恢復策略表,但在叢集裡要多算一筆:全量快照的成本要乘上「最壞情況下同時重連的連線數」。撐不住就必須做補發或降級快照
session 複製不複製 vs home node + 持久化 vs 全複製全複製才能做到「Broker 掛掉也不掉訊息」,代價是每則離線訊息寫 N 份。先問清楚業務要的是「盡力而為」還是「協定級承諾」,多數情況是前者
自建 vs 託管自建 gateway 叢集 vs 託管服務本篇整篇講的東西就是自建的成本清單。連線數在十萬以下、又沒有特殊語義需求時,託管(雲廠商的 WebSocket API、IoT Core、即時通訊 PaaS)通常更划算——你買的不是伺服器,是這些 edge case 已經有人踩過

共同結構:每一個選項都在「狀態放哪裡」上做取捨。放連線本地最快但節點不能死,放外部儲存最有彈性但每條連線都在打網路,放客戶端(lastSeq/resume token)最省伺服器但要相信客戶端。這三個方向就是本篇所有決策的座標軸。

面試高頻追問怎麼答

Q:WebSocket 要不要做 Sticky Session? 答:先拆題——長連線本身天然 sticky,一條 TCP 就固定在一台上。真正的問題是「重連後落到別台,狀態怎麼辦」,答案是狀態外部化、resume token 編碼分區、或 home node 三選一。
Q:訊息怎麼送到「持有那條連線的那一台」? 答:三步——查 connection registry 拿到 nodeId、把訊息投到該節點訂閱的 channel、節點在本地表找到 socket 寫出去。registry 是最終一致的 hint,所以投遞失敗要能被偵測、下游要冪等、客戶端要能補發。
Q:為什麼滾動重啟後某一台的連線特別多? 答:LB 對長連線只在建連時決策一次,先重啟的節點會吃掉後面每一台釋放的連線。對策是節點連線數上限 + 健康檢查摘除、定期小批量 rebalance,以及把 max/min 比值當發版驗收指標。
Q:怎麼優雅下線一台有三萬條連線的節點? 答:先從 LB 摘除停止新連線(LB 的 draining 只做到這一步),再分批帶 jitter 送 1012/MQTT 0x8B,用 registry 確認條目改指向,最後才 flush 與關行程;terminationGracePeriodSeconds 要大於排空預算,否則全部變 1006。
Q:重連風暴除了讓客戶端退避還能做什麼? 答:伺服器端四招——連線層限流分批放行、reconnect token 免掉 TLS full handshake 與認證、快照 API 快取與降級、由伺服器下發帶抖動的退避參數。因為被打垮的通常不是 gateway,而是認證與快照 API。
Q:MQTT 叢集裡誰代發 Last Will? 答:持有那條連線的節點(home node)判定 Keep Alive × 1.5 逾時並代發;該節點整台掛掉時,要靠 session 租約到期讓其他節點接手代發,否則 presence 會永遠卡在 online。
Q:長連線服務該監控什麼? 答:不是 QPS 與錯誤率。是每節點連線數與傾斜比、churn、1006 佔比、心跳逾時率、fan-out 端到端 p99、兩層佇列深度、registry 失敗率、drain 進度——而且每一個都要有 node 維度。
Q:容量規劃看什麼數字? 答:看「每秒能接受幾條新連線」而不是「能撐幾條連線」。前者受限於 TLS CPU、認證與快照 API,而故障時你要面對的永遠是前者。

主線收斂:從 TCP 到 production 的七級階梯

從 TCP/UDP 篇一路走到本篇,回頭看會發現一條非常整齊的線索:每一層解決的問題,都是上一層暴露出來的。這張表是這條主線的畢業照——每一列是一篇,讀法是由上往下的「解決了什麼、又留下什麼」。

#這一層解決了什麼它留下什麼問題誰接手
1 · TCP / UDP可靠、有序的位元組串流;以及 UDP 那份「什麼都不保證」的最小契約內容明文裸奔;沒有訊息邊界(framing 是應用的事)TLS 篇
2 · TLS 1.3加密、完整性,以及「對面真的是他」的憑證鏈驗證交握多一次 RTT;通道打通了,上面還沒有任何語義HTTP 篇(語義)· QUIC 篇(RTT)
3 · HTTP/1.1 · 2方法、狀態碼、快取、冪等性等中間層契約;二進位框架與多路復用TCP HoL Blocking 讓多路復用只做到一半;伺服器沒有主動開口的位置QUIC 篇(HoL)· WebSocket 篇(主動)
4 · QUIC / HTTP3在 UDP 上重建 transport:stream 獨立、交握合一、連線識別脫離五元組再快也還是請求/回應;而 CID 讓負載均衡必須重寫WebSocket 篇(模式)· 本篇(LB)
5 · WebSocket一條對稱的全雙工訊息通道,伺服器終於可以主動說話沒有投遞保證、對受限裝置太重;連線狀態只活在單一節點的記憶體裡MQTT 篇(保證)· 本篇(單機)
6 · MQTT把 pub/sub 路由、QoS、離線 session、Last Will 全寫進協定,換取裝置端的極簡代價是 Broker 扛下全部狀態——於是它也只活在單一節點的記憶體裡本篇
7 · 本篇 · 連線擴充把主詞換成節點集合:LB 與傾斜、registry、fan-out、backpressure、drain、重連風暴、可觀測性剩下的不再是協定問題,而是工程紀律——容量規劃、發版流程、故障演練、以及有沒有人在看那些指標你的團隊

兩個值得帶走的觀察:(1) 第 5、6 列留下的是同一個問題——不同協定、同一個結構性限制,這正是本篇第 1 節的起點;(2) 最後一列沒有「下一篇」。到了這個高度,協定能幫你的事情已經用完了,剩下的每一個決策都是在「狀態放哪裡」與「壞掉時誰負責」之間做選擇,沒有規格書可以查。

這張階梯只收一條依序接手的主線;SSE 篇與 WebRTC 篇是從它岔出去的兩條支線,所以不在表上。SSE 篇是第 3 列「伺服器沒有主動開口的位置」的另一個答案:只需要伺服器單向推時,不切換協定,直接讓一個 HTTP 回應不結束。WebRTC 篇則離開訊息的世界,處理音訊、視訊與 P2P DataChannel,媒體走 ICE 與 DTLS-SRTP,信令通道常用的正是 WebSocket。

如果這條主線只能帶走一句話:每一層抽象都是在用一組新問題,換掉一組更難處理的舊問題。TCP 用重傳與排序換掉了封包遺失,TLS 用金鑰管理換掉了明文,HTTP/2 用多路復用換掉了排隊、卻換來 HoL Blocking,QUIC 用 UDP 上的重建換掉了 HoL、卻換來負載均衡與可觀測性的重寫,WebSocket 用長連線換掉了輪詢、卻換來保活與狀態,MQTT 用 Broker 換掉了裝置複雜度、卻換來集中狀態。而本篇做的事,是把最後那個「集中狀態」攤到一群機器上——換來的新問題,就是這篇文章的目錄。知道自己換到了什麼,就是這一系列真正想教的東西。

一句話答法長連線的水平擴充沒有銀彈,所有決策都落在同一根座標軸上——狀態放連線本地(最快、但節點不能死)、放外部儲存(有彈性、但每條連線都在打網路)、還是放客戶端(最省、但要相信客戶端);而衡量做得好不好的,永遠是那幾個數字:連線傾斜比、churn、1006 佔比、fan-out p99,以及發版時 drain 走不走得完。