SpringBoot 多租户 SaaS 架构独立库/Schema/字段隔离 3 种方案深度对比演示地址http://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 自动改写的全链路。▲ 多租户全景独立库/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独立 SchemaSchema-per-Tenant 同一数据库实例每个租户一个 SchemaPostgreSQL 原生支持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_idRuoYi 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 次跨租户报表极难较难较易加 ignoreRuoYi 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-idcore.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 多租户的「神经中枢」用TransmittableThreadLocalTTL而非普通ThreadLocal确保线程池、异步任务、MQ 消费时租户编号不丢失。publicclassTenantContextHolder{/** 当前租户编号 —— 使用 TTL 支持线程池传递 */privatestaticfinalThreadLocalLongTENANT_IDnewTransmittableThreadLocal();/** 是否忽略租户全局表查询、回调接口等 */privatestaticfinalThreadLocalBooleanIGNOREnewTransmittableThreadLocal();publicstaticLonggetTenantId(){returnTENANT_ID.get();}publicstaticLonggetRequiredTenantId(){LongtenantIdgetTenantId();if(tenantIdnull){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请求结束finally中clear()防止线程池污染。publicclassTenantContextWebFilterextendsOncePerRequestFilter{OverrideprotectedvoiddoFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainchain)throwsServletException,IOException{LongtenantIdWebFrameworkUtils.getTenantId(request);if(tenantId!null){TenantContextHolder.setTenantId(tenantId);}try{chain.doFilter(request,response);}finally{TenantContextHolder.clear();}}}前端 Axios 拦截器会在登录后自动把当前租户 ID 写入 HeaderPC 端切换租户时同步更新。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 层隔离TenantDatabaseInterceptor4.1 自动装配TenantLineInnerInterceptorYudaoTenantAutoConfiguration将TenantDatabaseInterceptor包装为 MyBatis-Plus 的TenantLineInnerInterceptor插入拦截器链首位分页插件之前MyBatis-Plus 强制要求BeanpublicTenantLineInnerInterceptortenantLineInnerInterceptor(TenantPropertiesproperties,MybatisPlusInterceptorinterceptor){TenantLineInnerInterceptorinnernewTenantLineInnerInterceptor(newTenantDatabaseInterceptor(properties));MyBatisUtils.addInterceptor(interceptor,inner,0);returninner;}4.2 TenantLineHandler 核心逻辑TenantDatabaseInterceptor实现TenantLineHandler接口两件事取租户 ID和判定表是否忽略。publicclassTenantDatabaseInterceptorimplementsTenantLineHandler{OverridepublicExpressiongetTenantId(){returnnewLongValue(TenantContextHolder.getRequiredTenantId());}OverridepublicbooleanignoreTable(StringtableName){// 情况一全局忽略TenantIgnore AOP 或 executeIgnoreif(TenantContextHolder.isIgnore()){returntrue;}// 情况二配置 ignore-tables 实体注解 TenantBaseDO 判定tableNameSqlParserUtils.removeWrapperSymbol(tableName);BooleanignoreignoreTables.get(tableName.toLowerCase());if(ignorenull){ignorecomputeIgnoreTable(tableName);synchronized(ignoreTables){addIgnoreTable(tableName,ignore);}}returnignore;}privatebooleancomputeIgnoreTable(StringtableName){TableInfotableInfoTableInfoHelper.getTableInfo(tableName);if(tableInfonull){returntrue;// 非本项目表不拦截}// 继承 TenantBaseDO → 必须拦截if(TenantBaseDO.class.isAssignableFrom(tableInfo.getEntityType())){returnfalse;}// TenantIgnore 注解 → 忽略TenantIgnoretenantIgnoretableInfo.getEntityType().getAnnotation(TenantIgnore.class);returntenantIgnore!null;}}4.3 SQL 改写效果原始 SQL改写后SELECT * FROM oa_car_apply WHERE deleted0... AND tenant_id 100INSERT INTO oa_car_apply (...)自动填充tenant_id列UPDATE oa_car_apply SET ...... AND tenant_id 100全局表system_tenantTenantIgnore不追加条件4.4 TenantBaseDO业务表接入约定所有需要租户隔离的业务 DO 继承TenantBaseDO自动获得tenantId字段并被拦截器识别DataEqualsAndHashCode(callSupertrue)publicabstractclassTenantBaseDOextendsBaseDO{/** 多租户编号 */privateLongtenantId;}接入 checklistDO 继承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)publicinterfaceTenantIgnore{Stringenable()defaulttrue;// 支持 Spring EL}挂载位置效果DO 实体类该表等价于加入 ignore-tablesService/Controller 方法AOP 设置 ignoretrueController 类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 的 URLOpen 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-idRocketMQTenantRocketMQSend/ConsumeMessageHook发送/消费 HookRabbitMQTenantRabbitMQMessagePostProcessorMessagePostProcessorKafkaTenantKafkaProducerInterceptorProducer 拦截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无短板隔离可关闭enablefalse单体私有化零成本十、快速体验在线演示 地址http://ruoyioffice.com/web/ 账号admin/admin123 路径系统管理 → 租户管理查看多租户登录页可体验租户名切换本地启动# 后端默认 yudao.tenant.enabletruecd 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 后端https://gitcode.com/zhouzhongyan/ruoyi-office.gitGitHub 后端https://github.com/yuqing2026/ruoyi-office.gitGitCode 前端https://gitcode.com/zhouzhongyan/ruoyi-office-vben.git常见问题FAQRuoYi Office 支持多租户吗支持。默认采用共享表 tenant_id行级隔离基于 MyBatis-PlusTenantLineHandler自动改写 SQL模块位于yudao-spring-boot-starter-biz-tenant。三种隔离方案怎么选独立数据库金融/政务/大客户私有化隔离要求最高独立 SchemaPostgreSQL 中型 SaaS共享表 tenant_id通用企业 SaaS、快速迭代——RuoYi Office 默认如何关闭多租户配置yudao.tenant.enablefalse适用于单体私有化部署、无需 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 支持一下
SpringBoot 多租户 SaaS 架构:独立库/Schema/字段隔离 3 种方案深度对比
SpringBoot 多租户 SaaS 架构独立库/Schema/字段隔离 3 种方案深度对比演示地址http://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 自动改写的全链路。▲ 多租户全景独立库/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独立 SchemaSchema-per-Tenant 同一数据库实例每个租户一个 SchemaPostgreSQL 原生支持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_idRuoYi 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 次跨租户报表极难较难较易加 ignoreRuoYi 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-idcore.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 多租户的「神经中枢」用TransmittableThreadLocalTTL而非普通ThreadLocal确保线程池、异步任务、MQ 消费时租户编号不丢失。publicclassTenantContextHolder{/** 当前租户编号 —— 使用 TTL 支持线程池传递 */privatestaticfinalThreadLocalLongTENANT_IDnewTransmittableThreadLocal();/** 是否忽略租户全局表查询、回调接口等 */privatestaticfinalThreadLocalBooleanIGNOREnewTransmittableThreadLocal();publicstaticLonggetTenantId(){returnTENANT_ID.get();}publicstaticLonggetRequiredTenantId(){LongtenantIdgetTenantId();if(tenantIdnull){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请求结束finally中clear()防止线程池污染。publicclassTenantContextWebFilterextendsOncePerRequestFilter{OverrideprotectedvoiddoFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainchain)throwsServletException,IOException{LongtenantIdWebFrameworkUtils.getTenantId(request);if(tenantId!null){TenantContextHolder.setTenantId(tenantId);}try{chain.doFilter(request,response);}finally{TenantContextHolder.clear();}}}前端 Axios 拦截器会在登录后自动把当前租户 ID 写入 HeaderPC 端切换租户时同步更新。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 层隔离TenantDatabaseInterceptor4.1 自动装配TenantLineInnerInterceptorYudaoTenantAutoConfiguration将TenantDatabaseInterceptor包装为 MyBatis-Plus 的TenantLineInnerInterceptor插入拦截器链首位分页插件之前MyBatis-Plus 强制要求BeanpublicTenantLineInnerInterceptortenantLineInnerInterceptor(TenantPropertiesproperties,MybatisPlusInterceptorinterceptor){TenantLineInnerInterceptorinnernewTenantLineInnerInterceptor(newTenantDatabaseInterceptor(properties));MyBatisUtils.addInterceptor(interceptor,inner,0);returninner;}4.2 TenantLineHandler 核心逻辑TenantDatabaseInterceptor实现TenantLineHandler接口两件事取租户 ID和判定表是否忽略。publicclassTenantDatabaseInterceptorimplementsTenantLineHandler{OverridepublicExpressiongetTenantId(){returnnewLongValue(TenantContextHolder.getRequiredTenantId());}OverridepublicbooleanignoreTable(StringtableName){// 情况一全局忽略TenantIgnore AOP 或 executeIgnoreif(TenantContextHolder.isIgnore()){returntrue;}// 情况二配置 ignore-tables 实体注解 TenantBaseDO 判定tableNameSqlParserUtils.removeWrapperSymbol(tableName);BooleanignoreignoreTables.get(tableName.toLowerCase());if(ignorenull){ignorecomputeIgnoreTable(tableName);synchronized(ignoreTables){addIgnoreTable(tableName,ignore);}}returnignore;}privatebooleancomputeIgnoreTable(StringtableName){TableInfotableInfoTableInfoHelper.getTableInfo(tableName);if(tableInfonull){returntrue;// 非本项目表不拦截}// 继承 TenantBaseDO → 必须拦截if(TenantBaseDO.class.isAssignableFrom(tableInfo.getEntityType())){returnfalse;}// TenantIgnore 注解 → 忽略TenantIgnoretenantIgnoretableInfo.getEntityType().getAnnotation(TenantIgnore.class);returntenantIgnore!null;}}4.3 SQL 改写效果原始 SQL改写后SELECT * FROM oa_car_apply WHERE deleted0... AND tenant_id 100INSERT INTO oa_car_apply (...)自动填充tenant_id列UPDATE oa_car_apply SET ...... AND tenant_id 100全局表system_tenantTenantIgnore不追加条件4.4 TenantBaseDO业务表接入约定所有需要租户隔离的业务 DO 继承TenantBaseDO自动获得tenantId字段并被拦截器识别DataEqualsAndHashCode(callSupertrue)publicabstractclassTenantBaseDOextendsBaseDO{/** 多租户编号 */privateLongtenantId;}接入 checklistDO 继承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)publicinterfaceTenantIgnore{Stringenable()defaulttrue;// 支持 Spring EL}挂载位置效果DO 实体类该表等价于加入 ignore-tablesService/Controller 方法AOP 设置 ignoretrueController 类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 的 URLOpen 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-idRocketMQTenantRocketMQSend/ConsumeMessageHook发送/消费 HookRabbitMQTenantRabbitMQMessagePostProcessorMessagePostProcessorKafkaTenantKafkaProducerInterceptorProducer 拦截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无短板隔离可关闭enablefalse单体私有化零成本十、快速体验在线演示 地址http://ruoyioffice.com/web/ 账号admin/admin123 路径系统管理 → 租户管理查看多租户登录页可体验租户名切换本地启动# 后端默认 yudao.tenant.enabletruecd 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 后端https://gitcode.com/zhouzhongyan/ruoyi-office.gitGitHub 后端https://github.com/yuqing2026/ruoyi-office.gitGitCode 前端https://gitcode.com/zhouzhongyan/ruoyi-office-vben.git常见问题FAQRuoYi Office 支持多租户吗支持。默认采用共享表 tenant_id行级隔离基于 MyBatis-PlusTenantLineHandler自动改写 SQL模块位于yudao-spring-boot-starter-biz-tenant。三种隔离方案怎么选独立数据库金融/政务/大客户私有化隔离要求最高独立 SchemaPostgreSQL 中型 SaaS共享表 tenant_id通用企业 SaaS、快速迭代——RuoYi Office 默认如何关闭多租户配置yudao.tenant.enablefalse适用于单体私有化部署、无需 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 支持一下