跳到內容

DO SQLite Storage:每個 object 一顆資料庫

查證日期
驗證環境wrangler@4.114.0·workerd@1.20260722.1·compatibility_date: 2026-07-24

每個 Durable Object 有一顆自己的 SQLite 資料庫,跑在和你的程式碼同一台機器上。這帶來三件 D1 給不了的事:

  1. 同步 API。 ctx.storage.kv.get() 不回傳 Promise。sql.exec() 也不用 await。
  2. 真正的 transaction。 第 9 篇實測 D1 的 BEGIN TRANSACTION 會被拒絕,而這裡 transactionSync() 可以做「讀了再決定寫什麼」的原子操作。
  3. 10 GB 的私有資料庫,只屬於這一個物件。

但也有三個會咬人的地方,全部實測過:

  1. deleteAll()DROP TABLE 語意,不是 DELETE FROM 呼叫之後你的表就不見了。
  2. rowsRead 要等 cursor 消費完才是最終值 —— 而那是計費數字。
  3. PITR 在本機完全不能用。

D1 是託管的資料庫服務:你的 Worker 在 A 地,資料庫在 B 地,每次查詢都要跨網路。

DO 的 SQLite 就在物件旁邊。官方對兩者的定位:D1 是 “a managed database product” 而且 “your application code and SQL database queries are not colocated which can impact application performance”;SQLite in Durable Objects 則是 “a lower-level compute with storage building block for distributed systems”,跑在 “the same machine as the SQLite database”。

沒有網路,所以 API 可以是同步的。 這不只是語法糖 —— 它改變了你能寫出什麼樣的程式碼(見下面的 transaction)。

const cursor = ctx.storage.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM items");
const { n } = cursor.one();

實測 cursor 的形狀:

Terminal window
$ curl -s localhost:8787/cursor
{
"cursorProto": ["next","toArray","one","raw","columnNames","rowsRead","rowsWritten","constructor"],
"columnNames": ["id","name","qty"],
"rowsReadBeforeIterating": 1,
"rowsReadAfterToArray": 50,
"rowsWritten": 0,
"rowCount": 50,
"databaseSize": 16384
}

🔴 注意 rowsRead:迭代前是 1,toArray() 之後才是 50。 官方文件說它 “updates during iteration”。這代表:

rowsRead 是計費數字,而它要等 cursor 完全消費完才正確。 想記錄查詢成本(第 9 篇那個 query() 護欄的 DO 版本)就必須先 .toArray().one()

one() 的語意很嚴格:

Terminal window
$ curl -s localhost:8787/one
{
"exactlyOne": { "ok": { "id": 1, "name": "i0", "qty": 0 } },
"zeroRows": { "threw": "Error: Expected exactly one result from SQL query, but got no results." },
"manyRows": { "threw": "Error: Expected exactly one result from SQL query, but got multiple results." },
"raw": [[1, "i0"], [2, "i1"]],
"rawHasToArray": "function"
}

不是「取第一筆」,是「必須剛好一筆,否則 throw」。 要取第一筆用 toArray()[0] 或加 LIMIT 1

⚠️ 型別與文件的落差:文件說 raw() 回傳一個有 toArray()RawIterator,但 wrangler types 產生的型別是 IterableIterator<U>(沒有 toArray)。實測 runtime 上 toArray 確實存在rawHasToArray: "function")。這次是型別比實際窄 —— 用展開運算子 [...cursor.raw()] 兩邊都過得了。

官方警告值得完整引用:

「A cursor resumed after an await may observe rows inserted, updated, or deleted after the cursor was created」

配合第 14 篇的結論(非 storage 的 await 會讓其他事件進來),規則是:

// ❌ Another event can mutate the table while this cursor is suspended.
for (const row of ctx.storage.sql.exec("SELECT * FROM items")) {
await someExternalCall(row);
}
// ✅ Materialise first, then do the slow work.
const rows = ctx.storage.sql.exec("SELECT * FROM items").toArray();
for (const row of rows) await someExternalCall(row);

在任何 await 之前把 cursor 消費完。 這同時解決了 rowsRead 的問題。

SQLite-backed 專屬,而且完全沒有 Promise

Terminal window
$ curl -s localhost:8787/kv
{
"kvProto": ["get","list","put","delete","constructor"],
"getA": { "n": 1 },
"getAIsSync": true,
"missing": null,
"deleteExisting": true,
"deleteMissing": false,
"listed": [["a",{"n":1}], ["b","plain string"], ["c",42]],
"tables": ["items", "__miniflare_do_name", "_cf_KV"]
}
ctx.storage.kv.put("cursor", { page: 3 }); // no await
const v = ctx.storage.kv.get("cursor"); // no await
const existed = ctx.storage.kv.delete("cursor"); // boolean, not Promise
for (const [k, v] of ctx.storage.kv.list({ prefix: "user:" })) { /* ... */ }

list() 的選項:startstartAfterendprefixreverselimit,回傳按 key 升冪排序。

注意 tables 裡的 _cf_KV —— 那就是同步 KV 資料實際存放的隱藏表。它看得到但不能用 SQL 查詢。(__miniflare_do_name 是本機開發的產物,production 沒有。)

用 SQL用同步 KV
需要查詢、排序、聚合單純的 key → value
需要唯一約束只是存個游標、設定、旗標
資料量大、需要索引少量、扁平

實務上兩個常常並用:SQL 存主資料,KV 存「上次同步到哪」這種小狀態。

legacy 的非同步 KV API 沒有被棄用

Section titled “legacy 的非同步 KV API 沒有被棄用”

ctx.storage.get() / .put() / .delete() / .list()(回傳 Promise)在兩種後端上都還在,官方沒有標為棄用,只說「Cloudflare recommends all new Durable Object namespaces use the SQLite storage backend」。

新程式碼用同步版本,但看到舊程式碼用非同步版本不代表它壞了。

這是 DO storage 相對 D1 最大的優勢。

第 9 篇實測過,D1 的 BEGIN TRANSACTION 會拿到一個提到 state.storage.transaction() 的困惑錯誤 —— 困惑是因為那個 API 在 D1 binding 上根本不存在。

在 DO 裡,同樣的錯誤訊息終於說得通了

Terminal window
$ curl -s localhost:8787/rawtx
{
"begin": { "threw": "Error: To execute a transaction, please use the state.storage.transaction() or state.storage.transactionSync() APIs instead of the SQL BEGIN TRANSACTION or SAVEPOINT statements. ..." },
"savepoint": { "threw": "(同上)" },
"asyncTransaction": { "ok": "ok" }
}

transactionSync() 實測:

Terminal window
$ curl -s localhost:8787/tx
{
"before": 50,
"after": 50,
"rolledBack": { "threw": "Error: UNIQUE constraint failed: items.name: SQLITE_CONSTRAINT ..." },
"orphanSurvived": false,
"committed": { "ok": 100 }
}

兩件事:

① 回滾是真的。 第一個 INSERT 本來會成功,第二個違反唯一約束,整個交易回滾(beforeafter 都是 50,orphanSurvived: false)。

② 讀了再決定寫什麼,在同一個交易裡。

ctx.storage.transactionSync(() => {
const qty = ctx.storage.sql.exec<{ qty: number }>(
"SELECT qty FROM items WHERE name = 'i0'"
).one().qty;
ctx.storage.sql.exec("UPDATE items SET qty = ? WHERE name = 'i0'", qty + 100);
return qty + 100;
});

這是 D1 完全做不到的事(第 9 篇:batch() 的各條語句不能互相依賴結果)。這也是第 13 篇決策樹裡「需要原子操作」那條路徑指向 DO 的原因。

限制:callback 必須是同步的 —— 官方原文「it should not be declared async nor otherwise return a Promise」。所以交易裡不能打外部 API。需要跨越外部 I/O 的原子性,回去看第 14 篇的 blockConcurrencyWhile()

還有一個 async 版的 transaction()(兩種後端都有),但官方現在的說法是:

「Explicit transactions are no longer necessary. Any series of write operations with no intervening await will automatically be submitted atomically.」

預設心智模型是隱式交易:中間沒有 await 的一串寫入自動就是原子的(這是第 14 篇 output gate 的直接後果)。顯式交易是為了把讀取也framed進回滾邊界。

Terminal window
$ curl -s localhost:8787/deleteall
{
"tablesBefore": ["items", "__miniflare_do_name"],
"rowsBefore": 0,
"queryAfterDeleteAll": { "threw": "Error: no such table: items: SQLITE_ERROR" },
"tablesAfter": []
}

表整個消失了。 官方描述是「Removes the entire contents of a Durable Object’s private SQLite database」—— 「entire contents」包括 schema。

實務後果:

// ❌ Everything after this line throws "no such table".
await this.ctx.storage.deleteAll();
this.ctx.storage.sql.exec("INSERT INTO items ...");

呼叫 deleteAll() 之後必須重建 schema,而且 constructor 已經跑過了不會再跑一次。兩種做法:

// A. Re-run schema creation explicitly.
private ensureSchema() {
this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS items (...) STRICT`);
}
async wipe() {
await this.ctx.storage.deleteAll();
this.ensureSchema();
}
// B. Or abort the object so the constructor runs again.
async wipe() {
await this.ctx.storage.deleteAll();
this.ctx.abort();
}

把 schema 建立抽成一個可重複呼叫的方法(而不是只寫在 constructor 裡)是比較穩健的做法。

⚠️ 2026-02-24 起 deleteAll() 也會刪掉 alarm(compat flag delete_all_deletes_alarm,該日期後預設)。第 17 篇會談這對「自毀」模式的影響。

Terminal window
$ curl -s localhost:8787/pitr
{
"current": { "ok": "00000000-00000000-00000000-00000000000000000000000000000000" },
"forTimeNow": { "threw": "Error: This Durable Object's storage back-end does not implement point-in-time recovery." },
"forTime10DaysAgo": { "threw": "(同上)" },
"forTime100DaysAgo": { "threw": "(同上)" },
"storageProto": ["get","list","put","delete","deleteAll","transaction","getAlarm","setAlarm",
"deleteAlarm","sync","transactionSync","getCurrentBookmark",
"getBookmarkForTime","onNextSessionRestoreBookmark","constructor"]
}

本機完全不支援。 getCurrentBookmark() 回傳全零,getBookmarkForTime() 直接 throw。

Production 的用法:

// 1. Find the bookmark for a point in time (within the last 30 days).
const bookmark = await ctx.storage.getBookmarkForTime(Date.now() - 3600_000);
// 2. Arm the restore. It takes effect on the NEXT session, not now.
await ctx.storage.onNextSessionRestoreBookmark(bookmark);
// 3. Restart the object so the restore happens.
ctx.abort();

保留視窗 30 天。注意第 2 步的語意 —— 它不會立刻還原,是在下一次物件啟動時生效,所以要用 ctx.abort() 主動重啟。

這是 DO 的「Time Travel」,對照 D1 的同名功能(第 9 篇,Free 7 天 / Paid 30 天)。

比 baseline SQLite 多的:FTS5(含 fts5vocab)、JSON 擴充、數學函式

禁止的:BEGIN TRANSACTIONSAVEPOINT(用上面的 transaction API)。

⚠️ 不要說它和 D1 是同一個 SQLite 子集。 官方只說「SQL query pricing and limits are intended to be identical between D1」—— 那是計費與限制的宣稱,不是功能對等。我也找不到官方公布的「不支援的 SQLite 功能 / PRAGMA」清單。有疑慮就實測。

項目
單一物件儲存10 GB
每表欄位數100
單一字串 / BLOB / 列2 MB
SQL statement 長度100 KB
綁定參數100
LIKE / GLOB pattern50 bytes
儲存滿了寫入失敗 SQLITE_FULL,讀取與刪除仍可用

計費(Paid,含免費額度之後):

費率免費額度
Rows read$0.001 / 百萬含 250 億 / 月
Rows written$1.00 / 百萬含 5,000 萬 / 月
儲存$0.20 / GB-月含 5 GB-月

兩個計費細節:virtual table 的寫入也算 row write,而且索引更新算額外的 row write。所以 DO 裡的索引和 D1 一樣不是免費的 —— 只是這裡代價出現在寫入端。

SQLite 儲存計費從 2026 年 1 月開始生效。


完整程式碼:examples/ch15-do-storage/

Terminal window
cd examples/ch15-do-storage && npm install && npm run dev
Terminal window
B=localhost:8787
curl -s "$B/seed"
curl -s "$B/cursor" # rowsRead 迭代前 1、之後 50
curl -s "$B/one" # one() 的嚴格語意
curl -s "$B/kv" # 同步 API + _cf_KV 隱藏表
curl -s "$B/tx" # 真的回滾 + 讀後寫
curl -s "$B/rawtx" # BEGIN / SAVEPOINT 被禁止
curl -s "$B/pitr" # 本機不支援
curl -s "$B/deleteall" # 表被 DROP 掉

練習:在 transactionSyncDemo 的 callback 裡加一個 await,看它會怎麼樣 —— 官方說 callback 不能是 async。


第 14 篇決定了切分鍵是 link:{linkId}。這一篇把 LinkCounter 的儲存定案。

private ensureSchema() {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS clicks (
bucket INTEGER PRIMARY KEY, -- unix ms, floored to the minute
n INTEGER NOT NULL DEFAULT 0
) STRICT`);
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS geo (
bucket INTEGER NOT NULL,
country TEXT NOT NULL,
n INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (bucket, country)
) STRICT`);
}
constructor(ctx: DurableObjectState, env: CloudflareBindings) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => this.ensureSchema());
}

ensureSchema() 是獨立方法,因為 deleteAll() 之後要能重建。

async record(country: string): Promise<void> {
const bucket = Math.floor(Date.now() / 60_000) * 60_000;
// Additive. No read, so no lost update (chapter 14).
this.ctx.storage.sql.exec(
`INSERT INTO clicks (bucket, n) VALUES (?, 1)
ON CONFLICT(bucket) DO UPDATE SET n = n + 1`,
bucket,
);
this.ctx.storage.sql.exec(
`INSERT INTO geo (bucket, country, n) VALUES (?, ?, 1)
ON CONFLICT(bucket, country) DO UPDATE SET n = n + 1`,
bucket, country,
);
// No await between the two writes -> implicitly atomic, and the output gate
// means no response can escape before both are durable.
this.ctx.storage.setAlarm(Date.now() + 30_000); // chapter 17
}

三個決策:

ON CONFLICT ... DO UPDATE SET n = n + 1 —— 純累加。第 14 篇實測過 read-modify-write 會遺失更新。

② 兩個寫入之間沒有 await —— 依照官方的「Any series of write operations with no intervening await will automatically be submitted atomically」,這兩筆自動是原子的,不需要顯式交易。

③ 按分鐘分桶(bucket)而不是逐筆存。 一個熱門連結每天可能有百萬次點擊;逐筆存會撞上 10 GB 上限,而且 row write 的計費會很難看。分桶把每天的列數壓到 1,440 筆。

點擊記錄不需要(純累加)。但租戶配額需要:

// TenantQuota DO — read, decide, write, atomically.
async consume(n: number): Promise<{ ok: boolean; remaining: number }> {
return this.ctx.storage.transactionSync(() => {
const { remaining } = this.ctx.storage.sql
.exec<{ remaining: number }>("SELECT remaining FROM quota WHERE id = 1")
.one();
if (remaining < n) return { ok: false, remaining };
this.ctx.storage.sql.exec("UPDATE quota SET remaining = remaining - ? WHERE id = 1", n);
return { ok: true, remaining: remaining - n };
});
}

「檢查夠不夠,夠才扣」 就是那個 D1 做不到的形狀。

第 9 篇在 D1 上做過,DO 版本要注意 rowsRead 的時機:

private query<T extends Record<string, SqlStorageValue>>(sql: string, ...params: unknown[]): T[] {
const cursor = this.ctx.storage.sql.exec<T>(sql, ...params);
const rows = cursor.toArray(); // MUST consume before reading rowsRead
if (cursor.rowsRead > 1000) {
console.log(JSON.stringify({
event: "do_slow_query", do: this.ctx.id.name, rows_read: cursor.rowsRead,
sql: sql.slice(0, 200),
}));
}
return rows;
}

本篇交付物LinkCounter 的完整 schema 與 record()TenantQuotaconsume()ensureSchema() 模式、以及 DO 版的查詢成本護欄。


① 以為 deleteAll() 只清資料

它會 DROP 掉表。之後所有 SQL 都會 no such table。把 schema 建立抽成可重複呼叫的方法。

② cursor 跨越 await

可能看到別的事件寫入的資料。先 toArray()

③ 在消費完 cursor 之前讀 rowsRead

實測迭代前是 1、之後才是 50。那是計費數字。

④ 用 one() 當「取第一筆」

不是剛好一筆就 throw。

⑤ 在 transactionSync() 的 callback 裡 await

官方明說不能是 async。要跨外部 I/O 用 blockConcurrencyWhile()

⑥ 用 BEGIN TRANSACTION / SAVEPOINT

被禁止。用 transactionSync() / transaction()

⑦ 期待 PITR 在本機能用

getBookmarkForTime() 直接 throw,getCurrentBookmark() 回傳全零。

⑧ 逐筆存高頻事件

10 GB 上限 + row write 計費。分桶。

⑨ 以為索引是免費的

索引更新算額外的 row write。

⑩ 假設它和 D1 的 SQLite 子集完全相同

官方只保證計費與限制相同,沒保證功能對等。


  1. **deleteAll()DROP 不是 DELETE。**把 schema 建立寫成可重複呼叫的方法。
  2. transactionSync() 能做「讀了再決定寫什麼」 —— 那正是 D1 做不到、也是選 DO 的理由。
  3. **cursor 要在任何 await 之前消費完。**否則資料可能變,而且 rowsRead 不準。


下一篇16. WebSocket Hibernation:讓一萬條連線幾乎不花錢 —— 兩種 WebSocket 寫法的帳單差好幾個數量級,而其中一種會讓 setTimeout 悄悄毀掉你的省錢策略。