在 SaaS(Software as a Service)系统中,多租户(Multi-Tenant) 是常见架构模式。MyBatis-Plus 提供了强大的多租户支持,通过 TenantLineInnerInterceptor 配合自定义 TenantLineHandler 接口实现,可自动在 SQL 中注入租户条件(如 tenant_id = ?),从而实现数据隔离。


一、核心概念

1. 什么是 TenantLineHandler

  • TenantLineHandler 是 MyBatis-Plus 多租户模块的核心接口,用于定义:
    • 哪些表需要进行租户隔离。
    • 租户字段名(如 tenant_id)。
    • 当前租户 ID 的获取方式。
  • 它不直接执行拦截,而是由 TenantLineInnerInterceptor 调用,决定是否在 SQL 的 WHERE 条件中自动添加 tenant_id = ?

2. 核心方法

public interface TenantLineHandler {
    /**
     * 获取当前租户 ID 值表达式,不参数化(即直接返回值或表达式)
     */
    Expression getTenantId();

    /**
     * 数据表名是否需要拼接多租户字段(如 tenant_id)
     */
    default boolean ignoreTable(String tableName) {
        return false;
    }
}

⚠️ 注意:getTenantId() 返回的是 Expression 类型(来自 JSQLParser),不是简单的 StringLong


二、操作步骤(非常详细)

步骤 1:引入 MyBatis-Plus 多租户依赖

确保使用的是 MyBatis-Plus 3.4.0 及以上版本(推荐 3.5.5):

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.5</version>
</dependency>

多租户功能内置在主包中,无需额外引入。


步骤 2:创建租户处理器实现类

创建类实现 TenantLineHandler 接口。

package com.example.handler;

import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.springframework.stereotype.Component;

@Component
public class MyTenantLineHandler implements TenantLineHandler {

    /**
     * 定义租户字段名
     */
    private static final String TENANT_ID_COLUMN = "tenant_id";

    /**
     * 模拟从 ThreadLocal 或 SecurityContext 获取当前租户 ID
     * 实际项目中可从 JWT、Session、请求头等获取
     */
    @Override
    public Expression getTenantId() {
        Long tenantId = TenantContextHolder.getTenantId(); // 自定义上下文工具类
        return new LongValue(tenantId); // 返回 JSQLParser 的 LongValue 表达式
    }

    /**
     * 指定哪些表不进行多租户处理
     * @param tableName 表名
     * @return true 表示忽略(不加 tenant_id 条件),false 表示需要处理
     */
    @Override
    public boolean ignoreTable(String tableName) {
        // 系统表、用户表、角色表等不隔离
        return "sys_user".equalsIgnoreCase(tableName)
            || "sys_role".equalsIgnoreCase(tableName)
            || "sys_dept".equalsIgnoreCase(tableName)
            || "tenant_info".equalsIgnoreCase(tableName); // 租户信息表本身不隔离
    }
}

步骤 3:创建租户上下文工具类(推荐)

用于在请求过程中存储和获取当前租户 ID。

package com.example.util;

public class TenantContextHolder {
    private static final ThreadLocal<Long> CONTEXT = new ThreadLocal<>();

    public static void setTenantId(Long tenantId) {
        CONTEXT.set(tenantId);
    }

    public static Long getTenantId() {
        return CONTEXT.get();
    }

    public static void clear() {
        CONTEXT.remove();
    }
}

步骤 4:配置多租户拦截器

创建 MyBatis-Plus 配置类,注册 TenantLineInnerInterceptor

package com.example.config;

import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.example.handler.MyTenantLineHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MyBatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor(TenantLineHandler tenantLineHandler) {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

        // 添加多租户拦截器
        TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(tenantLineHandler);
        interceptor.addInnerInterceptor(tenantInterceptor);

        return interceptor;
    }

    // 如果不想用 @Component,也可在此处注入
    // @Bean
    // public TenantLineHandler tenantLineHandler() {
    //     return new MyTenantLineHandler();
    // }
}

步骤 5:在请求入口设置租户 ID(关键!)

通常在 拦截器或过滤器 中解析请求头或 Token 获取 tenant_id 并设置到上下文中。

@Component
public class TenantIdInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String tenantIdStr = request.getHeader("X-Tenant-Id");
        if (tenantIdStr != null && !tenantIdStr.isEmpty()) {
            try {
                Long tenantId = Long.valueOf(tenantIdStr);
                TenantContextHolder.setTenantId(tenantId);
            } catch (NumberFormatException e) {
                throw new RuntimeException("Invalid tenant ID: " + tenantIdStr);
            }
        } else {
            throw new RuntimeException("Missing tenant ID in request header");
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        // 清理 ThreadLocal,防止内存泄漏
        TenantContextHolder.clear();
    }
}

注册拦截器:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new TenantIdInterceptor());
    }
}

步骤 6:验证效果

假设有一个 order 表,执行查询:

@Mapper
public interface OrderMapper extends BaseMapper<Order> {}

@Service
public class OrderService {
    @Autowired
    private OrderMapper orderMapper;

    public List<Order> getAllOrders() {
        return orderMapper.selectList(null); // 无条件查询
    }
}

实际执行 SQL 将自动变为:

SELECT * FROM order WHERE tenant_id = 1001;

✅ 成功实现自动租户过滤。


三、常见错误与解决方案

错误现象 原因 解决方案
SQL 未添加 tenant_id 条件 ignoreTable 返回 true 或表名匹配错误 检查表名大小写、拼写
报错 Tenant ID is null getTenantId() 返回 null 确保 TenantContextHolder 已设置值
启动报错找不到 TenantLineHandler 未注入 Spring 容器 使用 @Component 或在配置类中 @Bean 注册
多线程环境下租户 ID 错乱 ThreadLocal 未清理 afterCompletion 中调用 clear()
Expression 类型错误 返回了 String 而非 Expression 使用 new LongValue(123L)new StringValue("xxx")

四、注意事项

  1. getTenantId() 必须返回 Expression 子类
    常用实现:

    • new LongValue(123L)
    • new StringValue("tenant_001")
    • new DoubleValue(1.5)
    • new NullValue()
  2. 避免在 ignoreTable 中忽略错误的表
    如租户业务表被忽略,将导致数据泄露。

  3. ThreadLocal 内存泄漏风险
    务必在请求结束时调用 clear()

  4. 不支持 INSERT 自动填充 tenant_id
    多租户插件只处理 SELECTUPDATEDELETEWHERE 条件。
    INSERTtenant_id 需通过 @TableField(fill = FieldFill.INSERT) + MetaObjectHandler 实现。


五、使用技巧

技巧 1:支持字符串型 tenant_id

@Override
public Expression getTenantId() {
    String tenantCode = TenantContextHolder.getTenantCode();
    return new StringValue(tenantCode);
}

技巧 2:结合 JWT 获取租户信息

在过滤器中解析 JWT Token,提取 tenant_id 并设置到上下文。

技巧 3:动态忽略表(基于注解)

可自定义注解 @IgnoreTenant,在 ignoreTable 中通过反射判断 Mapper 方法是否标记该注解。


六、最佳实践

实践 说明
✅ 使用 ThreadLocal 传递租户 ID 简单高效,适合 Web 场景
✅ 请求结束清理上下文 防止内存泄漏和脏数据
✅ 为系统表添加 ignoreTable 避免无限递归或逻辑错误
✅ INSERT 使用 MetaObjectHandler 填充 保证 tenant_id 字段必填
✅ 生产环境开启日志 记录 SQL,便于调试
✅ 支持多种租户识别方式 如域名、Header、Token 等

七、性能优化

  • 性能影响极小:拦截器仅在 SQL 解析阶段添加条件,无额外查询。
  • 缓存租户信息:若从数据库获取租户 ID,建议缓存(如 Redis)。
  • 避免复杂逻辑getTenantId()ignoreTable 中避免远程调用或复杂计算。
  • 使用连接池:合理配置数据库连接池,减少连接开销。

八、总结

项目 内容
核心接口 TenantLineHandler
拦截器 TenantLineInnerInterceptor
关键方法 getTenantId()ignoreTable()
租户传递 推荐 ThreadLocal
自动填充 INSERT 需配合 MetaObjectHandler
适用场景 SaaS 系统、数据隔离需求

一句话总结
通过实现 TenantLineHandler 并注册 TenantLineInnerInterceptor,MyBatis-Plus 可自动为 SQL 添加租户条件,是实现多租户数据隔离的优雅方案。


📌 立即实践建议

  1. 创建 TenantLineHandler 实现类。
  2. 配置拦截器。
  3. 在过滤器中设置租户 ID。
  4. 测试 SQL 是否自动添加 tenant_id 条件。

⚠️ 切记:多租户安全依赖正确配置,务必测试验证!