Durable Objects 入門:邊緣上的有狀態協調 | Cloudflare 完整教學

2026/08/23
Durable Objects 入門:邊緣上的有狀態協調 | Cloudflare 完整教學

上一篇《D1 Time Travel 與備份》我們為資料庫補上最後一道防線,也就此告別「資料表」的世界。這一篇,我們踏進 Cloudflare 上更強大的有狀態運算基元——Durable Objects(持久物件)。無狀態的 Worker 擅長處理獨立請求,卻無法讓多個用戶端「對同一份狀態」達成一致。Durable Objects 用一個關鍵保證解決這件事:對同一個名稱,全球只有一個活躍實例,並以單執行緒序列化執行,天然消除競態條件。這篇是 DO 系列首篇,我們先把「什麼是 DO、為何需要它、怎麼取得並呼叫它」講透,寫出你的第一個計數器。

前言

Durable Objects(持久物件,簡稱 DO)Cloudflare Workers 平台上一種有狀態運算原語(stateful compute primitive)。它把運算(compute)狀態(state) 合而為一,並提供一個其他無伺服器平台罕見的保證:對於同一個名稱(例如 "room-42"),全球網路中只會有一個活躍實例在運行,所有指向該名稱的請求都會被路由到它。

打個比方。無狀態的 Worker 就像一間連鎖速食店的任一分店:你走進哪一家、由哪個店員服務都無所謂,因為每筆點餐都是獨立的、彼此不需要共享記憶。這很有效率,但如果需求變成「全國所有人共用同一本簽到簿、輪流在上面加一筆」,連鎖分店模型就崩潰了——A 分店和 B 分店各自有一本簿子,誰也不知道對方寫了什麼。Durable Object 則像是那本全國唯一、指定放在某個櫃檯的簽到簿:不管你人在哪,要簽到就得排隊走到那個唯一的櫃檯,一次只服務一個人(單執行緒序列化),於是簿子上的內容永遠一致、不會有兩個人同時寫到同一行而打架。

這篇是 Durable Objects 系列的首篇,我們刻意把範圍收在「入門」——先建立正確的心智模型,再寫出第一個能跑的計數器。讀完你會掌握:

  • 為何需要有狀態協調——無狀態 Worker 的局限、單一實例單執行緒序列化如何提供強一致性
  • DO 的定位機制——DurableObjectNamespaceidFromName/getByNamenewUniqueId 的差異、如何取得 stub(存根)
  • 定義與呼叫 DO——用 extends DurableObject 寫一個計數器類別、wrangler.jsonc 的綁定與 migrations、從 Worker 呼叫其 RPC 方法
  • 適用與不適用場景——計數器、聊天室、分散式鎖為何適合 DO,純無狀態請求為何不該用

至於 SQLite 儲存、Alarms、WebSocket Hibernation、RPC 進階,都留給後續文章,這篇專心把地基打穩。

核心概念

無狀態 Worker 的局限:狀態該放哪?

標準的 Cloudflare Worker無狀態(stateless) 的。每次請求進來,都在一個全新或被重用的隔離環境(Isolate) 中執行,請求之間沒有可靠的共享記憶體,你也無法假設兩個請求會落在同一個資料中心、由同一份記憶體處理。這個設計讓 Worker 能無限水平擴展、就近服務全球用戶,對純運算與代理轉發非常理想。

但當你需要協調多個用戶端對同一份狀態的操作時,無狀態就成了硬傷:

  • 聊天室要維護「目前線上成員清單」——這份清單該存哪?
  • 計數器要保證「每次 +1 都基於最新值」——兩個請求同時讀到 99、各自寫回 100,最終該是 101 才對。
  • 預約系統要保證「最後一個名額不會被兩個人同時搶走」——這是典型的競態條件(Race Condition)

傳統解法是外接一個中心化的資料庫(如 PostgreSQL)或快取(如 Redis)當作唯一狀態源。但這帶來額外的網路往返延遲,而且在高並發下,你還得自己用鎖、交易或 CAS 去對付競態條件——複雜、易錯,而且效能受制於那個中心節點。

Durable Objects 的三個核心保證

Durable Objects 就是為了填補這個空缺而生。它提供三個層層相扣的保證:

1. 單一實例一致性(Single Instance Consistency)

對於同一個名稱(如 "room-42"),全球只會有一個 DO 實例在運行。所有指向這個名稱的請求,無論從哪個資料中心發起,都會被路由到同一個實例。這一刀就砍掉了分散式系統裡最棘手的問題:你不再需要協調「多份副本之間的一致性」,因為根本就只有一份。

2. 單執行緒序列化執行(Single-Threaded Serialized Execution)

每個 DO 實例在任何時間點只執行一個事件。當它正在處理一個請求時,後續進來的請求會排隊等待(Input Gate),而不是被丟棄或並行插隊。這代表你在 DO 內部操作狀態時,可以像寫單機、單執行緒的程式一樣直覺——不會有兩段程式碼同時改同一個變數。前面計數器「99 → 100 → 100」的競態,在 DO 裡自然變成「99 → 100 → 101」,因為第二個 +1 一定排在第一個完成之後才執行。

3. 計算與狀態共置(Co-located Compute and State)

每個 DO 都有屬於自己的持久儲存(預設是內嵌 SQLite),資料就在執行程式碼的同一個地方,讀寫延遲趨近於零,不需要跨網路呼叫外部資料庫。(儲存細節留待下一篇。)

DO 是怎麼被定位的?Namespace、ID 與 Stub

要在 Worker 裡「找到並使用」一個 DO,牽涉三個關鍵術語,務必先分清楚:

術語一句話定義
DurableObjectNamespace一整類 DO 的「命名空間」,對應 wrangler.jsonc 裡的一個 binding,是取得實例的入口
ID(DurableObjectId)一個 DO 實例的唯一識別碼,由 idFromName()newUniqueId() 產生
Stub(存根)指向某個 DO 實例的「遙控器」,透過它呼叫該 DO 的方法(RPC)

流程是:Namespace → 產生/取得 ID → 用 ID 取得 Stub → 透過 Stub 呼叫方法。從 Namespace 取得 ID 有兩種主要方式,差異至關重要:

  • idFromName(name)(冪等具名):相同名稱永遠對應到全球同一個實例。這是最常用的方式,適合你手上已有天然識別碼的場景(房間 ID、使用者 ID、API Key)。實務上更常用它的捷徑 getByName(name),一步到位拿到 stub。
  • newUniqueId()(隨機唯一):每次呼叫都產生一個全新、全球唯一的 ID,適合系統自己建立的一次性物件。關鍵陷阱:這個隨機 ID 若你不自己存下來,就再也找不回那個物件了。

一句話總結選法:手上有識別碼就用 getByName,需要系統產生匿名實例且會保存 ID 才用 newUniqueId

實作範例

我們來寫一個全域計數器:一個 DO 類別 Counter,提供 incrementgetCountreset 三個方法,再從 Worker 呼叫它。這個例子雖小,卻完整涵蓋了「定義 DO 類別、綁定、取得 stub、RPC 呼叫」四件事。

1. 定義 DO 類別(extends DurableObject)

Durable Object 類別必須繼承(extend) 來自 cloudflare:workersDurableObject 基礎類別。這裡我們用一個 instance variable value 當作記憶體快取,並在 constructor 中透過 blockConcurrencyWhile 從儲存還原初始值。

// src/index.ts
import { DurableObject } from "cloudflare:workers";

// Env 介面:DurableObjectNamespace<T> 的泛型 T 指向你的 DO 類別
// 這讓 stub 的 RPC 呼叫具備完整型別推斷
export interface Env {
  COUNTER: DurableObjectNamespace<Counter>;
}

// DO 類別:繼承 DurableObject<Env>
export class Counter extends DurableObject<Env> {
  // 記憶體快取:單執行緒序列化下,操作它是安全的
  private value = 0;

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    // blockConcurrencyWhile:在初始化完成前,不接受任何請求
    // 只在 constructor 用來還原狀態,別在每個請求上用(會拖垮吞吐)
    ctx.blockConcurrencyWhile(async () => {
      this.value = (await ctx.storage.get<number>("value")) ?? 0;
    });
  }

  // RPC 方法:由 Worker 直接呼叫,如同本地函式
  async increment(amount = 1): Promise<number> {
    this.value += amount;
    // 「先持久化,再更新快取」原則:重要狀態務必寫入儲存
    await this.ctx.storage.put("value", this.value);
    return this.value; // 回傳更新後的值
  }

  async getCount(): Promise<number> {
    return this.value;
  }

  async reset(): Promise<void> {
    this.value = 0;
    await this.ctx.storage.put("value", 0);
  }
}

三個重點:一是 extends DurableObject<Env>,泛型帶入 Envthis.env 有型別;二是單執行緒序列化this.value += amount 這種「讀-改-寫」不會被別的請求插隊,天然安全;三是我們仍把值寫進 ctx.storage——因為 DO 在閒置時可能被驅逐(evict)出記憶體,只存在 instance variable 的狀態會遺失,重要資料一定要持久化。

2. 從 Worker 取得 stub 並呼叫

Worker 是外部世界進入 DO 的入口。這裡示範用 getByName(具名冪等)取得指向同一個全域計數器的 stub,再直接呼叫它的 RPC 方法。

// 接續 src/index.ts

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // 取得指向「同一個」全域計數器的 stub
    // 相同名稱 "global-counter" 永遠對應全球同一個實例
    const stub = env.COUNTER.getByName("global-counter");

    if (url.pathname === "/increment") {
      // 直接呼叫 RPC 方法,型別完整推斷(newValue 是 number)
      const newValue = await stub.increment(1);
      return Response.json({ count: newValue });
    }

    if (url.pathname === "/count") {
      const count = await stub.getCount();
      return Response.json({ count });
    }

    if (url.pathname === "/reset") {
      await stub.reset();
      return Response.json({ ok: true });
    }

    return new Response("Not Found", { status: 404 });
  },
} satisfies ExportedHandler<Env>;

getByName("global-counter") 等價於先 const id = env.COUNTER.idFromName("global-counter")env.COUNTER.get(id),只是少寫一步。呼叫 stub.increment(1) 時,Worker 會把這個呼叫送到那個唯一的 Counter 實例執行——不管此刻有多少個 Worker 同時打進來,它們的 increment 都會在 DO 內部排隊逐一執行,計數永遠正確。

若你想要「每個使用者各自一個計數器」,只要把名稱換成識別碼即可:

// 每個使用者一個獨立的計數器實例(按實體分片)
const userId = url.searchParams.get("userId") ?? "anonymous";
const perUserStub = env.COUNTER.getByName(`user:${userId}`);
const count = await perUserStub.increment();

這就是 DO 的精髓:一個名稱 = 一個獨立的、強一致的狀態實體

3. wrangler.jsonc:綁定與 migration

DO 需要在 wrangler.jsonc 中做兩件事:用 durable_objects.bindings 把 binding 名稱對應到類別;用 migrations 宣告這個類別是新建立的(首次部署必做)。

// wrangler.jsonc
{
  "name": "my-counter-worker",
  "main": "src/index.ts",
  "compatibility_date": "2024-04-03", // RPC 需要 >= 2024-04-03

  "durable_objects": {
    "bindings": [
      {
        "name": "COUNTER",       // Worker 中用 env.COUNTER 存取
        "class_name": "Counter"  // 對應上面的 TypeScript class
      }
    ]
  },

  "migrations": [
    {
      "tag": "v1",
      // new_sqlite_classes:建立新的 SQLite-backed DO 類別(推薦)
      // 免費方案也只支援 SQLite backend
      "new_sqlite_classes": ["Counter"]
    }
  ]
}

migrations 常令新手困惑:它不是資料庫的那種 migration,而是告訴 Cloudflare「這個類別在平台上的生命週期變化」。首次部署一個新 DO 類別,必須在 migration 裡用 new_sqlite_classes(SQLite 後端,推薦)聲明它;之後若要改名或刪除,則分別用 renamed_classes / deleted_classestag(如 "v1")是每次 migration 的識別,新的變更要追加新的 tag,不要修改舊的。

設定完就能本地開發與部署:

# 本地開發(Miniflare 會在本地模擬 DO)
npx wrangler dev

# 部署到生產環境
npx wrangler deploy

# 即時查看 DO 執行日誌
npx wrangler tail

打開 http://localhost:8787/increment 連按幾次,再看 /count,你會看到數字穩定遞增——即使你開多個分頁同時狂點,計數也不會錯亂,這正是單執行緒序列化在替你把關。

適用場景:什麼問題天生適合 DO?

只要問題的本質是「多個用戶端需要對某個獨立實體的狀態達成強一致」,DO 就是首選:

場景為何適合 DO
計數器 / 即時統計讀-改-寫序列化,計數永遠精確,不需外部鎖
即時聊天室 / 房間每個房間一個 DO,持有成員狀態、協調廣播
分散式鎖 / 互斥單執行緒天然就是一把鎖,搶佔請求自動排隊
預約 / 庫存序列化避免超賣,最後一個名額不會被兩人搶走
分散式限流每個 API Key 一個 DO,精確計數不靠外部 Redis
多人遊戲對局每局一個 DO,持有並廣播權威遊戲狀態

它們的共同點是:有一個「協調中心」的天然邊界(一個房間、一把鎖、一個名額池),而 DO 剛好把「這個邊界」變成一個全球唯一、序列化執行的物件。

常見錯誤與最佳實踐

坑一:用無狀態 Worker 硬存跨請求狀態。

新手最常見的錯誤,是想在 Worker 的全域變數裡存狀態(例如一個 module-level 的 let counter = 0),以為它會跨請求累加。事實是——Worker 是無狀態的,不同請求可能落在完全不同的 Isolate,全域變數隨時會被重置或分裂成多份,計數必然錯亂。需要跨請求協調的狀態,就該交給 DO。

// ❌ 錯誤:在無狀態 Worker 的全域變數存狀態,會被重置/分裂
let globalCount = 0;
export default {
  async fetch() {
    globalCount++; // 不可靠!不同 Isolate 各有一份,數字會亂跳
    return Response.json({ count: globalCount });
  },
};

// ✅ 正確:交給 DO,單一實例 + 序列化保證一致
const stub = env.COUNTER.getByName("global-counter");
const count = await stub.increment();

坑二:ID 命名混亂,或誤用 newUniqueId 後找不回物件。

DO 的名稱就是它的「地址」。若你用不穩定的字串當名稱(例如把使用者輸入未正規化就當 key、或每次拼接時大小寫不一致),同一個實體會被解析成不同 DO,狀態就散掉了。更嚴重的是誤把 newUniqueId() 當成 idFromName() 用——每次呼叫都產生新 ID,又沒存下來,等於每次都建一個空物件、上一個永遠找不回。

// ❌ 錯誤:每次都產生全新隨機 ID 又不儲存 → 每次都是新空物件
const id = env.COUNTER.newUniqueId();
const stub = env.COUNTER.get(id);
await stub.increment(); // 這個 1 下次再也讀不到

// ✅ 正確:用穩定、正規化的具名 ID(冪等)
const roomId = rawRoomId.trim().toLowerCase(); // 正規化,避免同室多實例
const stub = env.COUNTER.getByName(`room:${roomId}`);

心法:名稱要用穩定且正規化的識別碼(統一前綴如 room:user:,統一大小寫);只有在真的需要系統產生匿名一次性物件、且會把 ID 存進 KV/D1 時,才用 newUniqueId()

坑三:把所有流量塞進「一個全域 DO」,製造單點瓶頸。

這是最傷擴展性的反模式。DO 是單執行緒的,單一實例有約 1,000 QPS 的軟性上限。若你把全站每個請求都導向同一個 DO,它會立刻成為瓶頸,所有請求排在一條隊伍裡動彈不得。

// ❌ 錯誤:全站共用一個全域 DO,序列化變成全域瓶頸
const stub = env.COUNTER.getByName("GLOBAL"); // 所有人都排這一條隊

// ✅ 正確:按實體分片,每個實體一個獨立、並行的 DO
const stub = env.COUNTER.getByName(`user:${userId}`); // 一萬個用戶 = 一萬個並行實例

正確的設計核心原則是:每個 DO 代表一個需要強一致的獨立實體(協調原子),而非一台全域共享伺服器。 按實體(房間、使用者、API Key)分片後,實例數量隨業務水平擴展,單一實例的序列化反而成了「每個實體內部」的一致性保證,而非全域瓶頸。

坑四:重要狀態只存記憶體,不寫儲存。

DO 在閒置時可能被驅逐出記憶體、之後再被喚醒,instance variable 的值會歸零。所以像計數器這種重要狀態,務必遵循**「先持久化,再更新快取」**:先 await this.ctx.storage.put(...),再更新記憶體變數。純快取、可重算的值才適合只放記憶體。

最佳實踐小結:記牢——跨請求狀態交給 DO,別塞進無狀態 Worker 的全域變數;名稱用穩定正規化的具名 ID(getByName),newUniqueId 只用於會保存 ID 的匿名物件;按實體分片,絕不做全域單例;重要狀態一定持久化,遵循先寫儲存再更新快取;blockConcurrencyWhile 只在 constructor 初始化時用。守住這五條,你的第一個 DO 就站在正確的地基上。

小結

上一篇《D1 Time Travel 與備份》,我們為 D1 補上時間點還原與冷備份的最後防線,替「資料表」段落收尾;這一篇,我們正式踏進 Cloudflare有狀態運算世界,把 Durable Objects 的地基打穩:

  • 為何需要有狀態協調——無狀態 Worker 無法讓多用戶端對同一份狀態達成一致;DO單一實例(同名全球唯一)+ 單執行緒序列化(請求排隊逐一執行)天然消除競態條件。
  • 定位機制——DurableObjectNamespace(binding 入口)→ ID(idFromName 冪等具名 / newUniqueId 隨機唯一)→ Stub(呼叫 RPC 的遙控器);九成場景用 getByName 就對了。
  • 定義與呼叫——extends DurableObject<Env>Counter 類別、wrangler.jsoncdurable_objects.bindings + migrations(new_sqlite_classes)綁定、從 Worker 直接呼叫 RPC 方法。
  • 場景與反模式——計數器、聊天室、分散式鎖、預約、限流適合 DO;避免用無狀態 Worker 存狀態、ID 命名混亂、全域單例瓶頸、狀態不持久化。

我們刻意把這篇收在「入門」,只讓 Counter 用最簡單的 ctx.storage.get/put 存值。但 DO 真正的威力,藏在它內嵌的 SQLite 資料庫裡——每個 DO 都自帶一個能跑同步 SQL、支援交易的完整 SQLite,讀寫延遲趨近於零。下一篇《DO SQLite Storage》,我們就深入 ctx.storage.sql,看如何在 DO 裡建表、查詢、用 transactionSync 做原子交易,把計數器升級成真正有結構的有狀態服務。

想先查閱官方對 Durable Objects 的完整說明,可以隨時參考 Cloudflare Durable Objects 官方文件。有狀態協調的第一步已經踏出,我們下一篇《DO SQLite Storage》見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →