跳到正文

MyBatis 插件机制 ​

一句话:MyBatis 插件是“JDK 动态代理 + 责任链”,只能拦截 Executor、StatementHandler、ParameterHandler、ResultSetHandler;换来不改源码就能扩展,代价是顺序、状态和签名都容易踩坑。

适用版本:MyBatis 3.5.x;示例插件兼容 3.5.x,集成方式以 mybatis-spring-boot-starter 3.0.x / Spring Boot 3.x 为例。

为什么需要 ​

分页、慢 SQL 监控、参数加密、结果脱敏这些需求都和 SQL 执行相关,但不应该散落在每个 Mapper 方法里。MyBatis 提供 Interceptor 接口,让你在四大组件的固定方法上插入逻辑。

它没有选择继承或事件总线,而是用 JDK 动态代理包装目标对象:配置几个插件,就套几层代理;每层决定是否继续调用 proceed()。这样插件之间可以组合,也可以中断。

核心机制 ​

四个拦截点 ​

拦截点可拦截的方法典型用途
Executorquery、update、commit、rollback分页、读写分离、SQL 审计
StatementHandlerprepare、parameterize、batchSQL 改写、表名替换
ParameterHandlersetParameters参数加密、脱敏
ResultSetHandlerhandleResultSets结果脱敏、数据权限

只能拦截这四类对象,因为 MyBatis 只在 Configuration.newExecutor()、newStatementHandler()、newParameterHandler()、newResultSetHandler() 里调用 InterceptorChain.pluginAll()。SqlSession、MapperProxy 不经过这个流程。

责任链:执行顺序与配置顺序相反 ​

pluginAll() 按配置顺序逐层包裹,后注册的插件在外层;调用时先执行外层 intercept(),内层要等外层调用 invocation.proceed() 才会被触发。所以 Timing 统计到的耗时包含 Audit,反过来不成立。

也就是说,最后配置的插件在最外层,最先执行。XML 的 <plugins> 按声明顺序注册;Spring 注入 Interceptor 数组时还会受 @Order/依赖排序影响,所以不要假设“写在前面就先执行”。

两个 query 重载 ​

Executor 有两个常用的 query 方法:

java
query(MappedStatement, Object, RowBounds, ResultHandler)
query(MappedStatement, Object, RowBounds, ResultHandler, CacheKey, BoundSql)

只声明 4 参签名会漏掉走二级缓存/延迟加载的 6 参调用。需要覆盖所有查询时,两个 @Signature 都写;只关心某一条路径时,明确写出你拦截的是哪一个。

拦截器是单例:不要在字段里存请求状态 ​

MyBatis 为每个插件创建一个实例,intercept() 会被多个业务线程同时调用。把当前 SQL、开始时间、分页参数存进实例字段,会被其他线程覆盖:

java
// 错误:字段被并发请求共享
private String currentSql;
public Object intercept(Invocation inv) {
    this.currentSql = ((MappedStatement) inv.getArgs()[0]).getSqlSource().toString();
    return inv.proceed();
}

正确做法:用局部变量;必须跨方法传递时用 ThreadLocal,并在 finally 里 remove();异步线程、线程池任务不会自动继承 ThreadLocal,要显式传参。

PageHelper 的上下文传递 ​

PageHelper 是插件机制的典型应用:PageHelper.startPage(pageNum, pageSize) 把分页参数放进 ThreadLocal,随后拦截 Executor.query,先执行 count,再按方言改写 SQL 加 LIMIT(或 ROWNUM/OFFSET FETCH),最后把结果封装成 Page。它必须在 finally 里清理 ThreadLocal;线程池复用线程时,未清理的分页参数会带到下一次查询,表现为“串页”或“下一次查询莫名分页”。

实战场景 ​

一、脱敏插件上线后“静默失效” ​

现象:客服系统上线手机号脱敏插件,测试环境抽检未发现明文;两天后安全审计在客服导出日志里拿到完整手机号。翻遍代码没有任何异常——插件从未执行过。

排查:插件不生效不会报错,只能用证据链确认它有没有被包进链里。

定位层取证据判定
是否注册Spring 上下文里 getBeansOfType(Interceptor.class);XML 工程看 <plugins>空 → 根本没注册
是否被包装在 plugin() 里打印 target.getClass() 与返回值是否 Proxy返回原对象 → 签名没匹配上
签名是否精确比对 @Signature 的 type/method/args 与目标方法声明args 少一个参数、ResultHandler 写成 Object → 不匹配即不拦截
拦截点选错结果脱敏应挂 ResultSetHandler.handleResultSets;挂 Executor.query 时返回的 List 已被映射位置与需求不符
是否被外层短路上层插件没调 invocation.proceed()下层插件永远不执行

这个案例命中第三行与第四行:handleResultSets 的签名把 ResultSet 参数类型写成了 Statement(应为 ResultSetHandler 的 handleResultSets(Statement),但插件被注册成了 Executor 类型),MyBatis 匹配不到就按“不拦截”处理,Plugin.wrap 直接返回原对象。

对症做法:

java
// 1. 写完插件先做一条“必须执行”的断言测试,而不是靠功能抽检
@Test void desensitizeInterceptorIsApplied() {
    Object proxied = new DesensitizeInterceptor().plugin(newExecutor(cfg, null));
    assertThat(java.lang.reflect.Proxy.isProxyClass(proxied.getClass())).isTrue();
    assertThat(oracleMapper.selectById(1L).getPhone()).isEqualTo("138****0000");
}

// 2. 启动期自检,把“注册了但没包上”变成启动日志里可见的事实
@PostConstruct void selfCheck() {
    log.info("registered mybatis interceptors={}", interceptors.stream()
            .map(i -> i.getClass().getSimpleName() + Arrays.toString(i.getClass()
                .getAnnotation(Intercepts.class).value())).toList());
}

签名一旦写错,MyBatis 不会提示。所以判定标准只能是“返回值是代理对象 + 结果被真的改写”,两者都要有断言。

复盘要点:横切能力(脱敏、加密、审计)必须有可验证的抽样口径:从真实出口(导出文件、响应体)取样本,而不是从 DAO 返回值取。上线检查项里加一条“插件命中数指标”,命中为 0 直接告警,这类失效就不会靠审计发现。

二、分页“串页”:ThreadLocal 残留到下一个请求 ​

现象:商品列表接口偶发返回 10 条(配置应为 20 条),且下一条无关查询也被莫名分页;只在压测和异常高发时出现,重启后一段时间不复现。

排查:PageHelper.startPage() 把参数放进 ThreadLocal,靠 finally 清理;残留意味着有路径绕过了清理。

定位层取证据判定
残留值在下一次请求入口打 PageHelper.getLocalPage()非 null → 上一次没清干净
set 与查询是否相邻代码里 startPage(...) 与 Mapper 调用之间是否有分支/异常中间抛异常,查询未执行,参数留在 ThreadLocal
是否复用线程线程名(容器线程池/自建线程池)与两次请求是否同一线程复用即放大残留
是否异步分页参数在 A 线程 set,查询在 B 线程执行ThreadLocal 不跨线程,B 读不到

典型触发写法是 startPage 后先做权限校验并抛异常,或 startPage 与查询之间夹了一次远程调用;异常路径让查询根本没执行,清理也就没发生。

对症做法:

java
// 1. startPage 必须紧贴 Mapper 调用,中间不放任何逻辑
PageHelper.startPage(q.getPageNum(), q.getPageSize());
List<Item> list = itemMapper.selectByCategory(q.getCategory());   // 紧邻
return new PageResult<>(PageInfo.of(list));                        // PageInfo 就地取 total

// 2. 显式清理,异常路径也要清
try { PageHelper.startPage(num, size); ... }
finally { PageHelper.clearPage(); }

// 3. 异步/线程池场景不要依赖分页插件的上下文
//    把 offset/limit 作为显式参数传进 Mapper,语义可预测且可测试
List<Item> selectByCategory(@Param("c") String c, @Param("offset") int o, @Param("limit") int l);

同样规则适用于自己写的插件:拦截器实例是单例,intercept() 被并发调用,任何“当前请求”的状态只能放局部变量或 ThreadLocal + finally remove()。

复盘要点:串页是线程复用放大的问题,低并发测不出来,所以用例要按“同一线程连续两个请求”设计(线程池大小设 1 压两轮即可稳定复现)。更彻底的方向是用显式分页参数替代上下文传递——把隐式状态留在框架里,是这类故障的根源,见 分布式 ID 里同样的“显式优于隐式”取舍。

三、多租户改写插件与分页插件顺序错乱:total 与列表不一致 ​

现象:接入多租户 SQL 改写插件后,运营后台出现列表 8 条、total 显示 0(或相反);翻页时第一页正常、第二页空。单个查询手工执行都正常。

排查:这是顺序问题,证据在两条 SQL 的文本差异。

定位层取证据判定
两条 SQL 的文本抓 count 语句与列表语句的 Preparing 行一条带 tenant_id = ?、一条不带 → 改写只作用了一半
插件执行层次日志打印外层/内层插件进入顺序后配置的插件在最外层,先执行
改写发生在哪层分页插件在 Executor.query 先做 count,再改写列表count 若在内层被改写、外层看不到,就不一致
缓存是否命中错值改写后是否重建 BoundSql/CacheKey未重建 → 一级/二级缓存返回别的租户的结果

根因链条:分页插件先于租户改写插件生成并执行了 count SQL,改写只覆盖了列表 SQL;两个数字来自不同条件的查询。更危险的是第四行——改写 SQL 却不重建 CacheKey,会让不同租户命中同一份缓存结果。

对症做法:

java
// 1. 让改写插件位于外层(最后注册/最高优先级),保证 count 与列表都经过它
@Order(Ordered.HIGHEST_PRECEDENCE)      // 数值越小越先装配,越靠外层;以实测日志为准
@Component
class TenantSqlInterceptor implements Interceptor { ... }

// 2. 改写 SQL 后必须同时替换 BoundSql 和 CacheKey,否则缓存键仍指向旧文本
Executor exec = (Executor) invocation.getTarget();
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
Object param = invocation.getArgs()[1];
RowBounds rb = (RowBounds) invocation.getArgs()[2];
BoundSql newBs = ms.getBoundSql(param);              // 拿到改写后的 SQL 文本
CacheKey newKey = exec.createCacheKey(ms, param, rb, newBs);   // 键随新文本重算

// 命中 6 参 query 时把两者写回参数;只拦 4 参时会漏掉这条路径
if (invocation.getArgs().length == 6) {
    invocation.getArgs()[4] = newKey;
    invocation.getArgs()[5] = newBs;
}
// 3. 顺序用测试固定,不靠注释
@Test void countAndListShareSameWhereClause() {
    List<String> sqls = SqlCapturer.captureAll(() -> service.page(tenantA, 1, 20));
    assertThat(sqls).hasSize(2)
        .allSatisfy(sql -> assertThat(sql).contains("tenant_id ="));
}

顺序假设不能靠“写在前面就先执行”:XML <plugins> 按声明顺序注册,Spring 注入 Interceptor 数组还受 @Order 影响,最终执行顺序要由日志与测试确认。

复盘要点:把“同一业务的 count 与列表必须来自同一改写条件”做成一条集成断言,跑在流水线里。租户隔离类插件应配一条越权测试:A 租户查询不能命中 B 租户缓存结果,这条用例比任何 review 都可靠。相关配置治理见 框架定制与配置治理。

最小上手 ​

一个记录耗时的插件,同时覆盖两个 query 重载:

java
@Intercepts({
  @Signature(type = Executor.class, method = "query",
    args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
  @Signature(type = Executor.class, method = "query",
    args = {MappedStatement.class, Object.class, RowBounds.class,
            ResultHandler.class, CacheKey.class, BoundSql.class})
})
public class TimingInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        long start = System.nanoTime();
        try {
            return invocation.proceed();
        } finally {
            var ms = (MappedStatement) invocation.getArgs()[0];
            long cost = (System.nanoTime() - start) / 1_000_000;
            System.out.println(ms.getId() + " cost=" + cost + "ms");
        }
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);   // 不匹配 @Signature 时原样返回
    }

    @Override
    public void setProperties(Properties properties) {
        // 读取 <plugin><property name="..." value="..."/></plugin>
    }
}

XML 注册(顺序:Timing 在内,Audit 在外):

xml
<plugins>
    <plugin interceptor="demo.TimingInterceptor"/>
    <plugin interceptor="demo.AuditInterceptor"/>
</plugins>

Spring Boot 中把 Interceptor 声明成 @Bean 即可,MybatisAutoConfiguration 会收集容器里的 Interceptor 并传给 SqlSessionFactoryBean。验证:执行一次查询,日志应先出现 Audit,再出现 Timing,与配置顺序相反。

生产动作 ​

  • @Intercepts 的 args 必须与目标方法签名完全一致:少一个参数、类型不匹配都不会报错,只是插件不生效;升级 MyBatis 后先回归签名。
  • invocation.proceed() 必须调用:不调用等于中断链路,后面的插件和目标方法都不会执行;只有明确的短路需求才这么做。
  • plugin() 固定返回 Plugin.wrap(target, this):不要自己 Proxy.newProxyInstance,否则会绕过 MyBatis 的接口匹配和链式包装。
  • setProperties() 只读配置:Properties 在启动期传入,运行期不要修改;需要动态开关用配置中心 + 每次读取。
  • 排障路径:现象“插件没生效” → 查 @Signature 的 type/method/args → 查插件是否注册(XML <plugins> 或 Spring Bean)→ 在 plugin() 打断点看 Plugin.wrap 是否返回代理 → 再查是否被其他插件短路。
  • 排障路径:现象“分页串页/参数错乱” → 查插件或分页工具是否把状态放在实例字段/未清理的 ThreadLocal → 在高并发或线程池复用下复现,finally 清理。
  • 排障路径:现象“同一 SQL 有时被改写、有时没有” → 查是否只拦截了 4 参 query,漏了 6 参重载。

边界 ​

  • 插件不是业务扩展点:它适合 SQL 执行层面的横切逻辑;业务规则放 Service,不要用插件改业务语义。
  • 分页插件不要拦截 StatementHandler 做两步查询:需要先 count 再分页时,Executor.query 才是合适的位置;StatementHandler 只能看到单次执行。
  • 插件会改变 SQL 文本:改写后要重新生成 BoundSql/CacheKey,否则一级/二级缓存可能命中错误结果。
  • 顺序敏感:加解密、分页、审计之间的顺序会改变结果;在配置里写清楚注释,并用测试固定顺序。
  • 分布式分页/分表:插件只改单条 SQL;跨库分页、分片归并要靠 ShardingSphere 等方案,不能指望一个拦截器解决。

讲清楚 ​

MyBatis 插件是责任链加 JDK 动态代理。配置插件时,InterceptorChain.pluginAll() 依次用每个插件包装目标对象,所以最后配置的插件在最外层,执行时最先被调用;调用 proceed() 才会进入下一层或目标方法。

插件只能拦截四大组件,因为只有这四类对象在 Configuration.newXxx() 里经过 pluginAll()。插件实例是单例,intercept() 会被并发调用,任何“当前请求”的状态都不能放在实例字段里;ThreadLocal 要用 finally 清理,异步线程不会自动继承。

最常见的两个坑是签名写漏(尤其 Executor 的 4 参/6 参 query)和顺序假设错误。写完插件先用一次查询把执行顺序、SQL 文本和参数打印出来,再上生产。

把一门技术讲透到既懂也会用