RLS 效能最佳化與陷阱 | Supabase 完整教學
上一篇我們把 RLS(資料列層級安全) 的 Policy(政策) 寫「對」了。這一篇要把它寫「快」——同一條 policy,在幾千列時毫無感覺,到了幾十萬、上百萬列卻可能從幾毫秒暴增到好幾秒。我們會拆解四個效能關鍵:為 policy 欄位建索引、用
(select auth.uid())包裝讓 planner 只算一次、用security definer函式封裝複雜授權、用to authenticated減少無謂的anon檢查,最後教你用explain analyze親眼觀察大表上的常見陷阱。
前言
一句話定義本篇主題:這篇要教你「怎麼讓 RLS Policy 跑得快」——因為 RLS 的條件會被 PostgreSQL 附加在你每一條查詢的背後,寫法的細節會直接決定查詢是走索引一瞬完成,還是全表掃描外加逐列重算函式而卡死。
先接住一個很多人忽略的事實:RLS 不是免費的。當你在 todos 表上建了一條 using ( (select auth.uid()) = user_id ) 的 policy,PostgreSQL 執行 select * from todos 時,實際上是在跑類似 select * from todos where user_id = (select auth.uid()) 的查詢。也就是說,你的 policy 條件就是一段會被塞進每一次查詢的 where。既然它是 where,那所有你對一般 SQL 查詢的效能直覺——「比對欄位要建索引」「別在每列重算昂貴函式」「JOIN 方向要對」——通通適用,而且因為它躲在背後、看不見,反而更容易被忽略。
用一個類比:如果說上一篇的 RLS 管理員是「照規則卡辦事的門房」,那這一篇要處理的是門房辦事的效率。同樣一條規則「只放行卷宗編號等於你的員工編號的人」,如果卷宗櫃有按員工編號排序的索引(建索引),門房一秒就抽出你的卷宗;如果沒有,他得從第一格翻到最後一格(全表掃描)。而如果每來一個人他都要重新打電話問總機「這個人的員工編號是多少」(逐列呼叫 auth.uid()),那更是慢上加慢——正確做法是「進門時問一次記在便條紙上」((select auth.uid()) 包裝)。
本篇你會學到:
- 索引:為 policy 條件用到的欄位(
user_id/org_id/team_id)建 btree 索引,大表下可快上千倍 - 包裝
select:(select auth.uid())為何能讓 planner 產生 InitPlan、整條語句只求值一次 security definer函式:把多表 JOIN 的複雜授權封裝起來,兼顧安全與效能to authenticated與explain analyze:減少anon的無謂檢查,並親手觀察查詢計劃、辨識陷阱
核心概念
RLS 效能的核心,可以濃縮成三個互相搭配的關鍵:建索引、包裝 select、封裝成 security definer 函式。理解它們各自解決什麼問題,你就掌握了九成的 RLS 調校。
關鍵一:為 policy 欄位建索引(解決「全表掃描」)
前面說過,policy 條件等效於一段隱藏的 where。既然是 where user_id = ...,那 user_id 這個欄位有沒有索引,就決定了資料庫是用索引直接跳到目標列(Index Scan),還是從頭到尾掃過整張表(Seq Scan)。
- 小表(幾千列):有沒有索引都很快,感覺不出來
- 大表(幾十萬、上百萬列):沒索引 = 每次查詢都全表掃描 = 效能災難
這也是為什麼 RLS 效能問題常常「上線初期沒事,資料長大後突然變慢」——因為全表掃描的成本是隨資料量線性成長的。官方測試中,為 policy 欄位補上 btree 索引,可讓查詢從約 171ms 降到 0.1ms 以下,提升約 1710 倍。
關鍵二:用 (select …) 包裝(解決「逐列重算函式」)
auth.uid()、auth.jwt() 這類函式,若直接裸寫在 policy 裡,PostgreSQL 的 planner 會保守地認為「它對每一列都可能回傳不同值」,於是對掃描到的每一列都重新呼叫一次。一百萬列就是一百萬次函式呼叫,純粹浪費。
解法是用純量子查詢包起來:(select auth.uid())。這一包,planner 就明白「這個值跟資料列無關、是個常數」,於是產生一個 InitPlan(初始化計劃)——整條語句只求值一次,把結果快取起來給所有列共用。
記住這個對照:
auth.uid()= 每列都問一次;(select auth.uid())= 只問一次、記在便條紙上。
這個技巧不只適用於 auth.uid(),任何與資料列無關的常數運算或函式(包括你自己寫的 security definer 函式)都適用。
關鍵三:用 security definer 函式封裝複雜邏輯
當授權條件不再是單純的「user_id 等於我」,而是「我在這個組織、且我的角色有這個權限」這種跨三四張表的 JOIN 時,直接把整段 JOIN 塞進 policy 有兩個問題:一是難維護(每條 policy 都複製一份)、二是可能觸發 RLS 遞迴檢查而變慢。
security definer 函式解決這兩點:它以函式建立者(而非呼叫者)的權限執行,能繞過被查詢表的 RLS,避免遞迴;把它宣告為 stable 並在 policy 裡用 (select my_func(...)) 包裝,還能享受上面說的 InitPlan 快取。代價是你必須加上 set search_path = '' 鎖定搜尋路徑,防止安全漏洞。
值得強調的是,這三個關鍵不是「三選一」,而是互相疊加的。一條調校完善的多租戶 policy,往往同時做到:欄位有索引(關鍵一)、auth.uid() 有包 select(關鍵二)、複雜授權抽成 security definer 函式(關鍵三)。少做任何一項,都可能成為那條讓查詢卡住的瓶頸。實務上有個簡單的決策順序:先確定索引到位,再確認所有 auth 函式都包了 select,最後才視 JOIN 複雜度決定要不要封裝成函式。因為前兩項成本幾乎為零、收益卻最大,應該無條件先做。
關鍵術語速查
| 術語 | 意義 |
|---|---|
| Seq Scan(循序掃描) | 從頭到尾掃過整張表,大表下很慢 |
| Index Scan(索引掃描) | 透過索引直接跳到目標列,快 |
| InitPlan(初始化計劃) | 與資料列無關的子查詢,整條語句只算一次 |
stable | 標記函式在同一次查詢中對相同輸入回傳相同結果,利於快取 |
security definer | 函式以「建立者」權限執行,可繞過被查詢表的 RLS |
explain analyze | 實際執行查詢並回報執行計劃與耗時,用來診斷效能 |
實作範例
以下 SQL 都可以在 Supabase Dashboard 的 SQL Editor 直接執行。我們沿用系列前幾篇的 documents、projects、org_members 等表來示範。
範例一:為 policy 欄位建索引
先看一條最常見的 owner-only policy。它本身完全正確,但如果 documents 有上百萬列、且 user_id 沒有索引,每次查詢都會全表掃描:
-- 這條 policy 正確,但缺了索引就會慢
create policy "documents_select_own"
on public.documents
for select
to authenticated
using ( (select auth.uid()) = user_id );
補上 btree 索引,讓 user_id = ... 的比對能走 Index Scan:
-- 為 policy 條件用到的欄位建 btree 索引
create index ix_documents_user_id
on public.documents using btree (user_id);
如果你的 policy 常同時用到兩個欄位(例如「自己的、且已發布的」),可以建複合索引,讓兩個條件都吃到索引:
-- 複合索引:同時支撐 user_id 與 status 的比對
create index ix_documents_user_status
on public.documents (user_id, status);
多租戶模式要特別注意:不只主表的 org_id 要建索引,連關聯表(org_members)裡用來反查歸屬的 user_id 也要建,否則 policy 內的子查詢自己就會慢:
-- 主表的 org_id
create index ix_documents_org_id on public.documents(org_id);
-- 關聯表的 user_id(policy 子查詢會用它反查「我在哪些組織」)
create index ix_org_members_user_id on public.org_members(user_id);
範例二:(select auth.uid()) 包裝前後對比
同一條 policy,裸寫與包裝的差別:
-- ❌ 慢:auth.uid() 對每一列都重新求值
create policy "slow_auth_uid"
on public.documents
for select
to authenticated
using ( auth.uid() = user_id );
-- ✅ 快:(select auth.uid()) 產生 InitPlan,整條語句只求值一次
create policy "fast_auth_uid"
on public.documents
for select
to authenticated
using ( (select auth.uid()) = user_id );
官方測試中,這個改動讓查詢從約 179ms 降到約 9ms,快約 20 倍。auth.jwt() 同理——只要是與資料列無關的呼叫,一律包 select:
-- app_metadata 角色判斷也用 (select ...) 包裝
create policy "admin_read_all"
on public.documents
for select
to authenticated
using ( (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' );
範例三:用 security definer 函式封裝複雜授權
假設「能不能讀這份文件」要跨 org_members、org_roles、role_permissions 三張表 JOIN 才能判斷。直接塞進 policy 又長又慢,改用 security definer 函式封裝:
-- 把複雜的多表 JOIN 授權邏輯封裝成函式
create or replace function public.can_read_document(p_doc_id uuid)
returns boolean
language sql
stable -- 同一次查詢中結果穩定,利於快取
security definer -- 以建立者權限執行,繞過被查表的 RLS、避免遞迴
set search_path = '' -- 必須:鎖定搜尋路徑,防止 search_path 注入
as $$
select exists (
select 1
from public.org_members om
join public.role_permissions rp on om.role = rp.role
join public.documents d on d.org_id = om.org_id
where om.user_id = (select auth.uid())
and d.id = p_doc_id
and rp.permission = 'documents.read'
);
$$;
在 policy 裡呼叫時,同樣用 (select ...) 包裝,讓整條語句只算一次:
-- policy 保持簡潔,複雜度都藏在函式裡
create policy "documents_can_read"
on public.documents
for select
to authenticated
using ( (select public.can_read_document(id)) );
官方測試中,把三表 JOIN 的複雜條件改寫成這種 security definer 函式方案,效能提升約 99.78%。set search_path = '' 這行不是可選的——少了它,security definer 函式會有被 search_path 注入攻擊的風險,務必養成習慣。
範例四:明確指定 to authenticated
不指定 to 角色時,policy 會對所有角色(包含未登入的 anon)都執行檢查——即使裡面的 auth.uid() 對匿名者根本回傳 NULL、注定不通過。當你的站有大量匿名流量時,這是白白浪費:
-- ❌ 未指定角色:anon 請求也會執行這條 policy(浪費)
create policy "without_role"
on public.documents
for select
using ( (select auth.uid()) = user_id );
-- ✅ 明確 to authenticated:匿名請求直接跳過這條,省去無謂檢查
create policy "with_role"
on public.documents
for select
to authenticated
using ( (select auth.uid()) = user_id );
範例五:用 explain analyze 觀察執行計劃
理論講再多,不如親眼看查詢計劃。在 SQL Editor 裡,可以先模擬成 authenticated 角色,再對套了 RLS 的表跑 explain analyze:
-- 模擬特定登入使用者,觀察 RLS 生效後的真實執行計劃
begin;
select set_config(
'request.jwt.claims',
'{"sub": "貼上某個 user 的 uuid", "role": "authenticated"}',
true
);
set local role authenticated;
explain (analyze, buffers)
select * from public.documents;
rollback;
你要在輸出裡找兩個關鍵字:
- 看到
Index Scan using ix_documents_user_id→ 索引有生效,good - 看到
Seq Scan on documents→ 全表掃描,代表索引沒吃到,該檢查是否漏建索引、或欄位型別不符
在前端也能取得同樣的查詢計劃,Supabase JS SDK 提供 .explain():
// 需先在資料庫開啟 pgrst.db_plan_enabled,才能從 API 取得 query plan
const { data } = await supabase
.from('documents')
.select('*')
.explain({ analyze: true, buffers: true });
console.log(data); // 印出執行計劃,確認走 Index Scan
常見錯誤與最佳實踐
坑一:policy 欄位沒建索引,大表全表掃描。
最普遍、也最容易被忽略的效能殺手。因為 policy 是隱藏的 where,你在 Dashboard 上看不到它,很難聯想到「查詢慢是因為 policy 欄位沒索引」。正確做法:凡是出現在 policy using/with check 裡、用來「比對」的欄位(user_id、author_id、org_id、team_id),一律建 btree 索引;關聯表裡用來反查的欄位也要建。建完用 explain analyze 確認走了 Index Scan。
坑二:裸寫 auth.uid(),逐列重複求值。
auth.uid() = user_id 會被 planner 當成「每列都可能不同」而逐列呼叫,百萬列就是百萬次。正確做法:一律 (select auth.uid()) = user_id。這個包裝的成本是零、收益是數十到上萬倍,應該從寫下第一條 policy 就養成習慣,別等變慢了才回頭改。
-- ❌ 慢
using ( auth.uid() = user_id )
-- ✅ 快
using ( (select auth.uid()) = user_id )
坑三:policy 內直接 JOIN 大表,且 JOIN 方向寫反。 多租戶場景裡,很多人會這樣寫「反查」——對每一列專案都去問「這個使用者在不在這個團隊」:
-- ❌ 慢:以每列為中心反查使用者(對每列都跑一次子查詢)
using (
(select auth.uid()) in (
select user_id from public.team_members
where team_members.team_id = projects.team_id
)
)
正確方向是以使用者為中心:先一次性算出「我加入的所有團隊」,再用這個小集合去比對:
-- ✅ 快:先縮小成「我的團隊集合」,再比對
using (
team_id in (
select team_id from public.team_members
where user_id = (select auth.uid())
)
)
官方測試中,光是把 JOIN 方向調對,就能從 9,000ms 降到 20ms,提升約 450 倍。核心原則:先縮小「使用者的存取集合」,再用它過濾主表。若 JOIN 涉及三張表以上,直接封裝成 security definer 函式。
坑四:在 policy 裡呼叫昂貴函式,卻沒包 select。
如果你的 policy 條件呼叫了一個含 JOIN 的自訂函式,卻沒包 select,等於「含 JOIN 的昂貴函式 × 每一列」,那是效能上的核彈。正確做法:把它宣告為 stable,並在 policy 裡用 (select my_func(...)) 包裝。官方測試中,含 JOIN 的函式加上 select 包裝,可從 178,000ms 降到 12ms,提升約 14,833 倍。
坑五:忘了指定 to 角色,anon 也白跑檢查。
沒有 to authenticated 的 policy,會對匿名請求也執行一遍注定失敗的 auth.uid() 檢查。正確做法:只服務登入者的 policy 一律加 to authenticated;需要對外公開的才加 to anon。這既是效能優化,也讓 policy 的意圖更清楚。
坑六:只在小表上測過就上線,沒模擬真實資料量。
RLS 效能陷阱最陰險的地方,是它們在開發環境(幾十、幾百列的測試資料)完全不會現形——全表掃描掃十列也是一瞬間。等到正式環境資料長到幾十萬列,同一條 policy 才突然把 API 拖垮。正確做法:對預期會長大的表,開發階段就灌入接近真實量級的假資料(幾十萬列),再用 explain analyze 觀察查詢計劃;把「policy 欄位是否有索引、auth 函式是否包 select」納入上線前的檢查清單,別讓效能問題留到使用者變多時才爆發。
最佳實踐總結:
- policy 欄位一律建索引:
user_id/org_id/team_id與關聯表的反查欄位都不能漏 auth.uid()/auth.jwt()一律(select …)包裝:讓 planner 產生 InitPlan、只算一次- JOIN 方向以使用者為中心:先縮小「我的存取集合」再比對主表
- 三表以上 JOIN 封裝成
security definer函式:宣告stable、加set search_path = ''、呼叫時包select - 明確指定
to角色:減少anon的無謂檢查 - 上線前用
explain analyze驗證:確認每張大表的關鍵查詢都走 Index Scan、沒有殘留的 Seq Scan
小結
這是 Supabase 系列教學的第 011 篇,承接上一篇《RLS Policy 語法與常見模式》——上一篇教你把 policy 寫「對」(using 管現有列、with check 管新列、四種操作各有填法),這一篇則把它寫「快」。核心是理解一件事:policy 就是一段藏在每次查詢背後的 where,所有 SQL 效能直覺都適用。
- 建索引:policy 條件裡用來比對的欄位(含關聯表反查欄位)都要建 btree 索引,避免大表全表掃描,可快上千倍
- 包裝
select:(select auth.uid())讓 planner 產生 InitPlan、整條語句只求值一次,快數十到上萬倍 security definer函式:把多表 JOIN 的複雜授權封裝起來,stable+set search_path = ''+ 呼叫端select包裝,兼顧安全與效能to authenticated與explain analyze:減少anon的無謂檢查,並用查詢計劃親眼確認走 Index Scan
一句話記住本篇:RLS policy 是隱藏的 where——欄位要建索引、函式要包 select、複雜邏輯封裝成 security definer 函式,剩下的交給 explain analyze 驗證。
到這裡,我們對 資料列層級安全(RLS) 的討論就告一段落了——從入門觀念、policy 語法,到這一篇的效能調校,你已經有能力設計出既安全又高效的資料庫存取控制。這也代表本系列的第一大主題 SB-1「資料庫核心」 正式收尾。接下來我們要往上走一層,進入存取層:下一篇《PostgREST 自動 API 入門》,會帶你認識 Supabase 是怎麼把你精心設計的資料表與 RLS,自動變成一整套 REST API 的。資料庫的地基打穩了,該蓋門面了,下一篇見。