头条-横评第2期-多租户源码拆解.md 12 KB

框架横评第 2 期:4 个 Java 后台的多租户拦截器,差距到底在哪?(附源码)

上一期《2026 年 Java 低代码后台横评》评论区吵翻了,呼声最高的是"拆多租户源码"。这一期就动真格,把若依、JeecgBoot、芋道、Forge 的多租户实现摊开看。

上期横评里,多租户那一维度拉开了最大的分差:若依只有 1 分,Forge 拿了 5 分。评论区有人不服:"不就是加个 tenant_id 吗,有那么玄乎?"

这一期我用源码回答这个问题。看完你会明白——多租户做没做、做到第几层,差的不是一个字段,是整套工程设计。

先说结论,4 个框架的多租户大致分三档:

档位 框架 一句话
没有 若依(RuoYi-Vue 社区版) 原生无多租户,要自己改
够用 JeecgBoot、芋道 拦截器自动注入,但忽略表靠手动维护
深度 Forge Admin 自动注入 + 五级忽略 + 线程传递 + 严格模式

下面逐档拆。


一、第一档:若依——你得自己从零写

很多人不知道,若依社区版(RuoYi-Vue)原生是不支持多租户的。它的数据隔离思路是"数据权限"(按部门/角色过滤),不是"租户隔离"。

这意味着你要做 SaaS,得自己干这几件事:

  1. 所有业务表加 tenant_id 字段
  2. 写一个拦截器,从请求上下文取当前租户
  3. 在每一个查询 SQL 里手动拼 WHERE tenant_id = ?
  4. 新增/修改时手动塞 tenant_id

第 3 步是噩梦。若依的 SQL 大量写在 Mapper XML 里,你要么逐个 XML 改,要么自己写 MyBatis 插件拦截。改到第 30 个 Mapper 的时候,手指是真的会抖——这是我在上一篇《用了 3 个月若依后换到 Forge》里写过的亲历。

当然,若依官方有付费版(RuoYi-Cloud-Plus 等衍生项目)做了多租户,但社区版要自己造轮子。这是它在这一维度只拿 1 分的原因——不是不能做,是什么都没给你。


二、第二档:JeecgBoot / 芋道——拦截器自动注入

这两个框架都用了 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 和芋道的做法是:你把要忽略的表名,手动维护到一个列表/配置里。

这就埋了两个坑:

  1. 漏配就报错:引入一个新的第三方表,忘了加进忽略列表,运行时 SQL 直接炸
  2. 维护成本高:表越来越多,忽略列表越来越长,谁也不敢删

够用,但谈不上省心。这是它们拿 4 分、没到 5 分的原因。


三、第三档:Forge——把 ignoreTable 做成了五级策略

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/芋道拉开差距的地方。

第 2 级:开箱内置的平台表白名单

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 等
}

你接手项目第一天,这些坑就已经填好了,不用自己一个个试错。

第 4 级:自动扫描,引第三方表不再报错

这是我最想夸的设计。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 多出来的两手:线程传递 + 严格模式

光有五级忽略还不够,Forge 还解决了两个真实生产环境才会暴露的问题。

1. 异步线程的租户丢失:TransmittableThreadLocal

租户 ID 一般存在 ThreadLocal 里。但如果你的代码用了线程池(@AsyncCompletableFuture、定时任务),子线程拿不到主线程的 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 注解的底层,就是切面调用了这个 executeIgnoreIgnoreTenantAspect.java)——所以注解、编程式 API、上下文标记三者是同一套机制,不会互相打架。

2. 严格模式:宁可报错,不可串户

如果某次请求拿不到租户 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 期,评论区点最高的我先拆,候选:

  • 数据权限:DataScope 怎么用 JSqlParser 在 Mapper 层改写 SQL,7 种数据范围怎么落地
  • 接口加解密:RSA 协商 + SM4/AES 会话密钥的完整链路
  • 代码生成:AI 生成配置 JSON + 模板渲染,跟 Jeecg 一键生成代码的本质区别

想看哪个,评论区扣:1 数据权限 / 2 接口加解密 / 3 代码生成


源码自取(本文所有代码片段均来自仓库,可对照核验):

对比项目


你做 SaaS 时,多租户踩过最深的坑是什么?是串户、线程丢上下文,还是第三方表报错?评论区聊聊,我会挨个回复。觉得有用求个👍收藏,横评系列持续更新。