Schema 設計與關聯:外鍵、索引、約束 | Supabase 完整教學

2026/09/24
Schema 設計與關聯:外鍵、索引、約束 | Supabase 完整教學

學會建單張 table 之後,真正的資料庫設計才要開始。Supabase 底層的 Postgres 讓你用**外鍵(Foreign Key)把多張表串起來,用索引(Index)讓查詢飛快,用約束(Constraint)**擋掉髒資料。本篇帶你搞懂正規化、一對多/多對多關聯、on delete 行為、btree/複合/部分索引,以及用 profiles 表關聯 auth.users 的經典模式,設計出經得起成長的 schema。

前言

一句話定義本篇主題:這篇要教你如何把多張資料表用外鍵正確地關聯起來、用索引加速查詢、用約束保證資料品質,並掌握 Supabase 專案裡最常見的 profiles 關聯 auth.users 模式。

上一篇《Postgres 資料庫入門》我們學會了建立單張資料表——選對型別、設好主鍵、insert 與 select。但真實世界的資料幾乎不會是孤立的:一位作者寫很多篇文章、一張訂單含多個商品、一部電影有很多位演員。這些「表與表之間的關係」,就是**資料建模(Data Modeling)**的核心,也是這一篇的主角。

如果用現實類比:單張表像一本各自獨立的筆記本,而關聯就是在筆記本之間拉出的參照線——「這篇文章的作者,指向作者名冊裡的第 42 號」。有了這條線,資料庫才能保證「不會有文章指向一個根本不存在的作者」,也才能在查詢時把散落各表的資料**接(JOIN)**回來。設計得好,資料乾淨、查詢快速、日後好維護;設計得差,資料重複、關係混亂、效能低落,越長越難救。

本篇你會學到:

  • **正規化(Normalization)**的基本精神,以及為什麼要把資料拆表
  • 外鍵與三種關聯:一對多、多對多(join table)、一對一,以及 references / on delete 的行為
  • 索引策略:btree(預設)、複合索引、部分索引,以及「外鍵不會自動建索引」這個大坑
  • 約束:not null、unique、check 如何在資料庫層把關資料品質
  • 實戰模式:自訂 schema vs public,以及用 profiles 表關聯 auth.users

核心概念

正規化:為什麼要拆表

**正規化(Normalization)**聽起來很學術,但精神很白話:同一份事實只存一次,用關聯去引用它,而不是到處複製。

舉個反例。假設你把「文章」和「作者」硬塞在同一張表裡:

idtitleauthor_nameauthor_email
1Postgres 入門陳大文ben@example.com
2索引實戰陳大文ben@example.com
3RLS 教學陳大文ben@example.com

問題來了:陳大文的 email 被複製了三次。哪天他換 email,你得改三筆(漏改就資料不一致);而且如果他還沒發任何文章,你根本沒地方存他。正確做法是拆成兩張表——一張 authors 存作者(每位作者只存一次),一張 articles 存文章,文章用一個 author_id 外鍵指回作者。這樣 email 只存一份、改一次就好,這就是正規化要解決的核心問題:消除重複、避免更新異常。

實務上不必追求極致的正規化理論(第三正規化通常就夠用),重點是抓住直覺:看到同一組資料在多列重複出現,就是該拆表、用外鍵關聯的訊號。

外鍵與三種關聯類型

外鍵(Foreign Key)是一個欄位(或一組欄位),它的值必須對應到另一張表主鍵的既有值。它做兩件事:表達關聯、強制參照完整性(不允許指向不存在的資料)。用 references 關鍵字宣告。三種最常見的關聯:

一對多(One-to-Many):最常見。一位作者對應多篇文章、一個分類對應多部電影。做法是在「多」的那一方(子表)放外鍵,指回「一」的那一方(父表)。

多對多(Many-to-Many):一部電影有多位演員、一位演員也演多部電影。這種關係無法只靠一個外鍵表達,必須引入一張中間表(join table,又稱關聯表),用兩個外鍵+複合主鍵把兩邊接起來。

一對一(One-to-One):一位使用者對應一份設定檔。做法是在外鍵欄位再加一個 unique 約束,強制「一對一」——這正是稍後 profiles 關聯 auth.users 會用到的模式。

on delete:刪除父列時,子列怎麼辦

宣告外鍵時,最該想清楚的是 on delete 行為——當被指向的父列被刪除,子列該如何反應。三個選項:

選項行為適用場景
on delete cascade連鎖刪除:父列刪除時,子列一起刪掉子資料離開父資料就沒意義(刪使用者→刪其貼文)
on delete set null子列的外鍵設為 null,但保留該列關聯可有可無(刪分類後商品還在、只是暫無分類)
on delete restrict只要有子列存在,就禁止刪除父列(不寫時的預設)想強制你先處理完子資料才能刪父資料

這是資料建模時必須明確決定的一環,別因為「反正不寫也能動」就跳過——預設的 restrict 未必是你要的行為。

索引:讓查詢從龜速變飛快

索引(Index)就像書末的索引頁:與其一頁頁翻找關鍵字(全表掃描,Seq Scan),不如直接查索引頁定位。它能讓讀取查詢快上數十甚至上百倍,代價是每次寫入(INSERT/UPDATE/DELETE)都要同步維護索引,稍微拖慢寫入。三種入門必懂:

  • btree(B樹,預設):最通用,適合等值比較(=)與範圍查詢(<、>、between)、以及 order by。不指定類型時建的就是它。
  • 複合索引(Composite Index):一個索引涵蓋多個欄位,適合經常「同時用多欄過濾/排序」的查詢。欄位順序很重要——把最常用來過濾的欄位放前面。
  • 部分索引(Partial Index):只對符合某條件的列建索引(用 where 指定),索引更小、更快,適合「只查一小部分資料」的場景(如只查未刪除、只查公開的)。

一個關鍵事實先記住:Postgres 不會自動替外鍵欄位建索引(只有主鍵和 unique 會自動建)。這是效能問題的頭號來源,下面實作會處理。

約束:在資料庫層把關品質

**約束(Constraint)**讓資料庫幫你擋髒資料,比在應用層檢查更可靠(因為任何寫入路徑都繞不過它):

  • not null:這一欄必填,不能留空。
  • unique:這一欄(或多欄組合)的值不可重複,例如 email 不該有兩筆一樣。
  • check:自訂條件,例如 price >= 0(價格不可為負)、char_length(title) <= 200。

實作範例

我們用一個部落格情境把上面所有概念串起來:作者寫文章(一對多)、文章與標籤多對多、每位使用者有一份 profile(一對一關聯 auth.users)。打開 Supabase 的 SQL Editor,跟著逐段執行。

步驟一:一對多——文章 references 作者

先建父表 authors,再建子表 articles,用 author_id 外鍵指回去:

-- 父表:作者
create table authors (
  id         bigint generated always as identity primary key,
  name       text not null,
  email      text unique not null,          -- unique:email 不可重複
  created_at timestamptz not null default now()
);

-- 子表:文章,用外鍵指向作者(一對多)
create table articles (
  id         bigint generated always as identity primary key,
  author_id  bigint not null
             references authors(id) on delete cascade,  -- 作者被刪,其文章一起刪
  title      text not null,
  content    text,
  status     text not null default 'draft'
             check (status in ('draft', 'published', 'archived')),  -- check 約束
  view_count integer not null default 0
             check (view_count >= 0),        -- 觀看數不可為負
  created_at timestamptz not null default now()
);

逐點拆解這段:

  • references authors(id):宣告 author_id 是外鍵,指向 authors 表的 id。插入文章時若 author_id 對應的作者不存在,資料庫會直接拒絕——這就是參照完整性。
  • on delete cascade:刪掉某位作者時,他名下的所有文章會被連帶刪除。因為「作者不在了,他的文章也失去歸屬」,這裡用 cascade 很合理。
  • email text unique not null:同時套用兩個約束——不可空、且不可重複。
  • check (status in (...)):把 status 限制成三個合法值,擋掉任何拼錯或亂填的狀態。
  • check (view_count >= 0):確保觀看數永遠非負。

步驟二:多對多——用 join table 連文章與標籤

一篇文章可有多個標籤、一個標籤也貼在多篇文章上。這是多對多,需要一張中間表 article_tags:

-- 標籤表
create table tags (
  id   bigint generated always as identity primary key,
  name text unique not null
);

-- 中間表(join table):連接 articles 與 tags
create table article_tags (
  article_id bigint not null references articles(id) on delete cascade,
  tag_id     bigint not null references tags(id)     on delete cascade,
  primary key (article_id, tag_id)   -- 複合主鍵:同一組合不重複
);

這張中間表是多對多的靈魂,注意兩個設計:

  • 兩個外鍵:article_id 指向文章、tag_id 指向標籤,各自 on delete cascade——刪文章或刪標籤時,這張關聯表裡對應的紀錄自動清掉,不留孤兒。
  • 複合主鍵 primary key (article_id, tag_id):把兩欄合起來當主鍵,保證「同一篇文章不會重複貼同一個標籤」,同時(因為主鍵會自動建索引)也順帶替 article_id 建好了索引。

實際掛標籤與查詢的樣子:

-- 幫 id=1 的文章掛上 id=3、id=5 兩個標籤
insert into article_tags (article_id, tag_id)
values (1, 3), (1, 5);

-- 查某篇文章的所有標籤(跨三張表 JOIN)
select a.title, t.name as tag
from articles a
join article_tags at on a.id = at.article_id
join tags t          on t.id = at.tag_id
where a.id = 1;

步驟三:補索引——外鍵不會自動建,要自己來

前面說過,Postgres 不會替外鍵自動建索引。article_tags 的 article_id 因為是複合主鍵的第一欄已被索引,但 tag_id、以及 articles.author_id 都還沒有。我們把常用來 JOIN/過濾的欄位補上:

-- 外鍵索引:加速「撈某作者的所有文章」
create index idx_articles_author_id on articles (author_id);

-- 中間表另一側的外鍵也要補(否則「某標籤有哪些文章」會慢)
create index idx_article_tags_tag_id on article_tags (tag_id);

接著示範另外兩種索引。複合索引適合「同時過濾多欄」的查詢,例如「撈某作者的已發布文章、依時間新到舊」:

-- 複合索引:欄位順序=先過濾 author_id,再依 created_at 排序
create index idx_articles_author_created
  on articles (author_id, created_at desc);

部分索引只索引符合條件的列,讓索引更精簡。假設多數查詢只看 published 的文章:

-- 部分索引:只索引已發布的文章,索引更小更快
create index idx_articles_published
  on articles (created_at desc)
  where status = 'published';

生產環境提醒:正式資料表上建索引,建議加 concurrently(例如 create index concurrently ...),它不會鎖住整張表、不影響線上讀寫,只是建得稍慢。開發初期表還空著時可省略。

步驟四:一對一——profiles 關聯 auth.users

這是幾乎每個 Supabase 專案都會用到的模式。Supabase 的登入系統把使用者存在 auth schema 的 auth.users 表裡,你不該直接去改它。標準做法是在 public schema 自己建一張 profiles 表,透過外鍵一對一對接:

-- 在 public schema 建 profiles,主鍵同時是指向 auth.users 的外鍵
create table profiles (
  id           uuid primary key
               references auth.users(id) on delete cascade,  -- 一對一 + 連帶刪除
  username     text unique,                 -- 暱稱不可重複
  display_name text,
  avatar_url   text,
  bio          text,
  updated_at   timestamptz not null default now()
);

-- 開啟 RLS(Supabase 資料表的標準做法,細節留待 RLS 專篇)
alter table profiles enable row level security;

這裡的設計巧思:id uuid primary key references auth.users(id) 一箭雙鵰——id 既是 profiles 的主鍵、又是指向 auth.users 的外鍵。因為主鍵天生 unique,就自動達成「一個 auth 使用者只能有一份 profile」的一對一關係,不必再額外加 unique。on delete cascade 則保證使用者被刪除時,profile 一起消失。

最後補上經典的一步:用一個 trigger 在使用者「註冊當下」自動幫他建好 profile,省得應用端每次都要記得補:

-- 新使用者建立時自動插入一筆 profile
create or replace function handle_new_user()
returns trigger
language plpgsql
security definer set search_path = public   -- 存取 auth 需要,且要鎖定 search_path
as $$
begin
  insert into public.profiles (id, display_name)
  values (new.id, new.raw_user_meta_data ->> 'name');
  return new;
end;
$$;

-- 綁到 auth.users 的 after insert
create trigger on_auth_user_created
  after insert on auth.users
  for each row
  execute function handle_new_user();

到這裡,你已經把一對多、多對多、一對一三種關聯,連同索引與約束全部實作了一遍,這就是一個真實 Supabase 專案 schema 的縮影。

補充:自訂 schema vs public

前面所有應用表都放在預設的 public schema——它是「一般應用資料」的家,Supabase 自動產生的 API 預設也只暴露它。但有時你有不想透過 API 對外暴露的敏感資料(如薪資、內部設定),可以建一個自訂 schema 收納:

-- 建立一個不對外暴露的 private schema
create schema if not exists private;

create table private.salaries (
  id          bigint generated always as identity primary key,
  profile_id  uuid not null references profiles(id) on delete cascade,
  amount      numeric(12, 2) not null check (amount >= 0),
  effective_date date not null
);

因為 private 不在 Supabase API 預設暴露的清單裡,這張表就不會被前端 REST API 直接讀到,多一層安全防護。要開放某個自訂 schema 給 API,需在專案的 API 設定中明確加入。系統 schema(auth、storage)則只讀不改,交給 Supabase 管理。

常見錯誤與最佳實踐

坑一:忘記替外鍵建索引。 最經典也最痛的坑。Postgres 只自動替主鍵與 unique 建索引,外鍵欄位一律要自己補。沒補的後果是:任何用外鍵欄位 JOIN 或過濾的查詢(例如 where author_id = ...)都退化成全表掃描,資料一多就明顯卡。正確做法:凡是會拿來 JOIN 或 WHERE 的外鍵,都建一個 btree 索引,如 create index idx_articles_author_id on articles (author_id)。

坑二:on delete 沒設,出事才發現。 不寫 on delete 時預設是 restrict——只要子列還在就禁止刪父列。很多人以為「刪掉使用者就會連帶刪掉他的資料」,結果刪不動、或反過來用了不該用的 cascade 誤刪一堆資料。正確做法:每個外鍵都明確寫出 on delete,並想清楚語意——連帶清除用 cascade、保留孤兒用 set null、強制先處理用 restrict。

坑三:直接去改 auth.users。 把頭像、暱稱等欄位硬加到 auth.users 上,或修改它的結構——這會在 Supabase 內部升級時出問題。正確做法:用獨立的 public.profiles 表,以 id references auth.users(id) on delete cascade 一對一對接,應用資料放 profiles、帳號驗證交給 auth。

坑四:過度正規化,把簡單問題複雜化。 正規化是好事,但矯枉過正——把每個小屬性都拆成獨立表、關聯層層疊疊——會讓每次查詢都得 JOIN 一大串,既難寫也難跑。正確做法:以第三正規化為務實目標即可;對「幾乎不會單獨變動、也不會被別處引用」的彈性屬性,直接用 jsonb 欄位存反而更簡潔。

坑五:複合索引欄位順序放錯。 複合索引 (a, b) 能高效服務「用 a 過濾」或「用 a 和 b 一起過濾」的查詢,但對「只用 b 過濾」幫助有限。正確做法:把最常單獨用來過濾的欄位放最前面,通常是那個選擇性最高(能篩掉最多列)的欄位。

最佳實踐總結:

  • 每個會 JOIN/過濾的外鍵都補 btree 索引——這是效能第一守則
  • 每個外鍵都明確寫 on delete(cascade / set null / restrict),別靠預設
  • 用 not null / unique / check 在資料庫層把關,別只靠應用端驗證
  • 多對多一律用 join table +複合主鍵,兩側外鍵各設 on delete cascade
  • 使用者資料用 profiles 表關聯 auth.users,別碰 auth schema
  • 生產環境建索引用 create index concurrently 避免鎖表
  • 正規化以務實的第三正規化為目標,彈性屬性可用 jsonb 收納

小結

這篇是 Supabase 系列教學的第 005 篇。承接上一篇《Postgres 資料庫入門》——我們已經會建單張表、選對型別與主鍵,這篇則讓這些表互相認識,正式踏入資料建模的核心:

  • 正規化:同一份事實只存一次,用關聯引用它,消除重複與更新異常
  • 外鍵與關聯:一對多(子表放外鍵)、多對多(join table +複合主鍵)、一對一(外鍵加 unique 或直接當主鍵),並用 references 與 on delete(cascade / set null / restrict)控制刪除行為
  • 索引:btree(預設)、複合索引(注意欄位順序)、部分索引(只索引部分列),並牢記「外鍵不會自動建索引,要自己補」
  • 約束:not null、unique、check 在資料庫層把關資料品質
  • 實戰模式:public vs 自訂 schema,以及 profiles 表以 references auth.users(id) on delete cascade 一對一對接、搭配 trigger 自動建檔

有了乾淨的 schema 與關聯,你的資料模型就有了堅實骨架。而 Postgres 真正的威力,其實還藏在它龐大的**擴充套件(Extensions)**生態裡——向量搜尋、地理空間、排程任務、加密雜湊,都靠 Extensions 一鍵解鎖。

下一篇《Postgres Extensions》,我們會帶你認識 Supabase 預裝的 50+ 個 Extensions,從 pgvector(語意搜尋)、postgis(地理位置)到 pg_cron(排程任務),學會如何啟用與運用它們,把資料庫變成一台多功能引擎。schema 打好底之後,是時候幫它裝上翅膀了。下一篇見。

BenZ Software Developer

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

本週主打

AI 自動化入門包

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

看看這個產品 →