多租户设计
当一个 PostgreSQL 实例要同时服务多个客户(租户,tenant)时,第一个架构问题就是:租户的数据放在一起还是分开? 这个问题没有万能答案,只有三种经典模型——共享表(tenant_id 列)、独立 schema、独立数据库——每一种在隔离强度、运维成本、资源密度之间做了不同的取舍。
选完模型,真正的工程难点才开始:怎么保证「租户 A 永远查不到租户 B 的数据」?靠应用层每个人都记得写 WHERE tenant_id = ? 是不可靠的,本页会讲两层机制化的保障:复合外键保证引用关系不跨租户、行级安全(Row-Level Security, RLS)把租户过滤强制下推到数据库层。此外还有索引怎么带租户前缀、连接池 transaction 模式下会话变量怎么安全传递、schema 模式的 search_path 管理等实战细节。
前置知识
建议先阅读:任务队列(SKIP LOCKED) 与 行级安全 RLS 使用场景。
基础概念
| 概念 | 英文 / 记法 | 说明 |
|---|---|---|
| 租户 | tenant | 共用一套系统的独立客户(公司、团队、店铺) |
| 共享表模型 | shared schema / shared table | 所有租户的数据放在同一批表里,用 tenant_id 区分 |
| 独立 schema 模型 | schema-per-tenant | 每个租户一个 schema,同名表各自独立 |
| 独立数据库模型 | database-per-tenant | 每个租户一个数据库实例或库 |
| RLS | row-level security | 数据库层的行级访问控制,强制按策略过滤行 |
| 会话变量 | session variable | SET app.tenant_id = '...' 传给 RLS 策略的运行时上下文 |
| 复合外键 | composite foreign key | (tenant_id, user_id) 联合引用,防止跨租户引用 |
| 连接池 | connection pooling | 复用数据库连接;transaction 模式下会话状态会被重置 |
SET LOCAL | — | 事务级变量设置,事务结束自动还原 |
search_path | — | schema 搜索路径,schema 模型的路由开关 |
三种模型一句话对比
- 共享表:所有租户同一批表,行上带
tenant_id。密度最高、运维最简单,隔离靠「每条查询都带租户条件」,必须用 RLS 或查询构建器强制。 - 独立 schema:每个租户一个 schema(
tenant_1001.users)。表结构天然隔离,跨租户泄漏要难一些,但连接、备份、迁移的复杂度随租户数线性增长。 - 独立数据库:每个租户一个库(甚至一个实例)。隔离最强(资源、权限、故障域都独立),密度最低,成百上千个租户时运维成本爆炸。
一句话总结:隔离强度与资源密度是一对反比。SaaS 长尾(海量小租户)通常选共享表,少数超大客户单独升级到独立库——混合模型是业界的常见终态。
三模型详细对照
| 维度 | 共享表 | 独立 schema | 独立数据库 |
|---|---|---|---|
| 数据隔离 | 行级(靠 RLS/查询纪律) | schema 级 | 数据库级 |
| 跨租户查询/聚合 | 容易(一条 SQL) | 需要跨 schema UNION | 需要外部汇聚 |
| 连接资源 | 一套连接池 | 连接数 × 租户数 | 实例数 × 租户数 |
| 单租户备份/恢复 | 难(按行导出) | 中(按 schema 导出) | 容易(pg_dump 整库) |
| schema 变更 | 一次改全租户 | 逐 schema 改(量大) | 逐库改(量大) |
| 租户级资源隔离 | 弱(共享 buffer/WAL) | 中(可按 schema 排查) | 强(独立实例) |
| 定制化表结构 | 不支持 | 支持(各 schema 可不同) | 支持 |
| 租户数上限 | 十万级 | 数百到千级 | 数十到百级 |
| 泄漏事故半径 | 全表(若 RLS 失效) | 单租户 | 单租户 |
| 成本密度 | 最高 | 中 | 最低 |
语法与用法
共享表模型的基础结构
-- 租户表(注册租户)
CREATE TABLE tenants (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
slug text NOT NULL UNIQUE,
name text NOT NULL,
plan text NOT NULL DEFAULT 'free',
created_at timestamptz NOT NULL DEFAULT now()
);
-- 业务表:所有行都带 tenant_id
CREATE TABLE projects (
id bigint GENERATED ALWAYS AS IDENTITY,
tenant_id bigint NOT NULL REFERENCES tenants(id),
name text NOT NULL,
owner_id bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, id) -- 复合主键:租户内唯一
);
CREATE TABLE tasks (
id bigint GENERATED ALWAYS AS IDENTITY,
tenant_id bigint NOT NULL,
project_id bigint NOT NULL,
title text NOT NULL,
assignee_id bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, id)
);两个关键决策:
- 主键用复合键
(tenant_id, id)还是全局唯一id? 全局唯一id(identity/UUID)实现简单、外键简单,但复合键把「租户内唯一」的语义写进数据库,且天然适合按租户分片。本页后面给出「全局 id + 复合外键」的折中方案,这是生产中最常见的组合。 tenant_id必须出现在每一张业务表上——包括看似「全局」的配置表。例外是租户定义表tenants自身。
复合外键:让引用关系无法跨租户
单独的 REFERENCES tenants(id) 只保证租户存在,不保证「project 和 task 属于同一个租户」。tasks.project_id 完全可能指向别的租户的项目。复合外键把引用完整性升级到租户维度:
-- tasks 同时引用 (tenant_id, project_id) 复合键
ALTER TABLE tasks
ADD CONSTRAINT tasks_project_fk
FOREIGN KEY (tenant_id, project_id)
REFERENCES projects (tenant_id, id);
-- 同理,task 的 assignee 也必须属于同一租户
ALTER TABLE tasks
ADD CONSTRAINT tasks_assignee_fk
FOREIGN KEY (tenant_id, assignee_id)
REFERENCES memberships (tenant_id, user_id);效果:插入 tasks (tenant_id=1, project_id=<租户2的项目>) 会直接违反外键约束——跨租户引用在写入层就被物理阻止,不依赖应用代码的正确性。这要求所有「被引用」表的主键都用 (tenant_id, id) 复合形式,这是共享表模型最重要的结构纪律。
RLS 强制租户隔离
RLS 让数据库自己按策略过滤行,应用即使忘写租户条件也查不到别人的数据:
-- 启用 RLS
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks FORCE ROW LEVEL SECURITY; -- 对表 owner 也生效
-- 策略:当前会话的 app.tenant_id 与行 tenant_id 相等才可见/可写
CREATE POLICY tenant_isolation ON tasks
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);
-- 应用连接后、查询前设置租户上下文
SET app.tenant_id = '1001';
SELECT * FROM tasks; -- 只返回租户 1001 的行RLS 策略的完整机制与更多使用模式见 RLS 使用场景。需要强调的是:SET app.tenant_id 是会话级变量,在连接池复用连接时会残留到下一个请求——这是共享表 + RLS 模型最危险的一类事故,处理方式见下面的连接池小节。
索引带 tenant_id 前缀
共享表模型下,几乎所有索引都应以 tenant_id 开头:
-- 反例:纯业务列索引。租户查询极少「跨全租户」按 name 搜索
CREATE INDEX ON tasks (title);
-- 正例:租户前缀。所有查询模式都是「某租户的某类数据」
CREATE INDEX ON tasks (tenant_id, created_at DESC);
CREATE INDEX ON tasks (tenant_id, status) WHERE status = 'open';
CREATE INDEX ON tasks (tenant_id, assignee_id);
CREATE UNIQUE INDEX ON tasks (tenant_id, title); -- 租户内标题唯一理由有三:查询必然带 tenant_id(RLS 强制下推后优化器也能利用);租户前缀让索引天然分区化,单租户热点只膨胀自己的索引段;唯一约束变成「租户内唯一」——这正是多租户语义。
详细说明
共享表模型的查询纪律
RLS 是底线,但应用层仍然要有正确的查询习惯:
-- 即使有 RLS,业务查询也应显式带 tenant_id(可读性 + 索引利用 + 排障友好)
SELECT * FROM tasks
WHERE tenant_id = :tenant_id
AND status = 'open'
ORDER BY created_at DESC
LIMIT 20;原因:RLS 的策略条件会被优化器合并进查询计划,但显式条件让执行计划更可预测(尤其涉及分区裁剪、参数化计划缓存时);日志与慢查询排查时,一条没有 tenant_id 的 SQL 立刻可以判定为异常代码。
连接池 transaction 模式与会话变量
PgBouncer 的 transaction 模式会把连接在不同请求间复用——上一个请求 SET app.tenant_id = '1001',下一个请求拿到同一个物理连接时,这个值还在!如果新请求忘了设置,RLS 会按上一个租户过滤,数据就串了。
正确做法是每个事务开头用 SET LOCAL:
BEGIN;
SET LOCAL app.tenant_id = '1001'; -- 事务结束自动还原,不污染连接
SELECT * FROM tasks;
COMMIT;SET LOCAL 的作用域是当前事务:提交或回滚后自动消失,连接被池复用时不会携带残留值。配套纪律:
- RLS 策略要在
current_setting('app.tenant_id', true)里处理缺失(第二参数true表示未设置时返回空串而不是报错),并把空串显式判定为「无权限」:
CREATE POLICY tenant_isolation ON tasks
USING (
tenant_id = CASE
WHEN current_setting('app.tenant_id', true) = ''
THEN NULL -- 未设置租户上下文:什么都看不到
ELSE current_setting('app.tenant_id')::bigint
END
);- 中间件统一注入。在框架中间件里「开启事务 → SET LOCAL → 业务逻辑 → 提交」,业务代码永远不碰租户上下文的设置。
- 禁止应用代码使用裸
SET(会话级)。评审见SET app.tenant_id直接打回。 - PgBouncer 的配置与模式细节见 PgBouncer 连接池。
PHP 中的事务级租户注入
<?php
// Middleware/TenantContext.php —— Laravel 风格的事务级租户上下文
class TenantContext
{
public function handle($request, Closure $next)
{
$tenantId = $request->user()?->tenant_id ?? null;
if ($tenantId === null) {
abort(401, 'tenant context required');
}
DB::beginTransaction();
try {
// SET LOCAL:只作用于当前事务,事务提交/回滚后自动清除
DB::statement('SELECT set_config(?, ?, true)', [
'app.tenant_id', (string)$tenantId,
]);
$response = $next($request);
DB::commit();
return $response;
} catch (Throwable $e) {
DB::rollBack();
throw $e;
}
}
}这里用 SELECT set_config('app.tenant_id', '1001', true) 代替 SET LOCAL app.tenant_id:两者等价,set_config 的第三个参数 is_local = true 即「事务级」,且它是函数调用,参数化更自然。
schema 模型的 search_path 管理
独立 schema 模型中,路由租户就是设置 search_path:
-- 为租户建 schema 与同名表
CREATE SCHEMA tenant_1001;
CREATE TABLE tenant_1001.projects (LIKE public.projects INCLUDING ALL);
-- 会话路由到租户 schema
SET search_path TO tenant_1001, public;
SELECT * FROM projects; -- 实际访问 tenant_1001.projects工程要点:
search_path同样受连接池复用影响,必须在每个事务/请求开头设置(SET LOCAL search_path)。- 迁移脚本要遍历所有租户 schema 执行。租户数增长后,schema 变更是逐个执行的循环,失败要有断点续跑能力。
- 新建租户的 DDL 要模板化。
CREATE SCHEMA+ 从模板复制表结构,避免每个租户手工建表。 - 跨租户聚合查询(如平台方看全网统计)需要
UNION ALL各 schema 或建汇总表,架构上要提前预留。 - schema 模型的连接池策略通常用 session 模式(每租户固定连接),连接数上限随租户数增长要提前规划。
RLS 的性能影响
RLS 策略本质是把额外条件拼进每条查询,通常能走 tenant_id 前缀索引,代价很小。但有三个坑:
- 策略表达式里的函数要便宜。
current_setting()是内存读取,没问题;但若策略里放了子查询或自定义函数,每行评估会显著变慢——策略应保持「列 = 会话变量」的简单形式。 FORCE ROW LEVEL SECURITY对 owner 也生效(默认 owner 绕过),管理工具直连时要么显式设置上下文,要么用BYPASSRLS属性的运维角色。- 超级用户与
BYPASSRLS角色不受 RLS 约束。平台管理功能如果复用业务连接,RLS 保护会被绕过;管理操作应走独立角色与独立连接。
混合模型:长尾共享、大客户独立
成熟 SaaS 的终态通常是混合模型:
- 长尾租户:共享表 + RLS,成本最低;
- 超大/合规要求高的租户:独立数据库(甚至独立实例),隔离与资源独占;
- 迁移路径:共享表 → 独立库用「按租户导出 + 逻辑复制追增量」实现,逻辑复制的表级粒度正好支持单租户搬运。
设计共享表模型时,把「某个大客户未来要拆出去」作为架构假设:主键带 tenant_id、外键全部复合、查询全部显式带租户条件——满足这三条,单租户导出就是三个 pg_dump --table 的事。
容量规划与租户热点
共享表模型的容量规划要按「最重租户」而不是「平均租户」:
-- 按租户统计行数与体积,找出热点租户
SELECT
tenant_id,
count(*) AS rows,
pg_size_pretty(pg_total_relation_size('tasks')) AS table_size,
min(created_at) AS first_row,
max(created_at) AS last_row
FROM tasks
GROUP BY tenant_id
ORDER BY rows DESC
LIMIT 20;规划要点:
- 单表上限假设。共享表的行数是所有租户之和,按「租户数 × 平均规模 × 头部系数(如 50 倍)」估算,超大表提前规划分区(按
tenant_id哈希分区或按时间范围分区)。 - 热点租户的 VACUUM 压力。一个大客户的频繁写入会让共享表的 autovacuum 加速,牵连所有租户;热点租户达到阈值时就是拆分时机。
- WAL 与备份窗口随全量数据增长,按全表而非单租户规划。
- 统计信息与执行计划。全表统计混合多租户分布,个别租户的数据倾斜会让优化器误判选择率;必要时对热点租户用扩展统计(
CREATE STATISTICS)改善计划。
全局表与租户表的边界
「tenant_id 必须出现在每一张表上」要辩证地执行。系统里其实有两类表:
- 租户私有表:订单、任务、文档……一切业务数据。行属于某个租户,必须有
tenant_id、必须启用 RLS、索引必须带租户前缀。 - 全局共享表:租户定义(
tenants)、计费方案(plans)、系统字典、功能开关。这些表的「行」是平台数据而不是租户数据——强行加tenant_id反而制造歧义(tenants表自己怎么加?)。
判断标准:一行的 ownership 归租户还是归平台。归租户的表进入 RLS 体系;归平台的表用普通权限控制(业务角色只读、管理角色可写)。最忌讳的是两类混在一张表里——「带可空 tenant_id 的配置表」会让 RLS 策略变成「tenant_id IS NULL 全局可见,否则等于当前租户」,每条策略都背一个特例,泄漏路径多一条。确有「默认配置 + 租户覆写」需求时,拆成 defaults 与 tenant_overrides 两张表在读取时 COALESCE 合并,比一张混合表清晰得多。
租户生命周期:开通、暂停与注销
租户是有生命周期的,三种模型都要为「开通 → 使用 → 暂停 → 注销」设计对应操作:
开通:
-- 共享表模型:一行注册即开通
INSERT INTO tenants (slug, name) VALUES ('acme', 'Acme Inc.') RETURNING id;
-- schema 模型:模板化建 schema(建议封装成函数,逐表 LIKE 模板)
CREATE SCHEMA tenant_3001;
CREATE TABLE tenant_3001.projects (LIKE public.projects INCLUDING ALL);暂停(欠费、违规):不要用「删数据」表达暂停。共享表模型用状态列 + 应用侧拦截:
ALTER TABLE tenants ADD COLUMN status text NOT NULL DEFAULT 'active';
-- active / suspended / closed;中间件在校验租户时读该列,暂停租户直接 403注销:注销 ≠ 立即物理删除,合规通常要求保留期(如 90 天)。注销时把状态置 closed、业务侧停止接入;数据由统一的「注销租户清理任务」在保留期结束后先归档再删除。共享表模型按 tenant_id 批量导出(COPY 或逻辑复制),schema/数据库模型直接 pg_dump 对应 schema/库后 DROP——这正是三模型在「单租户退出成本」上的显著差异,选型时就应把预期的租户流动率考虑进去。
审计与跨租户操作
平台方免不了「跨租户运维」(客服查数据、按租户迁移)。原则:
- 跨租户操作走显式授权的运维角色,该角色绕过 RLS(
BYPASSRLS),操作全程审计(谁、何时、查了哪个租户的什么)。 - 应用层禁止「带租户参数的万能查询」。即运维功能也要先确定目标租户再走单租户上下文,不要提供「无租户上下文的查询接口」。
- RLS 策略可以加例外逻辑(如
current_setting('app.role') = 'support'时允许),但例外的审计成本高于独立角色方案,谨慎选择。
实战示例
示例一:共享表模型完整搭建
-- 1. 租户与成员
CREATE TABLE tenants (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
slug text NOT NULL UNIQUE,
name text NOT NULL
);
CREATE TABLE memberships (
tenant_id bigint NOT NULL REFERENCES tenants(id),
user_id bigint NOT NULL,
role text NOT NULL DEFAULT 'member',
PRIMARY KEY (tenant_id, user_id)
);
-- 2. 业务表:复合主键 + 复合外键
CREATE TABLE projects (
id bigint GENERATED ALWAYS AS IDENTITY,
tenant_id bigint NOT NULL REFERENCES tenants(id),
name text NOT NULL,
PRIMARY KEY (tenant_id, id)
);
CREATE TABLE tasks (
id bigint GENERATED ALWAYS AS IDENTITY,
tenant_id bigint NOT NULL REFERENCES tenants(id),
project_id bigint NOT NULL,
title text NOT NULL,
assignee_id bigint NOT NULL,
PRIMARY KEY (tenant_id, id),
FOREIGN KEY (tenant_id, project_id) REFERENCES projects (tenant_id, id),
FOREIGN KEY (tenant_id, assignee_id) REFERENCES memberships (tenant_id, user_id)
);
-- 3. 租户前缀索引
CREATE INDEX ON tasks (tenant_id, created_at DESC);
CREATE INDEX ON tasks (tenant_id, assignee_id);
-- 4. RLS
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks FORCE ROW LEVEL SECURITY;
CREATE POLICY tasks_tenant_isolation ON tasks
USING (
tenant_id = CASE
WHEN current_setting('app.tenant_id', true) = '' THEN NULL
ELSE current_setting('app.tenant_id')::bigint
END
)
WITH CHECK (
tenant_id = CASE
WHEN current_setting('app.tenant_id', true) = '' THEN NULL
ELSE current_setting('app.tenant_id')::bigint
END
);验证隔离效果:
BEGIN;
SELECT set_config('app.tenant_id', '1', true);
INSERT INTO tasks (tenant_id, project_id, title, assignee_id)
VALUES (2, 1, 'cross tenant task', 1);ERROR: new row violates row-level security policy for table "tasks"INSERT INTO tasks (tenant_id, project_id, title, assignee_id)
VALUES (1, 1, 'my task', 1);
COMMIT;INSERT 0 1跨租户写入被 RLS 的 WITH CHECK 拒绝,同租户写入正常——隔离不依赖任何应用代码。
示例二:EXPLAIN 验证租户前缀索引
BEGIN;
SELECT set_config('app.tenant_id', '1', true);
EXPLAIN ANALYZE
SELECT * FROM tasks
WHERE status = 'open'
ORDER BY created_at DESC
LIMIT 20;
COMMIT;Limit (cost=0.56..25.10 rows=20 width=52) (actual time=0.091..0.163 rows=20 loops=1)
-> Index Scan using tasks_tenant_id_created_at_idx on tasks
Index Cond: (tenant_id = 1)
Filter: (status = 'open'::text)
(actual time=0.089..0.152 rows=20 loops=1)
Planning Time: 0.489 ms
Execution Time: 0.231 ms注意 Index Cond: (tenant_id = 1):业务 SQL 里没有写 tenant_id,这个条件来自 RLS 策略的下推。RLS 不只保安全,条件合并后同样利用了租户前缀索引。
给排障留一条后路:EXPLAIN 里看到 RLS 下推的条件属于「计划正常」;哪天计划里突然没有这个条件(比如角色被误配 BYPASSRLS、策略被禁用),隔离就静默失效了——把「RLS 条件是否出现在计划里」也纳入巡检 SQL(EXPLAIN (FORMAT JSON) 解析 Index Cond 或 Filter 中的租户条件),隔离失效从上线事故降级为巡检告警。
示例三:schema 模型的租户路由
-- 建租户 schema(模板化)
CREATE SCHEMA IF NOT EXISTS tenant_2001;
CREATE TABLE IF NOT EXISTS tenant_2001.projects (LIKE public.projects INCLUDING ALL);
CREATE TABLE IF NOT EXISTS tenant_2001.tasks (LIKE public.tasks INCLUDING ALL);
GRANT USAGE ON SCHEMA tenant_2001 TO app_rw;
GRANT ALL ON ALL TABLES IN SCHEMA tenant_2001 TO app_rw;
-- 路由与查询(事务级)
BEGIN;
SET LOCAL search_path = tenant_2001, public;
INSERT INTO projects (tenant_id, name) VALUES (2001, 'schema-mode project');
SELECT count(*) FROM tasks;
COMMIT;PHP 侧的路由封装(slug 解析租户 → 事务级设置 search_path):
<?php
function withTenantSchema(PDO $pdo, string $slug, callable $fn): mixed
{
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT id FROM tenants WHERE slug = ?');
$stmt->execute([$slug]);
$tenantId = $stmt->fetchColumn();
if ($tenantId === false) {
throw new DomainException('unknown tenant');
}
// schema 名由服务端按 id 拼接(格式固定为 tenant_<数字>),杜绝注入
$pdo->exec(sprintf('SET LOCAL search_path = tenant_%d, public', $tenantId));
$result = $fn($pdo);
$pdo->commit();
return $result;
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}路由封装的两个工程细节值得强调:search_path 永远在事务内设置——SET LOCAL 保证事务结束时自动还原,连接归还池后不会把租户 A 的 schema 漏给下一个租借者;schema 名由服务端按租户 id 拼接(固定 tenant_%d 格式),用户可控的 slug 只用于查询映射、永远不进入 SQL 拼接。这两条是 schema 模型防串号的底线,缺一条都是跨租户泄漏的入口。
schema 模型还有一条纪律:业务代码里不出现 schema 名字面量。所有 SQL 都假定 search_path 已由路由层设好,写 tenant_2001.projects 的代码一旦出现在控制器里,就意味着有人绕过了路由层——code review 把这类硬编码当隔离漏洞处理,而不是风格问题。
示例四:越权回归测试用例
RLS 策略的任何修改(哪怕只是加一个例外条件)都必须跑隔离回归——隔离逻辑属于「改坏比改慢严重得多」的一类:
<?php
// Tests/Feature/TenantIsolationTest.php
public function test_cross_tenant_read_returns_empty(): void
{
DB::statement('BEGIN');
DB::statement("SELECT set_config('app.tenant_id', '1', true)");
$rows = DB::select('SELECT id FROM tasks WHERE id = ?', [$taskOfTenant2]);
$this->assertCount(0, $rows, 'tenant 1 must not see tenant 2 tasks');
DB::statement('COMMIT');
}
public function test_cross_tenant_insert_is_rejected(): void
{
DB::statement('BEGIN');
DB::statement("SELECT set_config('app.tenant_id', '1', true)");
$this->expectException(QueryException::class); // RLS WITH CHECK 违反
DB::insert(
'INSERT INTO tasks (tenant_id, project_id, title, assignee_id) VALUES (2, ?, ?, ?)',
[$projectOfTenant2, 'intrusion', 1]
);
DB::statement('COMMIT');
}
public function test_missing_context_sees_nothing(): void
{
// 不设置 app.tenant_id:任何行都不可见(防御配置遗漏)
$rows = DB::select('SELECT count(*) AS c FROM tasks');
$this->assertEquals(0, $rows[0]->c);
}三条断言分别覆盖「读越权」「写越权」「上下文缺失」三种泄漏路径,缺一不可。更严格的团队会把这套用例做成 CI 门禁:RLS 策略放在独立迁移文件里,任何触碰 RLS 的 PR 自动触发隔离测试套件——隔离故障是安全事故,不值得用「概率低」来赌。测试数据上固定两套「标准租户夹具」(各带 3 个项目、10 个任务、1 个敏感行),断言可以精确到行级;夹具随迁移脚本演进,保证测试与生产 schema 始终同构。
回归用例的失败信息也要「可判读」:断言消息写明期望与实际(如 tenant 1 must not see tenant 2 tasks),失败时一眼定位是策略缺失还是条件写反——隔离测试是给排障的人看的,报错可读性直接决定排障速度。
注意事项
RLS 不是默认开启的。 ENABLE ROW LEVEL SECURITY 之后表 owner 仍默认绕过,生产表要 FORCE ROW LEVEL SECURITY;漏掉 FORCE 的 RLS 是「看起来有、实际没有」的保护。
current_setting('app.tenant_id', true) 的第二参数别忘了。 missing_ok = true 让未设置时返回 NULL 而不是报错——策略里必须显式处理 NULL(比较前判空、返回 false 拒绝全部行),否则 NULL 拼进比较条件的后果不可预测。
会话级 SET 在连接池下会串租户。 transaction 模式复用连接,上一位请求的 app.tenant_id 残留给下一位。一律 SET LOCAL / set_config(..., is_local := true),并在策略里把「未设置」处理为拒绝。
复合外键是防跨租户引用的最低成本手段。 只靠应用校验的引用关系,总有一天会出现「租户 A 的任务指向租户 B 的项目」这种数据腐化,修复成本远高于建约束。
索引必须带 tenant_id 前缀。 没有租户前缀的索引在共享表上基本用不上(查询条件必含租户),白占空间;唯一约束同理要改为「租户内唯一」。
策略表达式保持简单。 current_setting + 常量比较是零成本;策略里放子查询/自定义函数会在每行评估,性能悬崖。复杂判定放应用层,数据库策略只做「租户相等」这一件事。
RLS 拦不住超级用户与 BYPASSRLS 角色。 平台运维走独立角色 + 独立连接 + 审计,不要复用业务连接做跨租户操作。
schema 模型的 DDL 变更是乘法。 迁移脚本要按 schema 遍历、可断点续跑、可回滚;租户数超过数百后,schema 模型的变更流水线必须自动化。
共享表的容量按头部租户规划。 数据倾斜是常态,监控 GROUP BY tenant_id 的行数分布,为热点租户预留拆分路径(主键带 tenant_id、外键复合,正是为拆分预留的架构假设)。
租户上下文缺失要「拒绝」而不是「报错」或「放行」。 current_setting(..., true) 返回空串时策略应判 NULL(看不到任何行),防御性地把配置遗漏变成空结果而不是越权数据。
备份与恢复粒度在选模型时就要算账。 schema 模型可以 pg_dump --schema=tenant_x 单租户恢复;共享表模型只能整库恢复后按 tenant_id 抽取。租户级的 RTO/RPO 承诺不同,模型选择的账就不一样——别等客户问起才想起来。
RLS 策略的变更要走迁移与回归,不在线上手工改。 共享表模型一张表一组策略、变更成本低;schema 模型策略随每个租户的表走,变更要按 schema 遍历——策略改动纳入版本化迁移并触发隔离回归,手工改策略是串号事故的经典起点。
最佳实践
- SaaS 初期一律共享表模型,为单租户拆分预留结构。
tenant_id全表覆盖 + 复合主键 + 复合外键三条纪律从第一天执行,未来「大客户拆独立库」才有低成本路径。 - RLS 作为隔离底线,应用层查询仍显式带 tenant_id。 数据库层保证「查不到不该查的」,应用层保证「计划可预测、日志可排障」,两层都做。
- 租户上下文只允许事务级注入。 中间件统一执行
set_config('app.tenant_id', ?, true),业务代码禁止出现SET;缺失上下文在 RLS 策略里判定为拒绝。 - 连接池 transaction 模式是 RLS 的标准搭档。 事务级变量 + 池化连接是安全的组合;session 模式 + 会话级 SET 是串租户的温床,明确禁止。
- 所有索引以 tenant_id 开头,唯一约束改为租户内唯一。 既贴合查询模式,又把多租户语义固化进结构。
- 跨租户引用用复合外键物理阻止。
(tenant_id, project_id) REFERENCES (tenant_id, id)让引用腐化在写入时就被拦截。 - 运维跨租户操作走独立 BYPASSRLS 角色并全程审计。 业务连接永远不携带绕过 RLS 的权限,运维操作单独审计留痕。
- schema 模型要先把迁移流水线自动化再上量。 schema-per-tenant 的每一项便利都以「乘法复杂度」为代价,数百租户规模时变更、备份、监控必须全自动化。
- 监控租户数据分布与热点。 定期跑
GROUP BY tenant_id统计,头部租户的增长趋势直接决定何时拆分;热点租户的延迟告警单列。 - 租户级功能开关与配额放 tenants 表或配置服务。 配额(任务数、存储量)在应用层强制,但配额的「已用量」从共享表按 tenant_id 统计或维护计数器表,避免逐表 count。
- 为多租户表设计测试用例:跨租户读写必拒。 自动化测试里固定包含「租户 A 上下文读租户 B 数据返回空」「跨租户写入被 WITH CHECK 拒绝」两条断言,RLS 策略变更时立即回归。
- 把租户隔离模型写进 ADR 并约定升级路径。 「共享表起步、大客户独立库、逻辑复制搬运」作为公开决策记录,避免团队在三种模型间反复摇摆。
下一节
继续学习:树形结构(递归 CTE)
参考链接
- PostgreSQL 官方文档:Row Security Policies(RLS)
- PostgreSQL 官方文档:CREATE POLICY
- PostgreSQL 官方文档:set_config 与 current_setting
- PostgreSQL 官方文档:Schemas and search_path
- PostgreSQL 官方文档:FOREIGN KEY 约束
- PgBouncer 官方文档:连接池模式
- PostgreSQL 官方文档:Multitenancy 相关索引(Partial / Composite)
- PostgreSQL 官方文档:Extended Statistics(数据倾斜统计)
- Cybertec: Row-Level Security in PostgreSQL
- Laravel 官方文档:Database Transactions