2324 字
约 7 分钟
19
S08-X08 · MyBatis 执行流程与插件机制

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. 自测

  1. 为什么命中二级缓存时,拦截 Executor 的插件看不到这次调用?
  2. CachingExecutor 与 SimpleExecutor 是并列还是包装关系?装配顺序如何?
  3. 两个插件按 A→B 注册,谁先被调用?@Order 能改变它吗?
  4. 分页插件改写 SQL 加 LIMIT ?, ? 后,还必须同步改哪个结构?
  5. MetaObject.setValue 写一个不存在的属性,会发生什么?
  6. 插件里吞掉异常并返回 null,会对 SqlSession 造成什么影响?
S08-X08 · MyBatis 执行流程与插件机制
https://www.clxhxhhr.top/posts/5955/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。