MyBatis插件机制与拦截器链源码分析

引言

做过几年业务系统的同学,大概率都遇到过这样的需求:想给所有 SQL 统一加上租户条件、想统计每条 SQL 的耗时、想对敏感字段做脱敏、想在分页时自动改写 SQL……这些需求有个共同点——它们都发生在 SQL 执行的前后,但你不想改业务代码,也不想改 MyBatis 源码。

这时候,MyBatis 的插件机制(Plugin)就派上用场了。分页插件 PageHelper、SQL 监控插件 p6spy 的 MyBatis 集成、数据权限插件,几乎都是基于这套机制实现的。

但真正读过源码的人会发现一个问题:你明明传了一个 Interceptor 进去,为什么它返回的 Executor 却变成了一个 Plugin 对象? 更迷惑的是,Plugin 类实现了 InvocationHandler——它居然是个动态代理!也就是说,MyBatis 的插件本质是 JDK 动态代理的一层"马甲"。

这篇文章我们从一次生产事故讲起,然后一路挖到 Plugin.wrap() 和 InvocationChain 的源码,把整条拦截器链彻底讲透。读完之后,你不仅能写出正确的插件,还能避开那些"插件一多就乱序""拦截不到嵌套查询"的坑。


核心概念:餐厅后厨与"中间人"

先打个比方。

想象一家餐厅的后厨,主厨(Executor)负责真正炒菜,但餐厅里还有几类角色:采购员(StatementHandler,负责拿食材)、配菜员(ParameterHandler,负责处理调料)、摆盘师(ResultSetHandler,负责装盘)。

现在老板(业务方)提了个需求:每道菜出锅前都要过一遍质检。你不想让主厨改流程,也不想让采购员分心。于是老板安排了一个"质检员"站在主厨和点单台之间:所有点单先经过质检员,质检员可以看一眼、记个时间、甚至偷偷改一下订单内容,然后再转给主厨。

这个"质检员"就是 Interceptor(拦截器)。而站岗的岗位——主厨、采购员、配菜员、摆盘师——就是 MyBatis 允许你拦截的四个核心接口。

技术上讲,MyBatis 插件机制由三部分组成:

  1. Interceptor 接口:定义 intercept(Invocation)、plugin(Object)、setProperties(Properties) 三个方法。
  2. @Intercepts + @Signature 注解:声明你要拦截哪个类的哪个方法,参数类型是什么。
  3. Plugin.wrap():把目标对象包成 JDK 动态代理,代理的 InvocationHandler 就是 Plugin 本身。

关键点在于:拦截器不是"链式调用"的,而是"套娃式"的。多个插件会一层层包裹,最外层的插件先执行,最后才是真实目标对象。

graph TD A["业务调用 executor.query()"] --> B["Plugin (拦截器C)"] B --> C["Plugin (拦截器B)"] C --> D["Plugin (拦截器A)"] D --> E["真实 Executor (CachingExecutor)"] E --> F["JDBC 驱动"] style B fill:#f9f,stroke:#333 style C fill:#f9f,stroke:#333 style D fill:#f9f,stroke:#333 style E fill:#bbf,stroke:#333

看到这张图,你就能理解为什么插件的注册顺序和执行顺序是相反的——先注册的插件在 wrap 时先被包上,后来者又包在它外面。这个"套娃"特性是很多坑的根源,后面会重点讲。


源码深度分析:从 wrap 到 invoke

1. 插件是如何被"装配"的

MyBatis 启动时,XMLConfigBuilder 解析 标签,每个 生成一个 Interceptor 实例,塞进 Configuration.interceptorChain:

// Configuration.java(节选)
public void addInterceptor(Interceptor interceptor) {
    interceptorChain.addInterceptor(interceptor);
}

然后到了创建四大核心对象的环节。以 Executor 为例,newExecutor 方法最后一步:

// Configuration.java(节选)
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
    executorType = executorType == null ? defaultExecutorType : executorType;
    Executor executor;
    if (ExecutorType.BATCH == executorType) {
        executor = new BatchExecutor(this, transaction);
    } else if (ExecutorType.REUSE == executorType) {
        executor = new ReuseExecutor(this, transaction);
    } else {
        executor = new SimpleExecutor(this, transaction);
    }
    if (cacheEnabled) {
        executor = new CachingExecutor(executor);
    }
    // 关键:套上所有插件
    executor = (Executor) interceptorChain.pluginAll(executor);
    return executor;
}

InterceptorChain.pluginAll 的源码非常朴素,但正是"套娃"的源头:

// InterceptorChain.java
public Object pluginAll(Object target) {
    for (Interceptor interceptor : interceptors) {
        target = interceptor.plugin(target);
    }
    return target;
}

注意这里:target 被反复重新赋值。第一个插件包装后,第二个插件包的是"第一个插件包过的对象",以此类推。所以 interceptors 列表中越靠前的插件,越先被套上,也就越靠内层——执行时越晚。

2. Interceptor.plugin 默认实现:Plugin.wrap

Interceptor 接口本身有个 default 方法:

default Object plugin(Object target) {
    return Plugin.wrap(target, this);
}

Plugin.wrap 就是动态代理的入口:

// Plugin.java
public static Object wrap(Object target, Interceptor interceptor) {
    // 1. 解析 @Intercepts 注解,得到 "接口 -> 方法签名集合" 的映射
    Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
    Class<?> type = target.getClass();
    // 2. 找出目标对象实现了哪些被拦截的接口
    Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
    // 3. 如果一个都没实现,直接返回原对象(注意这个细节!)
    if (interfaces.length > 0) {
        return Proxy.newProxyInstance(
            type.getClassLoader(),
            interfaces,
            new Plugin(target, interceptor, signatureMap));
    }
    return target;
}

这里有三个细节值得深挖:

细节一:getSignatureMap 解析注解。 它把 @Signature 的 type、method、args 组合成 Method 对象,放进以接口类为 key 的 Map。注意 Method 的构造可能抛 NoSuchMethodException,如果注解写错了方法名或参数类型,启动时就会直接抛异常——这其实是好事,早失败早暴露。

细节二:getAllInterfaces 只返回"被拦截的接口"。 比如你只拦截 Executor,那么 CachingExecutor 实现的 Executor 接口会被返回,但 CachingExecutor 自己的类不会被代理。这意味着你无法拦截目标类的非接口方法——这是 JDK 动态代理的固有局限,不是 MyBatis 的锅。

细节三:没有匹配接口时返回原对象。 所以一个插件如果签名写错,不会报错,而是"静默失效"。这是最常见的坑之一。

3. Plugin.invoke:拦截器链的真正执行者

Plugin 实现了 InvocationHandler,所有代理方法调用都会进入 invoke:

// Plugin.java
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
        // 1. 拿到当前方法所属接口被拦截的方法集合
        Set<Method> methods = signatureMap.get(method.getDeclaringClass());
        // 2. 如果当前方法在拦截范围内,交给拦截器处理
        if (methods != null && methods.contains(method)) {
            return interceptor.intercept(new Invocation(target, method, args));
        }
        // 3. 否则直接反射调用真实对象
        return method.invoke(target, args);
    } catch (Exception e) {
        throw ExceptionUtil.unwrapThrowable(e);
    }
}

注意 method.getDeclaringClass() 这一行。假设你拦截 Executor.query,而 CachingExecutor 重写了 query,那么 method.getDeclaringClass() 是 CachingExecutor 还是 Executor?答案是取决于方法在哪个类中声明。如果 CachingExecutor 自己声明了 query,getDeclaringClass() 就是 CachingExecutor,而你的 signatureMap 里 key 是 Executor——匹配不上,拦截失效!

这是 MyBatis 插件最阴险的坑之一。实际上 CachingExecutor 确实重写了 query,但 signatureMap 的 key 是注解里写的 Executor.class。那为什么 PageHelper 能拦到?因为 getSignatureMap 里对 @Signature.type 用的是接口类,而 CachingExecutor.query 的 declaringClass 是 CachingExecutor……等等,这里需要澄清:Method.getDeclaringClass() 返回的是方法"首次声明"的类。CachingExecutor 的 query 是它自己声明的(重写),所以 declaringClass 是 CachingExecutor。那匹配岂不是必然失败?

实际源码里 getSignatureMap 用的是 type.getMethod(...),得到的 Method 的 declaringClass 就是 type 本身(接口)。而代理对象调用时传入的 method 是接口方法(因为 Proxy.newProxyInstance 传入的 interfaces 是接口数组),所以 method.getDeclaringClass() 返回的是接口。这就是为什么 getAllInterfaces 只返回接口、Proxy 也只代理接口方法——保证 declaringClass 与 signatureMap 的 key 一致。理解这一点,你就明白 JDK 代理在这里为什么"必须"用接口了。

4. Invocation:拦截器手里的"接力棒"

// Invocation.java
public class Invocation {
    private final Object target;
    private final Method method;
    private final Object[] args;

    public Object proceed() throws InvocationTargetException, IllegalAccessException {
        return method.invoke(target, args);
    }
}

proceed() 就是"把活交给下一棒"。但注意:它调用的是 target 的方法,而 target 是"下一个插件代理对象"或"真实对象"。所以当拦截器 A 的 intercept 里调用 invocation.proceed(),实际触发的是拦截器 B 的 invoke,B 再 proceed 到 C……最终到真实对象。这就是"责任链"的实现方式——用动态代理的嵌套调用模拟出链式效果。

一个标准的拦截器写法:

@Intercepts({
    @Signature(type = Executor.class, method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class TimingInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        long start = System.nanoTime();
        try {
            return invocation.proceed(); // 交给下一棒
        } finally {
            long cost = System.nanoTime() - start;
            System.out.println("SQL 耗时: " + cost / 1_000_000 + "ms");
        }
    }
}

实战代码:三个可运行的完整示例

下面三个示例都可以直接放进 Spring Boot + MyBatis 项目运行。为了聚焦插件本身,省略了部分样板代码。

示例一:SQL 耗时统计与慢 SQL 告警

这个插件拦截 Executor 的 query 和 update,统计耗时并打印慢 SQL。注意它必须同时拦截 query 和 update,否则更新操作统计不到。

import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;

import java.util.Properties;

/**
 * 慢 SQL 监控插件
 * 拦截 Executor 的 query / update 方法
 */
@Intercepts({
    @Signature(type = Executor.class, method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
    @Signature(type = Executor.class, method = "update",
        args = {MappedStatement.class, Object.class})
})
public class SlowSqlInterceptor implements Interceptor {

    /** 慢 SQL 阈值,单位毫秒,可通过 properties 配置 */
    private long thresholdMs = 500L;

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        long start = System.nanoTime();
        try {
            return invocation.proceed(); // 放行到下一棒
        } finally {
            long costMs = (System.nanoTime() - start) / 1_000_000;
            if (costMs >= thresholdMs) {
                // 第一个参数是 MappedStatement,可拿到 SQL 与 statementId
                MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
                Object param = invocation.getArgs()[1];
                BoundSql boundSql = ms.getBoundSql(param);
                System.err.printf("[慢SQL] %dms | %s | SQL=%s%n",
                        costMs, ms.getId(), boundSql.getSql().replaceAll("\\s+", " "));
            }
        }
    }

    @Override
    public Object plugin(Object target) {
        // 只对 Executor 类型做代理,避免无谓的代理开销
        if (target instanceof Executor) {
            return Plugin.wrap(target, this);
        }
        return target;
    }

    @Override
    public void setProperties(Properties properties) {
        // 支持 <plugin><property name="thresholdMs" value="1000"/></plugin>
        String v = properties.getProperty("thresholdMs");
        if (v != null) {
            this.thresholdMs = Long.parseLong(v);
        }
    }
}

要点:plugin 方法里加 instanceof 判断是个好习惯。因为 pluginAll 会对四大对象都调用 plugin,不加判断的话,Plugin.wrap 内部虽然也会检查签名,但多一次 getSignatureMap 解析(虽然有缓存,但仍有开销)。

示例二:多租户自动拼接条件(基于 SQL 改写)

这个示例演示如何改写 SQL。注意:直接改 BoundSql.sql 是危险操作,因为 BoundSql 被缓存复用。正确做法是通过反射替换 BoundSql 的 sql 字段,或者更稳妥地——修改 MappedStatement 的 SqlSource。这里用反射演示原理,生产环境建议用 MyBatis-Plus 的租户插件或自定义 SqlInjector。

import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;

import java.lang.reflect.Field;
import java.sql.Connection;
import java.util.Properties;

/**
 * 多租户插件(演示 SQL 改写原理)
 * 拦截 StatementHandler.prepare,在 SQL 执行前注入租户条件
 *
 * 警告:生产环境请使用官方推荐方式,直接改 BoundSql 有并发风险
 */
@Intercepts({
    @Signature(type = StatementHandler.class, method = "prepare",
        args = {Connection.class, Integer.class})
})
public class TenantInterceptor implements Interceptor {

    private static final ThreadLocal<String> TENANT = new ThreadLocal<>();

    public static void setTenant(String tenantId) { TENANT.set(tenantId); }
    public static void clear() { TENANT.remove(); }

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler handler = (StatementHandler) invocation.getTarget();
        // 用 MetaObject 优雅地读取私有属性,比纯反射友好
        MetaObject meta = SystemMetaObject.forObject(handler);
        BoundSql boundSql = (BoundSql) meta.getValue("delegate.boundSql");
        String sql = boundSql.getSql();
        String tenantId = TENANT.get();

        if (tenantId != null && sql.toLowerCase().contains("where")) {
            // 简化演示:在 WHERE 后插入租户条件
            String newSql = sql.replaceFirst("(?i)where",
                    "where tenant_id = '" + tenantId + "' and ");
            // 通过反射修改 BoundSql.sql(生产环境需加锁或改用 SqlSource 重写)
            Field field = BoundSql.class.getDeclaredField("sql");
            field.setAccessible(true);
            field.set(boundSql, newSql);
        }
        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) { }
}

坑点提醒:BoundSql.sql 是 final 吗?在 MyBatis 3.5.x 中不是 final,但直接反射修改在高并发下可能因为 BoundSql 被 MappedStatement 缓存而串数据。更稳妥的方案是拦截 Executor.query,用 MappedStatement 构造新的 BoundSql,再通过 Runnable 包装调用 query 的重载版本。这也是 PageHelper 的做法——它没有改原 SQL,而是生成了一条 count SQL 和一条分页 SQL,用 MappedStatement 替换后重新调用 Executor.query。

示例三:拦截器执行顺序验证

这个示例用两个插件验证"套娃"顺序。理解这个,你就理解了 90% 的插件顺序问题。

import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 通过两个插件验证执行顺序
 * 注册顺序:A 先注册,B 后注册
 * 实际执行顺序:B 进入 -> A 进入 -> 真实方法 -> A 退出 -> B 退出
 */
public class OrderDemo {

    @Intercepts(@Signature(type = Executor.class, method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
    static class PluginA implements Interceptor {
        @Override
        public Object intercept(Invocation invocation) throws Throwable {
            System.out.println(">>> PluginA 进入");
            try { return invocation.proceed(); }
            finally { System.out.println("<<< PluginA 退出"); }
        }
    }

    @Intercepts(@Signature(type = Executor.class, method = "query",
        args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}))
    static class PluginB implements Interceptor {
        @Override
        public Object intercept(Invocation invocation) throws Throwable {
            System.out.println(">>> PluginB 进入");
            try { return invocation.proceed(); }
            finally { System.out.println("<<< PluginB 退出"); }
        }
    }

    @Configuration
    static class MyBatisConfig {
        @Bean
        public PluginA pluginA() { return new PluginA(); }
        @Bean
        public PluginB pluginB() { return new PluginB(); }
    }

    // 输出(注册顺序 A -> B):
    // >>> PluginB 进入
    // >>> PluginA 进入
    // <<< PluginA 退出
    // <<< PluginB 退出
}

结论:后注册的插件先执行(进入),后退出。这就像穿衣服——最后穿的在外面,最先脱。所以如果你有"分页插件"和"数据权限插件",数据权限插件应该后注册,因为分页 SQL 需要在数据权限条件之后拼接,才能保证分页数量正确。这个顺序搞反,分页总数会算错,是经典线上事故。


方案对比:MyBatis 插件 vs 其他扩展方式

方案 原理 优点 缺点 适用场景
MyBatis 插件(Interceptor) JDK 动态代理 + 责任链 无侵入、可拦截四大对象、生态成熟 只能拦接口方法、顺序依赖注册、调试困难 分页、监控、多租户、SQL 改写
自定义 SqlSessionFactoryBean 包装 替换核心对象 完全可控 侵入性大、需理解初始化流程 深度定制
MyBatis-Plus 内置拦截器 官方实现 开箱即用、顺序管理 绑定 MP 生态 用 MP 的项目
AOP 切 Mapper 接口 Spring AOP 简单、可切业务方法 拿不到 SQL 上下文、无法改 SQL 缓存、日志
JDBC 层代理(p6spy) 驱动代理 与 ORM 无关 无法感知 MyBatis 语义 通用 SQL 监控

对比可以看出,MyBatis 插件的核心优势是能拿到 MappedStatement、BoundSql 这类 ORM 语义信息,这是 AOP 和 JDBC 代理做不到的。但代价就是前面说的"套娃顺序"和"只能拦接口"这两大限制。


最佳实践与避坑指南

1. 签名写错会静默失效,务必写单测验证。

@Signature 的 method 名或 args 类型写错,getSignatureMap 会抛 NoSuchMethodException(这是好事);但如果 type 写的是一个目标对象没实现的接口,getAllInterfaces 返回空数组,wrap 直接返回原对象,插件静默失效。建议每个插件都写一个断言"代理对象是 Plugin 实例"的测试。

2. 拦截 query 要拦全参数版本。

Executor 有 query(MappedStatement, Object, RowBounds, ResultHandler) 和 query(MappedStatement, Object, RowBounds, ResultHandler, CacheKey, BoundSql) 两个重载。只拦前者,某些内部调用(如嵌套查询)可能走后者,导致漏拦。PageHelper 两个都拦。

3. 顺序管理用 @Order 或 order 属性。

Spring Boot 下多个 Interceptor Bean,注册顺序受 Bean 加载顺序影响,不可靠。推荐在 的 property 里显式声明顺序,或自定义 ConfigurationCustomizer 按 @Order 排序后手动 addInterceptor。

4. 不要直接在 intercept 里改 BoundSql.sql。

BoundSql 可能被缓存复用,高并发下会串 SQL。要改写 SQL,走 MappedStatement 替换或 SqlSource 重写路线,参考 PageHelper 的 ExecutorUtil。

5. proceed() 必须放在 try-finally 里。

否则异常路径下"退出"逻辑不执行,监控数据会失真。示例一的写法就是标准姿势。

6. 注意 plugin 方法的 instanceof 判断。

pluginAll 会对四大对象都调 plugin,虽然 wrap 内部会检查,但提前判断能省掉 getSignatureMap 的解析开销(虽然 InterceptorChain 对 signatureMap 没有缓存,每次 wrap 都重新解析注解,插件多时这是实打实的性能损耗)。

7. 嵌套查询(Nested Query)的拦截盲区。

ResultSetHandler 处理嵌套结果时,可能通过 Executor 内部再次查询,此时新 Executor 是否被代理取决于 MappedStatement 的 executorType。如果发现"插件对某些 SQL 不生效",先检查是不是嵌套查询走了不同的 Executor 实例。


总结

把整条链路串起来:

  1. 装配:InterceptorChain.pluginAll 对四大对象依次调用 plugin,先注册的在内层。
  2. 代理:Plugin.wrap 用 @Intercepts 生成 signatureMap,对匹配接口做 JDK 动态代理。
  3. 执行:Plugin.invoke 命中签名则调 interceptor.intercept,拦截器内 invocation.proceed() 触发下一层代理,形成套娃式责任链。

核心洞察是:MyBatis 插件 = JDK 动态代理 + 责任链模式的嵌套实现。理解了"套娃"和"接口代理"这两点,顺序问题、失效问题、嵌套问题就都有了统一解释。

延伸思考:如果你要设计一个类似的扩展框架,MyBatis 这套方案给你什么启发?其实它牺牲了"非接口方法拦截能力"和"显式顺序控制",换来了"零侵入"和"与 Spring 解耦"。现代框架(如 Dubbo 的 Filter、Spring 的 HandlerInterceptor)更倾向于显式链式结构 + 顺序声明,可调试性更好。MyBatis 受限于历史设计(JDK 代理),只能靠约定和文档来弥补。这也提醒我们:扩展点的设计,顺序可观测性和失败可见性,比"优雅"更重要。

下次再写插件,先问自己三个问题:拦的是接口方法吗?顺序想清楚了吗?proceed() 在 finally 里吗?想清楚这三点,坑就少了一大半。