摘要:本文深入剖析了 RuoYi-Vue-Pro 多租户模块的设计与实现。文章首先介绍了多租户的三种经典方案,并解释了项目选择“共享数据库、共享 Schema”方案的成本、迭代和 MyBatis Plus 支持优势。接着,从需求溯源、竞品对比、核心业务流程、数据模型、产品设计等多个维度展开,重点解读了其 7 层全链路隔离(数据库、缓存、MQ、定时任务等)、租户套餐机制、一键开通流程、关键注解(如 @TenantIgnore、@TenantJob)以及安全校验等核心特性。最后,文章指出了当前设计的不足并提出了改进思路,为读者提供了从原理到实践的完整视角。

大家好,我是你们的源码拆解老朋友。今天继续我们的 RuoYi-Vue-Pro(芋道)系列第四篇。前面三篇我们聊了框架层、认证权限、用户与组织,今天终于来到了这个项目中最有商业价值的模块——多租户(Multi-Tenant)

为什么说它最有商业价值?因为如果你要把一个后台管理系统做成 SaaS 卖给多家公司用,多租户就是绕不开的核心能力。废话不多说,直接上干货!


一、今日模块概览

一句话总结:多租户模块解决的是"一套系统、一套数据库,同时服务 N 个相互隔离的客户(租户)"的问题。

想象一下这个场景:你做了一个项目管理 SaaS,A 公司和 B 公司都在用。A 公司看不到 B 公司的项目数据,B 公司也碰不到 A 的——这就是租户隔离。而 RuoYi-Vue-Pro 的多租户实现,从数据库 SQL 拦截、Redis 缓存隔离、消息队列上下文传递、到定时任务逐租户执行,覆盖了整整 7 层,堪称我见过的开源项目里最完整的多租户实现之一。


二、技术选型分析

2.1 多租户的三种经典方案

在聊 RuoYi 的选型之前,我们先看看业界多租户的三种主流方案:

方案隔离级别优点缺点代表项目
独立数据库每个租户一个 Database隔离性最强,数据物理分离成本高,维护复杂,迁移困难早期 SAP SaaS
共享数据库、独立 Schema同一 DB,每个租户一套表隔离性较好,逻辑清晰表结构变更时要同步 N 套 SchemaSalesforce
共享数据库、共享 Schema同一张表,通过 tenant_id 字段区分成本最低,维护最简单隔离性依赖代码层保证RuoYi-Vue-Pro、JeecgBoot

RuoYi-Vue-Pro 选择了第三种方案:共享数据库、共享 Schema,通过 tenant_id 字段做行级隔离

2.2 为什么选共享 Schema?

这个选择背后的逻辑其实很清晰:

第一,成本。 对于中小 SaaS 来说,客户可能几十上百个,如果每个租户一个数据库,光数据库连接池就能把你服务器吃垮。共享 Schema 方案下,一套数据库搞定所有租户,运维成本最低。

第二,迭代速度。 SaaS 产品迭代快,一周一版是常态。如果是独立数据库方案,每次加个字段你要同步 100 个数据库,想想就头大。共享 Schema 改一次就够了。

第三,MyBatis Plus 的加持。 MyBatis Plus 内置了 TenantLineInnerInterceptor(多租户 SQL 拦截器),可以在 SQL 层面自动追加 WHERE tenant_id = ?,开发者几乎不需要手动写隔离逻辑。这大大降低了"代码层保证隔离"的风险。

划重点: 共享 Schema 方案的核心风险在于——如果某条 SQL 忘了加 tenant_id 条件,就会造成数据泄漏。但 RuoYi 通过 MyBatis Plus 拦截器 + 全局 BaseDO 基类的方式,把这个风险降到了最低。

2.3 为什么用 TransmittableThreadLocal 而不是普通 ThreadLocal?

这是一个很多人容易忽略的细节。RuoYi 的 TenantContextHolder 用的是阿里巴巴的 TransmittableThreadLocal(TTL),而不是 JDK 原生的 ThreadLocal。

原因很简单:线程池场景下,普通 ThreadLocal 会丢失上下文。 比如你的 @Async 方法、CompletableFuture、甚至 Spring Security 的异步认证,都会把任务丢到线程池。普通 ThreadLocal 在线程复用时不会传递值,而 TTL 专门解决这个问题——它能在父线程设置值后,自动传递到子线程。

// TenantContextHolder.java —— 注意这里用的是 TransmittableThreadLocal
private static final ThreadLocal<Long> TENANT_ID = new TransmittableThreadLocal<>();
private static final ThreadLocal<Boolean> IGNORE = new TransmittableThreadLocal<>();

如果你在自己的项目里也要做多租户,强烈建议直接用 TTL,否则异步场景下租户上下文丢失,排查起来会让你怀疑人生。


三、需求溯源推演

好,接下来我们做一个有趣的事情——尝试还原这个模块最初的产品需求。

3.1 谁在什么场景下提出的需求?

我推测这个需求的起源大概是这样的:

时间: 2021 年左右,RuoYi 从单体后台管理系统向 SaaS 化演进 角色: 可能是芋道源码本人,或者某个企业客户 场景: "我们给 A 公司做了一套后台系统,B 公司也说想要一套,但功能差不多。能不能做一套系统,让 A 和 B 都登录进来,但互相看不到对方的数据?"

这就是最朴素的多租户需求——一套代码、一套部署、多客户共用、数据隔离

3.2 需求拆解推演

从代码实现反推,原始需求大概可以拆成这几个故事:

故事一:租户管理。 超级管理员(平台运营方)需要能创建、编辑、禁用、删除租户。每个租户有名字、联系人、过期时间、最大账号数等基本信息。

故事二:套餐管理。 不同的租户可能需要不同的功能权限。比如基础版只能用 OA 模块,高级版还能用 CRM 和 ERP。这就需要"租户套餐"的概念——一个套餐定义了一组可用的菜单/功能。

故事三:自动化开通。 创建租户时不能只建一条记录就完事了——还得自动创建该租户的管理员账号、管理员角色、并分配套餐对应的菜单权限。这是一个典型的"一键开通"流程。

故事四:数据隔离。 所有业务数据必须按租户隔离。A 租户的用户查不到 B 租户的数据,这是底线。

故事五:安全校验。 不能通过篡改请求头的方式越权访问其他租户。被禁用的租户、过期的租户都应该被拒绝访问。


四、竞品对标分析

说完了 RuoYi 自己的设计,我们来看看同类开源项目是怎么做多租户的。

4.1 竞品对比表

维度RuoYi-Vue-ProJeecgBootPigGunsSpringBlade
多租户方案共享 Schema + tenant_id共享 Schema + tenant_id共享 Schema + tenant_id独立数据库为主共享 Schema
SQL 拦截MyBatis Plus TenantLineInterceptorMyBatis Plus 拦截器MyBatis Plus 拦截器手动拼接为主MyBatis Plus 拦截器
租户套餐有(menuIds 控制菜单)有(权限包)
缓存隔离Redis key 自动拼接 tenantId手动处理手动处理手动处理
MQ 租户传递4 种 MQ 全覆盖(Redis/Kafka/RabbitMQ/RocketMQ)
定时任务多租户@TenantJob 注解自动逐租户执行
跨租户访问有(visit-tenant-id + 权限校验)
一键开通租户自动创建管理员 + 角色 + 权限手动手动手动手动

4.2 RuoYi 的优势

从对比可以看出,RuoYi-Vue-Pro 在多租户这块做得相当厚道,几个亮点:

第一,全链路隔离。 不只是数据库层面做了隔离,Redis 缓存、消息队列、定时任务都考虑到了。尤其是 MQ 和定时任务的多租户支持,在其他开源项目里几乎看不到。这意味着如果你用 RuoYi 做 SaaS,这些"脏活"已经帮你干完了。

第二,租户套餐机制。 通过 system_tenant_package 表定义功能包,可以灵活控制不同租户能用的功能。这个设计类似于 SaaS 行业的"版本/Plan"概念(基础版、专业版、企业版),非常贴合实际商业需求。

第三,跨租户访问能力。 超级管理员可以通过 visit-tenant-id 请求头切换到任意租户的视角查看数据,前提是拥有 system:tenant:visit 权限。这个设计对于客服场景、运维排查非常实用。

4.3 RuoYi 的不足

当然也有可以改进的地方:

缺少租户级别的数据库独立部署能力。 对于大客户(比如银行、政府),共享 Schema 的隔离级别可能不够。如果能支持"默认共享 Schema + 大客户独立数据库"的混合模式会更灵活。

租户配额管理偏简单。 目前只有 accountCount(最大账号数)这一个配额维度。实际 SaaS 场景中,可能还需要限制存储空间、API 调用次数、数据条数等。


五、核心业务流程

5.1 创建租户的完整链路

这是多租户模块最核心的流程,我来带大家从代码层面走一遍。

关键代码在 TenantServiceImpl.createTenant() 方法里:

@Override
@DSTransactional // 多数据源事务
public Long createTenant(TenantSaveReqVO createReqVO) {
    // 1. 各种校验(名称、域名、套餐)
    validTenantNameDuplicate(createReqVO.getName(), null);
    validTenantWebsiteDuplicate(createReqVO.getWebsites(), null);
    TenantPackageDO tenantPackage = tenantPackageService.validTenantPackage(createReqVO.getPackageId());

    // 2. 创建租户
    TenantDO tenant = BeanUtils.toBean(createReqVO, TenantDO.class);
    tenantMapper.insert(tenant);

    // 3. 关键!切换到新租户上下文,创建管理员和角色
    TenantUtils.execute(tenant.getId(), () -> {
        Long roleId = createRole(tenantPackage);       // 创建 TENANT_ADMIN 角色
        Long userId = createUser(roleId, createReqVO); // 创建管理员用户
        tenantMapper.updateById(new TenantDO().setId(tenant.getId()).setContactUserId(userId));
    });
    return tenant.getId();
}

注意这里的设计细节: TenantUtils.execute(tenantId, ...) 这个方法会临时把 TenantContextHolder 的租户 ID 切换到新租户,这样后续创建的角色、用户都会自动带上正确的 tenant_id。执行完毕后自动恢复上下文。非常优雅。

5.2 请求级别的租户隔离链路

当一个普通租户用户发起 API 请求时,会经过以下处理链:

TenantSecurityWebFilter 的三层校验逻辑值得细看:

// 第一层:防越权——登录用户的租户和请求头的租户不一致,直接 403
if (!Objects.equals(user.getTenantId(), TenantContextHolder.getTenantId())) {
    ServletUtils.writeJSON(response, CommonResult.error(FORBIDDEN, "您无权访问该租户的数据"));
    return;
}

// 第二层:必传校验——非忽略 URL 必须携带 tenant-id
if (tenantId == null) {
    ServletUtils.writeJSON(response, CommonResult.error(BAD_REQUEST, "请求的租户标识未传递"));
    return;
}

// 第三层:合法性校验——检查租户是否被禁用或过期
tenantFrameworkService.validTenant(tenantId);

5.3 套餐变更的级联更新

当管理员修改了某个套餐的菜单列表时,需要同步更新所有使用该套餐的租户的角色权限:

// TenantPackageServiceImpl.updateTenantPackage()
if (!CollUtil.isEqualList(tenantPackage.getMenuIds(), updateReqVO.getMenuIds())) {
    List<TenantDO> tenants = tenantService.getTenantListByPackageId(tenantPackage.getId());
    tenants.forEach(tenant -> tenantService.updateTenantRoleMenu(tenant.getId(), updateReqVO.getMenuIds()));
}

updateTenantRoleMenu 的逻辑也很有意思:对于租户管理员角色,直接把权限重置为套餐的 menuIds;对于租户内自建的其他角色,取原权限和套餐权限的交集——如果套餐里取消了某个菜单,所有角色都不能再看到它。


六、数据模型解读

6.1 核心表结构

多租户模块的核心表只有两张,但它们的設計思路值得细品。

system_tenant(租户表):

字段类型说明
idbigint租户编号,自增主键
namevarchar(30)租户名称,唯一
contact_user_idbigint关联 system_users 表,租户管理员的用户 ID
contact_namevarchar(30)联系人姓名
contact_mobilevarchar(500)联系手机
statustinyint状态(0 正常 / 1 禁用)
websitesvarchar(1024)绑定域名,JSON 数组存储
package_idbigint关联 system_tenant_package,套餐 ID
expire_timedatetime过期时间
account_countint最大账号数

system_tenant_package(租户套餐表):

字段类型说明
idbigint套餐编号
namevarchar(30)套餐名称,唯一
statustinyint状态
remarkvarchar(256)备注
menu_idsvarchar(4096)关联的菜单 ID,JSON 数组存储

6.2 设计亮点

亮点一:package_id = 0 表示系统内置租户。 这是一个巧妙的哨兵值设计。系统租户(id=1)的 package_id 为 0,代表"拥有全部菜单权限,不受套餐限制"。代码里用 PACKAGE_ID_SYSTEM = 0L 常量定义。

亮点二:websites 字段支持一对多绑定。 一个租户可以绑定多个域名(比如 PC 端和 H5 端用不同域名),通过 JSON 数组存储,查询时通过 selectListByWebsite 反查。

亮点三:租户表和套餐表都继承了 BaseDO 而不是 TenantBaseDO。 这意味着这两张表本身不参与租户隔离——它们是全局元数据,只有超级管理员能操作。对应的 DO 类上都标注了 @TenantIgnore 注解。

6.3 26 张带 tenant_id 的表

在整个数据库中,有 26 张表带有 tenant_id 字段,涵盖了系统管理(用户、角色、部门、日志、OAuth2 令牌等)、基础设施(API 日志、错误日志)以及各个业务模块。所有这些数据表都通过 MyBatis Plus 的 TenantDatabaseInterceptor 自动完成租户过滤,开发者不需要手动写 WHERE tenant_id = ?。


七、产品设计亮点与槽点

7.1 让人眼前一亮的设计

第一,7 层全链路租户隔离。 这是我见过的开源项目中最完整的多租户实现。从 Web 请求、安全校验、数据库 SQL、Redis 缓存、消息队列(4 种 MQ)、到定时任务,每一层都做了租户上下文传递。特别是 MQ 层面的支持——Redis/Kafka/RabbitMQ/RocketMQ 全覆盖——这意味着你的异步处理也不会出现租户串数据的问题。

第二,@TenantIgnore 注解的双重用途。 这个注解既可以标在 Controller 方法上(表示这个接口不需要租户校验),也可以标在 DO 实体类上(表示这张表不需要 SQL 拦截器追加 tenant_id)。一个注解,两个场景,设计得很精巧。而且框架启动时会自动扫描所有标注了 @TenantIgnore 的 Controller 方法,把它们的 URL 收集到忽略列表中。

第三,TenantJob 定时任务注解。 只需要在 JobHandler 方法上加一个 @TenantJob,框架就会自动获取所有租户列表,用 parallelStream() 并行执行。对于每个租户,自动切换上下文再执行。这个设计让定时任务的多租户改造成本降到了零——你只需要加一个注解。

// 使用示例:加一个注解,自动逐租户执行
@TenantJob
public String execute() {
    // 这里的代码会在每个租户的上下文中各执行一次
    // TenantContextHolder 已经自动设置好了
    return doSomething();
}

第四,跨租户访问能力。 通过 visit-tenant-id 请求头 + system:tenant:visit 权限控制,超级管理员可以"切换"到任意租户的视角查看数据。这对于客服排查问题、运维定位 Bug 非常实用,而且审计日志里会记录越权访问行为。

7.2 可以改进的地方

槽点一:租户创建时没有异步化处理。 当前 createTenant 方法在一个事务里同步完成了"创建租户 → 创建角色 → 创建用户 → 分配权限"的全部操作。如果未来租户创建变慢(比如要初始化大量数据、发送通知邮件等),这个同步流程会成为瓶颈。建议考虑引入事件驱动:先创建租户记录,然后通过领域事件异步完成后续初始化。

槽点二:套餐菜单用 JSON 数组存在 varchar(4096) 里。 当菜单数量很多时(比如几百个菜单),这个字段可能会不够长。而且 JSON 数组的查询和更新效率也不如关联表。如果菜单数量可能超过 100 个,建议改用 system_tenant_package_menu 关联表。

槽点三:缺少租户数据迁移能力。 如果一个租户要从共享 Schema 迁移到独立数据库(比如大客户升级),目前没有任何工具支持。这在商业 SaaS 中是一个常见需求。

槽点四:accountCount 配额没有在用户创建时校验。 虽然 TenantDO 有 accountCount 字段限制最大账号数,但在 AdminUserService.createUser() 的代码中,我没有看到对这个配额的校验逻辑。这意味着这个字段可能只是一个"展示用"的配额,并没有真正执行限制。


八、发散性思考

8.1 这个模块还能做什么?

租户级别的自定义配置。 目前所有租户共享同一套系统配置(比如上传文件大小限制、密码策略等)。如果能让每个租户自定义这些配置,就能满足更多个性化需求。

租户级别的数据备份与恢复。 如果某个租户误删了数据,能不能单独恢复这个租户的数据?在共享 Schema 方案下,这需要按 tenant_id 做逻辑备份。

租户用量统计与计费。 基于 tenant_id 做 API 调用次数、存储空间、活跃用户数等维度的统计,为按量计费提供数据支撑。

8.2 如果让我重新设计

如果让我从零设计多租户模块,我会做以下改进:

第一,支持混合隔离模式。 默认共享 Schema,但允许特定租户配置独立数据源。通过 TenantDO 增加 datasourceUrl、datasourceUsername 等字段,在 TenantDatabaseInterceptor 中判断:如果租户有独立数据源配置,则动态切换数据源。

第二,引入配额引擎。 抽象出一个 TenantQuotaManager,支持注册多种配额维度(账号数、存储空间、API 次数等),在对应的业务操作前进行配额校验。

第三,租户生命周期事件。 创建租户、禁用租户、删除租户时发出领域事件,让其他模块通过事件监听器做相应的处理(如初始化数据、清理缓存、释放资源),而不是在 TenantService 里硬编码所有逻辑。

8.3 设计思路的迁移场景

RuoYi 这套多租户的设计思路,其实可以迁移到很多其他场景:

多机构/多学校/多医院管理系统。 本质上就是多租户——每个机构独立数据,共享平台。

微服务间的数据隔离。 在微服务架构中,不同业务线可能需要共享基础设施但数据隔离。这套基于 tenant_id 的拦截方案可以直接复用,只需要把 tenant_id 换成 business_line_id。

多环境配置管理。 用类似的拦截器思路,可以实现"同一张表存储不同环境的配置",通过 env_id 字段区分。


九、关键代码导读

最后,列出 5 个最值得你打开 IDE 仔细阅读的代码文件:

1. TenantDatabaseInterceptor.java

路径: yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/.../core/db/TenantDatabaseInterceptor.java

为什么值得读: 这是整个多租户隔离的核心——MyBatis Plus 的 TenantLineHandler 实现。它决定了哪些表需要追加 tenant_id 条件,哪些表可以豁免。特别是 computeIgnoreTable() 方法中"通过判断实体是否继承 TenantBaseDO 来自动决定是否隔离"的逻辑,非常巧妙。

2. TenantSecurityWebFilter.java

路径: yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/.../core/security/TenantSecurityWebFilter.java

为什么值得读: 这是租户安全的守门人。三层校验逻辑(防越权 → 必传校验 → 合法性校验)是每一个做 SaaS 的人都应该理解的。特别是第一层"登录用户租户与请求头租户不一致即 403"的逻辑,是防止水平越权的关键。

3. TenantServiceImpl.java

路径: yudao-module-system/src/main/java/.../service/tenant/TenantServiceImpl.java

为什么值得读: 这是租户管理的业务核心。createTenant() 方法里"创建租户 → 切换上下文 → 创建角色 → 创建用户 → 分配权限"的完整链路,是理解"一键开通"业务逻辑的最佳入口。updateTenantRoleMenu() 方法里"管理员角色直接重置、普通角色取交集"的策略也值得学习。

4. TenantJobAspect.java

路径: yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/.../core/job/TenantJobAspect.java

为什么值得读: 这个 AOP 切面只有 60 行代码,但设计得非常精巧。它用 parallelStream() 并行遍历所有租户,对每个租户用 TenantUtils.execute() 切换上下文后执行 Job。代码注释里还提到了"幂等性"的要求——因为某个租户执行失败重试时,之前成功的租户也会再次执行。这是一个很好的"注解驱动 + AOP 切面"的设计范例。

5. TenantRedisCacheManager.java

路径: yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/.../core/redis/TenantRedisCacheManager.java

为什么值得读: 这个类展示了如何在 Redis 层面做租户隔离——在 cache name 后面拼接 :#tenantId 后缀。代码不到 50 行,但思路非常值得借鉴。特别是它支持 ignoreCaches 配置,允许某些全局缓存(如字典、OAuth2 客户端配置)不被租户隔离。


系列文章导航

下一篇预告: 字典 / 短信 / 邮件 / 通知模块——RuoYi 的消息通知体系是怎么设计的?为什么它要同时支持 4 种通知渠道?敬请期待~

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐