主题
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()。这样插件之间可以组合,也可以中断。
核心机制
四个拦截点
| 拦截点 | 可拦截的方法 | 典型用途 |
|---|---|---|
Executor | query、update、commit、rollback | 分页、读写分离、SQL 审计 |
StatementHandler | prepare、parameterize、batch | SQL 改写、表名替换 |
ParameterHandler | setParameters | 参数加密、脱敏 |
ResultSetHandler | handleResultSets | 结果脱敏、数据权限 |
只能拦截这四类对象,因为 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 文本和参数打印出来,再上生产。