Skip to content

想象一个场景:你把一套企业管理系统卖给 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 放进登录请求。后端校验三件事:

java
// 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 走:

java
// 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 = '当前租户' 条件

你在代码里写的:

sql
SELECT * FROM asset WHERE asset_name = '笔记本'

实际执行的:

sql
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 拦截器链里的注册顺序有个硬要求——必须第一位

java
// 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 决定:

java
// 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 里跳过:
yaml
# 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 入口:一个超管专属的切换端点

java
// 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 的值存在哪?不是单一位置,而是三级结构,读取时按优先级取:

租户 ID 的四级解析优先级

java
// 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 保证必清:

java
// 在指定租户的上下文中执行一段代码,执行完自动恢复
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 自动加工:

java
// 代码里写的 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 跳过租户加工,全租户共享(验证码、防重提交、限流计数都是全局性质,用的都是它)。

java
// 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 里,因为要跨租户读写,见第七节):

java
// 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,谁需要谁监听:

java
// 业务模块的监听器:收到事件后初始化本模块数据
@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临时关闭租户插件,在“无租户过滤”的模式下执行一段代码

java
// 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 内部又包一层)不会误清状态,最外层退出才真正恢复。所以使用它的铁律是:

  1. 作用范围最小化——只包跨租户的那几行,不要把整个大方法都裹进去;
  2. 手动补过滤条件——关掉自动过滤后,自己用 in(tenantIds) 精确圈定范围,别裸奔全表。

ignore 和 dynamic 是一对互补的“越界工具”:ignore 是“我不管租户,我自己圈范围”(跨租户统计、元数据维护);dynamic 是“把我自己当成另一个租户”(代客运维、按租户跑批)。

八、典型使用场景

把前面的机制串成三个实际场景:

场景一:平台运维排查租户问题。 超管调用 /system/tenant/dynamic/100002 切到目标租户(global 模式,值进 Redis),之后刷新页面看的就是 100002 的数据视野,排查完调 /dynamic/clear 恢复。全程不换账号、原租户的登录态完好无损。

场景二:向所有租户同步字典。 超管修改了字典模板,要同步到每个租户。流程是“先 ignore 读出全量 → 逐租户 diff 合并 → ignore 批量写入”,最后清理缓存时注意——缓存 key 带租户前缀,必须在对应租户的上下文里清才清得到:

java
// 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 / TenantSpringCacheManagerglobal: 豁免
会话登录态全局共享TenantSaTokenDaoglobal:
越界工具跨租户 / 换身份TenantHelper.ignore(自圈范围)/ dynamic(代租户身份)
开通流程事务初始化 + 事件解耦insertByBo + TenantCreatedEvent + 业务监听器

多租户的本质是用框架层的自动化,换业务层的零感知:业务代码一个 tenant_id 都不用写,代价是这套自动化有严格的适用边界(同步线程、正确 excludes、global key 纪律),越过边界它不会报错,只会静默把数据漏给不该看见的人。所以这套体系的真正的最后一道防线,不是插件,而是针对隔离的专项测试——空壳租户对照、跨租户断言,一条都不能省。

相关阅读:异步线程丢租户上下文的完整翻车与修复,见《工作台接口性能优化实战》;租户数据之外的另一层行级过滤(部门/个人维度的数据权限),是同一条 SQL 改写思路的姊妹应用。

SaaS 系列导航