多租户系统从哪里开始设计?租户身份、数据隔离、权限与套餐的整体方案

多租户系统最容易出现的误判是:给业务表加一个 tenant_id,再给页面加一个"切换公司"入口,就以为完成了多租户。实际运行时,同一账号可能加入多家公司;平台管理员和租户管理员管理的对象不同;某个功能即使出现在菜单上,也未必在当前套餐内;导出和后台任务又不在最初的请求线程里。任何一处只信前端传来的租户编号,都可能把两家公司的数据或管理权限混在一起。

本文先建立整体设计地图,回答系统要保存哪些对象、一次请求如何经过身份和授权边界、租户状态与套餐如何影响结果,并用一组固定数据验证。后续文章再分别深入开通、数据隔离、两级管理员、多级菜单、套餐和配额。这里不重复"单库还是多库"的部署选型;无论选哪种存储方式,业务请求仍要确定自己属于哪个租户。

示例环境:Java 17、Spring Boot 风格服务层、MySQL 8.x。代码和表名是教学用的最小模型,不对应任何产品的完整内部设计。案例中的公司、人员和单据均为虚构。

目录

  1. 先看一个会出错的业务场景
  2. 多租户系统的五类对象
  3. 一次请求必须经过哪些判断
  4. 数据隔离不能只靠列表条件
  5. 平台权限、租户权限和套餐能力如何区分
  6. 生命周期与订阅变化如何影响已有数据
  7. 最小实现、自动测试与SQL核对
  8. 上线验收清单与适用边界

一、先看一个会出错的业务场景

假设一个 SaaS 系统服务两家公司。账号 u_07 同时加入甲公司和乙公司,在甲公司是租户管理员,在乙公司只是普通查看者。甲公司的套餐允许使用"批量报表",乙公司的套餐暂未开通。两家公司各有一张编号相似的申请单。

对象 甲公司 t_a 乙公司 t_b
u_07 的成员身份 管理员,可查看和审批本公司单据 查看者,只能查看授权范围内单据
申请单 a_1001,归属 t_a b_1001,归属 t_b
批量报表能力 已开通 未开通
租户状态 ACTIVE ACTIVE

现在 u_07 在乙公司页面把详情 URL 中的 b_1001 改成 a_1001;或者把请求头里的租户编号改成 t_a,试图在乙公司会话中调用甲公司的审批接口。这两种请求都不应因为"账号确实加入了甲公司"就直接成功:当前会话选定的租户、该租户中的成员权限、目标记录的归属,以及功能权益,需要按顺序核对。

**本文预期结果:**乙公司会话查询甲公司单据返回不可见;乙公司会话发起批量报表被能力校验拒绝;切换到甲公司会话后可以查看甲公司授权范围内的数据,但管理员身份也不能越过单据归属或数据范围。平台管理员的运营权限另走受控入口,不能伪装成任意租户成员。

图1:账号可以相同,但当前租户、成员权限、业务数据和套餐能力必须分别判定。

二、多租户系统的五类对象

设计时先把"账号"和"租户"分开。账号回答"谁登录了",租户回答"当前在为哪个组织操作"。两者之间的成员关系回答"这个人是否属于该组织、在该组织担任什么角色"。同一个账号可以有多条成员关系,而一个租户可以有多名成员。

再把管理权限、业务数据和套餐权益分开:

对象 最小问题 常见错误
租户及生命周期 组织是否已开通、可用、冻结或停用 只检查登录成功,不检查租户是否可用
账号与租户成员 登录者在当前租户中是什么身份 把账号的全局角色当作各租户的角色
权限与数据范围 当前成员可执行什么动作、访问哪些记录 只控制菜单,不保护接口与详情
套餐订阅与权益 当前租户拥有哪些功能和资源额度 把菜单可见等同于功能已开通
业务资源 单据、文件、任务归属哪个租户 只给主表加租户字段,附件和导出漏掉

这五类对象并不意味着要堆成五个独立服务。小系统可以先在一个服务内清晰划分模块;复杂系统可按职责拆分。关键是对象语义不能混用:租户管理员是权限身份,不是套餐;套餐开通的是产品能力,不自动授予用户审批权限;租户状态决定是否允许新操作,不等于把历史数据删除。

图2:成员关系连接账号与租户,权限和订阅分别约束"谁能做"和"产品允许做什么"。

三、一次请求必须经过哪些判断

用 u_07 在甲公司审批 a_1001 为例,一次请求可拆成六个判断,顺序也有意义:

  1. **认证账号。**确认请求来自已登录账号 u_07,不把前端填写的用户 ID 当作身份。
  2. **确定当前租户。**从受信会话或经过校验的租户选择结果得到 t_a;如果允许切换租户,切换时重新验证成员关系并更新会话,不能只改一个请求头。
  3. **检查租户与成员状态。**租户必须可用,成员必须有效;停用成员即使持有旧会话,也不能继续新操作。
  4. **检查动作权限。**在当前租户内确认 u_07 有审批动作;平台级权限不能自动折算为租户级审批权。
  5. **检查业务资源归属与数据范围。**按 tenant_id=t_a AND id=a_1001 找记录,再核对该成员可访问这张单据;查不到时不返回另一租户的存在信息。
  6. **检查产品能力与执行条件。**若审批属于需订阅功能,检查当前生效权益;若有配额,还要在真正提交时作并发安全的额度校验。

认证、授权、权益和数据归属不是同一把锁。即使 u_07 在甲、乙两家公司都是合法成员,也不能省掉当前租户的确定;即使有菜单权限,也不能省掉业务资源的归属检查。列表、详情、修改、下载、导出及异步任务都要遵循同一套租户边界。

图3:任何一步失败都应停止执行;页面入口只是提示,不是服务器端授权证据。

四、数据隔离不能只靠列表条件

最常见的补丁是在列表查询增加 WHERE tenant_id = ?。这必要,却远远不够。详情通常通过单据 ID 直查;修改可能先按 ID 更新再校验;附件可能用对象地址下载;导出和后台任务可能在用户离开页面后继续执行。只要其中一条路径缺少租户归属约束,列表再正确也挡不住串数据。

一个实用原则是:**资源的创建、读取、修改和交付都携带可信的租户边界。**写入时由服务端赋予 tenant_id;读取和更新时将 tenant_id 与资源 ID 一起作为定位条件;附件保存所属租户和所属业务对象;缓存键、搜索索引、消息和后台任务也显式携带租户标识。不能用客户端给出的 tenant_id 覆盖服务端已确认的身份。

若采用共享数据库,业务表中的 tenant_id、组合索引和更新条件是基本防线。若采用独立数据库,也需要在连接路由前正确确认租户身份,且跨租户平台操作要有单独的审批与审计。两种部署方式解决的是不同层面的隔离,不能替代接口授权。

sql 复制代码
-- 教学用最小结构;实际字段和索引应依据查询模式调整。
CREATE TABLE sample_tenant (
    tenant_id VARCHAR(32) PRIMARY KEY,
    status VARCHAR(20) NOT NULL
);

CREATE TABLE sample_request (
    id BIGINT PRIMARY KEY,
    tenant_id VARCHAR(32) NOT NULL,
    request_no VARCHAR(40) NOT NULL,
    status VARCHAR(20) NOT NULL,
    UNIQUE KEY uk_tenant_request_no (tenant_id, request_no),
    UNIQUE KEY uk_tenant_request_id (tenant_id, id),
    CONSTRAINT fk_request_tenant FOREIGN KEY (tenant_id)
        REFERENCES sample_tenant (tenant_id)
);

CREATE TABLE sample_file (
    id BIGINT PRIMARY KEY,
    tenant_id VARCHAR(32) NOT NULL,
    request_id BIGINT NOT NULL,
    UNIQUE KEY uk_file_tenant_id (tenant_id, id),
    CONSTRAINT fk_file_request FOREIGN KEY (tenant_id, request_id)
        REFERENCES sample_request (tenant_id, id)
);

-- 详情:必须同时使用可信租户 ID 与业务记录 ID。
SELECT id, request_no, status
FROM sample_request
WHERE tenant_id = :currentTenantId AND id = :requestId;

-- 修改:以影响行数判断是否仍存在且状态允许推进。
UPDATE sample_request
SET status = 'APPROVED'
WHERE tenant_id = :currentTenantId
  AND id = :requestId
  AND status = 'PENDING';

上述 SQL 只能保证不会按另一个租户的记录 ID 直接改到那条记录;审批人范围、状态机和套餐能力仍应由服务层或更严格的数据访问层校验。尤其不要把"影响 0 行"直接解释为"记录不存在":也可能是记录属于别的租户、状态已变化,或版本已冲突。对外错误应避免泄露跨租户信息,对内日志保留可审计的原因码。

五、平台权限、租户权限和套餐能力如何区分

企业 SaaS 至少存在两个管理面。平台管理员维护租户开通、套餐模板、运行状态等平台对象;租户管理员在自己的租户内管理成员、角色和业务配置。平台管理员不应因为"级别更高"就无条件拥有每家租户的业务单据读写权。需要排查租户问题时,应通过受限、留痕的支持授权进入,而不是默默绕过租户边界。

多级菜单也容易混淆三个判断:

判断 回答什么 甲公司批量报表示例
套餐能力 甲公司是否购买/开通该能力 有效订阅包含 BATCH_REPORT
租户内权限 u_07 在甲公司能否使用该能力 角色包含"报表生成"动作
数据范围 生成的报表能包含哪些记录 只能包含甲公司且在授权范围内的数据

三项都成立,操作才可以执行。菜单展示可依据前两项做友好提示,但不能代替接口端的判断;报表下载还要再次校验文件归属和访问权。套餐到期时可以停止创建新报表,但如何处理已生成文件、进行中的任务,需要提前写成明确规则,不能由某个页面临时决定。

图4:两级管理员管理不同对象;能看见入口、能执行动作和能读取数据是三件事。

六、生命周期与订阅变化如何影响已有数据

租户不是只有"存在/不存在"两种状态。以开通流程为例,注册成功只代表取得租户编号;管理员建立、基础配置就绪、订阅生效、必要资源可用之后,才适合进入 ACTIVE。任何一步失败都应保留可恢复的状态与失败原因,避免页面宣告成功而后台尚不可用。

运行中还会遇到升级、降级、到期、冻结和停用。一个可解释的设计应分别回答:新操作能否发起?历史数据能否读取?正在执行的任务如何处理?已有文件保留多久?管理员能否导出必要记录?这些问题不宜全部由一个 enabled 布尔值决定。

变化 新操作 历史数据 需要额外核对
套餐升级 按生效时间开放新增能力 不应改写旧记录归属 新权益何时生效、缓存何时刷新
套餐降级或到期 禁止超出当前权益的新操作 原则上保留,读取政策需明确 运行任务、已生成产物及超额资源
租户冻结 暂停受限业务操作 按合同与安全策略保留 后台任务、消息发送、文件访问
租户恢复 通过核验后恢复允许的操作 检查冻结期间状态一致性 订阅是否仍有效、队列是否重放

表中是设计讨论的起点,不是适用于所有 SaaS 的合同条款。尤其涉及数据保留与删除,应由产品政策、客户约定和适用法规共同确定,工程实现只负责让规则可执行、可追踪。

七、最小实现、自动测试与SQL核对

下面的示例只展示业务服务层的检查顺序 。TenantSession 必须由认证层根据登录者、成员关系和租户切换结果构造;不能从请求体直接实例化。Rights 是预先解析的权限与套餐能力快照,实际系统还需定义版本或失效策略,避免权益变化后旧会话长期有效。

java 复制代码
record TenantSession(String userId, String tenantId,
                     boolean activeTenant, boolean activeMember) {}
record Rights(boolean canApprove, boolean approvalFeatureEnabled) {}
record RequestRow(long id, String tenantId, String status) {}

final class ApprovalService {
    private final RequestRepository requests;

    ApprovalService(RequestRepository requests) { this.requests = requests; }

    void approve(TenantSession session, Rights rights, long requestId) {
        if (!session.activeTenant()) throw new AccessDeniedException("TENANT_INACTIVE");
        if (!session.activeMember()) throw new AccessDeniedException("MEMBER_INACTIVE");
        if (!rights.approvalFeatureEnabled()) throw new AccessDeniedException("FEATURE_DISABLED");
        if (!rights.canApprove()) throw new AccessDeniedException("ACTION_FORBIDDEN");

        RequestRow row = requests.findByTenantAndId(session.tenantId(), requestId)
                .orElseThrow(() -> new AccessDeniedException("RESOURCE_UNAVAILABLE"));
        if (!"PENDING".equals(row.status())) throw new IllegalStateException("STATE_CHANGED");
        if (requests.approveIfPending(session.tenantId(), row.id()) != 1)
            throw new IllegalStateException("STATE_CHANGED");
    }
}

interface RequestRepository {
    Optional<RequestRow> findByTenantAndId(String tenantId, long id);
    int approveIfPending(String tenantId, long id);
}

示例刻意不把平台管理员作为特权分支塞进 approve:跨租户支持访问应走单独的受控流程。代码中 findByTenantAndId 体现读边界,approveIfPending 体现写边界和并发状态约束。activeTenant 应由可信租户状态服务生成,生产环境还要按业务要求对数据范围作进一步判断。AccessDeniedException 可用 Spring Security 对应异常;若脱离该框架,可替换为项目自己的拒绝异常。

最少应有以下自动测试,测试数据固定为 a_1001 属于 t_a、b_1001 属于 t_b:

java 复制代码
@Test
void bSessionCannotApproveARequestEvenWhenSameUserBelongsToBothTenants() {
    var bSession = new TenantSession("u_07", "t_b", true, true);
    var bRights = new Rights(true, true); // 即使误授予动作权限,资源归属仍要挡住越界
    when(repository.findByTenantAndId("t_b", 1001L)).thenReturn(Optional.empty());
    assertThatThrownBy(() -> service.approve(bSession, bRights, 1001L))
            .isInstanceOf(AccessDeniedException.class);
    verify(repository).findByTenantAndId("t_b", 1001L);
    verify(repository, never()).approveIfPending(anyString(), anyLong());
}

@Test
void aSessionApprovesOnlyItsPendingRequest() {
    var aSession = new TenantSession("u_07", "t_a", true, true);
    var aRights = new Rights(true, true);
    when(repository.findByTenantAndId("t_a", 1001L))
            .thenReturn(Optional.of(new RequestRow(1001L, "t_a", "PENDING")));
    when(repository.approveIfPending("t_a", 1001L)).thenReturn(1);
    service.approve(aSession, aRights, 1001L);
    verify(repository).approveIfPending("t_a", 1001L);
}

@Test
void menuPermissionCannotOverrideExpiredFeature() {
    var aSession = new TenantSession("u_07", "t_a", true, true);
    assertThatThrownBy(() -> service.approve(aSession, new Rights(true, false), 1001L))
            .isInstanceOf(AccessDeniedException.class);
    verifyNoInteractions(repository);
}

@Test
void frozenTenantRejectsOldSessionBeforeReadingBusinessData() {
    var frozen = new TenantSession("u_07", "t_a", false, true);
    assertThatThrownBy(() -> service.approve(frozen, new Rights(true, true), 1001L))
            .isInstanceOf(AccessDeniedException.class);
    verifyNoInteractions(repository);
}

这四个测试分别证明跨租户拒绝、合法路径可推进、功能未开通及租户被冻结时不访问业务数据。它们没有覆盖租户切换、附件下载或异步任务,那些入口必须另加接口或集成测试,不能因为服务层单测通过就宣布隔离完成。

上线前还可对共享库做两类巡检。第一类找缺少租户归属或与主单据归属不一致的附件;第二类检查业务记录是否关联有效租户。预期结果均为 0 行,否则应先修复数据与写入路径,再开放新功能。

sql 复制代码
SELECT f.id, f.tenant_id AS file_tenant, r.tenant_id AS request_tenant
FROM sample_file f
JOIN sample_request r ON r.id = f.request_id
WHERE f.tenant_id <> r.tenant_id;

SELECT r.id, r.tenant_id
FROM sample_request r
LEFT JOIN sample_tenant t ON t.tenant_id = r.tenant_id
WHERE t.tenant_id IS NULL;

这里的三张表只展示租户、业务单据和附件的关键约束,不含真实产品的全部字段。复合外键可拦截"附件写在甲租户、却关联乙租户单据"的数据错误;真实系统还应检查导出结果、缓存键和任务记录,但不要用一条大 SQL 假装覆盖所有存储介质。

图5:验收应同时包含允许路径与拒绝路径,并核对数据、权限和权益三个结果。

八、上线验收清单与适用边界

真正上线时,至少用两家虚构租户、一个跨组织账号、不同成员角色、不同套餐和同名业务编号走完下面的矩阵:

场景 预期结果 验证证据
在甲公司会话查看甲公司授权单据 成功 响应归属、查询日志与当前租户一致
在乙公司会话猜测甲公司单据 ID 拒绝且不泄露存在性 接口测试与拒绝原因码
乙公司未开通批量报表却直调接口 拒绝,不创建任务 权益校验记录、任务表无新增
租户管理员授予平台级权限 拒绝 授权范围测试、审计记录
冻结乙公司后旧会话继续请求 按冻结政策拒绝新操作 会话与租户状态联动测试
导出/后台任务在用户离开页面后执行 仍使用创建时已确认的租户身份 任务记录、文件归属及下载授权

如果只有单租户部署,或客户明确要求独立环境,可以简化部分跨租户运行机制,但仍要清楚区分账号身份、角色权限、资源归属与产品权益。反过来,若已经存在跨组织协作、平台代运营或复杂合同规则,上述最小模型只是起点,必须补上受控授权、审计、保留政策及更严格的隔离措施。

多租户的核心不是一个字段或一种数据库形态,而是让每次操作都能回答四个问题:**谁在操作、代表哪个租户、能碰哪些资源、当前权益是否允许。**把这四个答案贯穿页面、接口、数据、文件和后台任务,后续的开通、两级管理员与套餐控制才有稳定落点。

参考资料:

  1. Microsoft Learn:多租户方案的租赁模型与隔离权衡
  2. Microsoft Learn:多租户方案中的身份与授权
  3. OWASP:多租户安全检查清单
  4. Spring Security:方法级授权
相关推荐
10年前端老司机1 小时前
一次业务迭代:聊聊知识库多版本可追溯审核流程怎么做
人工智能·后端·agent
tiger从容淡定是人生1 小时前
从 Gemini 的任务示例出发:AI 能否从知识中推导工作流?
java·人工智能·aigc·知识图谱·创业创新·零知识证明
Nebula_g1 小时前
JavaSE高级:动态代理
java·开发语言·算法·javase
邱杰4701 小时前
多 Agent 客服的短期记忆改造:用 Redis + Checkpointer 管理会话状态
后端
高级程序源1 小时前
springboot校园外卖平台13676-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·tomcat·maven·课程设计
韩振方1 小时前
删掉一个 Pod 后,谁把它补回来?
后端
Gopher_HBo1 小时前
grpc客户端调用链
后端
时间海8711 小时前
对象键清洗之前,先拦住重名
javascript·后端
怦然丶心动1 小时前
用 Java 把干支历法算清楚:从节气切年到可读摘要
java·算法·vue·独立开发·spring boot / spring