Supabase RLS 多租户生产级架构设计指南
我在 Supabase 上构建多租户 SaaS 应用的所学所得
在过去两年中,我在 Supabase 上部署了三个多租户 SaaS 应用。第一个是个灾难。不是因为 Supabase 让我失望——它没有——而是因为我从根本上误解了行级安全性 (RLS) 如何在大规模查询规划中相互作用。第二个更好了。第三个处理 2,000 多个租户,查询时间在数百万行的表中达到亚 50 毫秒。
这篇文章包含了我希望在第一个项目之前有人告诉我的所有内容。我们将从零开始构建一个真实的多租户模式,连接实际上能执行的 RLS 策略,并涵盖只有在真实流量冲击数据库时才会出现的边界情况。
目录
- Supabase 中的多租户方法
- 基础:租户和用户模式
- 设计不会杀死性能的 RLS 策略
- tenant_id 模式:正确理解
- JWT 声明 vs 数据库查询
- 处理跨租户操作
- 迁移策略和模式演进
- 性能基准和优化
- 生产环保安全强化
- 真实世界模式示例
- FAQ

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 团队已经写过关于这个的文章,但很容易遗漏。

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 看起来很慢。优化后,开销对于生产工作负载来说是完全可以接受的。
其他优化提示
- 使用复合索引,
tenant_id作为前导列:create index on tasks(tenant_id, status, created_at) - 分区大型表(按
tenant_id),如果任何单个租户有数百万行 - 使用
pg_stat_statements找到慢查询——Supabase 在仪表板中的 Database > Query Performance 下公开这个 - 考虑物化视图进行跨租户分析,而不是实时运行昂贵的聚合
生产环保安全强化
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_tables 与 pg_class 结合来自动化这个检查。
我可以将 RLS 与 Supabase 边函数一起使用吗?
可以。边函数可以创建一个 Supabase 客户端,使用匿名密钥(RLS 适用)或服务角色密钥(RLS 绕过)。对于面向用户的操作使用匿名密钥和用户的 JWT,对于需要跨租户访问的管理任务使用服务角色密钥。
我如何从单租户应用迁移到多租户?
添加 tenant_id 作为可空列,回填所有现有数据(所有行得到相同的租户 ID,因为它是单租户),然后添加 NOT NULL 约束、索引和 RLS 策略。在启用 RLS 之前,用你的现有数据进行广泛测试——一个错误的策略可以锁定所有你的用户。使用 Supabase 本地开发环境来预演迁移。