D1 的關聯與 JOIN 查詢
用關聯式的方式設計 使用者、貼文、標籤,再用一句 JOIN 把它們重新拼回來。
我們要建立什麼模型?
真實的應用程式很少把所有東西塞進一張大表。我們會把資料拆成各司其職的小表,再用「關聯」把它們連起來(關聯 = 一條規則,說明某張表的列和另一張表的列怎麼對應)。這篇我們會做一個迷你部落格:使用者(users)會發表貼文(posts),而貼文會被貼上標籤(tags)。
兩種關聯幾乎就涵蓋了大部分情境:一對多(一位使用者寫很多篇貼文)以及多對多(一篇貼文有很多標籤,一個標籤也貼在很多貼文上)。資料拆開之後,我們再用 JOIN(合併查詢)在一次查詢裡把它們黏回來。
把它想成…
一棵家族樹。每個人(使用者)可以有很多小孩(貼文)——這條分支就是一對多。但「朋友關係」是雙向的:你有很多朋友、每個朋友也有很多朋友——這張網就是多對多,而你需要一份額外的名單來記錄「誰和誰是朋友」。
兩種關聯的形狀
在寫任何 SQL 之前,先抓住這兩種模式的感覺。訣竅永遠是問一句:「這一邊的一列,對應到另一邊的幾列?」
一對多(1:N)
一位使用者寫很多篇貼文,但每篇貼文剛好只有一位作者。由「多」的那一邊(貼文)存一個欄位,指回「一」的那一邊。
多對多(M:N)
一篇貼文有很多標籤,一個標籤也貼在很多貼文上——兩邊都是「多」。任何一張表都無法靠自己記下這個連結。
多對多要用中介表
多對多需要第三張中介表(post_tags),每一個連結存成一列:貼文 10 ↔ 標籤 'sql'。這是唯一誠實記錄它的方式。
別把清單塞進一個欄位
你可能會想把標籤存成一個 posts 欄位裡的文字 'sql,cloudflare'。別這樣。它無法建索引、無法 JOIN,也無法有效率地問「哪些貼文有 sql 標籤?」請改用中介表。
資料模型(ER 圖)
ER 圖(實體關聯圖)把資料表畫成方塊、把關聯畫成連線。那個像鳥腳的分叉符號(crow's-foot)標示「多」的那一端。這裡 users 對 posts 是一對多,posts 對 tags 是多對多——並透過 post_tags 拆成兩段一對多。
主鍵(PK)
每張表的 id 用來唯一指認某一列,像身分證字號。沒有兩列會重複。
外鍵(FK)
posts.user_id 是外鍵:它的值必須等於某個 users.id。這個指標就是建立一對多連結的關鍵。
複合主鍵
post_tags 把 (post_id, tag_id) 兩欄合起來當主鍵,所以同一組貼文-標籤配對不會被連結兩次。
JOIN 到底在做什麼
JOIN 把兩張表的列拿出來,依照配對規則把相符的縫成一條更寬的列。你用 ON 條件告訴它怎麼配對——通常就是「外鍵等於主鍵」。就這樣:它是一個發生在資料庫內部、而不是在你程式碼裡的查表動作。
INNER JOIN(內部合併)
只保留兩邊都配對成功的列。找不到對應作者的貼文,就會直接從結果裡消失。
LEFT JOIN(左側合併)
保留左表的每一列,就算沒配對到也留著——很適合讓「零標籤的貼文」依然出現(標籤欄會是 NULL)。
ON 配對條件
ON posts.user_id = users.id 就是配對列的規則。ON 寫錯就會多出重複列或漏列,所以務必再三確認。
從頭到尾動手做
現在把整條線串起來:建立資料表(含帶外鍵的中介表)、塞入資料、跑兩種 JOIN、用批次交易做原子寫入,最後從 Worker 與網頁讀出來。
綁定資料庫
跑完 'wrangler d1 create blog-db' 後,加上這個綁定,讓 Worker 能用 env.DB 存取它。
{ "d1_databases": [ { "binding": "DB", "database_name": "blog-db", "database_id": "<paste-your-id-here>" } ] }建立資料表結構(含外鍵)
post_tags 就是中介表。它有兩個外鍵和一個複合主鍵。ON DELETE CASCADE 代表刪掉一篇貼文時,會自動把它的連結列一併清掉。
-- users: one row per author CREATE TABLE users ( id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL ); -- posts: each post belongs to exactly ONE user (one-to-many) CREATE TABLE posts ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); -- tags: one row per reusable label CREATE TABLE tags ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE ); -- post_tags: the JOIN TABLE linking posts and tags (many-to-many) CREATE TABLE post_tags ( post_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (post_id, tag_id), FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ); -- index the foreign-key columns so JOINs stay fast CREATE INDEX idx_posts_user_id ON posts(user_id); CREATE INDEX idx_post_tags_tag_id ON post_tags(tag_id);塞入一些資料
兩位使用者、三篇貼文、三個標籤,以及把貼文連到標籤的連結列。
INSERT INTO users (id, email, name) VALUES (1, 'mei@example.com', 'Mei'), (2, 'alex@example.com', 'Alex'); INSERT INTO posts (id, user_id, title) VALUES (10, 1, 'Hello D1'), (11, 1, 'JOINs explained'), (12, 2, 'Edge databases'); INSERT INTO tags (id, name) VALUES (100, 'sql'), (101, 'cloudflare'), (102, 'beginner'); -- the many-to-many link rows INSERT INTO post_tags (post_id, tag_id) VALUES (10, 100), (10, 101), (11, 100), (11, 102), (12, 101);查一對多(貼文 + 作者)
用外鍵把 posts JOIN 到 users。現在每一列結果都帶著從 users 表取來的作者姓名。
-- One-to-many: every post WITH its author's name. SELECT posts.id, posts.title, users.name AS author FROM posts JOIN users ON posts.user_id = users.id WHERE users.id = ? ORDER BY posts.id DESC;查多對多(貼文 + 標籤)
從 posts → post_tags → tags 一路跳過去。因為一篇貼文會配對到很多標籤,用 GROUP BY + GROUP_CONCAT 把標籤摺回成「每篇貼文一列」。
-- Many-to-many: every post with a comma list of its tags. SELECT posts.id, posts.title, GROUP_CONCAT(tags.name) AS tags FROM posts LEFT JOIN post_tags ON post_tags.post_id = posts.id LEFT JOIN tags ON tags.id = post_tags.tag_id GROUP BY posts.id ORDER BY posts.id; -- The other direction: which posts carry the 'sql' tag? SELECT posts.title FROM posts JOIN post_tags ON post_tags.post_id = posts.id JOIN tags ON tags.id = post_tags.tag_id WHERE tags.name = ?;用 batch() 做原子寫入
建立貼文「以及」它的標籤連結必須是「全有或全無」。env.DB.batch() 會把所有語句依序放進同一個交易執行;last_insert_rowid() 會回傳同一條連線上剛插入那篇貼文的 id。
// Create a post AND attach two tags as ONE atomic transaction. // If any statement fails, D1 rolls the whole batch back. const results = await env.DB.batch([ env.DB .prepare("INSERT INTO posts (user_id, title, body) VALUES (?, ?, ?)") .bind(userId, title, body), env.DB .prepare("INSERT INTO post_tags (post_id, tag_id) VALUES (last_insert_rowid(), ?)") .bind(sqlTagId), env.DB .prepare("INSERT INTO post_tags (post_id, tag_id) VALUES (last_insert_rowid(), ?)") .bind(cfTagId), ]);從 Worker 回傳合併好的資料
一個端點、一次查詢:在伺服器端就把作者和標籤合好,讓瀏覽器收到可以直接渲染的列。
export default { async fetch(request, env) { const url = new URL(request.url); // GET /api/users/:id/posts -> each post joined with author + tags if (request.method === "GET" && url.pathname.startsWith("/api/users/")) { const userId = url.pathname.split("/")[3]; const { results } = await env.DB .prepare( `SELECT posts.id, posts.title, users.name AS author, GROUP_CONCAT(tags.name) AS tags FROM posts JOIN users ON posts.user_id = users.id LEFT JOIN post_tags ON post_tags.post_id = posts.id LEFT JOIN tags ON tags.id = post_tags.tag_id WHERE users.id = ? GROUP BY posts.id ORDER BY posts.id DESC` ) .bind(userId) .all(); return Response.json(results); } return new Response("Not found", { status: 404 }); }, };在瀏覽器渲染
前端只要 fetch JSON。每一列都已經帶著作者和標籤——不用額外請求,也不用在前端自己合併。
// Browser: load one author's posts. Each row already carries the // author name and tag list, thanks to the JOINs on the server. const res = await fetch(`/api/users/${userId}/posts`); const posts = await res.json(); posts.forEach((post) => { // e.g. 'Hello D1 - by Mei - sql,cloudflare' console.log(`${post.title} - by ${post.author} - ${post.tags}`); });
一次查詢勝過很多次
沒有 JOIN 的話,你得先抓貼文,再用迴圈一筆筆去抓每個作者、每個標籤——這就是經典的「N+1」問題。一句 JOIN 讓資料庫在貼近資料的地方一次把配對做完。
重點術語
一對多
一筆父列擁有很多子列;每筆子列指回唯一的父列(posts → users)。
多對多
兩邊都互相對應到很多筆(posts ↔ tags);沒有中介表就存不下來。
中介表
一張小表(post_tags),唯一的工作就是為兩張表之間的每個連結存一列。
JOIN 合併查詢
一種 SELECT,依 ON 條件配對兩張表的列,再把它們合併成更寬的列。
外鍵(FK)
一個必須對應到另一張表主鍵的欄位——資料庫可以替你強制檢查這件事。
交易(transaction)
一組寫入,要嘛全部成功、要嘛全部失敗,讓資料保持一致。batch() 就給你一個交易。
陷阱與小提示
D1 會強制檢查外鍵
先插父、再插子:一筆 posts 列需要它對應的 users 列先存在。若批次裡的語句彼此相依,可以加上 PRAGMA defer_foreign_keys = true; 把檢查延到交易提交時再做。
- 一定要在外鍵欄位(user_id、tag_id)上建索引,JOIN 才不會掃整張表。
- JOIN 可能讓列數倍增(一篇貼文 × 多個標籤);用 GROUP BY + GROUP_CONCAT 摺回成每篇貼文一列。
- 當左側就算沒配對到也要出現時(零標籤的貼文),請用 LEFT JOIN。
- batch() 是原子且全有全無的——最適合把「建立貼文 + 它的標籤連結」綁在一起。
- D1 依讀取與寫入列數計費;少讀幾列的精準 JOIN 也比較省錢。
- 把 CREATE TABLE 語句放進 schema.sql,再用 wrangler d1 execute --file=./schema.sql 套用。