多租户系统最容易出现的误判是:给业务表加一个 tenant_id,再给页面加一个"切换公司"入口,就以为完成了多租户。实际运行时,同一账号可能加入多家公司;平台管理员和租户管理员管理的对象不同;某个功能即使出现在菜单上,也未必在当前套餐内;导出和后台任务又不在最初的请求线程里。任何一处只信前端传来的租户编号,都可能把两家公司的数据或管理权限混在一起。
本文先建立整体设计地图,回答系统要保存哪些对象、一次请求如何经过身份和授权边界、租户状态与套餐如何影响结果,并用一组固定数据验证。后续文章再分别深入开通、数据隔离、两级管理员、多级菜单、套餐和配额。这里不重复"单库还是多库"的部署选型;无论选哪种存储方式,业务请求仍要确定自己属于哪个租户。
示例环境:Java 17、Spring Boot 风格服务层、MySQL 8.x。代码和表名是教学用的最小模型,不对应任何产品的完整内部设计。案例中的公司、人员和单据均为虚构。
目录
- 先看一个会出错的业务场景
- 多租户系统的五类对象
- 一次请求必须经过哪些判断
- 数据隔离不能只靠列表条件
- 平台权限、租户权限和套餐能力如何区分
- 生命周期与订阅变化如何影响已有数据
- 最小实现、自动测试与SQL核对
- 上线验收清单与适用边界
一、先看一个会出错的业务场景
假设一个 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 为例,一次请求可拆成六个判断,顺序也有意义:
- **认证账号。**确认请求来自已登录账号
u_07,不把前端填写的用户 ID 当作身份。 - **确定当前租户。**从受信会话或经过校验的租户选择结果得到
t_a;如果允许切换租户,切换时重新验证成员关系并更新会话,不能只改一个请求头。 - **检查租户与成员状态。**租户必须可用,成员必须有效;停用成员即使持有旧会话,也不能继续新操作。
- **检查动作权限。**在当前租户内确认
u_07有审批动作;平台级权限不能自动折算为租户级审批权。 - **检查业务资源归属与数据范围。**按
tenant_id=t_a AND id=a_1001找记录,再核对该成员可访问这张单据;查不到时不返回另一租户的存在信息。 - **检查产品能力与执行条件。**若审批属于需订阅功能,检查当前生效权益;若有配额,还要在真正提交时作并发安全的额度校验。
认证、授权、权益和数据归属不是同一把锁。即使 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 | 拒绝且不泄露存在性 | 接口测试与拒绝原因码 |
| 乙公司未开通批量报表却直调接口 | 拒绝,不创建任务 | 权益校验记录、任务表无新增 |
| 租户管理员授予平台级权限 | 拒绝 | 授权范围测试、审计记录 |
| 冻结乙公司后旧会话继续请求 | 按冻结政策拒绝新操作 | 会话与租户状态联动测试 |
| 导出/后台任务在用户离开页面后执行 | 仍使用创建时已确认的租户身份 | 任务记录、文件归属及下载授权 |
如果只有单租户部署,或客户明确要求独立环境,可以简化部分跨租户运行机制,但仍要清楚区分账号身份、角色权限、资源归属与产品权益。反过来,若已经存在跨组织协作、平台代运营或复杂合同规则,上述最小模型只是起点,必须补上受控授权、审计、保留政策及更严格的隔离措施。
多租户的核心不是一个字段或一种数据库形态,而是让每次操作都能回答四个问题:**谁在操作、代表哪个租户、能碰哪些资源、当前权益是否允许。**把这四个答案贯穿页面、接口、数据、文件和后台任务,后续的开通、两级管理员与套餐控制才有稳定落点。
参考资料: