為什麼選擇 Supabase RLS 進行多租戶架構

說實話,當涉及到在 SaaS 應用中處理多租戶時,你有很多選擇。你可以為每個租戶單獨設置一個數據庫,這聽起來像組織上的天堂,但成本高昂且管理起來非常痛苦。或者,你可以嘗試使用單獨的架構,這在操作上麻煩較少,但當涉及到遷移時仍然不是輕而易舉的事。但隨後出現了 SaaS 世界的寵兒——共享表加行級過濾。Supabase 通過其原生 PostgreSQL 行級安全性(Row Level Security,RLS)使這種方法變得輕而易舉。

為什麼這甚至重要?很簡單。你的數據過濾發生在數據庫級別。如果你在 Next.js API 路由中搞砸了 WHERE 子句,你不會為數據洩露而擔心失眠,因為數據庫本身就是你的安全網。而且說實話,在當今時代,這不是奢侈品——這是必需品。

但我們不要自欺欺人。RLS 為你的查詢增加開銷,使調試複雜化,並可能在遷移過程中絆你一跤。那麼,不同的多租戶方法如何比較呢?

方法 隔離級別 成本 操作複雜性 查詢性能
每個租戶一個數據庫 完全 高(50-200 美元/租戶/月) 非常高 最佳
每個租戶一個架構 中等 高(遷移) 良好
共享表 + RLS 行級 中等 良好(有注意事項)
應用級過濾 最低 最佳

對於租戶數少於 10,000 的大多數 SaaS 產品,共享表加 RLS 能以最少的成本獲得最大的回報。這正是我們在此深入探討的內容。

Multi-Tenant Next.js with Supabase RLS: Production Guide

架構模式:共享與隔離

在甚至考慮編寫代碼之前,你必須選擇你的租戶解析策略。在現實中,你大多會遇到兩種方式:

基於子域的租戶模式

見過 tenant-slug.yourapp.com 嗎?歡迎來到 B2B SaaS 最常見的模式。它時尚、專業,並使中間件中的租戶解析變得輕而易舉。

基於路徑的租戶模式

這個就是基本的 /org/tenant-slug/dashboard。由於沒有通配符 DNS,設置起來更容易,並且在 Vercel 等平台上無需自定義域即可運行。但說實話:它感覺有點像穿著襪子和涼鞋。我們通常為生產 B2B 應用推薦基於子域的方式,為內部工具或 MVP 推薦基於路徑的方式。稍後切換?你會詛咒你的過去——改變這些模式絕非小事。

設置租戶架構

這是一個在三個不同生產部署中從未讓我們失望的架構模式:

-- 核心租戶表
CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  plan TEXT NOT NULL DEFAULT 'free',
  created_at TIMESTAMPTZ DEFAULT now(),
  settings JSONB DEFAULT '{}'
);

-- 成員關聯表
CREATE TABLE memberships (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
  role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
  created_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE(user_id, org_id)
);

-- 示例租戶範圍表
CREATE TABLE projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID REFERENCES organizations(id) ON DELETE CASCADE NOT NULL,
  name TEXT NOT NULL,
  description TEXT,
  created_by UUID REFERENCES auth.users(id),
  created_at TIMESTAMPTZ DEFAULT now()
);

-- 在 org_id 上建立索引 — 你需要在「每個」租戶範圍表上建立索引
CREATE INDEX idx_projects_org_id ON projects(org_id);
CREATE INDEX idx_memberships_user_id ON memberships(user_id);
CREATE INDEX idx_memberships_org_id ON memberships(org_id);

memberships 表是粘合劑,將一切結合在一起。所有的 RLS 策略都會指向它,就像它是它們最喜歡的堂表親一樣。用戶可以加入多個組織,他們的角色決定了他們能或不能做什麼。這裡有一點智慧的提示:總是——非常認真地,總是——在每個租戶範圍表上索引 org_id。否則,一旦你在數據中遨遊,就會看著你的查詢像糖漿一樣爬行。當一個客戶的儀表板從 50ms 下降到 8 秒,有 100,000 行時,我們曾經措手不及。教訓已吸取。

真正可擴展的 RLS 策略

這是教程通常退出的地方,讓你陷入困境。他們向你扔出 auth.uid() = user_id 並說,「祝你好運!」但多租戶 RLS 不能這樣簡化。

幫助函數模式

為什麼要在每個策略中都混亂地進行成員檢查?改用幫助函數:

-- 幫助函數:檢查當前用戶是否是組織成員
CREATE OR REPLACE FUNCTION public.is_member_of(org UUID)
RETURNS BOOLEAN AS $$
  SELECT EXISTS (
    SELECT 1 FROM memberships
    WHERE user_id = auth.uid()
    AND org_id = org
  );
$$ LANGUAGE sql SECURITY DEFINER STABLE;

-- 幫助函數:獲取用戶在組織中的角色
CREATE OR REPLACE FUNCTION public.get_role_in(org UUID)
RETURNS TEXT AS $$
  SELECT role FROM memberships
    WHERE user_id = auth.uid()
    AND org_id = org
  LIMIT 1;
$$ LANGUAGE sql SECURITY DEFINER STABLE;

為什麼是 SECURITY DEFINER?因為該函數以創建者的權限運行,跳過 memberships 表上的 RLS。沒有這個,你會陷入一個循環依賴的兔子洞,其中 memberships 上的 RLS 會崩潰其他表依賴的成員資格檢查。

STABLE 部分呢?它向查詢規劃器發出信號,在單個查詢期間,函數的輸出對於相同的輸入保持一致,這能夠啟用一些很好的緩存優勢。想用 IMMUTABLE 嗎?別這樣。成員資格可以在事務之間翻轉。

租戶範圍表的策略

讓我們看一下 projects 表的一些策略:

-- 啟用 RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- SELECT:成員可以查看他們組織中的項目
CREATE POLICY "Members can view org projects"
  ON projects FOR SELECT
  USING (public.is_member_of(org_id));

-- INSERT:管理員和所有者可以創建項目
CREATE POLICY "Admins can create projects"
  ON projects FOR INSERT
  WITH CHECK (
    public.get_role_in(org_id) IN ('owner', 'admin')
  );

-- UPDATE:管理員和所有者可以更新項目
CREATE POLICY "Admins can update projects"
  ON projects FOR UPDATE
  USING (public.is_member_of(org_id))
  WITH CHECK (
    public.get_role_in(org_id) IN ('owner', 'admin')
  );

-- DELETE:只有所有者可以刪除項目
CREATE POLICY "Owners can delete projects"
  ON projects FOR DELETE
  USING (
    public.get_role_in(org_id) = 'owner'
  );

成員資格表本身的策略

這個很棘手。memberships 表有自己的 RLS,但它不能使用幫助函數,因為它們反過來查詢 memberships——引發循環參考噩夢:

ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;

-- 用戶可以看到他們所屬組織中的成員資格
CREATE POLICY "Users can view org memberships"
  ON memberships FOR SELECT
  USING (
    org_id IN (
      SELECT org_id FROM memberships WHERE user_id = auth.uid()
    )
  );

-- 只有所有者可以添加成員
CREATE POLICY "Owners can add members"
  ON memberships FOR INSERT
  WITH CHECK (
    org_id IN (
      SELECT org_id FROM memberships
      WHERE user_id = auth.uid() AND role = 'owner'
    )
  );

是的,同一個表上有一個子查詢。是的,PostgreSQL 能夠處理它。子查詢檢查你自己的成員資格,不受所定義策略的影響,因為 RLS 只圍繞外部查詢進行。但要測試這個——認真地說,你不想在生產中發現一個錯誤。

Multi-Tenant Next.js with Supabase RLS: Production Guide - architecture

Next.js 中間件進行租戶解析

使用 Next.js 15 和時尚的 App Router,在邊緣運行的中間件是租戶解析的完美房東。這是我們對基於子域設置的可信模式:

// middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const PUBLIC_ROUTES = ['/login', '/signup', '/invite'];

export async function middleware(request: NextRequest) {
  const hostname = request.headers.get('host') || '';
  const currentHost = hostname.split('.')[0];

  // 為主域和本地主機跳過
  const isMainDomain = currentHost === 'app' || currentHost === 'www' || currentHost === 'localhost:3000';

  let response = NextResponse.next({
    request: { headers: request.headers },
  });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) => {
            request.cookies.set(name, value);
            response.cookies.set(name, value, options);
          });
        },
      },
    }
  );

  const { data: { user } } = await supabase.auth.getUser();

  if (!isMainDomain) {
    response.headers.set('x-tenant-slug', currentHost);

    if (!user && !PUBLIC_ROUTES.some(r => request.nextUrl.pathname.startsWith(r))) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }

  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)'],
};

x-tenant-slug 標頭純金。使用它讓你的服務器組件和 API 路由知道他們在處理哪個租戶。如果你在與我們合作 Next.js 項目,設置這個是我們的第一天優先事項。

多租戶應用中的身份驗證流

Supabase Auth 在多租戶遊戲中保持中立。用戶存在於全局領域——租戶關係是你要解決的謎題。這是我們的遊戲計劃:

  1. 用戶註冊: 創建一個身份驗證用戶,構建一個組織,並用「owner」角色創建一個成員資格。
  2. 用戶被邀請: 管理員制定待處理邀請,新用戶通過邀請鏈接加入,然後出現——用指定角色的成員資格。
  3. 用戶登錄: 從子域提取租戶,確認成員資格,護送他們進入他們的儀表板。
// app/api/auth/signup/route.ts
import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const { email, password, orgName, orgSlug } = await request.json();
  const supabase = await createClient();

  // 註冊用戶
  const { data: authData, error: authError } = await supabase.auth.signUp({
    email,
    password,
  });

  if (authError) return NextResponse.json({ error: authError.message }, { status: 400 });

  // 使用服務角色客戶端進行組織創建(繞過 RLS)
  const adminClient = createAdminClient();

  const { data: org, error: orgError } = await adminClient
    .from('organizations')
    .insert({ name: orgName, slug: orgSlug })
    .select()
    .single();

  if (orgError) return NextResponse.json({ error: orgError.message }, { status: 400 });

  // 創建所有者成員資格
  await adminClient
    .from('memberships')
    .insert({
      user_id: authData.user!.id,
      org_id: org.id,
      role: 'owner',
    });

  return NextResponse.json({ org });
}

請注意,我們在註冊期間依賴服務角色客戶端。用戶還沒有任何成員資格,所以 RLS 會讓他們在組織創建時陷入困境。這是那些經典的引導問題之一——你的服務角色金鑰將是你的魔杖。

我再怎麼強調也不為過:永遠,永遠不要將你的服務角色金鑰暴露給客戶端。 它嚴格用於服務器端代碼。

服務器組件和 RLS:SSR 問題

Next.js 15 的服務器組件是服務器綁定的,提升了安全遊戲。但在使用 Supabase RLS 時存在一個小故障:你必須向 Supabase 客戶端提供用戶的會話,以便 RLS 策略知道誰在表上。

// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // 這在服務器組件中可能失敗(只讀)
            // 中間件處理 cookie 刷新
          }
        },
      },
    }
  );
}
// app/[orgSlug]/projects/page.tsx
import { createClient } from '@/lib/supabase/server';
import { headers } from 'next/headers';

export default async function ProjectsPage() {
  const supabase = await createClient();
  const headersList = await headers();
  const tenantSlug = headersList.get('x-tenant-slug');

  // 從 slug 獲取組織 ID
  const { data: org } = await supabase
    .from('organizations')
    .select('id')
    .eq('slug', tenantSlug)
    .single();

  if (!org) return <div>Organization not found</div>;

  // RLS 自動過濾 — 只返回項目
  // 當前用戶具有成員資格的地方
  const { data: projects } = await supabase
    .from('projects')
    .select('*')
    .eq('org_id', org.id)
    .order('created_at', { ascending: false });

  return (
    <div>
      {projects?.map(project => (
        <ProjectCard key={project.id} project={project} />
      ))}
    </div>
  );
}

這是關鍵:即使有人篡改請求中的 org_id,RLS 也不會動搖。它阻止訪問項目,除非用戶是成員。從技術上講,.eq('org_id', org.id) 對於安全性是冗餘的——RLS 處理它——但對於性能和可讀性很有好處。

性能優化和常見缺陷

N+1 RLS 查詢問題

每個 RLS 策略檢查都會啟動一個子查詢。當你盯著 100 行時,連接到一個 10 行 策略檢查意味著進行 100 輪成員資格查找。幸運的是,PostgreSQL 足夠聰明可以緩存——但存在開銷。

修復: 在幫助函數上使用 STABLE(如我們所概述的)。另外,考慮將 org_id 反規範化到 JWT 聲明中:

-- 自定義 JWT 鉤子(Supabase 儀表板 > 身份驗證 > 鉤子)
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event JSONB)
RETURNS JSONB AS $$
DECLARE
  org_ids UUID[];
BEGIN
  SELECT array_agg(org_id) INTO org_ids
  FROM memberships
  WHERE user_id = (event->>'user_id')::UUID;

  event := jsonb_set(
    event,
    '{claims,org_ids}',
    to_jsonb(org_ids)
  );

  RETURN event;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

然後你的 RLS 策略變成:

CREATE POLICY "Members can view"
  ON projects FOR SELECT
  USING (
    org_id = ANY(
      (SELECT array(SELECT jsonb_array_elements_text(
        auth.jwt()->'org_ids'
      ))::UUID[])
    )
  );

這完全消除了成員資格表的查找。組織 ID 直接來自 JWT。注意: JWT 聲明在登錄時被蓋章。改變某人的成員資格,他們需要重新身份驗證以同步聲明。通常,這完全是可管理的——只需將其保留在你的文檔中。

連接池

Supabase 通過 PgBouncer 提供連接池。如果你要在 Vercel 上實時運行 Next.js,請記住:為 API 路由和服務器組件使用池 URL。

# 用於常規操作(池)
DATABASE_URL=postgres://user:pass@db.project.supabase.co:6543/postgres

# 僅用於遷移(直接)
DIRECT_URL=postgres://user:pass@db.project.supabase.co:5432/postgres

Supabase 的 Pro(每月 25 美元)上的任何人都通過池獲得 200 個併發連接。對於大多數同時少於 1000 用戶的 SaaS 應用,綽綽有餘。

你絕對需要的索引

這是多租戶設置的蠻力索引集:

-- 在每個租戶範圍表上
CREATE INDEX idx_{table}_org_id ON {table}(org_id);

-- 複合索引用於常見查詢
CREATE INDEX idx_projects_org_created ON projects(org_id, created_at DESC);

-- 成員資格 — 通過 RLS 大量查詢
CREATE INDEX idx_memberships_user_org ON memberships(user_id, org_id);
CREATE INDEX idx_memberships_org_role ON memberships(org_id, role);

EXPLAIN ANALYZE——開發人員最好的朋友。看看你的查詢如何與 RLS 一起進行。你可能會對規劃器決定做什麼、沒有正確的索引感到驚訝。

測試 RLS 策略

每個人都跳過這個,但這是你防止數據洩露的最好安全網。我們直接在 SQL 中測試 RLS 策略:

-- 以特定用戶身份測試
SET request.jwt.claims = '{"sub": "user-uuid-here", "role": "authenticated"}';
SET role = 'authenticated';

-- 這應該只返回用戶可以訪問的項目
SELECT * FROM projects;

-- 這應該失敗(用戶不是這個組織的成員)
INSERT INTO projects (org_id, name) VALUES ('other-org-uuid', 'Sneaky Project');

-- 重置
RESET role;

別忘了 pgTAP 用於關鍵策略:

BEGIN;
SELECT plan(3);

-- 設置測試上下文作為用戶 A(組織 1 的成員)
SET LOCAL request.jwt.claims = '{"sub": "user-a-uuid"}';
SET LOCAL role = 'authenticated';

SELECT is(
  (SELECT count(*) FROM projects WHERE org_id = 'org-1-uuid')::INTEGER,
  5,
  'User A sees 5 projects in their org'
);

SELECT is(
  (SELECT count(*) FROM projects WHERE org_id = 'org-2-uuid')::INTEGER,
  0,
  'User A sees 0 projects in other org'
);

SELECT throws_ok(
  $$INSERT INTO projects (org_id, name) VALUES ('org-2-uuid', 'Hack')$$,
  'new row violates row-level security policy',
  'User A cannot insert into other org'
);

SELECT * FROM finish();
ROLLBACK;

在 CI 中運行這些。每個涉及 RLS 策略的遷移應該通過完整的測試套件進行劇烈鍛煉。

生產部署檢查清單

準備好發貨了嗎?用這個武裝自己:

  • RLS 在「每個」包含租戶數據的表上啟用
  • 服務角色金鑰秘密保管在服務器端,絕不在客戶端
  • org_id 在所有租戶範圍表上都正確索引
  • 成員資格幫助函數被授予 SECURITY DEFINERSTABLE
  • JWT 自定義聲明已上線(如果在 JWT 路線上)
  • 為雲部署設置了連接池?
  • RLS 策略通過 pgTAP 或其類似項的 QA 測試
  • 用 RLS 運行的關鍵查詢上已經啟動 EXPLAIN ANALYZE
  • 邀請/註冊流是否缺少任何成員資格引導?
  • 在身份驗證端點上是否設置了速率限制?Supabase 提供內置選項
  • 在 Supabase 儀表板中翻轉了 auth 架構表上的 RLS(通常是地雷)
  • 為任何慢查詢嵌入監控(Supabase 儀表板 > 數據庫 > 查詢性能)

正在推出多租戶產品並想要一個曾經涉足過這些水域的人嗎?我們的 無頭 CMS 開發解決方案 或通過我們的 聯繫頁面 進行快速聊天可能正是你所需要的。

常見問題

我可以為擁有數千個租戶的應用使用 Supabase RLS 嗎? 絕對可以。我們已經在擁有 5,000 多個租戶和數百萬行的共享表 RLS 上進行了試驗,沒有出汗。秘密醬料是什麼?在 org_id 列上進行適當的索引和 STABLE 幫助函數。考慮 50,000 多個租戶或十億行盛宴?深入研究按 org_id 分區表或尋求每個架構租戶設置。

當用戶屬於多個組織時,我如何處理租戶切換? 將活動組織放在 Cookie 或 URL(子域)中。交換組織?修改子域/cookie 並重新獲取。別把活動組織塞進 JWT 中——它需要重新登錄才能更改。你的中間件可以偷看的 cookie 是要走的路。

如果我忘記在表上啟用 RLS 會發生什麼? 每個經過身份驗證的用戶都可以點擊每一行。這是 PostgreSQL 的默認立場——沒有 RLS 的表上沒有行級限制。Supabase 儀表板標記缺少 RLS 的表,但將其嵌入到 CI 中並使用針對 pg_tablespg_policies 的查詢會有所幫助。

我應該使用 Supabase 的服務角色金鑰還是為管理職責烹飪自定義 PostgreSQL 角色? 大多數情況下,服務角色金鑰就足夠了。它完全繞過 RLS,所以它只能供服務器端使用。需要粒度治理(比如一個「admin」角色在所有組織中潛伏但避免刪除)?那是自定義 PostgreSQL 領土——高級,通常在複雜的內部工具需要它時才會出現在你的雷達上。

我如何運行數據庫遷移而不被 RLS 策略絆倒? Supabase 的 CLI(supabase db pushsupabase migration)以及直接數據庫 URL(跳過池)會幫你。將 RLS 策略編輯與架構調整塞進同一個遷移中。針對暫存項目測試演播轉遷——Supabase 讓你在 Pro 上旋轉預覽分支,僅用於此目的。

RLS 策略可以到達來自其他 API 或服務的數據嗎? 不行。RLS 策略安全地坐在 SQL 中,由 PostgreSQL 評估。渴望檢查外部數據(比如功能標誌服務)?將該數據水泥化到數據庫表中,然後在策略中引用。一個典型的模式是從 Stripe 同步訂閱狀態到 organizations.plan 列。

與應用層過濾相比,RLS 的性能稅是多少? 在我們的 Supabase Pro 基準測試(2 vCPU,8GB RAM)上,對於具有正確索引的基本成員資格檢查策略,RLS 為每個查詢增加 1-3ms。盡情享受策略複雜性或連接,你可能會添加 5-15ms。JWT 聲明策略(在令牌中存儲 org_ids)由於沒有子查詢舞蹈,將其切成低於 1ms。對於典型的網絡應用,延遲涓涓細流是無關緊要的。

這如何與 Supabase Realtime 訂閱一起工作? Supabase Realtime 遵循 RLS 規則簿。根據 RLS 調整到表更改並只捕捉你有資格看到的行的事件。這開箱即用,零額外調整。只要確保你的客戶端 Supabase 有用戶會話,@supabase/ssr 可以無縫處理。