DO WebSocket Hibernation:讓閒置連線省下計費 | Cloudflare 完整教學
上一篇《DO Alarms 定時器》,我們替 Durable Object 裝上了可靠、可重試的定時器,讓它能在未來某刻自己醒來做事。這一篇,我們碰 DO 最招牌的能力:長時間持有 WebSocket 連線。但有個關鍵陷阱——用「一般的 WebSocket」寫法,DO 會為了等訊息而全程醒著計費,就算連線整天閒著也一樣。Cloudflare 為此設計了 WebSocket Hibernation(休眠):用
ctx.acceptWebSocket接受連線後,DO 能在空閒時釋放記憶體、暫停計費,但連線不斷。你會學到acceptWebSocket、webSocketMessage/Close/Errorhandlers、用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 與跨物件呼叫》見。