D1 入門:Serverless SQLite,在邊緣用 SQL 查資料,免管理資料庫 | Cloudflare 完整教學

2026/08/19
D1 入門:Serverless SQLite,在邊緣用 SQL 查資料,免管理資料庫 | Cloudflare 完整教學

上一篇《R2 物件儲存》我們把「大型檔案」搬上了雲,但很多應用真正需要的其實是結構化的關聯式資料:使用者表、訂單表、商品表,要能用 SQLJOINWHERE、交易——這是 KV 和 R2 都不擅長的。這一塊,Cloudflare 交給了 D1:一個底層是 SQLite、跑在**邊緣(edge)**的 Serverless 關聯式資料庫。這篇我們從什麼是 D1、怎麼用 wrangler d1 create 建庫 + binding、wrangler d1 execute 建表,到查詢核心 prepare / bind / first / all / runbatch 交易、參數化查詢防注入,以及本地 vs 遠端的差別,一路帶你在 Workers 裡用熟悉的 SQL 查資料。

前言

Cloudflare D1 是 Cloudflare 提供的 Serverless SQLite 資料庫——你可以把它想成一個跑在 Cloudflare 邊緣網路上、底層用 SQLite 當儲存引擎的關聯式資料庫(relational database)。它跟你在本機用的 SQLite 幾乎共用同一套 SQL 語法,但不需要你管理連線、連接池(connection pool)或資料庫伺服器:你的 Worker 透過一個 binding(例如 env.DB)就能直接下查詢,零連線建立成本。

打個比方:如果說上一篇的 R2 像一座大型物流倉庫(存又大又重的檔案),那 D1 就像貼在你辦公桌旁的一格小抽屜櫃——它放的是有格子、有標籤、彼此有關聯的結構化資料(哪個使用者、哪筆訂單、哪個商品),而且就在你手邊(跟 Worker 在同一個運行環境),伸手就拿,不必打電話叫貨、不必建立 TCP 連線等它接通。傳統資料庫(PostgreSQL、MySQL)你得管連線、管連接池、管備份升級;D1 則把這些全都零管理地包好了,你只管寫 SQL。

這篇我們聚焦 D1 入門,承接前面 KV 與 R2 的儲存版圖,補上「關聯式資料」這最後一塊拼圖。讀完你會掌握:

  • D1 是什麼、為何是 Serverless SQLite——邊緣關聯式資料庫的心智模型、與傳統資料庫的差異、適合與不適合的場景
  • 建立資料庫與 binding——wrangler d1 createwrangler.jsoncd1_databases 設定、TypeScript 型別
  • 建表與查詢核心——wrangler d1 execute 建 schema、prepare / bind / first / all / run 的完整流程
  • 交易與安全——batch 一次往返做原子性交易、參數化查詢(? + bind)防 SQL Injection
  • 本地 vs 遠端——--local--remote 的差別,開發時別改到正式資料

核心概念

什麼是 D1?Serverless SQLite 的心智模型

先拆解「Serverless SQLite」這個名字:

  • SQLite:D1 的底層儲存引擎就是 SQLite——世界上部署最廣的資料庫。這代表你用的是標準 SQLite SQL 語法:CREATE TABLESELECT ... JOINWHEREGROUP BY、交易,幾乎都跟本機的 sqlite3 一模一樣。
  • Serverless:你不需要佈建伺服器、開連接池、設定備份排程。資料庫由 Cloudflare 全託管,你的 Worker 直接透過 binding 查詢,沒有 TCP 連線建立的延遲(資料庫邏輯上跟 Worker 在同一個運行環境)。

所以 D1 的心智模型是:一個你用 SQL 操作、但完全不用維運的關聯式資料庫。它跟 KV(鍵值)、R2(物件)最大的不同在於——它懂「關聯」:你可以有 users 表和 posts 表,用外鍵關聯起來,一次 JOIN 查出「某使用者的所有文章」。這是 KV/R2 那種「用 key 查單筆」的模型做不到的。

D1 的設計哲學:水平擴展(每租戶一庫)

傳統資料庫的擴展方式是垂直擴展(scale-up):資料越多、流量越大,就升級一台更強的主機。D1 反過來,鼓勵水平擴展(horizontal scale-out)——與其開一個巨大的單一資料庫,不如為每個使用者、每個租戶(tenant)開一個獨立的小資料庫。因為 D1 一個帳號可以開很多資料庫(付費方案最多 5 萬個),這種「每租戶一庫」的模式在多租戶 SaaS 特別自然:資料天然隔離、單庫小而快、備份還原也以租戶為單位。

D1 vs 傳統資料庫:什麼時候用它

D1 不是萬能的,它有清楚的甜蜜區。下面這張表劃清邊界:

面向Cloudflare D1傳統 PostgreSQL / MySQL
管理負擔零管理(全託管)需管連線、備份、升級
連接延遲接近零(同一運行環境)需建立 TCP 連線
SQL 語法SQLite 語法(子集)完整 SQL 標準
寫入吞吐量約 500–2,000 次/秒約 10,000–50,000 次/秒
擴展方式水平(開多個資料庫)垂直(升級規格)

D1 最適合:已經在 Workers 上開發的應用、讀多寫少的場景(部落格、內容站、目錄服務)、多租戶 SaaS、不想維運資料庫的中小團隊、需要全球低延遲讀取的應用。

D1 不適合:高頻寫入(即時競價、IoT 遙測、日誌管線)、需要 PostgreSQL 專屬功能(JSONB、陣列型別、複雜 CTE)、需要跨資料庫的複雜 JOIN。

核心查詢流程:prepare → bind → 執行

D1 的查詢 API 圍繞一個固定流程:建立 prepared statement(準備好的語句)→ 綁定參數(bind)→ 執行

// 三步驟拆解
const stmt = env.DB.prepare("SELECT * FROM users WHERE id = ?"); // 1. 準備語句(未執行)
const bound = stmt.bind(userId);                                 // 2. 綁定參數到 ? 佔位符
const result = await bound.all();                                // 3. 執行,取回所有列

// 實務上多半鏈式寫(method chaining)
const { results } = await env.DB
  .prepare("SELECT * FROM users WHERE id = ?")
  .bind(userId)
  .all();

這個「語句與資料分離」的流程不只是寫法習慣——它正是參數化查詢(parameterized query),是 D1 防禦 SQL Injection 的根本機制(稍後詳談)。

關鍵術語速覽

術語一句話定義
bindingwrangler.jsonc 綁定的資料庫變數名,程式裡用 env.DB 存取,不需連線字串
prepare(sql)建立一個 prepared statement,尚未執行任何查詢
bind(...values)把值綁到 SQL 裡的 ? 佔位符,是防注入的關鍵
.first()執行並只取第一列(找不到回 null),適合查單筆
.all()執行並取所有列,回傳含 results 陣列與 meta 的物件
.run()執行寫入(INSERT/UPDATE/DELETE),不回傳資料列
batch([...])一次網路往返執行多個語句,作為原子性交易
--local / --remoteCLI 操作本地模擬庫 vs 雲端正式庫

實作範例

我們用一組可執行的 CLI 指令與 TypeScript,把上面每個概念落地。

1. 建立資料庫與 binding

先用 Wrangler CLI 建一個 D1 資料庫:

# 建立新資料庫(名稱建立後不可更改,慎選)
wrangler d1 create my-app-db

# 輸出範例(把這段 d1_databases 設定複製到 wrangler.jsonc):
# ✅ Successfully created DB 'my-app-db' in region APAC
#
# [[d1_databases]]
# binding = "DB"
# database_name = "my-app-db"
# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

# 列出所有資料庫、查看單一資料庫詳情
wrangler d1 list
wrangler d1 info my-app-db

接著在 wrangler.jsonc 裡把資料庫綁定到 Worker,程式裡才能用 env.DB 存取:

{
  "name": "my-app",
  "main": "src/index.ts",
  "compatibility_date": "2026-01-01",
  "d1_databases": [
    {
      "binding": "DB",                                        // 程式中用 env.DB 存取
      "database_name": "my-app-db",                           // 建立後不可更改
      "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"   // 建立時取得的 UUID
    }
  ]
}

再加上 TypeScript 型別定義,讓 env.DB 有正確的 D1Database 型別:

// src/index.ts
interface Env {
  DB: D1Database;
}

有了 binding,你就能在 Worker 裡直接下 SQL,完全不需要連線字串、帳號密碼——這是 D1 相對傳統資料庫最大的便利。

2. 用 wrangler d1 execute 建表

D1 需要先有 schema(資料表結構)才能查。入門階段最直接的建表方式是 wrangler d1 execute,它可以執行一段 SQL 命令,或執行一個 .sql 檔案。先把 schema 寫進一個檔案:

-- schema.sql:定義 users 表與常用索引
CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT UNIQUE NOT NULL,
  name TEXT NOT NULL,
  active INTEGER NOT NULL DEFAULT 1,           -- SQLite 沒有原生 boolean,用 0/1
  created_at TEXT NOT NULL DEFAULT (datetime('now'))  -- 日期用 TEXT(ISO 8601)
);

CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);

然後套用到本地遠端:

# 先套用到本地模擬資料庫(開發用,資料在你電腦裡)
wrangler d1 execute my-app-db --local --file=./schema.sql

# 確認本地正常後,再套用到雲端正式資料庫
wrangler d1 execute my-app-db --remote --file=./schema.sql

# 也可以直接下單行命令快速查看(例如確認建表成功)
wrangler d1 execute my-app-db --local --command="SELECT name FROM sqlite_master WHERE type='table'"

小提醒:入門用 execute 手動建表很直接,但正式專案應該用 D1 Migrations(wrangler d1 migrations)來管理 schema 版本,這樣每個環境的資料表結構才能一致演進——這正是下一篇的主題。

3. all / first / run:查多筆、查單筆、寫入

有了表,來看三個最常用的執行方法。先定義一個型別方便取用:

interface User {
  id: number;
  email: string;
  name: string;
  active: number;
  created_at: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // ── .all():查所有列,回傳 { results, success, meta } ──
    if (url.pathname === "/users") {
      const { results, meta } = await env.DB
        .prepare("SELECT * FROM users WHERE active = ? ORDER BY created_at DESC")
        .bind(1)
        .all<User>();
      console.log(`查到 ${results.length} 筆,讀取 ${meta.rows_read} 列`); // rows_read 是計費依據
      return Response.json(results);
    }

    // ── .first():只取第一列,找不到回 null ──
    if (url.pathname === "/user") {
      const id = url.searchParams.get("id");
      const user = await env.DB
        .prepare("SELECT * FROM users WHERE id = ? LIMIT 1")
        .bind(id)
        .first<User>();
      if (user === null) {
        return new Response("找不到使用者", { status: 404 }); // 一定要處理 null
      }
      return Response.json(user);
    }

    return new Response("Not Found", { status: 404 });
  },
} satisfies ExportedHandler<Env>;

寫入操作(INSERT / UPDATE / DELETE)用 .run(),它不回傳資料列,但 meta 裡帶有 changes(影響列數)與 last_row_id(新插入的自增 ID):

// 新增一筆使用者
const result = await env.DB
  .prepare("INSERT INTO users (name, email) VALUES (?, ?)")
  .bind("Alice", "alice@example.com")
  .run();

console.log(`新增成功,新 ID:${result.meta.last_row_id}`); // 取回自增主鍵

// 更新:用 meta.changes 判斷是否真的有更新到
const update = await env.DB
  .prepare("UPDATE users SET name = ? WHERE id = ?")
  .bind("Alice Wang", 1)
  .run();

if (update.meta.changes === 0) {
  // changes === 0 代表沒有任何列符合條件(例如該 id 不存在)
  console.log("沒有更新任何列");
}

小結三者的用法:查多筆用 .all()、查單筆用 .first()、寫入用 .run()

4. batch:一次往返做原子性交易

當你需要「一組操作要嘛全部成功、要嘛全部回滾」的原子性(atomicity),就用 batch()。它把多個 prepared statement 打包成一次網路往返執行,並作為一個交易(transaction)——任何一個失敗,整批都會回滾:

// 情境:建立訂單的同時扣庫存,兩件事必須同生共死
async function placeOrder(env: Env, userId: number, productId: number, qty: number) {
  const results = await env.DB.batch([
    // 這兩個語句作為一個原子交易:要嘛都成功,要嘛都不做
    env.DB
      .prepare("INSERT INTO orders (user_id, product_id, qty) VALUES (?, ?, ?)")
      .bind(userId, productId, qty),
    env.DB
      .prepare("UPDATE products SET stock = stock - ? WHERE id = ?")
      .bind(qty, productId),
  ]);

  // batch 回傳一個 D1Result 陣列,順序對應傳入的語句
  const [orderResult] = results;
  return orderResult.meta.last_row_id; // 新訂單 ID
}

batch 的另一個常見用途是批次插入——用同一個 prepared statement 配不同參數,一次送出,避免在迴圈裡發幾百次獨立查詢:

// 批次插入多筆:一次往返,而不是迴圈裡 N 次查詢
async function seedUsers(env: Env, users: { name: string; email: string }[]) {
  const stmt = env.DB.prepare("INSERT INTO users (name, email) VALUES (?, ?)");
  // 用 map 把每一筆資料綁到同一個語句,組成一個陣列丟給 batch
  await env.DB.batch(users.map((u) => stmt.bind(u.name, u.email)));
}

比起在 for 迴圈裡一筆一筆 await ...run()(每筆都是一次往返),batch 一次搞定,速度快得多。

5. 參數化查詢:防 SQL Injection

前面每個範例都用 ? 佔位符 + bind(),這不是風格問題,而是安全底線。來看正反對照:

const userId = new URL(request.url).searchParams.get("id");

// ❌ 危險:字串插值。若 userId = "1 OR 1=1 --",這查詢會回傳整張表!
const bad = await env.DB
  .prepare(`SELECT * FROM users WHERE id = ${userId}`)
  .all();

// ✅ 安全:? 佔位符 + bind()。userId 永遠被當成純資料,不會被解析成 SQL
const good = await env.DB
  .prepare("SELECT * FROM users WHERE id = ?")
  .bind(userId)
  .all();

bind() 支援的型別:stringnumberboolean(自動轉成 0/1)、nullArrayBuffer

就算需要動態組查詢條件,原則也不變:動態組的是 SQL 的「結構」(欄位、AND),值的部分一律用 ? 佔位符,絕不把值拼進字串:

// 動態 WHERE:結構動態組,值全部走 bind(),仍然 100% 防注入
function buildUserQuery(filters: { name?: string; active?: boolean }) {
  const conditions: string[] = [];
  const params: (string | number)[] = [];

  if (filters.name !== undefined) {
    conditions.push("name LIKE ?");   // 結構:欄位 + 佔位符
    params.push(`%${filters.name}%`); // 值:進 params,之後 bind
  }
  if (filters.active !== undefined) {
    conditions.push("active = ?");
    params.push(filters.active ? 1 : 0);
  }

  const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
  return { sql: `SELECT * FROM users ${where} ORDER BY created_at DESC`, params };
}

// 使用
const { sql, params } = buildUserQuery({ name: "王", active: true });
const { results } = await env.DB.prepare(sql).bind(...params).all<User>();

常見錯誤與最佳實踐

坑一:字串拼接 SQL,門戶大開讓人注入。

這是最嚴重也最常見的坑。任何進到 SQL 的使用者輸入,一律走 bind(),永遠不要用範本字串把變數拼進 SQL:

// ❌ 錯誤:用使用者輸入的 email 拼字串
const email = await request.text();
await env.DB.prepare(`SELECT * FROM users WHERE email = '${email}'`).first();

// ✅ 正確:? + bind(),值被當成純資料
await env.DB.prepare("SELECT * FROM users WHERE email = ?").bind(email).first();

坑二:忘記 bind(),或 ? 數量對不上。

prepare() 只是「準備」語句,沒有 bind() 就直接執行,? 會拿不到值而報錯;同理,? 的數量必須跟 bind() 傳入的參數個數完全一致、順序對應:

// ❌ 錯誤:兩個 ? 卻只 bind 一個值
await env.DB.prepare("INSERT INTO users (name, email) VALUES (?, ?)").bind("Alice").run();

// ✅ 正確:? 的數量 = bind 參數個數,且順序對應
await env.DB.prepare("INSERT INTO users (name, email) VALUES (?, ?)").bind("Alice", "alice@example.com").run();

坑三:在迴圈裡發大量單筆查詢(N+1 問題)。

在迴圈裡一筆筆 await ...run(),每筆都是一次網路往返,10 筆就是 10 次往返,慢且浪費。改用 batch() 一次送出,或用 JOIN 一次查完:

// ❌ 慢:迴圈裡 N 次獨立往返
for (const u of users) {
  await env.DB.prepare("INSERT INTO users (name, email) VALUES (?, ?)").bind(u.name, u.email).run();
}

// ✅ 快:batch 一次往返,還順便有交易保證
const stmt = env.DB.prepare("INSERT INTO users (name, email) VALUES (?, ?)");
await env.DB.batch(users.map((u) => stmt.bind(u.name, u.email)));

坑四:搞混本地(--local)與遠端(--remote)資料庫。

這是新手最容易踩雷的地方。--local 操作的是你電腦上的模擬資料庫(存在 .wrangler/state,wrangler dev 預設連它),資料完全在本機、怎麼玩都不會動到線上;--remote 才是操作 Cloudflare 上的正式資料庫。兩者是完全獨立的兩份資料。標準流程是--local 開發測試,確認無誤再 --remote 套用到線上:

# 開發:先在本地建表、灌測試資料、跑 dev server(全都連本地庫)
wrangler d1 execute my-app-db --local --file=./schema.sql
wrangler dev                 # 預設連本地模擬庫,安全

# 上線:確認無誤,才把 schema 套到遠端正式庫
wrangler d1 execute my-app-db --remote --file=./schema.sql

特別小心 wrangler dev --remote——它會讓本地開發伺服器直接讀寫正式資料庫,一個手滑的 DELETE 就真的刪到線上資料,除非很清楚在做什麼,平常請用預設本地模式。

最佳實踐小結:記住 D1 的心法——使用者輸入一律 ? + bind()(永不字串拼接);查多筆用 .all()、查單筆用 .first()(記得處理 null)、寫入用 .run()(看 meta.changes/last_row_id);一組要同生共死的操作用 batch() 做原子交易;避免迴圈裡的 N+1 查詢;開發用 --local、上線才 --remote,兩者資料互相獨立。守住這幾條,你就能在邊緣用熟悉的 SQL 安全地查關聯式資料。

小結

上一篇《R2 物件儲存》,我們把 S3 相容、零 egress 的物件儲存徹底拆開,讓你把圖片、影片、備份這類大型檔案搬上雲又不被流量費坑;這一篇,我們補上 Cloudflare 儲存版圖的最後一塊——D1 關聯式資料庫:

  • D1 是什麼——底層 SQLite、跑在邊緣的 Serverless 關聯式資料庫,用熟悉的 SQL 查詢,免管理連線/連接池/伺服器;適合讀多寫少、多租戶、已在 Workers 上的應用。
  • 建庫與建表——wrangler d1 create 建庫、wrangler.jsoncd1_databases 綁定 env.DBwrangler d1 execute --file 套用 schema(先 --local--remote)。
  • 查詢核心——prepare → bind → 執行:查多筆 .all()、查單筆 .first()、寫入 .run();一組原子操作用 batch() 一次往返。
  • 安全與環境——使用者輸入一律 ? + bind()SQL Injection;--local(本機模擬)與 --remote(雲端正式)是兩份獨立資料,別搞混。

到這裡,你已經能在 D1 裡建表、查資料、做交易了。但你可能會問:如果之後要改資料表結構(加欄位、加索引、改關聯)怎麼辦?總不能每次都手動 execute 一堆 SQL、還要記得哪些環境套過、哪些沒套。這正是**資料庫遷移(Migrations)**要解決的問題。下一篇《D1 Migrations:用版本控制管理你的資料表結構》,我們就來看 wrangler d1 migrations 如何像 Git 一樣,把 schema 的每一次變更變成可追蹤、可重播、跨環境一致的版本。

想先查閱官方對 D1 的完整說明,可以隨時參考 Cloudflare D1 官方文件。關聯式資料庫這一課已就緒,我們下一篇《D1 Migrations》見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →