DO WebSocket Hibernation:讓閒置連線省下計費 | Cloudflare 完整教學

2026/08/26
DO WebSocket Hibernation:讓閒置連線省下計費 | Cloudflare 完整教學

上一篇《DO Alarms 定時器》,我們替 Durable Object 裝上了可靠、可重試的定時器,讓它能在未來某刻自己醒來做事。這一篇,我們碰 DO 最招牌的能力:長時間持有 WebSocket 連線。但有個關鍵陷阱——用「一般的 WebSocket」寫法,DO 會為了等訊息而全程醒著計費,就算連線整天閒著也一樣。Cloudflare 為此設計了 WebSocket Hibernation(休眠):用 ctx.acceptWebSocket 接受連線後,DO 能在空閒時釋放記憶體、暫停計費,但連線不斷。你會學到 acceptWebSocketwebSocketMessage/Close/Error handlers、用 serializeAttachment 跨休眠保存狀態,以及用 getWebSockets() 廣播——讓大量閒置連線也能省下絕大部分成本。

前言

WebSocket Hibernation(WebSocket 休眠)Durable Objects 專為「即時、長連線」場景設計的一項省成本機制。它要解決的問題非常具體:一個持有大量 WebSocket 連線的 DO(想像一個上萬人的聊天室、或推播中心),大部分時間其實沒有訊息在流動——連線就只是「掛在那裡等」。但只要你用一般的 WebSocket 寫法,DO 就得為了「隨時準備接住下一則訊息」而全程留在記憶體裡醒著,而 DO 的計費單位正是「活躍時長(記憶體 × 時間)」。結果就是:明明沒事發生,帳單卻一路在跑。

打個比方。一般的 WebSocket 寫法,像是請一位接線生,全程戴著耳機守在總機前,即使一整天只有兩三通電話,他也得從早坐到晚——你付的是「他在崗位上的時間」,不是「他實際接了幾通電話」。而 Hibernation 則像是幫這位接線生裝了一套智慧轉接系統:沒有來電時,他可以下班回家休息(DO 休眠、釋放記憶體、不計費),而電話線本身仍然接著沒有拔掉(WebSocket 連線不斷);一旦有電話進來,系統立刻把他叫回來接聽(平台喚醒 DO、呼叫 handler)。你付的變成「他實際接電話的時間」——對「電話稀疏」的場景,這是天差地遠的成本差距。

這個「連線還在、人可以休息」的分離,正是 Hibernation 的魔法:釋放的是記憶體與計費,保留的是連線本身

這篇聚焦在「DO 的 WebSocket 休眠」這一個主題(WebSocket 的基本協定、握手已在第 008 篇講過,這裡不重複)。讀完你會掌握:

  • 為何一般 WebSocket 會一直計費——server.accept() + addEventListener 為什麼讓 DO 無法休眠,以及 Hibernation 如何打破這點
  • Hibernation API 全貌——ctx.acceptWebSocket(server) 接受連線,搭配 webSocketMessage / webSocketClose / webSocketError 三個 handler
  • 跨休眠保存狀態——為何記憶體狀態會在休眠後消失,以及用 serializeAttachment / deserializeAttachment 把連線相關狀態綁在連線上
  • 廣播與省成本——用 getWebSockets() 取回所有連線做廣播,以及 setWebSocketAutoResponse 讓 ping/pong 不喚醒物件

核心概念

問題根源:一般 WebSocket 為何一直計費

先看「一般寫法」為什麼會有計費陷阱。在標準的 Web WebSocket API 裡,你會這樣接受一條連線並處理訊息:

// ❌ 一般寫法:DO 必須「全程醒著」才能收到訊息
const { 0: client, 1: server } = new WebSocketPair();
server.accept();
server.addEventListener("message", (event) => {
  // 這個閉包(callback)綁在「當前這個記憶體中的 DO 實例」上
  server.send(`echo: ${event.data}`);
});
return new Response(null, { status: 101, webSocket: client });

問題出在 addEventListener 這個 callback。它是一個閉包(closure),活在「當前這個 DO 實例的記憶體裡」。平台要能在任何時候觸發它,就必須讓這個 DO 實例一直存在於記憶體中——換句話說,DO 被「釘」在活躍狀態,無法被回收。而 Durable Objects 的計費是按 GB-s(記憶體大小 × 存活秒數) 計算的活躍時長:只要 DO 醒著,碼表就在轉。

於是一個掛著上萬條閒置連線的聊天室 DO,就算一整天沒人說一句話,也會全程醒著、全程計費。連線越多、閒置越久,浪費越大。

解法:Hibernation 讓「連線」與「記憶體」解耦

Hibernation 的核心洞見是:WebSocket 連線的維持,本質上是「網路層」的事,不需要你的應用程式碼一直在記憶體裡醒著。真正需要喚醒你的程式,只有在「訊息真的進來」的那一刻。

於是 Cloudflare 把兩件事拆開:

  • 連線的維持交給平台的網路層——就算你的 DO 休眠了,TCP/WebSocket 連線依然接著,瀏覽器端毫無感覺。
  • 訊息的處理改成「事件回呼到 DO 類別的方法」——不再是綁在記憶體的閉包,而是 webSocketMessage() 這個類別方法。平台記得「哪條連線屬於哪個 DO」,當訊息進來,它把 DO 重新喚醒、重新建構,再呼叫這個方法。

這樣一來,空閒時 DO 可以安心休眠(釋放記憶體、停止計費),連線卻不斷。下面這張圖是兩種模式的對照:

一般 WebSocket(Web Standard):
  accept() ──► addEventListener(閉包在記憶體) ──► DO 必須全程醒著
                                                     └──► 閒置也計費 💸

Hibernation:
  ctx.acceptWebSocket(server) ──► 訊息回呼 webSocketMessage() 類別方法
        ├─(有訊息)──► 平台喚醒 DO ──► 呼叫 webSocketMessage() ──► 處理完可再休眠
        └─(閒置)──► DO 休眠:釋放記憶體、停止計費,但「連線不斷」 ✅

計費與記憶體對照表

把兩種模式攤開比較,差異一目了然:

特性一般 WebSocket(Web Standard)Hibernation API(推薦)
接受連線server.accept()this.ctx.acceptWebSocket(server)
處理訊息addEventListener("message", ...)類別方法 webSocketMessage(ws, msg)
閒置時是否計費持續計費(DO 全程醒著)不計費(DO 可休眠)
休眠後記憶體狀態不適用(不會休眠)釋放(需靠 attachment 保存)
連線清單可自維護 Set(反正不休眠)必須用 ctx.getWebSockets()
適用場景短命、高頻密集互動長連線、訊息稀疏(聊天、推播)

關鍵術語速查

術語一句話定義
ctx.acceptWebSocket(ws, tags?)以 Hibernation 模式接受一條 WebSocket,可加 tags 分類
webSocketMessage(ws, msg)訊息進來時平台呼叫的類別方法(喚醒後執行)
webSocketClose(ws, code, reason, wasClean)連線關閉時的 handler
webSocketError(ws, error)連線錯誤時的 handler
ws.serializeAttachment(value)把狀態序列化附加到「這條連線」,跨休眠存活
ws.deserializeAttachment()取回先前附加的狀態
ctx.getWebSockets(tag?)取回目前 DO 持有的所有(或某 tag 的)連線
ctx.setWebSocketAutoResponse(pair)設定 ping/pong 自動回應,不喚醒 DO

實作範例

我們用一個完整可執行的聊天室 DO 貫穿本文:用戶端連進來、帶暱稱、傳訊息就廣播給所有人。全程採用 Hibernation API,並示範 serializeAttachment 保存暱稱、getWebSockets 廣播。前提是新專案已用 new_sqlite_classes 啟用 SQLite backend(見前幾篇)。

1. wrangler 設定

先確認 wrangler.jsonc 綁定了 DO(與前幾篇一致):

{
  "name": "chat-app",
  "main": "src/index.ts",
  "compatibility_date": "2024-04-03",
  "durable_objects": {
    "bindings": [
      { "name": "CHAT_ROOM", "class_name": "ChatRoom" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["ChatRoom"] }
  ]
}

2. Worker 端:把升級請求轉給 DO

Worker 只做一件事:收到 WebSocket 升級請求,依房間名取得對應 DO 的 stub,把整個 Request 交給它處理。這部分與第 008 篇的路由概念一致。

// src/index.ts
import { DurableObject } from "cloudflare:workers";

export interface Env {
  CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);
    // 例如 /room/lobby?name=Alice
    const room = url.pathname.split("/")[2] ?? "lobby";
    const stub = env.CHAT_ROOM.getByName(room);
    // 把升級請求整個轉進該房間的 DO
    return stub.fetch(req);
  },
} satisfies ExportedHandler<Env>;

3. DO 端:用 acceptWebSocket 接受連線

重點來了。在 DO 的 fetch 裡,我們不用 server.accept(),而是呼叫 this.ctx.acceptWebSocket(server)——這一步就是啟用 Hibernation 的開關。接著用 serializeAttachment 把「這條連線的暱稱」綁到連線上,這樣即使之後 DO 休眠、記憶體被清空,喚醒後仍能知道這條連線是誰。

export class ChatRoom extends DurableObject<Env> {
  private sql = this.ctx.storage.sql;

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      // 保存最近訊息,方便新進者拉歷史(示意)
      this.sql.exec(`
        CREATE TABLE IF NOT EXISTS messages (
          id   INTEGER PRIMARY KEY AUTOINCREMENT,
          who  TEXT NOT NULL,
          text TEXT NOT NULL
        );
      `);
    });

    // ping/pong 自動回應:讓保活訊號不必喚醒 DO,進一步省成本
    ctx.setWebSocketAutoResponse(
      new WebSocketRequestResponsePair("ping", "pong")
    );
  }

  async fetch(req: Request): Promise<Response> {
    const name = new URL(req.url).searchParams.get("name") ?? "anon";

    const pair = new WebSocketPair();
    const client = pair[0];
    const server = pair[1];

    // ★ 關鍵:用 Hibernation 模式接受連線(不是 server.accept())
    //   第二參數是 tags,可用來分類連線(例如依角色),之後可用 getWebSockets(tag) 篩選
    this.ctx.acceptWebSocket(server, [name]);

    // ★ 把「這條連線是誰」序列化附加到連線本身——跨休眠存活
    server.serializeAttachment({ name });

    return new Response(null, { status: 101, webSocket: client });
  }

4. Handlers:訊息、關閉、錯誤

訊息處理不再是記憶體裡的閉包,而是 DO 類別上的 webSocketMessage() 方法。平台在有訊息時把 DO 喚醒並呼叫它,傳入「是哪條連線 ws」與「訊息內容 message」。我們用 deserializeAttachment() 取回暱稱,寫入歷史,再廣播給全部人。

  // 有訊息進來時,平台喚醒 DO 並呼叫此方法
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    // ★ 取回這條連線先前附加的狀態(休眠後記憶體已清空,靠這個還原)
    const { name } = ws.deserializeAttachment() as { name: string };
    const text = typeof message === "string" ? message : "[binary]";

    // 寫入歷史(持久化到 SQLite)
    this.sql.exec(
      `INSERT INTO messages (who, text) VALUES (?, ?);`,
      name,
      text
    );

    // 廣播給房間內所有連線
    this.broadcast(JSON.stringify({ who: name, text }));
  }

  // 連線關閉時觸發:通知其他人、清理
  async webSocketClose(
    ws: WebSocket,
    code: number,
    reason: string,
    wasClean: boolean
  ) {
    const att = ws.deserializeAttachment() as { name: string } | null;
    if (att) {
      this.broadcast(JSON.stringify({ system: `${att.name} 離開了` }));
    }
    // 主動關閉伺服器端,確保連線完整結束
    ws.close(code, reason);
  }

  // 連線發生錯誤時觸發
  async webSocketError(ws: WebSocket, error: unknown) {
    console.error("ws error:", error);
  }

5. 廣播:用 getWebSockets 取回所有連線

這是 Hibernation 下最容易踩雷的地方:你不能自己在建構子裡維護一個 this.sockets = new Set() 來記所有連線——因為休眠喚醒後,DO 被重新建構,那個 Set 是空的。正解是隨時向平台索取:this.ctx.getWebSockets() 會回傳「這個 DO 目前實際持有的所有連線」,由平台為你保管。

  // 廣播給房間內所有連線(含自己),用 getWebSockets 而非自維護 Set
  private broadcast(payload: string) {
    // ★ 向平台索取當前所有連線——休眠喚醒後這才是唯一可靠來源
    for (const ws of this.ctx.getWebSockets()) {
      try {
        ws.send(payload);
      } catch {
        // 個別連線送失敗不影響其他人
      }
    }
  }
}

到此,一個完整、可休眠、可廣播的聊天室 DO 就完成了。它能持有大量連線,在沒有訊息往來的空檔安然休眠、停止計費,而所有人的連線都不會斷;一旦有人發言,平台喚醒它、webSocketMessage 觸發、廣播出去,然後又能再度睡去。

6. 用戶端:與一般 WebSocket 完全相同

值得再強調一次:Hibernation 是純伺服器端的優化,用戶端毫無感知。瀏覽器端就是最普通的 WebSocket,和第 008 篇教的一模一樣:

// 瀏覽器端 —— 和一般 WebSocket 沒有任何差別
const ws = new WebSocket("wss://chat-app.example.workers.dev/room/lobby?name=Alice");
ws.onmessage = (e) => console.log("收到:", e.data);
ws.onopen = () => ws.send("Hello!");

常見錯誤與最佳實踐

坑一:用 addEventListener 而不是 Hibernation handler,導致 DO 不休眠。

這是最根本的錯誤。只要你用了 server.accept() + server.addEventListener("message", ...),DO 就被釘在記憶體裡,Hibernation 完全不會生效,你依然全程計費。要休眠,接受連線必須用 ctx.acceptWebSocket,訊息處理必須搬到 webSocketMessage 類別方法

// ❌ 錯誤:這樣寫 DO 永遠不休眠,閒置也計費
server.accept();
server.addEventListener("message", (e) => server.send(e.data));

// ✅ 正確:Hibernation 模式,閒置時可休眠省錢
this.ctx.acceptWebSocket(server);
// 訊息在類別方法裡處理:
// async webSocketMessage(ws, msg) { ... }

坑二:把連線狀態放記憶體,休眠後全部消失。

休眠 = 釋放記憶體。任何綁在某條連線上、且要跨休眠存活的狀態(暱稱、使用者 ID、角色),都不能只放 this.xxx 或閉包變數,必須用 serializeAttachment 附加到連線本身。

// ❌ 錯誤:休眠喚醒後 userMap 是空的,name 拿不到
this.userMap.set(server, name); // Map 在記憶體,休眠即失

// ✅ 正確:附加到連線,跨休眠存活
server.serializeAttachment({ name });
// 喚醒後:
const { name } = ws.deserializeAttachment();

坑三:自己維護連線 Set 來廣播,休眠後 Set 清空、廣播不到人。

同樣道理,「所有連線的清單」也不能自維護。休眠喚醒後 this.sockets 會是空的,廣播就送不出去。永遠用 ctx.getWebSockets() 向平台索取。

// ❌ 錯誤:休眠後 this.sockets 為空,誰都收不到
for (const ws of this.sockets) ws.send(payload);

// ✅ 正確:向平台索取當前真實連線
for (const ws of this.ctx.getWebSockets()) ws.send(payload);

坑四:忽略 attachment 的大小上限。

serializeAttachment 有大小限制(約 16 KB,支援 structured clone)。別把整段聊天歷史、大物件塞進去——attachment 只該放「識別這條連線所需的精簡狀態」(ID、暱稱、房間角色)。大資料請寫進 DO 的 SQLite。

坑五:讓保活 ping 白白喚醒 DO。

很多用戶端會定期送 ping 保活。如果每個 ping 都喚醒 DO、跑一次 webSocketMessage,休眠的省錢效果會被吃掉。用 ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong")),讓平台在不喚醒 DO 的前提下自動回 pong,把保活訊號擋在休眠層外。

還要記得的兩個限制:

  • 只有 DO 作為伺服器端時支援 Hibernation。 DO 作為用戶端(outbound WebSocket)連出去的連線不支援休眠。
  • 重新部署會中斷所有現有連線。 每次 wrangler deploy 都會斷開該類別所有 WebSocket——用戶端要有自動重連邏輯,別假設連線永不中斷。

最佳實踐小結:記牢——接受連線用 ctx.acceptWebSocket、訊息用 webSocketMessage 類別方法;連線相關狀態一律 serializeAttachment(記憶體會在休眠時清空);廣播用 getWebSockets(),絕不自維護 Set;ping/pong 用 setWebSocketAutoResponse 擋在休眠層外;用戶端要能自動重連(部署會斷線)。守住這幾條,你的即時服務就能在大量閒置連線下依然省成本。

小結

上一篇《DO Alarms 定時器》,我們讓每個 DO 擁有可靠、可重試的定時器,能在未來某刻自己醒來做事;這一篇,我們讓它學會長時間持有 WebSocket 連線,並在空閒時優雅休眠以省下計費:

  • 問題根源——一般 WebSocket 寫法(server.accept + addEventListener)讓 DO 為了等訊息而全程醒著計費,大量閒置連線會浪費驚人的活躍時長。
  • Hibernation 解耦——用 ctx.acceptWebSocket(server) 接受連線後,平台把「連線維持」與「你的程式在記憶體醒著」拆開:空閒時 DO 釋放記憶體、停止計費,但連線不斷;有訊息才喚醒。
  • 跨休眠保存狀態——記憶體會在休眠時清空,所以連線相關狀態用 serializeAttachment / deserializeAttachment 綁到連線上,連線清單用 getWebSockets() 向平台索取。
  • 省成本細節——setWebSocketAutoResponse 讓 ping/pong 不喚醒 DO;記得 attachment 有 16 KB 上限、outbound 連線不支援休眠、部署會斷線需自動重連。

這一切都呼應了第 008 篇《WebSockets》所打下的基礎:協定與用戶端一模一樣,改變的只是「DO 伺服器端如何接與收」——而這個小改變,換來的是「大量閒置連線也能省成本」的巨大架構優勢。現在你的 DO 能存值、查詢、交易、排定時器,還能扛住成千上萬條長連線而不燒錢。但當系統長大、DO 越來越多,它們之間該如何「互相呼叫、協調」?下一篇《DO RPC 與跨物件呼叫》,我們就深入 DO 之間如何像呼叫本地函式一樣彼此溝通,把單一有狀態實體織成一張協作網。

想先查閱官方對 WebSocket Hibernation 的完整說明,可以隨時參考 Cloudflare Durable Objects WebSockets 官方文件。省成本的即時長連線已經到手,我們下一篇《DO RPC 與跨物件呼叫》見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →