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.storage的 KV-style API(get/put/delete/list)與 SQL API(ctx.storage.sql.exec)各自的定位與取捨 - 原子交易——用
transactionSync把多個寫入綁成一個不可分割的操作,避免中途出錯造成資料不一致 - 啟用 SQLite backend——為何
wrangler.jsonc的migrations必須用new_sqlite_classes,以及它跟舊的new_classes的差別 - DO+SQLite vs D1 的選型——何時該用「每個物件一個私有 DB」,何時該用「全域共享的 D1」,以及 10 GB 的儲存限制
核心概念
ctx.storage:一個入口,兩種介面
先建立最重要的心智模型:ctx.storage 底層永遠是同一個 SQLite 資料庫,但它對外暴露兩種操作介面。很多人以為 KV-style 與 SQL 是「兩套獨立的儲存」,其實不是——它們讀寫的是同一份資料、共享同一套交易與備份保證,只是抽象層級不同。
| 介面 | 入口 | 同步/非同步 | 定位 |
|---|---|---|---|
| KV-style API | ctx.storage.get/put/delete/list | 非同步(await) | 把 SQLite 當成隱藏的 key-value 表,存簡單扁平狀態 |
| SQL API | ctx.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 或前綴掃描,無法 WHERE、JOIN、聚合。
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。它們的資料表結構可以相同,但資料彼此完全隔離。
這帶來三個直接好處:
- 天生分片(sharding):你不需要在一張大表裡塞進所有房間的資料再用
WHERE room_id = ?過濾——每個房間的資料本來就在自己的 DB 裡,零競爭、零干擾。 - 讀寫共置、延遲趨近零:資料與運算在同一處,SQL 是同步執行的,沒有跨網路的資料庫連線。
- 強一致 + 序列化:承接上一篇的單執行緒序列化——同一個 DO 內對它私有 SQLite 的所有讀寫都排隊逐一進行,天然沒有並發衝突。
代價則是它的結構性限制:你無法輕易對「所有 DO 的資料」做一次全域查詢(資料散在成千上萬個獨立 SQLite 裡)。這正是後面要談的「何時該改用 D1」的分水嶺。
換個角度看,這其實是把「分片(sharding)」這件在傳統資料庫裡極其棘手的工程,變成了架構的預設行為。在單一大型資料庫裡,當某張熱門表成長到單機扛不住時,你得手動設計分片鍵、搬移資料、處理跨分片交易——這往往是後期最痛的重構。而 DO 的模型從第一天起就是「一個實體 = 一個資料庫」,每新增一個房間、一個使用者,就是新增一個獨立的、輕量的 SQLite,不需要任何額外配置。你付出的代價,只是接受「不做跨實體全域查詢」這個約束——而這個約束對絕大多數「按實體協調狀態」的場景來說,根本不是問題。
關鍵術語速查
| 術語 | 一句話定義 |
|---|---|
ctx.storage | DO 的儲存入口,同時提供 KV-style 與 SQL 兩種介面 |
ctx.storage.sql | SQL API 的命名空間,僅在 SQLite backend 下存在 |
exec(sql, ...params) | 執行同步 SQL,回傳可迭代的 SqlStorageCursor |
SqlStorageCursor | 查詢結果游標,需在下個 await 前用 toArray()/for...of 消耗完 |
transactionSync(fn) | 同步原子交易:fn 內的寫入全部成功或全部回滾 |
new_sqlite_classes | wrangler.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:workers 的 DurableObject。我們在 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 EXISTS 與 CREATE 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 BY、LIMIT 正是 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 + SQLite | 用 D1 |
|---|---|---|
| 資料歸屬 | 天然按實體切分(每房間/使用者一份) | 全域單一邏輯資料庫 |
| 查詢範圍 | 只查「自己這個實體」的資料 | 需要跨實體/全域聚合、JOIN |
| 讀寫延遲 | 與運算共置,趨近零 | 一般資料庫查詢延遲 |
| 一致性 | 單執行緒序列化,強一致 | 一般關聯式交易 |
| 典型場景 | 聊天室訊息、遊戲局狀態、session | 使用者總表、商品目錄、後台報表 |
| 容量 | 每個 DO 10 GB | 每個 D1 資料庫更大的容量規模 |
最佳實踐小結:記牢——先在 wrangler.jsonc 用 new_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.setAlarm 與 alarm() handler,看每個 DO 如何擁有自己獨立、可靠重試的定時器。
想先查閱官方對 Durable Objects 儲存 API 的完整說明,可以隨時參考 Cloudflare Durable Objects Storage API 官方文件。有結構的有狀態儲存已經到手,我們下一篇《DO Alarms 定時器》見。