十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RuoYi-Cloud多租户改造实战:数据隔离模型与MyBatis拦截器全解析

RuoYi-Cloud多租户改造实战:数据隔离模型与MyBatis拦截器全解析 简介基于RuoYi-Cloud二次开发的多租户SaaS开发框架主要面向中小企业与个人开发者旨在精简脚手架去除繁琐的集成选项让团队能快速启动业务项目。内含378个Java后端源码、99个Vue前端页面、156个JS交互逻辑及SVG图标等覆盖前后端分离开发的完整链路同时附带SQL脚本、Nginx配置、Dockerfile与批量启动脚本便于本地调试与容器化部署。整体共952个文件压缩包仅6.19MB结构紧凑适合熟悉Spring Cloud但希望减少选型成本的开发者直接借鉴。目前已有1623人学习使用印证其实用价值。通过该项目可掌握多租户隔离方案、网关鉴权与模块化开发思路并可直接在此框架上扩展自己的业务模块节省从零搭建基础环境的时间。1. 多租户SaaS开发框架的改造难点不是框架本身而是数据边界把一个单体管理系统改造成多租户SaaS框架最容易被低估的不是接口拆分也不是前端权限而是“租户A的数据怎么保证永远到不了租户B手里”。RuoYi-Cloud本身是一套标准的微服务权限中台用户、角色、菜单、字典模块都齐全但它的数据权限模型是面向“同一组织内部不同部门”设计的并不区分租户。基于RuoYi-Cloud版本改造的多租户SaaS开发框架本质上要做三件事在登录链路里识别租户身份在数据访问层强制追加租户隔离条件在缓存与异步任务里同样守住租户边界。这篇文章按改造的推进顺序把隔离模型选型、表结构改动、MyBatis层自动拼接条件、网关透传租户ID、本地部署验证这五段讲透。适合手里已经跑着若依微服务版本、准备接SaaS客户的技术负责人。2. RuoYi-Cloud多租户改造三种隔离模型与模块落点2.1 共享表、共享Schema、独立库三种多租户隔离模型的代价多租户SaaS开发框架第一步不是写代码而是选隔离粒度。常见做法分为三种每种对应不同的成本与合规要求。隔离模型数据存放方式隔离强度运维成本适合场景共享表所有租户的数据在同一张表通过tenant_id字段区分逻辑隔离最低只需加字段与SQL条件中小SaaS应用租户数大但单租户数据量小共享Schema同一个数据库实例每个租户一个独立Schema物理隔离中等连接池与迁移工具要支持Schema切换对数据隔离有较严格要求的ToB客户独立数据库每个租户单独一个数据库实例强隔离最高需要管理多个数据源与备份策略大客户、金融医疗等高合规场景RuoYi-Cloud默认的连接方式是一个微服务对应一个数据源走共享表模式改造最快。我一般会建议把共享表作为默认方案因为若依的代码生成器生成的所有Mapper都是单表操作改造时只要让SQL统一带上tenant_id条件业务代码几乎不用动。共享Schema和独立库则要引入动态数据源切换改造深度会大很多但遇到一个租户数据量特别大、需要单独清理归档的场景时共享表支撑不了。2.2 RuoYi-Cloud里需要动刀的模块与改造清单RuoYi-Cloud微服务版按模块拆分为网关ruoyi-gateway、认证ruoyi-auth、系统服务ruoyi-system、文件服务等。多租户改造涉及到的模块虽然分散但改动点非常集中认证模块登录时从用户信息里取出租户ID写进JWT的claims。网关模块统一从Token解析租户ID以请求头X-Tenant-Id透传到下游服务。系统服务新增租户管理、租户套餐、租户菜单分配相关接口。公共安全模块提供TenantContextHolder作为当前请求租户ID的上下文容器。公共数据源模块如果是共享Schema或独立库模式需要在这里替换为动态数据源。如果只做共享表模式RuoYi-Cloud原有代码中真正被大量改动的是ruoyi-common-security和ruoyi-common-mybatis这两个公共模块。它们决定了所有业务微服务能否感知到租户所以改造顺序应该是先把这两个公共模块的依赖关系理顺再动业务模块否则会出现A服务隔离了、B服务漏掉的局面。2.3 租户主数据表sys_tenant与业务表加列RuoYi-Cloud自带sys_user、sys_role、sys_menu等标准表这些表都要新增tenant_id列。而在这些业务表之上还缺一张租户主数据表用来记录租户的基础信息与状态。CREATE TABLE sys_tenant ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 租户编号, tenant_name varchar(100) NOT NULL DEFAULT COMMENT 租户名称, package_id bigint(20) DEFAULT NULL COMMENT 套餐编号, contact_name varchar(50) DEFAULT NULL COMMENT 联系人, contact_phone varchar(20) DEFAULT NULL COMMENT 联系电话, status char(1) NOT NULL DEFAULT 0 COMMENT 状态0正常 1停用, expire_time datetime DEFAULT NULL COMMENT 到期时间, create_time datetime DEFAULT NULL COMMENT 创建时间, PRIMARY KEY (id), KEY idx_tenant_status (status) ) ENGINEInnoDB AUTO_INCREMENT1000 COMMENT租户信息表; ALTER TABLE sys_user ADD COLUMN tenant_id bigint(20) NOT NULL DEFAULT 1000 COMMENT 租户编号 AFTER user_id; ALTER TABLE sys_role ADD COLUMN tenant_id bigint(20) NOT NULL DEFAULT 1000 COMMENT 租户编号 AFTER role_id; ALTER TABLE sys_menu ADD COLUMN tenant_id bigint(20) NOT NULL DEFAULT 1000 COMMENT 租户编号 AFTER menu_id;上面这段SQL把默认租户ID设为1000本质上可以理解为一个“内置租户”用来兼容改造前已经存在的数据。新增的sys_tenant表里的package_id指向租户套餐套餐里再配置允许访问的菜单ID集合这样不同租户登录后看到的菜单入口天然不同。expire_time则是租户到期控制字段在登录时判断到期状态比在业务操作中判断更高效。3. MyBatis层的租户隔离自动拼接tenant_id与动态数据源3.1 用MyBatis拦截器重写SQL给业务查询自动加租户条件RuoYi-Cloud的Mapper基于MyBatis实现最简单的租户隔离做法是给所有业务表都加tenant_id然后在代码里手写WHERE tenant_id ?。但这样改造量完全不可控业务人员写SQL时一旦忘记加条件就是一次数据越权事故。靠谱的方案是写一个MyBatis拦截器在StatementHandler.prepare阶段解析SQL并自动追加租户条件。Component Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class TenantLineInterceptor implements Interceptor { private static final String TENANT_COLUMN tenant_id; Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler (StatementHandler) invocation.getTarget(); BoundSql boundSql statementHandler.getBoundSql(); String sql boundSql.getSql(); Long tenantId TenantContextHolder.getTenantId(); if (tenantId ! null !isIgnoreTable(sql)) { // 核心改写SQL追加租户条件 String newSql appendTenantCondition(sql, tenantId); MetaObject metaObject SystemMetaObject.forObject(boundSql); metaObject.setValue(sql, newSql); } return invocation.proceed(); } private String appendTenantCondition(String sql, Long tenantId) { try { Statement statement CCJSqlParserUtil.parse(sql); if (statement instanceof Select) { Select select (Select) statement; PlainSelect plainSelect (PlainSelect) select.getSelectBody(); String alias getTableAlias(plainSelect.getFromItem()); EqualsTo equalsTo new EqualsTo( new Column(alias TENANT_COLUMN), new LongValue(tenantId) ); if (plainSelect.getWhere() null) { plainSelect.setWhere(equalsTo); } else { plainSelect.setWhere(new AndExpression(plainSelect.getWhere(), equalsTo)); } return select.toString(); } } catch (JSQLParserException e) { // 解析失败时放弃改写保证不影响原有SQL执行 log.error(SQL解析失败跳过租户条件追加: {}, sql); } return sql; } }这段代码有几个关键设计。TenantContextHolder.getTenantId()返回当前请求上下文中解析出来的租户ID它在Filter阶段被赋值。appendTenantCondition用JSqlParser把SQL解析为语法树统一在WHERE后面追加tenant_id 租户ID。getTableAlias拿到表别名避免多表关联时产生tenant_id字段歧义比如SELECT u.* FROM sys_user u别名就是u.拼出来就是u.tenant_id 1000。isIgnoreTable用来放行不需要隔离的表常见的是sys_tenant本身、字典表、参数配置表这类所有租户共享的基础表。3.2 哪些表不能走租户过滤放行与忽略清单需要忽略租户过滤的表要单独维护一份配置。常见做法是在application-common.yml中配置一个忽略列表mybatis: tenant: ignore-tables: - sys_tenant - sys_tenant_package - sys_tenant_package_menu - sys_config - sys_dict_type - sys_dict_data判断逻辑可以做成按表名精确匹配不建议用前缀模糊匹配否则容易误伤同前缀的业务表。sys_tenant、sys_tenant_package这类表是所有租户的元数据如果加上租户过滤租户登录时连自己的租户信息都查不到。sys_config和sys_dict_data属于全局配置本来就应该对所有租户可见。除了这个清单多租户表结构上还要给表加一层约定所有业务表必须包含tenant_id字段并且在复合索引中把tenant_id放到最前面否则拦截器加了条件后SQL依然会走全表扫描数据量和租户数一上来就卡死。3.3 租户级schema切换AbstractRoutingDataSource接入共享表模式不需要换数据源真正要切换到共享Schema模式时关键改动是把ruoyi-system里的数据源替换成动态数据源。Spring抽象类AbstractRoutingDataSource可以在每一次数据库操作前根据租户ID选择目标数据源。public class TenantRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { Long tenantId TenantContextHolder.getTenantId(); return tenantId null ? default : tenant_ tenantId; } }配置动态数据源的targetDataSources时需要维护一个Map把租户ID映射到对应的DataSource对象。RuoYi-Cloud里可以用Nacos配置中心下发每个租户自己的数据库连接URL租户开通时向Nacos发布一条tenant_1001的配置同时在Map中注册对应数据源。这个模式有一个要注意的地方线程池里如果租户上下文没传递determineCurrentLookupKey()会拿到nullSQL就会落到默认数据源一旦默认库没有对应表目录会直接抛表不存在的异常。如果走这个模式线程池上下文传递是必须处理的一环。4. 租户ID从登录到链路尾端的传递Token、Header与线程池4.1 认证服务把租户ID写进JWT的claimsRuoYi-Cloud登录成功后由ruoyi-auth生成Token后续服务通过解析Token拿用户信息。多租户改造点在于登录时查sys_user表的同时要把tenant_id查出来并写进JWT的claims里。SysUser user sysUserService.selectUserByUserName(username); MapString, Object claims new HashMap(); claims.put(login_user_key, UUID.randomUUID().toString()); claims.put(tenant_id, user.getTenantId()); String token tokenService.createToken(claims);参数说明tenant_id这个claim是核心后面网关解析Token就是从这里拿租户ID。Token创建完会存一份到Redis过期时间由token模块的expireTime配置控制。这里有个容易忽略的点用户改绑租户或者租户被停用时已经签发的Token在有效期内仍然能用所以在网关判断租户状态比在认证模块判断更有效。4.2 网关在转发前注入X-Tenant-Id请求头RuoYi-Cloud的网关配置里登录接口/auth/login是不需要认证的业务接口都要经过AuthorizeFilter校验Token。改造这个过滤器的常见做法是认证通过后顺手解析出tenant_id放行请求前把它加到Header里。String tenantId claims.get(tenant_id) null ? null : claims.get(tenant_id).toString(); if (StringUtils.hasText(tenantId)) { exchange exchange.mutate() .request(r - r.header(X-Tenant-Id, tenantId)) .build(); }调用链上各个微服务接收请求后在Filter里读取X-Tenant-Id并写入TenantContextHolder。这样做的价值是业务代码里完全不需要理会租户ID是怎么来的只管从上下文取。注意网关不能直接透传客户端传过来的X-Tenant-Id必须用Token里的值覆盖掉不然客户端伪造一个Header就能访问别的租户数据。4.3 Redis缓存与异步线程的租户上下文隔离RuoYi-Cloud的缓存工具类是RedisCache默认的缓存Key是方法名加参数。如果两个租户调用同一个查询方法参数碰巧相同就会命中同一份缓存租户B拿到租户A的数据。改造方案是让缓存Key带租户前缀public static final String TENANT_KEY_PREFIX tenant:; public static String buildKey(Long tenantId, String originalKey) { return TENANT_KEY_PREFIX tenantId : originalKey; }使用Redis时还要留意若依本身封装了Cacheable注解常用做法是自定义KeyGenerator根据当前租户上下文动态拼接前缀。和Redis同样隐蔽的是异步线程问题RuoYi-Cloud里Async或ThreadPoolTaskExecutor创建的线程默认不继承父线程的ThreadLocal值于是异步方法执行时TenantContextHolder拿到空值拦截器就会跳过租户条件追加产生越权查询。解决这个问题普遍的做法是把TenantContextHolder从ThreadLocal换成TransmittableThreadLocal再给线程池配置TaskDecoratorBean(tenantThreadPoolTaskExecutor) public ThreadPoolTaskExecutor tenantThreadPoolTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(50); executor.setTaskDecorator(runnable - { Long tenantId TenantContextHolder.getTenantId(); return () - { TenantContextHolder.setTenantId(tenantId); try { runnable.run(); } finally { TenantContextHolder.clear(); } }; }); return executor; }这段配置在提交异步任务时捕获当前的租户ID子线程执行前重新设置进去执行完再清理。凡是RuoYi-Cloud中自定义线程池的地方都要照顾到包括消息通知、异步导入导出、定时任务否则租户隔离在所有异步链路上一次性失效。这不是细节问题是多租户SaaS开发框架上线前必须逐个排查的链路边界。5. 部署参数与三个排错技巧多租户框架可运行只是起点5.1 Nacos配置与本地启动一个多租户实例的最小配置RuoYi-Cloud依赖Nacos做注册与配置中心。多租户改造后配置里至少需要新增租户忽略表清单和动态线程池开关。本地最小启动顺序是先启动Nacos再启动ruoyi-gateway、ruoyi-auth、ruoyi-system一个最小可用的多租户环境就能跑起来。cd /usr/local/nacos/bin sh startup.sh -m standalone java -jar ruoyi-gateway.jar --spring.profiles.activedev java -jar ruoyi-auth.jar --spring.profiles.activedev java -jar ruoyi-system.jar --spring.profiles.activedev网关的bootstrap.yml中需要确认Nacos地址和共享配置spring: application: name: ruoyi-gateway cloud: nacos: server-addr: 127.0.0.1:8848 config: file-extension: yml shared-configs: ->mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl跑一个查询接口观察控制台输出的SQL语句末尾是否带上了AND tenant_id 1000或AND tenant_id 2000。再用数据库执行计划验证索引是否生效EXPLAIN SELECT * FROM sys_user WHERE user_id 1 AND tenant_id 2000;重点看key列是否命中了包含(tenant_id, user_id)的复合索引type列是否优于ALL。如果出现全表扫描说明索引设计没跟上租户一多接口延迟会直线上升。缓存验证则是在租户A里查询一条记录再切换到租户B查同一条记录检查Redis中实际写入的Key开头有没有tenant:2000:这样的前缀。5.3 按经验排掉三个高频坑线程池、缓存Key、未走拦截器的查询第一个坑来自定时任务。RuoYi-Cloud的定时任务很多时候在ruoyi-job模块执行这个模块没有经过网关TenantContextHolder根本没有值。处理方式是给定时任务按租户维度循环执行每次循环开始前手动TenantContextHolder.setTenantId(tenantId)。第二个坑是JPA、JDBC Template或者原生SQL直查数据库没有经过Mapper拦截器这种查询一旦夹带在其他业务逻辑里租户隔离就被绕过了。改造时要统一检查代码里JdbcTemplate的使用点要么补上手工条件要么统一切换到扫描得到的Mapper。第三个坑出在MyBatis拦截器本身JSqlParser解析INSERT和UPDATE语句时不需要追加条件但UPDATE语句如果漏了租户条件同样能把别的租户的数据改掉。在拦截器里对UPDATE语句也要做WHERE条件校验没有WHERE的UPDATE要拒绝执行或者强制加上租户条件这也是多租户SaaS开发框架能安全上线的底线性保障。本文还有配套的精品资源点击获取
返回列表