MyBatis 的插件不是扩展点,是拦截点。没弄清 SqlSession 到 JDBC 之间经过哪几层,就会撞上两类事故:拦截签名写对了却不生效,或者插件跑起来了却把 BoundSql 的参数映射搞乱,报 Parameter index out of range。
下面按一条查询的实际链条走:每层职责、四种 Executor 的包装关系、四个拦截点的能力边界,以及插件最常踩的坑。
1. 一次查询的四层链路
sqlSession.selectOne("findUser", 1) 是一条固定流水线,每层只做一件事:
| 层 | 职责 | 关键对象 | 可拦截 |
|---|---|---|---|
SqlSession |
对外 API,持有事务与关闭,本身不发 SQL | DefaultSqlSession |
否 |
Executor |
调度:缓存命中判断、BoundSql 生成、批处理 |
BaseExecutor 子类、CachingExecutor |
是 |
StatementHandler |
建 Statement、设参、执行、取结果集 |
RoutingStatementHandler |
是 |
ParameterHandler |
把 Java 参数写进 PreparedStatement |
DefaultParameterHandler |
是 |
ResultSetHandler |
结果集映射成对象 | DefaultResultSetHandler |
是 |
SqlSession 只是门面,不可拦截。决定"用什么 SQL、走不走缓存"的是 Executor,决定"预编译语句长什么样"的是 StatementHandler。调用顺序单向:
SqlSession.selectList
└─ Executor.query(ms, param, rowBounds, resultHandler)
├─ CachingExecutor ← 二级缓存包在外层
├─ BaseExecutor.query ← 一级缓存、BoundSql
└─ StatementHandler.prepare / parameterize / query
├─ ParameterHandler.setParameters
└─ ResultSetHandler.handleResultSets
2. Executor 的三种实现与缓存装饰
Executor 有四个实现,选哪个由 ExecutorType 决定:
| 实现 | 触发方式 | 行为 | 代价 |
|---|---|---|---|
SimpleExecutor |
默认 | 每次新建 Statement,用完即关 |
无复用 |
ReuseExecutor |
ExecutorType.REUSE |
按 SQL 文本复用 Statement |
同 SQL 不同参数反复改参 |
BatchExecutor |
ExecutorType.BATCH |
攒 addBatch,flushStatements 才发 |
拿不到单条结果、报错定位难 |
CachingExecutor |
cacheEnabled 且 Mapper 有 <cache/> |
装饰上面任意一个,加二级缓存 | 多实例下不一致 |
关键点:CachingExecutor 不是并列的第四种,是装饰器:
// Configuration.newExecutor 简化逻辑
if (cacheEnabled) {
executor = new CachingExecutor(executor); // 先包二级缓存
}
executor = (Executor) interceptorChain.pluginAll(executor); // 再包插件
return executor;
由此两个结论:插件加在 CachingExecutor 之外,命中二级缓存时 query 根本不会进你的插件;而 flushCache 类操作由外层插件先经手再传到内层。想让"必然走到我"成立,只能拦 StatementHandler——那一层已经确定要发 SQL。
3. 四个拦截点各自能改什么
| 拦截点 | 典型方法 | 能改什么 | 不该做什么 |
|---|---|---|---|
Executor |
query、update、queryCursor |
SQL 改写、分页、缓存开关、读写分离路由 | 业务校验(拿不到映射信息) |
StatementHandler |
prepare、parameterize、batch |
BoundSql 的 SQL 文本、语句类型、超时 |
改 SQL 却忘了同步参数 |
ParameterHandler |
setParameters |
参数加密/脱敏、类型替换 | 增删占位符 |
ResultSetHandler |
handleResultSets |
结果脱敏、空结果兜底 | 关联查询(N+1 放大器) |
Executor.update 能同时看到 MappedStatement 和参数,逻辑删除、多租户条件注入多放这层;分页必须拦 query,因为要拿 RowBounds 或 IPage。
4. @Intercepts 与 Invocation.proceed
插件实现 Interceptor,用注解声明拦哪个类的哪个方法、参数签名是什么:
@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 PageInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
// 改写 SQL 或另发 count
return invocation.proceed(); // 必须调用,除非完全接管
}
@Override
public Object plugin(Object target) { return Plugin.wrap(target, this); }
@Override
public void setProperties(Properties p) { /* 读 <plugin> 下的 <property> */ }
}
三条硬规矩:args 要与实际签名逐项匹配(3.5.x 的 Executor 有 6 参与 4 参两个 query 重载,只写一个会漏掉另一条路径);继续往下只能用 invocation.proceed(),自己反射调 method.invoke 会跳过内层插件;返回值必须原样返回或同类型对象,返回 null 等于告诉调用方"没查到"。
Plugin.wrap 在 target 未实现所声明接口时返回原对象——这是"插件没生效"的头号原因:拦了 Executor,注入的 target 却是 SqlSession。
5. 插件链顺序:按注册顺序包,与 @Order 无关
多插件时 InterceptorChain.pluginAll 顺序遍历、逐个包裹,最后注册的在最外层、最先被调用:
| 注册顺序 | 调用顺序(外→内) | 谁先改 SQL |
|---|---|---|
| A, B, C | C → B → A → 真实 Executor | C 先改,A 最后改 |
| C, B, A | A → B → C → 真实 Executor | A 先改 |
Spring Boot 里顺序由 @Bean 声明顺序或 addInterceptor 调用顺序决定;@Order 对原生插件不起作用,只影响 Spring Bean 排序。分页插件与自定义改写插件共用时,谁在内层谁看到的是未分页 SQL,顺序必须实测确认,不能靠猜。
6. 分页插件的实现原理
主流分页插件(与 MyBatis-Plus 的 PaginationInnerInterceptor 同思路)做三件事:
// 1. 从参数里取分页对象
IPage<?> page = ParameterUtils.findPage(invocation.getArgs()).orElse(null);
if (page == null) return invocation.proceed();
// 2. searchCount=true 时另发一次 COUNT
if (page.searchCount()) {
MappedStatement countMs = executor.newCountMappedStatement(ms);
Long total = executeCount(executor, countMs, parameter, boundSql, rowBounds, resultHandler);
page.setTotal(total);
}
// 3. 改写 SQL,并同步 BoundSql 的 sql 与参数映射
String pageSql = dialect.buildPaginationSql(boundSql.getSql(), page.offset(), page.getSize());
MetaObject mo = SystemMetaObject.forObject(boundSql);
mo.setValue("sql", pageSql);
return invocation.proceed();
最容易出错的正是第 3 步后半段:LIMIT ?, ? 新增两个占位符,parameterMappings 必须同步追加两条且类型与驱动匹配。只改 sql 不改映射,执行时就是 Parameter index out of range。
COUNT 也不是简单包一层 select count(*) from (...):ORDER BY 要剥掉、GROUP BY 要外层包裹、DISTINCT 影响语义。searchCount=false 时跳过 COUNT,getTotal() 返回 0,前端分页条依赖 total 的话要自己补。
7. MetaObject:反射访问的统一入口
改 BoundSql、改 MappedStatement 属性都走 MetaObject,别手写 setAccessible(true):
MetaObject meta = SystemMetaObject.forObject(boundSql);
meta.setValue("sql", newSql);
MetaObject params = SystemMetaObject.forObject(meta.getValue("additionalParameters"));
params.setValue("pageOffset", offset);
| API | 用途 | 注意 |
|---|---|---|
SystemMetaObject.forObject(o) |
包装任意对象 | 对 null 返回 NULL_META_OBJECT,写入无效 |
getValue(name) |
读属性,支持 a.b.c |
属性名大小写敏感 |
setValue(name, v) |
写属性 | 集合属性走 add 分支 |
hasSetter(name) |
判断可写 | 先判后写 |
它对 Map、Collection、普通 Bean 走不同分支,对象上没有该属性时 setValue 静默不生效、不抛异常,所以改完必须回读校验。
8. 排错:五种"插件看起来没生效"
| 现象 | 原因 | 处理 |
|---|---|---|
完全不进 intercept |
@Signature 的 args 与方法签名不匹配 |
对照 Executor/StatementHandler 实际重载核对 |
| 部分语句生效、部分不生效 | 只声明 6 参 query,实际走 4 参重载 |
两个重载都声明 |
Parameter index out of range |
加了占位符没同步 parameterMappings |
同时改 sql 与 parameterMappings |
| 参数错位、值串到别的字段 | 多插件各自追加参数、顺序不同 | 明确顺序,统一扩展参数容器 |
SqlSession 状态异常 |
插件内吞异常,Executor 未正常 close |
try-finally;异常原样抛出交给上层回滚 |
最后一行的机理值得记住:Executor 在 finally 里做 close(isCommit),插件吞掉异常并返回半成品对象,会让 session 的 dirty 标记与实际状态脱节,同 session 后续复用时行为诡异。插件里要么成功,要么抛出去。
9. 边界:什么该用插件
| 需求 | 用插件 | 更合适的位置 |
|---|---|---|
| 分页、多租户、逻辑删除条件 | 是(SQL 层通用逻辑) | — |
| 参数加密、结果脱敏 | 是(ParameterHandler/ResultSetHandler) |
— |
| SQL 审计、慢 SQL 计时 | 是(StatementHandler) |
JDBC 层代理 |
| 业务规则校验(余额、库存) | 否 | Service 层,能拿完整上下文 |
| 关联查询补数据 | 否 | 显式 join 或 Service 组装 |
| 把两次查询合成一次 | 通常否 | 改写 Mapper XML |
判据:与具体业务实体无关、只依赖 SQL 结构的横切逻辑放插件;依赖业务语义的留 Service。 插件没有事务边界感知,也拿不到 @Transactional 上下文,把业务塞进去等于交出可测试性。性能上同理:插件在每条语句上执行,MetaObject 反射与字符串拼接都在热路径;分页的 COUNT 是额外一次往返,深翻页 LIMIT 1000000, 20 该由索引与游标分页解决,不该指望插件。
10. 自测
- 为什么命中二级缓存时,拦截
Executor的插件看不到这次调用? CachingExecutor与SimpleExecutor是并列还是包装关系?装配顺序如何?- 两个插件按 A→B 注册,谁先被调用?
@Order能改变它吗? - 分页插件改写 SQL 加
LIMIT ?, ?后,还必须同步改哪个结构? MetaObject.setValue写一个不存在的属性,会发生什么?- 插件里吞掉异常并返回
null,会对SqlSession造成什么影响?