MyBatis-Plus(简称 MP)是 MyBatis 的增强工具,它在 MyBatis 的基础上只做增强,不做改变,为简化开发、提高效率而生。在实际开发中,我们常常需要对 SQL 执行过程进行拦截和增强,比如分页、SQL 性能分析、SQL 重写、数据权限控制等。InnerInterceptor 是 MyBatis-Plus 提供的一种强大的插件机制,用于在 SQL 执行前后进行拦截和处理。


一、核心概念

1. InnerInterceptor 是什么?

InnerInterceptor 是 MyBatis-Plus 框架中用于拦截 SQL 执行过程的核心接口。它是 MyBatis-Plus 自研的拦截器机制,相比原生 MyBatis 的 Interceptor,InnerInterceptor 更加贴近 MP 的执行流程,能够更方便地获取 MP 的上下文信息(如 MetaObject、MappedStatement、BoundSql 等)。

InnerInterceptor 接口定义了多个方法,用于在 SQL 执行的不同阶段进行拦截:

  • beforeQuery:查询前拦截
  • beforePrepare:SQL 准备前拦截
  • afterAll:所有操作后拦截
  • beforeUpdate:更新前拦截
  • willDoQuery:即将执行查询时拦截(可用于分页)
  • willDoUpdate:即将执行更新时拦截

2. 与 MyBatis 原生 Interceptor 的区别

特性 MyBatis 原生 Interceptor MyBatis-Plus InnerInterceptor
拦截粒度 拦截 Executor、StatementHandler 等 拦截 MP 内部执行流程,更贴近业务
上下文信息 需手动解析 Invocation 提供 PluginChain、MetaObject 等封装
易用性 较复杂,需理解 MyBatis 内部机制 更简单,适合 MP 场景
扩展性 通用性强 专为 MP 设计,集成度高

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

下面以实现一个 SQL 打印与执行时间统计 的 InnerInterceptor 插件为例,详细说明开发步骤。

步骤 1:创建 Maven 项目并引入依赖

<dependencies>
    <!-- MyBatis-Plus 核心依赖 -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.3.1</version> <!-- 推荐使用最新稳定版 -->
    </dependency>

    <!-- Spring Boot Web(可选,用于测试) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>2.7.12</version>
    </dependency>

    <!-- Lombok(简化代码) -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.30</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

步骤 2:编写自定义 InnerInterceptor 实现类

import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;

import java.sql.Connection;
import java.util.Properties;

/**
 * 自定义 InnerInterceptor:SQL 打印与执行时间统计
 */
public class SqlPrintInterceptor implements InnerInterceptor {

    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(SqlPrintInterceptor.class);

    /**
     * 查询前拦截:用于记录开始时间
     */
    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds,
                            ResultHandler resultHandler, BoundSql boundSql) {
        long startTime = System.currentTimeMillis();
        // 将开始时间存入上下文,便于后续使用
        executor.getTransaction().getConnection().setClientInfo("startTime", String.valueOf(startTime));
    }

    /**
     * 所有操作后拦截:用于计算执行时间并打印 SQL
     */
    @Override
    public void afterAll(Executor executor, MappedStatement ms, Object parameter, BoundSql boundSql) {
        try {
            Connection connection = executor.getTransaction().getConnection();
            String startTimeStr = connection.getClientInfo("startTime");
            if (startTimeStr != null) {
                long startTime = Long.parseLong(startTimeStr);
                long endTime = System.currentTimeMillis();
                long costTime = endTime - startTime;

                // 获取 SQL
                String sql = boundSql.getSql();
                // 获取参数
                Object param = boundSql.getParameterObject();

                log.info("【SQL 执行耗时】: {} ms", costTime);
                log.info("【SQL】: {}", sql);
                log.info("【参数】: {}", param);
            }
        } catch (Exception e) {
            log.error("SQL 日志记录失败", e);
        }
    }

    /**
     * 插件初始化(可选)
     */
    @Override
    public void setProperties(Properties props) {
        // 可以从配置文件读取参数
    }
}

⚠️ 注意:getClientInfo 并非标准 JDBC 方法,此处仅为示例。实际中建议使用 ThreadLocal 或 PluginChain 上下文传递数据。

步骤 3:使用 ThreadLocal 优化上下文传递

public class SqlPrintInterceptor implements InnerInterceptor {

    private static final ThreadLocal<Long> startTimeThreadLocal = new ThreadLocal<>();

    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds,
                            ResultHandler resultHandler, BoundSql boundSql) {
        startTimeThreadLocal.set(System.currentTimeMillis());
    }

    @Override
    public void afterAll(Executor executor, MappedStatement ms, Object parameter, BoundSql boundSql) {
        Long startTime = startTimeThreadLocal.get();
        if (startTime != null) {
            long costTime = System.currentTimeMillis() - startTime;
            String sql = boundSql.getSql();
            Object param = boundSql.getParameterObject();

            log.info("【SQL 执行耗时】: {} ms", costTime);
            log.info("【SQL】: {}", sql);
            log.info("【参数】: {}", param);

            // 清理 ThreadLocal
            startTimeThreadLocal.remove();
        }
    }
}

步骤 4:注册插件到 MyBatis-Plus

方式一:Spring Boot 配置类

import com.baomidou.mybatisplus.extension.plugins.MyBatisPlusInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MyBatisPlusConfig {

    @Bean
    public MyBatisPlusInterceptor myBatisPlusInterceptor() {
        MyBatisPlusInterceptor interceptor = new MyBatisPlusInterceptor();
        // 添加自定义插件
        interceptor.addInnerInterceptor(new SqlPrintInterceptor());
        return interceptor;
    }
}

方式二:直接在 application.yml 中配置(不推荐用于自定义逻辑)

仅适用于 MP 内置插件,如分页插件。


三、常见错误

错误 原因 解决方案
插件未生效 未正确注册到 MyBatisPlusInterceptor 检查 @Bean 是否被 Spring 扫描到
ThreadLocal 内存泄漏 未调用 remove() 在 afterAll 中务必 remove()
SQL 打印乱码 参数包含复杂对象 使用 JSON.toJSONString() 格式化参数
多线程下数据错乱 共享变量未隔离 使用 ThreadLocal 隔离线程数据
afterAll 未执行 异常中断 确保异常被捕获,不影响插件执行

四、注意事项

  1. 线程安全:InnerInterceptor 是单例的,所有方法可能被多线程并发调用,避免使用实例变量。
  2. 性能影响:插件逻辑应尽量轻量,避免阻塞或复杂计算。
  3. 异常处理:插件内部异常不应影响主流程,建议 try-catch 包裹。
  4. SQL 格式化:生产环境建议格式化 SQL,便于阅读。
  5. 日志级别:建议使用 DEBUG 或 TRACE 级别,避免生产日志过多。

五、使用技巧

  1. 结合 AOP 做更复杂控制:如结合 Spring AOP 实现权限拦截。
  2. 动态开关插件:通过配置中心动态启用/禁用插件。
  3. SQL 重写:在 beforeQuery 中修改 boundSql.sql 实现 SQL 重写(如添加租户字段)。
  4. 慢 SQL 监控:在 afterAll 中判断执行时间,超过阈值则告警。
  5. 结合 SkyWalking / Prometheus:将 SQL 执行时间上报监控系统。

六、最佳实践与性能优化

最佳实践

  • ✅ 使用 ThreadLocal 传递上下文数据
  • ✅ 插件逻辑轻量,避免复杂计算
  • ✅ 日志输出可配置(开关、级别、格式)
  • ✅ 插件可插拔,支持动态加载
  • ✅ 单元测试覆盖核心逻辑

性能优化

  1. 减少日志 I/O:使用异步日志(如 Logback AsyncAppender)
  2. 缓存 SQL 解析结果:对频繁执行的 SQL 可缓存其结构
  3. 避免重复解析:BoundSql 已经是解析后的结果,无需再次解析
  4. 使用对象池:如 StringBuilder 复用,减少 GC
  5. 采样日志:高并发场景下可采样打印 SQL(如每 100 次打印一次)

七、扩展:实现分页插件(简化版)

public class SimplePaginationInterceptor implements InnerInterceptor {

    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds,
                            ResultHandler resultHandler, BoundSql boundSql) {
        if (rowBounds != RowBounds.DEFAULT) {
            // 重写 SQL 实现分页(此处简化,实际应使用 MP 自带分页)
            String originalSql = boundSql.getSql();
            String paginatedSql = originalSql + " LIMIT " + rowBounds.getOffset() + "," + rowBounds.getLimit();
            // 修改 boundSql(需通过反射或 PluginChain 修改)
        }
    }
}

⚠️ 实际开发中建议使用 MP 内置的 PaginationInnerInterceptor。


总结

通过实现 InnerInterceptor,你可以深度定制 MyBatis-Plus 的 SQL 执行流程,实现日志、监控、权限、分页、SQL 重写等高级功能。掌握其核心原理和开发步骤,能够显著提升开发效率和系统可观测性。