SpringBoot 多租户 SaaS 架构:独立库/Schema/字段隔离 3 种方案深度对比

0 阅读13分钟

SpringBoot 多租户 SaaS 架构:独立库/Schema/字段隔离 3 种方案深度对比

🌐 演示地址ruoyioffice.com | 📦 源码1ruoyi-office-vben | 📦 源码2ruoyi-office | 📦 源码3ruoyi-office | 💬 微信:17156169080(备注「RuoYi Office」)

一套代码服务 N 家企业——这是 SaaS 的终极梦想,也是架构师的第一道大考:租户数据怎么隔? 独立库、独立 Schema、还是共享表加 tenant_id?三种方案各有拥趸,选型错了后期迁移成本以「月」计。本文以 RuoYi Office 在 yudao-spring-boot-starter-biz-tenant 模块中的真实生产实现为样本,对比三种隔离方案,并完整走通从 HTTP Header 到 SQL 自动改写的全链路。

multi-tenant-architecture.png

▲ 多租户全景:独立库/Schema/共享表三种方案对比、TenantLineHandler 运行链路、SQL 改写效果、yudao.tenant 配置与 Redis/MQ 扩展一图看懂

引言:多租户 SaaS 到底难在哪?

多租户(Multi-Tenancy) 是指一套应用实例同时为多个客户(租户)提供服务,且各租户数据彼此隔离、互不可见。如果你只做过单企业内部系统,可能觉得「加个 company_id 过滤」就够了——但 SaaS 场景下,几个硬核问题立刻浮现:

问题一:隔离方案选型没有银弹。金融客户要物理隔离、中小客户要低成本共享——同一套产品如何兼顾?

问题二:租户上下文如何全链路透传。HTTP 请求、异步线程、MQ 消费、Feign RPC、XXL-Job 定时任务——tenant-id 丢了就是跨租户数据泄露,这是 P0 级事故。

问题三:全局表与业务表如何区分。系统字典、OAuth 客户端、支付回调——有些表必须跨租户共享,有些必须严格隔离,硬编码 if 不可维护。

问题四:开发体验与安全性平衡。理想状态是业务开发者无感知——写普通 CRUD 就自动带租户过滤;特殊场景又能一键 @TenantIgnore

问题五:与数据权限、缓存、MQ 的叠加。租户隔离是「横向」切片,数据权限是「纵向」切片——两层拦截器顺序、Redis Key 前缀、MQ Header 都要协同。

痛点不处理的后果
选型错误后期从共享表迁独立库,停机窗口以天计
上下文丢失异步任务读到错误租户数据,合规事故
全局表误拦截登录/字典查询失败,系统不可用
手工拼 tenant_id500+ 张表遗漏一处即漏洞

RuoYi Office 的解法是:共享表 + tenant_id 行级隔离(方案 C),配合 MyBatis-Plus TenantLineHandler 自动改写 SQL、TransmittableThreadLocal 全链路透传、@TenantIgnore 声明式豁免。模块路径:yudao-framework/yudao-spring-boot-starter-biz-tenant/


一、三种隔离方案:架构对比与选型

1.1 方案 A:独立数据库(Database-per-Tenant)

每个租户拥有独立的数据库实例(或独立库名),应用层通过动态数据源路由到对应 DB。

维度评价
隔离强度★★★★★ 物理级,几乎不可能跨租户泄露
开发成本低——SQL 无需 tenant_id,与单租户代码一致
运维成本★★★★★ 高——N 租户 = N 库备份/迁移/监控
横向扩展租户级扩展,单租户可独立迁云
典型场景金融、政务、大客户私有化部署

Tenant A ──→ DB_A (ruoyi_office_a) Tenant B ──→ DB_B (ruoyi_office_b) Tenant C ──→ DB_C (ruoyi_office_c)

**优点**:合规审计友好、单租户故障不扩散、可差异化 schema 版本。  
**缺点**:连接池膨胀、Schema 变更要跑 N 遍、小租户资源浪费严重。

### 1.2 方案 B:独立 Schema(Schema-per-Tenant)

同一数据库实例,每个租户一个 SchemaPostgreSQL 原生支持;MySQL 8.0Database 模拟)。

| 维度 | 评价 |
|:---|:---|
| **隔离强度** | ★★★★ 逻辑隔离,误连 Schema 仍有风险 |
| **开发成本** | 中——需动态切换 Schema / search_path |
| **运维成本** | ★★★★ 中——备份可按 Schema,比独立库轻 |
| **典型场景** | 中型 SaaS、PostgreSQL 技术栈 |

Instance ├── schema_tenant_100 ├── schema_tenant_101 └── schema_tenant_102

**优点**:比独立库省连接、比共享表隔离强。  
**缺点**:MySQL 对 Schema 支持弱于 PG;跨租户统计报表复杂。

### 1.3 方案 C:共享表 + tenant_id(RuoYi Office 采用)

所有租户共用同一套表结构,通过 `tenant_id` 列区分数据行。MyBatis-Plus 拦截器在 SQL 层自动追加 `AND tenant_id = ?`。

| 维度 | 评价 |
|:---|:---|
| **隔离强度** | ★★★ 依赖拦截器正确性,需代码审查兜底 |
| **开发成本** | ★★ 最低——框架自动处理,业务继承 `TenantBaseDO` 即可 |
| **运维成本** | ★★ 最低——单库备份、单套迁移脚本 |
| **典型场景** | 通用企业 SaaS、500+ 表一体化平台 |

```sql
-- 业务表统一带 tenant_id
CREATE TABLE oa_car_apply (
  id          BIGINT PRIMARY KEY,
  tenant_id   BIGINT NOT NULL DEFAULT 0 COMMENT '租户编号',
  bill_code   VARCHAR(64),
  -- ...
  deleted     BIT DEFAULT 0
);

优点:开发效率最高、运维最简单、与 RuoYi Office 14 大模块天然契合。 缺点:必须保证拦截器 100% 生效;超大租户需分库分表另行规划。

1.4 三种方案综合对比表

对比项独立数据库独立 Schema共享表 tenant_id
数据隔离物理逻辑(强)逻辑(行级)
SQL 改造量切换 Schema自动追加 tenant_id
连接/资源N 倍1 实例1 实例
Schema 迁移N 次N 次1 次
跨租户报表极难较难较易(加 ignore)
RuoYi Office可扩展可扩展默认内置

结论前置:RuoYi Office 作为覆盖 OA/HRM/CRM/ERP 等 500+ 表的企业一体化平台,默认采用方案 C,在开发效率与隔离性之间取得最佳平衡;金融级物理隔离可通过动态数据源扩展为方案 A。


二、RuoYi Office 多租户模块结构

模块路径:ruoyi-office/yudao-framework/yudao-spring-boot-starter-biz-tenant/

包/类职责
core.context.TenantContextHolder租户编号 ThreadLocal 持有
core.web.TenantContextWebFilter从 Header 解析 tenant-id
core.db.TenantDatabaseInterceptorMyBatis-Plus TenantLineHandler 实现
core.db.TenantBaseDO业务 DO 基类,含 tenantId 字段
core.aop.TenantIgnore方法/类/DO 级忽略租户
core.util.TenantUtilsexecute / executeIgnore 工具
config.TenantPropertiesyudao.tenant 配置绑定
config.YudaoTenantAutoConfiguration自动装配 Filter/Interceptor/AOP

底层是框架能力,上层则在 系统管理 → 租户管理 提供了完整的运营后台: tenant-list.png

▲ 租户列表(系统管理 → 租户管理 → 租户列表):每个租户绑定套餐、联系人、账号额度与过期时间——账号额度由 system_tenant.account_count 控制开通用户数上限,过期时间到期后自动禁用登录 tenant-package-list.png

▲ 租户套餐(租户管理 → 租户套餐):把一组菜单/权限打包成套餐,新建租户时选择套餐即可批量授权,实现「不同租户开通不同功能模块」的 SaaS 分版能力


三、租户上下文:TransmittableThreadLocal 全链路透传

3.1 TenantContextHolder 核心实现

TenantContextHolder 是 RuoYi Office 多租户的「神经中枢」,用 TransmittableThreadLocal(TTL)而非普通 ThreadLocal,确保线程池、异步任务、MQ 消费时租户编号不丢失。

public class TenantContextHolder {

    /** 当前租户编号 —— 使用 TTL 支持线程池传递 */
    private static final ThreadLocal<Long> TENANT_ID = new TransmittableThreadLocal<>();

    /** 是否忽略租户(全局表查询、回调接口等) */
    private static final ThreadLocal<Boolean> IGNORE = new TransmittableThreadLocal<>();

    public static Long getTenantId() {
        return TENANT_ID.get();
    }

    public static Long getRequiredTenantId() {
        Long tenantId = getTenantId();
        if (tenantId == null) {
            throw new NullPointerException("TenantContextHolder 不存在租户编号!");
        }
        return tenantId;
    }

    public static void setTenantId(Long tenantId) {
        TENANT_ID.set(tenantId);
    }

    public static boolean isIgnore() {
        return Boolean.TRUE.equals(IGNORE.get());
    }

    public static void clear() {
        TENANT_ID.remove();
        IGNORE.remove();
    }
}

为什么用 TTL? 普通 ThreadLocal 在线程池复用时会被污染或丢失;Alibaba TTL 在任务提交时拷贝上下文,在 @Async、XXL-Job、MQ Consumer 场景下租户 ID 仍能正确传递。

3.2 TenantContextWebFilter:请求入口解析

每个 HTTP 请求进入时,TenantContextWebFilter 从 Header 读取 tenant-id,写入 Holder;请求结束 finallyclear() 防止线程池污染。

public class TenantContextWebFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        Long tenantId = WebFrameworkUtils.getTenantId(request);
        if (tenantId != null) {
            TenantContextHolder.setTenantId(tenantId);
        }
        try {
            chain.doFilter(request, response);
        } finally {
            TenantContextHolder.clear();
        }
    }
}

前端 Axios 拦截器会在登录后自动把当前租户 ID 写入 Header,PC 端切换租户时同步更新。

3.3 TenantUtils:编程式切换租户

跨租户运维(如 SaaS 超管查看某租户数据)、定时任务逐租户扫描,使用 TenantUtils

// 以租户 100 的身份执行逻辑
TenantUtils.execute(100L, () -> {
    carApplyService.syncData();
});

// 忽略租户,查全局表
TenantUtils.executeIgnore(() -> {
    return tenantService.getAllTenants();
});
方法场景
execute(tenantId, runnable)临时切换租户执行
executeIgnore(runnable)全局表/跨租户统计
addTenantHeader(headers, tenantId)Feign RPC 传递 tenant-id

四、DB 层隔离:TenantDatabaseInterceptor

4.1 自动装配:TenantLineInnerInterceptor

YudaoTenantAutoConfigurationTenantDatabaseInterceptor 包装为 MyBatis-Plus 的 TenantLineInnerInterceptor插入拦截器链首位(分页插件之前,MyBatis-Plus 强制要求):

@Bean
public TenantLineInnerInterceptor tenantLineInnerInterceptor(
        TenantProperties properties, MybatisPlusInterceptor interceptor) {
    TenantLineInnerInterceptor inner =
        new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(properties));
    MyBatisUtils.addInterceptor(interceptor, inner, 0);
    return inner;
}

4.2 TenantLineHandler 核心逻辑

TenantDatabaseInterceptor 实现 TenantLineHandler 接口,两件事:取租户 ID判定表是否忽略

public class TenantDatabaseInterceptor implements TenantLineHandler {

    @Override
    public Expression getTenantId() {
        return new LongValue(TenantContextHolder.getRequiredTenantId());
    }

    @Override
    public boolean ignoreTable(String tableName) {
        // 情况一:全局忽略(@TenantIgnore AOP 或 executeIgnore)
        if (TenantContextHolder.isIgnore()) {
            return true;
        }
        // 情况二:配置 ignore-tables + 实体注解 + TenantBaseDO 判定
        tableName = SqlParserUtils.removeWrapperSymbol(tableName);
        Boolean ignore = ignoreTables.get(tableName.toLowerCase());
        if (ignore == null) {
            ignore = computeIgnoreTable(tableName);
            synchronized (ignoreTables) {
                addIgnoreTable(tableName, ignore);
            }
        }
        return ignore;
    }

    private boolean computeIgnoreTable(String tableName) {
        TableInfo tableInfo = TableInfoHelper.getTableInfo(tableName);
        if (tableInfo == null) {
            return true; // 非本项目表,不拦截
        }
        // 继承 TenantBaseDO → 必须拦截
        if (TenantBaseDO.class.isAssignableFrom(tableInfo.getEntityType())) {
            return false;
        }
        // @TenantIgnore 注解 → 忽略
        TenantIgnore tenantIgnore = tableInfo.getEntityType().getAnnotation(TenantIgnore.class);
        return tenantIgnore != null;
    }
}

4.3 SQL 改写效果

原始 SQL改写后
SELECT * FROM oa_car_apply WHERE deleted=0... AND tenant_id = 100
INSERT INTO oa_car_apply (...)自动填充 tenant_id
UPDATE oa_car_apply SET ...... AND tenant_id = 100
全局表 system_tenant(@TenantIgnore)不追加条件

4.4 TenantBaseDO:业务表接入约定

所有需要租户隔离的业务 DO 继承 TenantBaseDO,自动获得 tenantId 字段并被拦截器识别:

@Data
@EqualsAndHashCode(callSuper = true)
public abstract class TenantBaseDO extends BaseDO {

    /** 多租户编号 */
    private Long tenantId;
}

接入 checklist

  • DO 继承 TenantBaseDO(而非 BaseDO
  • 数据库表有 tenant_id BIGINT NOT NULL 字段
  • 索引考虑 (tenant_id, ...) 联合索引
  • 全局共享表用 @TenantIgnore 或配置 ignore-tables

五、@TenantIgnore 与 yudao.tenant 配置

5.1 @TenantIgnore 注解

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface TenantIgnore {
    String enable() default "true"; // 支持 Spring EL
}
挂载位置效果
DO 实体类该表等价于加入 ignore-tables
Service/Controller 方法AOP 设置 ignore=true
Controller 类URL 自动加入 ignore-urls

典型场景:短信回调、支付通知、登录查租户列表、积木报表 /jmreport/*

5.2 application.yaml 配置

yudao:
  tenant:
    enable: true
    ignore-urls:
      - /jmreport/*          # 积木报表无法带 tenant-id
    ignore-visit-urls:
      - /admin-api/system/user/profile/**
      - /admin-api/system/auth/**
    ignore-tables: []        # 额外忽略表
    ignore-caches:
      - user_role_ids
      - permission_menu_ids
      - oauth_client
      - notify_template
配置项含义
enable总开关,false 关闭多租户(单体私有化常用)
ignore-urls无需 tenant-id 的 URL(Open API 回调)
ignore-visit-urls禁止跨租户访问的 URL(个人信息)
ignore-tables静态配置的忽略表
ignore-cachesRedis 缓存不加租户前缀的 cacheName

六、全链路扩展:Redis / MQ / RPC / Security

多租户不只是 DB——RuoYi Office 在以下层面同步隔离:

层面实现类机制
Redis 缓存TenantRedisCacheManagerCache Key 追加 :tenantId 后缀
Redis MQTenantRedisMessageInterceptor消息 Header 带 tenant-id
RocketMQTenantRocketMQSend/ConsumeMessageHook发送/消费 Hook
RabbitMQTenantRabbitMQMessagePostProcessorMessagePostProcessor
KafkaTenantKafkaProducerInterceptorProducer 拦截
Feign RPCTenantRequestInterceptor请求 Header 透传
SecurityTenantSecurityWebFilter校验租户合法性与套餐
定时任务TenantJobAspectXXL-Job 逐租户执行

这保证了「DB 层加了 tenant_id,缓存层却读到别的租户数据」这类隐蔽 bug 不会发生。


七、与数据权限的协同

RuoYi Office 同时内置多租户(横向隔离)和数据权限(纵向隔离)两套拦截器:

SQL 最终形态:
WHERE deleted = 0
  AND tenant_id = 100          -- 多租户拦截器
  AND (dept_id IN (10,11) OR user_id = 200)  -- 数据权限拦截器

两者独立配置、互不侵入:租户保证「企业 A 看不到企业 B」;数据权限保证「企业 A 内销售看不到别人的客户」。


八、设计决策对比:为什么 RuoYi Office 选方案 C

决策点方案 A 独立库方案 C 共享表RuoYi Office 选择
14 模块 500+ 表迁移成本极高一次建表全局生效C
中小 SaaS 租户资源浪费资源共享C
开发团队规模需 DBA 逐库运维框架自动隔离C
金融大客户合规首选可叠加独立库扩展 A

九、技术亮点总结

设计要点实现方式价值
租户上下文TransmittableThreadLocal异步/MQ/线程池不丢租户
SQL 隔离TenantLineHandler + JSqlParser业务零侵入
实体识别TenantBaseDO + @TenantIgnore声明式,可审查
请求入口TenantContextWebFilterHeader 统一解析
编程式控制TenantUtils.execute/executeIgnore运维/Job 灵活切换
配置化yudao.tenant.*回调 URL/全局表可配
全链路Redis/MQ/RPC/Security无短板隔离
可关闭enable=false单体私有化零成本

十、快速体验

在线演示

  • 🌐 地址:ruoyioffice.com/web/
  • 👤 账号:admin / admin123
  • 📍 路径:系统管理 → 租户管理 查看多租户;登录页可体验租户名切换

本地启动

# 后端(默认 yudao.tenant.enable=true)
cd W:\ruoyi-office\ruoyi-office
mvn -P boot -DskipTests spring-boot:run -pl yudao-server

# 前端
cd W:\ruoyi-office\ruoyi-office-vben
pnpm dev:antd

推荐体验流程

  1. 系统管理 → 租户管理 → 新增租户「测试企业 B」
  2. 为该租户创建管理员账号并登录
  3. 在租户 A 创建业务数据(如 OA 用车申请)
  4. 切换租户 B 登录,确认看不到租户 A 数据
  5. 查看 yudao-server 日志中 SQL 的 tenant_id 条件
  6. 阅读 yudao-spring-boot-starter-biz-tenant 源码

源码仓库

仓库地址
GitCode 后端gitcode.com/zhouzhongya…
GitHub 后端github.com/yuqing2026/…
GitCode 前端gitcode.com/zhouzhongya…

常见问题(FAQ)

RuoYi Office 支持多租户吗?

支持。默认采用共享表 + tenant_id 行级隔离,基于 MyBatis-Plus TenantLineHandler 自动改写 SQL,模块位于 yudao-spring-boot-starter-biz-tenant

三种隔离方案怎么选?

  • 独立数据库:金融/政务/大客户私有化,隔离要求最高
  • 独立 Schema:PostgreSQL 中型 SaaS
  • 共享表 tenant_id:通用企业 SaaS、快速迭代——RuoYi Office 默认

如何关闭多租户?

配置 yudao.tenant.enable=false,适用于单体私有化部署、无需 SaaS 的场景。

全局表(如字典)如何排除?

三种方式任选:@TenantIgnore 标注 DO、配置 ignore-tables、或 TenantUtils.executeIgnore(...)

异步任务会丢租户上下文吗?

不会。TenantContextHolder 使用 TransmittableThreadLocal,配合 MQ Hook 和 TenantJobAspect,全链路透传。


结语

多租户 SaaS 的架构选型没有绝对正确答案,只有与业务阶段匹配的最优解。RuoYi Office 在 500+ 表、14 大模块的一体化场景下,选择了共享表 + tenant_id + MyBatis-Plus 拦截器的方案 C——用 TenantContextHolder 管上下文、用 TenantDatabaseInterceptor 管 SQL、用 @TenantIgnore 管例外,把「租户隔离」从业务代码里彻底剥离。

这套模式同样适用于:项目管理 SaaS、HRM 云化、连锁零售多门店——任何「一套系统、多个组织」的场景。如果你正在评估 SaaS 架构或二次开发 RuoYi Office,欢迎 Star 支持,也欢迎添加微信 17156169080(备注「RuoYi Office」)交流多租户落地细节。

你们团队用的是哪种隔离方案?有没有踩过 tenant_id 遗漏的坑?欢迎在评论区讨论。


💡 想要体验 RuoYi Office 的多租户能力?

🌐 在线演示ruoyioffice.com/web/(账号 admin / admin123)

📦 源码仓库GitCode | GitHub

💬 技术咨询:添加微信 17156169080,备注「RuoYi Office」

如果觉得不错,请给个 Star 支持一下!