我在 Supabase 上建置三個多租戶 SaaS 應用的經驗

我在過去兩年內在 Supabase 上部署了三個多租戶 SaaS 應用。第一個是個災難。並非 Supabase 讓我失望——它沒有——而是我從根本上誤解了行級安全性 (RLS) 在大規模查詢規劃中的互動方式。第二個好多了。第三個現在處理 2,000+ 個租戶,在擁有數百萬行的表中實現亞 50 毫秒的查詢時間。

本文是我希望在第一個專案之前有人告訴我的所有內容。我們將從頭開始構建一個真實的多租戶架構,配置實際上能夠執行的 RLS 政策,並涵蓋只有在真實流量到達資料庫時才會出現的邊界案例。

目錄

Supabase RLS Multi-Tenant Production Schema Design Guide

Supabase 中的多租戶方案

在我們寫一行 SQL 之前,讓我們先明確三種多租戶方案及為什麼其中一種對大多數 Supabase 專案最有利。

方案 隔離等級 複雜性 每租戶成本 最佳用途
每租戶資料庫 最高 非常高 $25+/月 企業級、法規要求高
每租戶架構 $5-15/月 中型 SaaS
共享架構 + RLS 中等 中等 幾分錢 大多數 SaaS 應用

每租戶資料庫是當你向銀行或醫療公司銷售且他們需要完全分開的基礎設施時使用的方案。Supabase 對此支援並不好——你需要管理多個 Supabase 專案。

每租戶架構(使用 PostgreSQL 架構如 tenant_123.projects)聽起來很吸引人,但會變成維護惡夢。每個遷移都要針對每個架構執行。我試過一次。對於 400 個租戶,一個簡單的 ALTER TABLE 遷移花了 45 分鐘。

共享架構加 RLS 對於 90% 的 SaaS 應用來說是最佳選擇。一組表、一組遷移,PostgreSQL 的 RLS 政策處理隔離。這就是我們在這裡建構的。

基礎:租戶和使用者架構

讓我們從核心表開始。我將向你展示我在生產環境中使用的架構,而不是教程玩具。

-- 啟用 UUID 生成
create extension if not exists "uuid-ossp";

-- 租戶(組織、工作區等)
create table public.tenants (
  id uuid primary key default uuid_generate_v4(),
  name text not null,
  slug text unique not null,
  plan text not null default 'free' check (plan in ('free', 'pro', 'enterprise')),
  settings jsonb not null default '{}',
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- 成員關聯:連接 auth.users 和租戶
create table public.tenant_memberships (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  user_id uuid not null references auth.users(id) on delete cascade,
  role text not null default 'member' check (role in ('owner', 'admin', 'member', 'viewer')),
  created_at timestamptz not null default now(),
  
  unique(tenant_id, user_id)
);

-- 關鍵:RLS 政策將依賴的索引
create index idx_tenant_memberships_user_id on public.tenant_memberships(user_id);
create index idx_tenant_memberships_tenant_id on public.tenant_memberships(tenant_id);
create index idx_tenant_memberships_user_tenant on public.tenant_memberships(user_id, tenant_id);

要注意兩件事。首先,我使用關聯表(tenant_memberships)而不是直接在使用者檔案上放置 tenant_id。使用者可以屬於多個租戶——這是幾乎每個 SaaS 應用的真實需求。其次,這些索引不是可選的。沒有它們,每個 RLS 檢查都會對成員關聯表進行完整掃描。我見過這種情況在你擁有幾千個成員關聯後加入 200 毫秒以上的時間。

設計不會殺死效能的 RLS 政策

這是大多數教程讓你失望的地方。他們展示一個簡單的政策,如:

-- 不要在生產中這樣做
create policy "Users can view their tenant's data"
  on public.projects for select
  using (
    tenant_id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid()
    )
  );

這是可行的。它會通過你的測試。然後你會部署它,獲得 500 個使用者,並想知道為什麼你的儀表板需要 4 秒鐘才能加載。

問題在於 PostgreSQL 為每一行計算這個子查詢。查詢規劃器有時可以優化它,但通常不會——特別是涉及 JOIN 的情況下。

安全定義函數模式

以下是在生產中實際運作的方法:

-- 建立返回使用者租戶 ID 的函數
-- SECURITY DEFINER 表示它使用函數建立者的權限執行
-- 這很關鍵:它繞過成員關聯表本身的 RLS
create or replace function public.get_user_tenant_ids()
returns setof uuid
language sql
security definer
stable
set search_path = public
as $$
  select tenant_id 
  from public.tenant_memberships 
  where user_id = auth.uid();
$$;

-- 現在在政策中使用它
create policy "tenant_isolation_select"
  on public.projects for select
  using (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_insert"
  on public.projects for insert
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_update"
  on public.projects for update
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_delete"
  on public.projects for delete
  using (tenant_id in (select public.get_user_tenant_ids()));

為什麼這更快?STABLE 關鍵字告訴 PostgreSQL 該函數在單個語句中返回相同的結果。規劃器可以調用它一次並重複使用結果。在我的基準測試中,這將觸及多行查詢的 RLS 開銷減少了 60-80%。

search_path 陷阱

看到函數上的 set search_path = public 了嗎?這不是可選的。沒有它,惡意使用者可能會在另一個架構中建立一個函數來遮蔽 auth.uid() 並繞過你的安全性。Supabase 團隊已經寫過這個,但很容易忽視。

Supabase RLS Multi-Tenant Production Schema Design Guide - architecture

tenant_id 模式:正確實現

每個保存租戶特定資料的表都需要一個 tenant_id 列。沒有例外。即使該表有到另一個租戶範圍表的外鍵。原因如下:

-- 你的專案表
create table public.projects (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  name text not null,
  created_at timestamptz not null default now()
);

-- 任務屬於專案。你可能認為 tenant_id 在這裡是冗餘的。
create table public.tasks (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  project_id uuid not null references public.projects(id) on delete cascade,
  title text not null,
  status text not null default 'todo',
  created_at timestamptz not null default now()
);

-- 用於 RLS 和常見查詢模式的索引
create index idx_projects_tenant on public.projects(tenant_id);
create index idx_tasks_tenant on public.tasks(tenant_id);
create index idx_tasks_project on public.tasks(project_id);
create index idx_tasks_tenant_project on public.tasks(tenant_id, project_id);

是的,tasks 上的 tenant_id 是非正規化的。是的,這是正確的選擇。沒有它,tasks 上的 RLS 政策需要 JOIN 到 projects 來驗證租戶——並且該 JOIN 在每次查詢時發生。使用非正規化的 tenant_id,RLS 檢查是一個簡單的索引查詢。

我使用觸發器強制執行一致性:

create or replace function public.verify_tenant_consistency()
returns trigger
language plpgsql
as $$
begin
  if NEW.tenant_id != (
    select tenant_id from public.projects where id = NEW.project_id
  ) then
    raise exception 'tenant_id mismatch: task tenant does not match project tenant';
  end if;
  return NEW;
end;
$$;

create trigger check_task_tenant
  before insert or update on public.tasks
  for each row execute function public.verify_tenant_consistency();

JWT 聲明 vs 資料庫查詢

Supabase 允許你在 JWT 令牌中嵌入自訂聲明。有些人使用它來儲存當前租戶 ID:

-- 從 JWT 讀取(快速,但有警告)
auth.jwt() -> 'app_metadata' ->> 'current_tenant_id'

相比每次都從資料庫查詢:

-- 資料庫查詢(較慢,但始終最新)
select tenant_id from tenant_memberships where user_id = auth.uid()
方面 JWT 聲明 資料庫查詢
速度 ~0.1ms ~1-5ms
新鮮度 過時直到令牌刷新 始終最新
多租戶切換 需要令牌刷新 立即
撤銷時安全性 延遲到 JWT 過期 立即
實現複雜性 更高(需要 Edge Function) 更低

我的建議:對大多數應用使用具有安全定義函數模式的資料庫查詢。當你有適當的索引時,效能差異可以忽略不計,你可以避免整個圍繞過時令牌的錯誤類別。

如果你正在服務 10,000+ 個並發使用者並且節省毫秒很重要,那麼是的,將活動租戶 ID 移動到 JWT 中。你需要一個 Supabase Edge Function(或一個鉤子)在使用者切換租戶時設定聲明,並且你需要處理客戶端上的令牌刷新流程。

處理跨租戶操作

某些操作確實需要跨越租戶邊界。管理儀表板、計費系統、分析聚合。以下是如何安全地處理它們。

服務角色金鑰(謹慎使用)

Supabase 服務角色金鑰完全繞過 RLS。僅在伺服器端程式碼中使用它——永遠不要將其暴露給客戶端。

// 僅伺服器端(Next.js API 路由、Edge Function 等)
import { createClient } from '@supabase/supabase-js';

const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // 永遠不要暴露這個
);

// 這繞過 RLS - 謹慎使用
const { data } = await supabaseAdmin
  .from('tenants')
  .select('id, name, plan')
  .eq('plan', 'enterprise');

如果你在 Next.js 上構建(我們做很多這種工作),你的 API 路由和伺服器元件是服務角色操作的正確地點。

用於控制跨租戶存取的資料庫函數

對於更精細的控制,建立特定函數:

create or replace function public.admin_get_tenant_stats(target_tenant_id uuid)
returns json
language plpgsql
security definer
set search_path = public
as $$
declare
  result json;
  caller_role text;
begin
  -- 驗證呼叫者是目標租戶的管理員
  select role into caller_role
  from tenant_memberships
  where user_id = auth.uid() and tenant_id = target_tenant_id;
  
  if caller_role not in ('owner', 'admin') then
    raise exception 'Unauthorized: requires admin role';
  end if;
  
  select json_build_object(
    'project_count', (select count(*) from projects where tenant_id = target_tenant_id),
    'task_count', (select count(*) from tasks where tenant_id = target_tenant_id),
    'member_count', (select count(*) from tenant_memberships where tenant_id = target_tenant_id)
  ) into result;
  
  return result;
end;
$$;

遷移策略和架構演進

向現有表新增 tenant_id 是每個人都害怕的遷移。以下是可最小化停機時間的方法:

-- 步驟 1:新增可為空的列
alter table public.some_existing_table 
  add column tenant_id uuid references public.tenants(id);

-- 步驟 2:回填(對於大型表分批執行)
update public.some_existing_table set tenant_id = (
  select tenant_id from public.projects 
  where projects.id = some_existing_table.project_id
)
where tenant_id is null;

-- 步驟 3:新增 NOT NULL 約束
alter table public.some_existing_table 
  alter column tenant_id set not null;

-- 步驟 4:新增索引
create index concurrently idx_some_table_tenant 
  on public.some_existing_table(tenant_id);

-- 步驟 5:啟用 RLS 並新增政策
alter table public.some_existing_table enable row level security;

create policy "tenant_isolation" on public.some_existing_table
  for all using (tenant_id in (select public.get_user_tenant_ids()));

索引建立上的 concurrently 關鍵字至關重要——沒有它,你將在整個索引構建期間鎖定表。在擁有一百萬行的表上,這可能是幾分鐘的停機時間。

效能基準和優化

我在 Supabase Pro 計劃(2025 年初為 $25/月)上使用 500,000 行在 tasks 表中分散在 1,000 個租戶中進行了基準測試。

查詢模式 無 RLS 簡樸 RLS 優化 RLS 開銷
選擇 50 行(單個租戶) 2.1ms 18.4ms 3.8ms +81%
插入單行 1.2ms 4.1ms 1.9ms +58%
帶過濾的計數 3.4ms 22.1ms 5.2ms +53%
跨 2 個表的 JOIN 4.8ms 45.2ms 8.1ms +69%

「優化 RLS」是指:安全定義函數、適當的複合索引、在子表上非正規化的 tenant_idSTABLE 函數波動性。

簡樸方法(內聯子查詢、缺失索引)使 RLS 感覺很慢。優化後,開銷對生產工作負載來說完全可以接受。

其他優化提示

  1. 使用複合索引tenant_id 作為領先列:create index on tasks(tenant_id, status, created_at)
  2. 分割大型表tenant_id 如果任何單個租戶有數百萬行
  3. 使用 pg_stat_statements 查找慢查詢——Supabase 在儀表板的資料庫 > 查詢效能下公開了這個
  4. 考慮物化視圖用於跨租戶分析,而不是實時執行昂貴的聚合

生產環境安全加固

RLS 是你的主要防禦,但它不應該是你唯一的防禦。

1. 預設拒絕

始終啟用 RLS,不使用預設政策——這意味著如果你忘記向新表新增政策,它會被鎖定而不是完全開放。

alter table public.new_table enable row level security;
-- 在你思考過存取模式之前,不要新增允許政策

2. 稽核日誌

create table public.audit_log (
  id bigint generated always as identity primary key,
  tenant_id uuid not null,
  user_id uuid,
  action text not null,
  table_name text not null,
  record_id uuid,
  old_data jsonb,
  new_data jsonb,
  created_at timestamptz not null default now()
);

-- 通用稽核觸發器
create or replace function public.audit_trigger_func()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
  if TG_OP = 'DELETE' then
    insert into audit_log(tenant_id, user_id, action, table_name, record_id, old_data)
    values (OLD.tenant_id, auth.uid(), TG_OP, TG_TABLE_NAME, OLD.id, to_jsonb(OLD));
    return OLD;
  else
    insert into audit_log(tenant_id, user_id, action, table_name, record_id, new_data, old_data)
    values (
      NEW.tenant_id, auth.uid(), TG_OP, TG_TABLE_NAME, NEW.id, 
      to_jsonb(NEW),
      case when TG_OP = 'UPDATE' then to_jsonb(OLD) else null end
    );
    return NEW;
  end if;
end;
$$;

3. 邊緣限速

RLS 防止資料洩露,但不防止濫用。為限速使用 Supabase Edge Functions 或你的 Next.js 中介軟體。如果你使用 Astro 構建,你可以在伺服器端點中處理這個。

4. 測試你的政策

寫實際測試。使用 Supabase 的本地開發環境(supabase start)並測試:

  • 使用者 A 無法看到使用者 B 的租戶資料
  • 移除成員關聯會立即撤銷存取權
  • 服務角色正確繞過 RLS
  • 插入/更新政策防止 tenant_id 篡改
// 使用 Vitest 的範例測試
import { describe, it, expect } from 'vitest';
import { createClient } from '@supabase/supabase-js';

describe('RLS Policies', () => {
  it('should prevent cross-tenant data access', async () => {
    const userA = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
      global: { headers: { Authorization: `Bearer ${tokenA}` } }
    });
    
    const { data, error } = await userA
      .from('projects')
      .select('*')
      .eq('tenant_id', TENANT_B_ID);
    
    expect(data).toHaveLength(0); // RLS 應該過濾這些
  });
});

真實世界架構示例

這是一個專案管理 SaaS 的簡明但完整的架構。這接近我在生產中部署的內容。

-- 在所有表上啟用 RLS
alter table public.tenants enable row level security;
alter table public.tenant_memberships enable row level security;
alter table public.projects enable row level security;
alter table public.tasks enable row level security;

-- 租戶:使用者可以看到他們所屬的租戶
create policy "view_own_tenants" on public.tenants for select
  using (id in (select public.get_user_tenant_ids()));

-- 僅擁有者可以更新租戶設定
create policy "owners_update_tenants" on public.tenants for update
  using (
    id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid() and role = 'owner'
    )
  );

-- 成員關聯:成員可以看到其租戶中的其他成員
create policy "view_tenant_members" on public.tenant_memberships for select
  using (tenant_id in (select public.get_user_tenant_ids()));

-- 僅管理員+可以管理成員
create policy "admins_manage_members" on public.tenant_memberships for insert
  with check (
    tenant_id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid() and role in ('owner', 'admin')
    )
  );

-- 專案和任務:標準租戶隔離
create policy "tenant_projects" on public.projects for all
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_tasks" on public.tasks for all
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

常見問題

Supabase RLS 是否對查詢新增顯著延遲? 通過優化的政策(安全定義函數、適當的索引、非正規化的 tenant_id),開銷通常是原始查詢時間的 50-80%。對於無 RLS 花費 3 毫秒的查詢,期望 5-6 毫秒。這對生產使用完全可以接受。簡樸方法可以增加 10-20 倍的開銷,這就是為什麼優化很重要。

我可以將 RLS 與 Supabase Realtime 訂閱一起使用嗎? 是的。Supabase Realtime 尊重 RLS 政策。當客戶端訂閱表上的更改時,他們只會收到他們有權查看的行的事件。這會自動運作——你不需要在客戶端新增任何額外的過濾邏輯。只要確保你的 RLS 政策高效,因為它們對每次廣播都會被計算。

我如何在 UI 中處理租戶切換? 將活動租戶 ID 儲存在你的應用狀態中(React context、Zustand store 等)並在你的查詢中將其作為過濾器傳遞。由於 RLS 已經將結果限制在使用者所屬的租戶,切換只是改變你按哪個 tenant_id 過濾的問題。如果你使用資料庫查詢方法,不需要令牌刷新。

我應該使用 Supabase 的內置身份驗證還是外部提供者如 Clerk? Supabase Auth 通過 auth.uid() 與 RLS 自然整合。如果你使用外部提供者,你需要將使用者同步到 Supabase 並使用自訂 JWT 聲明或映射表。除非你有具體理由,我會堅持使用 Supabase Auth——它可以節省大量的整合工作。

單個 Supabase 專案可以處理多少個租戶? 使用共享架構多租戶,我個人在 Supabase Pro 計劃($25/月)上運行了 2,000+ 個租戶。實際限制取決於總資料量和查詢模式,而不是租戶計數。具有 2 個 vCPU 和 1GB RAM 的 Supabase Pro 實例如果你的索引正確,可以處理有數千萬行的表。

如果我忘記在新表上啟用 RLS 會發生什麼? 如果你使用 Supabase 客戶端和匿名金鑰,任何沒有啟用 RLS 的表都可以被任何擁有該金鑰的人存取。這是 Supabase 最大的陷阱。設定 CI 檢查來驗證 public 架構中的所有表都啟用了 RLS。你可以查詢 pg_tablespg_class 結合來自動化這個檢查。

我可以將 RLS 與 Supabase Edge Functions 一起使用嗎? 是的。Edge Functions 可以使用匿名金鑰(RLS 適用)或服務角色金鑰(RLS 被繞過)建立 Supabase 客戶端。使用帶有使用者 JWT 的匿名金鑰進行面向使用者的操作,以及使用服務角色金鑰進行需要跨租戶存取的管理任務。

我如何從單租戶應用遷移到多租戶? 新增 tenant_id 作為可為空的列,回填所有現有資料(所有行獲得相同的租戶 ID,因為它是單租戶),然後新增 NOT NULL 約束、索引和 RLS 政策。在啟用 RLS 之前使用你的現有資料進行廣泛測試——一個錯誤的政策會鎖定所有你的使用者。使用 Supabase 的本地開發環境來預演遷移。