RuoYi-Vue/docs/dev/TENANT_GUIDE.md

33 KiB
Raw Blame History

若依多租户架构实现指南

文档概述

本文档详细介绍了若依框架的多租户SaaS架构实现方案包括设计原理、核心组件、集成步骤和最佳实践。适合希望在项目中引入多租户功能的开发者学习和参考。


第一部分:多租户架构概述

1.1 什么是多租户SaaS

多租户Multi-Tenancy 是一种软件架构模式,单一应用实例可以同时为多个租户(客户组织)提供服务,每个租户的数据相互隔离,互不可见。

典型应用场景

  • SaaS产品如理发店管理系统每个理发店是一个租户
  • 企业内部多分公司系统
  • 连锁门店管理平台
  • 培训机构多校区管理

核心优势

  • 成本低:共享服务器资源,降低运维成本
  • 易维护:统一升级,无需为每个客户单独部署
  • 易扩展:新增租户只需添加数据记录,无需改代码

1.2 三种多租户隔离模式对比

隔离模式 实现方式 数据隔离性 成本 适用场景
独立数据库 每个租户独立的数据库实例 最强 💰💰💰 最高 金融、医疗等高安全场景
共享数据库+独立Schema 同一数据库不同SchemaMySQL为database 💰💰 中等 中大型企业客户
共享数据库+共享Schema 通过tenant_id字段区分 较强 💰 最低 中小型租户,数量较多

若依采用第三种模式:共享数据库+共享Schema+tenant_id隔离

选型理由

  • 成本低廉适合中小型SaaS产品
  • 实现简单,维护方便
  • 通过拦截器实现自动过滤,业务代码无感知
  • 性能优秀,单库可支持数千租户

第二部分:核心组件实现原理

2.1 租户上下文管理TenantContext

核心设计

TenantContext 是租户ID的存储容器基于 TransmittableThreadLocal 实现。

为什么不用普通ThreadLocal

ThreadLocal TransmittableThreadLocal
只能在当前线程访问 支持父子线程传递
线程池场景下会丢失 线程池场景下自动传递
异步任务无法获取 异步任务自动继承

若依框架大量使用了 @Async 异步任务和线程池普通ThreadLocal会导致租户上下文丢失引发数据泄露风险。

关键方法

public class TenantContext {
    // 存储租户ID
    private static final TransmittableThreadLocal<Long> TENANT_ID_HOLDER = new TransmittableThreadLocal<>();

    // 存储是否忽略租户过滤(超级管理员专用)
    private static final TransmittableThreadLocal<Boolean> IGNORE_TENANT = new TransmittableThreadLocal<>();

    // 设置当前租户ID
    public static void setTenantId(Long tenantId) {
        TENANT_ID_HOLDER.set(tenantId);
    }

    // 获取当前租户ID
    public static Long getTenantId() {
        return TENANT_ID_HOLDER.get();
    }

    // 清除租户上下文(请求结束时必须调用)
    public static void clear() {
        TENANT_ID_HOLDER.remove();
        IGNORE_TENANT.remove();
    }

    // 设置忽略租户过滤(超级管理员全局模式)
    public static void setIgnore(boolean ignore) {
        IGNORE_TENANT.set(ignore);
    }

    // 是否处于全局模式
    public static boolean isIgnore() {
        Boolean ignore = IGNORE_TENANT.get();
        return ignore != null && ignore;
    }
}

文件位置ruoyi-common/src/main/java/com/ruoyi/common/core/context/TenantContext.java

生命周期管理

HTTP请求进入
  → TenantInterceptor.preHandle() 设置租户ID
  → Controller/Service处理业务从TenantContext获取租户ID
  → MyBatis拦截器自动添加tenant_id过滤
  → 请求完成
  → TenantInterceptor.afterCompletion() 清除上下文

为什么必须清除? Tomcat使用线程池处理请求如果不清除线程复用时会串用上一个请求的租户ID导致数据泄露


2.2 Web拦截器TenantInterceptor

工作流程

HTTP请求
  ↓
判断是否是忽略URL登录、验证码等
  ↓ 是 → 跳过租户拦截
  ↓ 否
从SecurityContext获取LoginUser
  ↓
判断用户类型:
  ├─ 超级管理员 → TenantContext.setIgnore(true) [全局模式]
  ├─ 普通用户有tenant_id → TenantContext.setTenantId(tenantId)
  └─ 普通用户无tenant_id → 抛出异常拒绝访问
  ↓
业务处理...
  ↓
请求完成 → afterCompletion() → TenantContext.clear()

核心逻辑代码

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    String requestUri = request.getRequestURI();

    // 忽略URL判断
    if (TenantConfig.isIgnoreUrl(requestUri)) {
        return true;
    }

    // 获取登录用户
    LoginUser loginUser = SecurityUtils.getLoginUser();

    if (loginUser != null) {
        Long userId = loginUser.getUserId();

        // 超级管理员 → 全局模式
        if (TenantConfig.isSuperAdmin(userId)) {
            TenantContext.setIgnore(true);
            log.warn("【超级管理员】用户 {} 进入全局模式", loginUser.getUsername());
        }
        // 普通用户 → 设置租户ID
        else if (loginUser.getTenantId() != null) {
            TenantContext.setTenantId(loginUser.getTenantId());
        }
        // 未分配租户 → 拒绝访问
        else {
            throw new ServiceException("用户未分配租户,请联系管理员");
        }
    }

    return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                            Object handler, Exception ex) {
    TenantContext.clear();  // 必须清除!
}

文件位置ruoyi-framework/src/main/java/com/ruoyi/framework/interceptor/TenantInterceptor.java

超级管理员机制

超级管理员特权

  • 登录后自动进入全局模式(TenantContext.setIgnore(true)
  • 可以查看和操作所有租户的数据
  • 用于系统管理、数据迁移、问题排查

安全建议

  • 超级管理员账号必须严格控制
  • 所有全局模式操作应记录审计日志
  • 生产环境建议禁用超级管理员的数据修改权限

2.3 SQL拦截器TenantSqlInterceptor

工作原理

TenantSqlInterceptor 是MyBatis插件拦截所有SQL执行自动在WHERE条件中添加 tenant_id = ? 过滤。

技术实现

  1. 拦截MyBatis的 StatementHandler.prepare() 方法
  2. 使用 JSQLParser 解析SQL语句
  3. 判断主表是否需要租户隔离
  4. 在WHERE子句中添加租户ID条件
  5. 将改写后的SQL设置回BoundSql

SQL改写示例

原始SQL

SELECT * FROM sys_user WHERE user_name = 'admin'

改写后租户ID=1000

SELECT * FROM sys_user WHERE tenant_id = 1000 AND user_name = 'admin'

UPDATE语句

-- 原始
UPDATE sys_user SET nick_name = '张三' WHERE user_id = 2

-- 改写
UPDATE sys_user SET nick_name = '张三' WHERE tenant_id = 1000 AND user_id = 2

JOIN查询自动识别表别名

-- 原始
SELECT u.*, d.dept_name
FROM sys_user u
LEFT JOIN sys_dept d ON u.dept_id = d.dept_id
WHERE u.user_name = 'admin'

-- 改写
SELECT u.*, d.dept_name
FROM sys_user u
LEFT JOIN sys_dept d ON u.dept_id = d.dept_id
WHERE u.tenant_id = 1000 AND d.tenant_id = 1000 AND u.user_name = 'admin'

智能判断机制

只拦截以下情况

  1. SQL类型SELECTUPDATEDELETEINSERT不拦截由业务代码设置
  2. 主表是租户表(配置在 TenantConfig.TENANT_TABLES 中)
  3. 非全局模式(超级管理员自动跳过)

白名单机制

  • 全局共享表不拦截(sys_menusys_dictsys_config等)
  • 日志表不拦截(sys_logininforsys_oper_log
  • 关联表不拦截(sys_user_rolesys_role_menu

核心拦截逻辑

@Override
public Object intercept(Invocation invocation) throws Throwable {
    // 获取原始SQL
    BoundSql boundSql = statementHandler.getBoundSql();
    String originalSql = boundSql.getSql();

    // 判断是否需要拦截
    if (!shouldIntercept(sqlCommandType, originalSql)) {
        return invocation.proceed();
    }

    // 超级管理员全局模式 → 跳过拦截
    if (TenantContext.isIgnore()) {
        return invocation.proceed();
    }

    // 获取租户ID
    Long tenantId = TenantContext.getTenantId();
    if (tenantId == null) {
        throw new ServiceException("租户上下文丢失,请重新登录");
    }

    // 使用JSQLParser解析并改写SQL
    String modifiedSql = processSql(originalSql, tenantId, sqlCommandType);

    // 设置改写后的SQL
    metaObject.setValue("delegate.boundSql.sql", modifiedSql);

    return invocation.proceed();
}

文件位置ruoyi-framework/src/main/java/com/ruoyi/framework/interceptor/TenantSqlInterceptor.java


2.4 登录流程集成

登录流程图

用户登录
  → 临时开启全局模式TenantContext.setIgnore(true)
  → Spring Security验证用户名密码
  → 查询sys_user表获取用户信息
  → 提取user.tenantId
  → 检查非超级管理员必须有tenantId否则拒绝登录
  → 创建LoginUser对象设置tenantId
  → 生成JWT Token包含tenantId
  → 清除全局模式TenantContext.clear()
  → 返回Token给前端

为什么登录时要开启全局模式?

这是一个 "先有鸡还是先有蛋" 的问题:

  • 用户登录时,尚未设置租户上下文
  • 但查询 sys_user 表需要知道租户ID
  • 如果不开启全局模式SQL拦截器会报错"租户上下文丢失"

解决方案:登录阶段临时开启全局模式,验证通过后立即关闭。

关键代码片段

public String login(String username, String password, String code, String uuid) {
    // 临时开启全局模式
    TenantContext.setIgnore(true);

    try {
        // 验证码校验
        validateCaptcha(username, code, uuid);

        // Spring Security认证
        Authentication authentication = authenticationManager
            .authenticate(new UsernamePasswordAuthenticationToken(username, password));

        // 获取用户信息
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        SysUser user = loginUser.getUser();

        // 提取租户ID
        Long tenantId = user.getTenantId();

        // 非超级管理员必须有租户ID
        if (!TenantConfig.isSuperAdmin(user.getUserId()) && tenantId == null) {
            throw new ServiceException("用户未分配租户,请联系管理员");
        }

        // 设置到LoginUser
        loginUser.setTenantId(tenantId);

        // 生成JWT Token
        return tokenService.createToken(loginUser);

    } finally {
        // 清除全局模式
        TenantContext.clear();
    }
}

文件位置ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/SysLoginService.java

后续请求的租户识别

用户携带Token发起请求
  → Spring Security从Token解析出LoginUser
  → TenantInterceptor从LoginUser提取tenantId
  → 设置到TenantContext
  → 后续所有SQL自动添加tenant_id过滤

2.5 数据库设计

租户主表sys_tenant

CREATE TABLE `sys_tenant` (
  `tenant_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '租户ID',
  `tenant_name` varchar(50) NOT NULL COMMENT '租户名称',
  `tenant_code` varchar(50) NOT NULL COMMENT '租户编码(唯一)',
  `contact_name` varchar(50) DEFAULT NULL COMMENT '联系人',
  `contact_phone` varchar(20) DEFAULT NULL COMMENT '联系电话',
  `expire_time` datetime DEFAULT NULL COMMENT '过期时间NULL表示永久',
  `package_id` bigint(20) DEFAULT NULL COMMENT '套餐ID预留',
  `status` char(1) NOT NULL DEFAULT '0' COMMENT '状态0正常 1停用',
  `create_time` datetime DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  PRIMARY KEY (`tenant_id`),
  UNIQUE KEY `uk_tenant_code` (`tenant_code`)
) COMMENT='租户信息表';

SQL脚本位置sql/ry_20250522_with_tenant.sql

业务表改造规范

所有需要隔离的表添加tenant_id字段

ALTER TABLE sys_user ADD COLUMN tenant_id bigint(20) DEFAULT NULL COMMENT '租户ID';
ALTER TABLE sys_dept ADD COLUMN tenant_id bigint(20) DEFAULT NULL COMMENT '租户ID';
ALTER TABLE sys_role ADD COLUMN tenant_id bigint(20) DEFAULT NULL COMMENT '租户ID';

-- 创建索引(必须!)
CREATE INDEX idx_tenant_id ON sys_user(tenant_id);
CREATE INDEX idx_tenant_id ON sys_dept(tenant_id);
CREATE INDEX idx_tenant_id ON sys_role(tenant_id);

-- 用户表特殊处理:复合索引(提升登录查询性能)
CREATE INDEX idx_tenant_user ON sys_user(tenant_id, user_name);

为什么是 DEFAULT NULL 而不是 NOT NULL

  • 超级管理员的tenant_id为NULL表示全局用户
  • 初始化数据可能没有租户ID
  • 便于数据迁移和测试

表分类清单

需要隔离的表TENANT_TABLES

若依框架表:
- sys_user          用户表
- sys_dept          部门表
- sys_role          角色表
- sys_post          岗位表
- sys_notice        通知公告表

业务表(示例):
- hl_customer       客户表
- hl_member_card    会员卡表
- hl_service        服务表
- hl_staff          员工表
- hl_order          订单表

全局共享表IGNORE_TABLES

- sys_menu          菜单表(所有租户共享菜单)
- sys_dict_type     字典类型表
- sys_dict_data     字典数据表
- sys_config        系统配置表
- sys_tenant        租户表本身
- sys_user_role     用户角色关联表
- sys_role_menu     角色菜单关联表
- sys_role_dept     角色部门关联表
- sys_logininfor    登录日志表
- sys_oper_log      操作日志表
- sys_job           定时任务表
- sys_job_log       定时任务日志表

配置位置ruoyi-common/src/main/java/com/ruoyi/common/config/TenantConfig.java


第三部分:快速集成指南

步骤1Maven依赖配置

在根pom.xml中添加依赖版本

<properties>
    <!-- 多租户相关依赖版本 -->
    <transmittable-thread-local.version>2.14.3</transmittable-thread-local.version>
    <jsqlparser.version>4.6</jsqlparser.version>
</properties>

<dependencyManagement>
    <dependencies>
        <!-- TransmittableThreadLocal支持线程池场景的上下文传递 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>transmittable-thread-local</artifactId>
            <version>${transmittable-thread-local.version}</version>
        </dependency>

        <!-- JSQLParserSQL解析器用于SQL改写 -->
        <dependency>
            <groupId>com.github.jsqlparser</groupId>
            <artifactId>jsqlparser</artifactId>
            <version>${jsqlparser.version}</version>
        </dependency>
    </dependencies>
</dependencyManagement>

ruoyi-common/pom.xml 中引入:

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>transmittable-thread-local</artifactId>
</dependency>

ruoyi-framework/pom.xml 中引入:

<dependency>
    <groupId>com.github.jsqlparser</groupId>
    <artifactId>jsqlparser</artifactId>
</dependency>

步骤2复制核心类文件

必须复制的新增文件

文件 路径 说明
TenantContext.java ruoyi-common/.../context/ 租户上下文
TenantConfig.java ruoyi-common/.../config/ 租户配置
TenantInterceptor.java ruoyi-framework/.../interceptor/ Web拦截器
TenantSqlInterceptor.java ruoyi-framework/.../interceptor/ SQL拦截器
SysTenant.java ruoyi-system/.../domain/ 租户实体
SysTenantMapper.java ruoyi-system/.../mapper/ Mapper接口
SysTenantMapper.xml ruoyi-system/.../mapper/system/ Mapper XML
ISysTenantService.java ruoyi-system/.../service/ Service接口
SysTenantServiceImpl.java ruoyi-system/.../service/impl/ Service实现
SysTenantController.java ruoyi-admin/.../controller/system/ Controller

需要修改的现有文件

1. BaseEntity.java - 添加tenantId字段

public class BaseEntity implements Serializable {
    // ... 现有字段 ...

    /** 租户ID */
    @JsonIgnore
    private Long tenantId;

    public Long getTenantId() {
        return tenantId;
    }

    public void setTenantId(Long tenantId) {
        this.tenantId = tenantId;
    }
}

2. SysUser.java - 添加tenantId字段

public class SysUser extends BaseEntity {
    // ... 现有字段 ...

    /** 租户ID */
    private Long tenantId;

    public Long getTenantId() {
        return tenantId;
    }

    public void setTenantId(Long tenantId) {
        this.tenantId = tenantId;
    }
}

3. LoginUser.java - 添加tenantId字段

public class LoginUser implements UserDetails {
    // ... 现有字段 ...

    /** 租户ID */
    private Long tenantId;

    public Long getTenantId() {
        return tenantId;
    }

    public void setTenantId(Long tenantId) {
        this.tenantId = tenantId;
    }
}

4. SysLoginService.java - 集成租户逻辑

login() 方法中添加租户处理逻辑参考2.4节)。

5. TokenService.java - Token中包含租户ID

createToken()getLoginUser() 方法中添加租户ID的存取逻辑。


步骤3注册拦截器

注册Web拦截器

ResourcesConfig.java 中注册:

@Configuration
public class ResourcesConfig implements WebMvcConfigurer {

    @Autowired
    private TenantInterceptor tenantInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 租户拦截器必须在第一位!
        registry.addInterceptor(tenantInterceptor).addPathPatterns("/**");

        // 其他拦截器...
    }
}

文件位置ruoyi-framework/src/main/java/com/ruoyi/framework/config/ResourcesConfig.java

注册MyBatis拦截器

MyBatisConfig.java 中注册:

@Configuration
public class MyBatisConfig {

    @Autowired
    private TenantSqlInterceptor tenantSqlInterceptor;

    @Bean
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
        SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
        // ... 其他配置 ...

        // 注册租户SQL拦截器
        sessionFactory.setPlugins(tenantSqlInterceptor);

        return sessionFactory.getObject();
    }
}

文件位置ruoyi-framework/src/main/java/com/ruoyi/framework/config/MyBatisConfig.java


步骤4数据库改造

执行SQL脚本

-- 1. 创建租户表
CREATE TABLE `sys_tenant` ( ... );

-- 2. 为现有表添加tenant_id字段
ALTER TABLE sys_user ADD COLUMN tenant_id bigint(20) DEFAULT NULL COMMENT '租户ID';
ALTER TABLE sys_dept ADD COLUMN tenant_id bigint(20) DEFAULT NULL COMMENT '租户ID';
ALTER TABLE sys_role ADD COLUMN tenant_id bigint(20) DEFAULT NULL COMMENT '租户ID';
ALTER TABLE sys_post ADD COLUMN tenant_id bigint(20) DEFAULT NULL COMMENT '租户ID';
ALTER TABLE sys_notice ADD COLUMN tenant_id bigint(20) DEFAULT NULL COMMENT '租户ID';

-- 3. 创建索引
CREATE INDEX idx_tenant_id ON sys_user(tenant_id);
CREATE INDEX idx_tenant_id ON sys_dept(tenant_id);
CREATE INDEX idx_tenant_id ON sys_role(tenant_id);
CREATE INDEX idx_tenant_id ON sys_post(tenant_id);
CREATE INDEX idx_tenant_id ON sys_notice(tenant_id);

-- 4. 用户表特殊优化
CREATE INDEX idx_tenant_user ON sys_user(tenant_id, user_name);

-- 5. 初始化默认租户
INSERT INTO sys_tenant (tenant_id, tenant_name, tenant_code, status, create_time)
VALUES (1, '默认租户', 'DEFAULT', '0', NOW());

-- 6. 更新现有数据关联到默认租户
UPDATE sys_user SET tenant_id = 1 WHERE user_id > 1;  -- 保留admin为NULL超级管理员
UPDATE sys_dept SET tenant_id = 1;
UPDATE sys_role SET tenant_id = 1 WHERE role_id > 1;

完整SQL脚本sql/ry_20250522_with_tenant.sql


步骤5配置租户规则

TenantConfig.java 中配置:

配置超级管理员

public static final List<Long> SUPER_ADMIN_USER_IDS = Arrays.asList(
    1L  // admin用户ID根据实际情况调整
);

配置忽略URL

public static final List<String> IGNORE_URLS = Arrays.asList(
    "/login",
    "/captchaImage",
    "/logout",
    "/register"
    // 根据项目添加其他公共接口
);

配置租户表

public static final List<String> TENANT_TABLES = Arrays.asList(
    // 若依框架表
    "sys_user",
    "sys_dept",
    "sys_role",
    "sys_post",
    "sys_notice",

    // 你的业务表(根据实际情况添加)
    "your_business_table1",
    "your_business_table2"
);

配置全局表

public static final List<String> IGNORE_TABLES = Arrays.asList(
    "sys_menu",
    "sys_dict_type",
    "sys_dict_data",
    "sys_config",
    "sys_user_role",
    "sys_role_menu",
    "sys_role_dept",
    "sys_tenant",
    "sys_logininfor",
    "sys_oper_log",
    "sys_job",
    "sys_job_log"
    // 根据项目添加其他全局表
);

第四部分:使用指南

4.1 租户管理API

创建租户

接口POST /link/tenant

请求体

{
  "tenantName": "理发店A",
  "tenantCode": "SHOP_A",
  "contactName": "张三",
  "contactPhone": "13800138000",
  "expireTime": "2026-12-31 23:59:59",
  "status": "0"
}

响应

{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "tenantId": 1001
  }
}

用户分配租户

编辑用户时,设置 tenantId 字段:

接口PUT /system/user

请求体

{
  "userId": 100,
  "userName": "shop_admin",
  "nickName": "店长",
  "tenantId": 1001,  // 分配到租户1001
  ...
}

租户状态管理

启用租户

PUT /link/tenant
{
  "tenantId": 1001,
  "status": "0"
}

停用租户(用户将无法登录):

PUT /link/tenant
{
  "tenantId": 1001,
  "status": "1"
}

4.2 开发注意事项

哪些表需要添加tenant_id

判断标准

  • 数据是否属于某个租户(客户组织)?
  • 不同租户之间是否需要隔离?

需要添加

  • 用户相关表:sys_usersys_deptsys_rolesys_post
  • 业务核心表:订单、客户、产品、服务、员工等
  • 业务关联表:订单明细、客户标签等

不需要添加

  • 系统配置表:菜单、字典、系统参数
  • 日志表:登录日志、操作日志
  • 关联表:sys_user_role通过user_id间接关联租户
  • 枚举表:性别、状态等固定选项

新增业务表规范

CREATE TABLE your_business_table (
  id bigint(20) NOT NULL AUTO_INCREMENT,
  tenant_id bigint(20) DEFAULT NULL COMMENT '租户ID',  -- 必须添加
  your_field1 varchar(50),
  ...
  PRIMARY KEY (id),
  INDEX idx_tenant_id (tenant_id)  -- 必须添加索引
) COMMENT='业务表';

然后在 TenantConfig.TENANT_TABLES 中注册表名。

特殊场景处理

场景1定时任务

定时任务无HTTP请求无法自动设置租户上下文需要手动设置

@Scheduled(cron = "0 0 2 * * ?")
public void dailyTask() {
    // 遍历所有租户
    List<SysTenant> tenants = tenantService.selectActiveTenants();

    for (SysTenant tenant : tenants) {
        try {
            // 设置租户上下文
            TenantContext.setTenantId(tenant.getTenantId());

            // 执行租户相关任务
            doBusinessLogic();

        } finally {
            // 必须清除
            TenantContext.clear();
        }
    }
}

场景2异步任务

使用 @Async 注解的异步任务,TransmittableThreadLocal 会自动传递租户上下文,无需手动处理:

@Async
public void asyncTask() {
    // TenantContext.getTenantId() 自动获取到主线程的租户ID
    Long tenantId = TenantContext.getTenantId();

    // 执行异步任务...
}

场景3跨租户查询谨慎使用

某些场景需要查询其他租户数据(如数据统计、迁移工具):

public void crossTenantQuery() {
    try {
        // 临时开启全局模式
        TenantContext.setIgnore(true);

        // 查询所有租户数据
        List<SysUser> allUsers = userMapper.selectUserList(new SysUser());

        // 处理数据...

    } finally {
        // 必须恢复租户过滤
        TenantContext.setIgnore(false);
    }
}

安全提醒:跨租户操作必须严格控制权限,并记录审计日志!


4.3 测试验证

验证租户隔离

步骤1创建2个测试租户

INSERT INTO sys_tenant (tenant_name, tenant_code, status, create_time)
VALUES
  ('租户A', 'TENANT_A', '0', NOW()),
  ('租户B', 'TENANT_B', '0', NOW());

步骤2:创建属于不同租户的用户

-- 租户A的用户
INSERT INTO sys_user (user_name, nick_name, tenant_id, password, create_time)
VALUES ('user_a', '用户A', 1001, '$2a$...', NOW());

-- 租户B的用户
INSERT INTO sys_user (user_name, nick_name, tenant_id, password, create_time)
VALUES ('user_b', '用户B', 1002, '$2a$...', NOW());

步骤3:登录验证

  • 使用 user_a 登录,查询用户列表 → 只能看到租户A的用户
  • 使用 user_b 登录,查询用户列表 → 只能看到租户B的用户
  • 使用 admin 登录(超级管理员) → 可以看到所有用户

开启SQL日志

application.yml 中配置:

logging:
  level:
    com.ruoyi.system.mapper: DEBUG  # 开启MyBatis日志

重启后控制台会输出执行的SQL

==> Preparing: SELECT * FROM sys_user WHERE tenant_id = ? AND user_name = ?
==> Parameters: 1001(Long), user_a(String)
<== Total: 1

验证SQL是否包含 tenant_id 过滤条件。

常见问题排查

问题现象 可能原因 解决方法
租户ID为空 LoginUser未设置tenantId 检查登录流程确保提取并设置租户ID
SQL未添加租户过滤 表未在TENANT_TABLES中 在TenantConfig中注册表名
看到其他租户数据 拦截器未生效 检查拦截器注册顺序
异步任务丢失租户ID 使用了普通ThreadLocal 确认使用TransmittableThreadLocal
线程池场景丢失上下文 未清除TenantContext 检查afterCompletion是否执行

第五部分:最佳实践与常见问题

5.1 安全建议

防止数据泄露

  • 强制规范:所有业务表必须添加 tenant_id 字段和索引
  • 代码审查禁止使用原生JDBC或直接拼接SQL绕过拦截器
  • 测试覆盖:编写单元测试验证租户隔离
  • 审计日志:记录超级管理员的全局模式操作
// 错误示例:绕过拦截器
jdbcTemplate.query("SELECT * FROM sys_user", ...);  // ❌

// 正确示例使用MyBatis
userMapper.selectUserList(query);  // ✅

异常处理

// 租户ID缺失时拒绝执行
public static Long getRequiredTenantId() {
    Long tenantId = getTenantId();
    if (tenantId == null && !isIgnore()) {
        log.error("【安全警告】租户上下文丢失");
        throw new ServiceException("租户上下文丢失,请重新登录");
    }
    return tenantId;
}

数据完整性检查

定期执行SQL检查是否有遗漏的数据

-- 检查是否有用户未分配租户(排除超级管理员)
SELECT * FROM sys_user WHERE tenant_id IS NULL AND user_id > 1;

-- 检查是否有租户ID不存在的数据
SELECT * FROM sys_user u
LEFT JOIN sys_tenant t ON u.tenant_id = t.tenant_id
WHERE u.tenant_id IS NOT NULL AND t.tenant_id IS NULL;

5.2 性能优化

索引设计

单列索引

CREATE INDEX idx_tenant_id ON your_table(tenant_id);

复合索引(推荐):

-- 将tenant_id放在第一位
CREATE INDEX idx_tenant_xxx ON your_table(tenant_id, frequently_queried_field);

-- 示例:用户表登录查询
CREATE INDEX idx_tenant_user ON sys_user(tenant_id, user_name);

-- 示例:订单表按时间查询
CREATE INDEX idx_tenant_time ON hl_order(tenant_id, create_time);

为什么要复合索引?

  • SQL查询WHERE tenant_id = 1001 AND user_name = 'admin'
  • 单列索引先用tenant_id索引再回表过滤user_name
  • 复合索引:直接定位到结果,无需回表

大数据量场景

场景单个租户数据量超过500万

优化方案

  1. 分表策略按租户ID分表hl_order_1001hl_order_1002
  2. 冷热分离:历史数据归档到历史表
  3. 读写分离:租户表使用独立的从库
// 动态表名示例
String tableName = "hl_order_" + tenantId;
orderMapper.selectFromTable(tableName, query);

缓存策略

Redis缓存Key设计

// 错误未包含租户ID不同租户会冲突
String cacheKey = "user:" + userId;  // ❌

// 正确包含租户ID
String cacheKey = "tenant:" + tenantId + ":user:" + userId;  // ✅

// 或使用前缀
String cacheKey = "T" + tenantId + ":user:" + userId;  // ✅

缓存清除

@CacheEvict(value = "user", key = "'T' + #user.tenantId + ':' + #user.userId")
public void updateUser(SysUser user) {
    userMapper.updateUser(user);
}

5.3 常见问题FAQ

Q1: TransmittableThreadLocal vs ThreadLocal

特性 ThreadLocal TransmittableThreadLocal
线程内访问
父子线程传递
线程池场景 会丢失 自动传递
性能开销 稍高(可接受)

结论若依框架使用了大量异步任务和线程池必须使用TransmittableThreadLocal。

Q2: 为什么登录时要临时开启全局模式?

问题:用户登录时尚未设置租户上下文,但查询 sys_user 需要租户ID。

解决方案:登录阶段临时开启全局模式,验证通过后立即关闭。

TenantContext.setIgnore(true);  // 开启全局模式
try {
    // 查询用户、验证密码...
} finally {
    TenantContext.clear();  // 清除全局模式
}

Q3: JOIN查询如何处理

TenantSqlInterceptor 使用JSQLParser解析SQL自动识别JOIN中的每个表并为租户表添加过滤条件。

-- 原始SQL
SELECT u.*, d.dept_name FROM sys_user u LEFT JOIN sys_dept d ON u.dept_id = d.dept_id

-- 自动改写
SELECT u.*, d.dept_name
FROM sys_user u LEFT JOIN sys_dept d ON u.dept_id = d.dept_id
WHERE u.tenant_id = 1001 AND d.tenant_id = 1001

注意确保JOIN的两个表都有 tenant_id 字段且都在 TENANT_TABLES 中。

Q4: 如何支持租户级别的配置?

方案1:租户配置表

CREATE TABLE sys_tenant_config (
  id bigint(20) PRIMARY KEY,
  tenant_id bigint(20) NOT NULL,
  config_key varchar(50),
  config_value varchar(500),
  INDEX idx_tenant_key (tenant_id, config_key)
);

方案2:扩展租户表字段

ALTER TABLE sys_tenant ADD COLUMN config_json TEXT COMMENT '租户配置JSON';

Q5: 租户数据如何迁移和备份?

导出单个租户数据

mysqldump -u root -p your_database \
  --where="tenant_id=1001" \
  sys_user sys_dept sys_role hl_order > tenant_1001_backup.sql

导入到新租户

-- 1. 创建新租户
INSERT INTO sys_tenant (...) VALUES (...);  -- 获得新租户ID 1002

-- 2. 导入数据并替换租户ID
-- 使用脚本批量替换 tenant_id=1001 为 tenant_id=1002

总结

若依多租户架构采用 共享数据库+共享Schema+tenant_id隔离 模式,通过 双层拦截机制Web拦截器+SQL拦截器实现自动数据隔离业务代码无需感知租户逻辑。

核心优势

  • 成本低,维护简单
  • 透明化隔离,业务代码无侵入
  • 性能优秀,单库可支持数千租户
  • 扩展性强,支持租户级配置和定制

关键设计

  • TenantContext基于TransmittableThreadLocal的租户上下文
  • TenantInterceptorWeb层识别租户并设置上下文
  • TenantSqlInterceptorMyBatis层自动改写SQL添加tenant_id过滤
  • 超级管理员机制:支持全局模式管理所有租户数据

安全提醒

  • 所有业务表必须添加 tenant_id 字段和索引
  • 避免使用原生SQL绕过拦截器
  • 超级管理员操作必须记录审计日志
  • 定期检查数据完整性

参考资料

  • 源码位置:ruoyi-commonruoyi-frameworkruoyi-system 模块
  • SQL脚本sql/ry_20250522_with_tenant.sql
  • 提交记录:cc618482 feat(tenant): 实现多租户数据隔离功能

文档版本v1.0 最后更新2025-12-19 维护者HairLink开发团队