一、核心概念

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()

四、注意事项

  1. ThreadLocal 内存泄漏:务必在请求结束时调用 TenantContext.clear()
  2. 字段命名一致性:所有表的租户字段建议统一命名为 tenant_id
  3. 主键策略:若使用分布式ID(如 IdWorker),需确保租户内唯一或全局唯一。
  4. 联表查询:多租户插件会为每个涉及的表自动添加 tenant_id 条件,确保所有相关表都有该字段。
  5. 缓存问题:若使用二级缓存,需注意缓存键中包含 tenant_id,避免数据泄露。

五、使用技巧

  1. 动态数据源 + 多租户:可结合 dynamic-datasource-spring-boot-starter 实现数据库级隔离。
  2. 租户ID来源多样化:支持从 JWT Token、请求头、子域名、URL 路径等多种方式获取。
  3. 测试时绕过:可通过 ignoreTable 或在测试中手动设置 TenantContext 模拟不同租户。
  4. SQL 审计:结合 p6spydruid 监控 SQL,验证 tenant_id 是否正确注入。

六、最佳实践与性能优化

✅ 最佳实践

  • 统一租户字段名:所有表使用 tenant_id 字段。
  • 索引优化:为 tenant_id 字段添加索引(通常是联合索引的一部分)。
  • 逻辑删除 + 多租户:结合 @TableLogic 实现软删除,避免数据误删。
  • API 设计:在 API 路径或 Header 中明确传递租户信息,如 X-Tenant-Id: 1001

⚡ 性能优化

  1. 索引策略

    -- 建议在常用查询字段上建立联合索引
    CREATE INDEX idx_tenant_status ON user (tenant_id, status);
    
  2. 避免 N+1 查询:使用 @Select 注解或 Wrapper 一次性查询,减少数据库交互。

  3. 分页优化:使用 MP 的 Page 对象,避免一次性加载大量数据。

  4. 缓存租户信息:若租户信息频繁访问,可缓存到 Redis,减少数据库查询。


七、验证是否生效

  1. 查看日志:开启 MyBatis SQL 日志,观察生成的 SQL 是否包含 tenant_id = ?
  2. 单元测试
    @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 实现多租户的能力。