多租户 (tenant)
概述
租户插件模块(ruoyi-common-tenant)是一个企业级多租户解决方案,提供了数据库、缓存、认证等多个层面的完整租户隔离功能。该模块基于MyBatis-Plus的TenantLineInnerInterceptor实现SQL自动改写,结合Redisson的NameMapper实现Redis键前缀隔离,通过TenantSaTokenDao实现认证数据隔离,确保不同租户的数据完全隔离,避免数据泄露和混淆。
多租户架构是SaaS(Software as a Service)应用的核心技术,通过共享应用实例为多个租户提供服务,同时保证租户间数据的完全隔离。本模块采用"共享数据库、共享Schema、共享表"的轻量级多租户模式,通过在每张表中添加tenant_id字段实现逻辑隔离。
核心特性
- 数据库隔离 - 基于MyBatis-Plus TenantLineInnerInterceptor自动为SQL添加租户条件
- 缓存隔离 - Redis缓存键和Spring Cache自动添加租户前缀
- 认证隔离 - SaToken认证数据添加全局前缀,确保会话隔离
- 动态租户 - 支持运行时切换租户上下文,线程级别和全局级别
- 忽略机制 - 支持跳过租户过滤的场景,支持重入调用
- 始终生效 - 底层隔离始终启用,
tenant.enable仅控制业务功能 - 配置灵活 - 支持系统固定排除表和配置文件排除表
模块架构
目录结构
ruoyi-common-tenant/
├── src/main/java/plus/ruoyi/common/tenant/
│ ├── config/ # 配置类
│ │ └── TenantAutoConfiguration.java # 自动配置(始终启用)
│ ├── core/ # 核心组件
│ │ ├── TenantEntity.java # 多租户实体基类
│ │ └── TenantSaTokenDao.java # 多租户认证数据层
│ ├── exception/ # 异常类
│ │ └── TenantException.java # 多租户异常
│ ├── handle/ # 处理器
│ │ ├── PlusTenantLineHandler.java # SQL租户处理器
│ │ └── TenantKeyPrefixHandler.java # Redis键前缀处理器
│ ├── helper/ # 工具类
│ │ └── TenantHelper.java # 租户助手工具类
│ ├── manager/ # 管理器
│ │ └── TenantSpringCacheManager.java # 多租户缓存管理器
│ └── properties/ # 配置属性
│ └── TenantProperties.java # 租户配置属性
├── src/test/java/ # 测试类
│ └── plus/ruoyi/common/tenant/
│ └── helper/TenantHelperTest.java # TenantHelper单元测试
└── pom.xml # Maven配置依赖关系
<dependencies>
<!-- MyBatis模块 - 提供数据库访问与租户隔离(可选) -->
<dependency>
<groupId>plus.ruoyi</groupId>
<artifactId>ruoyi-common-mybatis</artifactId>
<optional>true</optional>
</dependency>
<!-- Redis模块 - 提供缓存支持 -->
<dependency>
<groupId>plus.ruoyi</groupId>
<artifactId>ruoyi-common-redis</artifactId>
</dependency>
</dependencies>架构图
┌─────────────────────────────────────────────────────────────────┐
│ Application Layer │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ TenantHelper │ │
│ │ ┌───────────┐ ┌───────────┐ ┌───────────────────┐ │ │
│ │ │ isEnable │ │ getTenantId│ │ ignore/dynamic │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ 检查开关 │ │ 获取租户ID │ │ 切换租户上下文 │ │ │
│ │ └───────────┘ └───────────┘ └───────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Isolation Layer │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Database │ │ Redis │ │ SaToken │ │
│ │ Isolation │ │ Isolation │ │ Isolation │ │
│ │ │ │ │ │ │ │
│ │PlusTenantLine │ │TenantKeyPrefix │ │TenantSaTokenDao │ │
│ │ Handler │ │ Handler │ │ │ │
│ │ │ │ │ │ │ │
│ │ SQL自动改写 │ │ Redis键前缀 │ │ 认证数据前缀 │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Storage Layer │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ MySQL │ │ Redis │ │ Redis │ │
│ │ tenant_id字段 │ │ tenant:key │ │ global::satoken │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘快速开始
1. 添加依赖
在 pom.xml 中添加依赖:
<dependency>
<groupId>plus.ruoyi</groupId>
<artifactId>ruoyi-common-tenant</artifactId>
</dependency>2. 配置文件
在 application.yml 中添加配置:
# 多租户配置
tenant:
# 是否启用多租户业务功能
enable: true
# 排除表列表(这些表不会应用租户过滤)
excludes:
- sys_dict_type
- sys_dict_data
- sys_config3. 数据库设计
在需要租户隔离的表中添加 tenant_id 字段:
CREATE TABLE user_info (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id VARCHAR(20) NOT NULL COMMENT '租户ID',
username VARCHAR(50) NOT NULL COMMENT '用户名',
email VARCHAR(100) COMMENT '邮箱',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tenant_id (tenant_id)
) COMMENT='用户信息表';4. 实体类配置
继承 TenantEntity 类:
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("user_info")
public class UserInfo extends TenantEntity {
/** 主键ID */
@TableId(type = IdType.AUTO)
private Long id;
/** 用户名 */
private String username;
/** 邮箱 */
private String email;
}自动配置详解
TenantAutoConfiguration
多租户自动配置类,配置所有租户隔离组件,所有组件始终启用以确保数据一致性:
@EnableConfigurationProperties(TenantProperties.class)
@AutoConfiguration(after = {RedisAutoConfiguration.class})
public class TenantAutoConfiguration {
// MyBatis-Plus多租户配置(始终启用)
@ConditionalOnClass(TenantLineInnerInterceptor.class)
@AutoConfiguration
static class MybatisPlusConfiguration {
@Bean
public TenantLineInnerInterceptor tenantLineInnerInterceptor(
TenantProperties tenantProperties) {
return new TenantLineInnerInterceptor(
new PlusTenantLineHandler(tenantProperties));
}
}
// Redisson多租户键前缀处理器(始终启用)
@Bean
public RedissonAutoConfigurationCustomizer tenantRedissonCustomizer(
RedissonProperties redissonProperties) {
return config -> {
TenantKeyPrefixHandler nameMapper =
new TenantKeyPrefixHandler(redissonProperties.getKeyPrefix());
// 设置单机模式或集群模式的NameMapper
// ...
};
}
// 多租户缓存管理器(始终启用)
@Primary
@Bean
public CacheManager tenantCacheManager() {
return new TenantSpringCacheManager();
}
// 多租户认证数据持久层(始终启用)
@Primary
@Bean
public SaTokenDao tenantSaTokenDao() {
return new TenantSaTokenDao();
}
}设计理念:
- 底层隔离始终生效 - 数据库、Redis、SaToken的租户隔离始终启用
- 业务开关独立控制 -
tenant.enable仅控制业务层功能 - 避免数据混乱 - 即使关闭租户功能,数据也有明确的租户归属
TenantHelper 工具类
核心方法
TenantHelper是多租户操作的核心工具类,提供租户相关的所有操作方法:
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class TenantHelper {
// 动态租户Redis键前缀
private static final String DYNAMIC_TENANT_KEY =
GlobalConstants.GLOBAL_REDIS_KEY + "dynamicTenant";
// 线程本地动态租户存储
private static final ThreadLocal<String> TEMP_DYNAMIC_TENANT =
new ThreadLocal<>();
// 租户忽略重入计数器
private static final ThreadLocal<Stack<Integer>> REENTRANT_IGNORE =
ThreadLocal.withInitial(Stack::new);
}检查租户状态
// 检查多租户功能是否启用
public static boolean isEnable() {
return Convert.toBool(SpringUtils.getProperty("tenant.enable"), false);
}获取当前租户ID
// 获取当前有效的租户ID
// 优先级:动态租户 > 登录用户租户 > 请求头租户 > 默认租户
public static String getTenantId() {
// 如果租户功能未开启,直接返回默认租户 保证数据隔离
if (!isEnable()) {
return TenantConstants.DEFAULT_TENANT_ID;
}
// 优先获取动态租户
String tenantId = getDynamic();
if (StringUtils.isBlank(tenantId)) {
// 从登录用户信息获取
tenantId = LoginHelper.getTenantId();
}
if (StringUtils.isBlank(tenantId)) {
// 从请求中获取(包含域名识别和请求头获取)
try {
tenantId = SpringUtils.getBean(TenantService.class).getTenantIdByRequest();
} catch (Exception ignored) {
// 忽略获取请求失败的异常(比如非Web环境)
}
}
return StringUtils.isNotBlank(tenantId) ? tenantId : TenantConstants.DEFAULT_TENANT_ID;
}租户ID获取优先级
┌─────────────────────────────────────────────────────────────┐
│ getTenantId() 获取优先级 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 动态租户 (TenantHelper.setDynamic) │
│ ↓ 如果为空 │
│ 2. 登录用户租户 (LoginHelper.getTenantId) │
│ ↓ 如果为空 │
│ 3. 请求租户 (TenantService.getTenantIdByRequest) │
│ - 域名识别 │
│ - 请求头 X-Tenant-Id │
│ ↓ 如果为空 │
│ 4. 默认租户 (TenantConstants.DEFAULT_TENANT_ID = "000000") │
│ │
└─────────────────────────────────────────────────────────────┘忽略租户模式
在某些场景下需要跳过租户过滤,如管理员查看所有租户数据:
// 方式一:使用Runnable(无返回值)
TenantHelper.ignore(() -> {
// 这里的数据库操作不会添加租户条件
userService.getAllUsers();
});
// 方式二:使用Supplier(有返回值)
List<User> allUsers = TenantHelper.ignore(() -> {
return userService.getAllUsers();
});忽略模式实现原理:
private static void enableIgnore() {
IgnoreStrategy ignoreStrategy = getIgnoreStrategy();
if (ObjectUtil.isNull(ignoreStrategy)) {
InterceptorIgnoreHelper.handle(
IgnoreStrategy.builder().tenantLine(true).build());
} else {
ignoreStrategy.setTenantLine(true);
}
// 重入计数 - 支持嵌套调用
Stack<Integer> reentrantStack = REENTRANT_IGNORE.get();
reentrantStack.push(reentrantStack.size() + 1);
}
private static void disableIgnore() {
IgnoreStrategy ignoreStrategy = getIgnoreStrategy();
if (ObjectUtil.isNotNull(ignoreStrategy)) {
// 检查是否还有其他忽略策略
boolean noOtherIgnoreStrategy =
!Boolean.TRUE.equals(ignoreStrategy.getDynamicTableName())
&& !Boolean.TRUE.equals(ignoreStrategy.getBlockAttack())
&& !Boolean.TRUE.equals(ignoreStrategy.getIllegalSql())
&& !Boolean.TRUE.equals(ignoreStrategy.getDataPermission())
&& CollectionUtil.isEmpty(ignoreStrategy.getOthers());
Stack<Integer> reentrantStack = REENTRANT_IGNORE.get();
boolean empty = reentrantStack.isEmpty() || reentrantStack.pop() == 1;
if (noOtherIgnoreStrategy && empty) {
// 清除所有忽略策略
InterceptorIgnoreHelper.clearIgnoreStrategy();
} else if (empty) {
// 只关闭租户忽略
ignoreStrategy.setTenantLine(false);
}
}
}重入支持:
// 支持嵌套调用,只有最外层调用时才真正关闭忽略模式
TenantHelper.ignore(() -> {
// 第一层忽略
TenantHelper.ignore(() -> {
// 第二层忽略 - 仍然有效
userService.doSomething();
});
// 第二层结束,但忽略模式仍然有效
orderService.doSomething();
});
// 最外层结束,忽略模式真正关闭动态租户切换
运行时切换到指定租户执行操作:
// 方式一:使用Runnable(无返回值)
TenantHelper.dynamic("tenant123", () -> {
// 这里的操作都在tenant123租户下执行
dataService.queryData();
});
// 方式二:使用Supplier(有返回值)
List<Data> data = TenantHelper.dynamic("tenant123", () -> {
return dataService.queryData();
});
// 方式三:手动控制(线程级别)
TenantHelper.setDynamic("tenant123");
try {
// 业务代码
} finally {
TenantHelper.clearDynamic();
}
// 方式四:全局动态租户(存储到Redis,跨请求生效)
TenantHelper.setDynamic("tenant123", true); // global = true动态租户存储机制:
public static void setDynamic(String tenantId, boolean global) {
if (!isEnable()) {
return;
}
// 未登录或非全局模式,只在线程内生效
if (!LoginHelper.isLogin() || !global) {
TEMP_DYNAMIC_TENANT.set(tenantId);
return;
}
// 存储到Redis,跨请求生效(有效期10天)
String cacheKey = DYNAMIC_TENANT_KEY + ":" + LoginHelper.getUserId();
RedisUtils.setCacheObject(cacheKey, tenantId, Duration.ofDays(10));
SaHolder.getStorage().set(cacheKey, tenantId);
}数据库租户隔离
PlusTenantLineHandler
SQL租户处理器,实现MyBatis-Plus的TenantLineHandler接口:
@Slf4j
public class PlusTenantLineHandler implements TenantLineHandler {
// 系统固定排除表(硬编码,防止用户误配置)
private static final Set<String> SYSTEM_EXCLUDE_TABLES = Set.of(
"sys_tenant", // 租户表
"sys_tenant_package", // 租户套餐表
"sys_gen_table", // 代码生成表
"sys_gen_table_column", // 代码生成列
"sys_menu", // 菜单表
"sys_role_menu", // 角色菜单关联
"sys_role_dept", // 角色部门关联
"sys_user_role", // 用户角色关联
"sys_user_post", // 用户岗位关联
"sys_oss_config", // OSS配置表
"flow_spel" // 工作流SpEL表
);
private final Set<String> excludeTables;
public PlusTenantLineHandler(TenantProperties tenantProperties) {
// 合并系统排除表和配置排除表
this.excludeTables = Stream.concat(
SYSTEM_EXCLUDE_TABLES.stream(),
tenantProperties.getExcludes() != null ?
tenantProperties.getExcludes().stream() : Stream.empty()
)
.map(String::toLowerCase)
.collect(Collectors.toSet());
log.info("多租户拦截器已启用(始终生效),排除表: {}", excludeTables);
}
@Override
public Expression getTenantId() {
// 直接使用TenantHelper.getTenantId(),它已经保证返回有效值
return new StringValue(TenantHelper.getTenantId());
}
@Override
public boolean ignoreTable(String tableName) {
if (StringUtils.isBlank(tableName)) {
return true;
}
return excludeTables.contains(tableName.toLowerCase());
}
}SQL改写示例
查询语句:
-- 原始SQL
SELECT * FROM user_info WHERE status = 1
-- 改写后SQL
SELECT * FROM user_info WHERE status = 1 AND tenant_id = '000000'插入语句:
-- 原始SQL
INSERT INTO user_info (username, email) VALUES ('test', 'test@example.com')
-- 改写后SQL
INSERT INTO user_info (username, email, tenant_id)
VALUES ('test', 'test@example.com', '000000')更新语句:
-- 原始SQL
UPDATE user_info SET email = 'new@example.com' WHERE id = 1
-- 改写后SQL
UPDATE user_info SET email = 'new@example.com'
WHERE id = 1 AND tenant_id = '000000'删除语句:
-- 原始SQL
DELETE FROM user_info WHERE id = 1
-- 改写后SQL
DELETE FROM user_info WHERE id = 1 AND tenant_id = '000000'排除表配置
tenant:
enable: true
excludes:
# 系统配置表(全局共享)
- sys_config
# 字典表(全局共享)
- sys_dict_type
- sys_dict_data
# 地区表(全局共享)
- sys_area
# 你的自定义全局表
- your_global_table缓存租户隔离
TenantKeyPrefixHandler
Redis键前缀处理器,继承KeyPrefixHandler,为Redis键添加租户前缀:
@Slf4j
public class TenantKeyPrefixHandler extends KeyPrefixHandler {
public TenantKeyPrefixHandler(String keyPrefix) {
super(keyPrefix);
}
@Override
public String map(String name) {
if (StringUtils.isBlank(name)) {
return null;
}
try {
// 检查是否忽略租户处理
if (InterceptorIgnoreHelper.willIgnoreTenantLine("")) {
return super.map(name);
}
} catch (NoClassDefFoundError ignore) {
// 某些服务不需要MyBatis-Plus,忽略类找不到的错误
}
// 全局键不需要租户前缀
if (StringUtils.contains(name, GlobalConstants.GLOBAL_REDIS_KEY)) {
return super.map(name);
}
// 获取租户ID(永远不为null)
String tenantId = TenantHelper.getTenantId();
log.debug("Redis键添加租户前缀: {} -> {}:{}", name, tenantId, name);
// 如果已经包含租户前缀,直接返回
if (StringUtils.startsWith(name, tenantId + StringUtils.EMPTY)) {
return super.map(name);
}
// 添加租户前缀
return super.map(tenantId + ":" + name);
}
@Override
public String unmap(String name) {
// 移除租户前缀...
}
}Redis键转换示例:
原始键名 转换后键名
user:info:123 → 000000:user:info:123
order:detail:456 → tenant123:order:detail:456
global::config → global::config (全局键不转换)TenantSpringCacheManager
Spring Cache多租户缓存管理器:
@Slf4j
public class TenantSpringCacheManager extends PlusSpringCacheManager {
@Override
public Cache getCache(String name) {
// 检查是否忽略租户处理
if (InterceptorIgnoreHelper.willIgnoreTenantLine("")) {
return super.getCache(name);
}
// 全局缓存不添加租户前缀
if (StringUtils.contains(name, GlobalConstants.GLOBAL_REDIS_KEY)) {
return super.getCache(name);
}
// 获取租户ID(永远不为null)
String tenantId = TenantHelper.getTenantId();
// 如果已经包含租户前缀,直接返回
if (StringUtils.startsWith(name, tenantId)) {
return super.getCache(name);
}
// 添加租户前缀
return super.getCache(tenantId + ":" + name);
}
}Spring Cache使用示例:
@Service
public class UserService {
// 租户级缓存 - 自动添加租户前缀
@Cacheable(value = "userData", key = "#userId")
public UserData getUserData(String userId) {
// 实际缓存键:000000:userData::userId
return userMapper.selectById(userId);
}
// 全局缓存 - 使用global::前缀,不添加租户前缀
@Cacheable(value = "global::systemConfig", key = "#key")
public String getSystemConfig(String key) {
return TenantHelper.ignore(() -> {
// 实际缓存键:global::systemConfig::key
return configMapper.selectByKey(key);
});
}
}认证租户隔离
TenantSaTokenDao
SaToken认证数据多租户持久层,为所有认证数据添加全局前缀:
public class TenantSaTokenDao extends PlusSaTokenDao {
@Override
public String get(String key) {
return super.get(GlobalConstants.GLOBAL_REDIS_KEY + key);
}
@Override
public void set(String key, String value, long timeout) {
super.set(GlobalConstants.GLOBAL_REDIS_KEY + key, value, timeout);
}
@Override
public void delete(String key) {
super.delete(GlobalConstants.GLOBAL_REDIS_KEY + key);
}
@Override
public Object getObject(String key) {
return super.getObject(GlobalConstants.GLOBAL_REDIS_KEY + key);
}
@Override
public void setObject(String key, Object object, long timeout) {
super.setObject(GlobalConstants.GLOBAL_REDIS_KEY + key, object, timeout);
}
// ... 其他方法同样添加全局前缀
}认证数据存储示例:
原始键名 存储键名
satoken:login:token:xxx → global::satoken:login:token:xxx
satoken:login:session:1 → global::satoken:login:session:1TenantEntity 实体基类
类定义
@Data
@EqualsAndHashCode(callSuper = true)
public class TenantEntity extends BaseEntity {
/**
* 租户ID
* 用于标识数据属于哪个租户,实现数据隔离
*/
private String tenantId;
}使用示例
// 租户实体 - 继承TenantEntity
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("order_info")
public class OrderInfo extends TenantEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String orderNo;
private BigDecimal amount;
private Integer status;
}
// 全局实体 - 继承BaseEntity(不需要租户隔离)
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_config")
public class SysConfig extends BaseEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String configKey;
private String configValue;
}TenantException 异常类
public class TenantException extends BaseException {
@Serial
private static final long serialVersionUID = 1L;
public TenantException(String code, Object... args) {
super("tenant", code, args, null);
}
}使用示例:
if (StringUtils.isBlank(tenantId)) {
throw new TenantException("tenant.not.found");
}
if (!tenantService.isValidTenant(tenantId)) {
throw new TenantException("tenant.invalid", tenantId);
}配置属性
TenantProperties
@Data
@ConfigurationProperties(prefix = "tenant")
public class TenantProperties {
/**
* 是否启用多租户功能
*/
private Boolean enable;
/**
* 多租户排除表列表
*/
private List<String> excludes;
}完整配置示例
# 多租户配置
tenant:
# 启用多租户功能
enable: true
# 排除表配置
excludes:
# 系统配置
- sys_config
# 字典数据
- sys_dict_type
- sys_dict_data
# 公共通知
- sys_notice
# 地区数据
- sys_area租户相关常量
public interface TenantConstants {
/** 超级管理员ID */
Long SUPER_ADMIN_ID = 1L;
/** 超级管理员角色 roleKey */
String SUPER_ADMIN_ROLE_KEY = "superadmin";
/** 租户管理员角色 roleKey */
String TENANT_ADMIN_ROLE_KEY = "admin";
/** 租户管理员角色名称 */
String TENANT_ADMIN_ROLE_NAME = "管理员";
/** 默认租户ID */
String DEFAULT_TENANT_ID = "000000";
}使用场景
1. SaaS多租户应用
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
// 查询当前租户的订单 - 自动添加租户条件
public List<Order> getCurrentTenantOrders() {
return orderMapper.selectList(null);
}
// 管理员查看所有租户的订单
public List<Order> getAllTenantsOrders() {
return TenantHelper.ignore(() -> {
return orderMapper.selectList(null);
});
}
// 数据迁移:将数据从一个租户转移到另一个租户
public void migrateData(String fromTenant, String toTenant) {
// 从源租户获取数据
List<Order> orders = TenantHelper.dynamic(fromTenant, () -> {
return orderMapper.selectList(null);
});
// 写入目标租户
TenantHelper.dynamic(toTenant, () -> {
orders.forEach(order -> {
order.setId(null); // 清除ID,插入新记录
orderMapper.insert(order);
});
});
}
}2. 多租户报表系统
@Service
public class ReportService {
@Autowired
private ReportGenerator reportGenerator;
// 生成当前租户报表 - 缓存自动添加租户前缀
@Cacheable(value = "report", key = "#type + ':' + #month")
public Report generateMonthlyReport(String type, String month) {
return reportGenerator.generate(type, month);
}
// 生成全局报表(所有租户汇总)
@Cacheable(value = "global::report", key = "#type + ':' + #month")
public Report generateGlobalReport(String type, String month) {
return TenantHelper.ignore(() -> {
return reportGenerator.generateGlobal(type, month);
});
}
// 生成多租户对比报表
public Map<String, Report> generateCompareReport(List<String> tenantIds) {
Map<String, Report> reports = new HashMap<>();
for (String tenantId : tenantIds) {
Report report = TenantHelper.dynamic(tenantId, () -> {
return reportGenerator.generate("summary", LocalDate.now().toString());
});
reports.put(tenantId, report);
}
return reports;
}
}3. 系统管理功能
@Service
public class SystemService {
@Autowired
private ConfigMapper configMapper;
@Autowired
private TenantConfigMapper tenantConfigMapper;
// 系统配置管理(全局,不受租户影响)
public void updateSystemConfig(String key, String value) {
TenantHelper.ignore(() -> {
configMapper.updateByKey(key, value);
});
}
// 租户特定配置 - 自动应用租户条件
public void updateTenantConfig(String key, String value) {
tenantConfigMapper.updateByKey(key, value);
}
// 获取系统配置(带缓存)
@Cacheable(value = "global::config", key = "#key")
public String getSystemConfig(String key) {
return TenantHelper.ignore(() -> {
return configMapper.selectValueByKey(key);
});
}
}4. 定时任务处理
@Slf4j
@Component
public class TenantDataSyncTask {
@Autowired
private TenantMapper tenantMapper;
@Autowired
private DataSyncService dataSyncService;
// 遍历所有租户执行数据同步
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void syncAllTenantsData() {
// 获取所有租户
List<String> tenantIds = TenantHelper.ignore(() -> {
return tenantMapper.selectAllTenantIds();
});
// 逐个租户执行同步
for (String tenantId : tenantIds) {
try {
TenantHelper.dynamic(tenantId, () -> {
dataSyncService.syncData();
});
log.info("租户[{}]数据同步完成", tenantId);
} catch (Exception e) {
log.error("租户[{}]数据同步失败: {}", tenantId, e.getMessage());
}
}
}
}5. 消息队列处理
@Slf4j
@Component
public class OrderMessageConsumer {
@Autowired
private OrderService orderService;
@RocketMQMessageListener(
topic = "order-topic",
consumerGroup = "order-consumer-group"
)
public void onMessage(OrderMessage message) {
// 消息中携带租户ID
String tenantId = message.getTenantId();
// 在指定租户上下文中处理消息
TenantHelper.dynamic(tenantId, () -> {
orderService.processOrder(message.getOrderId());
});
}
}最佳实践
1. 数据库设计规范
-- ✅ 正确:租户表包含tenant_id字段和索引
CREATE TABLE order_info (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id VARCHAR(20) NOT NULL COMMENT '租户ID',
order_no VARCHAR(32) NOT NULL COMMENT '订单号',
amount DECIMAL(10,2) NOT NULL COMMENT '订单金额',
status TINYINT DEFAULT 0 COMMENT '订单状态',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tenant_id (tenant_id),
INDEX idx_tenant_order_no (tenant_id, order_no)
) COMMENT='订单表';
-- ✅ 正确:全局表不包含tenant_id字段
CREATE TABLE sys_config (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
config_key VARCHAR(100) NOT NULL COMMENT '配置键',
config_value TEXT COMMENT '配置值',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_config_key (config_key)
) COMMENT='系统配置表';2. 实体类设计规范
// ✅ 正确:租户实体继承TenantEntity
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("order_info")
public class OrderInfo extends TenantEntity {
// tenantId字段自动继承,无需手动定义
private Long id;
private String orderNo;
private BigDecimal amount;
}
// ✅ 正确:全局实体继承BaseEntity
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_config")
public class SysConfig extends BaseEntity {
// 不继承TenantEntity,不需要租户隔离
private Long id;
private String configKey;
private String configValue;
}
// ❌ 错误:租户实体手动添加tenantId
@Data
@TableName("order_info")
public class OrderInfo {
private Long id;
private String tenantId; // 应该通过继承获得
}3. 服务层设计规范
@Service
public class DataService {
// ✅ 正确:常规业务方法,自动应用租户过滤
public List<Order> getOrders() {
return orderMapper.selectList(null);
}
// ✅ 正确:系统管理方法,明确使用忽略模式
public List<Order> getAllOrdersForAdmin() {
return TenantHelper.ignore(() -> {
return orderMapper.selectList(null);
});
}
// ✅ 正确:跨租户操作,使用动态租户
public void copyData(String fromTenant, String toTenant) {
List<Order> data = TenantHelper.dynamic(fromTenant, () -> {
return orderMapper.selectList(null);
});
TenantHelper.dynamic(toTenant, () -> {
data.forEach(order -> {
order.setId(null);
orderMapper.insert(order);
});
});
}
// ❌ 错误:手动拼接租户条件
public List<Order> getOrdersByTenant(String tenantId) {
LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Order::getTenantId, tenantId); // 不需要手动添加
return orderMapper.selectList(wrapper);
}
}4. 缓存使用规范
@Service
public class CacheService {
// ✅ 正确:租户级缓存,自动添加租户前缀
@Cacheable(value = "userInfo", key = "#userId")
public UserInfo getUserInfo(Long userId) {
return userMapper.selectById(userId);
}
// ✅ 正确:全局缓存,使用global::前缀
@Cacheable(value = "global::systemConfig", key = "#key")
public String getSystemConfig(String key) {
return TenantHelper.ignore(() -> {
return configMapper.selectValueByKey(key);
});
}
// ✅ 正确:清除租户缓存
@CacheEvict(value = "userInfo", key = "#userId")
public void updateUser(Long userId, UserInfo user) {
userMapper.updateById(user);
}
// ✅ 正确:清除所有租户的某个缓存
public void clearAllTenantsCache(String cacheName) {
TenantHelper.ignore(() -> {
// 获取所有租户
List<String> tenantIds = tenantMapper.selectAllIds();
tenantIds.forEach(tenantId -> {
RedisUtils.deleteKeys(tenantId + ":" + cacheName + "*");
});
});
}
}5. 安全使用规范
@Service
public class SecurityService {
// ✅ 正确:权限检查后再执行忽略操作
public List<Order> getAllOrders() {
// 先检查权限
if (!SecurityUtils.hasRole("superadmin")) {
throw new ServiceException("无权限查看所有租户数据");
}
return TenantHelper.ignore(() -> {
return orderMapper.selectList(null);
});
}
// ✅ 正确:跨租户操作需要记录日志
public void crossTenantOperation(String targetTenant, Runnable operation) {
String currentTenant = TenantHelper.getTenantId();
log.info("跨租户操作: {} -> {}", currentTenant, targetTenant);
TenantHelper.dynamic(targetTenant, operation);
log.info("跨租户操作完成");
}
// ❌ 错误:无权限检查直接忽略租户
public List<Order> unsafeGetAllOrders() {
return TenantHelper.ignore(() -> {
return orderMapper.selectList(null);
});
}
}常见问题
1. 查询不到数据
问题原因:
- 当前登录用户的租户ID与数据的tenant_id不匹配
- 表被错误地配置到排除列表
- 未正确设置动态租户
解决方案:
// 1. 检查当前租户ID
String tenantId = TenantHelper.getTenantId();
log.info("当前租户ID: {}", tenantId);
// 2. 检查数据的租户ID
Order order = TenantHelper.ignore(() -> {
return orderMapper.selectById(orderId);
});
log.info("数据租户ID: {}", order.getTenantId());
// 3. 确认是否需要切换租户
if (!tenantId.equals(order.getTenantId())) {
// 需要在指定租户下操作
TenantHelper.dynamic(order.getTenantId(), () -> {
// 业务操作
});
}2. 缓存数据错乱
问题原因:
- 全局缓存没有使用
global::前缀 - 在忽略模式下写入了租户缓存
- 缓存键没有正确添加租户前缀
解决方案:
// ✅ 正确:全局缓存使用global::前缀
@Cacheable(value = "global::systemConfig", key = "#key")
public String getSystemConfig(String key) {
return TenantHelper.ignore(() -> {
return configMapper.selectValueByKey(key);
});
}
// ✅ 正确:租户缓存不使用global::前缀
@Cacheable(value = "userData", key = "#userId")
public UserData getUserData(Long userId) {
return userMapper.selectById(userId);
}
// 手动检查Redis键
// 租户缓存:000000:userData::1
// 全局缓存:global::systemConfig::key3. 认证会话异常
问题原因:
- TenantSaTokenDao未正确注入
- 全局前缀配置不一致
- Redis连接异常
解决方案:
// 检查SaTokenDao是否为多租户版本
SaTokenDao saTokenDao = SpringUtils.getBean(SaTokenDao.class);
log.info("SaTokenDao类型: {}", saTokenDao.getClass().getName());
// 应该是: TenantSaTokenDao
// 检查认证数据Redis键
// 正确格式:global::satoken:login:token:xxx
String key = "global::satoken:login:token:" + token;
String value = RedisUtils.getCacheObject(key);4. 性能问题
问题原因:
- tenant_id字段没有索引
- 大量跨租户查询
- 缓存未命中率高
解决方案:
-- 1. 为tenant_id字段添加索引
ALTER TABLE order_info ADD INDEX idx_tenant_id (tenant_id);
-- 2. 创建复合索引优化常用查询
ALTER TABLE order_info ADD INDEX idx_tenant_status (tenant_id, status);
ALTER TABLE order_info ADD INDEX idx_tenant_create_time (tenant_id, create_time);// 3. 批量操作时使用单次动态租户切换
TenantHelper.dynamic(targetTenant, () -> {
// 批量操作在同一个租户上下文中执行
batchInsert(dataList);
});
// 4. 合理使用缓存
@Cacheable(value = "orderList", key = "#status + ':' + #page")
public Page<Order> getOrdersByStatus(Integer status, int page) {
return orderMapper.selectPage(new Page<>(page, 10),
new LambdaQueryWrapper<Order>().eq(Order::getStatus, status));
}5. 动态租户切换失效
问题原因:
- 在异步线程中丢失租户上下文
- 未正确清除动态租户设置
- 嵌套调用时上下文混乱
解决方案:
// 1. 异步线程传递租户上下文
String tenantId = TenantHelper.getTenantId();
CompletableFuture.runAsync(() -> {
TenantHelper.dynamic(tenantId, () -> {
// 异步任务在正确的租户上下文中执行
asyncService.doSomething();
});
});
// 2. 确保使用try-finally清除
TenantHelper.setDynamic("tenant123");
try {
businessService.doSomething();
} finally {
TenantHelper.clearDynamic(); // 确保清除
}
// 3. 推荐使用Lambda方式(自动清除)
TenantHelper.dynamic("tenant123", () -> {
businessService.doSomething();
}); // 自动清除调试方法
开启SQL日志
# 开启MyBatis-Plus SQL日志
logging:
level:
com.baomidou.mybatisplus: DEBUG
plus.ruoyi.common.tenant: DEBUG检查租户状态
@RestController
@RequestMapping("/debug/tenant")
public class TenantDebugController {
@GetMapping("/status")
public Map<String, Object> getTenantStatus() {
Map<String, Object> status = new HashMap<>();
status.put("enable", TenantHelper.isEnable());
status.put("currentTenantId", TenantHelper.getTenantId());
status.put("dynamicTenantId", TenantHelper.getDynamic());
status.put("loginTenantId", LoginHelper.getTenantId());
return status;
}
}监控Redis键
# 查看租户相关键
redis-cli keys "*:userData:*"
# 查看全局键
redis-cli keys "global::*"
# 查看动态租户键
redis-cli keys "global::dynamicTenant:*"