D1 入門:邊緣上的 SQLite
這篇要解決的問題
Section titled “這篇要解決的問題”D1 是 Cloudflare 的 SQLite 服務。如果你寫過 SQL,API 一小時就上手。但有三件事會讓你在 production 出事,而且沒有一件是 SQL 知識能預防的:
rows_read就是你的帳單,而它算的是「掃了幾列」不是「回傳幾列」。 實測同一個邏輯查詢:有索引rows_read: 1,沒索引rows_read: 2000。2000 倍。wrangler d1 execute和migrations apply預設打的是本機,不是 production。你以為 migration 上線了,其實只寫進.wrangler/state/。- D1 沒有互動式 transaction。
BEGIN TRANSACTION會拿到一個提到 Durable Object API 的困惑錯誤訊息。
D1 是什麼,不是什麼
Section titled “D1 是什麼,不是什麼”是:一顆真的 SQLite —— 完整 SQL、外鍵、索引、FTS5 全文檢索、JSON1、視窗函數。
不是:Postgres 的替代品。三個結構性差異:
| D1 | 傳統關聯式資料庫 | |
|---|---|---|
| 併發 | 單執行緒,查詢一個一個跑 | 連線池、平行執行 |
| 計費 | 掃過的列數 | 執行個體時間 |
| Transaction | 只有 batch()(隱式) | 完整 BEGIN/COMMIT |
| 單庫上限 | 10 GB(Paid)/ 500 MB(Free) | TB 級 |
| 連線 | binding,無連線字串 | 連線池管理 |
「單執行緒」是最容易被忽略的一條。官方原文:「an individual D1 database is inherently single-threaded, and processes queries one at a time」。所以吞吐量 = 1 / 平均查詢時間,加機器沒用。1ms 的查詢大約 1,000 qps,10ms 的查詢就只剩 100 qps。
推論:D1 不該出現在熱路徑上。 LinkForge 的 redirect 走 KV 而不是 D1,就是這個原因。
🔴 rows_read 是帳單,索引是唯一的槓桿
Section titled “🔴 rows_read 是帳單,索引是唯一的槓桿”計費定義(官方):rows read 算的是查詢掃描了多少列,和回傳幾列無關。「A full table scan of 5,000 rows counts as 5,000 rows read, even if the query returns fewer results.」
實測。兩張結構相同的表,各 2000 列,唯一差別是有沒有 (tenant_id, slug) 的索引:
$ curl -s localhost:8787/rowsread{ "indexed": { "rows_read": 1, "found": 1 }, "unindexed": { "rows_read": 2000, "found": 1 }}兩者都只回傳 1 列,但一個掃了 1 列、一個掃了 2000 列。
換算成錢:Paid 方案每百萬列 $0.001。一個每月 1 億次的查詢,有索引是 $0.10,沒索引是 $200。而且免費額度 250 億列在後者只夠撐 1250 萬次查詢。
檢查方式是 EXPLAIN QUERY PLAN:
$ curl -s localhost:8787/explain{ "indexed": [{ "detail": "SEARCH links USING INDEX idx_links_tenant_slug (tenant_id=? AND slug=?)" }], "unindexed": [{ "detail": "SCAN links_unindexed" }]}看到 SCAN 就是在燒錢,看到 SEARCH ... USING INDEX 才對。
三個實務建議:
- 每條上 production 的查詢都跑一次
EXPLAIN QUERY PLAN。 - 每次查詢後把
meta.rows_read記進結構化 log(第 39 篇),異常值就會自己浮出來。 - production 用
wrangler d1 insights --sort-by reads找出最貴的查詢。
first() 不會幫你加 LIMIT 1
Section titled “first() 不會幫你加 LIMIT 1”這是同一個問題的隱藏版本:
$ curl -s localhost:8787/first{"noLimit_rows_read":2000,"withLimit_rows_read":1}.first() 只是取結果的第一列 —— 查詢本身還是把整張表掃完了。官方文件明講要自己加 LIMIT 1。
// ❌ Scans everything, then discards all but one row.await db.prepare("SELECT * FROM links WHERE tenant_id = ?").bind(t).first();
// ✅await db.prepare("SELECT * FROM links WHERE tenant_id = ? LIMIT 1").bind(t).first();🔴 CLI 預設打本機
Section titled “🔴 CLI 預設打本機”$ npx wrangler d1 migrations apply linkforge-demo
Resource location: local
Use --remote if you want to access the remote instance.
Migrations to be applied:┌─────────────────────────┐│ name │├─────────────────────────┤│ 0001_init.sql │└─────────────────────────┘🌀 Executing on local database linkforge-demo (...) from .wrangler/state/v3/d1:🌀 To execute on your remote database, add a --remote flag to your wrangler command.沒有 --local 也沒有 --remote,預設就是本機。 d1 execute、d1 migrations apply、d1 migrations list、d1 export 全都一樣。
文件的寫法會讓人以為必須明確給 flag。實際上 Wrangler v4 的分派邏輯是 remote || preview ? 遠端 : 本機。
這是第 2 篇那個「v4 資料指令 local-first」的具體後果,而在 migration 上特別危險:你的 CI 跑了 migration、綠燈、部署,然後 production 資料庫其實一張表都沒有。
一定要 --remote 的命令(會直接對線上生效):d1 create、info、list、delete、time-travel、insights。
護欄(實測的錯誤訊息):
--local和--remote同時給 →can't use --local and --remote at the same time--preview沒配--remote→Cannot use --preview without --remote--command和--file同時給 →can't provide both --command and --file
順帶一提:沒有
wrangler d1 import這個指令。 匯入是wrangler d1 execute --file=dump.sql --remote。
🔴 沒有互動式 transaction
Section titled “🔴 沒有互動式 transaction”$ curl -s localhost:8787/tx{ "begin": { "threw": "Error: D1_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. The JavaScript API is safer beca..." }, "savepoint": { "threw": "(同上)" }}這個錯誤訊息很讓人困惑,因為 state.storage.transaction() 在 D1 binding 上根本不存在 —— 那是 Durable Object 的 API(第 15 篇),是 D1 早期架構留下來的訊息。
你能用的是 batch(),它是一個 transaction:
# 兩條 INSERT,第二條違反唯一索引{ "batchRollback": { "error": "D1_ERROR: UNIQUE constraint failed: links.tenant_id, links.slug", "before": 2000, "after": 2000, "firstRowSurvived": false }}第一條 INSERT 本來會成功,但因為第二條失敗,整批回滾了。 before 和 after 都是 2000,firstRowSurvived 是 false。這是真的原子性。
batch() 的語意與限制:
- 順序執行、不併發,結果陣列與輸入順序對應。
- 任一條失敗 → 整批回滾。
- 各條之間不能互相依賴結果(沒有來回互動)。
- 每條各自受 100 KB statement / 100 個綁定參數 / 1000 次查詢等限制。
需要「讀了再決定寫什麼」的原子操作?D1 給不了。用 Durable Object 的 transactionSync()(第 15 篇)。
型別轉換的四個陷阱
Section titled “型別轉換的四個陷阱”$ curl -s localhost:8787/types{ "undefinedBind": { "threw": "Error: D1_TYPE_ERROR: Type 'undefined' not supported for value 'undefined'" }, "nullBind": { "ok": { "v": null } }, "booleanBind": { "ok": { "v": 1 } }, "blobBind": { "ok": { "v": [1, 2, 3] } }, "bigintBind": { "threw": "Error: D1_TYPE_ERROR: Type 'bigint' not supported for value '10'" }, "storedBoolean": { "ok": { "is_active": 1 } }}① undefined 直接爆炸。 這是實務上最常見的一個 —— 物件裡少一個 optional 欄位,展開進 .bind() 就 throw:
// ❌ note 沒填就炸await db.prepare("INSERT INTO links (slug, note) VALUES (?, ?)").bind(input.slug, input.note);
// ✅.bind(input.slug, input.note ?? null);② boolean 是單向的。 寫 true 讀回 1。SQLite 沒有 BOOLEAN 型別。應用層要自己轉。
③ BLOB 不對稱。 寫 Uint8Array,讀回普通的整數陣列,不是 ArrayBuffer。
④ BigInt 不支援。 安全範圍是 Number.MAX_SAFE_INTEGER(2^53-1)。雪花 ID 之類的 64 位元整數要存成 TEXT。
meta:每次查詢的成本收據
Section titled “meta:每次查詢的成本收據”$ curl -s localhost:8787/meta{ "meta": { "served_by": "miniflare.db", "duration": 0, "changes": 0, "last_row_id": 2000, "changed_db": false, "size_after": 253952, "rows_read": 1, "rows_written": 0 }}Production 上還會有 served_by_region、served_by_colo、served_by_primary(讀取複本用,第 11 篇)、timings.sql_duration_ms、total_attempts(自動重試次數)。
⚠️ 文件與型別定義對不上:官方 return-object 頁面記載
served_by但沒有served_by_colo;型別定義剛好相反。因為meta的型別是D1Meta & Record<string, unknown>,served_byruntime 有但 TypeScript 看不到,要 cast。又一次:兩邊都不完全對,以實測為準。
exec() 的兩個注意事項
Section titled “exec() 的兩個注意事項”官方明確不建議用在應用查詢上:「This method can have poorer performance (prepared statements can be reused in some cases) and, more importantly, is less safe」——因為它不支援參數綁定。只用在 migration 和維護腳本。
而且它是按換行分隔多條語句,不是按分號:
$ curl -s localhost:8787/exec # exec("SELECT 1; SELECT 2"){"exec":{"ok":{"count":1,"duration":0}}}count 是 1 不是 2。要多條就用 \n 分隔。
本機的 SQLite 檔就是一顆真的 SQLite
Section titled “本機的 SQLite 檔就是一顆真的 SQLite”$ find .wrangler -name "*.sqlite".wrangler/state/v3/d1/miniflare-D1DatabaseObject/e3c3975a...d17701.sqlite停掉 wrangler dev 之後可以直接用 sqlite3 打開:
sqlite3 "$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -name '*.sqlite' | head -1)"sqlite> .tablessqlite> .schema links除錯 migration 時非常好用。
Migration 的實際機制
Section titled “Migration 的實際機制”Wrangler 自己維護一張表:
CREATE TABLE IF NOT EXISTS "d1_migrations"( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL);每個 migration 檔套用後會 INSERT 一筆檔名。所以檔名就是 idempotency key —— 改名等於重跑。
設定:
{ "d1_databases": [{ "binding": "DB", "database_name": "linkforge", "database_id": "<uuid>", "migrations_dir": "./migrations", "migrations_table": "d1_migrations", // 預設值 "migrations_pattern": "drizzle/*/migration.sql" // 2026 新增,第 10 篇會用到 }]}migrations_pattern 是 2026 年 5–6 月新增的,用來支援 ORM 的巢狀目錄佈局。官方的 migrations 文件頁和 Wrangler 設定參考頁都還沒寫到它,只有 changelog 和 JSON schema 有。第 10 篇會看到它為什麼是必要的。
另外 wrangler d1 execute --file 會做兩件事你要知道:
- 傳二進位
.sqlite檔進去 →Provided file is a binary SQLite database file instead of an SQL text file. - 檔案裡有多於一個
BEGIN TRANSACTION→D1 runs your SQL in a transaction for you.(它會自動剝掉恰好一組BEGIN/COMMIT)
| 項目 | Free | Paid |
|---|---|---|
| 資料庫數 | 10 | 50,000 |
| 單庫大小 | 500 MB | 10 GB |
| 帳號總儲存 | 5 GB | 1 TB |
| 每次 invocation 查詢數 | 50 | 1,000 |
| 讀取列數 | 500 萬 / 日 | 含 250 億 / 月,之後 $0.001 / 百萬 |
| 寫入列數 | 10 萬 / 日 | 含 5,000 萬 / 月,之後 $1.00 / 百萬 |
| 儲存 | 5 GB | 含 5 GB,之後 $0.75 / GB-月 |
| Time Travel | 7 天 | 30 天 |
不分方案:查詢逾時 30 秒、單一 statement 100 KB、綁定參數 100 個、每表 100 欄、單列/字串/BLOB 2 MB、LIKE/GLOB pattern 50 bytes、Time Travel 每 10 分鐘最多 10 次還原。
索引和表都算進儲存量。空資料庫約 12 KB。
完整程式碼:
examples/ch09-d1/
cd examples/ch09-d1 && npm installnpx wrangler d1 migrations apply linkforge-demo # 注意:預設本機npm run devB=localhost:8787curl -s "$B/seed?n=2000" # 兩張表各 2000 列,只有一張有索引curl -s "$B/rowsread" # 1 vs 2000curl -s "$B/explain" # SEARCH USING INDEX vs SCANcurl -s "$B/meta" # 成本收據curl -s "$B/types" # undefined / boolean / blob / bigintcurl -s "$B/first" # first() 不會加 LIMIT 1curl -s "$B/tx" # BEGIN 失敗、batch 真的回滾curl -s "$B/exec" # exec 按換行分隔練習:把 migrations/0002 那張表加上索引再跑一次 /rowsread,看 2000 變回 1。
接進 LinkForge
Section titled “接進 LinkForge”D1 是 LinkForge 的真相來源,但刻意不出現在熱路徑上。
schema 第一版
Section titled “schema 第一版”CREATE TABLE tenants ( id TEXT PRIMARY KEY, name TEXT NOT NULL, plan TEXT NOT NULL DEFAULT 'free', created_at INTEGER NOT NULL) STRICT;
CREATE TABLE links ( id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT NOT NULL REFERENCES tenants(id), slug TEXT NOT NULL, url TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, expires_at INTEGER) STRICT;
-- 唯一約束 + 查詢索引,一石二鳥CREATE UNIQUE INDEX idx_links_tenant_slug ON links(tenant_id, slug);-- 列表頁:某租戶最新的連結CREATE INDEX idx_links_tenant_created ON links(tenant_id, created_at DESC);
CREATE TABLE click_rollup ( link_id INTEGER NOT NULL REFERENCES links(id), bucket INTEGER NOT NULL, -- 小時對齊的 unix timestamp count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (link_id, bucket)) STRICT;五個決策:
① 全部用 STRICT 表。 SQLite 預設允許把字串塞進 INTEGER 欄位。STRICT 關掉這個行為 —— 官方也建議。
② 時間一律存 INTEGER(unix 毫秒)。 SQLite 沒有日期型別,存 TEXT 會讓範圍查詢無法用索引。
③ 每個索引都要有一條對應的查詢。 索引佔儲存空間、拖慢寫入。上面兩個索引分別對應「按 slug 查」和「列表頁分頁」,沒有第三個。
④ click_rollup 的複合主鍵就是它的索引。 第 17 篇的 DO alarm 會每 30 秒 upsert 一次;主鍵 (link_id, bucket) 讓 upsert 和讀取都走索引。
⑤ boolean 欄位命名成 is_* 並註明是 INTEGER。 因為讀回來是 0/1,命名慣例可以提醒後續維護者。
熱路徑上完全沒有 D1
Section titled “熱路徑上完全沒有 D1”redirect (每秒數千次) → Workers Cache → KV ← D1 不在這條路上dashboard / API → D1 ← 每秒數十次,完全撐得住點擊統計 → DO → alarm → D1 rollup ← 批次寫入,不是逐筆理由就是前面那條「單執行緒、吞吐量 = 1 / 查詢時間」。把每秒數千次的 redirect 打在 D1 上會直接排隊。
寫進 apps/api 的共用 helper:
export async function query<T>( db: D1Database, sql: string, params: unknown[], env: CloudflareBindings,): Promise<D1Result<T>> { const r = await db.prepare(sql).bind(...params).all<T>(); if (r.meta.rows_read > 1000) { console.log(JSON.stringify({ event: "slow_query", rows_read: r.meta.rows_read, duration: r.meta.duration, sql: sql.slice(0, 200), v: env.VERSION.id, })); } return r;}任何掃超過 1000 列的查詢都留下 log。 這條 log 在第 39 篇會變成一個 Query Builder 的告警規則,在第 42 篇會變成成本分析的輸入。
本篇交付物:packages/db/migrations/ 的初始 schema、apps/api 改用 D1 取代記憶體 map、query() 成本護欄、以及一條「所有新查詢都要附 EXPLAIN QUERY PLAN 輸出」的 PR checklist。
① 以為 rows_read 是回傳的列數
是掃描的列數。沒索引就是全表。
② first() 沒加 LIMIT 1
一樣掃全表。
③ 以為 d1 execute / migrations apply 打的是 production
預設本機。CI 一定要明寫 --remote。
④ 用 BEGIN TRANSACTION
會拿到一個提到 Durable Object API 的困惑錯誤。用 batch()。
⑤ 綁定 undefined
D1_TYPE_ERROR。一律 ?? null。
⑥ 期待 boolean 或 BigInt
boolean 讀回 0/1;BigInt 直接 throw。
⑦ 用 exec() 跑應用查詢
官方明說較慢且較不安全(不支援參數綁定)。只用在 migration。
⑧ 把 D1 放在熱路徑
單執行緒。吞吐量 = 1 / 查詢時間。
⑨ 用 dump()
型別上標了 @deprecated ... will be removed soon,只對 alpha 時期的資料庫有效。
本篇要記住的三句話
Section titled “本篇要記住的三句話”- **
rows_read是帳單,索引是唯一的槓桿。**同一個查詢實測 1 vs 2000 列。每條查詢都跑EXPLAIN QUERY PLAN。 - **CLI 預設打本機。**CI 的 migration 一定要
--remote,否則 production 什麼都沒發生。 - **只有
batch()有原子性。**需要「讀了再決定寫什麼」就得換 Durable Object。
- D1 Worker API · 回傳物件
- 使用索引(含
EXPLAIN QUERY PLAN) - Wrangler D1 指令
- 限制 · 計費
- Time Travel
下一篇:10. D1 + Drizzle ORM —— 這是全系列地雷最多的一篇。npm 的 latest 和官網文件describe 的是兩條不相容的版本線,而 db.transaction() 在兩條線上都會在 D1 上炸掉。