MyBatis 分页插件实现原理(Interceptor 机制 + SQL 改写)

MyBatis 分页插件实现原理(Interceptor 机制 + SQL 改写)

目标:把 SELECT ... 变成两件事

1)可选的 COUNT(*) 统计总数

2)真正的分页查询:MySQL 用 LIMIT offset, size(其他数据库用各自方言)

这篇讲 MyBatis 插件(Plugin/Interceptor)是怎么"插进去"的,以及分页插件通常拦截哪一层、怎么改 SQL、怎么处理参数、缓存、以及坑。


1. MyBatis 插件体系:Interceptor 到底拦截了什么?

MyBatis 的插件机制基于 JDK 动态代理,核心接口是:

  • org.apache.ibatis.plugin.Interceptor
  • org.apache.ibatis.plugin.Plugin
  • org.apache.ibatis.plugin.Invocation

1.1 插件能拦截的 4 个点(官方支持)

插件只允许拦截下列四类核心组件的方法:

  1. Executor(执行器,负责 query/update、缓存等)
  2. StatementHandler(生成 JDBC Statement、拼 SQL、设置参数)
  3. ParameterHandler(把参数塞进 PreparedStatement)
  4. ResultSetHandler(把 ResultSet 映射成对象)

分页插件 最常拦截的是 Executor.query(...)

因为这个层级能拿到 MappedStatement + BoundSql + 参数 + RowBounds + 缓存 key 等信息,最适合做:

  • 先跑一次 count
  • 再把 SQL 改成分页 SQL
  • 最后走原来的执行链

2. 插件是怎么"包裹" MyBatis 组件的?

MyBatis 在创建上述 4 大对象时,会执行类似:

java 复制代码
target = interceptorChain.pluginAll(target);

pluginAll 会依次调用每个 Interceptor 的 plugin(target),最终把 target 包成一层层代理对象。

2.1 Interceptor 的典型结构

java 复制代码
public interface Interceptor {
  Object intercept(Invocation invocation) throws Throwable;
  default Object plugin(Object target) { return Plugin.wrap(target, this); }
  default void setProperties(Properties properties) {}
}
  • Plugin.wrap 用动态代理创建代理对象
  • 只会代理你通过 @Intercepts/@Signature 声明的方法
  • 命中后会进入 intercept(...),否则直接透传

3. 分页插件的总体流程(你可以把它当"标准套路")

以拦截 Executor.query 为例,分页插件一般做:

  1. 判断这次查询要不要分页
    • 传了 RowBounds
    • 或者参数里有 pageNum/pageSize
    • 或者 ThreadLocal 里标记了分页(PageHelper 常用)
  2. (可选)执行 count
    • 把原 SQL 改造成 SELECT COUNT(1) FROM (原SQL) tmp
    • 或者做更聪明的改写(去掉 order by)
  3. 改写分页 SQL
    • MySQL:... LIMIT offset, size
    • PostgreSQL:... LIMIT size OFFSET offset
    • Oracle:ROWNUM/窗口函数
  4. 把改写后的 BoundSql 传回去继续执行
  5. 把结果包装成 Page 对象(含 total、pages 等)或直接返回 list

4. 关键拦截点:为什么大家都爱拦 Executor.query

Executor.query 的典型签名(不同版本可能略有差别):

java 复制代码
List<E> query(MappedStatement ms, Object parameter,
              RowBounds rowBounds, ResultHandler resultHandler,
              CacheKey cacheKey, BoundSql boundSql)

你能拿到:

  • MappedStatement ms:映射信息(statementId、配置、参数映射、返回类型等)
  • BoundSql boundSql:最终 SQL + 参数映射(ParameterMapping)
  • parameter:入参对象(Map/实体/基本类型)
  • RowBounds:偏移/限制(经典分页入口)
  • CacheKey:二级缓存相关(分页会影响 key)
  • resultHandler:结果处理

分页插件的核心:改 boundSql.getSql(),以及必要时构造 count 的 MappedStatement/BoundSql


5. SQL 改写:BoundSql 改不了?------常见做法

BoundSqlsql 字段通常没有 public setter,所以插件一般用两种方式:

5.1 反射修改 BoundSql.sql(最常见)

  • 直接通过反射把 BoundSql 的私有字段 sql 改掉
  • 同时可能要改 additionalParameters(比如 foreach 产生的临时参数)

5.2 新建 BoundSql +(有时)新建 MappedStatement

  • 构造一个新的 BoundSql(newSql, parameterMappings, parameterObject)
  • 构造新的 MappedStatement(复制原 ms 的配置,只替换 SqlSource)
  • 这套更"干净",但代码更长

6. Count 查询:怎么做才靠谱?

6.1 最稳(但可能慢):子查询包一层

sql 复制代码
SELECT COUNT(1) FROM (
  原SQL
) tmp_count

优点:几乎不出错(group by / distinct / union 都能包)

缺点:某些数据库/复杂 SQL 可能性能差,且 order by 可能拖慢

6.2 更快(但更容易踩坑):去掉 order by

把原 SQL 的 ORDER BY ... 剪掉再 count,性能通常更好。

但注意:如果原 SQL 里有 ORDER BY 影响语义(极少见)或包含子查询 order by,就要谨慎。


7. 参数处理:LIMIT 的参数怎么绑定?

分页 SQL 有两种常见方式:

7.1 直接拼字面量(简单,但不推荐)

sql 复制代码
... LIMIT 20, 10

问题:可能导致 SQL 计划缓存/安全性差(虽然 offset/size 通常来自可信来源)

7.2 用参数占位(更规范)

sql 复制代码
... LIMIT ?, ?

这会带来一个关键问题:
你必须把 offset/size 作为"额外参数"加入 BoundSql,并且补齐 ParameterMapping,否则 ParameterHandler 不知道怎么 set 这两个参数。

PageHelper/MP 等成熟插件都封装了这块逻辑。


8. 缓存(CacheKey)与分页:不处理会出事

MyBatis 二级缓存的 key 与 BoundSql.sql、参数等有关。

分页会改变 SQL,所以:

  • 如果你在 Executor.query 拦截点改了 SQL
    必须确保 CacheKey 也对应变化(通常用 MyBatis 已经生成的 cacheKey 并在调用另一个重载 query 时重新生成,或自己生成新的)

否则可能出现:

  • 第 1 页缓存结果被第 2 页复用(灾难)
  • count 被缓存成 list 查询结果等

9. 一个"能跑"的简化版分页插件示例(MySQL 方言)

说明:这是教学版,展示"原理骨架"。生产环境建议用 PageHelper / MyBatis-Plus 等成熟实现。

java 复制代码
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.RowBounds;
import org.apache.ibatis.session.ResultHandler;

import java.lang.reflect.Field;
import java.util.Properties;

@Intercepts({
  @Signature(type = Executor.class, method = "query",
    args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class SimplePaginationInterceptor implements Interceptor {

  @Override
  public Object intercept(Invocation invocation) throws Throwable {
    Object[] args = invocation.getArgs();
    MappedStatement ms = (MappedStatement) args[0];
    Object parameter = args[1];
    RowBounds rb = (RowBounds) args[2];

    // 1) 没有分页就放行(RowBounds.DEFAULT 表示不分页)
    if (rb == null || rb == RowBounds.DEFAULT) {
      return invocation.proceed();
    }

    // 2) 拿到原 SQL
    BoundSql boundSql = ms.getBoundSql(parameter);
    String sql = boundSql.getSql();

    // 3) 改写为 MySQL LIMIT
    int offset = rb.getOffset();
    int limit = rb.getLimit();
    String pageSql = sql + " LIMIT " + offset + "," + limit;

    // 4) 反射写回 BoundSql.sql
    setField(boundSql, "sql", pageSql);

    // 5) 把 RowBounds 置回 DEFAULT,避免 MyBatis 内部再做内存分页(双重分页)
    args[2] = RowBounds.DEFAULT;

    return invocation.proceed();
  }

  private static void setField(Object target, String fieldName, Object value) throws Exception {
    Field f = target.getClass().getDeclaredField(fieldName);
    f.setAccessible(true);
    f.set(target, value);
  }

  @Override
  public Object plugin(Object target) {
    return Plugin.wrap(target, this);
  }

  @Override
  public void setProperties(Properties properties) {}
}

关键点:把 RowBounds 置回 DEFAULT

否则 MyBatis 可能会在 JDBC 返回全量后再内存截断一次,性能直接爆炸。


10. 工程级分页插件还需要补哪些东西?

上面的"教学版"少了很多生产必须项:

  1. count 总数
    • 生成 countSql 并执行
    • 把 total 塞回 Page 对象
  2. 方言 Dialect
    • 同一套插件支持 MySQL/Postgres/Oracle/SQLServer
  3. 参数占位绑定
    • LIMIT ?, ? 需要追加 ParameterMapping
  4. 复杂 SQL 的 count 优化
    • 去 order by、处理 distinct/group by/union
  5. 多插件链路兼容
    • 别把 BoundSql 改坏导致后续插件(例如审计、租户、数据权限)出问题
  6. 二级缓存与 CacheKey
  7. 线程上下文
    • PageHelper 常用 ThreadLocal 来在 mapper 方法外注入分页信息(PageHelper.startPage(...)

11. PageHelper / MyBatis-Plus 大致怎么做(高层理解)

11.1 PageHelper(经典)

  • startPage(pageNum, pageSize) 把分页信息放进 ThreadLocal
  • 拦截 Executor.query
  • 先跑 count(可配置)
  • 再改写 SQL(方言)
  • 最后把 list 包成 Page(实现了 List 接口 + total 等字段)

11.2 MyBatis-Plus 分页(常见于 Spring Boot)

  • 通过 MybatisPlusInterceptor + PaginationInnerInterceptor
  • InnerInterceptor 链式处理:分页、乐观锁、租户、数据权限等
  • 同样走 SQL 改写 + count 的套路,但工程化更强

12. 最容易踩坑的 10 个点(实战很常见)

  1. WHERE 不走索引:分页 + 大表 = 慢到怀疑人生
  2. 双重分页:没把 RowBounds 置回 DEFAULT
  3. count 很慢:没去掉 order by,或子查询包太深
  4. group by/distinct:count 改写不正确
  5. union:count 改写不正确
  6. 二级缓存:CacheKey 没考虑分页参数
  7. 多插件顺序:租户插件/数据权限插件与分页插件顺序会影响 SQL
  8. 参数追加:LIMIT 参数没追加 ParameterMapping 导致报错
  9. for each 参数:additionalParameters 丢失导致参数绑定失败
  10. 分页过深 :offset 很大时,MySQL LIMIT offset, size 本身就会慢(可考虑"基于游标/ID 的 seek 分页")

13. 一句话建议(真实经验)

  • 真要自己写分页插件 :从拦 Executor.query 入手,先做"只改 SQL + 关闭 RowBounds 内存分页"的最小闭环
  • 生产落地:优先直接用 PageHelper / MyBatis-Plus 的成熟实现,把时间花在索引和 SQL 设计上

14. 附:Spring Boot 配置方式(示例)

java 复制代码
@Configuration
public class MybatisConfig {
  @Bean
  public SimplePaginationInterceptor simplePaginationInterceptor() {
    return new SimplePaginationInterceptor();
  }
}

或在 mybatis-config.xml

xml 复制代码
<plugins>
  <plugin interceptor="com.example.SimplePaginationInterceptor"/>
</plugins>

相关推荐
小肖爱笑不爱笑14 分钟前
JDBC Mybatis
数据库·mybatis
while(1){yan}1 小时前
图书管理系统(超详细版)
spring boot·spring·java-ee·tomcat·log4j·maven·mybatis
林shir5 小时前
3.6-Web后端基础(java操作数据库)
spring·mybatis
super_lzb18 小时前
mybatis拦截器ParameterHandler详解
java·数据库·spring boot·spring·mybatis
CodeAmaz1 天前
MyBatis 如何实现“面向接口”查询
mybatis·面向接口
此剑之势丶愈斩愈烈1 天前
mybatis-plus乐观锁
开发语言·python·mybatis
雨中飘荡的记忆1 天前
MyBatis数据源模块详解
mybatis
heartbeat..1 天前
Java 持久层框架 MyBatis 全面详解(附带Idea添加对应的XML文件模板教程)
java·数据库·intellij-idea·mybatis·持久化
Predestination王瀞潞1 天前
Java EE数据访问框架技术(第三章:Mybatis多表关系映射-下)
java·java-ee·mybatis