为什么选择 Supabase RLS 进行多租户架构

坦白说,在 SaaS 应用中处理多租户,你有很多选择。可以为每个租户旋转一个独立数据库,听起来像组织天堂,但成本高得离谱,管理也是噩梦。或者,你可以尝试用独立的模式(Schema),操作上麻烦少一些,但迁移时仍然不轻松。但随后出现了 SaaS 世界的宠儿——带行级过滤的共享表。得益于其 PostgreSQL 原生的行级安全性(RLS),Supabase 让这种方法变得轻而易举。

为什么这甚至重要?很简单。你的数据过滤发生在数据库层级。如果你在 Next.js API 路由中的 WHERE 子句搞砸了,你也不用整晚都在担心数据泄露,因为数据库本身是你的安全网。说实话,在当今时代,这不是奢侈品——这是必需品。

但我们别自欺欺人。RLS 给你的查询增加了开销,复杂化了调试,在迁移期间也可能绊你一跤。那么,不同的多租户方法如何比较呢?

方法 隔离级别 成本 运维复杂度 查询性能
每个租户一个数据库 完全 高($50-200/租户/月) 非常高 最优
每个租户一个模式 中等 高(迁移) 良好
共享表 + RLS 行级 中等 良好(有注意事项)
应用层过滤 最低 最优

对于租户少于 10,000 的大多数 SaaS 产品,共享表加 RLS 能给你最大的性价比。这正是我们在这里深入探讨的内容。

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

架构模式:共享 vs 隔离

在甚至想到写代码之前,你得选择你的租户解析策略。在现实中,你大多会遇到两种情况:

基于子域的租户

看到过 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 跌落到有 100,000 行时的 8 秒时,我们措手不及。吸取的教训。

真正可伸缩的 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。没有这个,你冒着陷入循环依赖兔子洞的风险,其他表依赖的成员资格检查会导致 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 和花哨的应用路由中,在边缘运行的中间件是租户解析的完美房东。这是我们对基于子域设置的可信模式:

// 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];

  // 跳过主域名和 localhost
  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. 用户注册: 创建一个认证用户,建立一个组织,并施展出一个具有"所有者"角色的成员资格。
  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 仪表板 > Auth > Hooks)
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 个并发连接。对于大多数 SaaS 应用少于 1000 个并发用户,这已经足够了。

你绝对需要的索引

这是多租户设置的蛮力索引集:

-- 在每个租户范围的表上
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 或其类似物的质量保证测试
  • 使用 RLS 运行关键查询上的 EXPLAIN ANALYZE 吗?
  • 邀请/注册流程是否遗漏了任何成员资格引导?
  • 在认证端点上有速率限制吗?Supabase 提供内置选项
  • 在 Supabase 仪表板中打开 auth 模式表的 RLS(经常是地雷)
  • 为任何慢查询嵌入监控(Supabase 仪表板 > 数据库 > 查询性能)

推出多租户产品并想要某个已经涉猎过这些水域的人?我们的 无头 CMS 开发解决方案 或通过我们的 联系页面 的快速聊天可能正是你所需要的。

FAQ

我可以为拥有数千租户的应用使用 Supabase RLS 吗?

绝对可以。我们已经使用 5,000+ 租户和数百万行的共享表 RLS 进行了试点,没有出现任何问题。秘诀是什么?在 org_id 列上正确索引和 STABLE 辅助函数。考虑 50,000+ 租户或十亿行的奢侈品?深入研究按 org_id 进行表分区或与每个模式一个租户的设置调情。

当用户属于多个组织时,我如何处理租户切换?

将活动组织tucked在 cookie 或 URL 中(子域)。交换组织?调整子域/cookie 并重新获取。不要将活动组织tucked进 JWT 中——它要求重新登录以更改。你的中间件可以偷看的 cookie 是方法。

如果我忘记在表上启用 RLS 会发生什么?

每个认证用户都可以点击每一行。这是 PostgreSQL 的默认立场——没有行级约束的表上没有行级限制。Supabase 仪表板标记缺少 RLS 的表,但使用查询 pg_tablespg_policies 将其嵌入到 CI 中也会有帮助。

我应该使用 Supabase 的服务角色密钥还是为管理任务烹饪一个自定义 PostgreSQL 角色?

大多数情况下,服务角色密钥就足够了。它完全避开 RLS,所以这是你对服务器端使用唯一的最高机密。需要细粒度的治理(比如在所有组织中朦胧的"管理员"角色,但害羞于删除)?这是自定义 PostgreSQL 领土——高级的,通常在复杂的内部工具要求它之前不在你的视野中。

我如何运行数据库迁移而不被 RLS 策略绊倒?

Supabase 的 CLI(supabase db pushsupabase migration)加上直接数据库 URL(跳过池化)已经帮你了。将 RLS 策略编辑与模式调整tucked进同一迁移中。针对分阶段项目测试演出迁移——Supabase 让你在 Pro 上旋转预览分支,仅用于这种事情。

RLS 策略能到达其他 API 或服务的数据吗?

不行。RLS 策略舒适地坐在 SQL 中,由 PostgreSQL 评估。对检查外部数据感兴趣(比如一个功能标志服务)?将该数据水泥到一个数据库表中,然后在你的策略中引用。典型的模式是从 Stripe 同步订阅状态到一个 organizations.plan 列。

RLS 与应用层过滤相比的性能税是什么?

根据我们的 Supabase Pro 基准测试(2 vCPU,8GB RAM),RLS 在具有正确索引的基本成员资格检查策略上为每个查询添加了额外的 1-3ms。对策略复杂性或连接疯狂,你可能添加 5-15ms。JWT 声明tactic(将 org_ids 存储在令牌中)将其切割到低于 1ms,因为没有子查询舞蹈。对于典型的网络应用,那个延迟涓流是可以忽略的。

这如何与 Supabase Realtime 订阅一起工作?

Supabase Realtime 遵循 RLS 手册。调整表变化并仅捕捉你根据 RLS 有资格看到的行的事件。这开箱即用,没有任何额外的修补。只需确保你的客户端 Supabase 拥有用户会话,@supabase/ssr 无缝处理。