跳到內容

D1 入門:邊緣上的 SQLite

查證日期
驗證環境wrangler@4.114.0·workerd@1.20260722.1·compatibility_date: 2026-07-24
對應範例examples/ch09-d1

D1 是 Cloudflare 的 SQLite 服務。如果你寫過 SQL,API 一小時就上手。但有三件事會讓你在 production 出事,而且沒有一件是 SQL 知識能預防的:

  1. rows_read 就是你的帳單,而它算的是「掃了幾列」不是「回傳幾列」。 實測同一個邏輯查詢:有索引 rows_read: 1,沒索引 rows_read: 20002000 倍。
  2. wrangler d1 executemigrations apply 預設打的是本機,不是 production。你以為 migration 上線了,其實只寫進 .wrangler/state/
  3. D1 沒有互動式 transaction。 BEGIN TRANSACTION 會拿到一個提到 Durable Object API 的困惑錯誤訊息。

:一顆真的 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) 的索引:

Terminal window
$ 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

Terminal window
$ 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 找出最貴的查詢。

這是同一個問題的隱藏版本:

Terminal window
$ 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();
Terminal window
$ 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 executed1 migrations applyd1 migrations listd1 export 全都一樣。

文件的寫法會讓人以為必須明確給 flag。實際上 Wrangler v4 的分派邏輯是 remote || preview ? 遠端 : 本機

這是第 2 篇那個「v4 資料指令 local-first」的具體後果,而在 migration 上特別危險:你的 CI 跑了 migration、綠燈、部署,然後 production 資料庫其實一張表都沒有。

一定要 --remote 的命令(會直接對線上生效):d1 createinfolistdeletetime-travelinsights

護欄(實測的錯誤訊息):

  • --local--remote 同時給 → can't use --local and --remote at the same time
  • --preview 沒配 --remoteCannot use --preview without --remote
  • --command--file 同時給 → can't provide both --command and --file

順帶一提:沒有 wrangler d1 import 這個指令。 匯入是 wrangler d1 execute --file=dump.sql --remote

Terminal window
$ 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:

Terminal window
# 兩條 INSERT,第二條違反唯一索引
{
"batchRollback": {
"error": "D1_ERROR: UNIQUE constraint failed: links.tenant_id, links.slug",
"before": 2000,
"after": 2000,
"firstRowSurvived": false
}
}

第一條 INSERT 本來會成功,但因為第二條失敗,整批回滾了。 beforeafter 都是 2000,firstRowSurvivedfalse。這是真的原子性。

batch() 的語意與限制:

  • 順序執行、不併發,結果陣列與輸入順序對應。
  • 任一條失敗 → 整批回滾。
  • 各條之間不能互相依賴結果(沒有來回互動)。
  • 每條各自受 100 KB statement / 100 個綁定參數 / 1000 次查詢等限制。

需要「讀了再決定寫什麼」的原子操作?D1 給不了。用 Durable Object 的 transactionSync()(第 15 篇)。

Terminal window
$ 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

Terminal window
$ 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_regionserved_by_coloserved_by_primary(讀取複本用,第 11 篇)、timings.sql_duration_mstotal_attempts(自動重試次數)。

⚠️ 文件與型別定義對不上:官方 return-object 頁面記載 served_by 但沒有 served_by_colo;型別定義剛好相反。因為 meta 的型別是 D1Meta & Record<string, unknown>served_by runtime 有但 TypeScript 看不到,要 cast。又一次:兩邊都不完全對,以實測為準。

官方明確不建議用在應用查詢上:「This method can have poorer performance (prepared statements can be reused in some cases) and, more importantly, is less safe」——因為它不支援參數綁定。只用在 migration 和維護腳本。

而且它是按換行分隔多條語句,不是按分號:

Terminal window
$ 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”
Terminal window
$ find .wrangler -name "*.sqlite"
.wrangler/state/v3/d1/miniflare-D1DatabaseObject/e3c3975a...d17701.sqlite

停掉 wrangler dev 之後可以直接用 sqlite3 打開:

Terminal window
sqlite3 "$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -name '*.sqlite' | head -1)"
sqlite> .tables
sqlite> .schema links

除錯 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 TRANSACTIOND1 runs your SQL in a transaction for you.(它會自動剝掉恰好一組 BEGIN/COMMIT
項目FreePaid
資料庫數1050,000
單庫大小500 MB10 GB
帳號總儲存5 GB1 TB
每次 invocation 查詢數501,000
讀取列數500 萬 / 日含 250 億 / 月,之後 $0.001 / 百萬
寫入列數10 萬 / 日含 5,000 萬 / 月,之後 $1.00 / 百萬
儲存5 GB含 5 GB,之後 $0.75 / GB-月
Time Travel7 天30 天

不分方案:查詢逾時 30 秒單一 statement 100 KB綁定參數 100 個每表 100 欄單列/字串/BLOB 2 MB、LIKE/GLOB pattern 50 bytes、Time Travel 每 10 分鐘最多 10 次還原。

索引和表都算進儲存量。空資料庫約 12 KB。


完整程式碼:examples/ch09-d1/

Terminal window
cd examples/ch09-d1 && npm install
npx wrangler d1 migrations apply linkforge-demo # 注意:預設本機
npm run dev
Terminal window
B=localhost:8787
curl -s "$B/seed?n=2000" # 兩張表各 2000 列,只有一張有索引
curl -s "$B/rowsread" # 1 vs 2000
curl -s "$B/explain" # SEARCH USING INDEX vs SCAN
curl -s "$B/meta" # 成本收據
curl -s "$B/types" # undefined / boolean / blob / bigint
curl -s "$B/first" # first() 不會加 LIMIT 1
curl -s "$B/tx" # BEGIN 失敗、batch 真的回滾
curl -s "$B/exec" # exec 按換行分隔

練習:把 migrations/0002 那張表加上索引再跑一次 /rowsread,看 2000 變回 1。


D1 是 LinkForge 的真相來源,但刻意不出現在熱路徑上

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,命名慣例可以提醒後續維護者。

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 時期的資料庫有效。


  1. **rows_read 是帳單,索引是唯一的槓桿。**同一個查詢實測 1 vs 2000 列。每條查詢都跑 EXPLAIN QUERY PLAN
  2. **CLI 預設打本機。**CI 的 migration 一定要 --remote,否則 production 什麼都沒發生。
  3. **只有 batch() 有原子性。**需要「讀了再決定寫什麼」就得換 Durable Object。


下一篇10. D1 + Drizzle ORM —— 這是全系列地雷最多的一篇。npm 的 latest 和官網文件describe 的是兩條不相容的版本線,而 db.transaction() 在兩條線上都會在 D1 上炸掉。