一、核心概念

1. 多租户 SQL 拼接的本质

多租户 SQL 拼接是指在执行数据库查询或更新操作时,MyBatis-Plus 自动在原始 SQL 中追加 tenant_id = ? 条件,以实现数据隔离。这一过程对开发者透明,无需手动编写 WHERE tenant_id = ?

目标:确保每个租户只能访问属于自己的数据,防止跨租户数据泄露。

2. 实现机制:InnerInterceptor 拦截器链

MyBatis-Plus 从 3.4.0 版本起采用 InnerInterceptor 架构,通过拦截 MyBatis 的 SQL 执行流程,在 SQL 解析阶段动态修改 Statement 对象。

  • 关键类TenantLineInnerInterceptor
  • 作用时机:在 SQL 构建为 MappedStatement 前进行干预
  • 拼接方式:使用 MyBatis 的 Expression API 在 WHERE 子句中注入条件

3. SQL 拼接触发场景

SQL 类型 是否自动拼接 说明
SELECT 自动添加 AND tenant_id = ?
UPDATE 添加 WHERE tenant_id = ? 作为安全条件
DELETE 同上,防止误删其他租户数据
INSERT 不拼接,但通过 MetaObjectHandler 自动填充字段

⚠️ 注意:INSERT 不需要拼接 WHERE,而是通过自动填充将 tenant_id 写入记录。


二、SQL 拼接原理深度解析

1. 执行流程图解

[用户发起查询] 
       ↓
[MyBatis 执行 SQL]
       ↓
[MyBatis-Plus 拦截器链触发]
       ↓
[TenantLineInnerInterceptor 拦截]
       ↓
[判断表是否需要租户隔离(ignoreTable)]
       ↓
[获取当前线程的 tenantId(getTenantId)]
       ↓
[解析 SQL AST(抽象语法树)]
       ↓
[在 WHERE 条件中追加 AND tenant_id = ?]
       ↓
[继续执行修改后的 SQL]
       ↓
[返回结果]

2. SQL 改写原理(AST 层面)

MyBatis-Plus 使用 JSQLParser 解析 SQL,构建抽象语法树(AST),然后在 WHERE 节点中插入新的条件表达式。

示例:原始 SQL

SELECT * FROM user WHERE status = 1

经过 TenantLineInnerInterceptor 处理后

SELECT * FROM user WHERE status = 1 AND tenant_id = 1001

内部 AST 操作逻辑:

  • 如果原 SQL 有 WHERE:追加 AND tenant_id = ?
  • 如果原 SQL 无 WHERE:新增 WHERE tenant_id = ?
  • 支持复杂查询如 JOIN, UNION, SUBQUERY 的正确嵌套处理

三、操作步骤(详细到每一行代码)

步骤 1:引入依赖(确保版本 ≥ 3.4.0)

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

✅ 推荐使用最新稳定版,避免旧版 SqlParserFilter 的废弃问题。


步骤 2:实体类添加租户字段

@Data
@TableName("user")
public class User {
    private Long id;
    private String name;
    private Integer status;

    // 标记为插入时自动填充
    @TableField(fill = FieldFill.INSERT)
    private Long tenantId;
}

🔍 @TableField(fill = FieldFill.INSERT) 表示插入时由 MetaObjectHandler 填充。


步骤 3:实现自动填充处理器

@Component
@Slf4j
public class MyMetaObjectHandler implements MetaObjectHandler {

    @Override
    public void insertFill(MetaObject metaObject) {
        Long tenantId = TenantContext.getTenantId();
        if (tenantId != null && getFieldValByName("tenantId", metaObject) == null) {
            this.strictInsertFill(metaObject, "tenantId", Long.class, tenantId);
        }
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        // 更新时不修改 tenantId
    }
}

strictInsertFill 确保字段为 null 时才填充,避免覆盖已有值。


步骤 4:创建租户上下文(ThreadLocal 管理)

public class TenantContext {
    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(); // 必须 remove 防止内存泄漏
    }
}

步骤 5:配置 TenantLineInnerInterceptor(核心)

@Configuration
@MapperScan("com.example.mapper")
public class MyBatisPlusConfig {

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

        TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor(
            new TenantLineHandler() {
                @Override
                public String getTenantId() {
                    Long id = TenantContext.getTenantId();
                    if (id == null) {
                        throw new TenantNotSetException("当前请求未设置租户ID");
                    }
                    return id.toString(); // 返回字符串形式
                }

                @Override
                public String getTenantIdColumn() {
                    return "tenant_id"; // 数据库字段名
                }

                @Override
                public boolean ignoreTable(String tableName) {
                    // 系统表不启用多租户
                    return Set.of("sys_user", "sys_role", "sys_dept").contains(tableName.toLowerCase());
                }

                // 可选:自定义租户字段别名
                @Override
                public String getTenantAlias() {
                    return null; // 默认使用表别名或无别名
                }
            }
        );

        interceptor.addInnerInterceptor(tenantInterceptor);
        return interceptor;
    }
}

ignoreTable 是性能关键,避免对系统表进行无谓判断。


步骤 6:在请求中设置租户 ID(如通过拦截器)

@Component
public class TenantInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String header = request.getHeader("X-Tenant-Id");
        if (StringUtils.isEmpty(header)) {
            throw new IllegalArgumentException("Missing X-Tenant-Id header");
        }
        try {
            Long tenantId = Long.valueOf(header);
            TenantContext.setTenantId(tenantId);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("Invalid tenant ID format");
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        TenantContext.clear(); // 清理 ThreadLocal
    }
}

注册拦截器:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private TenantInterceptor tenantInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(tenantInterceptor)
                .addPathPatterns("/api/**");
    }
}

四、常见错误与排查

错误现象 原因分析 解决方案
SQL 中未出现 tenant_id 条件 ignoreTable 返回 true 或表名不匹配 检查表名大小写、是否在忽略列表中
报错 tenant_id 字段不存在 数据库表缺少该字段 添加字段并建立索引
多线程下租户错乱 ThreadLocal 未清理 确保 afterCompletion 调用 clear()
插入时报 tenant_id 为空 MetaObjectHandler 未生效 检查 @Component@TableField(fill=...)
联表查询出错 某些表没有 tenant_id 字段 所有关联表都需有该字段或配置 ignoreTable

五、注意事项

  1. 字段一致性:所有参与多租户的表必须包含 tenant_id 字段。
  2. 索引必须加tenant_id 应作为联合索引的前导列,否则全表扫描。
  3. 避免动态表名:如 FROM ${tableName} 会导致 AST 解析失败。
  4. 不支持 UNION 外层 WHERE:复杂 SQL 需测试验证。
  5. 缓存穿透风险:若使用 MyBatis 一级/二级缓存,需注意缓存键包含 tenant_id

六、使用技巧

1. 调试 SQL 拼接结果

开启 MyBatis 日志,查看最终执行的 SQL:

# application.yml
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

输出示例:

==>  Preparing: SELECT * FROM user WHERE status = ? AND tenant_id = ?
==> Parameters: 1(Integer), 1001(Long)

2. 单元测试验证拼接逻辑

@Test
public void testSelectWithTenant() {
    TenantContext.setTenantId(1001L);
    List<User> users = userMapper.selectList(Wrappers.<User>lambdaQuery().eq(User::getStatus, 1));
    // 断言:SQL 应包含 AND tenant_id = 1001
}

3. 临时关闭多租户(如后台管理)

// 在需要绕过的代码块中
TenantContext.setTenantId(null); // 抛出异常或配置 allowEmpty = true

或通过 ignoreTable 配置系统表。


七、最佳实践与性能优化

✅ 最佳实践

实践 说明
统一字段命名 所有表使用 tenant_id BIGINT
联合索引设计 (tenant_id, status, create_time) 提升查询效率
逻辑删除共用 tenant_id + deleted = 0 双重过滤
API 显式传递 Header 传递 X-Tenant-Id,便于网关识别
租户上下文封装 提供 @LoginTenant 注解自动注入

⚡ 性能优化建议

  1. 索引优化

    -- 错误:单独索引
    CREATE INDEX idx_tenant ON user(tenant_id);
    
    -- 正确:联合索引(根据查询场景)
    CREATE INDEX idx_tenant_status_time ON user(tenant_id, status, create_time DESC);
    
  2. 减少 AST 解析开销

    • 避免过于复杂的嵌套查询
    • 使用 ignoreTable 排除不需要拦截的表
  3. 连接池配置

    • 增大连接池大小,因多租户可能增加并发连接数
  4. 分库分表准备

    • 若未来需分库,建议初期设计支持 tenant_id 作为分片键

八、高级场景:自定义 SQL 拼接逻辑

若需更复杂控制(如不同租户不同策略),可继承 TenantLineInnerInterceptor

public class CustomTenantInterceptor extends TenantLineInnerInterceptor {
    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, 
                            RowBounds rowBounds, ResultHandler resultHandler, 
                            BoundSql boundSql) {
        // 可在此处修改 SQL 或参数
        super.beforeQuery(executor, ms, parameter, rowBounds, resultHandler, boundSql);
    }
}

总结

MyBatis-Plus 多租户 SQL 拼接的核心是:

TenantLineInnerInterceptor + JSQLParser AST 修改 + ThreadLocal 上下文 + MetaObjectHandler 自动填充

只要掌握以下四点,即可熟练应用:

  1. ✅ 正确配置 TenantLineHandler
  2. ✅ 实现 MetaObjectHandler 填充插入字段
  3. ✅ 使用拦截器设置 TenantContext
  4. ✅ 为 tenant_id 建立高效索引

这套机制几乎零侵入,是实现 SaaS 系统多租户隔离的首选方案