5.4 Durable Objects:強一致性的有狀態邊緣#
本節目標#
- 理解為什麼某些場景「必須」有單一實例(single instance)的有狀態物件
- 掌握 DO 的單一執行緒(single-threaded)執行模型
- 實作房型房間(rooms)的全域計數器
- 認識 DO 與 WebSocket 的搭配,以及 DO 的計價方式
理論:單實例繞過分布式難題#
5.1 節的問題在此得到最直接的回答:CAP 定理束縛的是「多副本」系統。如果你的資料只有一份、只在一個地方被讀寫,那就根本沒有副本間的一致性問題。Durable Objects(下稱 DO)正是這個思想的產物:
一個 DO 類別可以在全世界啟動成千上萬個實例,但每個實例(依名字決定)在任一時刻都只存在於一個地點、由單一執行緒服務所有請求。
歷史脈絡:DO 於 2020 年公布(率先承載了 Cloudflare 自家的線上遊戲與協作應用負載),2023 年全面 GA 並开放 SQLite 儲存後端。它可視為 Cloudflare 對 actor model(Erlang 的精神)在邊緣網路上的實踐:actor = 有狀態 + 序列化處理 + 定址透明。
為什麼需要它?回看前面兩節的缺口:
| 需求 | KV | D1 | DO |
|---|---|---|---|
| 全域唯一的計數器 | ✗(LWW 遺失更新) | △(要繞路+寫入熱點) | ✓(單實例自增) |
| 即時房間狀態(成員、游標) | ✗ | △(太慢) | ✓ |
| 讀自寫(read-your-write) | ✗ | ✓ | ✓(本地即回) |
| WebSocket 集中管理 | ✗ | ✗ | ✓ |
單執行緒模型:序列化一切#
DO 的執行模型是 JavaScript 的「單一 event loop」:
- 同一個 DO 實例的所有請求依序處理,不會並行進入你的程式碼
- 存取實例內記憶體中的變數,就是全域計數器——不會有 race condition
- 你不需要鎖、不需要交易——「只執行一條執行緒」就是最強的隔離
兩個補充:
blockConcurrencyWhile可以把初始化與第一個請求序列化,避免「還沒載入完狀態就被讀取」。- 單執行緒會成為吞吐 bottleneck:若某個實例是世界級熱點(如全站單一計數器),請分片(多個實例共同分摊,見練習 3)。
實作一:房型房間的計數器#
目標:一個聊天室頁面要顯示「目前線上 N 人」。同一房間的更新必須永遠落在同一個實例上,否則人數會各說各話。
# wrangler.toml 片段
[durable_objects]
bindings = [
{ name = "ROOMS", class_name = "ChatRoom" }
]
[[migrations]]
tag = "v1"
new_classes = ["ChatRoom"]
// src/room.ts
export class ChatRoom implements DurableObject {
private users = new Set<string>(); // 房間狀態:線上成員
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// 加入房間:單執行緒內操作 Set,天然原子
if (url.pathname === "/join" && request.method === "POST") {
const { user } = await request.json<{ user: string }>();
this.users.add(user);
return Response.json({ online: this.users.size });
}
// 離開房間
if (url.pathname === "/leave" && request.method === "POST") {
const { user } = await request.json<{ user: string }>();
this.users.delete(user);
return Response.json({ online: this.users.size });
}
return new Response("Not found", { status: 404 });
}
}
Worker 端使用 idFromName 把「房名」映射到唯一實例:
// src/index.ts
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
const roomName = url.searchParams.get("room") ?? "lobby";
// 同名房間永遠路由到同一個 DO 實例
const id = env.ROOMS.idFromName(roomName);
const stub = env.ROOMS.get(id);
const withRoom = new Request(`https://room${url.pathname}${url.search}`, req);
return stub.fetch(withRoom);
},
};
idFromName("lobby") 的語意值得咀嚼:不管請求從台北還是紐約進來,這個名字永遠對應同一個實例——Cloudflare 負責把它放在某個地點(首次使用時靠近第一個請求者),需要時在必要節點間搬移。這就是「定址透明」:你不關心它在哪,只關心它「唯一」。
實作二:WebSocket 與 DO 的天然契合#
即時房間(聊天、協作白板、多人遊戲)需要長連接。DO 的限制是「每個實例可以有多個 WebSocket 客戶端連入」,這恰好構成「星型拓撲」:房間是中心,成員是邊緣,廣播由中心推。
export class ChatRoomWS implements DurableObject {
private sockets = new Set<WebSocket>();
async fetch(request: Request): Promise<Response> {
if (request.headers.get("Upgrade") !== "websocket")
return new Response("expected ws", { status: 426 });
const pair = new WebSocketPair();
this.sockets.add(pair[1] as WebSocket);
(pair[1] as WebSocket).accept();
return new Response(null, { status: 101, webSocket: pair[0] });
}
webSocketMessage(ws: WebSocket, message: string) {
// 收到某人的訊息 → 廣播給房間所有人
for (const s of this.sockets) s.send(message);
}
webSocketClose(ws: WebSocket) {
this.sockets.delete(ws);
}
}
重點:WebSocket 訊息透過 webSocketMessage 進入同一條單執行緒,廣播寫起來就像普通的同步迴圈——不需要 Redis pub/sub、不需要分散式協調。這是 DO 最迷人的地方:把「分布式即時系統」簡化成「單機物件導向程式」的體驗。
DO 的 pricing 心法#
DO 的計費獨樹一格,寫作前先搞清楚,否則帳單會給你驚喜:
| 項目 | 計費單位(Paid plan) | 說明 |
|---|---|---|
| Duration(持續時間) | GB-seconds | 只要實例存活就計費,哪怕沒有請求 |
| Requests | 每百萬次 | 每次 stub.fetch() 算一次 |
| Storage | GB-month | SQLite 後端的資料量 |
關鍵陷阱是 duration:DO 預設在無活動後很快就會被淘汰(hibernate),但配 WebSocket 或 timer 的長期常駐實例會持續燒錢。優化方向:
- 無活動即淘汰:長連線用 WebSocket Hibernation API——斷線擇期休眠,訊息到達才喚醒
- 簡短活動:HTTP 型 DO 用完即眠,duration 幾乎可忽略
- 分片降低單例熱度:以 hash 分成多個實例,分攤計數器寫入
搭配免費方案下限,一個中型聊天網站的 DO 費用通常控制在幾美元等級;影音協作那種「全站單一實例」外的設計才有大帳單風險。細節以官方 pricing 頁為準。
小結與練習#
DO 把「有狀態的分布式問題」翻譯成「有狀態的單機物件」,用單實例 + 單執行緒換來思考的簡單性;WebSocket 是它的殺手應用。KV/R2/D1/DO 四件套至此完整:鍵值快取、SQL 交易、大媒體、即時共享狀態。
練習:
- 把
ChatRoom擴充為聊天房間完整版:/join、/leave、/send(訊息廣播給房內所有人),用curl -N測試。 - 為
usersSet 加上「5 分鐘沒活動就視為離線」的心跳邏輯(提示:用一個lastSeen: Map<string, number>記錄,在fetch內清掃過期成員)。 - 思考題:全站共用的「讚數」若用單一 DO,會遇到什麼瓶頸?設計一個「分片 + 定期彙總」的方案(提示:hash(userId) 分片到 16 個計數器實例)。
- 查閱文件:研究 WebSocket Hibernation API,理解它如何把 duration 費用降到近零,並說明
webSocketMessage在休眠喚醒後的行為。