news 2026/6/20 16:39:27

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot 多租户 SaaS 架构:独立库/Schema/字段隔离 3 种方案深度对比

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

🌐演示地址:http://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_id500+ 张表遗漏一处即漏洞

RuoYi Office 的解法是:共享表 + tenant_id 行级隔离(方案 C),配合 MyBatis-PlusTenantLineHandler自动改写 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.TenantDatabaseInterceptorMyBatis-Plus TenantLineHandler 实现
core.db.TenantBaseDO业务 DO 基类,含 tenantId 字段
core.aop.TenantIgnore方法/类/DO 级忽略租户
core.util.TenantUtilsexecute / executeIgnore 工具
config.TenantPropertiesyudao.tenant 配置绑定
config.YudaoTenantAutoConfiguration自动装配 Filter/Interceptor/AOP

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

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

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


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

3.1 TenantContextHolder 核心实现

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

publicclassTenantContextHolder{/** 当前租户编号 —— 使用 TTL 支持线程池传递 */privatestaticfinalThreadLocal<Long>TENANT_ID=newTransmittableThreadLocal<>();/** 是否忽略租户(全局表查询、回调接口等) */privatestaticfinalThreadLocal<Boolean>IGNORE=newTransmittableThreadLocal<>();publicstaticLonggetTenantId(){returnTENANT_ID.get();}publicstaticLonggetRequiredTenantId(){LongtenantId=getTenantId();if(tenantId==null){thrownewNullPointerException("TenantContextHolder 不存在租户编号!");}returntenantId;}publicstaticvoidsetTenantId(LongtenantId){TENANT_ID.set(tenantId);}publicstaticbooleanisIgnore(){returnBoolean.TRUE.equals(IGNORE.get());}publicstaticvoidclear(){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()防止线程池污染。

publicclassTenantContextWebFilterextendsOncePerRequestFilter{@OverrideprotectedvoiddoFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainchain)throwsServletException,IOException{LongtenantId=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(()->{returntenantService.getAllTenants();});
方法场景
execute(tenantId, runnable)临时切换租户执行
executeIgnore(runnable)全局表/跨租户统计
addTenantHeader(headers, tenantId)Feign RPC 传递 tenant-id

四、DB 层隔离:TenantDatabaseInterceptor

4.1 自动装配:TenantLineInnerInterceptor

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

@BeanpublicTenantLineInnerInterceptortenantLineInnerInterceptor(TenantPropertiesproperties,MybatisPlusInterceptorinterceptor){TenantLineInnerInterceptorinner=newTenantLineInnerInterceptor(newTenantDatabaseInterceptor(properties));MyBatisUtils.addInterceptor(interceptor,inner,0);returninner;}

4.2 TenantLineHandler 核心逻辑

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

publicclassTenantDatabaseInterceptorimplementsTenantLineHandler{@OverridepublicExpressiongetTenantId(){returnnewLongValue(TenantContextHolder.getRequiredTenantId());}@OverridepublicbooleanignoreTable(StringtableName){// 情况一:全局忽略(@TenantIgnore AOP 或 executeIgnore)if(TenantContextHolder.isIgnore()){returntrue;}// 情况二:配置 ignore-tables + 实体注解 + TenantBaseDO 判定tableName=SqlParserUtils.removeWrapperSymbol(tableName);Booleanignore=ignoreTables.get(tableName.toLowerCase());if(ignore==null){ignore=computeIgnoreTable(tableName);synchronized(ignoreTables){addIgnoreTable(tableName,ignore);}}returnignore;}privatebooleancomputeIgnoreTable(StringtableName){TableInfotableInfo=TableInfoHelper.getTableInfo(tableName);if(tableInfo==null){returntrue;// 非本项目表,不拦截}// 继承 TenantBaseDO → 必须拦截if(TenantBaseDO.class.isAssignableFrom(tableInfo.getEntityType())){returnfalse;}// @TenantIgnore 注解 → 忽略TenantIgnoretenantIgnore=tableInfo.getEntityType().getAnnotation(TenantIgnore.class);returntenantIgnore!=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)publicabstractclassTenantBaseDOextendsBaseDO{/** 多租户编号 */privateLongtenantId;}

接入 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@interfaceTenantIgnore{Stringenable()default"true";// 支持 Spring EL}
挂载位置效果
DO 实体类该表等价于加入 ignore-tables
Service/Controller 方法AOP 设置 ignore=true
Controller 类URL 自动加入 ignore-urls

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

5.2 application.yaml 配置

yudao:tenant:enable:trueignore-urls:-/jmreport/*# 积木报表无法带 tenant-idignore-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单体私有化零成本

十、快速体验

在线演示

  • 🌐 地址:http://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 后端https://gitcode.com/zhouzhongyan/ruoyi-office.git
GitHub 后端https://github.com/yuqing2026/ruoyi-office.git
GitCode 前端https://gitcode.com/zhouzhongyan/ruoyi-office-vben.git

常见问题(FAQ)

RuoYi Office 支持多租户吗?

支持。默认采用共享表 + tenant_id行级隔离,基于 MyBatis-PlusTenantLineHandler自动改写 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 的多租户能力?

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

📦源码仓库:GitCode | GitHub

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

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/20 13:05:04

别再死磕ATS了!手把手教你用PCIe PRS解决DMA内存页未命中问题

高性能PCIe设备开发实战&#xff1a;用PRS机制优化DMA内存访问效率 在开发高性能PCIe设备&#xff08;如GPU、DPU或智能网卡&#xff09;时&#xff0c;工程师们常常会遇到一个棘手问题&#xff1a;当设备通过ATS&#xff08;地址转换服务&#xff09;执行DMA操作时&#xff0c…

作者头像 李华
网站建设 2026/6/21 15:13:49

终极指南:如何用GetQzonehistory永久备份你的QQ空间记忆

终极指南&#xff1a;如何用GetQzonehistory永久备份你的QQ空间记忆 【免费下载链接】GetQzonehistory 获取QQ空间发布的历史说说 项目地址: https://gitcode.com/GitHub_Trending/ge/GetQzonehistory 你是否曾担心QQ空间里的珍贵记忆会随着时间流逝而消失&#xff1f;那…

作者头像 李华
网站建设 2026/6/14 6:23:04

从纠错到5G:硬判决维特比译码的‘老兵新传’与实战局限

从纠错到5G&#xff1a;硬判决维特比译码的‘老兵新传’与实战局限1970年代&#xff0c;当NASA工程师们为"旅行者号"探测器设计深空通信系统时&#xff0c;他们选择了一种名为维特比算法的译码方案。这种算法在极低信噪比环境下仍能保持惊人的纠错能力&#xff0c;最…

作者头像 李华
网站建设 2026/6/21 2:18:54

告别乱码!用Charles抓包App/小程序HTTPS数据的完整避坑指南

告别乱码&#xff01;用Charles抓包App/小程序HTTPS数据的完整避坑指南在移动应用开发与调试过程中&#xff0c;网络请求分析是不可或缺的一环。Charles作为业界知名的抓包工具&#xff0c;能够帮助开发者深入理解应用与服务器之间的数据交互。然而&#xff0c;当面对HTTPS加密…

作者头像 李华
网站建设 2026/6/21 2:13:29

告别命令行!Eclipse Git实战:从Gitee拉代码到提交推送的保姆级图文指南

Eclipse Git图形化实战&#xff1a;零命令行玩转Gitee代码协作在Java开发领域&#xff0c;Eclipse依然是许多开发者的首选IDE。虽然现代开发越来越倾向于命令行操作&#xff0c;但Eclipse内置的强大Git图形化工具能让版本控制变得直观简单。本文将带你完全通过界面操作完成从Gi…

作者头像 李华