跳轉到

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
  • 你不需要鎖、不需要交易——「只執行一條執行緒」就是最強的隔離

兩個補充:

  1. blockConcurrencyWhile 可以把初始化與第一個請求序列化,避免「還沒載入完狀態就被讀取」。
  2. 單執行緒會成為吞吐 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 的長期常駐實例會持續燒錢。優化方向:

  1. 無活動即淘汰:長連線用 WebSocket Hibernation API——斷線擇期休眠,訊息到達才喚醒
  2. 簡短活動:HTTP 型 DO 用完即眠,duration 幾乎可忽略
  3. 分片降低單例熱度:以 hash 分成多個實例,分攤計數器寫入

搭配免費方案下限,一個中型聊天網站的 DO 費用通常控制在幾美元等級;影音協作那種「全站單一實例」外的設計才有大帳單風險。細節以官方 pricing 頁為準。

小結與練習#

DO 把「有狀態的分布式問題」翻譯成「有狀態的單機物件」,用單實例 + 單執行緒換來思考的簡單性;WebSocket 是它的殺手應用。KV/R2/D1/DO 四件套至此完整:鍵值快取、SQL 交易、大媒體、即時共享狀態。

練習:

  1. 把 ChatRoom 擴充為聊天房間完整版:/join、/leave、/send(訊息廣播給房內所有人),用 curl -N 測試。
  2. 為 users Set 加上「5 分鐘沒活動就視為離線」的心跳邏輯(提示:用一個 lastSeen: Map<string, number> 記錄,在 fetch 內清掃過期成員)。
  3. 思考題:全站共用的「讚數」若用單一 DO,會遇到什麼瓶頸?設計一個「分片 + 定期彙總」的方案(提示:hash(userId) 分片到 16 個計數器實例)。
  4. 查閱文件:研究 WebSocket Hibernation API,理解它如何把 duration 費用降到近零,並說明 webSocketMessage 在休眠喚醒後的行為。