资讯详情

Ruoyi-Cloud 改造 SaaS 多租户:链路、隔离与生命周期实践

📅 2026/9/17 16:58:33 | 华诺云谱 👁 阅读
Ruoyi-Cloud 改造 SaaS 多租户:链路、隔离与生命周期实践
简介这是一套基于 RuoYi-Cloud 二次开发的多租户 SaaS 后台管理框架面向中小企业与 Java 全栈开发者旨在精简脚手架、降低集成选型成本快速搭建可扩展的微服务项目。资源包共 952 个文件、约 6.19MB以 378 个 Java 源文件、156 个 JavaScript、99 个 Vue 组件和 54 个 XML 配置为主体辅以 Dockerfile、YML、SQL 等部署与初始化脚本较完整覆盖前后端分离开发、容器化部署与基础数据初始化。目前已有 1623 人学习下载。从中可了解多租户数据隔离、网关鉴权、模块化拆分等实现思路并借助 run 系列批处理脚本快速启动网关、认证及业务模块适合正在做 SaaS 化改造或需要快速搭建微服务中后台的开发者参考。1. 拿 Ruoyi-Cloud 改造成 SaaS 多租户先想清楚这三件事做项目交付的团队最容易遇到这个场景客户一开始给单机授权客户多了以后每次都要现场部署、数据库全部分开、客户各自维护账号体系最后连代码版本都分叉。这时候往往不是推倒重写而是把现成的 Ruoyi-Cloud 微服务框架改成一套「一套代码、N 个租户」的 SaaS 底座。改造的关键不在业务代码而在三个决策点租户身份在调用链里怎么传递、数据按行隔离还是按库隔离、系统权限与租户数据权限如何共存。下面按一条实际落地路线来讲先补租户链路再做隔离选型最后把生命周期、缓存和排错兜住。适合已经有 Ruoyi-Cloud 业务代码、准备升成多租户 SaaS 的团队也适合正在做 SaaS 框架选型的人做对照。2. 改造前先补租户链路登录、网关转发与 MyBatis Plus 行级隔离2.1 登录态里带上租户 IDToken 的 claim 设计与网关透传Ruoyi-Cloud 的登录流程是常见的 Spring Security OAuth2 模式用户名密码交给认证服务生成 Token 后把登录用户信息放 Redis后续服务拿 Token 换用户信息。改造第一步是让登录用户对象里显式长出tenantId字段并且在认证成功前把它填进去。常见做法是在认证服务里注入租户查询服务登录表单右侧新增一个tenantCode// 常见的写法是在认证服务里注入租户查询服务 Autowired private ISysTenantService sysTenantService; // 登录表单里新增 tenantCode认证前先查租户 LoginUser loginUser userDetailsService.loadUserByUsername(loginBody.getUsername()); SysTenant tenant sysTenantService.getOne(new LambdaQueryWrapperSysTenant() .eq(SysTenant::getTenantCode, loginBody.getTenantCode())); loginUser.setTenantId(tenant.getId());逻辑说明先按租户编码查出租户主档再把租户 ID 塞进登录用户对象。后续 Token 落 Redis 时这个对象整体被序列化于是每次按 Token 换取用户信息时都能拿回tenantId。前提是sys_tenant表已存在且登录表单接受tenantCode。不要在接受账号密码之前用请求头里的租户编码直接做鉴权容易被伪造。登录态有了之后看链路。Ruoyi-Cloud 的服务间调用走 OpenFeign而 Feign 默认不会把请求头透传给下游服务。如果订单服务里再调库存服务Token 里的租户信息就断了。我一般会在每个服务里放一个TenantContextHolder和 Feign 请求拦截器网关侧解析出租户 ID 放进X-Tenant-Id头服务间调用继续带同一个 HeaderBean public RequestInterceptor tenantHeaderInterceptor() { return requestTemplate - { String tenantId TenantContextHolder.getTenantId(); if (StringUtils.hasText(tenantId)) { requestTemplate.header(X-Tenant-Id, tenantId); } }; }有两个容易踩的地方。一是TenantContextHolder必须用 ThreadLocal 存租户 ID不能用 Bean 实例变量否则并发请求会串数据。二是异步线程池或响应式场景里 ThreadLocal 不会自动传递要手动传参。Header 名建议统一写成X-Tenant-Id不要在一个服务里叫Tenant-ID、另一个服务里叫tenant_id排查时会非常痛苦。2.2 用 TenantLineInnerInterceptor 做共享表行级隔离链路通了以后需要在 MyBatis Plus 的 SQL 层自动追加tenant_id条件。这是「共享数据库、共享 Schema、行级隔离」模式的核心。Ruoyi-Cloud 默认集成了 MyBatis Plus但租户拦截器没有配置。如果只给业务表加了tenant_id字段而不配拦截器查询会把所有租户的数据一起拉出来。常见做法是注册一个MybatisPlusInterceptorBean public MybatisPlusInterceptor tenantLineInnerInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); TenantLineInnerInterceptor tenantLine new TenantLineInnerInterceptor(new TenantLineHandler() { Override public Expression getTenantId() { // 这里必须从 TenantContextHolder 取当前租户 ID return new LongValue(Long.parseLong(TenantContextHolder.getTenantId())); } Override public boolean ignoreTable(String tableName) { // 全局表不能加租户条件否则系统管理功能会崩 return IGNORE_TABLES.contains(tableName); } }); interceptor.addInnerInterceptor(tenantLine); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }逻辑说明每当 MyBatis Plus 执行增删改查时拦截器会在 SQL 上自动追加AND tenant_id ?INSERT 语句自动补tenant_id字段。业务代码不需要每次手写租户判断。关于ignoreTable要舍得放列表。sys_config、sys_dict、sys_menu这类全局配置不属于某个租户sys_tenant、sys_tenant_package是租户元数据表也需要跳过。比较稳的配置长这样public static final ListString IGNORE_TABLES Arrays.asList( sys_menu, sys_dict, sys_config, sys_job, sys_job_log, sys_tenant, sys_tenant_package, sys_tenant_order );注意sys_user是否要 ignore 取决于产品定义。若依的单租户体系里sys_user是系统表但在多租户下用户又必须按租户隔离。我的建议是租户管理员和平台管理员都放sys_user靠tenant_id区分所以sys_user不能 ignore但平台自身的超级管理员不应污染业务表所以要在拦截器里单独判断「当前请求是否为平台管理员」。如果觉得这个判断太重就把用户表拆成sys_platform_user和sys_tenant_user两张表后一种方案在多租户产品里更常见后期统计套餐人数也更顺。2.3 定时任务和 MQ 消费里的租户上下文恢复上面所有拦截器都依赖TenantContextHolder但有一个场景没有 HTTP 请求定时任务。Ruoyi-Cloud 的分布式任务由调度中心回调执行器调用过程里没有用户 Token。如果不处理任务执行时TenantContextHolder为空MyBatis 拦截器拿不到租户 ID业务表的数据会被全部查出或全部查不到。常见解法是把租户 ID 作为任务参数传给 JobHandler在执行方法第一行恢复上下文public void execute(String tenantId) { try { TenantContextHolder.setTenantId(tenantId); // 原有业务代码可直接使用 mapper 查询 orderService.statisticsToday(tenantId); } finally { TenantContextHolder.clear(); } }MQ 消费端同理消息体带tenantId消费时先 set 再执行业务finally 里 clear。clear 必须放 finally因为 ThreadLocal 在线程池里复用不清理的话下一个任务会继承上一个租户 ID出现「租户 A 的任务把数据写进租户 B 的月表」这种极难排查的问题。建议在开发期就写一个公共的TenantTaskTemplate统一包 try-finally避免每个 JobHandler 复制粘贴漏掉清理。3. 数据隔离选型与动态数据源路由从共享库到独立库3.1 三种隔离模式的适用边界行级隔离不是唯一答案。SaaS 产品在规划期就要在三种数据隔离模式里做取舍共享表行级隔离共享数据库隔离 Schema租户独立数据库。三者的隔离强度、运维成本和改造量差异很大列个实际决策表模式隔离强度运维成本Ruoyi-Cloud 改造量适用场景共享表 tenant_id最弱靠 SQL 层强制过滤最低一套环境低配置拦截器即可租户几十到几百数据总量可控工具型 SaaS共享实例 独立 Schema较强物理隔离中备份要按 Schema 处理中需动态数据源或按库前缀切换租户几千要求数据可分可迁移独立实例 / 独立数据库最强高每个库都要监控与备份高需要路由与注册表金融医疗等强合规场景反向提示不要为了「听起来高级」把租户全部上独立库。独立库在报表统计时要跨库汇总每次迭代都要同步所有库的表结构会拖慢研发速度。比较稳的做法是先共享表在sys_tenant里预留db_key字段将来某个大租户要升级为独立库时把该租户的db_key填上数据路由层自动切走。这个「先共享、后升级」的架构比一开始就把所有租户丢进独立库更可控。3.2 基于 AbstractRoutingDataSource 的租户路由实现如果决定走「部分租户独立库」路径底层数据源路由一般用 Spring 自带的AbstractRoutingDataSource。它在每次获取数据库连接前调用determineCurrentLookupKey()返回值决定用哪个 DataSource。实现直接Slf4j public class TenantDataSourceRouter extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { String dbKey TenantContextHolder.getDataSourceKey(); // 没配置独立库的租户命中默认数据源 if (!StringUtils.hasText(dbKey)) { return master; } return dbKey; } }配置数据源时把 master 设为默认数据源其余独立租户库以租户编码为 key 注册Bean public DataSource routingDataSource() { TenantDataSourceRouter router new TenantDataSourceRouter(); MapObject, Object targetDataSources new HashMap(); targetDataSources.put(master, masterDataSource); targetDataSources.put(tenant_code_1003, tenant1003DataSource); router.setDefaultTargetDataSource(masterDataSource); router.setTargetDataSources(targetDataSources); return router; }三个参数要注意targetDataSources里的 key 必须和sys_tenant.db_key完全一致defaultTargetDataSource指向的库仍然要装有 Ruoyi-Cloud 的基础表和sys_tenant元数据表这个路由器和 MyBatis 租户拦截器要协同工作当租户的db_key有值时行级拦截器就可以跳过该租户否则同一张业务表会被加两次条件。开启事务之后再切数据源是动态数据源最常见的坑Transactional会先拿到连接再切数据源时连接已经不是目标库。如果需要独立库路由和事务共存用编程式事务而不是在方法上直接标注Transactional或者把租户切换动作放到事务方法外的入口处。3.3 租户、套餐、订单三张核心表怎么建模多租户框架的根基数据模型核心是三张表租户表、套餐表、租户订单表。租户表存主档和路由信息CREATE TABLE sys_tenant ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 租户ID, tenant_code VARCHAR(32) NOT NULL COMMENT 登录租户编码, tenant_name VARCHAR(64) NOT NULL, db_key VARCHAR(64) DEFAULT NULL COMMENT 独立数据库时对应的数据源key, schema_name VARCHAR(64) DEFAULT NULL COMMENT 独立Schema名, package_id BIGINT NOT NULL COMMENT 当前套餐ID, expire_time DATETIME DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0试用 1正常 2禁用 3过期, contact_mobile VARCHAR(20), industry VARCHAR(64), create_time DATETIME, PRIMARY KEY (id), UNIQUE KEY uk_tenant_code (tenant_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT租户信息表;套餐表把可售卖的版本、人数上限、存储上限收拢到一个配置里CREATE TABLE sys_tenant_package ( id BIGINT NOT NULL AUTO_INCREMENT, package_code VARCHAR(32) DEFAULT NULL, package_name VARCHAR(64) DEFAULT NULL, max_user INT DEFAULT 20, max_storage_mb INT DEFAULT 1024, price DECIMAL(10,2) DEFAULT 0.00, status TINYINT DEFAULT 1, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;租户订单表用于记账和自动续费判断。三张表之间靠package_id关联即可但tenant_code必须全局唯一因为登录时要拿它定位租户。字段命名也要注意db_key只存数据源 key不存连接字符串数据库密码全部放在 Nacos 配置中心不要明文落库。4. 租户生命周期管理注册初始化、套餐到期与服务禁用4.1 租户开通的初始化步骤与幂等保障一个租户从申请到可登录中间要经历创建租户记录、创建租户管理员账号、初始化菜单和角色关系、分配套餐限额。这一串操作最怕中途失败租户记录有了但管理员没建成或者菜单关系建重复了。常见做法是把它做成带幂等键的初始化任务而不是十几个零散 SQL。步骤动作幂等键失败现场能看到的症状1插入 sys_tenant状态设为 0tenant_code租户编码重复唯一索引拒绝2建租户管理员的 sys_user(tenant_id, username)用户名相同但租户不同需要能并存3为租户绑定默认角色与菜单(tenant_id, role_id)角色 ID 全局唯一必须按租户区分4写入套餐订单刷新 Redis 租户缓存订单号订单号重复用唯一键防重5租户状态置为 1tenant_id状态变更需要和上面的步骤同事务把全部步骤放进一个大事务会导致锁范围大、日志表回滚困难。我一般把第 1 步和第 5 步单独用事务包住中间步骤靠唯一键和订单号保证重试安全。这里是最容易被忽略的点初始化过程必须幂等因为运营后台的「重新开通」按钮会重复调用同一套接口。4.2 菜单权限与数据权限的多租户收敛若依的原生权限模型是「用户-角色-菜单」这套模型对单租户没问题多租户下要警惕角色 ID。没有改造时两个租户的系统管理员的role_id可能都是 2权限缓存一旦按role_id做 key租户 A 改了菜单权限租户 B 的权限被一起刷新了。解决思路不是把role_id改成 UUID而是让角色和菜单的关联查询始终带上tenant_id。sys_role_menu表在改造时要增加tenant_id字段所有 join 查询再按它过滤。同时要区分平台管理员和租户管理员平台管理员访问的是租户列表、套餐配置这类全局功能租户管理员只能访问本租户数据。这个区别要让拦截器对平台管理员角色单独放行否则租户管理员登录后台时会查不到任何菜单。一个常见的坏做法是把「平台管理员」也塞进sys_user且tenant_id为空然后在拦截器里对所有tenant_id IS NULL的记录放行。看起来简单实际会让所有漏写tenant_id的脏数据都被当成全局数据暴露。宁可把用户拆成平台端和租户端两套体系让边界清晰。4.3 到期禁用与数据保留策略SaaS 里租户到期不能等客户自己发现必须有自动巡检。写定时任务每天扫描sys_tenant中expire_time小于当天且状态正常的数据把状态改成禁用public void disableExpiredTenant() { ListSysTenant expired sysTenantMapper.selectList(Wrappers.SysTenantlambdaQuery() .eq(SysTenant::getStatus, 1) .lt(SysTenant::getExpireTime, new Date())); for (SysTenant tenant : expired) { // 先清掉该租户的登录态缓存再改数据库状态 redisService.deleteObject(login_tokens:tenant: tenant.getTenantCode()); tenant.setStatus(2); sysTenantMapper.updateById(tenant); } }这里有个高风险点如果该租户所有用户的 Token 都缓存在 Redis逐个删除会阻塞。更稳的做法是把该租户的会话键统一按前缀删除而不是一次 scan 全库。多租户到期策略常见有三种清理缓存且禁用登录保留只读不可写保留数据全部逻辑删除。建议无论哪种都不要物理删除业务数据以便租户续费后原样恢复。5. 多租户框架上线排错上下文丢失、缓存串号与大租户慢查询5.1 日志里把 tenantId 和 traceId 打在一条上多租户系统排障最大的痛点是同一张日志表里几十个租户混在一起你拿到一条报错不知道它在为谁服务。租户上下文补完之后要马上做日志联动。用 MDC 把tenantId打进每条日志配合traceId就能靠一条 trace 串起整次请求。Component public class TenantLogFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { MDC.put(tenantId, TenantContextHolder.getTenantId()); chain.doFilter(request, response); } finally { MDC.remove(tenantId); } } }logback-spring.xml 里把 MDC 变量加入输出格式pattern%d{yyyy-MM-dd HH:mm:ss.SSS}|%X{traceId}|%X{tenantId}|%-5level|%thread|%logger{36} - %msg%n/pattern这步操作写不到两分钟但收益很大从网关入口到数据库调用每条日志都能回答「这是哪个租户、链路经过了哪些服务」。如果日志里tenantId出现空值直接从 Feign 链路或异步线程池入手查。5.2 缓存 Key 必须带租户维度CacheKey 统一封装多租户另一个高频事故是缓存串号。Redis 里登录用户缓存如果按用户名做 key当租户 A 和租户 B 都有admin账号时后登录的会覆盖前面的。同样的原理适用于所有业务缓存订单号、商品号在不同租户之间可能完全一样缓存 key 不带租户 ID就会出现租户 A 读到租户 B 的数据。推荐对所有缓存 key 做一个统一封装public class CacheKeyBuilder { public static String build(String tenantId, String module, String business, String id) { return String.format(saas:%s:%s:%s:%s, tenantId, module, business, id); } }固定把tenantId放在 key 的第二段线上 grep 时一眼能看出某个租户的缓存分布。对菜单、字典这类全局数据不走带租户 key 的路径单独设计全局 key 前缀避免和租户缓存混淆。5.3 共享表索引设计一条 explain 找出跨租户慢查行级隔离容易掩盖一个性能问题SQL 自动追加了tenant_id条件但如果业务表没有tenant_id的相关索引查询会退化成全表扫描。上线前要检查所有核心业务表的索引结构。EXPLAIN SELECT order_no, amount FROM od_order WHERE tenant_id T1003 AND create_time 2025-06-01 00:00:00 ORDER BY create_time DESC;如果 explain 结果里key为 NULL或type是 ALL说明查询没有命中租户维度索引。正确索引一般是复合索引(tenant_id, create_time)把租户列放最前。还有一个容易忽略的细节不要只建tenant_id单列索引数据量大后order by和范围查询仍然慢。如果某个租户的数据已经明显超过其它租户一个量级就该按第 3 章的方式切独立库而不是指望索引解决一切。6. 上线前用三条 SQL 验证多租户隔离边界6.1 看 MyBatis 改写后的 SQL租户条件有没有正确追加上线前别靠肉眼读代码直接看实际执行的 SQL。在 Nacos 配置中心打开 SQL 日志mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl然后随便调一个查询接口找日志里最终的 SQL 语句确认末尾自动带上了AND tenant_id 1003这样的条件。如果发现某张业务表没有追加租户条件回到ignoreTable配置里检查是不是误把它列进去了。6.2 用同一用户两种 token 做交叉验证用租户 A 的账号调一个列表接口记录其中一个订单号再换租户 B 的 token 调同一个接口搜索是否出现租户 A 的订单号。这个动作可以用 JMeter 的 CSV Data Set Config 把多租户的 token 参数化并同时开两个线程组压直接验证并发时上下文有没有串。curl -H Authorization: Bearer 租户A的token \ http://localhost:8080/order/list?pageNum1pageSize1006.3 独立库租户的数据路由正确性最后验证sys_tenant里db_key有值的大租户打开 SQL 日志访问该租户的接口确认日志里连接数据库的库名是它的独立库而不是 master。只要这三类验证通过多租户隔离链路基本就稳了。后续要再打磨的是数据库连接池隔离、分页插件顺序和任务队列的租户传递这几个位置但这三条可以让你在周五上线时睡得着觉。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。