SpringBoot 多租户 SaaS 架构:独立库/Schema/字段隔离 3 种方案深度对比
🌐 演示地址:ruoyioffice.com | 📦 源码1:ruoyi-office-vben | 📦 源码2:ruoyi-office | 📦 源码3:ruoyi-office | 💬 微信:17156169080(备注「RuoYi Office」)
一套代码服务 N 家企业——这是 SaaS 的终极梦想,也是架构师的第一道大考:租户数据怎么隔? 独立库、独立 Schema、还是共享表加
tenant_id?三种方案各有拥趸,选型错了后期迁移成本以「月」计。本文以 RuoYi Office 在yudao-spring-boot-starter-biz-tenant模块中的真实生产实现为样本,对比三种隔离方案,并完整走通从 HTTP Header 到 SQL 自动改写的全链路。
▲ 多租户全景:独立库/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_id | 500+ 张表遗漏一处即漏洞 |
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)
同一数据库实例,每个租户一个 Schema(PostgreSQL 原生支持;MySQL 8.0 用 Database 模拟)。
| 维度 | 评价 |
|:---|:---|
| **隔离强度** | ★★★★ 逻辑隔离,误连 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.TenantDatabaseInterceptor | MyBatis-Plus TenantLineHandler 实现 |
core.db.TenantBaseDO | 业务 DO 基类,含 tenantId 字段 |
core.aop.TenantIgnore | 方法/类/DO 级忽略租户 |
core.util.TenantUtils | execute / executeIgnore 工具 |
config.TenantProperties | yudao.tenant 配置绑定 |
config.YudaoTenantAutoConfiguration | 自动装配 Filter/Interceptor/AOP |
底层是框架能力,上层则在 系统管理 → 租户管理 提供了完整的运营后台:
▲ 租户列表(系统管理 → 租户管理 → 租户列表):每个租户绑定套餐、联系人、账号额度与过期时间——账号额度由 system_tenant.account_count 控制开通用户数上限,过期时间到期后自动禁用登录
▲ 租户套餐(租户管理 → 租户套餐):把一组菜单/权限打包成套餐,新建租户时选择套餐即可批量授权,实现「不同租户开通不同功能模块」的 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;请求结束 finally 中 clear() 防止线程池污染。
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
YudaoTenantAutoConfiguration 将 TenantDatabaseInterceptor 包装为 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-caches | Redis 缓存不加租户前缀的 cacheName |
六、全链路扩展:Redis / MQ / RPC / Security
多租户不只是 DB——RuoYi Office 在以下层面同步隔离:
| 层面 | 实现类 | 机制 |
|---|---|---|
| Redis 缓存 | TenantRedisCacheManager | Cache Key 追加 :tenantId 后缀 |
| Redis MQ | TenantRedisMessageInterceptor | 消息 Header 带 tenant-id |
| RocketMQ | TenantRocketMQSend/ConsumeMessageHook | 发送/消费 Hook |
| RabbitMQ | TenantRabbitMQMessagePostProcessor | MessagePostProcessor |
| Kafka | TenantKafkaProducerInterceptor | Producer 拦截 |
| Feign RPC | TenantRequestInterceptor | 请求 Header 透传 |
| Security | TenantSecurityWebFilter | 校验租户合法性与套餐 |
| 定时任务 | TenantJobAspect | XXL-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 | 声明式,可审查 |
| 请求入口 | TenantContextWebFilter | Header 统一解析 |
| 编程式控制 | 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
推荐体验流程
- 系统管理 → 租户管理 → 新增租户「测试企业 B」
- 为该租户创建管理员账号并登录
- 在租户 A 创建业务数据(如 OA 用车申请)
- 切换租户 B 登录,确认看不到租户 A 数据
- 查看
yudao-server日志中 SQL 的tenant_id条件 - 阅读
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)
💬 技术咨询:添加微信 17156169080,备注「RuoYi Office」
⭐ 如果觉得不错,请给个 Star 支持一下!