DO SQLite Storage:每個物件內建的資料庫 | Cloudflare 完整教學

2026/08/24
DO SQLite Storage:每個物件內建的資料庫 | Cloudflare 完整教學

上一篇《Durable Objects 入門》,我們用最簡單的 ctx.storage.get/put 讓計數器把值存下來。但那只是冰山一角——Cloudflare 的每個 Durable Object 其實都內建一個完整的 SQLite 資料庫,能跑同步 SQL、建索引、做原子交易,而且讀寫延遲趨近於零。這一篇,我們深入 ctx.storage:分清 KV-style API(get/put)與 SQL API(ctx.storage.sql.exec)、學會用 transactionSync 做原子交易、用 new_sqlite_classes 啟用 SQLite backend,並把最重要的一個決策講透:DO+SQLite 與 D1 到底該怎麼分工

前言

Durable Objects(持久物件,簡稱 DO) 裡,儲存(storage) 不是外接的附屬品,而是與運算共置(co-located) 的核心能力。每個 DO 實例都自帶一個內建的 SQLite 資料庫——資料就住在執行你程式碼的同一個地方,不需要跨網路呼叫外部資料庫,讀寫延遲趨近於零。你透過 ctx.storage(在類別內即 this.ctx.storage)這個統一入口存取它。

打個比方。傳統的無伺服器架構就像在辦公室工作、資料卻放在城市另一端的中央倉庫:每次要拿一份文件,都得派人跑一趟,來回耗時。而 DO 的內建 SQLite 則像是每張辦公桌旁都有一個專屬的小型檔案櫃:你要的資料就在手邊,伸手就拿到,而且這個檔案櫃只屬於你這張桌子(每個 DO 私有),別人不會來翻。更棒的是,這個檔案櫃不只是隨手塞紙的抽屜,它是一個有分類、有索引、能做複雜檢索的完整資料庫——這正是 SQLite backend 的價值。

這篇聚焦在「DO 的儲存」這一個主題(DO 的定位、RPC、生命週期已在上一篇講過,這裡不重複)。讀完你會掌握:

  • 兩種 API 的分工——ctx.storageKV-style API(get/put/delete/list)與 SQL API(ctx.storage.sql.exec)各自的定位與取捨
  • 原子交易——用 transactionSync 把多個寫入綁成一個不可分割的操作,避免中途出錯造成資料不一致
  • 啟用 SQLite backend——為何 wrangler.jsoncmigrations 必須用 new_sqlite_classes,以及它跟舊的 new_classes 的差別
  • DO+SQLite vs D1 的選型——何時該用「每個物件一個私有 DB」,何時該用「全域共享的 D1」,以及 10 GB 的儲存限制

核心概念

ctx.storage:一個入口,兩種介面

先建立最重要的心智模型:ctx.storage 底層永遠是同一個 SQLite 資料庫,但它對外暴露兩種操作介面。很多人以為 KV-style 與 SQL 是「兩套獨立的儲存」,其實不是——它們讀寫的是同一份資料、共享同一套交易與備份保證,只是抽象層級不同

介面入口同步/非同步定位
KV-style APIctx.storage.get/put/delete/list非同步(await)把 SQLite 當成隱藏的 key-value 表,存簡單扁平狀態
SQL APIctx.storage.sql.exec(...)同步(不需 await)完整 SQLite,自己建表、下 SQL,能查詢/索引/聚合

KV-style API 是最簡單的鍵值介面。你用 put(key, value) 存、get(key) 取、delete(key) 刪、list() 掃描,值會自動序列化(支援 structured clone)。它適合「狀態只是幾個值」的場景:計數器的當前值、一個設定物件、一組旗標。限制是:單個 key 最大 2 KiB、單個 value 最大 128 KiB,而且查詢能力有限——你只能靠 key 或前綴掃描,無法 WHEREJOIN、聚合。

SQL API 則把整個 SQLite 交到你手上。你透過 ctx.storage.sql.exec(sql, ...params) 執行同步 SQL:建表、插入、查詢、建索引、做聚合統計都行。注意「同步」這個關鍵字——exec 不回傳 Promise,你不需要 await 它(因為資料就在本地、沒有網路往返)。它適合「有結構、需要查詢」的資料:聊天訊息歷史、訂單明細、遊戲事件流。

一句話選法:狀態只是「幾個值」就用 KV-style;一旦你想「查詢、過濾、統計」,就用 SQL API 建真正的表。 兩者可以在同一個 DO 裡自由混用。

值得強調的是,即便你全程只用 KV-style API,底層依然是 SQLite——put/get 實際上是對一張平台隱藏維護的鍵值表做插入與查詢。這也是為什麼兩種介面能共享同一套交易語義時間點復原(PITR,可還原至最近 30 天內任意時間點):它們寫的本來就是同一個資料庫檔案。理解這一點,你就不會再把「KV 儲存」與「SQLite 儲存」當成兩個要二選一的產品,而是同一份儲存的兩種便利度不同的操作方式。

為什麼是「每個 DO 一個資料庫」?

這是 DO 儲存最反直覺、也最強大的地方:SQLite 資料庫不是全域共享的,而是「每個 DO 實例私有」的。名稱為 "room-42" 的 DO 有它自己的一整個 SQLite;"room-99" 又是另一個完全獨立的 SQLite。它們的資料表結構可以相同,但資料彼此完全隔離

這帶來三個直接好處:

  1. 天生分片(sharding):你不需要在一張大表裡塞進所有房間的資料再用 WHERE room_id = ? 過濾——每個房間的資料本來就在自己的 DB 裡,零競爭、零干擾。
  2. 讀寫共置、延遲趨近零:資料與運算在同一處,SQL 是同步執行的,沒有跨網路的資料庫連線。
  3. 強一致 + 序列化:承接上一篇的單執行緒序列化——同一個 DO 內對它私有 SQLite 的所有讀寫都排隊逐一進行,天然沒有並發衝突。

代價則是它的結構性限制:你無法輕易對「所有 DO 的資料」做一次全域查詢(資料散在成千上萬個獨立 SQLite 裡)。這正是後面要談的「何時該改用 D1」的分水嶺。

換個角度看,這其實是把「分片(sharding)」這件在傳統資料庫裡極其棘手的工程,變成了架構的預設行為。在單一大型資料庫裡,當某張熱門表成長到單機扛不住時,你得手動設計分片鍵、搬移資料、處理跨分片交易——這往往是後期最痛的重構。而 DO 的模型從第一天起就是「一個實體 = 一個資料庫」,每新增一個房間、一個使用者,就是新增一個獨立的、輕量的 SQLite,不需要任何額外配置。你付出的代價,只是接受「不做跨實體全域查詢」這個約束——而這個約束對絕大多數「按實體協調狀態」的場景來說,根本不是問題。

關鍵術語速查

術語一句話定義
ctx.storageDO 的儲存入口,同時提供 KV-style 與 SQL 兩種介面
ctx.storage.sqlSQL API 的命名空間,僅在 SQLite backend 下存在
exec(sql, ...params)執行同步 SQL,回傳可迭代的 SqlStorageCursor
SqlStorageCursor查詢結果游標,需在下個 await 前用 toArray()/for...of 消耗完
transactionSync(fn)同步原子交易:fn 內的寫入全部成功或全部回滾
new_sqlite_classeswrangler.jsonc migration 欄位,宣告 DO 類別使用 SQLite backend

實作範例

我們把上一篇的計數器升級成一個有結構的留言板 DO:每個「看板」是一個 DO,內建 SQLite 存留言。這個例子完整涵蓋「啟用 SQLite backend、建表、KV-style 與 SQL 混用、原子交易」四件事。

1. wrangler.jsonc:用 new_sqlite_classes 啟用 SQLite backend

這是一切的前提:ctx.storage.sql 只有在 DO 使用 SQLite storage backend 時才存在,而這由 migrations 決定。你必須new_sqlite_classes(而非舊的 new_classes)來建立這個 DO 類別。

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

  "durable_objects": {
    "bindings": [
      { "name": "BOARD", "class_name": "Board" }
    ]
  },

  "migrations": [
    {
      "tag": "v1",
      // ✅ new_sqlite_classes:啟用 SQLite backend(ctx.storage.sql 才會存在)
      // ❌ 若寫成 new_classes,則是舊的 KV backend,sql 會是 undefined
      // 免費方案本來也只支援 SQLite backend
      "new_sqlite_classes": ["Board"]
    }
  ]
}

記住這個因果鏈:new_sqlite_classes → SQLite backend → ctx.storage.sql 可用。少了第一步,後面所有 SQL 程式碼都會在執行期報「Cannot read properties of undefined」。

2. 在 constructor 建表(SQL API)

DO 類別繼承自 cloudflare:workersDurableObject。我們在 constructor 裡用 blockConcurrencyWhile 確保建表在接受任何請求之前完成——CREATE TABLE IF NOT EXISTS 是冪等的,重複執行不會出錯。

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

export interface Env {
  BOARD: DurableObjectNamespace<Board>;
}

// 一筆留言的型別(用於 exec 的泛型,讓查詢結果有型別)
interface Message {
  id: number;
  author: string;
  content: string;
  created_at: number;
}

export class Board extends DurableObject<Env> {
  // 直接把 sql 拉出來,程式碼更簡潔
  private sql = this.ctx.storage.sql;

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    // 只在初始化時 block:建表 + 建索引,完成前不接受請求
    ctx.blockConcurrencyWhile(async () => {
      // exec 是同步的,不需要 await
      this.sql.exec(`
        CREATE TABLE IF NOT EXISTS messages (
          id         INTEGER PRIMARY KEY AUTOINCREMENT,
          author     TEXT NOT NULL,
          content    TEXT NOT NULL,
          created_at INTEGER NOT NULL
        );
      `);
      // 建索引:加速「按時間倒序取最新留言」
      this.sql.exec(
        `CREATE INDEX IF NOT EXISTS idx_created ON messages(created_at DESC);`
      );
    });
  }
}

重點:this.sql.exec(...) 沒有 await——SQL 在本地同步執行。CREATE TABLE IF NOT EXISTSCREATE INDEX IF NOT EXISTS 都是冪等的,所以每次 DO 被喚醒重跑 constructor 都安全。

3. 查詢與寫入:exec 與游標消耗

exec 回傳一個 SqlStorageCursor(游標)。你用 .toArray() 一次取全部、.one() 取剛好一列(沒有或多於一列會 throw),或用 for...of 迭代。關鍵規則:在下一個 await 之前,必須把游標消耗完,否則它會失效。參數一律用 ? 佔位並帶入 ...params,永遠不要用字串拼接 SQL(防注入)。

// 接續 Board 類別

  // 新增留言,回傳這則留言的 id(RPC 方法)
  async post(author: string, content: string): Promise<number> {
    const now = Date.now();
    // 用 RETURNING 取回自動產生的 id;.one() 取剛好一列
    const row = this.sql
      .exec<{ id: number }>(
        `INSERT INTO messages (author, content, created_at)
         VALUES (?, ?, ?) RETURNING id;`,
        author,
        content,
        now
      )
      .one();
    return row.id;
  }

  // 取最新 N 則留言(RPC 方法)
  async latest(limit = 20): Promise<Message[]> {
    // 泛型 <Message> 讓 toArray() 的結果有型別
    return this.sql
      .exec<Message>(
        `SELECT id, author, content, created_at
         FROM messages
         ORDER BY created_at DESC
         LIMIT ?;`,
        limit
      )
      .toArray(); // 立即消耗游標為陣列
  }

  // 統計留言總數(聚合查詢,展示 SQL API 相對 KV 的優勢)
  async count(): Promise<number> {
    const { c } = this.sql
      .exec<{ c: number }>(`SELECT COUNT(*) AS c FROM messages;`)
      .one();
    return c;
  }

上面的 COUNT(*)ORDER BYLIMIT 正是 KV-style API 做不到的——這就是「一旦需要查詢就該用 SQL」的具體體現。

4. transactionSync:原子交易

當一個操作牽涉多個寫入、必須全有或全無時,用 transactionSync。它是同步交易:傳入的函式若正常結束就整批 commit,若中途 throw 就整批 rollback。經典場景是「發文的同時更新統計表」,兩者必須一致。

// 接續 Board 類別

  // 發文 + 同步更新作者的發文計數,兩者原子化
  async postWithStats(author: string, content: string): Promise<void> {
    // transactionSync:內部所有寫入 all-or-nothing
    this.ctx.storage.transactionSync(() => {
      this.sql.exec(
        `INSERT INTO messages (author, content, created_at) VALUES (?, ?, ?);`,
        author,
        content,
        Date.now()
      );
      // 若這一步 throw(例如觸發 CHECK 約束),上面的 INSERT 也會被回滾
      this.sql.exec(
        `INSERT INTO author_stats (author, posts) VALUES (?, 1)
         ON CONFLICT(author) DO UPDATE SET posts = posts + 1;`,
        author
      );
    });
    // 出了這個函式,兩筆寫入要嘛都在、要嘛都不在,絕不會只做一半
  }

因為 DO 是單執行緒序列化的,transactionSync 內不會有別的請求插進來動同一份資料——這讓它比傳統資料庫的交易更單純、更好推理。傳統資料庫的交易要處理隔離級別、鎖競爭、死鎖偵測,是因為多個連線可能並行操作同一份資料;但在 DO 裡,同一時間點只有一段程式碼在跑,transactionSync 的作用純粹是「原子性(全有或全無)」與「一致性(中途出錯自動回滾)」,你不必再操心並發隔離的那一整套複雜度。(KV-style API 也有對應的非同步交易 ctx.storage.transaction(async txn => {...}),用於純 KV 場景。)

5. KV-style 與 SQL 混用

同一個 DO 裡,結構化資料放 SQL 表,而「幾個扁平的值」(如看板設定)用 KV-style 更省事。它們共存於同一個底層 SQLite,共享交易與備份保證。

// 接續 Board 類別

  // 看板設定用 KV-style:簡單、非同步、無需建表
  async setTitle(title: string): Promise<void> {
    await this.ctx.storage.put("title", title); // 注意:KV-style 要 await
  }

  async getTitle(): Promise<string> {
    return (await this.ctx.storage.get<string>("title")) ?? "未命名看板";
  }

從 Worker 呼叫這些 RPC 方法的方式,與上一篇完全相同——env.BOARD.getByName("board:tech") 取得 stub 後直接呼叫,不再贅述。

常見錯誤與最佳實踐

坑一:忘了 new_sqlite_classes,導致 ctx.storage.sql 是 undefined。

這是最高頻的錯誤。你在程式裡寫了一堆 this.ctx.storage.sql.exec(...),執行時卻報「無法讀取 undefined 的屬性」。原因幾乎總是:wrangler.jsonc 的 migration 用了舊的 new_classes(KV backend),或根本沒寫 migration。SQL API 只在 SQLite backend 下存在。

// ❌ 錯誤:new_classes 是 KV backend,ctx.storage.sql 為 undefined
"migrations": [{ "tag": "v1", "new_classes": ["Board"] }]

// ✅ 正確:new_sqlite_classes 才有 SQLite backend
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Board"] }]

補充:現有的 KV-backed 類別無法就地改成 SQLite,那需要另外的遷移流程。所以新專案請一開始就用 new_sqlite_classes(反正免費方案也只支援 SQLite backend)。

坑二:把 DO+SQLite 當成 D1 那樣「全域共享」來用。

新手常誤以為「所有 DO 共用一個大 SQLite」,於是想從某個 DO 撈「全站所有房間的最新留言」。做不到——每個 DO 的 SQLite 是私有且互相隔離的,"room-42" 看不到 "room-99" 的資料。若你的需求是跨實體的全域查詢,那就不是 DO+SQLite 的守備範圍,該用 D1

// ❌ 錯誤心智:以為能在一個 DO 裡查到「所有房間」的資料
// 每個 DO 只有自己的私有 SQLite,查不到別的 DO

// ✅ 正確:全域查詢用 D1(全域共享的關聯式 DB)
// 各房間內部的高頻讀寫用 DO+SQLite(私有、共置、序列化)

坑三:游標沒消耗完就 await,或用字串拼接 SQL。

exec 回傳的游標必須在下一個 await 之前.toArray() / .one() / for...of 消耗完,否則游標失效、資料讀不到。另外,參數一律用 ? 佔位,絕不字串拼接(SQL 注入風險)。

// ❌ 錯誤:字串拼接 → SQL 注入風險;且跨 await 未消耗游標
const cursor = this.sql.exec(`SELECT * FROM messages WHERE author = '${name}'`);
await somethingElse(); // 危險:cursor 可能已失效
const rows = cursor.toArray();

// ✅ 正確:? 佔位 + 立即消耗游標
const rows = this.sql
  .exec<Message>(`SELECT * FROM messages WHERE author = ?;`, name)
  .toArray();

坑四:單一 DO 的 SQLite 塞太大,撞上 10 GB 上限。

每個 DO 的 SQLite 儲存上限是 10 GB。若你把「應該分片的資料」全塞進一個 DO(例如全站留言都進同一個看板 DO),遲早撞牆,而且單一實例也會成為吞吐瓶頸。正解仍是上一篇的核心原則——按實體分片:每個看板、每個使用者、每局遊戲各一個 DO,各自的 SQLite 各自成長,天生水平擴展。

DO+SQLite vs D1 選型總表:

判斷維度DO + SQLiteD1
資料歸屬天然按實體切分(每房間/使用者一份)全域單一邏輯資料庫
查詢範圍只查「自己這個實體」的資料需要跨實體/全域聚合、JOIN
讀寫延遲與運算共置,趨近零一般資料庫查詢延遲
一致性單執行緒序列化,強一致一般關聯式交易
典型場景聊天室訊息、遊戲局狀態、session使用者總表、商品目錄、後台報表
容量每個 DO 10 GB每個 D1 資料庫更大的容量規模

最佳實踐小結:記牢——先在 wrangler.jsoncnew_sqlite_classes 啟用 SQLite backend;簡單扁平的值用 KV-style,有結構要查詢的資料用 SQL API;SQL 用 ? 佔位、游標即時消耗;多寫入要原子化就包 transactionSync;資料按實體分片,別把一個 DO 的 SQLite 養到逼近 10 GB;需要全域跨實體查詢時改用 D1。守住這幾條,你的 DO 儲存層就穩了。

小結

上一篇《Durable Objects 入門》,我們建立了 DO 的心智模型,並用最簡單的 ctx.storage.get/put 讓計數器把值存下來;這一篇,我們把儲存這一層徹底講透:

  • 一個入口,兩種介面——ctx.storage 底層是同一個 SQLite,但同時提供非同步的 KV-style API(get/put/delete/list,存扁平值)與同步的 SQL API(ctx.storage.sql.exec,建表查詢聚合)。
  • 每個 DO 私有一個資料庫——SQLite 綁在實例上、天生分片、讀寫共置、單執行緒序列化強一致;代價是無法做跨 DO 的全域查詢。
  • 啟用與交易——wrangler.jsonc 必須用 new_sqlite_classes 才有 SQL API;多個寫入的原子性用 transactionSync 保證 all-or-nothing。
  • DO+SQLite vs D1——資料按實體切分、只查自己、要低延遲共置 → DO+SQLite;需要全域視角、跨實體聚合 → D1;兩者常搭配使用。單一 DO 儲存上限 10 GB,務必按實體分片。

現在你的 DO 不只能存值,還能建表、查詢、做原子交易,成了一個真正有結構的有狀態服務。但有一種能力我們還沒碰:讓 DO 在未來某個時間點自己醒來做事——批次聚合、TTL 過期清理、到期提醒。下一篇《DO Alarms 定時器》,我們就深入 ctx.storage.setAlarmalarm() handler,看每個 DO 如何擁有自己獨立、可靠重試的定時器。

想先查閱官方對 Durable Objects 儲存 API 的完整說明,可以隨時參考 Cloudflare Durable Objects Storage API 官方文件。有結構的有狀態儲存已經到手,我們下一篇《DO Alarms 定時器》見。

BenZ Software Developer

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

本週主打

AI 自動化入門包

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

看看這個產品 →