一、核心概念
1. 多租户(Multi-Tenancy)
多租户是一种软件架构技术,允许多个独立的用户(租户)共享同一套系统或应用实例,同时保证数据隔离。在数据库层面,常见实现方式有:
- 独立数据库:每个租户拥有独立数据库(隔离最强,成本高)
- 独立 Schema:共享数据库,但每个租户使用独立 Schema
- 共享表 + 租户字段:所有租户数据存于同一张表,通过
tenant_id
字段区分(最常用)
2. MyBatis-Plus 多租户插件
MyBatis-Plus 提供了 TenantLineInnerInterceptor
插件,用于自动在 SQL 中注入 tenant_id = ?
条件,实现数据自动隔离。
原理:通过拦截 SQL 执行,在
WHERE
条件中自动追加tenant_id = 当前租户ID
,无需手动编写。
二、操作步骤(超详细)
步骤 1:添加依赖(Maven)
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.6</version> <!-- 推荐使用最新稳定版 -->
</dependency>
注意:MyBatis-Plus 从 3.4.0 开始,多租户功能由
InnerInterceptor
实现,旧版使用SqlParserFilter
已废弃。
步骤 2:在实体类中添加 @TableField(fill = FieldFill.INSERT)
注解
确保你的实体类中包含 tenantId
字段,并标记为自动填充。
@Data
@TableName("user")
public class User {
private Long id;
private String name;
@TableField(fill = FieldFill.INSERT)
private Long tenantId; // 租户ID字段
}
步骤 3:实现自动填充处理器
创建类实现 MetaObjectHandler
,用于插入时自动填充 tenantId
。
@Component
@Slf4j
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
// 获取当前租户ID(从上下文中获取)
Long tenantId = TenantContext.getTenantId();
if (tenantId != null) {
this.strictInsertFill(metaObject, "tenantId", Long.class, tenantId);
}
}
@Override
public void updateFill(MetaObject metaObject) {
// 更新时通常不需要更新 tenantId
}
}
TenantContext
是一个自定义的上下文工具类,用于存储当前请求的租户ID。
步骤 4:创建租户上下文工具类
public class TenantContext {
private static final ThreadLocal<Long> TENANT_ID = new ThreadLocal<>();
public static void setTenantId(Long tenantId) {
TENANT_ID.set(tenantId);
}
public static Long getTenantId() {
return TENANT_ID.get();
}
public static void clear() {
TENANT_ID.remove();
}
}
步骤 5:配置多租户插件(关键步骤)
创建 MyBatis Plus 配置类,注册 TenantLineInnerInterceptor
。
@Configuration
@MapperScan("com.example.mapper") // 扫描 Mapper 接口
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 创建多租户拦截器
TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(
new TenantLineHandler() {
@Override
public String getTenantId() {
// 从上下文中获取当前租户ID
Long tenantId = TenantContext.getTenantId();
if (tenantId == null) {
throw new RuntimeException("租户ID不能为空!");
}
return tenantId.toString();
}
// 指定哪些表需要进行多租户控制
@Override
public String getTenantIdColumn() {
return "tenant_id"; // 数据库字段名
}
// 可以设置哪些表不进行多租户过滤
@Override
public boolean ignoreTable(String tableName) {
// 系统表、用户表等不需要租户隔离
return "sys_user".equalsIgnoreCase(tableName) ||
"sys_dept".equalsIgnoreCase(tableName);
}
}
);
// 将拦截器加入插件链
interceptor.addInnerInterceptor(tenantInterceptor);
// 可选:添加分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
步骤 6:在请求中设置租户ID(如通过拦截器)
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 从请求头、Token、URL 参数等获取租户ID
String tenantIdStr = request.getHeader("X-Tenant-Id");
if (StringUtils.hasText(tenantIdStr)) {
try {
Long tenantId = Long.valueOf(tenantIdStr);
TenantContext.setTenantId(tenantId);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("无效的租户ID");
}
} else {
throw new RuntimeException("缺少租户ID");
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 清理 ThreadLocal,防止内存泄漏
TenantContext.clear();
}
}
注册拦截器:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private TenantInterceptor tenantInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantInterceptor)
.addPathPatterns("/api/**"); // 拦截需要租户隔离的路径
}
}
三、常见错误与解决方案
错误现象 | 原因 | 解决方案 |
---|---|---|
SQL 报错字段 tenant_id 不存在 |
表中没有 tenant_id 字段 |
确保所有需要隔离的表都有该字段 |
查询结果为空或数据混乱 | TenantContext 未正确设置租户ID |
检查拦截器是否执行,ThreadLocal 是否设置 |
插入时报 tenant_id 为空 |
MetaObjectHandler 未生效 |
检查类是否被 @Component 扫描,字段是否加 @TableField(fill = ...) |
某些表被错误过滤 | ignoreTable 未配置 |
在 TenantLineHandler 中正确配置忽略表 |
多线程环境下租户错乱 | ThreadLocal 未清理 |
在 afterCompletion 中调用 clear() |
四、注意事项
- ThreadLocal 内存泄漏:务必在请求结束时调用
TenantContext.clear()
。 - 字段命名一致性:所有表的租户字段建议统一命名为
tenant_id
。 - 主键策略:若使用分布式ID(如
IdWorker
),需确保租户内唯一或全局唯一。 - 联表查询:多租户插件会为每个涉及的表自动添加
tenant_id
条件,确保所有相关表都有该字段。 - 缓存问题:若使用二级缓存,需注意缓存键中包含
tenant_id
,避免数据泄露。
五、使用技巧
- 动态数据源 + 多租户:可结合
dynamic-datasource-spring-boot-starter
实现数据库级隔离。 - 租户ID来源多样化:支持从 JWT Token、请求头、子域名、URL 路径等多种方式获取。
- 测试时绕过:可通过
ignoreTable
或在测试中手动设置TenantContext
模拟不同租户。 - SQL 审计:结合
p6spy
或druid
监控 SQL,验证tenant_id
是否正确注入。
六、最佳实践与性能优化
✅ 最佳实践
- 统一租户字段名:所有表使用
tenant_id
字段。 - 索引优化:为
tenant_id
字段添加索引(通常是联合索引的一部分)。 - 逻辑删除 + 多租户:结合
@TableLogic
实现软删除,避免数据误删。 - API 设计:在 API 路径或 Header 中明确传递租户信息,如
X-Tenant-Id: 1001
。
⚡ 性能优化
索引策略:
-- 建议在常用查询字段上建立联合索引 CREATE INDEX idx_tenant_status ON user (tenant_id, status);
避免 N+1 查询:使用
@Select
注解或Wrapper
一次性查询,减少数据库交互。分页优化:使用 MP 的
Page
对象,避免一次性加载大量数据。缓存租户信息:若租户信息频繁访问,可缓存到 Redis,减少数据库查询。
七、验证是否生效
- 查看日志:开启 MyBatis SQL 日志,观察生成的 SQL 是否包含
tenant_id = ?
。 - 单元测试:
@Test public void testTenantQuery() { TenantContext.setTenantId(1001L); List<User> users = userMapper.selectList(null); // 断言 SQL 中包含 tenant_id = 1001 }
总结
MyBatis-Plus 多租户插件通过 TenantLineInnerInterceptor
实现了无侵入式的数据隔离,配合 MetaObjectHandler
自动填充,极大简化了多租户系统的开发。只要按照上述步骤配置,即可快速实现基于字段的多租户数据隔离。
关键点:
✅ 正确配置TenantLineHandler
✅ 实现MetaObjectHandler
自动填充
✅ 使用拦截器设置TenantContext
✅ 及时清理ThreadLocal
掌握这些,你已经具备了在生产环境中使用 MyBatis-Plus 实现多租户的能力。