multi-tenancy.md 5.15 KB

多租户与产品版本

xly 是多租户 SaaS。同一套代码库、同一套数据库 schema、同一套元数据服务多个客户。框架通过三条相互独立的轴来强制租户边界,它们作用在不同层级。

真实模块中的贯穿示例见切片 2。本页是可从任何地方链接过来的规范摘要。

三条轴

携带位置 粒度 事实来源
sBrandsId(加工商ID) 几乎每条业务行 每行 用户 session(UserInfo.getsBrandsId()
sSubsidiaryId(子公司ID) 几乎每条业务行 每行 用户 session
sVersionFlowId / sVersionFlowCode(版本流程ID / code) gdsmodule 每模块标签 版本目录元数据;运行时菜单可见性使用许可证产出的 sVerifyLicense 模块列表

逐行作用域在业务数据表中非常普遍:几乎每张业务数据表和视图都带有 sBrandsIdsSubsidiaryId。大多数框架元数据表也带有这两个列,但四张表(gdsformconstgdsmodulegdsconfigformmastergdsconfigformslave)是明确例外:BusinessBaseServiceImpl.sTableNameList(162-169 行)把它们列为“不需要公司子公司的表”,1078-1084 行会从这些表的写入载荷中剥掉 sBrandsId / sSubsidiaryId。实际使用中它们保存的是所有客户共享的一组哨兵租户值。惯例应理解为:如果一行代表租户拥有的状态,就有这两个列,且它们由 session 填充。

每模块版本元数据则相反:它只存在于 gdsmodule 上。实时运行时并不直接按 sVersionFlowId 过滤;模块发现由许可证产出的 sVerifyLicense 允许模块列表控制。因此版本控制发生在模块发现阶段,是一次性过滤,不是每行检查。

如何强制执行

每个已认证 REST 端点在请求到达后都会运行同一行:

RequestAddParamUtil.me().addParams(params, userInfo);

xlyPersist/.../RequestAddParamUtil.java。这个 56 行工具类从 UserInfo 中取出用户身份,并向请求 params map 写入 16 个 key(sBrandsIdsSubsidiaryIdsBrIdsSuIdsLoginIdsIpAddresssComputeNamesUserIduserIdsLanguagesUserTypesUserNamesMakePersonsTeamIdsMachineId,以及 CURRENT_USER_LOGIN_TYPE)。xlyApi 在 xlyApi/.../api/util/RequestAddParamUtil.java 中有几乎相同的 57 行副本。任何下游 MyBatis 查询只要引用 #{sBrandsId}#{sSubsidiaryId},就会自动受租户作用域约束。前端不能影响这些值;它们来自服务端 session,并通过 @CurrentUser 参数解析器进入。

失效模式

忘记按 sBrandsId 过滤的查询会返回所有租户的行,这是灾难性数据泄漏。需要关注三处:

  1. 运行时拼动态 SQL 的存储过程,开发者必须记得注入 sBrandsId。代码库中基本一致,但数据库不会强制。
  2. 数据库视图。 几乎所有 viw_* 视图都会从主基础表带出租户列,但手写视图如果漏掉这些列,就可能造成按行泄漏。维护审计脚本应标记此类视图。
  3. 保存端点addUpdateDelBusinessData),它允许前端在 payload 中直接提供 sTable。如果运行时不校验该表是否属于表单授权范围,这就是权限提升面。见切片 1

这个设计如何扩展,以及哪里扩展不了

框架的多租户设计靠行数扩展,而不是靠代码分支扩展。只有一个品牌、一个子公司、一种版本的小型 SaaS 部署,和拥有几十个品牌、几十个子公司、多个版本的部署,使用的是同一套 Java、MyBatis mapper 和存储过程;差异只体现在 gdsmodulesisversionflow 和业务数据表中的行分布。

按行数扩展运维上简单,但限制也很明确:

  • 共享物理 schema 意味着共享资源竞争。 所有租户的查询都打到同一个 MySQL 实例、同一批表和同一批索引上。租户 A 的重报表会和租户 B 的录单竞争 buffer pool 和 CPU。这里没有按租户隔离资源。
  • 每个 WHERE 都要带租户过滤。 每条读查询都要携带 sBrandsId = ? AND sSubsidiaryId = ?。索引通常也要以这些列开头才有用;xly 大多数表按约定这样做,但维护人员新增索引时必须记得这一点。漏掉后,查询计划会扫过所有租户的行,并在表变大后悄悄变慢。
  • 没有物理硬删除边界。 租户下线不会 drop 一个数据库;行会留在原处,有的标记 bInvalid,有的被删除,有的完全不动。永久移除需要按租户写清理脚本。从 GDPR / 数据驻留角度看,“这个租户已经彻底消失”很难证明。
  • sBrandsId / sSubsidiaryId everywhere 固化了租户单位。 “租户”精确定义为 (sBrandsId, sSubsidiaryId) 这个二元组。其他切法(例如按区域授权、按部门授权但不拆子公司)不适合这个模型,需要并行的作用域列。这个模型假定该形状永远适合所有客户;目前实践上基本成立,但这是一个硬承诺。