beio Logobeio

【Cloudflare Workers 全端架構師之路 07】狀態篇:Durable Objects 深度解析與 WebSocket 叢集

發布於

01. 前言:Serverless 的「失憶症」與「腦裂」

在 Part 1 到 Part 6,我們構建的系統都是 無狀態 (Stateless) 的。 這意味著,如果有兩個使用者 User A (台北) 和 User B (紐約) 同時存取你的 API,他們會被導向不同的邊緣節點。這兩個節點互不認識,也沒有共享記憶體。

這對 API 擴展性很好,但對以下場景是災難:

  1. 聊天室: User A 說話,User B 聽不到(因為連在不同伺服器)。
  2. 搶票/庫存: 兩個節點同時讀到「剩餘 1 張票」,同時賣給兩個人 -> 超賣 (Overselling)
  3. 協作編輯: 兩個節點同時接受寫入,資料衝突。

傳統解法是把狀態推給 Redis 或資料庫,但在全球分散式架構下,這會帶來巨大的延遲與鎖定 (Locking) 問題。

Durable Objects (DO) 提供了另一種典範轉移:將運算 (Compute) 移動到數據 (Data) 旁邊,並保證全球唯一性。

02. 架構比較:Stateful Serverless vs. AWS 傳統架構

特性Cloudflare Durable ObjectsAWS 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

關於作者

Ken Huang

熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。

最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。

這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。

Android APP DevelopmentiOS APP DevelopmentBePTT CreatorFull Stack Learner