跳轉到

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。

練習:

  1. 為 comments 資料表新增 email 欄位,寫一個 0002_add_email.sql migration 並套用到 local 與 remote。
  2. 實作 PUT /articles/:slug(更新文章標題與內容),注意:更新不存在的 slug 時應回傳 404,你怎麼用一條查詢或兩步驟做到?
  3. 用 db.batch() 把「讀文章 + 讀留言」合併成單次呼叫,並比較兩種寫法的程式碼差異。
  4. 思考題:若你的部落格突然湧入百萬筆留言寫入,D1 會發生什麼事?回到 5.1 節的表格,哪個產品更適合?