總覽與定位
SSE 不是另一條「即時協定」,而是一個故意不結束的 HTTP 回應——伺服器用 text/event-stream 持續寫事件,客戶端用 EventSource 接。
WebSocket 那篇已經把選型判準講清楚:客戶端需不需要低延遲地說話。本篇不重做那張對照表,也不重講 Upgrade/Ping-Pong。這裡要補的是另一半——當答案是「幾乎只要伺服器推」時,你實際會碰到的線協定、重連語意、以及中間層怎麼把一條「看起來很簡單」的串流弄壞。
data/event/id/retry,以空行分隔事件。沒有二進位 frame,沒有 Upgrade。Last-Event-ID。這是協定層給你的狀態恢復,不是應用自己拼的退避腳本。一條 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 自己解析。
text/event-stream 事件框線;換來的是 proxy 友善與內建重連,換掉的是全雙工與原生二進位。
text/event-stream 線協定
事件是「欄位列 + 空行」組成的文字塊;伺服器寫什麼,客戶端就解什麼——沒有隱藏的 frame 標頭。
1 · 一則事件長什麼樣
以 : 開頭的是註解(常見心跳);多行 data: 會用 \n 接成單一 payload。事件以空行結束——少寫那一行,客戶端會一直等你「講完」。
data 的塊通常不會觸發 message/具名事件(純註解心跳就是這種)。id;重連時放進 request header Last-Event-ID。伺服器沒發 id,續接就無從談起。message。設了 event: progress 後,客戶端要用 addEventListener('progress', …),不是只聽 onmessage。2 · 互動:把一段串流餵進解析器
右邊每一行都照 SSE 的規則真的跑一次:以 CRLF/CR/LF 切行、: 開頭是註解、其餘在第一個冒號切成欄位名與值(值的前導單一空格要剝掉)、空行才 dispatch。底下的位元組帳也是逐行算出來的。
逐行拆解台帳(每個色框是一個事件塊)
解析器吐出來的事件
位元組帳
三個最容易踩的規則都可以在這裡當場驗:(1) 把某則的 id: 42 改成只剩 id:,游標不是「維持原值」而是被重設成空字串,而且後面的事件都跟著沒有 id。(2) 刪掉某個空行,那一塊就永遠停在緩衝區——台帳最後一個框會變成紅色虛線,位元組帳的「結尾未終止」會告訴你卡了幾 bytes。(3) 切換 CRLF 不會改變任何一則事件的內容,只會改變位元組數:三種行終止符在規範裡是等價的,但帳單不同。
2 · 回應標頭的最低配置
| 回應要素 | 值 | 作用 | 漏了會怎樣 |
|---|---|---|---|
| 狀態碼 | 200 OK | 宣告串流開始、body 刻意不結束 | 非 2xx 會讓 EventSource 觸發 error,甚至不再重連 |
Content-Type | text/event-stream | 觸發瀏覽器改用 SSE 解析器讀 body | 型別不符就整條判定失敗,一則事件都收不到 |
Cache-Control | no-cache | 擋住瀏覽器與中間層快取這段永不結束的 body | 可能拿到快取副本,或重連時收到舊資料 |
Connection | keep-alive | HTTP/1.1 下明示連線不關閉(HTTP/2 不需要也不允許此標頭) | 中間層可能提早收掉連線,變成反覆重連 |
四列是「必須同時成立」的組合,不是先後步驟:前兩列決定瀏覽器要不要把它當 SSE 讀,後兩列決定這條連線能不能活著。最右欄就是排查順序——一則都收不到先查 Content-Type,收到舊資料先查 Cache-Control,反覆重連先查 Connection 與中間層逾時。
還有兩件常被漏掉的事:(1) 關掉反向代理/應用層的 response buffering(或顯式 flush);(2) 若經過 gzip/Brotli 中間層,確認它不會把「未結束的 body」整段捂住。SSE 的延遲不是協定算力問題,幾乎都是「誰把位元組暫時藏起來了」。
少一個分隔空行 → 事件永遠不 dispatch。回應前多了 UTF-8 BOM → 某些解析器把第一個欄位名讀壞。Nginx 預設 proxy_buffering on → 客戶端要等緩衝滿或上游結束才看到第一則。這三件事加總,足以讓「curl 看得到、瀏覽器看不到」的工單長達一週。
id 給續接、event 給分型、retry 給重連節奏,而空行與禁用緩衝決定它能不能「即時」。
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 日誌管」,斷線就等於丟資料——協定幫不了你。
重放 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 做。
為何 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。
- 訂單狀態、進度百分比
- LLM token 增量(協定層)
- 儀表板指標、告警扇出
POST /cancel、POST /ack- 表單提交、設定變更
- 少量控制面訊息(非高頻)
「SSE 推 + REST/RPC 寫」讓讀路徑與寫路徑分開演進:推播服務可以水平訂閱訊息匯流排;寫入仍走原本的 API Gateway、鑑權與幂等中介層。你不會為了讓進度條即時,就把整站命令通道都搬進同一條雙向 socket。
不重做 Long Polling/SSE/WebSocket/WebTransport 總表(WebSocket 篇的面試折衷章節已有那張四技術對照表);不講 Upgrade 握手與 Ping/Pong 幀。本篇預設你已接受「單向推播優先 SSE」,往下只談把它做穩。
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),而不是對齊業務事件頻率。業務很吵時不必狂發心跳;業務很安靜時,心跳就是連線的生命線。
多路復用解除的是瀏覽器連線上限,不是伺服器的每連線記憶體與檔案描述符成本。扇出通知仍應優先走匯流排/房間訂閱,而不是讓每個 UI widget 各自掛一條永不關閉的 stream。H2 讓 SSE 可行,不讓濫用變免費。
LLM Token Streaming:只談協定層背壓與取消
把「逐 token 吐出」當成 SSE 事件流時,面試該講的是取消傳播與讀取側背壓,不是 prefill/decode。
推論服務化那篇已經覆蓋 Prefill/Decode、continuous batching 與 KV。這裡只留協定接縫:
- 取消:使用者點 Stop → 客戶端
EventSource.close()(或 abortfetchstream)→ 邊緣/API 層停止讀上游 → 儘速把取消傳到推論排程,避免 GPU 繼續為已丟棄的請求解碼。 - 背壓:SSE 跑在 TCP 上,客戶端讀得慢會讓傳送緩衝堆積;若應用層仍無上限地
write,最終體現為記憶體與延遲膨脹。實務上要有「未刷新事件上限」或改為可丟棄的中間狀態(只留最新進度)。 - 結束語意:常見用具名事件
event: done或最後一則data: [DONE]約定;然後伺服器結束 body。不要依賴「瀏覽器自己猜結束」。
不重講 TTFT、KV cache、continuous batching。SSE 只負責把已經產生的 token/進度可靠推到瀏覽器;模型側排程與批次策略屬 Inference Serving。
互動 · 斷線切點、補送窗口與心跳成本
同一條串流,斷在不同的位元組、id 發得多疏、伺服器保留多深,決定了補送幾筆、重複幾筆、真正丟掉幾筆。
上面那條是位元組尺:一段一則事件,寬度就是它在線上的實際長度,細段是心跳註解;深色代表這一則帶了 id。紅線是斷線位置。下面那條是事件序號帶,一格一則,顏色說明它重連後會怎麼樣。
最值得動手的是「多久發一次 id」:把它從每則調成每 5 則,客戶端手上的 Last-Event-ID 會倒退,重複筆數立刻從 0 跳起來——這就是為什麼「續接」不是瀏覽器自動重連給的,而是每則事件都發 id 加上伺服器能依游標重放才成立。調到 0(完全不發 id)更極端:重連時帶不出任何游標,伺服器只能從保留窗口的頭開始重播。兩個模型假設要講明:伺服器在缺口期間仍以 4 則/秒 的假設速率繼續產生事件(所以重連等待越久、要補的越多);重複筆數的算法假設 id 是連續序號,實務上若用不連續的 offset/ULID,結論相同但要換成「游標之後有幾筆」。
id + 伺服器依 Last-Event-ID 重放,而重放窗口的深度決定了「補得回來」還是「只是看起來有重連」。
面試折衷與邊界
把 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)。
需要自訂 Authorization header、要送二進位、或瀏覽器外的嚴格後端對後端扇出——考慮 fetch stream、gRPC streaming 或訊息匯流排。SSE 是瀏覽器純推播的甜點區,不是所有串流問題的萬用插座。