MyBatis-Plus 多租户插件扩展:支持当前 + 子租户数据访问

一、前言:为什么需要扩展多租户插件?

在多租户系统设计中,“租户数据隔离”是核心需求之一。MyBatis-Plus 提供的 TenantLineInnerInterceptor 插件通过自动拼接 tenant_id = ? 条件实现单租户数据隔离,满足了大多数基础场景。

但在复杂业务场景中,常常存在“父子租户”层级关系(如总部-分部、平台-子商户),需要支持「当前租户 + 所有子租户」的数据联合查询(例如总部查看所有分部数据)。而官方插件的 getTenantId() 方法仅支持返回单个租户ID,无法满足多租户ID的 IN 条件查询需求。因此,我们需要对官方插件进行扩展,实现“当前 + 子租户”的灵活数据访问控制。

二、核心基础:MyBatis-Plus 多租户插件原理解析

MyBatis-Plus 多租户插件的核心是 TenantLineHandler 接口,通过实现该接口的抽象方法,可自定义租户ID获取、租户字段名、忽略表规则等逻辑。其核心方法如下:


public interface TenantLineHandler {
    /**
     * 获取租户 ID 值表达式,官方明确仅支持单个 ID 值
     * @return 租户 ID 值表达式(如 new LongValue(1001))
     */
    Expression getTenantId();

    /**
     * 获取租户字段名(默认:tenant_id)
     */
    default String getTenantIdColumn() {
        return "tenant_id";
    }

    /**
     * 判断是否忽略当前表的多租户条件拼接
     * @param tableName 表名
     * @return true:忽略(不拼接条件),false:需要拼接
     */
    default boolean ignoreTable(String tableName) {
        return false;
    }

    /**
     * 忽略插入租户字段逻辑(如手动传入tenant_id时跳过自动填充)
     */
    default boolean ignoreInsert(List<Column> columns, String tenantIdColumn) {
        return columns.stream().map(Column::getColumnName).anyMatch(i -> i.equalsIgnoreCase(tenantIdColumn));
    }
}

官方默认实现逻辑:每次SQL操作时,插件会调用 getTenantId() 获取单个租户ID,自动拼接 tenant_id = ? 条件到 WHERE 子句(或 JOIN 条件),实现数据隔离。

官方示例实现类(单租户场景):


@Component
public class CustomTenantHandler implements TenantLineHandler {

    @Override
    public Expression getTenantId() {
        // 从上下文获取当前租户ID
        Long tenantId = TenantContextHolder.getCurrentTenantId();
        // 返回单个租户ID的表达式(LongValue对应SQL的bigint类型)
        return new LongValue(tenantId);
    }

    @Override
    public String getTenantIdColumn() {
        return "tenant_id";
    }
}

三、扩展设计:核心需求与实现思路

3.1 核心需求

  • 支持两种查询模式:单租户查询(tenant_id = ?)、当前+子租户查询(tenant_id IN (?, ?, ...));

  • 可通过配置指定哪些表需要启用“当前+子租户”查询;

  • 兼容官方插件原有逻辑,不侵入基础业务;

  • 线程安全:通过 ThreadLocal 传递当前表名、租户信息,避免多线程干扰。

3.2 实现思路

  1. 扩展 TenantContextHolder:增加子租户列表的存储与传递;

  2. 自定义 TenantLineHandler 实现:重写 getTenantId(),根据场景动态返回「单个租户ID表达式」或「IN多租户表达式」;

  3. 解决 JSQLParser 拼接问题:重写 ExpressionList 确保 IN 条件正确拼接括号;

  4. 自定义拦截器:继承 TenantLineInnerInterceptor,重写 buildTableExpression 方法,根据表达式类型(单租户/多租户)动态构建SQL条件;

  5. 灵活配置:通过配置文件指定忽略表、需要启用子租户查询的表。

四、具体实现:分步编码实现扩展功能

4.1 步骤1:扩展 TenantContextHolder 上下文

基于线程上下文存储当前租户ID和子租户列表,确保多线程环境下数据隔离。此处复用 yudao 框架的 TenantContextHolder,新增子租户列表相关方法:


public class TenantContextHolder {
    /** 当前租户编号 */
    private static final ThreadLocal<Long> TENANT_ID = new TransmittableThreadLocal<>();
    /** 当前租户的所有子租户编号列表 */
    private static final ThreadLocal<List<Long>> CHILDREN_TENANT_ID = new TransmittableThreadLocal<>();
    /** 是否忽略租户条件 */
    private static final ThreadLocal<Boolean> IGNORE = new TransmittableThreadLocal<>();

    // ========== 新增:子租户列表操作 ==========
    public static List<Long> getChildrenTenantId() {
        return CHILDREN_TENANT_ID.get();
    }

    public static void setChildrenTenantId(List<Long> tenantIds) {
        CHILDREN_TENANT_ID.set(tenantIds);
    }

    // ========== 原有方法保留 ==========
    public static Long getTenantId() {
        return TENANT_ID.get();
    }

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

    public static boolean isIgnore() {
        return Boolean.TRUE.equals(IGNORE.get());
    }

    public static void setIgnore(Boolean ignore) {
        IGNORE.set(ignore);
    }

    // 清空上下文(关键:避免线程复用导致数据污染)
    public static void clear() {
        TENANT_ID.remove();
        CHILDREN_TENANT_ID.remove(); // 新增:清空子租户列表
        IGNORE.remove();
    }
}

4.2 步骤2:解决 JSQLParser 括号拼接问题

使用 JSQLParser 构建 IN 表达式时,默认的 ExpressionList 调用 toString() 不会拼接括号,导致生成 tenant_id IN ?, ? 语法错误。因此,重写 ExpressionList 类,强制拼接括号:


/**
 * 重写 ExpressionList,解决 IN 条件缺少括号问题
 */
public class BracketedExpressionList extends ExpressionList<Expression> {

    public BracketedExpressionList(List<Expression> expressions) {
        super(expressions);
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("("); // 强制添加左括号
        for (int i = 0; i < this.size(); i++) {
            if (i > 0) {
                sb.append(", ");
            }
            sb.append(this.get(i));
        }
        sb.append(")"); // 强制添加右括号
        return sb.toString();
    }
}

4.3 步骤3:自定义 TenantLineHandler 实现动态表达式

核心逻辑:重写 getTenantId(),根据当前表是否需要启用“子租户查询”,动态返回「单租户 = 表达式」或「多租户 IN 表达式」。


import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import net.sf.jsqlparser.expression.Column;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.expression.operators.relational.InExpression;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.CollectionUtils;

import java.util.*;
import java.util.stream.Collectors;

public class CustomTenantHandler implements TenantLineHandler {
    // 新增:日志对象(便于问题排查)
    private static final Logger log = LoggerFactory.getLogger(CustomTenantHandler.class);

    // 租户相关服务(用于查询子租户列表)
    private final TenantFrameworkService tenantFrameworkService;
    // 忽略多租户的表名集合(配置文件注入)
    private final Set<String> ignoreTables = new HashSet<>();
    // 需要启用「当前+子租户」查询的表名集合(配置文件注入)
    private final Set<String> multiTenantTables = new HashSet<>();
    // 传递当前查询的表名(线程安全)
    private static final InheritableThreadLocal<String> CURRENT_TABLE_NAME = new InheritableThreadLocal<>();

    // 构造方法:注入配置和服务
    public CustomTenantHandler(TenantProperties properties, TenantFrameworkService tenantFrameworkService) {
        this.tenantFrameworkService = tenantFrameworkService;
        // 初始化忽略表(兼容大小写,适配不同数据库)
        properties.getIgnoreTables().forEach(table -> {
            ignoreTables.add(table.toLowerCase());
            ignoreTables.add(table.toUpperCase());
        });
        // 初始化需要子租户查询的表(兼容大小写)
        properties.getMultiTenantTables().forEach(table -> {
            multiTenantTables.add(table.toLowerCase());
            multiTenantTables.add(table.toUpperCase());
        });
        // 特殊处理:Oracle 主键生成时查询的 DUAL 表需忽略
        ignoreTables.add("DUAL");
    }

    /**
     * 核心方法:动态返回租户表达式
     * - 普通表:返回单个租户ID的 = 表达式
     * - 多租户表:返回当前+子租户的 IN 表达式
     */
    @Override
    public Expression getTenantId() {
        // 1. 获取当前租户ID(从上下文或登录用户中获取)
        Long currentTenantId = getCurrentTenantId();

        // 2. 判断当前表是否需要启用「子租户查询」
        if (!isMultiTenantTable()) {
            // 普通表:使用单租户 = 表达式(兼容官方逻辑)
            return new LongValue(currentTenantId);
        }

        // 3. 获取当前租户的所有子租户ID(递归查询)
        List<Long> tenantIds = getChildrenTenantIds(currentTenantId);
        // 4. 合并当前租户ID和子租户ID(去重)
        tenantIds.add(currentTenantId);
        tenantIds = tenantIds.stream().distinct().collect(Collectors.toList());

        // 5. 构建动态表达式(单租户用 =,多租户用 IN)
        return buildTenantExpression(tenantIds);
    }

    /**
     * 获取当前租户ID(优先级:上下文 > 登录用户)
     */
    private Long getCurrentTenantId() {
        Long currentTenantId = TenantContextHolder.getTenantId();
        if (currentTenantId != null) {
            return currentTenantId;
        }
        // 从登录用户中获取租户ID(适配权限框架)
        LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
        if (loginUser != null && loginUser.getTenantId() != null) {
            currentTenantId = loginUser.getTenantId();
            TenantContextHolder.setTenantId(currentTenantId);
            return currentTenantId;
        }
        // 无租户ID时抛出异常(根据业务调整,可改为默认租户)
        throw new NullPointerException("TenantContextHolder 不存在租户编号!请先设置租户上下文");
    }

    /**
     * 查询当前租户的所有子租户ID(通过业务服务递归查询)
     */
    private List<Long> getChildrenTenantIds(Long currentTenantId) {
        List<Long> childrenTenantIds = TenantContextHolder.getChildrenTenantId();
        if (!CollectionUtils.isEmpty(childrenTenantIds)) {
            return childrenTenantIds;
        }
        // 从业务服务查询子租户(如递归查询租户表的 parent_id 字段)
        if (tenantFrameworkService != null) {
            try {
                childrenTenantIds = tenantFrameworkService.getChildrenTenantIdListByParentId(currentTenantId);
                TenantContextHolder.setChildrenTenantId(childrenTenantIds);
            } catch (Exception e) {
                log.warn("查询子租户列表失败,当前租户ID:{}", currentTenantId, e);
            }
        }
        return childrenTenantIds == null ? new ArrayList<>() : childrenTenantIds;
    }

    /**
     * 构建租户表达式:单租户用 =,多租户用 IN
     */
    private Expression buildTenantExpression(List<Long> tenantIds) {
        if (tenantIds.size() == 1) {
            // 单租户:返回 = 表达式
            return new LongValue(tenantIds.get(0));
        } else {
            // 多租户:返回 IN 表达式
            return createInExpression(tenantIds);
        }
    }

    /**
     * 构建 IN 表达式:tenant_id IN (?, ?, ...)
     */
    private InExpression createInExpression(List<Long> tenantIds) {
        // 左侧:租户字段(tenant_id)
        Column tenantColumn = new Column(getTenantIdColumn());
        // 右侧:租户ID列表(用重写的 BracketedExpressionList 确保括号)
        List<Expression> idExpressions = tenantIds.stream()
                .map(LongValue::new)
                .collect(Collectors.toList());
        BracketedExpressionList expressionList = new BracketedExpressionList(idExpressions);

        // 构建 IN 表达式
        InExpression inExpression = new InExpression();
        inExpression.setLeftExpression(tenantColumn);
        inExpression.setRightExpression(expressionList);
        return inExpression;
    }

    /**
     * 判断当前表是否需要启用「子租户查询」
     */
    private boolean isMultiTenantTable() {
        String tableName = CURRENT_TABLE_NAME.get();
        return tableName != null && multiTenantTables.contains(tableName);
    }

    // ========== 实现接口其他方法 ==========
    @Override
    public String getTenantIdColumn() {
        return "tenant_id"; // 租户字段名,可通过配置注入
    }

    @Override
    public boolean ignoreTable(String tableName) {
        // 忽略条件:1. 全局忽略标识 2. 表名在忽略列表中
        return TenantContextHolder.isIgnore() || ignoreTables.contains(tableName);
    }

    // ========== 表名传递工具方法 ==========
    public static void setCurrentTableName(String tableName) {
        CURRENT_TABLE_NAME.set(tableName);
    }

    public static void clearCurrentTableName() {
        CURRENT_TABLE_NAME.remove();
    }
}

4.4 步骤4:自定义拦截器动态构建SQL条件

MyBatis-Plus 官方拦截器的 buildTableExpression 方法会默认拼接 tenant_id = ? 条件,无法直接复用。因此,继承官方拦截器,重写该方法:根据 getTenantId() 返回的表达式类型(单租户/多租户),动态构建 SQL 条件。


import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.operators.relational.InExpression;
import net.sf.jsqlparser.schema.Table;

/**
 * 支持「当前+子租户」查询的多租户拦截器
 * 核心:根据表达式类型动态选择拼接 = 或 IN 条件
 */
public class MultiTenantLineInnerInterceptor extends TenantLineInnerInterceptor {

    // 注入自定义的租户处理器
    public MultiTenantLineInnerInterceptor(CustomTenantHandler tenantHandler) {
        super(tenantHandler);
    }

    /**
     * 重写表表达式构建逻辑
     * @param table 表对象
     * @param where WHERE条件表达式
     * @param whereSegment WHERE片段字符串
     * @return 构建后的租户条件表达式
     */
    @Override
    public Expression buildTableExpression(final Table table, final Expression where, final String whereSegment) {
        // 1. 设置当前表名到上下文(供 CustomTenantHandler 判断是否启用子租户查询)
        CustomTenantHandler.setCurrentTableName(table.getName());
        try {
            // 2. 检查是否忽略当前表(忽略则不拼接条件)
            if (this.getTenantLineHandler().ignoreTable(table.getName())) {
                return null;
            }

            // 3. 获取租户表达式(单租户 = 或 多租户 IN)
            Expression tenantIdExpr = this.getTenantLineHandler().getTenantId();

            // 4. 动态构建条件:
            // - 多租户(InExpression):直接返回IN表达式,插件会自动拼接 to WHERE 子句
            // - 单租户:调用父类方法,拼接 = 条件
            if (tenantIdExpr instanceof InExpression) {
                return tenantIdExpr;
            } else {
                return super.buildTableExpression(table, where, whereSegment);
            }
        } finally {
            // 5. 清理表名上下文(避免线程复用导致数据污染)
            CustomTenantHandler.clearCurrentTableName();
        }
    }
}

4.5 步骤5:配置集成(基于 yudao 框架)

通过自动配置类注入自定义处理器和拦截器,同时支持通过配置文件灵活配置忽略表和子租户查询表。

5.1 配置类:YudaoTenantAutoConfiguration
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConditionalOnProperty(prefix = "tenant", name = "enable", havingValue = "true")
public class YudaoTenantAutoConfiguration {

    /**
     * 注入自定义租户处理器
     */
    @Bean
    public CustomTenantHandler customTenantHandler(TenantProperties properties, TenantFrameworkService tenantFrameworkService) {
        return new CustomTenantHandler(properties, tenantFrameworkService);
    }

    /**
     * 注入自定义多租户拦截器(替换官方拦截器)
     */
    @Bean
    public MultiTenantLineInnerInterceptor multiTenantLineInnerInterceptor(
            CustomTenantHandler customTenantHandler,
            MybatisPlusInterceptor mybatisPlusInterceptor) {
        MultiTenantLineInnerInterceptor tenantInterceptor = new MultiTenantLineInnerInterceptor(customTenantHandler);
        // 将租户拦截器添加到 MyBatis-Plus 拦截器链(优先级设为0,确保最先执行)
        MyBatisUtils.addInterceptor(mybatisPlusInterceptor, tenantInterceptor, 0);
        return tenantInterceptor;
    }
}
5.2 配置文件:application.yaml

通过配置文件指定忽略表和需要启用子租户查询的表:


tenant:
  enable: true # 启用多租户
  ignore-tables: # 忽略多租户的表(不拼接tenant_id条件)
    - aa
    - bb
  multi-tenant-tables: # 需要启用「当前+子租户」查询的表
    - a # 示例表名
    - b # 示例表名
5.3 配置类:TenantProperties

定义配置属性类,用于接收配置文件中的参数,此处复用 yudao 框架的TenantProperties,只需要新增multiTenantTables:

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.Collections;
import java.util.Set;

/**
 * 多租户配置
 *
 * @author 芋道源码
 */
@ConfigurationProperties(prefix = "yudao.tenant")
@Data
public class TenantProperties {

    /**
     * 租户是否开启
     */
    private static final Boolean ENABLE_DEFAULT = true;

    /**
     * 是否开启
     */
    private Boolean enable = ENABLE_DEFAULT;

    /**
     * 需要忽略多租户的请求
     *
     * 默认情况下,每个请求需要带上 tenant-id 的请求头。但是,部分请求是无需带上的,例如说短信回调、支付回调等 Open API!
     */
    private Set<String> ignoreUrls = Collections.emptySet();

    /**
     * 需要忽略多租户的表
     *
     * 即默认所有表都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟
     */
    private Set<String> ignoreTables = Collections.emptySet();

    /**
     * 需要忽略多租户的 Spring Cache 缓存
     *
     * 即默认所有缓存都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟
     */
    private Set<String> ignoreCaches = Collections.emptySet();

//===================== 新增 ===========================
    /**
     * 需要开启子租户的表
     *
     * 默认所有表都未开启子租户功能
     */
    private Set<String> multiTenantTables = Collections.emptySet();
}

五、关键补充:注意事项与优化建议

5.1 线程安全问题

所有线程共享的变量(如当前表名、租户ID、子租户列表)必须通过 ThreadLocal 存储,且在使用后及时清理(如finally 块中调用 remove()),避免线程池复用导致数据污染。

5.2 子租户查询性能优化

递归查询子租户列表可能存在性能问题,建议:

  • 缓存子租户列表:将查询结果缓存到 Redis,设置合理过期时间;

  • 租户层级限制:避免过深的租户层级,可在业务中限制最大层级(如3级:总部-分部-门店)。

5.3 数据库索引优化

启用 IN 条件查询后,需确保 tenant_id 字段创建索引,避免大量数据查询时全表扫描。建议索引:idx_tenant_id (tenant_id)

5.4 特殊场景处理

  • Oracle 数据库:需忽略 DUAL 表(主键生成时会查询),否则会拼接租户条件导致报错;

  • 手动指定租户ID:若业务中需要手动查询其他租户数据,可通过 TenantContextHolder.setIgnore(true) 临时忽略租户条件;

  • 批量操作:确保批量插入/更新时,子租户列表的租户ID均有权限操作对应数据(需配合权限校验)。

5.5 兼容性说明

本扩展完全兼容 MyBatis-Plus 官方插件逻辑:

  • 未配置 multiTenantTables 的表,仍使用单租户 = 条件;

  • 支持官方 ignoreTableignoreInsert 等原有功能;

  • jsqlparser版本:4.9 ,MyBatis-Plus版本:3.5.7。

六、总结

本文通过扩展 MyBatis-Plus 多租户插件,实现了「当前 + 子租户」的数据联合查询功能,核心亮点:

  • 动态表达式:根据表配置动态返回 =IN 表达式,兼容单/多租户场景;

  • 灵活配置:通过配置文件指定需要启用子租户查询的表,无需侵入业务代码;

  • 线程安全:基于 ThreadLocal传递上下文,避免多线程干扰。

该扩展方案已在实际项目中验证,适用于各类存在父子租户层级的多租户系统,可直接复用或根据业务需求微调。

七、补充:测试验证步骤(关键复现环节)

7.1 单租户场景测试

  1. 配置文件中不将表 user 加入 multi-tenant-tables
  2. 执行查询:SELECT * FROM user WHERE name = 'test'
  3. 期望结果:插件自动拼接 tenant_id = ?,最终SQL为 SELECT * FROM user WHERE tenant_id = 1001 AND name = 'test'(1001为当前租户ID)。

7.2 多租户场景测试

  1. 配置文件中把表 order 加入 multi-tenant-tables
  2. 确保当前租户(1001)存在子租户(1002、1003);
  3. 执行查询:SELECT * FROM order WHERE status = 1
  4. 期望结果:插件自动拼接 tenant_id IN (1001,1002,1003),最终SQL为SELECT * FROM order WHERE tenant_id IN (1001,1002,1003) AND status = 1

7.3 忽略表场景测试

  1. 配置文件中把表 dict 加入 ignore-tables
  2. 执行查询:SELECT * FROM dict WHERE type = 'sex'
  3. 期望结果:插件不拼接租户条件,最终SQL与原始查询一致。

(注:本文章经过AI润色,文档部分内容可能由 AI 生成,代码部分可参考)

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐