MYBATIS · INTERCEPTOR · PLUGIN.WRAP · PAGEHELPER · MYBATIS-PLUS

插件是一层层套上去的,分页就是其中一层

MyBatis 的插件,就是给四个核心对象套上一层层 JDK 动态代理。分页插件也是其中一层:SQL 发出去之前,它先帮你查总数,再在 SQL 后面加上 LIMIT。

调用往里走从最外层开始,每层做完自己的事,调用 proceed() 交给里面一层。
分页插件 PageInterceptor先 count,再加 LIMIT
你写的计时插件记下用了多久
Executor真正干活的那个对象
结果往外回里面返回后,外面一层还能再加工结果,比如装进 Page。
插件Interceptor

你写的类。用 @Signature 说清楚拦哪个对象的哪个方法。

只要实现 intercept 一个方法
套娃工Plugin.wrap

给对象套一层 JDK 动态代理。签名对得上才套,对不上原样返回。

org.apache.ibatis.plugin.Plugin
插件链InterceptorChain

按登记顺序一个个套。最后登记的在最外层,最先执行。

对象一造出来就套
分页插件PageHelper · MyBatis-Plus

也是一个插件。拦下查询,先 count、再改写成 LIMIT。

都靠 JSqlParser 改 SQL
配角 Invocation 装着目标对象、方法和参数,proceed() 就是往里走一层 JSqlParser 把 SQL 解析成语法树再改,比拼字符串可靠 RowBounds MyBatis 自带的分页,在内存里跳过

能插在哪:只有四个对象

MyBatis 在造这四种对象时,都会顺手调用一次 interceptorChain.pluginAll(…)。别的对象,插件碰不到。

Executor
query · update · commit · rollback

查缓存、调度一次查询。分页插件拦在这里。

每开一个 SqlSession 造一次
StatementHandler
prepare · parameterize · query · update

准备语句、执行。要改 SQL、统计慢 SQL,拦这里。

每执行一条 SQL 造一次
ParameterHandler
setParameters

填问号。参数加密可以拦这里。

跟着 StatementHandler 一起造
ResultSetHandler
handleResultSets

把行变成对象。结果脱敏可以拦这里。

跟着 StatementHandler 一起造

后三个每执行一条 SQL 就造一次,也就套一次。插件里别做重活,它会跟着每条 SQL 跑。

一个插件长什么样

一个统计查询耗时的插件。注解说要拦什么,intercept 里写要做什么,中间调一次 proceed()。

TimerPlugin.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 TimerPlugin implements Interceptor {
    @Override
    public Object intercept(Invocation inv) throws Throwable {      // ②
        MappedStatement ms = (MappedStatement) inv.getArgs()[0];
        long start = System.nanoTime();
        try {
            return inv.proceed();                                     // ③ 往里走一层
        } finally {
            log.info("{} 用时 {} ms", ms.getId(), (System.nanoTime() - start) / 1_000_000);
        }
    }
}

// ④ Spring Boot 里声明成 Bean,自动配置会把它装进 SqlSessionFactory
@Bean
TimerPlugin timerPlugin() { return new TimerPlugin(); }
1拦哪个方法:type 是四个接口之一,method 和 args 要和接口里的方法一字不差。Executor 有两个 query,参数个数不同,最好两个都写上。
2Invocation 里有目标对象、方法、参数。改参数、换返回值,都在这里。
3proceed() 交给里面一层。不调它,后面谁都执行不到,SQL 也不会发。
4plugin()、setProperties() 从 MyBatis 3.5.2 起有默认实现,通常不用写。
想改 SQL,就拦 StatementHandler.prepare
StatementHandler h = (StatementHandler) inv.getTarget();
MetaObject meta = SystemMetaObject.forObject(h);
String sql = (String) meta.getValue("delegate.boundSql.sql");
meta.setValue("delegate.boundSql.sql", rewrite(sql));  // 用 JSqlParser 改
return inv.proceed();

套娃原理:三段源码

造对象时一层层套代理;调方法时,每层代理先看这个方法在不在自己的签名里,在就交给插件,不在就直接往里传。

简化版,去掉了异常处理和缓存
// ① InterceptorChain:所有插件挨个套一层(Configuration.newExecutor 等方法里调用)
public Object pluginAll(Object target) {
    for (Interceptor interceptor : interceptors) {
        target = interceptor.plugin(target);        // 默认就是 Plugin.wrap(target, this)
    }
    return target;                                  // 最后登记的,套在最外面
}

// ② Plugin.wrap:签名里有这个接口,才套
public static Object wrap(Object target, Interceptor interceptor) {
    Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);   // 读 @Signature
    Class<?>[] interfaces = getAllInterfaces(target.getClass(), signatureMap);
    if (interfaces.length > 0) {
        return Proxy.newProxyInstance(target.getClass().getClassLoader(), interfaces,
                new Plugin(target, interceptor, signatureMap));               // JDK 动态代理
    }
    return target;                                                          // 不相干,原样返回
}

// ③ Plugin.invoke:方法在签名里,才交给插件
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Set<Method> methods = signatureMap.get(method.getDeclaringClass());
    if (methods != null && methods.contains(method)) {
        return interceptor.intercept(new Invocation(target, method, args));
    }
    return method.invoke(target, args);             // 不拦,直接往里传
}

试一试:登记顺序变了,套娃顺序就变了

项目里登记了四个插件:计时插件拦 Executor.query;审计插件只拦 Executor.update;SQL 日志插件只拦 StatementHandler;还有 PageHelper 的分页插件。调用一次 PageHelper.startPage(3, 10) 加 selectByStatus(1),看看会发生什么。

谁最后登记
SqlLogPlugin 没套在 Executor 上:它的签名里只有 StatementHandler,Plugin.wrap 原样返回。它套在每次造出来的 StatementHandler 上。

    分页的五种做法,比一比

    同一个需求:查 status = 1 的订单,每页 10 条。换个做法、换个页码,看发出去的 SQL 和数据库干的活有多少。

    假设:t_order 里 status = 1 的有 1,000,000 行,有 (status, id) 联合索引,按 id 排序。

    做法
    翻到
    你写的代码

    PageHelper 一次分页的经过

    它靠一张贴在当前线程上的便签(ThreadLocal)知道“下一条查询要分页”。

    你写的两行代码
    1贴便签:下一条查询要分页PageHelper.startPage(3, 10) → ThreadLocal
    2照常调用 Mapper,SQL 一个字没改orderMapper.selectByStatus(1)
    分页插件拦 Executor.query
    3看便签,没有就直接放行PageInterceptor.intercept
    4改成 count 查总数,是 0 就直接返回空JSqlParser 改写
    5原 SQL 后面加 LIMIT,再执行MySqlDialect.getPageSql
    收尾finally
    6结果装进 Page,带上 totalPage<E> extends ArrayList<E>
    7撕掉便签PageHelper.clearPage()
    你在 XML 里写的
    SELECT id, order_no, amount
    FROM t_order
    WHERE status = ?
    ORDER BY id
    ④ 插件生成的 count
    SELECT count(0)
    FROM t_order
    WHERE status = ?
    ORDER BY 对计数没用,去掉了
    ⑤ 插件生成的分页 SQL
    SELECT id, order_no, amount
    FROM t_order
    WHERE status = ?
    ORDER BY id
     LIMIT ?, ?
    参数:1, 20, 10

    和 MyBatis-Plus 的分页对比

    PageHelperMyBatis-Plus
    怎么说要分页PageHelper.startPage(),放进 ThreadLocal把 Page 对象当第一个参数传进去
    拦在哪Executor.queryMybatisPlusInterceptor 拦 Executor 和 StatementHandler 的几个方法,分页交给其中的 PaginationInnerInterceptor
    count 长什么样SELECT count(0) FROM …SELECT COUNT(*) AS total FROM …,还会去掉用不上的 LEFT JOIN
    返回什么Page(就是个 ArrayList),常包成 PageInfoIPage:records、total、pages
    要注意只管紧跟着的下一条查询要先注册拦截器 Bean,否则不分页
    PageHelperstartPage 和查询要紧挨着
    PageHelper.startPage(3, 10);
    List<Order> list = orderMapper.selectByStatus(1);
    PageInfo<Order> info = new PageInfo<>(list);   // total、pages 都在里面
    
    // 更保险的写法:查询包在 lambda 里,用完自动清掉
    Page<Order> page = PageHelper.startPage(3, 10)
            .doSelectPage(() -> orderMapper.selectByStatus(1));
    MyBatis-Plus先注册,再传 Page
    @Bean
    MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor i = new MybatisPlusInterceptor();
        i.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return i;
    }
    
    Page<Order> page = orderMapper.selectPage(new Page<>(3, 10),
            Wrappers.<Order>lambdaQuery().eq(Order::getStatus, 1));

    越往后翻越慢:深分页

    不管哪个分页插件,最后都是 LIMIT offset, size。翻到第 100000 页,数据库要先数过 999,990 行,再把它们丢掉。

    扫过再丢掉:999,990 行
    按比例画的:1,000,000 行真正要的:10 行(最右边那一条)
    办法一:按 id 往后翻记住上一页最后的 id
    SELECT id, order_no, amount FROM t_order
    WHERE status = 1 AND id > #{lastId}
    ORDER BY id
    LIMIT 10;
    -- 不管翻到哪,都只扫 10 行
    -- 代价:只能下一页,不能直接跳到第 N 页
    办法二:先找 id,再回表延迟关联
    SELECT o.* FROM t_order o
    JOIN (SELECT id FROM t_order
          WHERE status = 1
          ORDER BY id
          LIMIT 999990, 10) t ON o.id = t.id;
    -- 子查询只在索引里数,最后只回表 10 次
    -- 还是要数过那些行,但每行便宜多了

    count 也一样贵:每翻一页都要把 1,000,000 行数一遍。不需要总数时,关掉它:PageHelper.startPage(n, size, false),或者 MyBatis-Plus 里 page.setSearchCount(false)。

    JDK 8 和 JDK 17:原理一样,依赖要换

    插件机制是 MyBatis 3.5 本体的,两边完全一样。变的是分页插件的 starter,和它们依赖的 JSqlParser。

    Spring Boot 2

    JDK 8+
    • PageHelper 用 1.x 的 pagehelper-spring-boot-starter
    • MyBatis-Plus 用 mybatis-plus-boot-starter
    • MyBatis-Plus 3.5.9 起,分页插件要单独引入 JSqlParser 模块。JDK 8 引 mybatis-plus-jsqlparser-4.9

    Spring Boot 3

    JDK 17+
    • PageHelper 要用支持 Boot 3 的新版 starter(比如 2.x)。老版本的自动配置只登记在 spring.factories 里,Boot 3 不读,分页会悄悄失效,也不报错
    • MyBatis-Plus 换成 mybatis-plus-spring-boot3-starter
    • 3.5.9 起引 mybatis-plus-jsqlparser,里面是 JSqlParser 5,要求 JDK 11+
    没变的:只能拦四个对象Plugin.wrap 套 JDK 代理最后登记的最先执行PageHelper 靠 ThreadLocalMyBatis-Plus 靠 Page 参数

    常踩的坑

    插件的坑多出在签名和顺序上,分页的坑多出在“到底分了哪一条查询”上。

    startPage 和查询之间夹了别的查询,分页分错了

    便签只管紧跟着的下一条查询。中间先查了一下用户权限,被分页的就成了那条。让两者紧挨着,或者用 doSelectPage(() -> …)。

    startPage 之后提前 return,下一个请求莫名其妙被分页

    便签没被用掉,留在线程上,线程池把这个线程分给下一个请求时还在。在 finally 里调用 PageHelper.clearPage()。

    接口返回给前端,只有数组,没有 total

    Page 就是一个 ArrayList,转 JSON 时只剩元素。包成 PageInfo 或自己的分页对象再返回。

    一对多查询分页,每页条数不对

    LIMIT 限制的是 JOIN 之后的行,一个订单有 3 条明细就占 3 行。先分页查主表的 id,再按 id 查明细。

    MyBatis-Plus 的 selectPage 不分页,total 是 0

    没注册 MybatisPlusInterceptor 和 PaginationInnerInterceptor。3.5.9 以后还要引入 jsqlparser 模块。

    启动报 Could not find method on interface … Executor named query

    @Signature 的 args 和接口方法对不上,多写或少写了一个参数类型。照着接口源码抄。

    只拦了 6 个参数的 query,插件一次都没进来

    SqlSession 调的是 4 个参数的那个。Executor 内部再转调 6 参数版本时,是对象自己调自己,不经过代理。两个签名都写上。

    StatementHandler 插件里,MetaObject 取不到 delegate

    它已经被别的插件套过一层,getTarget() 拿到的是代理。先顺着 h.target 一层层剥到真对象。

    页码越往后越慢

    深分页。能只往后翻就按 id 翻;必须跳页,就用延迟关联。

    每翻一页都 count,count 比查询还慢

    不需要总数就关掉 count;需要的话,把 total 缓存一会儿,或者只在第一页查一次。