【Cloudflare Workers 全端架構師之路 07】狀態篇:Durable Objects 深度解析與 WebSocket 叢集
01. 前言:Serverless 的「失憶症」與「腦裂」
在 Part 1 到 Part 6,我們構建的系統都是 無狀態 (Stateless) 的。 這意味著,如果有兩個使用者 User A (台北) 和 User B (紐約) 同時存取你的 API,他們會被導向不同的邊緣節點。這兩個節點互不認識,也沒有共享記憶體。
這對 API 擴展性很好,但對以下場景是災難:
- 聊天室: User A 說話,User B 聽不到(因為連在不同伺服器)。
- 搶票/庫存: 兩個節點同時讀到「剩餘 1 張票」,同時賣給兩個人 -> 超賣 (Overselling)。
- 協作編輯: 兩個節點同時接受寫入,資料衝突。
傳統解法是把狀態推給 Redis 或資料庫,但在全球分散式架構下,這會帶來巨大的延遲與鎖定 (Locking) 問題。
Durable Objects (DO) 提供了另一種典範轉移:將運算 (Compute) 移動到數據 (Data) 旁邊,並保證全球唯一性。
02. 架構比較:Stateful Serverless vs. AWS 傳統架構
| 特性 | Cloudflare Durable Objects | AWS Lambda + DynamoDB/Redis |
|---|---|---|
| 狀態位置 | 與運算同在 (In-memory / Local Disk) | 分離 (運算在 Lambda,資料在 DB) |
| 併發模型 | 單執行緒 (Single-threaded) | 多併發 (需自行處理 Race Conditions) |
| 一致性 | 強一致性 (Strong Consistency) | 通常是最終一致性 (或需使用 Conditional Writes) |
| WebSocket | 原生支援 (且支援休眠) | 需搭配 API Gateway WebSocket API (昂貴) |
| 延遲 | 極低 (存取本地變數/儲存) | 較高 (需跨網路存取 DB) |
架構師筆記: DO 的單執行緒特性是它最強大的武器。你不需要寫複雜的 Mutex 或 Transaction Lock,因為在同一個 DO 實例內,程式碼永遠是依序執行的。這讓「防止超賣」的邏輯變得跟
count--一樣簡單。
03. 核心技術:Hibernatable WebSockets
早期的 Workers WebSocket 實作有一個缺點:只要連線還在,Worker 就必須保持執行狀態 (Active),這會燃燒大量的 CPU 時間費用,即使雙方根本沒講話。
Cloudflare 推出的 Hibernatable WebSockets API 解決了這個問題:
- 當沒有訊息時,Worker 會自動休眠 (不計費)。
- 當有新訊息進來,或連線斷開時,系統會自動喚醒 DO 處理。
- 這讓維護數百萬個長連線的成本大幅下降。
04. 實作:即時聊天室 (Chat Room)
我們將實作一個 DO Class,負責管理聊天室的連線與訊息廣播。
步驟 1: 設定 Wrangler
修改 wrangler.toml:
[durable_objects]
bindings = [
{ name = "CHAT_ROOM", class_name = "ChatRoom" }
]
[[migrations]]
tag = "v1"
new_classes = ["ChatRoom"]
步驟 2: 撰寫 DO Class (src/ChatRoom.ts)
注意這裡使用了 DurableObject 基類與 webSocketMessage handler,這是新的標準寫法。
import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject {
// 儲存所有連線中的 WebSocket
// 透過 SQL 或 getWebSockets() 也可以,這裡用 Set 示範記憶體內狀態
sessions: Set<WebSocket> = new Set();
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// 恢復狀態:如果有持久化的連線資訊,可以在這裡載入
// this.ctx.getWebSockets().forEach(ws => this.sessions.add(ws));
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// WebSocket 升級請求
if (url.pathname === "/websocket") {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected websocket", { status: 426 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// 關鍵:使用 acceptWebSocket 讓系統接管連線生命週期
// 這啟用了 Hibernation (休眠) 能力
this.ctx.acceptWebSocket(server);
this.sessions.add(server);
return new Response(null, { status: 101, webSocket: client });
}
return new Response("ChatRoom DO is active", { status: 200 });
}
// 當收到訊息時,DO 會被喚醒並執行此方法
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
// 廣播邏輯
const msg = message.toString();
// 這裡還可以把訊息寫入 SQL (this.ctx.storage.sql) 做持久化
for (const session of this.sessions) {
// 排除發送者自己 (可選)
// if (session === ws) continue;
try {
session.send(JSON.stringify({
sender: "User", // 真實場景需結合 Part 6 的 JWT 解析 User ID
text: msg,
timestamp: Date.now()
}));
} catch (err) {
this.sessions.delete(session);
}
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
this.sessions.delete(ws);
}
}
步驟 3: 主 Worker 路由 (src/index.ts)
import { Hono } from 'hono'
import { ChatRoom } from './ChatRoom'
type Bindings = {
CHAT_ROOM: DurableObjectNamespace<ChatRoom>
}
const app = new Hono<{ Bindings: Bindings }>()
app.get('/chat/:roomName', async (c) => {
const roomName = c.req.param('roomName');
// 1. 根據房間名稱取得全球唯一的 ID
// idFromName 會確保 "general" 這個字串永遠對應到同一個 ID
const id = c.env.CHAT_ROOM.idFromName(roomName);
// 2. 取得 Stub (代理物件)
const stub = c.env.CHAT_ROOM.get(id);
// 3. 轉發請求
return stub.fetch(c.req.raw);
})
export default app
export { ChatRoom } // 務必匯出 Class
05. 進階功能:Alarms API (定時任務)
DO 還有一個殺手級功能:Alarms。 你可以設定 DO 在未來的某個時間點「叫醒自己」,即使那時候沒有任何 Request 進來。這非常適合做:
- 清理閒置房間:如果是遊戲房間,10分鐘沒人動就關閉並寫入 DB。
- 延遲寫入 (Write-behind):先將資料存在 DO 記憶體,每 30 秒再一次寫入 D1,減輕資料庫壓力。
// 在 ChatRoom Class 中
// 設定鬧鐘
async webSocketMessage(ws: WebSocket, message: string) {
// ... 處理訊息
// 如果還沒設鬧鐘,設一個 30 秒後的鬧鐘來 flush 資料
const currentAlarm = await this.ctx.storage.getAlarm();
if (currentAlarm == null) {
await this.ctx.storage.setAlarm(Date.now() + 30 * 1000);
}
}
// 鬧鐘響時執行
async alarm() {
// 將記憶體中的對話紀錄批量寫入 D1 或 R2
await this.saveHistoryToDatabase();
// 清除鬧鐘 (或設下一個)
}
06. 最佳實踐:DO 的適用邊界
雖然 DO 很強,但不要濫用。
-
** ✅ 適合 DO 的場景**:
- 協作類:Google Docs, Figma (狀態需即時同步)。
- 遊戲類:多人對戰房間。
- 計數器/限流:全域 API Rate Limiter。
- 購物車/庫存:防止超賣。
-
** ❌ 不適合 DO 的場景**:
- 單純的 CRUD:請直接用 D1。DO 有額外的調用成本。
- 靜態內容:請用 KV 或 R2。
- 無關聯的請求:如果 Request A 和 Request B 不需要互相知道,就不要把它們塞進同一個 DO,這會變成效能瓶頸 (因為 DO 是單執行緒)。
07. 小結與下一步
透過 Durable Objects,我們補齊了 Edge 架構中「狀態一致性」的最後一塊拼圖。現在我們能處理高併發的即時互動,而且不用擔心資料錯亂。
但是,如果我們有一個耗時很長的任務呢?例如:使用者註冊後,我們要發送歡迎信、壓縮頭像圖片、還要分析使用者數據。 如果這些都在 API 請求中同步執行,使用者會等到天荒地老,甚至 Worker 會超時 (Timeout)。
在下一篇 Part 8: 非同步篇,我們將引入 Cloudflare Queues。 這是一個基於生產者-消費者 (Producer-Consumer) 模式的訊息佇列,專門用來解耦系統,處理背景任務。

關於作者
Ken Huang
熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。
最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。
這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。
系列文章目錄
- 【Cloudflare Workers 全端架構師之路 05】儲存篇:R2 檔案處理與零流量費架構
- 【Cloudflare Workers 全端架構師之路 06】安全篇:守門員 Middleware、CORS 與 JWT 驗證
- 【Cloudflare Workers 全端架構師之路 07】狀態篇:Durable Objects 深度解析與 WebSocket 叢集 (本文)
- 【Cloudflare Workers 全端架構師之路 08】非同步篇:使用 Queues 解耦系統與背景任務
- 【Cloudflare Workers 全端架構師之路 09】智慧篇:在邊緣運行 Llama 3 與 RAG 向量搜尋