總覽與定位
WebSocket 篇與 MQTT 篇的結尾各留下一句話。仔細看,那其實是同一句話的兩個版本——本篇把它的主詞換掉。
WebSocket 篇談認證與連線狀態的章節,在盤點伺服器的 connection state 之後說:使用者是誰、訂了哪些 topic、最後收到第幾則,「這份狀態只存在於那一台機器的記憶體裡」——主詞是 WebSocket gateway。MQTT 篇的面試折衷章節在盤點 Broker 替你記住的東西之後說:retained store、每個 client id 的 session 與離線佇列、每條連線的 inflight window、整棵訂閱樹,「全部活在那一台 Broker 的記憶體裡」——主詞是 MQTT Broker。兩篇走了完全不同的路,卻在最後一句話上撞在一起。
左邊是「應用自己扛的狀態」,右邊是「協定要求 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 節末)。
接入層: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),而最常見的觸發不是流量而是發版。四台節點各三萬條連線,做一次滾動重啟:
兩組條的總長度完全相同——要看的是形狀不是總和。先重啟的節點會把後面每一台釋放出來的連線都吃進去,最後重啟的那台則是空的。沒有任何人做錯事,這是「一次性決策 + 依序重啟」的算術必然。傾斜的代價很實在: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 節。
Connection Registry:找到持有那條連線的那一台
一張全叢集都看得到的表,回答唯一一個問題:這個使用者的連線現在在哪個節點上。
1 · 最小可行設計
核心資料結構簡單到讓人懷疑:一個從身分到節點的映射。真正的設計都在細節裡——
| 欄位 | 例值 | 為什麼需要它 |
|---|---|---|
key conn:{userId}:{deviceId} | conn:U-7731:web | 一個 user 對多條連線是常態(手機 + 網頁 + 平板),所以 key 要帶裝置維度、或值是一個集合——否則第二台裝置上線就把第一台的路由資訊蓋掉,訊息從此只送給最後登入的那個 |
nodeId | gw-3 | 這張表存在的唯一理由:訊息該投到哪一個節點的 channel |
connId | c-91f2 | 節點收到訊息之後,還要在自己的本地表從 connId 找到那個 socket |
epoch(節點世代) | 7 | 節點每次啟動遞增的世代號,用來讓「上一世代留下的殘骸」在比對時直接失效 |
connectedAt | 17:42:08 | 條目的生命週期是「交握完成後寫、關閉時刪、隨心跳續期」,這一欄記的是第一步發生的時刻 |
TTL | 90s | 續期掛在心跳上(見下一小節);它是節點突然消失時唯一能保證垃圾被清掉的機制 |
六個欄位,每一個都對應一種故障: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 抖動。
指向已死的節點:訊息投進一個沒人訂閱的 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)要談的訂閱聚合。
跨節點 Fan-out 與系統級 Backpressure
找到那一台之後,訊息要怎麼過去;以及當它過不去時,壓力會往哪裡跑。
1 · 兩種拓撲:中心 bus 與節點互聯 mesh
| 後端 | 投遞語義 | 適合放什麼 | 要小心什麼 |
|---|---|---|---|
| Redis Pub/Sub | fire-and-forget,不持久化,訂閱者不在就沒了 | presence、游標、行情這類「可丟且最新值才有意義」的訊息 | 慢訂閱者會被 Redis 直接斷開(見本節 4);訂閱者重連期間的訊息全部遺失 |
| NATS(core) | 同上,但主題比對與叢集路由更強 | 同上,加上跨區的節點間路由 | 要留存與重播必須上 JetStream,那就是另一套運維 |
| Kafka | 有序、有留存、可重播 | 不可丟的業務事件、以及需要被其他系統消費的資料 | partition 數限制 fan-out 彈性、延遲比前兩者高一個量級;把它擺在即時推播的關鍵路徑上通常是誤用 |
MQTT 篇已經論證過標準分層:MQTT 負責接入、bridge 到 Kafka 負責留存與重播。WebSocket 叢集的版本是同一張圖——即時路徑走輕量 bus,留存路徑走 Kafka,兩條路不要合併。合併的結果通常是為了「不丟訊息」而讓所有人的延遲一起變差。
2 · 訂閱聚合:節點代表它上面所有連線去訂閱
承接第 3 節最後那個規模問題。作法是在每個節點內部維護一張本地表 topic → 連線集合,而對 bus 只註冊節點層級的訂閱:
效果是 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,這是最後一道也是最弱的一道 |
分層原則:越靠近源頭丟越便宜,越靠近使用者丟越精準。所以策略通常是「源頭做粗粒度限流,出口做細粒度取捨」,中間兩層只作為過載時的保險絲。
請求/回應系統可以讓上游等——排隊、限流、429 重試,使用者只是慢一點。即時推播不行:讓上游變慢等於讓所有訂閱者一起變慢,只為了遷就最慢的那一個。正確的方向是降低精度而不是降低速度——把每秒 20 次的行情降成 5 次、把逐字元的游標移動合併成每 100 毫秒一次、把 10 則通知合併成一則摘要。
判準很簡單:問這則訊息「遲到還有沒有價值」。沒有價值的(狀態類)就合併成最新值;有價值的(事件類)就必須進有界佇列並準備好在滿的時候斷線重來——而不是無限緩衝,因為那個選項的名字叫 OOM。
心跳、Graceful Drain 與重連風暴
這三件事其實是同一件:叢集怎麼判定一條連線死了、怎麼讓它有秩序地死、以及怎麼承受它們一起回來。
1 · timeout 責任鏈:四層計時器要排出順序
單機時只有一個逾時值要設;多節點的路徑上有四層,而且它們互不知道彼此的存在。排序原則只有一條:希望由誰來偵測,就讓誰的計時器最短。你要的是「gateway 發現連線死了,然後執行清理、刪 registry、發 Last Will」,而不是「某個中間設備靜默拆掉連線,兩邊都以為對方還在」。
左半邊是 MQTT 篇的規格值(Keep Alive × 1.5),也是應用層心跳的合理起手式:容忍一次丟包再宣告死亡。右半邊是 WebSocket 篇那條鐵律的完整版——不只是「心跳要比 idle timeout 短」,而是「連死亡判定都要比它短」,否則中間設備會搶在你前面動手。
| 層級 | 誰在計時 | 典型值 | 設錯的症狀 |
|---|---|---|---|
| 客戶端心跳 | 客戶端(也負責偵測 half-open) | 20–30 秒(行動端要權衡耗電,IoT 則以電池為上限,見 MQTT 篇) | 太長:NAT 映射先過期(TCP/UDP 篇),連線變殭屍 |
| gateway 讀取逾時 | 節點——這一層應該最先發現 | 心跳 × 1.5(例如 45 秒) | 短於心跳:健康的連線被誤殺,客戶端無限重連 |
| LB idle timeout | LB | 60 秒起跳,要調到大於上一列 | 短於心跳:滿地 1006(WebSocket 篇),而且 gateway 完全不知道發生什麼事 |
| bus / broker 連線 | bus | 最長,或直接關掉——這是節點對節點的長連線,本來就該一直活著 | 誤設短值:fan-out 間歇性中斷,症狀是「有些人偶爾收不到」 |
驗收方式:把上表當成一條不等式鏈畫在圖上,任何一段沒有嚴格遞增就是設定錯誤。而唯一能證明你設對的證據是 1006 佔比夠低——它是這條責任鏈的體檢報告。
2 · 誰判定 Keep Alive 逾時、誰代發 Last Will
MQTT 篇留下的第二題。答案的第一半很直接:只有持有那條連線的節點有資格計時——封包只流經它,別的節點連「有沒有收到 PINGREQ」都不知道。這就是 home node 概念在保活上的具體意義:session 的擁有權必須明確,而擁有者就是判定者。
難的是第二半:那台節點自己掛了怎麼辦?此時沒有任何人在替那批連線計時,於是 Last Will 不會被發出、presence 永遠停在 online——這比裝置真的離線還糟,因為看板上顯示的是錯的。
正解是把 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,再等既有的做完。
# 排空 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 的簽章運算是連線建立裡最貴的一項,三萬條同時來會直接吃滿 CPU | session resumption/0-RTT(TLS 篇已論證它對頻繁重連的客戶端最有價值);STEK 要跨機共享,否則換一台就退回 full handshake |
| 認證服務 | 每條連線一次 token 驗證,等於對 auth API 的一次 DDoS | reconnect 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 實作,關閉時明確告訴它「幾秒後再來」,並在那個值上自己加抖動。
工程師直覺會去優化連線建立本身,但實測下來,重連尖峰壓垮的通常是快照 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 與 max | backpressure 是否正在作用(第 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 上已經失效),但它是逐連線的除錯工具,取代不了上面這些聚合指標。
兩種叢集: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/1013 | DISCONNECT 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,需要全域真相的留在中心。
三層之間不是資料流而是責任歸屬——每一格的第二行都在回答同一個問題:它消耗的是 CPU 與延遲(下沉 edge),還是需要全域真相(留中心)。這張圖就是 MQTT 篇那條「MQTT 接入 → bridge → Kafka 留存」分層的一般化版本(該篇已論證,此處不重複):WebSocket 的版本是 gateway → bus → 業務服務,形狀完全一樣。差別只在 MQTT 的 edge 層被協定要求扛更多狀態,所以它比較不「可拋棄」。
跨區部署還有一條實務鐵律:連線只連最近的 edge,跨區複製的是訊息不是連線。理由是長連線跨區的成本是持續的(每一則訊息都多付一次跨區 RTT 與流量費),而訊息跨區複製的成本是一次性的(一則訊息跨區一次,再由當地 edge 展開給幾萬條連線)。把 fan-out 的展開動作留在使用者所在的區域,這是第 4 節「每個節點只複製一份」原則的地理版本。
互動 · 連線佔用地圖
把叢集畫成「節點 × 容量」這個空間:點一台節點就排空它,看連線落到哪裡、傾斜怎麼一層層沉積出來、天花板在哪一步被撞到。
每一根柱子是一台節點的容量空間:柱高等於連線數上限,虛線就是上限本身,裡面的色帶是「這批連線是第幾波接手進來的」——要看的不是總高度,是分層。傾斜是一層層沉積出來的,不是一開始就歪。「一次性抽籤」以期望值模擬(釋放出的連線平均分給在線且未滿的節點,不看目前負載),所以同一組參數永遠得到同一張圖。
重連峰值的分母是本 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。後面那兩個數字才是使用者感受到的東西。
面試折衷與實務選型
這一章同時是網路協定主線的收尾:先把本篇的選型攤開,最後用一張表回頭看從 TCP/UDP 篇到本篇走過的路。
| 決策點 | 選項 | Trade-off |
|---|---|---|
| 負載均衡層 | L4 vs L7 | L4 便宜、對協定無感,但參與不了優雅下線也擋不掉壞交握;L7 功能全開,代價是每條連線要兩條 TCP 與一份解析狀態——乘以連線數之後那是真的帳單。MQTT 幾乎只能 L4,QUIC 一定要 CID 感知路由 |
| fan-out 拓撲 | 中心 bus vs 節點 mesh | mesh 延遲最低但 O(N²) 且腦裂要自己處理,實務上限幾十台;中心 bus 多一跳、是故障域,但擴充與運維成本低得多。不確定就選中心 bus |
| registry | Redis 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 換掉了裝置複雜度、卻換來集中狀態。而本篇做的事,是把最後那個「集中狀態」攤到一群機器上——換來的新問題,就是這篇文章的目錄。知道自己換到了什麼,就是這一系列真正想教的東西。
1006 佔比、fan-out p99,以及發版時 drain 走不走得完。