DO SQLite Storage:每個 object 一顆資料庫
這篇要解決的問題
Section titled “這篇要解決的問題”每個 Durable Object 有一顆自己的 SQLite 資料庫,跑在和你的程式碼同一台機器上。這帶來三件 D1 給不了的事:
- 同步 API。
ctx.storage.kv.get()不回傳 Promise。sql.exec()也不用 await。 - 真正的 transaction。 第 9 篇實測 D1 的
BEGIN TRANSACTION會被拒絕,而這裡transactionSync()可以做「讀了再決定寫什麼」的原子操作。 - 10 GB 的私有資料庫,只屬於這一個物件。
但也有三個會咬人的地方,全部實測過:
deleteAll()是DROP TABLE語意,不是DELETE FROM。 呼叫之後你的表就不見了。rowsRead要等 cursor 消費完才是最終值 —— 而那是計費數字。- PITR 在本機完全不能用。
為什麼「同一台機器」很重要
Section titled “為什麼「同一台機器」很重要”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)。
兩套 API:SQL 與同步 KV
Section titled “兩套 API:SQL 與同步 KV”const cursor = ctx.storage.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM items");const { n } = cursor.one();實測 cursor 的形狀:
$ 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() 的語意很嚴格:
$ 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()]兩邊都過得了。
🔴 cursor 不要跨越 await
Section titled “🔴 cursor 不要跨越 await”官方警告值得完整引用:
「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:
$ 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 awaitconst v = ctx.storage.kv.get("cursor"); // no awaitconst existed = ctx.storage.kv.delete("cursor"); // boolean, not Promisefor (const [k, v] of ctx.storage.kv.list({ prefix: "user:" })) { /* ... */ }list() 的選項:start、startAfter、end、prefix、reverse、limit,回傳按 key 升冪排序。
注意 tables 裡的 _cf_KV —— 那就是同步 KV 資料實際存放的隱藏表。它看得到但不能用 SQL 查詢。(__miniflare_do_name 是本機開發的產物,production 沒有。)
什麼時候用哪個
Section titled “什麼時候用哪個”| 用 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」。
新程式碼用同步版本,但看到舊程式碼用非同步版本不代表它壞了。
🎯 真正的 transaction
Section titled “🎯 真正的 transaction”這是 DO storage 相對 D1 最大的優勢。
第 9 篇實測過,D1 的 BEGIN TRANSACTION 會拿到一個提到 state.storage.transaction() 的困惑錯誤 —— 困惑是因為那個 API 在 D1 binding 上根本不存在。
在 DO 裡,同樣的錯誤訊息終於說得通了:
$ 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() 實測:
$ 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 本來會成功,第二個違反唯一約束,整個交易回滾(before 和 after 都是 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
awaitwill automatically be submitted atomically.」
預設心智模型是隱式交易:中間沒有 await 的一串寫入自動就是原子的(這是第 14 篇 output gate 的直接後果)。顯式交易是為了把讀取也framed進回滾邊界。
🔴 deleteAll() 會把表 DROP 掉
Section titled “🔴 deleteAll() 會把表 DROP 掉”$ 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 flagdelete_all_deletes_alarm,該日期後預設)。第 17 篇會談這對「自毀」模式的影響。
PITR:production only
Section titled “PITR:production only”$ 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 天)。
SQL 功能範圍
Section titled “SQL 功能範圍”比 baseline SQLite 多的:FTS5(含 fts5vocab)、JSON 擴充、數學函式。
禁止的:BEGIN TRANSACTION、SAVEPOINT(用上面的 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 pattern | 50 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/
cd examples/ch15-do-storage && npm install && npm run devB=localhost:8787curl -s "$B/seed"curl -s "$B/cursor" # rowsRead 迭代前 1、之後 50curl -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。
接進 LinkForge
Section titled “接進 LinkForge”第 14 篇決定了切分鍵是 link:{linkId}。這一篇把 LinkCounter 的儲存定案。
Schema
Section titled “Schema”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() 之後要能重建。
累加而非 read-modify-write
Section titled “累加而非 read-modify-write”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 筆。
什麼時候用 transactionSync()
Section titled “什麼時候用 transactionSync()”點擊記錄不需要(純累加)。但租戶配額需要:
// 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 做不到的形狀。
護欄:記錄昂貴的查詢
Section titled “護欄:記錄昂貴的查詢”第 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()、TenantQuota 的 consume()、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 子集完全相同
官方只保證計費與限制相同,沒保證功能對等。
本篇要記住的三句話
Section titled “本篇要記住的三句話”- **
deleteAll()是DROP不是DELETE。**把 schema 建立寫成可重複呼叫的方法。 transactionSync()能做「讀了再決定寫什麼」 —— 那正是 D1 做不到、也是選 DO 的理由。- **cursor 要在任何
await之前消費完。**否則資料可能變,而且rowsRead不準。
- SQL Storage API
- Storage API(含同步 KV、transaction、PITR)
- Access Durable Objects Storage
- 限制 · 計費
下一篇:16. WebSocket Hibernation:讓一萬條連線幾乎不花錢 —— 兩種 WebSocket 寫法的帳單差好幾個數量級,而其中一種會讓 setTimeout 悄悄毀掉你的省錢策略。