上一期《2026 年 Java 低代码后台横评》评论区吵翻了,呼声最高的是"拆多租户源码"。这一期就动真格,把若依、JeecgBoot、芋道、Forge 的多租户实现摊开看。
上期横评里,多租户那一维度拉开了最大的分差:若依只有 1 分,Forge 拿了 5 分。评论区有人不服:"不就是加个 tenant_id 吗,有那么玄乎?"
这一期我用源码回答这个问题。看完你会明白——多租户做没做、做到第几层,差的不是一个字段,是整套工程设计。
先说结论,4 个框架的多租户大致分三档:
| 档位 | 框架 | 一句话 |
|---|---|---|
| 没有 | 若依(RuoYi-Vue 社区版) | 原生无多租户,要自己改 |
| 够用 | JeecgBoot、芋道 | 拦截器自动注入,但忽略表靠手动维护 |
| 深度 | Forge Admin | 自动注入 + 五级忽略 + 线程传递 + 严格模式 |
下面逐档拆。
很多人不知道,若依社区版(RuoYi-Vue)原生是不支持多租户的。它的数据隔离思路是"数据权限"(按部门/角色过滤),不是"租户隔离"。
这意味着你要做 SaaS,得自己干这几件事:
tenant_id 字段WHERE tenant_id = ?tenant_id第 3 步是噩梦。若依的 SQL 大量写在 Mapper XML 里,你要么逐个 XML 改,要么自己写 MyBatis 插件拦截。改到第 30 个 Mapper 的时候,手指是真的会抖——这是我在上一篇《用了 3 个月若依后换到 Forge》里写过的亲历。
当然,若依官方有付费版(RuoYi-Cloud-Plus 等衍生项目)做了多租户,但社区版要自己造轮子。这是它在这一维度只拿 1 分的原因——不是不能做,是什么都没给你。
这两个框架都用了 MyBatis-Plus 的 TenantLineInnerInterceptor,思路一致:
// 典型用法:注册 MyBatis-Plus 多租户插件
TenantLineInnerInterceptor interceptor = new TenantLineInnerInterceptor();
interceptor.setTenantLineHandler(new TenantLineHandler() {
@Override
public Expression getTenantId() {
// 从上下文取当前租户 ID
return new LongValue(TenantContext.getTenant());
}
@Override
public boolean ignoreTable(String tableName) {
// 哪些表不加租户条件 —— 关键就在这个方法
return IGNORE_TABLES.contains(tableName);
}
});
它会在 SQL 解析阶段,自动把 SELECT * FROM biz_order 改写成 SELECT * FROM biz_order WHERE tenant_id = 1。这一步很省心,业务代码完全不用关心租户。
但问题出在 ignoreTable() 这个方法上。
不是所有表都该加租户条件。比如系统配置表、数据字典表、地区编码表,这些是全平台共享的,加了 tenant_id 反而查不到数据。还有你引入的第三方库的表(比如 Quartz 的 qrtz_* 表、工作流引擎的表),它们根本没有 tenant_id 字段,一旦被注入条件,SQL 直接报错"Unknown column 'tenant_id'"。
JeecgBoot 和芋道的做法是:你把要忽略的表名,手动维护到一个列表/配置里。
这就埋了两个坑:
够用,但谈不上省心。这是它们拿 4 分、没到 5 分的原因。
Forge 也是基于 MyBatis-Plus 的 TenantLineHandler,但它把那个最容易踩坑的 ignoreTable() 方法,做成了五级递进的判断链。
直接上源码(DefaultTenantLineHandler.java,一行没改):
@Override
public boolean ignoreTable(String tableName) {
// 1. 如果上下文设置了忽略租户,则跳过
if (TenantContextHolder.isIgnore()) {
return true;
}
// 2. 检查是否在手动配置的忽略表列表中
if (tenantProperties.getIgnoreTables() != null &&
tenantProperties.getIgnoreTables().contains(tableName)) {
return true;
}
// 3. 检查手动添加到缓存的忽略表
if (ignoreTableCache.contains(tableName)) {
return true;
}
// 4. 自动检测:如果启用了自动检测,检查表是否包含租户字段
if (tenantProperties.getAutoDetectTenantColumn() && tenantTableChecker != null) {
// 不包含租户字段的表需要忽略
return !tenantTableChecker.hasTenantColumn(tableName);
}
// 默认不忽略
return false;
}
这五层从上到下,优先级递减,各自解决一类问题:
| 级别 | 机制 | 解决什么场景 |
|---|---|---|
| 1 | 上下文标记 | 运行时临时关闭租户(系统级操作、定时任务) |
| 2 | 配置白名单 | 平台级共享表(配置、字典、地区编码等) |
| 3 | 手动注册缓存 | 代码里动态临时加忽略表 |
| 4 | 自动扫描表结构 | 引入无 tenant_id 的第三方表,自动跳过不报错 |
| 5 | 默认行为 | 兜底:加租户条件 |
重点看 第 2 级和第 4 级,这是它跟 Jeecg/芋道拉开差距的地方。
Forge 在 TenantProperties 构造函数里,默认就把 16 张平台级表加进了白名单:
public TenantProperties() {
// 平台级表不按当前租户做 SQL 隔离
ignoreTables.add("sys_tenant");
ignoreTables.add("sys_resource");
ignoreTables.add("sys_client");
ignoreTables.add("sys_region_code");
ignoreTables.add("sys_api_config");
// ... 还有 gen_table、worker_node、sys_id_sequence 等
}
你接手项目第一天,这些坑就已经填好了,不用自己一个个试错。
这是我最想夸的设计。Forge 在应用启动时,会扫描数据库所有表结构,记录哪些表有 tenant_id、哪些没有(TenantTableChecker.java):
// 启动时扫描全库表结构
try (ResultSet tables = metaData.getTables(catalog, schema, "%", new String[]{"TABLE"})) {
while (tables.next()) {
String tableName = tables.getString("TABLE_NAME");
boolean hasTenantColumn = checkTableHasColumn(..., tenantColumn);
if (hasTenantColumn) {
tablesWithTenantColumn.add(tableName);
} else {
tablesWithoutTenantColumn.add(tableName); // 自动归入"无需租户过滤"
}
}
}
效果就是:你引入任何一个没有 tenant_id 字段的第三方表,Forge 自动识别、自动跳过注入,不用你手动配置,也绝不会报"Unknown column"。
Jeecg/芋道要你手动维护忽略列表,Forge 让数据库自己告诉框架该忽略谁。这就是"够用"和"省心"的区别。
一个工程细节:这个扫描用
SmartInitializingSingleton触发,在所有单例 Bean 初始化完成后、ApplicationReadyEvent之前执行。而且扫描期间会setIgnore(true)关掉租户拦截——否则扫描表结构的 SQL 自己就会被拦截,形成死循环。能想到这一层,说明是踩过坑的。
光有五级忽略还不够,Forge 还解决了两个真实生产环境才会暴露的问题。
租户 ID 一般存在 ThreadLocal 里。但如果你的代码用了线程池(@Async、CompletableFuture、定时任务),子线程拿不到主线程的 ThreadLocal,租户上下文直接丢失——查出来的数据要么为空,要么串租户。
Forge 用阿里的 TransmittableThreadLocal 解决(TenantContextHolder.java):
// 用 TTL 而不是普通 ThreadLocal,支持线程池场景下的上下文传递
private static final ThreadLocal<Long> TENANT_ID_HOLDER = new TransmittableThreadLocal<>();
并且封装了优雅的函数式 API,临时切租户、临时忽略租户都能自动恢复现场:
// 临时以"忽略租户"身份执行,结束后自动还原上下文
TenantContextHolder.executeIgnore(() -> {
return systemMapper.selectAllTenantsData();
});
// 临时切到指定租户执行
TenantContextHolder.executeWithTenant(2L, () -> {
return orderMapper.selectList();
});
@IgnoreTenant 注解的底层,就是切面调用了这个 executeIgnore(IgnoreTenantAspect.java)——所以注解、编程式 API、上下文标记三者是同一套机制,不会互相打架。
如果某次请求拿不到租户 ID,怎么办?
Forge 用一个配置开关(TenantProperties.strictMode)让你自己选。对金融、政务这类"宁可报错也不能数据越权"的场景,严格模式是保命的。
forge:
tenant:
enabled: true
column: tenant_id
strict-mode: true # 严格模式
auto-detect-tenant-column: true # 自动扫描表结构
把这一期拆的东西汇总成一张表:
| 能力点 | 若依社区版 | JeecgBoot | 芋道 | Forge |
|---|---|---|---|---|
| 原生多租户 | ❌ | ✅ | ✅ | ✅ |
| SQL 自动注入 | 自己写 | ✅ | ✅ | ✅ |
| 忽略表方式 | — | 手动配置 | 手动配置 | 5 级策略 |
| 平台表内置白名单 | — | 部分 | 部分 | ✅ 开箱 16 张 |
| 第三方无租户表自动跳过 | — | ❌ 手动 | ❌ 手动 | ✅ 启动扫描 |
| 线程池上下文传递 | — | 看实现 | 看实现 | ✅ TTL |
| 严格模式开关 | — | ❌ | ❌ | ✅ |
回到开头那个质疑——"不就是加个 tenant_id 吗?"
加字段不难。难的是:
这四个问题,每一个都是真实生产环境会出血的坑。若依把它们留给你,Jeecg/芋道解决了一半(自动注入但要手动配忽略表),Forge 用五级策略 + 自动扫描 + TTL + 严格模式把四个都兜住了。
这就是上一期"多租户"那一维,分数从 1 到 5 的真实差距。不是功能列表的差距,是工程深度的差距。
这一期拆了多租户。横评第 3 期,评论区点最高的我先拆,候选:
想看哪个,评论区扣:1 数据权限 / 2 接口加解密 / 3 代码生成。
源码自取(本文所有代码片段均来自仓库,可对照核验):
对比项目:
你做 SaaS 时,多租户踩过最深的坑是什么?是串户、线程丢上下文,还是第三方表报错?评论区聊聊,我会挨个回复。觉得有用求个👍收藏,横评系列持续更新。