我在 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 "用户可以查看其租户的数据"
  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 的时候。

Security Definer 函数模式

这是在生产中实际有效的方法:

-- 创建一个返回用户租户 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 不匹配:任务租户与项目租户不匹配';
  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 过期 立即
实现复杂性 更高(需要边函数) 更低

我的建议:对大多数应用使用数据库查询和 security definer 函数模式。当你有适当的索引时,性能差异是可以忽略的,你可以避免围绕陈旧令牌的整个错误类。

如果你正在服务 10,000 多个并发用户,并且节省毫秒数很重要,那么是的,将活动租户 ID 移到 JWT 中。你需要一个 Supabase 边函数(或钩子)来在用户切换租户时设置声明,你需要在客户端处理令牌刷新流程。

处理跨租户操作

有些操作合法地需要跨越租户边界。管理员仪表板、计费系统、分析聚合。以下是安全处理它们的方法。

服务角色密钥(谨慎使用)

Supabase 服务角色密钥完全绕过 RLS。仅在服务器端代码中使用——永远不要向客户端公开它。

// 仅服务器端(Next.js API 路由、边函数等)
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 '未授权:需要管理员角色';
  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/月)上运行了基准测试,其中 tasks 表中有 500,000 行,分布在 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" 意味着:security definer 函数、适当的复合索引、子表上的反范式化 tenant_id,以及 STABLE 函数易失性。

朴素方法(内联子查询、缺失的索引)使 RLS 看起来很慢。优化后,开销对于生产工作负载来说是完全可以接受的。

其他优化提示

  1. 使用复合索引tenant_id 作为前导列:create index on tasks(tenant_id, status, created_at)
  2. 分区大型表(按 tenant_id),如果任何单个租户有数百万行
  3. 使用 pg_stat_statements 找到慢查询——Supabase 在仪表板中的 Database > Query Performance 下公开这个
  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 边函数或你的 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 策略', () => {
  it('应该防止跨租户数据访问', 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 后端与多个客户的前端配对。

FAQ

Supabase RLS 是否会给查询添加显著延迟?

使用优化的策略(security definer 函数、适当的索引、反范式化的 tenant_id),开销通常是原始查询时间的 50-80%。对于没有 RLS 需要 3ms 的查询,期望有 RLS 的 5-6ms。这对生产使用来说是完全可以接受的。朴素方法可以增加 10-20 倍的开销,这就是为什么优化很重要。

我可以将 RLS 与 Supabase Realtime 订阅一起使用吗?

可以。Supabase Realtime 尊重 RLS 策略。当客户端订阅表上的更改时,他们只会收到他们有权查看的行的事件。这自动工作——你不需要在客户端添加任何额外的过滤逻辑。只要确保你的 RLS 策略是高效的,因为它们对每个广播都被评估。

我如何在 UI 中处理租户切换?

在你的应用状态(React 上下文、Zustand 存储等)中存储活动租户 ID,并在你的查询中将其作为过滤器传递。由于 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 边函数一起使用吗?

可以。边函数可以创建一个 Supabase 客户端,使用匿名密钥(RLS 适用)或服务角色密钥(RLS 绕过)。对于面向用户的操作使用匿名密钥和用户的 JWT,对于需要跨租户访问的管理任务使用服务角色密钥。

我如何从单租户应用迁移到多租户?

添加 tenant_id 作为可空列,回填所有现有数据(所有行得到相同的租户 ID,因为它是单租户),然后添加 NOT NULL 约束、索引和 RLS 策略。在启用 RLS 之前,用你的现有数据进行广泛测试——一个错误的策略可以锁定所有你的用户。使用 Supabase 本地开发环境来预演迁移。