01

總覽與定位

SSE 不是另一條「即時協定」,而是一個故意不結束的 HTTP 回應——伺服器用 text/event-stream 持續寫事件,客戶端用 EventSource 接。

WebSocket 那篇已經把選型判準講清楚:客戶端需不需要低延遲地說話。本篇不重做那張對照表,也不重講 Upgrade/Ping-Pong。這裡要補的是另一半——當答案是「幾乎只要伺服器推」時,你實際會碰到的線協定、重連語意、以及中間層怎麼把一條「看起來很簡單」的串流弄壞。

線協定
text/event-stream · RFC 8895 語意
UTF-8 文字欄位:data/event/id/retry,以空行分隔事件。沒有二進位 frame,沒有 Upgrade。
瀏覽器 API
EventSource
內建自動重連;斷線後會帶 Last-Event-ID。這是協定層給你的狀態恢復,不是應用自己拼的退避腳本。
營運痛點
proxy · CDN · timeout
緩衝、idle timeout、錯誤壓縮與 H2 多路,才是 production SSE 真正翻車的地方。
座標SSE 仍是 HTTP——這既是優點,也是約束

一條 SSE 連線就是一個長生命週期的 GET(或少數實作允許的 POST)回應:狀態碼先回 200,Content-Type: text/event-stream,然後 body 一直寫下去。沒有協定切換,所以 CDN、反向代理、企業防火牆把它當普通 HTTP 處理的機率遠高於 WebSocket。
代價也很明確:方向性是 server → client;客戶端要說話,另開普通 HTTP 請求。二進位要 base64/另通道。瀏覽器的 EventSource 也不帶自訂 request header(Cookie/同源規則仍適用)——需要 Authorization header 時,多數團隊改用 fetch + ReadableStream 自己解析。

一句話答法SSE = 不結束的 HTTP 回應 + text/event-stream 事件框線;換來的是 proxy 友善與內建重連,換掉的是全雙工與原生二進位。
02

text/event-stream 線協定

事件是「欄位列 + 空行」組成的文字塊;伺服器寫什麼,客戶端就解什麼——沒有隱藏的 frame 標頭。

1 · 一則事件長什麼樣

: heartbeat 15:04:01 id: 42 event: progress data: {"pct":40,"stage":"encode"} data: {"note":"multi-line ok"}

以 : 開頭的是註解(常見心跳);多行 data: 會用 \n 接成單一 payload。事件以空行結束——少寫那一行,客戶端會一直等你「講完」。

data
必用 · 事件本體
可重複多行。沒有 data 的塊通常不會觸發 message/具名事件(純註解心跳就是這種)。
id
續接游標
瀏覽器記住最後一個非空 id;重連時放進 request header Last-Event-ID。伺服器沒發 id,續接就無從談起。
event
具名事件型別
缺省型別是 message。設了 event: progress 後,客戶端要用 addEventListener('progress', …),不是只聽 onmessage。
retry
重連間隔(ms)
伺服器可動態改瀏覽器的重連等待。這是「建議值」,不是嚴格 SLA;真正的退避策略仍要在服務端配合。

2 · 互動:把一段串流餵進解析器

互動 · text/event-stream 拆解器 左邊改一個字,右邊整份重新拆解

右邊每一行都照 SSE 的規則真的跑一次:以 CRLF/CR/LF 切行、: 開頭是註解、其餘在第一個冒號切成欄位名與值(值的前導單一空格要剝掉)、空行才 dispatch。底下的位元組帳也是逐行算出來的。

逐行拆解台帳(每個色框是一個事件塊)

這一塊有 dispatch 這一塊沒有 data,不觸發 這一塊沒有收尾空行,卡在緩衝區

解析器吐出來的事件

位元組帳

三個最容易踩的規則都可以在這裡當場驗:(1) 把某則的 id: 42 改成只剩 id:,游標不是「維持原值」而是被重設成空字串,而且後面的事件都跟著沒有 id。(2) 刪掉某個空行,那一塊就永遠停在緩衝區——台帳最後一個框會變成紅色虛線,位元組帳的「結尾未終止」會告訴你卡了幾 bytes。(3) 切換 CRLF 不會改變任何一則事件的內容,只會改變位元組數:三種行終止符在規範裡是等價的,但帳單不同。

2 · 回應標頭的最低配置

回應要素值作用漏了會怎樣
狀態碼200 OK宣告串流開始、body 刻意不結束非 2xx 會讓 EventSource 觸發 error,甚至不再重連
Content-Typetext/event-stream觸發瀏覽器改用 SSE 解析器讀 body型別不符就整條判定失敗,一則事件都收不到
Cache-Controlno-cache擋住瀏覽器與中間層快取這段永不結束的 body可能拿到快取副本,或重連時收到舊資料
Connectionkeep-aliveHTTP/1.1 下明示連線不關閉(HTTP/2 不需要也不允許此標頭)中間層可能提早收掉連線,變成反覆重連

四列是「必須同時成立」的組合,不是先後步驟:前兩列決定瀏覽器要不要把它當 SSE 讀,後兩列決定這條連線能不能活著。最右欄就是排查順序——一則都收不到先查 Content-Type,收到舊資料先查 Cache-Control,反覆重連先查 Connection 與中間層逾時。

還有兩件常被漏掉的事:(1) 關掉反向代理/應用層的 response buffering(或顯式 flush);(2) 若經過 gzip/Brotli 中間層,確認它不會把「未結束的 body」整段捂住。SSE 的延遲不是協定算力問題,幾乎都是「誰把位元組暫時藏起來了」。

陷阱空行、BOM、與「看起來有寫卻收不到」

少一個分隔空行 → 事件永遠不 dispatch。回應前多了 UTF-8 BOM → 某些解析器把第一個欄位名讀壞。Nginx 預設 proxy_buffering on → 客戶端要等緩衝滿或上游結束才看到第一則。這三件事加總,足以讓「curl 看得到、瀏覽器看不到」的工單長達一週。

一句話答法SSE 事件 = 欄位列 + 空行;id 給續接、event 給分型、retry 給重連節奏,而空行與禁用緩衝決定它能不能「即時」。
03

EventSource 生命週期與 Last-Event-ID

readyState 只有三態;真正的產品能力在「斷線後帶著游標回來」。

1 · 三態,不是四態

stateDiagram-v2
    [*] --> CONNECTING
    CONNECTING --> OPEN: 標頭正確
    CONNECTING --> CONNECTING: 等待重試間隔
    OPEN --> CONNECTING: 閃斷自動重連
    OPEN --> CLOSED: 主動 close
    CONNECTING --> CLOSED: 致命錯誤
    CLOSED --> [*]

三態的 readyState 依序是 CONNECTING 0、OPEN 1、CLOSED 2。要看的是 OPEN 折回 CONNECTING 那一條邊:閃斷不會把 EventSource 推進 CLOSED,它自己退回 CONNECTING 重試——這就是 SSE 沒有 WebSocket 那個 CLOSING 態的原因。只有「主動 close」與「致命錯誤」兩條邊會真的走進 CLOSED,而 CLOSED 是終點,不會再自動回頭。

與 WebSocket 不同,EventSource 沒有 CLOSING。網路閃斷時,實作會回到 CONNECTING 並自動重試——除非你呼叫了 close(),或伺服器以會終結重連的方式回應(例如某些瀏覽器對 204/明確錯誤的處理)。面試若把 WS 的四態背到 SSE 上,會直接露餡。

2 · Last-Event-ID 補洞契約

sequenceDiagram
    participant C as 客戶端
    participant S as 伺服器
    S-->>C: event id 3
    Note over C: 游標記為 3
    S--xC: 連線中斷
    Note over S: 續產生 4、5
    C->>S: 重連帶 Last-Event-ID 3
    S-->>C: 重放 4
    S-->>C: 重放 5

補洞是兩邊各出一半力:斷線(--x 那條邊)之後的第一個請求由客戶端發起,它只負責把最後一個 id 帶回去;能不能補得起來則取決於伺服器手上還有沒有可依游標重放的 4、5。任一邊缺席都補不了洞——事件沒寫 id,或後端沒保留可重放的日誌,斷線就等於丟資料。

這條契約分成兩邊:客戶端記住最後 id 並自動帶上;伺服器必須能依游標重放或從日誌/佇列續讀。若你的事件根本沒寫 id,或後端把 SSE 當「純 fire-and-forget 日誌管」,斷線就等於丟資料——協定幫不了你。

關鍵游標語意要先定:at-least-once 還是 exactly-once 外觀?

重放 4、5 時,客戶端可能已經在斷線邊緣處理過部分資料。實務預設是 at-least-once:事件帶冪等鍵,UI/寫入路徑去重。若業務要「剛好一次」的外觀,去重必須在應用層完成——SSE 只保證「我能告訴你從哪裡續」,不保證端到端 exactly-once。

失敗模式瀏覽器行為你該準備什麼
短暫網路閃斷自動重連 + Last-Event-ID伺服器可依 id 續讀;事件可重放
伺服器進程重啟客戶端重連進來游標存外部(Redis/日誌 offset),不能只活在記憶體
主動 close()停止重連頁面卸載、登出、切換租戶時明確關閉
鑑權過期重連帶舊 Cookie/失敗循環偵測 401 類失敗後改走 token 刷新,再開新 EventSource
一句話答法EventSource 三態+自動重連;產品級續接靠每則事件的 id 與伺服器對 Last-Event-ID 的重放能力,預設當 at-least-once 做。
04

為何 HTTP/2 之後 SSE 復活,以及「SSE 推 + HTTP 寫」

限制解除之後,單向推播不再需要為了「連線名額」去付 WebSocket 的全套維運成本。

HTTP/1.1 時代,瀏覽器對同一 origin 大約只給 6 條並行連線。開一條常駐 SSE,等於永久佔掉六分之一——同一頁再開儀表板、通知、進度條就開始互相排擠。這是當年許多團隊「明明只是推播卻上 WebSocket」的主因之一。

HTTP/2 多路復用之後,多條 SSE 可以共用同一條 TCP/TLS 連線上的不同 stream。連線名額稅消失,SSE 的工程帳變成:

  • 協定簡單:curl/瀏覽器 Network 面板直接可讀。
  • 重連內建:少寫一整套心跳與退避(WS 篇花整節補的東西)。
  • 中間層友善:多數 CDN/LB 本來就懂長 HTTP。
SSE 負責推
server → client · 狀態/token/通知
  • 訂單狀態、進度百分比
  • LLM token 增量(協定層)
  • 儀表板指標、告警扇出
HTTP 負責寫
client → server · 命令與副作用
  • POST /cancel、POST /ack
  • 表單提交、設定變更
  • 少量控制面訊息(非高頻)
關鍵混用不是妥協,是預設架構

「SSE 推 + REST/RPC 寫」讓讀路徑與寫路徑分開演進:推播服務可以水平訂閱訊息匯流排;寫入仍走原本的 API Gateway、鑑權與幂等中介層。你不會為了讓進度條即時,就把整站命令通道都搬進同一條雙向 socket。

差異化本篇不重講的東西

不重做 Long Polling/SSE/WebSocket/WebTransport 總表(WebSocket 篇的面試折衷章節已有那張四技術對照表);不講 Upgrade 握手與 Ping/Pong 幀。本篇預設你已接受「單向推播優先 SSE」,往下只談把它做穩。

一句話答法H2 多路拿掉了 SSE 的六連線稅之後,純推播場景用「SSE 推 + HTTP 寫」通常比維運一整條 WebSocket 通道更划算。
05

Proxy、CDN 緩衝與逾時實務

SSE 在開發機「秒出字」,上了邊緣節點卻一分鐘才吐第一包——幾乎總是緩衝與 idle timeout。

1 · 誰在捂住你的位元組

層級典型症狀對策方向
反向代理緩衝首則事件延遲數秒~數十秒proxy_buffering off/顯式 X-Accel-Buffering: no;應用側每次事件後 flush
CDN/邊緣快取偶發看到舊流、或整段被當可快取下載Cache-Control: no-cache(必要時 no-store);關閉該路徑快取
Idle timeout安靜場景每 N 秒被斷線註解行心跳(: ping)間隔 < 最短中間層 timeout
負載均衡粘滯重連打到無游標狀態的實例游標外置;或對 SSE 路徑啟 sticky(仍要能接受實例死掉)
HTTP/2 多 stream單連線上多條 SSE 互相搶窗觀察連線級流量控制;重要流可隔離優先級/分域

2 · 心跳是給中間層看的,不是給業務看的

註解行 : keepalive\n\n 不觸發 message,卻能重置代理的 idle 計時器。間隔要對齊基礎設施的最短 timeout(常見 15–60s),而不是對齊業務事件頻率。業務很吵時不必狂發心跳;業務很安靜時,心跳就是連線的生命線。

陷阱「H2 能開很多 SSE」≠「應該在同一頁開很多」

多路復用解除的是瀏覽器連線上限,不是伺服器的每連線記憶體與檔案描述符成本。扇出通知仍應優先走匯流排/房間訂閱,而不是讓每個 UI widget 各自掛一條永不關閉的 stream。H2 讓 SSE 可行,不讓濫用變免費。

一句話答法production SSE 的延遲與斷線,優先查緩衝、idle timeout 與游標是否外置;心跳註解是寫給 proxy 的,不是寫給產品經理的。
06

LLM Token Streaming:只談協定層背壓與取消

把「逐 token 吐出」當成 SSE 事件流時,面試該講的是取消傳播與讀取側背壓,不是 prefill/decode。

推論服務化那篇已經覆蓋 Prefill/Decode、continuous batching 與 KV。這裡只留協定接縫:

  • 取消:使用者點 Stop → 客戶端 EventSource.close()(或 abort fetch stream)→ 邊緣/API 層停止讀上游 → 儘速把取消傳到推論排程,避免 GPU 繼續為已丟棄的請求解碼。
  • 背壓:SSE 跑在 TCP 上,客戶端讀得慢會讓傳送緩衝堆積;若應用層仍無上限地 write,最終體現為記憶體與延遲膨脹。實務上要有「未刷新事件上限」或改為可丟棄的中間狀態(只留最新進度)。
  • 結束語意:常見用具名事件 event: done 或最後一則 data: [DONE] 約定;然後伺服器結束 body。不要依賴「瀏覽器自己猜結束」。
邊界本節刻意不講什麼

不重講 TTFT、KV cache、continuous batching。SSE 只負責把已經產生的 token/進度可靠推到瀏覽器;模型側排程與批次策略屬 Inference Serving。

一句話答法LLM 的 SSE 串流要補的是取消鏈路與寫入背壓;模型怎麼 batch、怎麼管 KV,不在這條 HTTP 長回應裡解決。
07

互動 · 斷線切點、補送窗口與心跳成本

同一條串流,斷在不同的位元組、id 發得多疏、伺服器保留多深,決定了補送幾筆、重複幾筆、真正丟掉幾筆。

互動 · 斷線與續接 拖動任一個旋鈕,補送/重複/遺失即時重算

上面那條是位元組尺:一段一則事件,寬度就是它在線上的實際長度,細段是心跳註解;深色代表這一則帶了 id。紅線是斷線位置。下面那條是事件序號帶,一格一則,顏色說明它重連後會怎麼樣。

24 則
48 B
每 4 則
每 1 則
62%
12 則
3000 ms
byte 0↑ 紅線=斷線位置串流結尾
在游標之內,不會重送 已顯示過但會再收一次(重複) 需要補送 掉出保留窗口(遺失) 尚未產生

最值得動手的是「多久發一次 id」:把它從每則調成每 5 則,客戶端手上的 Last-Event-ID 會倒退,重複筆數立刻從 0 跳起來——這就是為什麼「續接」不是瀏覽器自動重連給的,而是每則事件都發 id 加上伺服器能依游標重放才成立。調到 0(完全不發 id)更極端:重連時帶不出任何游標,伺服器只能從保留窗口的頭開始重播。兩個模型假設要講明:伺服器在缺口期間仍以 4 則/秒 的假設速率繼續產生事件(所以重連等待越久、要補的越多);重複筆數的算法假設 id 是連續序號,實務上若用不連續的 offset/ULID,結論相同但要換成「游標之後有幾筆」。

一句話答法自動重連只是把連線接回來;真正補洞的是 id + 伺服器依 Last-Event-ID 重放,而重放窗口的深度決定了「補得回來」還是「只是看起來有重連」。
08

面試折衷與邊界

把 SSE 講成「HTTP 長回應上的事件游標協定」,並畫清與 WebSocket/推論服務的責任邊界。

面試常問建議答法骨架
SSE 跟 WebSocket 怎麼選? 先問客戶端要不要低延遲發話。純推播優先 SSE;高頻雙向再 WS。不要重背整張表,講判準。
斷線如何不丟事件? 每則事件發 id;重連帶 Last-Event-ID;伺服器依游標重放;應用層做冪等。
為什麼本地順、上線一頓一頓? 查 proxy/CDN buffering、壓縮、idle timeout;用註解心跳與強制 flush。
H2 之後還需要 WS 嗎? 需要——當寫路徑也是高頻、低延遲、或要二進位幀。H2 只是讓 SSE 不再被六連線懲罰。
LLM 串流為何常用 SSE? 方向單向、除錯容易、取消可用關閉讀側表達;別把 GPU 排程細節塞進這題。
差異化本篇管什麼、不管什麼

管:text/event-stream 欄位語意、EventSource 三態與 Last-Event-ID、H2 後的復活原因、SSE 推 + HTTP 寫、proxy 緩衝/timeout、串流取消與背壓的協定接縫。不管:WebSocket framing/心跳實作(WS 篇);NAT/UDP 與即時媒體(WebRTC 篇);LLM prefill/decode 與 KV(Inference Serving)。

邊界何時不要硬上 EventSource

需要自訂 Authorization header、要送二進位、或瀏覽器外的嚴格後端對後端扇出——考慮 fetch stream、gRPC streaming 或訊息匯流排。SSE 是瀏覽器純推播的甜點區,不是所有串流問題的萬用插座。

一句話答法面試先講「不結束的 HTTP + 事件游標」,再補 H2 復活理由、續接契約與中間層緩衝;選型用「誰需要低延遲發話」一句話收束。