sell整合實戰
hub

用 Durable Objects 管理強一致狀態

當狀態「一定要正確」時,把每個請求都導向同一個實例、一次處理一個——不用鎖、沒有競爭條件、不會遺失更新。

1每個名稱一個實例
Strong一致性
0遺失的更新
~1k每物件請求數/秒
insights

我們要串什麼?

有時候你需要全世界「剛好只有一個地方」來保管某份狀態——一個絕不能算錯的計數器、一個流量限制器、一個遊戲房間、或一份即時文件。Durable Object(DO,持久物件)正是為此而生:一個實例,用「名稱」定址,一次處理一個請求,而且自帶儲存。

這篇教學會把瀏覽器前端串到一個 Worker,再讓 Worker 把每個請求都轉送給「同一個」共享的 Counter Durable Object。因為某個名稱的所有流量都匯流到同一個實例,計數的累加永遠不會互撞。

schema多個用戶端,一個實例

idFromName(global)

用戶端 A

Worker

用戶端 B

用戶端 C

DO 識別碼

Counter(唯一實例)

儲存(v)

support_agent

把它想成…

想成熱門活動的「單一售票口」。不管來多少人,都只有一位櫃檯人員、一次發一張號碼牌。沒有人會拿到重複的號碼,數量永遠正確——因為計數的就只有這一位櫃檯。

account_tree

各部件,以及為何此處 DO 勝過 KV

這裡有四個角色。Worker 是大門;id 是物件的「地址」;stub(代理把手)是一支遙控器;instance(實例)則是那個帶著自己儲存的「真正物件」。

door_front

Worker(大門)

一個普通、無狀態的 Worker,負責接住 HTTP 請求,並決定要把它轉給哪個物件。

fingerprint

id(地址)

idFromName("global") 把一個好記的名稱轉成全球唯一的 ID。相同名稱永遠對應到相同的實例。

settings_remote

stub(遙控器)

env.COUNTER.get(id) 回傳一個 stub。你在本地呼叫 stub.fetch(),Cloudflare 會把它轉送到真正的物件。

hub

instance(單一物件)

那個唯一存活的 Counter,帶著自己專屬的儲存,實際負責修改並保存數值。

為什麼不直接用 Workers KV?KV 是「最終一致(eventual consistency)」:一次寫入會隨著時間擴散到各邊緣副本,所以不同地區的兩個讀取者可能短暫看到不同的值。對計數器來說這是致命的——兩邊都讀到 5、都寫回 6,你就少算了一次。Durable Object 則是「強一致(strong consistency)」:只有一份副本,讀取永遠看到最新的寫入。

schemaKV(最終一致)vs Durable Object(強一致)

寫入 v=6

KV(最終一致)

邊緣副本 1(最新)

邊緣副本 2(過時)

寫入 v=6

Durable Object(強一致)

唯一數值,永遠最新

balance

判斷原則

讀到稍舊的資料沒關係時(設定檔、快取 HTML)就用 KV。當很多寫入者必須對齊「同一個正確、即時的值」時,就用 Durable Object。

swap_vert

一次一個:不會遺失更新

這就是關鍵特性:Durable Object 會「序列化(serialize)」處理進來的請求——一個完全做完,下一個才開始。所以就算用戶端 A 和 B 在同一毫秒打到計數器,物件也會把它們排好隊,依序執行「讀取 → 累加 → 寫入」兩次。最後的值是 2,絕不會只是 1。

schema兩個並行的累加,被序列化處理
"儲存""Counter DO""用戶端 B""用戶端 A""儲存""Counter DO""用戶端 B""用戶端 A"序列化,一次處理一個POST /incrementPOST /increment讀取 v = 00寫入 v = 11讀取 v = 11寫入 v = 22
lock

你免費拿到一把鎖

用一般資料庫時,你得自己寫交易(transaction)或鎖(lock)來避免兩個請求互相覆蓋。Durable Object 的「單執行緒、一次一個」執行方式,自動就給了你這種互斥(mutual exclusion)——沒有會寫錯的鎖程式碼。

construction

動手做:前端 + Worker + 物件儲存

三層,後端集中在一個檔案。Worker(大門)和 Counter 類別(實例+儲存)都放在 src/index.js;wrangler.toml 把它們綁在一起;再用一小段瀏覽器程式碼呼叫 API。請注意:這裡的「資料層」就是物件自己的儲存——不用另外架一個資料庫。

  1. 建立 Worker 專案

    先建立一個全新專案。等一下我們會用自己的 Counter 取代產生出來的程式碼。

    bash
    npm create cloudflare@latest -- counter-app
    cd counter-app
  2. 寫 Worker + Durable Object

    Worker 用名稱解析出 id 並轉送請求;Counter 類別則讀取、累加、保存它的值。完整的 src/index.js 在下方。

  3. 在 wrangler.toml 綁定並設定 migration

    加上 binding(Worker 用來呼叫它的名稱)和 migration(告訴 Cloudflare:Counter 是一個新的、用 SQLite 儲存的類別)。

  4. 部署

    發佈到 Cloudflare 的網路。"global" 這個名稱的實例,會在第一次使用時自動建立。

    bash
    npx wrangler deploy
  5. 測試看看

    連打幾次——不管誰來打,數字每次都剛好加一。

    bash
    curl -X POST https://counter-app.<your-subdomain>.workers.dev/
    # 1
    curl -X POST https://counter-app.<your-subdomain>.workers.dev/
    # 2
jssrc/index.js — Worker + Counter 物件
// The Durable Object: one instance holds and mutates the count
export class Counter {
  constructor(state, env) {
    this.state = state; // gives access to this object's private storage
    this.env = env;
  }

  async fetch(req) {
    // Read the current value from THIS object's own storage (0 if unset)
    let v = (await this.state.storage.get('v')) || 0;
    v++;
    // Persist before responding; the next request will read this value
    await this.state.storage.put('v', v);
    return new Response(v.toString(), {
      headers: { 'content-type': 'text/plain' }
    });
  }
}

// The Worker (front door): forward every request to the ONE instance
export default {
  async fetch(request, env) {
    // Same name -> same single instance, anywhere on Earth
    const id = env.COUNTER.idFromName('global');
    const stub = env.COUNTER.get(id);
    // Cloudflare routes this call to the real Counter object
    return stub.fetch(request);
  }
};
tomlwrangler.toml — 綁定 + migration
name = "counter-app"
main = "src/index.js"
compatibility_date = "2025-06-01"

# Bind the COUNTER namespace in env to the Counter class
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"

# Register Counter as a new SQLite-backed Durable Object class
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Counter"]
html前端 — 大家共用的同一個計數器
<button id="go">+1</button>
<span id="count">0</span>

<script>
  document.querySelector('#go').addEventListener('click', async () => {
    // Every click hits the ONE shared Counter behind the Worker
    const res = await fetch('https://counter-app.example.workers.dev/', {
      method: 'POST'
    });
    document.querySelector('#count').textContent = await res.text();
  });
</script>
warning

別在 Worker 裡讀狀態

Worker 是無狀態的,而且會同時在很多地方執行。所有的讀寫都要放進 Durable Object 的 fetch 裡——那是唯一能保證「一次一個的順序」與「強一致」的地方。

school

重點概念,以及何時該用它

badge

名稱(Name)

一個好記的字串,例如 "global" 或 "room-42",傳給 idFromName。決定你連到哪個實例。

fingerprint

id(識別碼)

由名稱推導出的全球唯一地址。相同名稱永遠得到相同 id。

settings_remote

Stub(代理)

由 .get(id) 拿到的本地把手。呼叫 stub.fetch() 會透明地連到真正的物件。

looks_one

單一實例

全世界每個名稱剛好只有一個存活的物件——唯一的真相來源。

format_list_numbered

序列化執行

請求在物件內一次跑一個,所以不會有兩個交錯而把狀態弄亂。

verified

強一致性

資料只有一份;每次讀取都看到最新的寫入。永遠不會讀到過時的值。

database

儲存(Storage)

每個物件都有專屬、具交易性的儲存,透過 this.state.storage.get / put 存取。

move_up

Migration(遷移)

wrangler.toml 裡的一個項目,用來登記、改名或移除某個物件類別。

不確定該選哪種儲存?跟著這棵小小的決策樹走一遍。

schema我該用哪種儲存?

不用,只是快取

需要,而且要一致

不是,是大量關聯式資料

需要共享狀態嗎?

用 KV

是單一熱點或協調點嗎?

用 Durable Object

用 D1

tips_and_updates

陷阱、限制與計費

balance

一個物件 = 一個瓶頸

某個名稱的所有流量都匯集到單一實例(約 1,000 req/s 的軟性上限)。全域計數器是教學範例;正式環境請把熱點狀態切分到很多物件——一個房間、一位使用者、或一份文件各一個。

常見錯誤

  • 把值快取在 Worker 而不是從物件讀取——Worker 沒有共享狀態。
  • 拿 KV 來做計數器或鎖——最終一致會讓兩個寫入者遺失一次更新。
  • 忘了寫 [[migrations]] 項目——在類別登記之前部署都會失敗。
  • 以為不同名稱會共用資料——"room-1" 和 "room-2" 是完全分開的兩個實例。
savings

免費就能開始

用 SQLite 儲存的 Durable Objects 在 Workers 免費方案就能跑(限制較低),所以你不用付費就能把這套模型做出來、學起來。計費依據是請求數、運算時間,以及儲存的資料量。