Skip to content

多租户设计 ​

当一个 PostgreSQL 实例要同时服务多个客户(租户,tenant)时,第一个架构问题就是:租户的数据放在一起还是分开? 这个问题没有万能答案,只有三种经典模型——共享表(tenant_id 列)、独立 schema、独立数据库——每一种在隔离强度、运维成本、资源密度之间做了不同的取舍。

选完模型,真正的工程难点才开始:怎么保证「租户 A 永远查不到租户 B 的数据」?靠应用层每个人都记得写 WHERE tenant_id = ? 是不可靠的,本页会讲两层机制化的保障:复合外键保证引用关系不跨租户、行级安全(Row-Level Security, RLS)把租户过滤强制下推到数据库层。此外还有索引怎么带租户前缀、连接池 transaction 模式下会话变量怎么安全传递、schema 模式的 search_path 管理等实战细节。

基础概念 ​

概念英文 / 记法说明
租户tenant共用一套系统的独立客户(公司、团队、店铺)
共享表模型shared schema / shared table所有租户的数据放在同一批表里,用 tenant_id 区分
独立 schema 模型schema-per-tenant每个租户一个 schema,同名表各自独立
独立数据库模型database-per-tenant每个租户一个数据库实例或库
RLSrow-level security数据库层的行级访问控制,强制按策略过滤行
会话变量session variableSET 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 失效)单租户单租户
成本密度最高中最低

语法与用法 ​

共享表模型的基础结构 ​

sql
-- 租户表(注册租户)
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)
);

两个关键决策:

  1. 主键用复合键 (tenant_id, id) 还是全局唯一 id? 全局唯一 id(identity/UUID)实现简单、外键简单,但复合键把「租户内唯一」的语义写进数据库,且天然适合按租户分片。本页后面给出「全局 id + 复合外键」的折中方案,这是生产中最常见的组合。
  2. tenant_id 必须出现在每一张业务表上——包括看似「全局」的配置表。例外是租户定义表 tenants 自身。

复合外键:让引用关系无法跨租户 ​

单独的 REFERENCES tenants(id) 只保证租户存在,不保证「project 和 task 属于同一个租户」。tasks.project_id 完全可能指向别的租户的项目。复合外键把引用完整性升级到租户维度:

sql
-- 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 让数据库自己按策略过滤行,应用即使忘写租户条件也查不到别人的数据:

sql
-- 启用 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 开头:

sql
-- 反例:纯业务列索引。租户查询极少「跨全租户」按 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 是底线,但应用层仍然要有正确的查询习惯:

sql
-- 即使有 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:

sql
BEGIN;
SET LOCAL app.tenant_id = '1001';      -- 事务结束自动还原,不污染连接
SELECT * FROM tasks;
COMMIT;

SET LOCAL 的作用域是当前事务:提交或回滚后自动消失,连接被池复用时不会携带残留值。配套纪律:

  1. RLS 策略要在 current_setting('app.tenant_id', true) 里处理缺失(第二参数 true 表示未设置时返回空串而不是报错),并把空串显式判定为「无权限」:
sql
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
    );
  1. 中间件统一注入。在框架中间件里「开启事务 → SET LOCAL → 业务逻辑 → 提交」,业务代码永远不碰租户上下文的设置。
  2. 禁止应用代码使用裸 SET(会话级)。评审见 SET app.tenant_id 直接打回。
  3. PgBouncer 的配置与模式细节见 PgBouncer 连接池。

PHP 中的事务级租户注入 ​

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:

sql
-- 为租户建 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

工程要点:

  1. search_path 同样受连接池复用影响,必须在每个事务/请求开头设置(SET LOCAL search_path)。
  2. 迁移脚本要遍历所有租户 schema 执行。租户数增长后,schema 变更是逐个执行的循环,失败要有断点续跑能力。
  3. 新建租户的 DDL 要模板化。CREATE SCHEMA + 从模板复制表结构,避免每个租户手工建表。
  4. 跨租户聚合查询(如平台方看全网统计)需要 UNION ALL 各 schema 或建汇总表,架构上要提前预留。
  5. schema 模型的连接池策略通常用 session 模式(每租户固定连接),连接数上限随租户数增长要提前规划。

RLS 的性能影响 ​

RLS 策略本质是把额外条件拼进每条查询,通常能走 tenant_id 前缀索引,代价很小。但有三个坑:

  1. 策略表达式里的函数要便宜。current_setting() 是内存读取,没问题;但若策略里放了子查询或自定义函数,每行评估会显著变慢——策略应保持「列 = 会话变量」的简单形式。
  2. FORCE ROW LEVEL SECURITY 对 owner 也生效(默认 owner 绕过),管理工具直连时要么显式设置上下文,要么用 BYPASSRLS 属性的运维角色。
  3. 超级用户与 BYPASSRLS 角色不受 RLS 约束。平台管理功能如果复用业务连接,RLS 保护会被绕过;管理操作应走独立角色与独立连接。

混合模型:长尾共享、大客户独立 ​

成熟 SaaS 的终态通常是混合模型:

  • 长尾租户:共享表 + RLS,成本最低;
  • 超大/合规要求高的租户:独立数据库(甚至独立实例),隔离与资源独占;
  • 迁移路径:共享表 → 独立库用「按租户导出 + 逻辑复制追增量」实现,逻辑复制的表级粒度正好支持单租户搬运。

设计共享表模型时,把「某个大客户未来要拆出去」作为架构假设:主键带 tenant_id、外键全部复合、查询全部显式带租户条件——满足这三条,单租户导出就是三个 pg_dump --table 的事。

容量规划与租户热点 ​

共享表模型的容量规划要按「最重租户」而不是「平均租户」:

sql
-- 按租户统计行数与体积,找出热点租户
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;

规划要点:

  1. 单表上限假设。共享表的行数是所有租户之和,按「租户数 × 平均规模 × 头部系数(如 50 倍)」估算,超大表提前规划分区(按 tenant_id 哈希分区或按时间范围分区)。
  2. 热点租户的 VACUUM 压力。一个大客户的频繁写入会让共享表的 autovacuum 加速,牵连所有租户;热点租户达到阈值时就是拆分时机。
  3. WAL 与备份窗口随全量数据增长,按全表而非单租户规划。
  4. 统计信息与执行计划。全表统计混合多租户分布,个别租户的数据倾斜会让优化器误判选择率;必要时对热点租户用扩展统计(CREATE STATISTICS)改善计划。

全局表与租户表的边界 ​

「tenant_id 必须出现在每一张表上」要辩证地执行。系统里其实有两类表:

  1. 租户私有表:订单、任务、文档……一切业务数据。行属于某个租户,必须有 tenant_id、必须启用 RLS、索引必须带租户前缀。
  2. 全局共享表:租户定义(tenants)、计费方案(plans)、系统字典、功能开关。这些表的「行」是平台数据而不是租户数据——强行加 tenant_id 反而制造歧义(tenants 表自己怎么加?)。

判断标准:一行的 ownership 归租户还是归平台。归租户的表进入 RLS 体系;归平台的表用普通权限控制(业务角色只读、管理角色可写)。最忌讳的是两类混在一张表里——「带可空 tenant_id 的配置表」会让 RLS 策略变成「tenant_id IS NULL 全局可见,否则等于当前租户」,每条策略都背一个特例,泄漏路径多一条。确有「默认配置 + 租户覆写」需求时,拆成 defaults 与 tenant_overrides 两张表在读取时 COALESCE 合并,比一张混合表清晰得多。

租户生命周期:开通、暂停与注销 ​

租户是有生命周期的,三种模型都要为「开通 → 使用 → 暂停 → 注销」设计对应操作:

开通:

sql
-- 共享表模型:一行注册即开通
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);

暂停(欠费、违规):不要用「删数据」表达暂停。共享表模型用状态列 + 应用侧拦截:

sql
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——这正是三模型在「单租户退出成本」上的显著差异,选型时就应把预期的租户流动率考虑进去。

审计与跨租户操作 ​

平台方免不了「跨租户运维」(客服查数据、按租户迁移)。原则:

  1. 跨租户操作走显式授权的运维角色,该角色绕过 RLS(BYPASSRLS),操作全程审计(谁、何时、查了哪个租户的什么)。
  2. 应用层禁止「带租户参数的万能查询」。即运维功能也要先确定目标租户再走单租户上下文,不要提供「无租户上下文的查询接口」。
  3. RLS 策略可以加例外逻辑(如 current_setting('app.role') = 'support' 时允许),但例外的审计成本高于独立角色方案,谨慎选择。

实战示例 ​

示例一:共享表模型完整搭建 ​

sql
-- 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
    );

验证隔离效果:

sql
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);
text
ERROR:  new row violates row-level security policy for table "tasks"
sql
INSERT INTO tasks (tenant_id, project_id, title, assignee_id)
VALUES (1, 1, 'my task', 1);
COMMIT;
text
INSERT 0 1

跨租户写入被 RLS 的 WITH CHECK 拒绝,同租户写入正常——隔离不依赖任何应用代码。

示例二:EXPLAIN 验证租户前缀索引 ​

sql
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;
text
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 模型的租户路由 ​

sql
-- 建租户 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
<?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
<?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 遍历——策略改动纳入版本化迁移并触发隔离回归,手工改策略是串号事故的经典起点。

最佳实践 ​

  1. SaaS 初期一律共享表模型,为单租户拆分预留结构。 tenant_id 全表覆盖 + 复合主键 + 复合外键三条纪律从第一天执行,未来「大客户拆独立库」才有低成本路径。
  2. RLS 作为隔离底线,应用层查询仍显式带 tenant_id。 数据库层保证「查不到不该查的」,应用层保证「计划可预测、日志可排障」,两层都做。
  3. 租户上下文只允许事务级注入。 中间件统一执行 set_config('app.tenant_id', ?, true),业务代码禁止出现 SET;缺失上下文在 RLS 策略里判定为拒绝。
  4. 连接池 transaction 模式是 RLS 的标准搭档。 事务级变量 + 池化连接是安全的组合;session 模式 + 会话级 SET 是串租户的温床,明确禁止。
  5. 所有索引以 tenant_id 开头,唯一约束改为租户内唯一。 既贴合查询模式,又把多租户语义固化进结构。
  6. 跨租户引用用复合外键物理阻止。 (tenant_id, project_id) REFERENCES (tenant_id, id) 让引用腐化在写入时就被拦截。
  7. 运维跨租户操作走独立 BYPASSRLS 角色并全程审计。 业务连接永远不携带绕过 RLS 的权限,运维操作单独审计留痕。
  8. schema 模型要先把迁移流水线自动化再上量。 schema-per-tenant 的每一项便利都以「乘法复杂度」为代价,数百租户规模时变更、备份、监控必须全自动化。
  9. 监控租户数据分布与热点。 定期跑 GROUP BY tenant_id 统计,头部租户的增长趋势直接决定何时拆分;热点租户的延迟告警单列。
  10. 租户级功能开关与配额放 tenants 表或配置服务。 配额(任务数、存储量)在应用层强制,但配额的「已用量」从共享表按 tenant_id 统计或维护计数器表,避免逐表 count。
  11. 为多租户表设计测试用例:跨租户读写必拒。 自动化测试里固定包含「租户 A 上下文读租户 B 数据返回空」「跨租户写入被 WITH CHECK 拒绝」两条断言,RLS 策略变更时立即回归。
  12. 把租户隔离模型写进 ADR 并约定升级路径。 「共享表起步、大客户独立库、逻辑复制搬运」作为公开决策记录,避免团队在三种模型间反复摇摆。

下一节

继续学习:树形结构(递归 CTE)

参考链接 ​