想象一个场景:你把一套企业管理系统卖给 50 家公司用。每家公司都觉得自己“独享”这套系统——自己的员工、自己的资产、自己的单据,谁也看不见谁。而对你来说,只有一套代码、最好也只有一套数据库,不然 50 家公司的版本升级能把运维逼疯。
这就是 SaaS 多租户的核心矛盾:客户要隔离感,你要低成本。这套机制的难点不在“加个字段”,而在于:租户 ID 从哪来、怎么自动跟着每一条 SQL 走、缓存和登录态怎么隔离,以及运维人员临时“替某个租户干活”时怎么安全切换。
这篇基于 RuoYi-Vue-Plus 的租户模块(ruoyi-common-tenant)把这套机制拆开讲透。文中代码是框架源码的摘录整理(静态梳理,依赖完整的 Spring + MyBatis-Plus + Redis 环境才能跑,未单独实跑),机制描述以 MyBatis-Plus 官方插件行为为准。
一、先选隔离策略:三种方案,一张表
多租户隔离策略是老三样,先看全景再选型:
| 策略 | 做法 | 隔离强度 | 成本 | 适合 |
|---|---|---|---|---|
| 独立数据库 | 一租户一库 | 最强(物理隔离) | 最高:N 倍连接数/备份/升级 | 金融、医疗等强合规 |
| 独立 Schema | 同库不同 Schema | 较强 | 中:跨租户统计难、变更跑 N 遍 | 租户量可控的中型 SaaS |
| 共享表 + 字段 | 每张表加 tenant_id | 靠 SQL 约束 | 最低:一套表、升级一遍 | 租户量大、结构同质 |
RuoYi-Vue-Plus 选的是字段级隔离——73 张业务表共享,每张带 tenant_id 列。选它的理由很实际:SaaS 目标是海量中小租户,独立库的成本模型直接不成立;而字段级最大的风险“漏拼条件导致泄漏”,可以用框架层自动改写 SQL 来兜底——这正是下一节的主角。
二、识别:租户 ID 从登录那刻绑定
字段级方案的前提是:每个请求进来时,系统得知道“这是哪个租户的人”。整条链路是:
① 登录时校验并绑定。 前端登录页先拉取可用租户列表(/auth/tenant/list),用户选择后把 tenantId 放进登录请求。后端校验三件事:
// SysLoginService.checkTenant —— 登录时的租户校验
public void checkTenant(String tenantId) {
if (!TenantHelper.isEnable()) {
return; // 租户开关没开,直接放行
}
if (StringUtils.isBlank(tenantId)) {
throw new TenantException("tenant.number.not.blank"); // 必须选租户
}
if (TenantConstants.DEFAULT_TENANT_ID.equals(tenantId)) {
return; // 000000 管理租户,内置放行
}
SysTenantVo tenant = tenantService.queryByTenantId(tenantId);
if (ObjectUtil.isNull(tenant)) {
throw new TenantException("tenant.not.exists"); // 不存在
} else if (SystemConstants.DISABLE.equals(tenant.getStatus())) {
throw new TenantException("tenant.blocked"); // 已停用
} else if (ObjectUtil.isNotNull(tenant.getExpireTime())
&& new Date().after(tenant.getExpireTime())) {
throw new TenantException("tenant.expired"); // 已过期
}
}② 绑定进 token。 校验通过后,租户 ID 作为 extra 写进 Sa-Token 的登录会话——之后的每个请求不再需要传租户 ID,它跟着 token 走:
// LoginHelper.login —— 租户 ID 作为 extra 存入登录会话
StpUtil.login(
loginUser.getLoginId(),
model.setExtra(TENANT_KEY, loginUser.getTenantId())
.setExtra(USER_KEY, loginUser.getUserId())
// ... 其他 extra
);
// 之后任何请求:从 token extra 解析当前登录租户
public static String getTenantId() {
return Convert.toStr(getExtra(TENANT_KEY));
}这就是静态租户:租户 ID 在登录那刻写死进会话,token 生命周期内不变。普通用户的每一次操作,系统都知道该把数据圈在哪个租户里。
但只有静态绑定不够——总有人需要“临时以另一个租户的身份干活”(平台运维排查租户问题、向所有租户同步配置),这就轮到本文的主角:动态租户。
三、静态隔离:租户插件自动改写 SQL
静态租户的防线是 MyBatis-Plus 的 TenantLineInnerInterceptor,工作在 SQL 层:所有经过它的 SQL,自动给涉及的表追加 tenant_id = '当前租户' 条件。
你在代码里写的:
SELECT * FROM asset WHERE asset_name = '笔记本'实际执行的:
SELECT * FROM asset
WHERE asset_name = '笔记本'
AND asset.tenant_id = '100001' -- 插件自动追加增删改同样被改写(INSERT 自动补 tenant_id 列、UPDATE/DELETE 自动追加 WHERE 条件)。业务开发者全程无感——Mapper 里不用写 tenant_id,Service 里不用传租户参数,这是字段级方案能低成本落地的关键。
3.1 装配:插件必须是第一位
租户插件在 MyBatis-Plus 拦截器链里的注册顺序有个硬要求——必须第一位:
// MybatisPlusConfig
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 多租户插件 必须放到第一位
try {
TenantLineInnerInterceptor tenant = SpringUtils.getBean(TenantLineInnerInterceptor.class);
interceptor.addInnerInterceptor(tenant);
} catch (BeansException ignore) {
// tenant.enable=false 时没有这个 bean,直接跳过
}
// 数据权限处理
interceptor.addInnerInterceptor(dataPermissionInterceptor());
// 分页插件
interceptor.addInnerInterceptor(paginationInnerInterceptor());
// 乐观锁插件
interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor());
return interceptor;
}原因:后续插件的逻辑都建立在“带租户条件的 SQL”之上——分页插件先执行会拿到没过滤租户的总数,数据权限拼进去的条件也可能被租户条件搅乱。租户条件是最外层的圈,必须最先画。
3.2 处理器:租户 ID 从哪取、哪些表不管
插件怎么知道“当前租户是谁”“哪些表不用过滤”?都由自定义的 TenantLineHandler 决定:
// PlusTenantLineHandler
@Slf4j
@AllArgsConstructor
public class PlusTenantLineHandler implements TenantLineHandler {
private final TenantProperties tenantProperties;
@Override
public Expression getTenantId() {
String tenantId = TenantHelper.getTenantId();
if (StringUtils.isBlank(tenantId)) {
log.error("无法获取有效的租户id -> Null");
return new NullValue(); // ⚠️ 取不到租户时的行为,见 3.3
}
return new StringValue(tenantId); // 拼进 SQL 的租户值
}
@Override
public boolean ignoreTable(String tableName) {
String tenantId = TenantHelper.getTenantId();
if (StringUtils.isNotBlank(tenantId)) {
// 不需要过滤租户的表
List<String> excludes = tenantProperties.getExcludes();
List<String> tables = ListUtil.toList("gen_table", "gen_table_column");
tables.addAll(excludes);
return StringUtils.equalsAnyIgnoreCase(tableName, tables.toArray(new String[0]));
}
return true; // ⚠️ 拿不到租户 ID:整表放行,不拼条件
}
}两个要点:
- 哪些表不该过滤:本身就是“租户维度的元数据”的表——
sys_tenant(租户表)、sys_tenant_package(套餐表)、sys_menu(所有租户共用的菜单)等,配置在tenant.excludes里跳过:
# application.yml
tenant:
enable: true
excludes:
- sys_menu
- sys_tenant
- sys_tenant_package
- sys_role_dept
- sys_role_menu
- sys_user_post
- sys_user_role
- sys_client
- sys_oss_config
- flow_spel- 业务实体怎么带上租户字段:框架提供
TenantEntity基类(含tenantId字段),租户业务表实体继承它即可,配合 MP 的自动填充让 INSERT 自动落值。
3.3 必须警惕的边界:拿不到租户 ID 时
注意 3.2 代码里标 ⚠️ 的分支:上下文里没有租户 ID 时,ignoreTable 返回 true——整张表放行,不拼任何租户条件。这个设计有它的道理(定时任务、系统内部操作往往没有“当前租户”),但代价是:一旦业务代码在错误的环境里丢掉了租户上下文,查询就静默变成全表扫,数据从“看不见”变成“全泄漏”,而且没有任何报错。
这不是理论风险。异步线程是重灾区——@Async、线程池、CompletableFuture.supplyAsync 里的代码拿不到 ThreadLocal 上下文,TenantHelper.getTenantId() 返回 null,插件不拼条件。真实踩坑案例(《工作台接口性能优化实战》):把统计查询并行化搬进 supplyAsync 后,租户过滤条件消失,A 租户的待办数里混进了管理租户的数据——单测抓出来的,不是靠 code review。这篇文章的“注意事项”一节会把这条展开。
四、动态租户:临时切换“当前租户”
现在回答开头的问题:运维要替某个租户排查数据,怎么办?
粗暴做法是拿租户管理员账号登录——麻烦且越权痕迹混乱。这套框架的方案是动态租户:保持登录身份不变,临时改写“当前生效的租户 ID”。
4.1 入口:一个超管专属的切换端点
// SysTenantController
@SaCheckRole(TenantConstants.SUPER_ADMIN_ROLE_KEY) // 只有超级管理员能切
@GetMapping("/dynamic/{tenantId}")
public R<Void> dynamicTenant(@PathVariable String tenantId) {
TenantHelper.setDynamic(tenantId, true); // global=true:全局生效
return R.ok();
}
@SaCheckRole(TenantConstants.SUPER_ADMIN_ROLE_KEY)
@GetMapping("/dynamic/clear")
public R<Void> dynamicClear() {
TenantHelper.clearDynamic();
return R.ok();
}切换之后,这个用户(不换 token、不重新登录)发出的所有请求,租户插件拼的都是新租户的 ID——相当于“人还在自己公司,数据视野切到了另一家公司”。
4.2 存储:三个层级的上下文
setDynamic 的值存在哪?不是单一位置,而是三级结构,读取时按优先级取:
// TenantHelper —— 动态租户的核心实现(有删减)
private static final String DYNAMIC_TENANT_KEY = GlobalConstants.GLOBAL_REDIS_KEY + "dynamicTenant";
private static final ThreadLocal<String> TEMP_DYNAMIC_TENANT = new ThreadLocal<>();
/** 设置动态租户:global=true 全局生效(存 Redis),否则仅当前线程(ThreadLocal) */
public static void setDynamic(String tenantId, boolean global) {
if (!isEnable()) return;
if (!LoginHelper.isLogin() || !global) {
TEMP_DYNAMIC_TENANT.set(tenantId); // 未登录(定时任务)或线程级:存 ThreadLocal
return;
}
String cacheKey = DYNAMIC_TENANT_KEY + ":" + LoginHelper.getUserId();
RedisUtils.setCacheObject(cacheKey, tenantId); // 全局:存 Redis,跨请求生效
SaHolder.getStorage().set(cacheKey, tenantId); // 同时缓存到本次请求
}
/** 读取:ThreadLocal > 请求级缓存 > Redis(-1 哨兵防重复查空) */
public static String getDynamic() {
if (!isEnable()) return null;
if (!LoginHelper.isLogin()) {
return TEMP_DYNAMIC_TENANT.get();
}
String tenantId = TEMP_DYNAMIC_TENANT.get();
if (StringUtils.isNotBlank(tenantId)) return tenantId; // ① 线程内优先
SaStorage storage = SaHolder.getStorage();
String cacheKey = DYNAMIC_TENANT_KEY + ":" + LoginHelper.getUserId();
tenantId = storage.getString(cacheKey);
if (StringUtils.isNotBlank(tenantId)) {
return tenantId.equals("-1") ? null : tenantId; // ② 本次请求已查过
}
tenantId = RedisUtils.getCacheObject(cacheKey); // ③ 查 Redis
storage.set(cacheKey, StringUtils.isBlank(tenantId) ? "-1" : tenantId);
return tenantId;
}
/** 当前生效租户:动态优先,兜底登录租户 */
public static String getTenantId() {
String tenantId = TenantHelper.getDynamic();
if (StringUtils.isBlank(tenantId)) {
tenantId = LoginHelper.getTenantId();
}
return tenantId;
}三个设计点值得咀嚼:
global参数的双语义:true= 存 Redis,切一次管到手动清除为止(管理端点用它);false= 只存 ThreadLocal,管一段代码块(定时任务、异步场景)。-1哨兵:Redis 里没设置动态租户是常态,每次请求都空查一次 Redis 太浪费。查过且为空时,在请求级缓存里放-1,下次直接短路。典型的“用哨兵值区分'没查过'和'查过没有'”。- 未登录回退 ThreadLocal:定时任务线程没有登录态,
setDynamic自动降级为线程级上下文——这就是“系统代码也能安全地以某个租户身份跑批”的入口。
4.3 两种用法:手动 set 与回调式包裹
手动 setDynamic + clearDynamic 容易忘清理(一旦泄漏,这个线程后续所有请求都在替别人查数据)。所以框架强烈建议用回调式 API,try-finally 保证必清:
// 在指定租户的上下文中执行一段代码,执行完自动恢复
TenantHelper.dynamic("100002", () -> {
// 这里面的所有 SQL,租户插件拼的都是 100002
List<Asset> assets = assetMapper.selectList(null);
CacheUtils.clear(CacheNames.SYS_DICT);
});4.4 对照表:静态租户 vs 动态租户
| 静态租户 | 动态租户 | |
|---|---|---|
| 绑定时机 | 登录时写进 token extra | 运行中 setDynamic 设置 |
| 生命周期 | 整个登录会话 | 一次回调 / 一个线程 / Redis 里直到清除 |
| 谁在用 | 所有普通用户(唯一方式) | 超级管理员(端点权限 @SaCheckRole 控制) |
| 典型场景 | 日常业务操作 | 平台运维代查、向指定租户同步配置、定时任务按租户跑批 |
| 兜底关系 | 是 getTenantId 的兜底层 | 优先级高于登录租户 |
五、配套隔离:缓存和 Redis 也不能漏
数据隔离只做 SQL 层是不完整的——缓存层如果不隔离,A 租户的数据会被 B 租户读到。租户模块配套了三件套:
① Redisson key 前缀(TenantKeyPrefixHandler)。Redisson 提供的 NameMapper 钩子,所有读写 Redis 的 key 自动加工:
// 代码里写的 key
CacheUtils.put("sys_dict:gender", list);
// 实际落进 Redis 的 key(当前租户 100001)
100001:sys_dict:gender同一套代码,租户 100002 读到的就是 100002:sys_dict:gender——天然互不可见。
② Spring Cache 的 cacheName 前缀(TenantSpringCacheManager)。@Cacheable("sys_dict") 这类注解缓存的 cacheName 被动态改写为 {tenantId}:sys_dict,逻辑同上。
③ 登录会话的豁免前缀(TenantSaTokenDao)。Sa-Token 的会话数据也存 Redis,但登录会话不应该按租户隔离(一个用户登录后可能动态切换租户),所以它的 Dao 实现把所有 key 统一挂到 global: 前缀下。这个 global: 是框架的全局约定——带 global: 前缀的 key 跳过租户加工,全租户共享(验证码、防重提交、限流计数都是全局性质,用的都是它)。
// TenantSaTokenDao:登录态 key 统一挂 global:,绕开租户前缀
@Override
public String get(String key) {
return super.get(GlobalConstants.GLOBAL_REDIS_KEY + key); // "global:"
}至此三层防线齐了:SQL 层拼 tenant_id、缓存层拼 key 前缀、会话层全局共享。任何一个层面漏掉,隔离就是纸糊的。
六、租户的创建:一条事务初始化一整套数据
新建租户不是“往 sys_tenant 插一行”那么简单——新租户需要开箱即用的角色、部门、管理员、字典、参数。看 insertByBo 的完整流程(整个方法跑在 TenantHelper.ignore 里,因为要跨租户读写,见第七节):
// SysTenantServiceImpl.insertByBo(流程摘录)
@Transactional(rollbackFor = Exception.class)
public Boolean insertByBo(SysTenantBo bo) {
// 1. 生成租户 ID:随机 6 位数字,冲突则重试
List<String> tenantIds = baseMapper.selectObjs(...);
String tenantId = generateTenantId(tenantIds);
add.setTenantId(tenantId);
baseMapper.insert(add);
// 2. 按套餐创建租户管理员角色(角色绑定套餐里的菜单)
Long roleId = createTenantRole(tenantId, bo.getPackageId());
// 3. 创建部门(公司名即根部门)→ 创建管理员账号 → 绑角色
SysDept dept = new SysDept();
dept.setTenantId(tenantId);
dept.setDeptName(bo.getCompanyName());
deptMapper.insert(dept);
SysUser user = new SysUser();
user.setTenantId(tenantId);
user.setPassword(BCrypt.hashpw(bo.getPassword()));
userMapper.insert(user);
// 4. 从默认租户 000000 复制字典、参数配置(重置主键、改 tenantId)
List<SysDictType> dictTypeList = dictTypeMapper.selectList(
new LambdaQueryWrapper<SysDictType>().eq(SysDictType::getTenantId, defaultTenantId));
for (SysDictType dictType : dictTypeList) {
dictType.setDictId(null); // 让主键重新生成
dictType.setTenantId(tenantId); // 归属新租户
}
dictTypeMapper.insertBatch(dictTypeList);
// 5. 发布租户创建事件,各业务模块自行初始化(解耦关键,见下)
eventPublisher.publishEvent(new TenantCreatedEvent(this, tenantId, bo.getPackageId()));
return true;
}第 5 步值得单独说。业务模块往往有自己的“新租户初始化”需求(比如资产模块要为新租户预置资产分类、区域、参数配置)。如果把这些直接写进 system 模块的创建流程,每加一个业务模块就要改一遍系统核心代码——耦合死路。
框架的做法是领域事件解耦:创建流程只发 TenantCreatedEvent,谁需要谁监听:
// 业务模块的监听器:收到事件后初始化本模块数据
@Component
@RequiredArgsConstructor
public class TenantEventListener {
private final AssetTenantInitService assetTenantInitService;
@EventListener
public void handleTenantCreated(TenantCreatedEvent event) {
assetTenantInitService.initAssetData(event.getTenantId());
}
}监听器里的初始化服务对每一条插入显式 setTenantId(event.getTenantId()),不依赖线程上下文——这个细节很关键:事件虽然在本线程同步发布,但显式赋值让初始化逻辑对“调用来源”零依赖,从任何地方触发都不会写错租户。
七、ignore:跨租户操作的合法通道
读第六节代码时你可能会问:创建租户要查“所有已有租户 ID”、要从 000000 复制数据——这些操作全在租户过滤的范围之外,怎么做到的?
答案是 TenantHelper.ignore:临时关闭租户插件,在“无租户过滤”的模式下执行一段代码:
// SysTenantController —— 创建租户必须 ignore(要跨所有租户生成/校验数据)
return toAjax(TenantHelper.ignore(() -> tenantService.insertByBo(bo)));
// 跨租户查询:列表页要展示每个租户的管理员账号
private void fillAdminUserNames(List<SysTenantVo> voList) {
List<SysUser> users = TenantHelper.ignore(() -> userMapper.selectList(
new LambdaQueryWrapper<SysUser>()
.in(SysUser::getTenantId, tenantIds) // 手动指定多个租户,精确圈定
...));
}实现上它驱动的是 MyBatis-Plus 的 InterceptorIgnoreHelper(让插件对当前线程的 SQL 罢工),并配合一个可重入计数栈:ignore 里套 ignore(很常见——controller 包一层、service 内部又包一层)不会误清状态,最外层退出才真正恢复。所以使用它的铁律是:
- 作用范围最小化——只包跨租户的那几行,不要把整个大方法都裹进去;
- 手动补过滤条件——关掉自动过滤后,自己用
in(tenantIds)精确圈定范围,别裸奔全表。
ignore 和 dynamic 是一对互补的“越界工具”:ignore 是“我不管租户,我自己圈范围”(跨租户统计、元数据维护);dynamic 是“把我自己当成另一个租户”(代客运维、按租户跑批)。
八、典型使用场景
把前面的机制串成三个实际场景:
场景一:平台运维排查租户问题。 超管调用 /system/tenant/dynamic/100002 切到目标租户(global 模式,值进 Redis),之后刷新页面看的就是 100002 的数据视野,排查完调 /dynamic/clear 恢复。全程不换账号、原租户的登录态完好无损。
场景二:向所有租户同步字典。 超管修改了字典模板,要同步到每个租户。流程是“先 ignore 读出全量 → 逐租户 diff 合并 → ignore 批量写入”,最后清理缓存时注意——缓存 key 带租户前缀,必须在对应租户的上下文里清才清得到:
// SysTenantServiceImpl.syncTenantDict(摘录)
for (String tenantId : syncTenantIds) {
TenantHelper.dynamic(tenantId, () -> CacheUtils.clear(CacheNames.SYS_DICT));
}这里如果不用 dynamic 包裹,CacheUtils.clear 生成的 key 不带目标租户前缀,等于清了个寂寞——“在谁的上下文里操作谁的数据”,缓存层比 SQL 层体现得更赤裸。
场景三:新租户开通。 第六节的全流程:创建角色部门管理员 → 复制字典参数 → 发事件 → 各业务模块监听初始化。一条事务 + 事件解耦,新模块接入只需加监听器,系统核心零改动。
九、注意事项与易错点
字段级隔离的每一层“自动化”都对应一个“自动化失灵”的坑,集中在最后这节说透:
① 异步线程是租户泄漏的头号源头。 ThreadLocal 不跨线程,@Async、线程池、CompletableFuture.supplyAsync 里的代码拿不到租户上下文 → 插件不拼条件 → 全表查询。修法两条路:查询依赖登录态/数据权限的,留在同步线程(如 @DataPermission 也依赖 ThreadLocal,同样不能进异步);确需异步的,提交前解析好租户 ID 作为参数传入,任务内用 TenantHelper.dynamic(tenantId, …) 包裹。案例见《工作台接口性能优化实战》。
② ignore 的范围要最小。 它关闭的是整个线程的租户过滤,裹太大等于给越权开了门;且关闭后必须手动补范围条件(in(tenantIds)),否则是真·全表。
③ 新表两步走:实体继承 TenantEntity + 确认不在 excludes。 反过来的两个翻车:租户维度的表忘了加 tenant_id 字段(插件拼条件直接 SQL 报错,还好发现得早);元数据表(菜单、租户表本身)忘配 excludes(所有租户互相看不见菜单,系统“看起来坏了”)。
④ global: 前缀是安全边界也是陷阱。 挂上它的 key 全租户共享——验证码、限流计数用它是正确设计;但如果把租户数据误挂到 global key 上,等于对全部租户开了读权限。设计缓存 key 时先问一句:这份数据是“每个租户一份”还是“全世界一份”。
⑤ 动态租户的清理纪律。 setDynamic(tenantId, true) 存在 Redis 里不会自动过期,切完忘了 clear,超管就一直活在别人的租户视野里。代码内一律用 dynamic() 回调式;端点切换的,前端离开管理页面时必须调 clear。
⑥ “拿不到租户 ID”不是报错而是放行。 3.3 节讲过的设计:上下文为空时插件静默跳过过滤。这意味着所有“租户上下文丢失”的 bug 都不会以异常形式暴露,只能靠测试用例(空壳租户对照、跨租户数据断言)主动抓——这也是隔离功能必须有专项测试的原因。
十、收个尾
把整套机制收进一张表:
| 层面 | 机制 | 关键类/配置 |
|---|---|---|
| SQL 数据 | 插件自动拼 tenant_id 条件 | TenantLineInnerInterceptor + PlusTenantLineHandler,插件链第一位 |
| 上下文 | 登录绑定(静态)+ 三级动态租户 | LoginHelper extra / TenantHelper(ThreadLocal → SaStorage → Redis) |
| 缓存 | key / cacheName 按租户加前缀 | TenantKeyPrefixHandler / TenantSpringCacheManager,global: 豁免 |
| 会话 | 登录态全局共享 | TenantSaTokenDao 挂 global: |
| 越界工具 | 跨租户 / 换身份 | TenantHelper.ignore(自圈范围)/ dynamic(代租户身份) |
| 开通流程 | 事务初始化 + 事件解耦 | insertByBo + TenantCreatedEvent + 业务监听器 |
多租户的本质是用框架层的自动化,换业务层的零感知:业务代码一个 tenant_id 都不用写,代价是这套自动化有严格的适用边界(同步线程、正确 excludes、global key 纪律),越过边界它不会报错,只会静默把数据漏给不该看见的人。所以这套体系的真正的最后一道防线,不是插件,而是针对隔离的专项测试——空壳租户对照、跨租户断言,一条都不能省。
相关阅读:异步线程丢租户上下文的完整翻车与修复,见《工作台接口性能优化实战》;租户数据之外的另一层行级过滤(部门/个人维度的数据权限),是同一条 SQL 改写思路的姊妹应用。
SaaS 系列导航
- 上一篇:《SaaS 架构演进》——字段级隔离是演进路线上的哪一步
- 下一篇:《动态租户实现原理》——上下文的三级存储、哨兵值与自动清理,本文第四节的实现层展开
- 《租户生命周期管理》——
checkTenant三查只是入口,租户一生还有冻结、注销和保留期 - 《SaaS 套餐功能开关设计》——隔离之外,套餐还要管“能用什么”
- 《SaaS 套餐与计费设计》——租户机制之上的商业模式落地
