5.2 D1:SQLite 在邊緣上跑 SQL#
本節目標#
- 理解 D1 的架構:SQLite 資料庫跑在 Cloudflare 邊緣網路上
- 學會用 wrangler 建立資料庫、管理 migrations
- 將 D1 binding 到 Worker 的
env,寫出 prepared statement 的 CRUD 程式碼 - 認識 D1 的硬性限制與設計心法
理論:把資料庫搬到邊緣的難題,D1 怎麼解#
第 5.1 節說過,在多副本邊緣環境維持強一致性的成本很高,D1 的解法很務實:
- 單一主寫入者:每個 D1 資料庫只有一個「主」節點負責接受寫入,寫入之間不存在衝突——用「單點」換「一致性」,繞過 CAP 難題。
- 讀取副本:主節點把資料複製到附近的多個唯讀副本,就近服務讀取請求。
- 引擎是 SQLite:單檔、零設定、極致輕量。SQLite 原生並非 client-server 資料庫,但 Cloudflare 控管整個網路後,embedded 特性反而成為資源效率的優點。
歷史脈絡:SQLite 2000 年誕生,是全世界部署量最大的資料庫(每一支手機、瀏覽器裡都有)。D1 於 2022 年公布、2023 年 GA,是「SQLite + 全球分布式主從架構」的組合。
實作一:建立資料庫與 schema#
建立資料庫只需一行:
$ npx wrangler d1 create blog
# ✅ Successfully created DB "blog",輸出 binding 區塊
把輸出的 binding 區塊貼進 wrangler.toml:
[[d1_databases]]
binding = "DB"
database_name = "blog"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
Schema 檔 schema.sql:
-- schema.sql
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_comments_article
ON comments(article_id, created_at);
正式專案建議用 migrations 而非一次性 schema——schema 歷史可追蹤、可回放:
$ npx wrangler d1 migrations create blog init_articles
# 產生 ./migrations/0001_init_articles.sql,編輯後套用:
$ npx wrangler d1 migrations apply blog --local # 本地測試
$ npx wrangler d1 migrations apply blog --remote # 部署到線上
實作二:binding 到 Worker 與 CRUD#
D1 透過 env.DB(binding 名稱)在 Worker 中使用。務必使用 prepared statements——安全(防 SQL injection)且高效(可快取執行計畫)。
// src/index.ts
export interface Env {
DB: D1Database;
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
const db = env.DB;
// 讀取:列出文章(最 simple 的 SELECT)
if (url.pathname === "/articles" && req.method === "GET") {
const { results } = await db
.prepare("SELECT id, slug, title, created_at FROM articles ORDER BY created_at DESC LIMIT 20")
.all();
return Response.json(results);
}
// 讀取:單篇文章 + 其留言(一次 batch 送兩個查詢)
if (url.pathname.startsWith("/articles/") && req.method === "GET") {
const slug = url.pathname.split("/")[2];
const stmtArticle = db
.prepare("SELECT * FROM articles WHERE slug = ?1")
.bind(slug);
const [article, comments] = await db.batch([
stmtArticle,
db.prepare("SELECT author, body, created_at FROM comments WHERE article_id = (SELECT id FROM articles WHERE slug = ?1) ORDER BY created_at").bind(slug),
]);
if (article.results.length === 0)
return new Response("Not found", { status: 404 });
return Response.json({ article: article.results[0], comments: comments.results });
}
// 寫入:新增文章(INSERT prepared statement)
if (url.pathname === "/articles" && req.method === "POST") {
const { slug, title, body } = await req.json<{
slug: string; title: string; body: string;
}>();
const result = await db
.prepare("INSERT INTO articles (slug, title, body) VALUES (?1, ?2, ?3)")
.bind(slug, title, body)
.run();
return Response.json({ id: result.meta.last_row_id }, { status: 201 });
}
// 刪除:刪除文章
if (url.pathname.startsWith("/articles/") && req.method === "DELETE") {
const slug = url.pathname.split("/")[2];
await db.prepare("DELETE FROM articles WHERE slug = ?1").bind(slug).run();
return new Response(null, { status: 204 });
}
return new Response("Not found", { status: 404 });
},
};
幾個值得注意的細節:
?1, ?2是位置萬用字元(SQLite 風格),.bind()的參數依序填入,永遠不要用字串拼接 SQL。.first<T>()取單列(沒有則null);.all<T>()取多列;.run()用於寫入並回傳meta.last_row_id。- 多個查詢可一次批次執行(例如文章 + 留言),用
db.batch([stmt1, stmt2])回傳結果陣列。
本地開發時,先 npx wrangler d1 migrations apply blog --local 套用本地 migrations,再用 npx wrangler dev 測試。
限制與設計心法#
| 限制 | 數值(官方文件為準) | 對設計的影響 |
|---|---|---|
| 單庫大小上限 | ~10GB | 大檔案放 R2,D1 只存中繼資料 |
| 寫入者 | 單一主寫入者 | 高併發寫入要序列化(計數器類不適合) |
| 資料庫數量 | 有帳戶層級上限 | 多租戶可用一庫多表或 shard 模式 |
心法:D1 適合「小型關聯資料 + 中低寫入量」的網站(部落格、CMS、小型 SaaS)。若寫入是熱點或需要線性強一致計數,請用 Durable Objects(5.4 節);若只是大鍵值對,用 KV 更便宜。
小結與練習#
D1 用「單一寫入者 + 讀取副本」把 SQLite 搬上邊緣,提供與傳統 LAMP 栈相似的心智模型卻免維運;CRUD 四式本質上就是四個 prepared statements。
練習:
- 為
comments資料表新增email欄位,寫一個0002_add_email.sqlmigration 並套用到 local 與 remote。 - 實作
PUT /articles/:slug(更新文章標題與內容),注意:更新不存在的 slug 時應回傳 404,你怎麼用一條查詢或兩步驟做到? - 用
db.batch()把「讀文章 + 讀留言」合併成單次呼叫,並比較兩種寫法的程式碼差異。 - 思考題:若你的部落格突然湧入百萬筆留言寫入,D1 會發生什麼事?回到 5.1 節的表格,哪個產品更適合?