1. 多租户架构设计与实现原理
在SaaS系统开发中,多租户架构是基础且关键的设计模式。RuoYi-SpringBoot3-Pro采用共享数据库、独立Schema的方式实现多租户隔离,这种方案在资源利用和数据隔离之间取得了良好平衡。
1.1 核心实现机制
MyBatis-Plus的多租户插件通过SQL拦截器实现自动数据过滤。其核心类TenantLineInnerInterceptor会在SQL执行前动态修改语句,自动追加租户条件。具体工作流程如下:
- 拦截所有Mapper方法调用
- 解析当前SQL语句结构
- 根据配置判断目标表是否需要租户过滤
- 对需要过滤的表自动添加
tenant_id = ?条件 - 执行修改后的SQL语句
这种实现方式的最大优势是业务代码无需显式处理租户ID,所有过滤逻辑由框架层统一处理,显著降低了开发复杂度。
1.2 租户上下文管理
租户信息的获取通过MultiTenantHandler实现,其核心代码如下:
java复制public class MultiTenantHandler implements TenantLineHandler {
private final TenantProperties properties;
@Override
public String getTenantIdColumn() {
return properties.getColumn();
}
@Override
public boolean ignoreTable(String tableName) {
return properties.getIgnoreTables().contains(tableName);
}
@Override
public String getTenantId() {
return SecurityUtils.getTenantId();
}
}
租户ID的存储和传递采用线程本地变量(ThreadLocal)实现,确保在多线程环境下也能正确获取当前租户上下文。用户登录时,系统会将租户ID存入SecurityContext,后续操作自动继承该上下文。
2. 详细配置指南
2.1 基础配置参数解析
在application.yml中,多租户配置包含以下关键参数:
yaml复制tenant:
enable: true # 总开关
column: tenant_id # 租户字段名
filterTables: # 强制过滤表
ignoreTables: # 忽略过滤表
- sys_user
- sys_role
- sys_menu
ignoreLoginNames: # 超级管理员账号
- admin
各参数详细说明:
filterTables:即使表名不符合常规命名规则,也会强制进行租户过滤ignoreTables:系统级表,不参与租户隔离ignoreLoginNames:指定的管理员账号可以查看所有租户数据
2.2 数据库设计规范
为实现有效的租户隔离,数据库设计需遵循以下规范:
- 所有业务表必须包含
tenant_id字段 - 字段类型建议与主键类型一致(通常为BIGINT)
- 需要建立复合索引:(tenant_id, id)
- 系统表(用户、角色等)可不加租户字段
示例建表语句:
sql复制CREATE TABLE `biz_order` (
`id` bigint NOT NULL,
`tenant_id` bigint NOT NULL COMMENT '租户ID',
`order_no` varchar(64) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_tenant` (`tenant_id`)
) ENGINE=InnoDB;
3. 核心实现代码解析
3.1 拦截器配置
在MybatisPlusConfig中配置多租户拦截器:
java复制@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(TenantProperties tenantProperties) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 多租户拦截器
if (Boolean.TRUE.equals(tenantProperties.getEnable())) {
interceptor.addInnerInterceptor(
new TenantLineInnerInterceptor(
new MultiTenantHandler(tenantProperties)
)
);
}
// 其他拦截器(分页、乐观锁等)
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
拦截器执行顺序需要注意:
- 多租户拦截器应最先添加
- 分页拦截器应最后添加
- 其他业务拦截器按需添加在中间
3.2 自定义SQL处理
对于手写SQL,需要特别注意表别名问题:
xml复制<!-- 错误写法 -->
<select id="selectJoin" resultType="map">
SELECT a.*, b.name
FROM biz_order a, biz_product b
WHERE a.product_id = b.id
</select>
<!-- 正确写法 -->
<select id="selectJoin" resultType="map">
SELECT a.*, b.name
FROM biz_order a
INNER JOIN biz_product b ON a.product_id = b.id
WHERE a.tenant_id = #{tenantId}
AND b.tenant_id = #{tenantId}
</select>
框架会自动处理简单SQL的租户条件,但复杂JOIN查询需要手动确保每个表都添加了租户条件。
4. 高级应用场景
4.1 定时任务处理
定时任务执行时没有用户上下文,需要特殊处理租户问题:
java复制public void syncAllTenantsData() {
List<Tenant> tenants = tenantService.listAll();
for (Tenant tenant : tenants) {
// 设置当前租户上下文
TenantContext.setCurrentTenant(tenant.getId());
try {
// 执行业务逻辑
syncService.doSync();
} finally {
// 清除上下文
TenantContext.clear();
}
}
}
4.2 跨租户数据统计
管理员可能需要查看跨租户的统计数据:
java复制public List<StatisticVO> getCrossTenantStats() {
// 保存原始租户上下文
String originalTenant = TenantContext.getCurrentTenant();
try {
// 临时切换为无租户模式
TenantContext.setAdminMode();
// 执行统计查询
return statisticMapper.selectCrossTenantStats();
} finally {
// 恢复原始上下文
if (originalTenant != null) {
TenantContext.setCurrentTenant(originalTenant);
} else {
TenantContext.clear();
}
}
}
5. 常见问题与解决方案
5.1 SQL不生效问题排查
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 租户条件未追加 | 表名在ignoreTables列表中 | 检查配置文件中ignoreTables配置 |
| 部分SQL未过滤 | 使用了自定义SQL未遵循规范 | 确保SQL中使用标准JOIN语法 |
| 管理员账号看到所有数据 | 账号在ignoreLoginNames列表中 | 检查账号是否在配置的白名单中 |
5.2 性能优化建议
- 索引优化:确保所有带tenant_id的查询条件都有合适索引
- SQL规范:避免在WHERE子句中对tenant_id进行函数计算
- 缓存策略:租户级缓存建议使用格式:
tenantId:cacheKey - 批量操作:批量插入时确保每条记录都设置了tenant_id
5.3 事务处理注意事项
跨租户操作需要特别注意事务边界:
java复制// 错误示例 - 跨租户事务
@Transactional
public void transferData(Long sourceTenant, Long targetTenant) {
// 从源租户查询
TenantContext.setCurrentTenant(sourceTenant);
List<Data> sourceData = dataMapper.selectList();
// 向目标租户插入
TenantContext.setCurrentTenant(targetTenant);
dataMapper.batchInsert(sourceData);
}
// 正确做法 - 拆分事务
public void safeTransfer(Long sourceTenant, Long targetTenant) {
List<Data> sourceData = getSourceData(sourceTenant);
saveTargetData(targetTenant, sourceData);
}
@Transactional
public List<Data> getSourceData(Long tenantId) {
TenantContext.setCurrentTenant(tenantId);
return dataMapper.selectList();
}
@Transactional
public void saveTargetData(Long tenantId, List<Data> data) {
TenantContext.setCurrentTenant(tenantId);
dataMapper.batchInsert(data);
}
6. 扩展开发指南
6.1 动态租户数据源
对于大型SaaS系统,可能需要分库分表:
java复制public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
String tenantId = TenantContext.getCurrentTenant();
return "tenant_" + tenantId;
}
}
配置示例:
yaml复制spring:
datasource:
dynamic:
primary: master
strict: true
datasource:
master:
url: jdbc:mysql://localhost:3306/master
username: root
password: 123456
tenant_1:
url: jdbc:mysql://localhost:3306/tenant_1
username: root
password: 123456
tenant_2:
url: jdbc:mysql://localhost:3306/tenant_2
username: root
password: 123456
6.2 多租户缓存策略
使用Redis实现租户隔离缓存:
java复制public class TenantAwareCacheManager implements CacheManager {
private final CacheManager delegate;
@Override
public Cache getCache(String name) {
String tenantId = TenantContext.getCurrentTenant();
String tenantAwareName = tenantId + ":" + name;
return delegate.getCache(tenantAwareName);
}
}
6.3 租户特定配置管理
支持租户级别的自定义配置:
java复制public interface TenantConfigService {
String getConfig(String key);
void setConfig(String key, String value);
}
@Service
public class TenantConfigServiceImpl implements TenantConfigService {
@Autowired
private TenantConfigMapper configMapper;
@Override
@Cacheable(value = "tenant_config", key = "#key")
public String getConfig(String key) {
String tenantId = TenantContext.getCurrentTenant();
return configMapper.selectByTenantAndKey(tenantId, key);
}
}
7. 最佳实践与经验分享
7.1 租户初始化流程
新租户注册时应执行的初始化操作:
- 创建租户基础记录
- 初始化租户专属数据
- 分配默认资源
- 设置初始管理员
java复制public Tenant registerTenant(TenantRegisterDTO dto) {
// 1. 保存租户基本信息
Tenant tenant = new Tenant();
BeanUtils.copyProperties(dto, tenant);
tenantMapper.insert(tenant);
try {
// 2. 设置当前租户上下文
TenantContext.setCurrentTenant(tenant.getId());
// 3. 初始化系统数据
initDefaultRoles(tenant);
initDefaultMenus(tenant);
createAdminAccount(dto);
// 4. 初始化业务数据
businessInitService.init(tenant);
return tenant;
} finally {
TenantContext.clear();
}
}
7.2 多租户系统监控
实现租户级别的系统监控:
java复制@Aspect
@Component
public class TenantMonitorAspect {
@Autowired
private MetricService metricService;
@Around("execution(* com..service..*.*(..))")
public Object monitorTenantOperation(ProceedingJoinPoint pjp) throws Throwable {
String tenantId = TenantContext.getCurrentTenant();
String method = pjp.getSignature().getName();
long start = System.currentTimeMillis();
try {
return pjp.proceed();
} finally {
long cost = System.currentTimeMillis() - start;
metricService.recordOperation(tenantId, method, cost);
}
}
}
7.3 数据导出与迁移
租户数据导出注意事项:
- 确保导出文件包含tenant_id字段
- 批量处理时注意内存控制
- 支持增量导出模式
java复制public void exportTenantData(Long tenantId, OutputStream out) {
TenantContext.setCurrentTenant(tenantId);
try (CSVPrinter printer = new CSVPrinter(new OutputStreamWriter(out), CSVFormat.DEFAULT)) {
// 导出用户数据
Page<SysUser> page;
int pageNum = 1;
do {
page = userService.selectPage(new Page<>(pageNum, 500));
for (SysUser user : page.getRecords()) {
printer.printRecord(
user.getUserId(),
user.getUserName(),
// 其他字段...
user.getTenantId()
);
}
pageNum++;
} while (page.hasNext());
// 导出其他业务数据...
} finally {
TenantContext.clear();
}
}
8. 安全与权限控制
8.1 租户数据隔离验证
建议编写测试用例验证隔离效果:
java复制@Test
public void testTenantIsolation() {
// 租户1操作
TenantContext.setCurrentTenant(1L);
Data data1 = new Data();
dataService.save(data1);
// 租户2操作
TenantContext.setCurrentTenant(2L);
Data data2 = new Data();
dataService.save(data2);
// 验证租户1只能看到自己的数据
TenantContext.setCurrentTenant(1L);
List<Data> list1 = dataService.list();
assertEquals(1, list1.size());
assertEquals(data1.getId(), list1.get(0).getId());
// 验证租户2只能看到自己的数据
TenantContext.setCurrentTenant(2L);
List<Data> list2 = dataService.list();
assertEquals(1, list2.size());
assertEquals(data2.getId(), list2.get(0).getId());
}
8.2 敏感数据处理
租户敏感信息加密方案:
java复制public class TenantAwareEncryptor implements Encryptor {
private final Encryptor delegate;
@Override
public String encrypt(String plainText) {
String tenantId = TenantContext.getCurrentTenant();
String tenantSpecificKey = getTenantKey(tenantId);
return delegate.encrypt(plainText, tenantSpecificKey);
}
private String getTenantKey(String tenantId) {
// 从安全存储获取租户特定密钥
}
}
9. 性能调优实战
9.1 数据库连接池配置
针对多租户环境的连接池优化:
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
pool-name: TenantHikariCP
建议配置:
- 根据租户数量动态调整连接池大小
- 为重要租户分配专用连接池
- 实现租户级别的连接监控
9.2 SQL优化案例
租户查询典型优化场景:
sql复制-- 优化前
SELECT * FROM biz_order
WHERE tenant_id = 123
AND DATE(create_time) = '2023-01-01';
-- 优化后
SELECT * FROM biz_order
WHERE tenant_id = 123
AND create_time >= '2023-01-01 00:00:00'
AND create_time < '2023-01-02 00:00:00';
优化要点:
- 避免对tenant_id字段使用函数
- 为tenant_id + create_time创建复合索引
- 使用范围查询代替函数计算
10. 升级与迁移策略
10.1 从单租户迁移到多租户
迁移步骤:
-
数据库变更:
sql复制ALTER TABLE biz_order ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 1; ALTER TABLE biz_order ADD INDEX idx_tenant (tenant_id); -
数据迁移:
java复制public void migrateToMultiTenant() { // 设置超级租户上下文 TenantContext.setCurrentTenant(1L); // 更新历史数据tenant_id jdbcTemplate.update("UPDATE biz_order SET tenant_id = 1"); // 其他表迁移... } -
代码适配:
- 检查所有自定义SQL
- 验证事务边界
- 测试定时任务
10.2 多租户系统升级
滚动升级策略:
-
新增租户字段版本号:
sql复制ALTER TABLE biz_order ADD COLUMN schema_version INT DEFAULT 1; -
实现版本兼容逻辑:
java复制public class VersionAwareTenantHandler extends MultiTenantHandler { @Override public boolean ignoreTable(String tableName) { // 版本1兼容逻辑 if (getCurrentVersion() == 1) { return super.ignoreTable(tableName); } // 版本2新逻辑 return isSystemTable(tableName); } } -
分租户逐步升级:
java复制public void rollingUpgrade(List<Long> tenantIds) { for (Long tenantId : tenantIds) { try { upgradeTenant(tenantId); } catch (Exception e) { log.error("Tenant upgrade failed: " + tenantId, e); } } }