多租户系统最容易出现的误判是给业务表加一个tenant_id再给页面加一个“切换公司”入口就以为完成了多租户。实际运行时同一账号可能加入多家公司平台管理员和租户管理员管理的对象不同某个功能即使出现在菜单上也未必在当前套餐内导出和后台任务又不在最初的请求线程里。任何一处只信前端传来的租户编号都可能把两家公司的数据或管理权限混在一起。本文先建立整体设计地图回答系统要保存哪些对象、一次请求如何经过身份和授权边界、租户状态与套餐如何影响结果并用一组固定数据验证。后续文章再分别深入开通、数据隔离、两级管理员、多级菜单、套餐和配额。这里不重复“单库还是多库”的部署选型无论选哪种存储方式业务请求仍要确定自己属于哪个租户。示例环境Java 17、Spring Boot 风格服务层、MySQL 8.x。代码和表名是教学用的最小模型不对应任何产品的完整内部设计。案例中的公司、人员和单据均为虚构。目录先看一个会出错的业务场景多租户系统的五类对象一次请求必须经过哪些判断数据隔离不能只靠列表条件平台权限、租户权限和套餐能力如何区分生命周期与订阅变化如何影响已有数据最小实现、自动测试与SQL核对上线验收清单与适用边界一、先看一个会出错的业务场景假设一个 SaaS 系统服务两家公司。账号u_07同时加入甲公司和乙公司在甲公司是租户管理员在乙公司只是普通查看者。甲公司的套餐允许使用“批量报表”乙公司的套餐暂未开通。两家公司各有一张编号相似的申请单。对象甲公司t_a乙公司t_bu_07的成员身份管理员可查看和审批本公司单据查看者只能查看授权范围内单据申请单a_1001归属t_ab_1001归属t_b批量报表能力已开通未开通租户状态ACTIVEACTIVE现在u_07在乙公司页面把详情 URL 中的b_1001改成a_1001或者把请求头里的租户编号改成t_a试图在乙公司会话中调用甲公司的审批接口。这两种请求都不应因为“账号确实加入了甲公司”就直接成功当前会话选定的租户、该租户中的成员权限、目标记录的归属以及功能权益需要按顺序核对。**本文预期结果**乙公司会话查询甲公司单据返回不可见乙公司会话发起批量报表被能力校验拒绝切换到甲公司会话后可以查看甲公司授权范围内的数据但管理员身份也不能越过单据归属或数据范围。平台管理员的运营权限另走受控入口不能伪装成任意租户成员。图1账号可以相同但当前租户、成员权限、业务数据和套餐能力必须分别判定。二、多租户系统的五类对象设计时先把“账号”和“租户”分开。账号回答“谁登录了”租户回答“当前在为哪个组织操作”。两者之间的成员关系回答“这个人是否属于该组织、在该组织担任什么角色”。同一个账号可以有多条成员关系而一个租户可以有多名成员。再把管理权限、业务数据和套餐权益分开对象最小问题常见错误租户及生命周期组织是否已开通、可用、冻结或停用只检查登录成功不检查租户是否可用账号与租户成员登录者在当前租户中是什么身份把账号的全局角色当作各租户的角色权限与数据范围当前成员可执行什么动作、访问哪些记录只控制菜单不保护接口与详情套餐订阅与权益当前租户拥有哪些功能和资源额度把菜单可见等同于功能已开通业务资源单据、文件、任务归属哪个租户只给主表加租户字段附件和导出漏掉这五类对象并不意味着要堆成五个独立服务。小系统可以先在一个服务内清晰划分模块复杂系统可按职责拆分。关键是对象语义不能混用租户管理员是权限身份不是套餐套餐开通的是产品能力不自动授予用户审批权限租户状态决定是否允许新操作不等于把历史数据删除。图2成员关系连接账号与租户权限和订阅分别约束“谁能做”和“产品允许做什么”。三、一次请求必须经过哪些判断用u_07在甲公司审批a_1001为例一次请求可拆成六个判断顺序也有意义**认证账号。**确认请求来自已登录账号u_07不把前端填写的用户 ID 当作身份。**确定当前租户。**从受信会话或经过校验的租户选择结果得到t_a如果允许切换租户切换时重新验证成员关系并更新会话不能只改一个请求头。**检查租户与成员状态。**租户必须可用成员必须有效停用成员即使持有旧会话也不能继续新操作。**检查动作权限。**在当前租户内确认u_07有审批动作平台级权限不能自动折算为租户级审批权。**检查业务资源归属与数据范围。**按tenant_idt_a AND ida_1001找记录再核对该成员可访问这张单据查不到时不返回另一租户的存在信息。**检查产品能力与执行条件。**若审批属于需订阅功能检查当前生效权益若有配额还要在真正提交时作并发安全的额度校验。认证、授权、权益和数据归属不是同一把锁。即使u_07在甲、乙两家公司都是合法成员也不能省掉当前租户的确定即使有菜单权限也不能省掉业务资源的归属检查。列表、详情、修改、下载、导出及异步任务都要遵循同一套租户边界。图3任何一步失败都应停止执行页面入口只是提示不是服务器端授权证据。四、数据隔离不能只靠列表条件最常见的补丁是在列表查询增加WHERE tenant_id ?。这必要却远远不够。详情通常通过单据 ID 直查修改可能先按 ID 更新再校验附件可能用对象地址下载导出和后台任务可能在用户离开页面后继续执行。只要其中一条路径缺少租户归属约束列表再正确也挡不住串数据。一个实用原则是**资源的创建、读取、修改和交付都携带可信的租户边界。**写入时由服务端赋予tenant_id读取和更新时将tenant_id与资源 ID 一起作为定位条件附件保存所属租户和所属业务对象缓存键、搜索索引、消息和后台任务也显式携带租户标识。不能用客户端给出的tenant_id覆盖服务端已确认的身份。若采用共享数据库业务表中的tenant_id、组合索引和更新条件是基本防线。若采用独立数据库也需要在连接路由前正确确认租户身份且跨租户平台操作要有单独的审批与审计。两种部署方式解决的是不同层面的隔离不能替代接口授权。-- 教学用最小结构实际字段和索引应依据查询模式调整。CREATETABLEsample_tenant(tenant_idVARCHAR(32)PRIMARYKEY,statusVARCHAR(20)NOTNULL);CREATETABLEsample_request(idBIGINTPRIMARYKEY,tenant_idVARCHAR(32)NOTNULL,request_noVARCHAR(40)NOTNULL,statusVARCHAR(20)NOTNULL,UNIQUEKEYuk_tenant_request_no(tenant_id,request_no),UNIQUEKEYuk_tenant_request_id(tenant_id,id),CONSTRAINTfk_request_tenantFOREIGNKEY(tenant_id)REFERENCESsample_tenant(tenant_id));CREATETABLEsample_file(idBIGINTPRIMARYKEY,tenant_idVARCHAR(32)NOTNULL,request_idBIGINTNOTNULL,UNIQUEKEYuk_file_tenant_id(tenant_id,id),CONSTRAINTfk_file_requestFOREIGNKEY(tenant_id,request_id)REFERENCESsample_request(tenant_id,id));-- 详情必须同时使用可信租户 ID 与业务记录 ID。SELECTid,request_no,statusFROMsample_requestWHEREtenant_id:currentTenantIdANDid:requestId;-- 修改以影响行数判断是否仍存在且状态允许推进。UPDATEsample_requestSETstatusAPPROVEDWHEREtenant_id:currentTenantIdANDid:requestIdANDstatusPENDING;上述 SQL 只能保证不会按另一个租户的记录 ID 直接改到那条记录审批人范围、状态机和套餐能力仍应由服务层或更严格的数据访问层校验。尤其不要把“影响 0 行”直接解释为“记录不存在”也可能是记录属于别的租户、状态已变化或版本已冲突。对外错误应避免泄露跨租户信息对内日志保留可审计的原因码。五、平台权限、租户权限和套餐能力如何区分企业 SaaS 至少存在两个管理面。平台管理员维护租户开通、套餐模板、运行状态等平台对象租户管理员在自己的租户内管理成员、角色和业务配置。平台管理员不应因为“级别更高”就无条件拥有每家租户的业务单据读写权。需要排查租户问题时应通过受限、留痕的支持授权进入而不是默默绕过租户边界。多级菜单也容易混淆三个判断判断回答什么甲公司批量报表示例套餐能力甲公司是否购买/开通该能力有效订阅包含BATCH_REPORT租户内权限u_07在甲公司能否使用该能力角色包含“报表生成”动作数据范围生成的报表能包含哪些记录只能包含甲公司且在授权范围内的数据三项都成立操作才可以执行。菜单展示可依据前两项做友好提示但不能代替接口端的判断报表下载还要再次校验文件归属和访问权。套餐到期时可以停止创建新报表但如何处理已生成文件、进行中的任务需要提前写成明确规则不能由某个页面临时决定。图4两级管理员管理不同对象能看见入口、能执行动作和能读取数据是三件事。六、生命周期与订阅变化如何影响已有数据租户不是只有“存在/不存在”两种状态。以开通流程为例注册成功只代表取得租户编号管理员建立、基础配置就绪、订阅生效、必要资源可用之后才适合进入ACTIVE。任何一步失败都应保留可恢复的状态与失败原因避免页面宣告成功而后台尚不可用。运行中还会遇到升级、降级、到期、冻结和停用。一个可解释的设计应分别回答新操作能否发起历史数据能否读取正在执行的任务如何处理已有文件保留多久管理员能否导出必要记录这些问题不宜全部由一个enabled布尔值决定。变化新操作历史数据需要额外核对套餐升级按生效时间开放新增能力不应改写旧记录归属新权益何时生效、缓存何时刷新套餐降级或到期禁止超出当前权益的新操作原则上保留读取政策需明确运行任务、已生成产物及超额资源租户冻结暂停受限业务操作按合同与安全策略保留后台任务、消息发送、文件访问租户恢复通过核验后恢复允许的操作检查冻结期间状态一致性订阅是否仍有效、队列是否重放表中是设计讨论的起点不是适用于所有 SaaS 的合同条款。尤其涉及数据保留与删除应由产品政策、客户约定和适用法规共同确定工程实现只负责让规则可执行、可追踪。七、最小实现、自动测试与SQL核对下面的示例只展示业务服务层的检查顺序。TenantSession必须由认证层根据登录者、成员关系和租户切换结果构造不能从请求体直接实例化。Rights是预先解析的权限与套餐能力快照实际系统还需定义版本或失效策略避免权益变化后旧会话长期有效。recordTenantSession(StringuserId,StringtenantId,booleanactiveTenant,booleanactiveMember){}recordRights(booleancanApprove,booleanapprovalFeatureEnabled){}recordRequestRow(longid,StringtenantId,Stringstatus){}finalclassApprovalService{privatefinalRequestRepositoryrequests;ApprovalService(RequestRepositoryrequests){this.requestsrequests;}voidapprove(TenantSessionsession,Rightsrights,longrequestId){if(!session.activeTenant())thrownewAccessDeniedException(TENANT_INACTIVE);if(!session.activeMember())thrownewAccessDeniedException(MEMBER_INACTIVE);if(!rights.approvalFeatureEnabled())thrownewAccessDeniedException(FEATURE_DISABLED);if(!rights.canApprove())thrownewAccessDeniedException(ACTION_FORBIDDEN);RequestRowrowrequests.findByTenantAndId(session.tenantId(),requestId).orElseThrow(()-newAccessDeniedException(RESOURCE_UNAVAILABLE));if(!PENDING.equals(row.status()))thrownewIllegalStateException(STATE_CHANGED);if(requests.approveIfPending(session.tenantId(),row.id())!1)thrownewIllegalStateException(STATE_CHANGED);}}interfaceRequestRepository{OptionalRequestRowfindByTenantAndId(StringtenantId,longid);intapproveIfPending(StringtenantId,longid);}示例刻意不把平台管理员作为特权分支塞进approve跨租户支持访问应走单独的受控流程。代码中findByTenantAndId体现读边界approveIfPending体现写边界和并发状态约束。activeTenant应由可信租户状态服务生成生产环境还要按业务要求对数据范围作进一步判断。AccessDeniedException可用 Spring Security 对应异常若脱离该框架可替换为项目自己的拒绝异常。最少应有以下自动测试测试数据固定为a_1001属于t_a、b_1001属于t_bTestvoidbSessionCannotApproveARequestEvenWhenSameUserBelongsToBothTenants(){varbSessionnewTenantSession(u_07,t_b,true,true);varbRightsnewRights(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());}TestvoidaSessionApprovesOnlyItsPendingRequest(){varaSessionnewTenantSession(u_07,t_a,true,true);varaRightsnewRights(true,true);when(repository.findByTenantAndId(t_a,1001L)).thenReturn(Optional.of(newRequestRow(1001L,t_a,PENDING)));when(repository.approveIfPending(t_a,1001L)).thenReturn(1);service.approve(aSession,aRights,1001L);verify(repository).approveIfPending(t_a,1001L);}TestvoidmenuPermissionCannotOverrideExpiredFeature(){varaSessionnewTenantSession(u_07,t_a,true,true);assertThatThrownBy(()-service.approve(aSession,newRights(true,false),1001L)).isInstanceOf(AccessDeniedException.class);verifyNoInteractions(repository);}TestvoidfrozenTenantRejectsOldSessionBeforeReadingBusinessData(){varfrozennewTenantSession(u_07,t_a,false,true);assertThatThrownBy(()-service.approve(frozen,newRights(true,true),1001L)).isInstanceOf(AccessDeniedException.class);verifyNoInteractions(repository);}这四个测试分别证明跨租户拒绝、合法路径可推进、功能未开通及租户被冻结时不访问业务数据。它们没有覆盖租户切换、附件下载或异步任务那些入口必须另加接口或集成测试不能因为服务层单测通过就宣布隔离完成。上线前还可对共享库做两类巡检。第一类找缺少租户归属或与主单据归属不一致的附件第二类检查业务记录是否关联有效租户。预期结果均为0 行否则应先修复数据与写入路径再开放新功能。SELECTf.id,f.tenant_idASfile_tenant,r.tenant_idASrequest_tenantFROMsample_file fJOINsample_request rONr.idf.request_idWHEREf.tenant_idr.tenant_id;SELECTr.id,r.tenant_idFROMsample_request rLEFTJOINsample_tenant tONt.tenant_idr.tenant_idWHEREt.tenant_idISNULL;这里的三张表只展示租户、业务单据和附件的关键约束不含真实产品的全部字段。复合外键可拦截“附件写在甲租户、却关联乙租户单据”的数据错误真实系统还应检查导出结果、缓存键和任务记录但不要用一条大 SQL 假装覆盖所有存储介质。图5验收应同时包含允许路径与拒绝路径并核对数据、权限和权益三个结果。八、上线验收清单与适用边界真正上线时至少用两家虚构租户、一个跨组织账号、不同成员角色、不同套餐和同名业务编号走完下面的矩阵场景预期结果验证证据在甲公司会话查看甲公司授权单据成功响应归属、查询日志与当前租户一致在乙公司会话猜测甲公司单据 ID拒绝且不泄露存在性接口测试与拒绝原因码乙公司未开通批量报表却直调接口拒绝不创建任务权益校验记录、任务表无新增租户管理员授予平台级权限拒绝授权范围测试、审计记录冻结乙公司后旧会话继续请求按冻结政策拒绝新操作会话与租户状态联动测试导出/后台任务在用户离开页面后执行仍使用创建时已确认的租户身份任务记录、文件归属及下载授权如果只有单租户部署或客户明确要求独立环境可以简化部分跨租户运行机制但仍要清楚区分账号身份、角色权限、资源归属与产品权益。反过来若已经存在跨组织协作、平台代运营或复杂合同规则上述最小模型只是起点必须补上受控授权、审计、保留政策及更严格的隔离措施。多租户的核心不是一个字段或一种数据库形态而是让每次操作都能回答四个问题**谁在操作、代表哪个租户、能碰哪些资源、当前权益是否允许。**把这四个答案贯穿页面、接口、数据、文件和后台任务后续的开通、两级管理员与套餐控制才有稳定落点。参考资料Microsoft Learn多租户方案的租赁模型与隔离权衡Microsoft Learn多租户方案中的身份与授权OWASP多租户安全检查清单Spring Security方法级授权