網頁即時通訊的地圖——以及被小看的輪詢

嗨,我是凱文大叔。

開發網頁應用,遲早會遇到「即時」需求:聊天室訊息要馬上出現、通知鈴鐺要自己亮起來、儀表板數字要自己動、AI 回答要像打字機一樣逐字吐出來。

問題是——HTTP 天生不是為這件事設計的。HTTP 是「請求—回應」模型:瀏覽器不開口,伺服器就不能說話。伺服器手上有新資料,卻沒有管道主動塞給你。

所有網頁即時技術,本質上都在回答同一個問題:怎麼讓伺服器能「主動」通知瀏覽器?

答案不只一種,而且選錯的代價很真實:拿輪詢做聊天室,使用者一多伺服器就被打爆;拿 WebSocket 做每天更新一次的公告,白白扛著連線管理的複雜度。更麻煩的是,現在很多人讓 AI 寫即時功能——AI 寫得出能動的程式,但它選的技術對不對,你得自己看得懂才知道。

接下來三期,我會把這張地圖鋪完整:

  • 本期:輪詢家族(短輪詢、長輪詢)——最古老、最簡單,也最常被誤用
  • 下期:SSE——被低估的單向串流,AI 時代意外翻紅的主角
  • 第三期:WebSocket、WebRTC,加上 Web Push 與 WebTransport 這些新世代成員,最後給你一張選型總表

先修正一個直覺:「底層不都是輪詢嗎?」

很多人(包括以前的我)有個直覺:管你什麼協定,底層反正都是輪詢。

這個直覺對一半。短輪詢確實是應用層的輪詢——你寫的程式每隔幾秒問一次。但 SSE 和 WebSocket 是持久連線:TCP 連線建好之後一直放著,資料到了就送過來。

「那作業系統不是還要一直檢查連線上有沒有資料?」——現代伺服器用的是 epoll/kqueue 這類事件通知機制:不是作業系統逐一巡邏一萬條連線問「你有資料嗎」,而是網卡收到封包後由中斷驅動,一路把「這條連線有資料了」的事件推給應用程式。一萬條安靜的連線,成本接近零。

所以正確的說法是:短輪詢是「你去問」,持久連線是「它來說」。這個差別直接決定了延遲、伺服器負載和流量成本——也是整個系列的主軸。

「你去問」與「它來說」的對比:短輪詢反覆請求多半得到「沒資料」;持久連線建立一次,伺服器有事件才推送

輪詢家族:什麼場景其實該用它

先幫輪詢平反:它不是落後技術,是適用場景被誤解的技術。

短輪詢(Short Polling):每隔固定時間發一個請求問「有新資料嗎」。

適合它的場景,共同特徵是更新頻率低、即時性要求鬆:

  • 儀表板每 30 秒刷新一次統計數字
  • 批次任務的進度條(匯出報表、影片轉檔)
  • 每天只變動幾次的公告或設定
  • Serverless/無狀態架構——函式跑完就結束,根本沒有地方掛長連線,輪詢反而是最自然的解

長輪詢(Long Polling):請求發出後伺服器先不回,等到有新資料(或超時)才回應,客戶端收到後立刻再發下一個請求。介於短輪詢和持久連線之間:延遲接近即時,但每輪仍有重建請求的開銷。它是 2000 年代「Comet」技術的核心,今天仍活在很多 SDK 的降級路徑裡——WebSocket 連不上時,退回長輪詢。

長輪詢時序:請求發出後伺服器掛著等,有新資料才回應,客戶端收到後立刻再發下一輪

工商時間

「功能會動」和「架構選對」中間差的那一層,就是這系列在講的東西。

我的 Hahow 課程 《駕馭 AI 的全端實戰養成班:從零打造企業級智慧應用系統》 用同一套 AI CRM 專案,帶你從 Spring Boot、React、資料庫權限,一路做到 Spring AI 的串流回覆——下期要講的 SSE 打字機效果,課程裡就是實戰單元。

👉 前往 Hahow 課程頁

後段:把輪詢寫到生產等級

輪詢人人會寫,但「能上線的輪詢」有幾個專業細節。每一招我都附一句可以直接丟給 AI 的提示詞——看懂原理之後,實作就交給它。

1. 流量算給你看

100 個使用者、每 5 秒輪詢一次 = 每秒 20 個請求打在伺服器上——而其中 99% 的回應是「沒有新資料」。輪詢的成本公式很殘酷:負載跟「使用者數 × 頻率」成正比,跟「實際有沒有新資料」無關。這就是為什麼高頻場景要換技術。

🤖 丟給 AI:「幫我估算這個輪詢功能對伺服器的負載,並評估要不要改用 SSE。」

2. 條件請求:讓「沒變」的回應近乎免費

搭配 ETag/If-None-Match,資料沒變時伺服器只回 304 Not Modified,不傳整包資料:

// 前端:帶上次的 ETag 去問
const res = await fetch('/api/board', {
  headers: lastEtag ? { 'If-None-Match': lastEtag } : {}
});
if (res.status === 304) return;        // 沒變,什麼都不用做
lastEtag = res.headers.get('ETag');    // 有變,更新畫面與 ETag
render(await res.json());

🤖 丟給 AI:「幫我的輪詢加上 ETag 條件請求,資料沒變就回 304。」

3. 頁面不在前景就別問

使用者切去別的分頁,你的輪詢還在跑就是純浪費。用 Page Visibility API 暫停:

document.addEventListener('visibilitychange', () => {
  if (document.hidden) stopPolling();
  else { pollOnce(); startPolling(); }   // 回來先立刻補一次
});

🤖 丟給 AI:「用 Page Visibility API 讓我的輪詢在分頁隱藏時自動暫停。」

4. 出錯要退避,還要加亂數

伺服器一出錯,成千個客戶端同時重試會把它踩死(thundering herd)。指數退避+隨機抖動(jitter)是標配:

let delay = 5000;
async function poll() {
  try {
    await fetchBoard();
    delay = 5000;                        // 成功就重置
  } catch {
    delay = Math.min(delay * 2, 60000);  // 失敗指數退避,上限一分鐘
  }
  setTimeout(poll, delay + Math.random() * 1000);  // 加抖動錯開大家
}

🤖 丟給 AI:「幫我的輪詢加上指數退避和隨機抖動。」

5. 長輪詢的伺服器端:別佔住執行緒

長輪詢最大的坑是把伺服器執行緒「掛著等」。Spring MVC 用 DeferredResult 讓等待不佔工作執行緒:

@GetMapping("/api/notifications/poll")
public DeferredResult<List<Notification>> poll(@RequestParam long since) {
  // 30 秒超時,超時回空陣列,客戶端收到後再發下一輪
  DeferredResult<List<Notification>> result =
      new DeferredResult<>(30_000L, List.of());
  notificationHub.register(since, result);  // 有新通知時 setResult 即刻回應
  return result;
}

客戶端收到回應(不管有沒有資料)就立刻再發下一個請求,形成「永遠有一個請求在伺服器待命」的循環。

有人會問:用 Callable 或 @Async 把工作丟到另一條執行緒,不也是非同步?差別在等待期間有沒有執行緒被佔著。Callable 只是把「空等」從 Tomcat 執行緒搬到你自己的執行緒池——1000 人掛著就燒 1000 條執行緒,什麼都沒解決。DeferredResult 則是連「等」都不佔執行緒:方法 return 之後,沒有任何執行緒在替這個請求做事,直到未來某條執行緒(例如別人發訊息的那個請求)呼叫 setResult(),回應才被寫回去。長輪詢等的是事件、不是計算——這正是前面 epoll「事件通知」精神在應用層的翻版:不派人空等,事件來了才動手。

🤖 丟給 AI:「用 Spring MVC 的 DeferredResult 幫我把這支 API 改成長輪詢。」

本期帶走三句話

  • 即時技術都在解同一題:讓伺服器能主動說話;短輪詢是「你去問」,其他都是「它來說」。
  • 低頻、鬆即時、serverless——這些場景輪詢反而是對的選擇,別急著上重武器。
  • 能上線的輪詢 = 條件請求 + 前景偵測 + 指數退避 + 抖動;長輪詢記得別佔執行緒。

下期講 SSE——你每天在 ChatGPT 看到的打字機效果,背後就是它。單向串流為什麼在 AI 時代翻紅、又有哪些反向代理的坑,下期見。

—— 凱文大叔


延伸閱讀: