設計 D1 資料庫綱要(資料表與 migration)
好的資料庫綱要是每個應用的地基——設計一次,之後永遠都能安全地修改它。
我們要設計什麼?
綱要(schema)就是你資料庫的設計藍圖——有哪些資料表、每張表有哪些欄位,以及讓資料保持乾淨的規則。這篇我們會設計一個迷你部落格:使用者(users)撰寫貼文(posts)。你會學到主鍵(primary key)、外鍵(foreign key)、索引(index),以及 NOT NULL/UNIQUE 規則,最後用 migration 管理之後的每一次變更。
D1 底層是 SQLite,所以這裡用的就是樸實的標準 SQL。核心觀念是:一開始把資料表設計好,之後絕不直接手動改線上資料庫——而是為每次變更寫一個小小的 migration 檔,讓你的綱要擁有清楚、有版本紀錄的歷史。
資料模型:users 與 posts
一位使用者可以發表很多篇貼文,而每篇貼文剛好屬於一位使用者,這就是「一對多」(one-to-many)關聯。我們用外鍵把它們連起來:posts.user_id 指回 users.id。
主鍵(PK)
id 用來唯一辨識每一列。在 SQLite 裡 INTEGER PRIMARY KEY 會在每次新增時自動填入一個新號碼。
外鍵(FK)
posts.user_id 存放擁有者使用者的 id,把兩張表連起來,並讓資料保持一致。
索引(index)
在 user_id 上建索引,能讓資料庫直接跳到某位使用者的貼文,而不必把整張表掃過一遍。
NOT NULL(不可空白)
email 與 name 是必填——只要這些欄位留空,資料庫就會拒絕那一列。
UNIQUE(唯一)
email 設為 UNIQUE,所以兩位使用者絕不可能用同一個信箱註冊。
一對多
魚尾符號(o{)代表一位使用者對應很多貼文——這是應用程式裡最常見的關聯。
綱要術語小辭典
以下是你之後設計幾乎每一張資料表都會用到的基本元件。
綱要 schema
你所有資料表、欄位、型別與規則的完整定義——也就是資料庫的藍圖。
資料型別
SQLite 用 TEXT、INTEGER、REAL、BLOB、NULL 五種。時間戳記建議用 TEXT(ISO 字串)儲存,最單純。
主鍵
用來唯一命名每一列的欄位。INTEGER PRIMARY KEY 還會幫你自動遞增編號。
外鍵
一個指向另一張表主鍵的欄位,用來表達列與列之間的關聯。
索引
一種查找捷徑,能加速某欄位的篩選與 join——代價是寫入會稍微慢一點點。
約束條件
像 NOT NULL、UNIQUE、DEFAULT 這類由資料庫強制執行的規則,讓壞資料根本進不來。
DEFAULT 預設值
沒給欄位值時自動帶入的值,例如 status 預設為 'draft'、created_at 預設為現在時間。
Migration(綱要遷移)
一個有編號的 SQL 檔,描述一次綱要變更。依序執行後,就替你的資料庫建立了版本歷史。
用 migration 修改綱要
應用程式會長大:你總會需要新增欄位、新表,或新索引。Migration 就是一個小小、有編號的 SQL 檔(0001_...、0002_...),剛好記錄一次變更。Wrangler 會記住哪些檔案已經跑過,所以在每個環境套用都能重現一樣的結果。
黃金守則:一旦某個 migration 已經套用到共用或正式資料庫,就絕對不要再去改那個檔案。要修正或變更,就寫一個新的 migration。這能讓歷史保持誠實,隊友只要跑一次 migrations apply 就能跟上進度。
為什麼要用有版本的檔案?
因為 migrations 資料夾會和你的程式碼一起放進 Git。任何人 clone 下來後,只要依序跑這些 migration,就能重建出一模一樣的綱要——不必手動敲 SQL,也不用猜。
一步步動手做
現在把全部串起來:建立資料庫、綁定到 Worker、在 migration 裡定義綱要、套用它、再加第二個 migration 讓綱要演進,最後用 JOIN 跨兩張表查詢。
建立資料庫
Wrangler 會印出一組 database_id——複製起來,下一步會用到。
npx wrangler d1 create blog-db綁定到你的 Worker
把這段加進 wrangler.jsonc,讓 Worker 能用 env.DB 存取這個資料庫。
{ "d1_databases": [ { "binding": "DB", "database_name": "blog-db", "database_id": "<paste-your-id-here>" } ] }建立第一個 migration
這會建立一個 migrations/ 資料夾,以及一個空的 0001_create_users_and_posts.sql 檔。
npx wrangler d1 migrations create blog-db create_users_and_posts寫下綱要 SQL
打開剛產生的檔案,把兩張表連同它們的鍵、約束與索引都定義好。
-- migrations/0001_create_users_and_posts.sql -- Enforce foreign keys (SQLite leaves this off by default) PRAGMA foreign_keys = ON; CREATE TABLE users ( id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE posts ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT, status TEXT NOT NULL DEFAULT 'draft', created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); -- Index the columns we filter and join on CREATE INDEX idx_posts_user_id ON posts (user_id); CREATE INDEX idx_posts_status ON posts (status);先套用到本機,再套用到遠端
先對本機副本測試;確認沒問題後,再套用到雲端上真正的資料庫。
# Build the schema on the local test DB npx wrangler d1 migrations apply blog-db --local # See what is still pending on production npx wrangler d1 migrations list blog-db --remote # Apply to the real cloud database npx wrangler d1 migrations apply blog-db --remote之後讓綱要演進
需要新欄位?建立第二個 migration,而不是去改第一個——接著用同樣方式套用。
npx wrangler d1 migrations create blog-db add_published_at_to_posts # In migrations/0002_add_published_at_to_posts.sql: # ALTER TABLE posts ADD COLUMN published_at TEXT; npx wrangler d1 migrations apply blog-db --local npx wrangler d1 migrations apply blog-db --remote從 Worker 跨表查詢
prepare() 建立安全查詢,bind() 填入 ? 佔位符(擋掉 SQL 注入),JOIN 則把每篇貼文和它的作者一起拉出來。
export default { async fetch(request, env) { const url = new URL(request.url); const userId = url.searchParams.get("user_id") ?? "1"; // Join each post to its author, newest first. const { results } = await env.DB .prepare( `SELECT posts.id, posts.title, posts.status, users.name AS author FROM posts JOIN users ON users.id = posts.user_id WHERE posts.user_id = ? ORDER BY posts.created_at DESC` ) .bind(userId) .all(); return Response.json(results); }, };部署上線
發布這個 Worker——你那個有綱要撐腰的 API 就上線了。
npx wrangler deploy
塞一點資料來測試
npx wrangler d1 execute blog-db --remote --command "INSERT INTO users (email, name) VALUES ('mei@example.com', 'Mei');"
npx wrangler d1 execute blog-db --remote --command "INSERT INTO posts (user_id, title, status) VALUES (1, 'Hello D1', 'live');"常見陷阱與小提示
絕不要改已套用的 migration
一旦 0001 已經在共用資料庫跑過,再去改它也不會重跑,隊友的資料庫就會慢慢和你的不同步。要改變更,永遠加一個新的有編號 migration。
- 用 PRAGMA foreign_keys = ON; 開啟外鍵約束,否則 SQLite 會忽略 FK 規則。
- 為每個外鍵、以及任何常用來篩選或排序的欄位都建立索引。
- SQLite 的 ALTER TABLE 能新增欄位、改名,但不容易刪欄位——欄位請事先想清楚再設計。
- 新增欄位時優先用 NOT NULL 搭配 DEFAULT,這樣既有的列才不會變不合法。
- 每個 migration 都先用 --local 測過再 --remote;本機副本又快又可隨時丟掉重建。
- 把 migrations/ 資料夾放進 Git,整段綱要歷史就會跟著你的程式碼一起走。
索引能省下讀取列數
D1 是依讀取與寫入的列數計費。好的索引能讓查詢只碰到真正需要的列,而不是把整張表掃一遍——回應更快,帳單也更小。