引言
内部低代码平台和对外 SaaS 低代码平台,最大的差别是「多租户」。内部平台服务一个组织,数据边界清晰;对外平台服务多个互不信任的客户,任何一次隔离失效都是数据泄露事故。多租户不是加一个 tenant_id 字段那么简单,它渗透到元数据、数据、权限、配额、生命周期每一个环节。
低代码平台的多租户还有一层特殊复杂性:它本身是「造应用的平台」,租户既消费平台(用平台的表存自己的业务数据),又产出内容(自己搭建的应用与元数据)。这意味着「平台自身的多租户」与「租户应用的多租户」是两个层次,必须分清。
本文按「两层多租户 → 三种隔离模型 → 元数据隔离 → 数据隔离 → RLS → 租户定制 → 配额限流 → 生命周期 → 应用分发 → 越权防护」展开,给出隔离字段、RLS 策略与配额的实现。读完后你应当能判断:多租户的成本主要花在哪里,以及哪些隔离决策必须在第一行代码前确定。
目录
- 低代码平台的两层多租户
- 三种隔离模型
- 元数据的租户隔离
- 数据的租户隔离
- 行级权限与 RLS
- 租户级配置与定制
- 资源配额与限流
- 租户生命周期与数据导出
- 应用的多租户分发
- 安全边界与越权防护
1. 低代码平台的两层多租户
第一层:平台级多租户
平台自身服务多个租户
租户 A 与租户 B 用同一个平台实例
隔离对象:应用、元数据、用户、配额
第二层:应用级多租户
某个租户搭建的应用,自身也可能服务多个客户
即「租户的应用也是多租户的」
隔离对象:业务数据
两层容易混淆:
平台开发者的 tenant_id 是「第一层」
应用搭建者的 tenant_id 是「第二层」
命名必须区分,如 org_id(平台)与 tenant_id(应用)
混淆两层是最常见的架构错误,会导致权限判断引用错误的租户上下文。
2. 三种隔离模型
| 模型 | 隔离 | 成本 | 扩展性 | 适用 |
|---|---|---|---|---|
| 共享库共享表 | 弱 | 低 | 好 | 中小租户、成本敏感 |
| 共享库独立 Schema | 中 | 中 | 中 | 需较强隔离 |
| 独立库 | 强 | 高 | 差 | 大客户、合规要求 |
共享库共享表:
所有租户数据在同一张表,靠 tenant_id 区分
优点:运维简单、成本最低、聚合统计方便
缺点:单表膨胀、隔离靠代码保证、误查风险高
共享库独立 Schema:
每个租户一个 schema(PG)或 database(MySQL)
优点:隔离较强、可按租户备份
缺点:连接数膨胀、迁移需遍历所有 schema
独立库:
每租户一个数据库实例
优点:隔离最强、可独立扩容
缺点:成本高、运维复杂、跨租户统计难
推荐路线:默认共享库共享表,大客户可升级独立库。这要求数据访问层抽象出「租户路由」,使隔离模型对上层透明。
3. 元数据的租户隔离
平台自身的元数据(应用、表单、流程定义)必须按租户隔离。
CREATE TABLE app_metadata (
app_id varchar(36) PRIMARY KEY,
org_id varchar(36) NOT NULL, -- 平台租户
name varchar(100) NOT NULL,
schema jsonb NOT NULL,
version int NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_meta_org ON app_metadata(org_id);
元数据隔离要点:
1. 每条元数据必须带 org_id
2. 所有查询强制注入 org_id 条件
3. 跨租户引用必须拒绝(如应用 A 引用应用 B 的数据源)
4. 元数据的缓存键必须含 org_id
第 4 条容易被忽略:缓存若只用 app_id 做键,两个租户的 app_id 碰撞就会串数据。缓存键必须是 org_id:app_id。
4. 数据的租户隔离
租户搭建的应用产生的业务数据,隔离更复杂,因为表结构是租户自己定义的。
两种存储策略:
A. 统一表:所有租户的业务数据存在平台预置的大表里
(行存/列存,带 org_id + app_id + entity + data jsonb)
优点:表数量可控、加租户零成本
缺点:类型约束弱、查询需解析 jsonb、单表巨大
B. 按租户建表:每个租户的业务实体建真实表
优点:类型强、查询快、可用外键
缺点:表数量爆炸、迁移需遍历
-- 策略 A:统一表 + JSONB
CREATE TABLE biz_record (
id bigserial PRIMARY KEY,
org_id varchar(36) NOT NULL,
app_id varchar(36) NOT NULL,
entity varchar(64) NOT NULL,
data jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_biz_org_entity ON biz_record(org_id, entity);
CREATE INDEX idx_biz_data ON biz_record USING GIN (data jsonb_path_ops);
策略 A 适合「实体多、单实体数据量小」的场景;策略 B 适合「实体少、单实体数据量大」。混合方案:热点实体用真实表,长尾实体用统一表。
5. 行级权限与 RLS
行级权限是多租户的兜底防线:即使应用层忘了过滤,数据库层也能拦住。
-- PostgreSQL RLS:为每个租户设置会话变量
ALTER TABLE biz_record ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON biz_record
USING (org_id = current_setting('app.current_org', true));
-- 连接建立后设置
SET app.current_org = 'org_123';
RLS 的代价与收益:
收益:绕过应用层也隔离,防越权最后一道墙
代价:每个连接需设会话变量、连接池要小心
查询计划可能变差、调试更复杂
连接池陷阱:
连接复用时会话变量可能残留
→ 每次取连接后必须重置会话变量
连接池 + RLS 是经典组合,也是最容易出事故的地方:忘了重置会话变量,下一个租户就用了上一个的上下文。
6. 租户级配置与定制
每个租户可能要求不同的品牌、字段、流程。
定制层次:
1. 主题(Logo、颜色)→ 配置即可
2. 字段扩展(租户特有字段)→ 元数据扩展
3. 流程差异(不同审批链)→ 流程变体
4. 代码差异(租户特有逻辑)→ 插件/钩子
原则:能配置就不扩展,能扩展就不分支
→ 避免为每个租户维护一份代码分支
{
"orgId": "org_123",
"branding": { "logo": "/assets/org123.svg", "primary": "#1a73e8" },
"featureFlags": { "advancedReport": true, "apiAccess": false },
"extensions": {
"customFields": { "order": ["internal_code"] }
}
}
租户定制最危险的做法是「为每个租户 fork 一份代码」。正确做法是把差异收敛到配置与元数据,代码保持单一。
7. 资源配额与限流
一个租户的滥用不能影响其他租户。
配额维度:
- 应用数、用户数、数据行数
- API 调用次数、存储空间
- 并发查询数、执行时长
实现:
- 计数:Redis 计数器 + 定期落库
- 限流:令牌桶 / 滑动窗口,按 org_id 维度
- 隔离:单租户的慢查询不能占满连接池
async function checkQuota(orgId: string, kind: string, amount = 1) {
const key = `quota:${orgId}:${kind}`;
const used = await redis.incrby(key, amount);
const limit = await getLimit(orgId, kind);
if (used > limit) {
await redis.decrby(key, amount); // 回滚
throw new QuotaExceededError(kind);
}
}
连接池与线程池也要按租户隔离或限流,否则一个租户的长事务会耗尽公共资源,导致全平台雪崩。
8. 租户生命周期与数据导出
租户有生有死,生命周期管理是合规要求。
生命周期:
注册(开通)→ 试用 → 付费 → 续费 → 停用 → 注销(数据删除)
关键能力:
- 开通:初始化租户元数据、配额、默认配置
- 数据导出:租户可导出全部数据(合规要求)
- 注销:彻底删除数据(GDPR 等要求)
- 归档:停用但不删除,保留恢复可能
-- 注销流程(软删 → 保留窗口 → 物理删)
UPDATE org SET status = 'deleting', deleted_at = now() WHERE id = $1;
-- 保留 30 天后由定时任务物理删除
DELETE FROM biz_record WHERE org_id = $1;
DELETE FROM app_metadata WHERE org_id = $1;
「数据可导出」与「数据可删除」是 SaaS 合规的硬要求,必须在架构早期就预留,事后补极难。
9. 应用的多租户分发
租户搭建的应用如何分发给它的客户,是低代码平台特有的问题。
分发模式:
A. 每个客户一份实例(复制应用)
简单,但改动需逐份同步
B. 一份应用 + 运行时租户切换
客户共享应用定义,数据按 tenant_id 隔离
推荐:改一次全生效
C. 应用市场(模板分发)
平台提供模板,租户基于模板创建自己的应用
模式 B 是主流:应用定义是「模板」,运行时的数据按客户隔离。这要求应用定义本身不含客户数据,数据全部外置并按 tenant_id 分区。
10. 安全边界与越权防护
多租户的安全事故几乎都源于「越权」。
越权类型:
1. 水平越权:租户 A 访问租户 B 的数据
2. 垂直越权:普通用户执行管理员操作
3. 上下文越权:切换租户后残留旧上下文
防护清单:
- 所有查询强制注入 org_id(数据访问层统一注入)
- 所有缓存键含 org_id
- 所有会话变量在取连接时重置
- 跨租户引用一律拒绝
- 定期越权扫描测试(自动化)
// 数据访问层统一注入,避免各处手写
function scopedQuery(table: string, ctx: RequestContext) {
return db(table).where('org_id', ctx.orgId); // 强制注入,不可绕过
}
把租户过滤放在数据访问层统一注入,而不是每个查询手写 where org_id = ?,是防越权最有效的手段——手写总有遗漏。
权衡取舍
| 决策点 | 选项 A | 选项 B | 建议 |
|---|---|---|---|
| 隔离模型 | 共享表 | 独立库 | 默认共享,大客户升级 |
| 业务数据 | 统一表 JSONB | 按租户建表 | 长尾统一表、热点真实表 |
| 租户过滤 | 各查询手写 | 数据层统一注入 | 统一注入,防遗漏 |
| RLS | 不开 | 开启兜底 | 开启,注意连接池重置 |
| 定制 | fork 代码 | 配置 + 元数据 | 配置,保持单一代码 |
常见坑清单
- 两层租户混用命名:平台 org_id 与业务 tenant_id 混淆,导致权限判断取错上下文。
- 缓存键不含租户:app_id 碰撞导致串数据,键必须含 org_id。
- 连接池不重置会话变量:RLS 上下文残留,下一个租户继承上一个的权限。
- 租户过滤靠手写:总有遗漏,应在数据访问层统一注入。
- 跨租户引用不校验:应用 A 引用应用 B 的数据源,形成越权通道。
- 无配额与限流:单租户滥用耗尽公共资源,全平台雪崩。
- 为租户 fork 代码:维护成本爆炸,差异应收敛到配置。
- 无数据导出能力:合规审计不过,且租户被绑定无法离开。
- 注销只软删不物理删:违反数据删除合规要求。
- 无越权自动化测试:依赖人工检查,遗漏不可避免。
小结
多租户与 SaaS 化的骨架是「两层租户分清 → 隔离模型 → 元数据隔离 → 数据隔离 → RLS → 定制 → 配额 → 生命周期 → 应用分发 → 越权防护」。三条原则最关键:数据访问层统一注入租户过滤、缓存键必须含租户、差异收敛到配置而非代码分支。
隔离模型的选型(共享表 / 独立 Schema / 独立库)是架构级决策,一旦选定,迁移成本极高。稳妥路线是「默认共享表,为需要强隔离的租户预留升级路径」,并让数据访问层对上层透明。
多租户平台的应用分发依赖元数据的可复制与参数化,见 元数据驱动架构设计 ;而平台的长期健康依赖治理,见 治理边界与常见反模式 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。