多租户与 SaaS 化落地

拆解低代码平台的多租户与 SaaS 化:三种隔离模型(共享库共享表、共享库独立表、独立库)的取舍、元数据与数据的租户隔离、行级权限与 RLS、租户级配置与定制、资源配额与限流、租户生命周期与数据导出、应用的多租户分发、以及越权防护,给出隔离字段设计、RLS 策略与配额实现,回答如何把内部低代码平台产品化对外服务。

引言

内部低代码平台和对外 SaaS 低代码平台,最大的差别是「多租户」。内部平台服务一个组织,数据边界清晰;对外平台服务多个互不信任的客户,任何一次隔离失效都是数据泄露事故。多租户不是加一个 tenant_id 字段那么简单,它渗透到元数据、数据、权限、配额、生命周期每一个环节。

低代码平台的多租户还有一层特殊复杂性:它本身是「造应用的平台」,租户既消费平台(用平台的表存自己的业务数据),又产出内容(自己搭建的应用与元数据)。这意味着「平台自身的多租户」与「租户应用的多租户」是两个层次,必须分清。

本文按「两层多租户 → 三种隔离模型 → 元数据隔离 → 数据隔离 → RLS → 租户定制 → 配额限流 → 生命周期 → 应用分发 → 越权防护」展开,给出隔离字段、RLS 策略与配额的实现。读完后你应当能判断:多租户的成本主要花在哪里,以及哪些隔离决策必须在第一行代码前确定。

目录

  1. 低代码平台的两层多租户
  2. 三种隔离模型
  3. 元数据的租户隔离
  4. 数据的租户隔离
  5. 行级权限与 RLS
  6. 租户级配置与定制
  7. 资源配额与限流
  8. 租户生命周期与数据导出
  9. 应用的多租户分发
  10. 安全边界与越权防护

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 代码配置 + 元数据配置,保持单一代码

常见坑清单

  1. 两层租户混用命名:平台 org_id 与业务 tenant_id 混淆,导致权限判断取错上下文。
  2. 缓存键不含租户:app_id 碰撞导致串数据,键必须含 org_id。
  3. 连接池不重置会话变量:RLS 上下文残留,下一个租户继承上一个的权限。
  4. 租户过滤靠手写:总有遗漏,应在数据访问层统一注入。
  5. 跨租户引用不校验:应用 A 引用应用 B 的数据源,形成越权通道。
  6. 无配额与限流:单租户滥用耗尽公共资源,全平台雪崩。
  7. 为租户 fork 代码:维护成本爆炸,差异应收敛到配置。
  8. 无数据导出能力:合规审计不过,且租户被绑定无法离开。
  9. 注销只软删不物理删:违反数据删除合规要求。
  10. 无越权自动化测试:依赖人工检查,遗漏不可避免。

小结

多租户与 SaaS 化的骨架是「两层租户分清 → 隔离模型 → 元数据隔离 → 数据隔离 → RLS → 定制 → 配额 → 生命周期 → 应用分发 → 越权防护」。三条原则最关键:数据访问层统一注入租户过滤、缓存键必须含租户、差异收敛到配置而非代码分支。

隔离模型的选型(共享表 / 独立 Schema / 独立库)是架构级决策,一旦选定,迁移成本极高。稳妥路线是「默认共享表,为需要强隔离的租户预留升级路径」,并让数据访问层对上层透明。

多租户平台的应用分发依赖元数据的可复制与参数化,见 元数据驱动架构设计 ;而平台的长期健康依赖治理,见 治理边界与常见反模式 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「低代码」更多文章

  1. 自定义代码与逃生舱
  2. 低代码应用测试与质量
  3. 连接器与 API 编排