1. 项目概述:SpringBoot多租户架构实战
在SaaS系统开发中,数据隔离是每个开发者必须面对的挑战。最近我在重构一个企业级管理系统时,选择了RuoYi-SpringBoot3-Pro框架作为基础,其内置的MyBatis-Plus多租户插件让我省去了大量重复工作。这个方案最吸引我的地方在于:它通过拦截器机制自动注入租户条件,开发者只需关注业务逻辑本身。
多租户架构的核心价值在于:
- 实现不同客户数据的物理隔离
- 共享同一套代码和基础设施
- 降低运维复杂度
- 提高资源利用率
2. 核心原理与架构设计
2.1 MyBatis-Plus多租户实现机制
MyBatis-Plus通过TenantLineInnerInterceptor拦截器实现多租户,其工作原理可分为三个阶段:
- SQL解析阶段:拦截器会解析执行的SQL语句,识别其中的表名
- 租户判断阶段:根据配置判断当前表是否需要租户隔离
- 条件注入阶段:对需要隔离的表自动追加
tenant_id = ?条件
这种实现方式的优势在于:
- 对业务代码零侵入
- 支持动态开关
- 可灵活配置隔离规则
- 与MyBatis原生功能完美兼容
2.2 租户上下文传递方案
框架通过ThreadLocal实现租户上下文的传递,具体流程如下:
java复制// 伪代码展示核心逻辑
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static String getTenantId() {
return CURRENT_TENANT.get();
}
}
// 在登录拦截器中设置租户ID
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String tenantId = // 从token或session中获取
TenantContext.setTenantId(tenantId);
return true;
}
}
3. 详细配置指南
3.1 基础配置详解
在application.yml中,多租户配置分为以下几个部分:
yaml复制tenant:
enable: true # 总开关
column: tenant_id # 租户字段名
# 需要强制过滤的表(即使没有tenant_id字段也会尝试过滤)
filterTables:
- biz_order
- biz_contract
# 忽略租户过滤的表(系统表等)
ignoreTables:
- sys_user
- sys_role
- sys_menu
# 超级管理员账号(可跨租户查询)
ignoreLoginNames:
- admin
重要提示:filterTables和ignoreTables是互斥配置,同一个表不能同时出现在两个列表中
3.2 数据库设计建议
对于多租户系统,数据库设计通常有三种方案:
- 独立Schema:每个租户使用单独的数据库schema
- 共享表+租户ID:所有租户共享表,通过tenant_id区分
- 混合模式:核心表独立schema,公共表共享
RuoYi默认采用第二种方案,这也是最平衡的选择。表结构设计时需要注意:
- 所有需要隔离的表必须包含tenant_id字段
- 建议为tenant_id字段建立索引
- 外键关联需要额外考虑租户维度
sql复制CREATE TABLE `biz_order` (
`id` bigint NOT NULL,
`order_no` varchar(64) DEFAULT NULL,
`tenant_id` varchar(64) NOT NULL COMMENT '租户ID',
PRIMARY KEY (`id`),
KEY `idx_tenant` (`tenant_id`)
) ENGINE=InnoDB;
4. 高级功能实现
4.1 自定义租户处理器
框架允许通过实现TenantLineHandler接口来自定义租户逻辑:
java复制public class CustomTenantHandler implements TenantLineHandler {
@Override
public Expression getTenantId() {
// 可以从JWT、Redis等任意位置获取
String tenantId = TenantContext.getCurrentTenant();
return new StringValue(tenantId);
}
@Override
public boolean ignoreTable(String tableName) {
// 动态判断是否忽略某表
return TenantConfig.shouldIgnore(tableName);
}
}
4.2 多租户与事务处理
在多租户环境下,事务管理需要特别注意:
- 跨租户操作:需要临时切换租户上下文时
java复制@Transactional
public void crossTenantOperation() {
String originalTenant = TenantContext.getTenantId();
try {
TenantContext.setTenantId("tenantA");
// 操作tenantA的数据
TenantContext.setTenantId("tenantB");
// 操作tenantB的数据
} finally {
TenantContext.setTenantId(originalTenant);
}
}
- 批量处理:建议按租户分批处理,避免上下文混乱
5. 实战问题排查指南
5.1 常见问题及解决方案
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| SQL缺少租户条件 | 表名未在filterTables中配置 | 检查配置或添加@InterceptorIgnore注解 |
| 租户ID为null | 用户未登录或上下文丢失 | 检查登录拦截器和ThreadLocal管理 |
| 超级管理员查询不到数据 | ignoreLoginNames配置错误 | 确认用户名大小写匹配 |
| 联表查询失效 | 未给表添加别名 | 确保SQL符合MyBatis-Plus规范 |
5.2 性能优化建议
- 索引优化:确保tenant_id字段有适当索引
- SQL监控:定期检查生成的SQL是否符合预期
- 缓存策略:考虑租户维度的缓存隔离
- 连接池配置:根据租户数量调整连接池大小
6. 扩展开发建议
6.1 多租户与微服务集成
在微服务架构下,租户信息需要通过Feign调用传递:
java复制@Bean
public RequestInterceptor tenantFeignInterceptor() {
return template -> {
String tenantId = TenantContext.getTenantId();
if (StringUtils.isNotBlank(tenantId)) {
template.header("X-Tenant-Id", tenantId);
}
};
}
// 在服务提供方添加拦截器
public class TenantHeaderInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String tenantId = request.getHeader("X-Tenant-Id");
if (StringUtils.isNotBlank(tenantId)) {
TenantContext.setTenantId(tenantId);
}
return true;
}
}
6.2 动态租户管理
对于需要动态创建租户的场景,可以考虑:
- 使用Spring动态注册Bean
- 实现租户配置的热加载
- 设计租户生命周期管理接口
java复制public interface TenantManager {
void registerNewTenant(TenantInfo tenantInfo);
void disableTenant(String tenantId);
List<TenantInfo> listActiveTenants();
}
在实际项目中,我遇到一个典型场景:需要让部分表在开发环境关闭租户过滤。通过自定义TenantLineHandler实现了这个需求:
java复制public class EnvAwareTenantHandler extends TenantLineInnerInterceptor {
@Value("${spring.profiles.active}")
private String activeProfile;
@Override
public boolean ignoreTable(String tableName) {
if ("dev".equals(activeProfile) && tableName.startsWith("test_")) {
return true;
}
return super.ignoreTable(tableName);
}
}
这个方案既保持了生产环境的严格隔离,又在开发环境提供了灵活性。建议在实施多租户架构时,提前规划好这类边界情况的处理方案。