Supabase 最佳實踐與反模式:一張該做 vs 別做的總表 | Supabase 完整教學
這是 Supabase 系列的收官前一篇。過去四十五篇我們橫跨了資料庫、RLS、Auth、Storage、Realtime、Edge Functions 一路到自架維運。這一篇不教新功能,而是把散落各篇的關鍵原則,收斂成一份可對照的「該做 vs 別做」總表——涵蓋安全(一律開 RLS、
service_role只放後端、金鑰管理)、資料建模、效能、維運四大面向,並逐一拆解最常見的反模式。把該做的變成習慣、把反模式變成警訊,才是讓 Supabase 專案長期健康的最後一哩路。
前言
先用一句話定義本篇:最佳實踐(best practices)不是一堆規則的堆疊,而是一套「預設就做對」的心智習慣;反模式(anti-patterns)則是那些「當下省事、日後爆炸」的常見捷徑。 這一篇的目標,是把你在前面四十五篇學到的零碎原則,織成一張能隨手對照的檢查網——寫每一段程式碼、開每一張表、設每一條策略時,腦中都有一把尺。
用一個類比理解為什麼要有這份清單。如果把 Supabase 專案比作一棟房子,前面各篇教你的是怎麼砌牆、拉水電、裝門窗;而最佳實踐就是那本「住戶安全手冊」——瓦斯用完關閥、出門鎖門、定期檢查煙霧偵測器。 每一條單獨看都很基本,但正是這些基本動作的缺席,造成了絕大多數的資安事故與半夜當機。真正的資深工程師,不是知道多少炫技的功能,而是把這些基本動作內化成反射。
再點破一個關鍵前提:Supabase 的架構,是「前端直連資料庫、由資料庫本身當守門員」。 這跟傳統「前端 → 後端 API → 資料庫」的三層架構不同——在 Supabase 裡,前端拿著 anon key 直接打 PostgREST,中間沒有一層後端幫你把關。這意味著你的安全邊界不在後端程式碼,而在資料庫的 RLS 政策與權限設定裡。理解這一點,就理解了為什麼「一律開 RLS」是本系列反覆強調、且永不妥協的第一原則。
本篇你會學到:
- 四大面向的最佳實踐:安全、資料建模、效能、維運,各自的核心原則與正確做法
- 正確 vs 反模式對照:用可執行的 SQL/程式碼,把「該這樣寫」與「別這樣寫」並排講清楚
- 常見反模式清單:前端用
service_role、關掉 RLS、商業邏輯全前端、N+1 查詢、無備份——為什麼危險、怎麼修 - 一張總表:把散落各篇的原則收斂成可隨手對照的心智檢查清單
核心概念
在動手看程式碼之前,先建立一張分類地圖:Supabase 的最佳實踐可以歸到四個面向,每個面向都有它對應的核心反模式。理解「每個面向在防什麼」,比死記規則更有用。
一、四大面向與它們各自要防的事
| 面向 | 核心原則 | 對應的反模式 | 後果 |
|---|---|---|---|
| 安全 | 一律開 RLS、service_role 只在後端、金鑰分清楚 | 前端用 service_role、關 RLS、信 user_metadata | 資料外洩、任意讀寫 |
| 資料建模 | schema 分層、命名一致、外鍵完整 | 全塞 public、Foreign Table 無保護 | 權限失控、難維護 |
| 效能 | 策略欄位建索引、(SELECT auth.uid()) 快取、避免 N+1 | 漏索引全表掃描、N+1 查詢、雙重池化 | 查詢極慢、連線耗盡 |
| 維運 | 遷移用版本控管、備份且測還原、監控告警 | 手改正式庫、無備份、只看不告警 | 環境漂移、資料永久遺失 |
這張表是本篇的骨架。接下來的每一節,都是在把某一格展開成「為什麼」與「怎麼做」。
二、安全:三條不可妥協的底線
安全是四大面向裡唯一不能用「效能換方便」來妥協的一塊,因為它的失敗模式是「災難級」而非「劣化級」。三條底線:
- 底線一:一律開 RLS。前面說過,Supabase 前端直連資料庫。
publicschema 裡任何一張沒開 RLS 的表,等於對全世界的anonkey 敞開。建表後立刻ENABLE ROW LEVEL SECURITY,並記住:啟用後預設拒絕一切,必須明確建立允許策略。 - 底線二:
service_role只在後端。金鑰分兩種——anonkey 可公開、不繞過 RLS,給前端用;service_rolekey 絕對機密、繞過所有 RLS,只能存在於 Edge Function、後端 API、CI/CD 的環境變數裡。它一旦進了前端打包產物或公開 Repo,等於把資料庫的萬能鑰匙丟到街上。 - 底線三:權限判斷別信
user_metadata。JWT 裡的user_metadata使用者能自行修改,拿它判斷role = 'admin'等於讓使用者自封管理員。要嘛用只能由service_role修改的app_metadata,要嘛(最安全)直接查資料庫的user_roles表。
三、資料建模與效能:結構決定一切
資料建模的核心是 schema 分層與命名一致:給前端直接存取的放 public(且都開 RLS);不該被前端碰的(Foreign Table、內部函式、暫存表)放 private schema,再用 Security Definer 函式包一層對外開放。命名上,資料表用複數蛇底(user_profiles)、外鍵欄位用 xxx_id,一致的命名讓 PostgREST 自動生成的 API 與巢狀查詢更好用。
效能的兩大主軸貫穿全系列:一是索引——RLS 策略裡拿來比對的欄位(通常是 user_id)一定要建索引,否則百萬筆資料每次查詢都全表掃描;二是查詢形狀——用 Supabase SDK 的巢狀 select 一次 JOIN 取回關聯資料,避免 N+1 那種「一筆資料打一次 API」的災難。再加上 RLS 策略用 (SELECT auth.uid()) 觸發快取、避免雙重池化,效能的地基就穩了。
四、維運:遷移紀律與備份
維運面的核心是紀律。遷移(migration)一定要走版本控管——用 Supabase CLI 產生 migration 檔、進 Git、在 CI 套用到各環境,永遠不要手動去正式庫改 schema,否則環境間會漂移、出事無法重現。備份則是「沒測過還原的備份等於沒有備份」——自架 Supabase 沒有內建自動備份,pg_dump 或 WAL-G 都要自己排,且每月至少演練一次還原。這些在自架段(SB-5)已詳述,本篇把它們納入總清單。
實作範例
這一節把幾個最關鍵的最佳實踐,用正確 vs 反模式並排的方式攤開。每段都可直接在 Studio 的 SQL Editor 或你的專案裡執行。
一、建表就開 RLS(而非事後補)
最該內化成肌肉記憶的一段:建表、開 RLS、寫策略,三件事一氣呵成。
-- ❌ 反模式:建完表就走人,忘了 RLS
CREATE TABLE public.documents (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid REFERENCES auth.users,
content text
);
-- 後果:沒有 ENABLE RLS → 任何人拿 anon key 就能讀寫所有人的文件
-- ✅ 正確:建表後立刻開 RLS,並補上擁有者策略
CREATE TABLE public.documents (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid REFERENCES auth.users,
content text
);
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY; -- 啟用後預設全拒絕
CREATE POLICY "使用者只能存取自己的文件"
ON public.documents
FOR ALL
TO authenticated
USING ((SELECT auth.uid()) = user_id) -- 讀取/刪除時檢查
WITH CHECK ((SELECT auth.uid()) = user_id); -- 寫入時檢查
判讀重點:ENABLE ROW LEVEL SECURITY 之後,若沒有任何策略,等於全部拒絕——這是刻意的安全預設。注意 USING 用 (SELECT auth.uid()) 包起來,這不只是風格,而是效能優化(下面第三段會解釋)。
想一次揪出「漏開 RLS」的表,把這條檢查排進上線清單:
-- 找出 public schema 裡「沒有啟用 RLS」的資料表
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
AND tablename NOT IN (
SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relrowsecurity = true AND n.nspname = 'public'
);
-- 回傳結果應該是「空的」;有任何一列,就是一個安全破口
二、service_role 只在後端(前端只用 anon)
金鑰放錯位置是災難級的錯。前端一律 anon,需要繞 RLS 的管理操作放後端。
// ❌ 反模式:service_role 出現在前端(會被打包進 JS,全世界可見)
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY! // ← 萬能鑰匙外洩!
)
// ✅ 正確:前端只用 anon key
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! // ← anon key 設計來公開,受 RLS 保護
)
// ✅ 正確:需要 admin 權限的操作,放後端 API route 或 Edge Function
// app/api/admin/route.ts —— 這段程式碼只在伺服器端執行
import { createClient } from '@supabase/supabase-js'
export async function POST(request: Request) {
const admin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // ← 只存在於伺服器環境變數
)
// 在這裡做繞過 RLS 的管理操作,例如批次匯入、跨使用者統計
}
關鍵區別在環境變數前綴:NEXT_PUBLIC_ 開頭的變數會被打包進前端,所以 service_role 永遠不能用這種前綴,只能放沒有前綴、僅伺服器端可見的變數。
三、RLS 策略欄位要建索引 + 用 (SELECT auth.uid()) 快取
安全與效能在 RLS 這裡交會。光開 RLS 不夠,還要讓它跑得快。
-- ❌ 反模式:策略比對 user_id,卻沒索引,且每行都重算 auth.uid()
CREATE POLICY "read own" ON public.posts
FOR SELECT USING (auth.uid() = user_id);
-- 問題一:auth.uid() 對「每一列」都執行一次(沒被快取)
-- 問題二:user_id 沒索引 → 百萬筆資料時全表掃描,慢到逾時
-- ✅ 正確:(1) 用 SELECT 包裝 auth.uid() 觸發查詢級快取
CREATE POLICY "read own" ON public.posts
FOR SELECT USING ((SELECT auth.uid()) = user_id);
-- ✅ 正確:(2) 為策略比對的欄位建索引(用 CONCURRENTLY 避免鎖表)
CREATE INDEX CONCURRENTLY idx_posts_user_id
ON public.posts (user_id);
-- ✅ 正確:(3) 常見的複合查詢也建對應複合索引(最左前綴原則)
CREATE INDEX CONCURRENTLY idx_posts_user_created
ON public.posts (user_id, created_at DESC);
(SELECT auth.uid()) 這個小小的包裝很關鍵:沒包裝時,PostgreSQL 會把 auth.uid() 當成 volatile 函式,對掃描到的每一列都呼叫一次;包成子查詢後,規劃器會辨識它是 stable 的,整個查詢只算一次再快取。表越大,差距越明顯。
四、避免 N+1:用巢狀 select 一次 JOIN 取回
這是效能反模式裡最普遍的一個,尤其容易在前端不知不覺寫出來。
// ❌ 反模式:先撈文章,再逐篇撈作者 → N+1 次 API 請求
const { data: posts } = await supabase.from('posts').select('*')
for (const post of posts) {
const { data: author } = await supabase
.from('profiles').select('name').eq('id', post.user_id).single()
// 100 篇文章 = 1 + 100 = 101 次往返,每次都有網路延遲
}
// ✅ 正確:用 Supabase 的巢狀 select 一次 JOIN 取回作者資料
const { data: posts } = await supabase
.from('posts')
.select(`
id,
title,
created_at,
profiles ( id, name, avatar_url )
`)
// 只有 1 次 API 請求,PostgREST 在資料庫層做好 JOIN 再回傳
Supabase SDK 的巢狀 select 語法會被 PostgREST 轉成資料庫層的 JOIN,一次往返就把關聯資料帶回來。只要你發現迴圈裡在打 API,幾乎都是 N+1 的味道——停下來,改成一次巢狀查詢。
五、Foreign Table 與內部物件放 private schema
不該被前端直接碰的東西,不要放在 public。
-- ❌ 反模式:Foreign Table 放 public,且它不支援 RLS → 全裸露
CREATE FOREIGN TABLE public.stripe_customers (...) SERVER stripe_srv;
-- 任何有 anon key 的人都能直接讀取 Stripe 客戶資料
-- ✅ 正確:放 private schema,用 Security Definer 函式包一層對外
CREATE SCHEMA IF NOT EXISTS private;
CREATE FOREIGN TABLE private.stripe_customers (...) SERVER stripe_srv;
CREATE OR REPLACE FUNCTION public.get_my_stripe_info()
RETURNS TABLE (id text, email text) AS $$
SELECT id, email FROM private.stripe_customers
WHERE email = (SELECT auth.email()) -- 只回傳呼叫者自己的資料
$$ LANGUAGE sql SECURITY DEFINER SET search_path = '';
GRANT EXECUTE ON FUNCTION public.get_my_stripe_info() TO authenticated;
原則:public schema = 前端可直接存取的公開介面(且都開 RLS);private schema = 內部零件,只透過受控的函式對外。 Foreign Table 不支援 RLS,所以更必須用這種包裝模式。
常見錯誤與最佳實踐
把最容易出事的反模式集中列一遍。每一條都是「當下省事、日後爆炸」的典型——把它們背起來當警訊。
反模式一:前端用 service_role key。
最災難級的錯誤。service_role 繞過所有 RLS,一旦被打包進前端 JS 或提交到公開 Repo,等於把資料庫的萬能鑰匙公開。正確做法:前端只用 anon key;需要 admin 權限的操作全部移到後端(Edge Function / API route)的伺服器端環境變數。定期用工具掃描 Repo 與前端產物,確認 service_role 沒外洩。
反模式二:關掉 RLS 圖方便。
開發時嫌 RLS 難寫就把它關了,想著「上線再補」,結果幾乎都忘了補。這等於讓整個資料庫對 anon key 敞開。正確做法:建表就開 RLS 當肌肉記憶;策略寫不出來先用 (SELECT auth.uid()) = user_id 擁有者策略打底再細化;真要繞 RLS 的操作放後端 service_role,而不是在前端關 RLS。上線前跑一次「找出沒開 RLS 的表」檢查。
反模式三:把商業邏輯全塞前端。 前端是使用者完全掌控的環境,任何前端做的驗證、金額計算、權限檢查都能被繞過(改 JS、直接打 API)。正確做法:資料完整性與權限放資料庫(RLS、CHECK 約束、觸發器);跨資源協調或呼叫外部服務的邏輯放後端 Edge Function;前端只做 UI 與即時回饋。金額由後端依商品 ID 重算、庫存用資料庫交易加約束保證不超賣,前端驗證只是體驗加分,絕非唯一防線。
反模式四:N+1 查詢。
迴圈裡逐筆打 API 撈關聯資料,100 筆資料就是 101 次網路往返,慢且吃連線。正確做法:用 Supabase 的巢狀 select 一次 JOIN 取回;看到迴圈裡有 await supabase.from(...) 就警覺。
反模式五:漏建索引就上 RLS。
RLS 策略比對 user_id 卻沒建索引,百萬筆資料每次查詢全表掃描,慢到逾時。正確做法:凡是策略、WHERE、JOIN 會用到的欄位都建索引,用 CREATE INDEX CONCURRENTLY 避免鎖表;定期查 pg_stat_user_tables 看哪張表 seq_scan 偏高。
反模式六:沒有備份(或有備份但沒測還原)。
自架 Supabase 沒有內建自動備份,很多人以為「有跑 Docker volume 就安全」,直到資料損毀才發現根本沒備份、或備份根本還不回來。正確做法:排定 pg_dump 或 WAL-G 備份、上傳異地(S3/R2),並每月演練一次完整還原。沒測過還原的備份等於沒有備份。
反模式七:手動改正式庫、遷移不進版本控管。
直接連上正式庫 ALTER TABLE,導致各環境 schema 漂移、出事無法重現、也無法回滾。正確做法:用 Supabase CLI 產生 migration 檔、進 Git、經 CI 依序套用到 staging 與 production;把資料庫 schema 當程式碼一樣管理。
反模式八:雙重池化(Double Pooling)。
應用程式已經透過 Supavisor 連線池,ORM 層又開一套自己的連線池,連線數翻倍、容易耗盡。正確做法:走 Supavisor(Transaction Mode)時,把 ORM 的連線池調到最小(Prisma 加 ?pgbouncer=true、SQLAlchemy pool_size=5)。
反模式九:JWT 過期時間設極長。
把 Access Token 設 24 小時甚至 1 年,一旦洩露影響巨大。正確做法:Access Token 設 1 小時(JWT_EXPIRY=3600)搭配 Refresh Token 輪替(REFRESH_TOKEN_ROTATION_ENABLED=true)。
反模式十:只看儀表、沒有主動告警。 Studio Reports/Logs 是「你去看才有用」的被動工具,磁碟半夜滿了不會叫你。正確做法:接外部 uptime 監控 + Prometheus/Grafana,對連線數、CPU、磁碟、慢查詢、容器重啟設告警(此點在《監控與 pg_cron/pgmq》詳述)。
最佳實踐總表(該做 vs 別做):
| 面向 | ✅ 該做 | ❌ 別做 |
|---|---|---|
| 安全 | 建表就開 RLS;service_role 只在後端;查 user_roles 判權限 | 關 RLS 圖方便;前端放 service_role;信 user_metadata |
| 資料建模 | public 都開 RLS;內部物件放 private;命名一致 | Foreign Table 放 public;全塞 public;命名混亂 |
| 效能 | 策略欄位建索引;(SELECT auth.uid()) 快取;巢狀 select | 漏索引全表掃描;N+1 逐筆查;雙重池化 |
| 維運 | 遷移進 Git 走 CI;備份且測還原;設主動告警 | 手改正式庫;無備份;只看不告警 |
| 邏輯分層 | 完整性/權限放 DB,商業邏輯放後端 | 商業邏輯與權限全塞前端 |
小結
這是 Supabase 系列教學的第 046 篇,也是自架段(SB-5)工具維運的收尾。上一篇《監控與 pg_cron/pgmq》把自架的「看得住、跑得動」補齊;這一篇則往回站得更高一點,把橫跨資料庫、RLS、Auth、Storage、Realtime、Edge Functions 到自架的關鍵原則,收斂成一份可隨手對照的「該做 vs 別做」總表。
核心觀念濃縮成一句話:Supabase 的架構前提是「前端直連資料庫、由資料庫當守門員」,所以安全的第一原則永遠是「一律開 RLS、service_role 只在後端」;在這條底線之上,再用「策略欄位建索引 + (SELECT auth.uid()) 快取」顧效能、用「巢狀 select」避開 N+1、用「schema 分層」管好權限邊界、用「遷移進版控 + 備份測還原 + 主動告警」守住維運——把該做的變成習慣、把反模式變成警訊。
- 安全三底線:一律開 RLS(建表就開)、
service_role只在後端、權限判斷查資料庫而非user_metadata - 效能兩主軸:RLS 策略欄位建索引 +
(SELECT auth.uid())快取;用巢狀select一次 JOIN 避開 N+1 - 資料建模:
public都開 RLS、內部物件與 Foreign Table 放private用函式包裝、命名一致 - 維運紀律:遷移進 Git 走 CI(別手改正式庫)、備份且每月測還原、接主動告警
- 邏輯分層:完整性與權限放資料庫、商業邏輯放後端、前端只做 UI——別把防線全押在前端
到這裡,你已經有了一整套從功能到心智的 Supabase 檢查清單。下一篇《全端實戰》,我們會把系列學到的所有零件——資料庫、RLS、Auth、Storage、Realtime、Edge Functions——組裝成一個完整的全端應用,讓這些散落的最佳實踐在真實專案裡落地。把清單變成習慣,是成為 Supabase 資深工程師的最後一哩;而把它用在一個真正跑起來的專案上,則是驗收這一切的最好方式。