2. 多租户模型设计

多租户模型设计是SaaS运营后台的地基。它要回答两个问题:第一,租户、用户、组织、角色、权限这五类核心对象之间是什么关系;第二,不同租户的数据在同一套系统里如何做到互不可见、互不干扰。前者决定数据模型能否支撑业务演进,后者决定隔离策略能否在成本、安全与运维复杂度之间取得平衡。

五类核心对象的关系

先厘清对象语义。租户(Tenant)是SaaS中独立的商业主体,是计费、配额、数据归属的最高边界。用户(User)是自然人身份,一个自然人可能同时属于多个租户,因此用户与租户之间通常是多对多关系,通过“租户成员(Membership)”这张关联表落地。组织(Organization)是租户内部的部门树,用于表达汇报线与数据归属范围。角色(Role)是权限的集合,是“一组能做什么”的命名封装。权限(Permission)是最小授权单元,通常以“资源:动作”形式表达,例如 order:read、order:refund。

它们的关系可以这样理解:租户是容器,组织是容器内的层级骨架,用户通过成员关系进入容器并被挂到某个组织节点上,角色把权限打包后授予成员,最终形成“某人在某租户的某组织节点上拥有某组权限”的完整授权链。这条链上任何一环缺失,鉴权都会失败。

租户 Tenant 计费/配额/数据归属边界 租户成员 Membership user_id + tenant_id + org_id 用户 User 自然人身份,可跨租户 组织 Organization 租户内部门树,parent_id 角色 Role 权限集合,可继承 权限 Permission 资源:动作,最小授权单元 1:N N:1 1:N 层级 N:M N:M 归属组织 授予角色 成员角色关联 member_role member_id + role_id + scope_org_id 隔离策略选型 独立数据库 最强隔离 · 成本最高 共享库独立Schema 平衡隔离与成本 共享表 tenant_id 成本最低 · 需行级隔离

隔离策略的三种典型选型

独立数据库:每个租户一套库。隔离性最强,单租户故障不影响他人,适合金融、医疗等强合规场景。代价是连接池数量随租户线性增长,跨租户统计需要额外汇聚层,运维成本高。

共享库、独立Schema:同一数据库实例内,每个租户一个Schema。隔离性中等,备份恢复可按Schema粒度操作,连接池压力可控。适合租户数量在数百到数千、且对隔离有明确要求的场景。

共享表、tenant_id行级隔离:所有租户数据在同一张表,靠 tenant_id 列区分。成本最低、扩展性最好,但隔离完全依赖应用层与数据库策略,一旦漏加条件就会造成越权。适合租户量大、单租户数据量小、以标准化SaaS为主的场景。

实际工程中常采用混合策略:核心敏感数据独立库,普通业务数据共享表。选型时重点评估四个维度——合规要求、租户规模、单租户数据量、团队运维能力。

数据模型落地要点

以共享表方案为例,关键表结构如下:

-- 租户表
CREATE TABLE tenant (
  id          BIGINT PRIMARY KEY,
  name        VARCHAR(128) NOT NULL,
  plan        VARCHAR(32)  NOT NULL DEFAULT 'basic',
  status      TINYINT      NOT NULL DEFAULT 1,
  created_at  DATETIME     NOT NULL
);

-- 用户表(全局,不绑定租户)
CREATE TABLE user (
  id          BIGINT PRIMARY KEY,
  email       VARCHAR(128) NOT NULL UNIQUE,
  nickname    VARCHAR(64),
  created_at  DATETIME NOT NULL
);

-- 租户成员表:用户进入租户的入口
CREATE TABLE membership (
  id          BIGINT PRIMARY KEY,
  tenant_id   BIGINT NOT NULL,
  user_id     BIGINT NOT NULL,
  org_id      BIGINT,
  status      TINYINT NOT NULL DEFAULT 1,
  UNIQUE KEY uk_tenant_user (tenant_id, user_id)
);

-- 组织表:租户内部门树
CREATE TABLE organization (
  id          BIGINT PRIMARY KEY,
  tenant_id   BIGINT NOT NULL,
  parent_id   BIGINT DEFAULT 0,
  name        VARCHAR(128) NOT NULL,
  path        VARCHAR(512) NOT NULL
);

-- 角色表
CREATE TABLE role (
  id          BIGINT PRIMARY KEY,
  tenant_id   BIGINT NOT NULL,
  code        VARCHAR(64) NOT NULL,
  name        VARCHAR(64) NOT NULL,
  UNIQUE KEY uk_tenant_code (tenant_id, code)
);

-- 权限表(全局定义)
CREATE TABLE permission (
  id          BIGINT PRIMARY KEY,
  code        VARCHAR(128) NOT NULL UNIQUE,
  resource    VARCHAR(64) NOT NULL,
  action      VARCHAR(32) NOT NULL
);

-- 角色权限关联
CREATE TABLE role_permission (
  role_id       BIGINT NOT NULL,
  permission_id BIGINT NOT NULL,
  PRIMARY KEY (role_id, permission_id)
);

-- 成员角色关联,scope_org_id 支持数据范围授权
CREATE TABLE member_role (
  member_id     BIGINT NOT NULL,
  role_id       BIGINT NOT NULL,
  scope_org_id  BIGINT DEFAULT 0,
  PRIMARY KEY (member_id, role_id)
);

这里有两个设计决策值得注意。第一,user 表不携带 tenant_id,因为一个自然人可能同时是多个租户的成员,把租户信息放在成员表里才能正确表达多对多。第二,member_role 中的 scope_org_id 用于表达数据范围:当它为0时表示全租户范围,为某个组织ID时表示仅该组织及其子树可见。这样同一角色在不同组织节点上可以产生不同的数据可见范围。

组织表的 path 字段建议存成 /1/5/12/ 这样的物化路径,查询某节点及其所有子孙时用 WHERE path LIKE '/1/5/%' 即可,避免递归查询。

鉴权链与隔离校验

一次请求的鉴权应沿着“用户 → 成员 → 角色 → 权限 → 数据范围”这条链逐级校验。典型流程是:先由登录态解析出 user_id,再根据请求头或路由中的 tenant_id 查出对应的 membership,确认成员状态有效;然后加载该成员的角色集合,合并出权限码集合;最后判断目标资源是否在 scope_org_id 覆盖的数据范围内。

隔离校验的关键在于:所有业务查询都必须带上 tenant_id 条件,且这个条件不能由前端传入,而应由服务端从已认证的成员上下文中强制注入。常见做法是在数据访问层统一拦截,把 tenant_id 作为隐式过滤条件拼进SQL,避免开发者遗漏。

共享表方案下,任何绕过统一数据访问层、手写SQL的代码都是越权风险点。建议在代码评审中把“是否携带 tenant_id 过滤”列为强制检查项,并配合自动化测试用两个租户的数据交叉验证。

选型决策参考

维度 独立数据库 独立Schema 共享表 tenant_id
隔离强度 最高 较高 依赖应用层
单租户成本 高 中 低
扩展租户数 受限 数百至数千 数万以上
跨租户统计 困难 较困难 容易
适用场景 强合规大客户 中型SaaS 标准化SaaS

模型设计完成后,建议用一组最小验证用例确认隔离有效:创建租户A和租户B,各自建一个用户并授予相同角色;用A的用户登录后尝试读取B的订单,预期返回空或403;再尝试用A的成员身份访问B的组织节点,预期被拒绝。只有这两类交叉访问都被正确拦截,隔离策略才算真正落地。