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 的定位機制——
DurableObjectNamespace、idFromName/getByName與newUniqueId的差異、如何取得 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,提供 increment、getCount、reset 三個方法,再從 Worker 呼叫它。這個例子雖小,卻完整涵蓋了「定義 DO 類別、綁定、取得 stub、RPC 呼叫」四件事。
1. 定義 DO 類別(extends DurableObject)
Durable Object 類別必須繼承(extend) 來自 cloudflare:workers 的 DurableObject 基礎類別。這裡我們用一個 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>,泛型帶入 Env 讓 this.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_classes。tag(如 "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.jsonc用durable_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》見。