D1 入門:Serverless SQLite,在邊緣用 SQL 查資料,免管理資料庫 | Cloudflare 完整教學
上一篇《R2 物件儲存》我們把「大型檔案」搬上了雲,但很多應用真正需要的其實是結構化的關聯式資料:使用者表、訂單表、商品表,要能用 SQL 做
JOIN、WHERE、交易——這是 KV 和 R2 都不擅長的。這一塊,Cloudflare 交給了 D1:一個底層是 SQLite、跑在**邊緣(edge)**的 Serverless 關聯式資料庫。這篇我們從什麼是 D1、怎麼用wrangler d1 create建庫 + binding、wrangler d1 execute建表,到查詢核心prepare/bind/first/all/run、batch交易、參數化查詢防注入,以及本地 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 create、wrangler.jsonc的d1_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 TABLE、SELECT ... JOIN、WHERE、GROUP 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 的根本機制(稍後詳談)。
關鍵術語速覽
| 術語 | 一句話定義 |
|---|---|
| binding | 在 wrangler.jsonc 綁定的資料庫變數名,程式裡用 env.DB 存取,不需連線字串 |
prepare(sql) | 建立一個 prepared statement,尚未執行任何查詢 |
bind(...values) | 把值綁到 SQL 裡的 ? 佔位符,是防注入的關鍵 |
.first() | 執行並只取第一列(找不到回 null),適合查單筆 |
.all() | 執行並取所有列,回傳含 results 陣列與 meta 的物件 |
.run() | 執行寫入(INSERT/UPDATE/DELETE),不回傳資料列 |
batch([...]) | 一次網路往返執行多個語句,作為原子性交易 |
--local / --remote | CLI 操作本地模擬庫 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() 支援的型別:string、number、boolean(自動轉成 0/1)、null、ArrayBuffer。
就算需要動態組查詢條件,原則也不變:動態組的是 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.jsonc的d1_databases綁定env.DB、wrangler 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》見。