ARTICLE · 1044448
MyBatis 插件(Interceptor)原理与 PageHelper 分页实现
Interceptor:MyBatis 提供的插件接口,基于 JDK 动态代理拦截四大核心对象(Executor、StatementHandler、ParameterHandler、ResultSetHandler),实现 SQL 改写、性能监控、分页等功能。
PageHelper:国内最常用的 MyBatis 分页插件,本质就是一个 Interceptor,通过拦截 Executor.query,在 SQL 执行前按数据库方言自动生成分页语句。
核心痛点:面试常问"PageHelper 为什么只对紧跟的第一次查询生效""插件是什么时候、怎么包上代理的""多个插件谁先执行"——不懂 Interceptor 代理链就答不上来。
一、四大可拦截对象
MyBatis 只允许拦截以下四个接口(注意是接口,不是实现类),每次查询中它们被调用的顺序为:Executor → StatementHandler → ParameterHandler → ResultSetHandler。
PreparedStatement | ||
插件限制:MyBatis 插件只能拦截上述四大接口的方法,不能拦截 Mapper 接口方法(Mapper 是 MapperProxy 动态代理,不走 interceptorChain)。想对 Mapper 层做增强,要用 Spring AOP 或 MyBatis-Plus 等框架能力。
二、Interceptor 接口与代理链原理
实现 org.apache.ibatis.plugin.Interceptor 接口,通过 @Intercepts + @Signature 注解声明拦截目标。注意 type 必须写接口 Class(如 Executor.class),不能写实现类;method 名称和参数列表必须与目标方法严格一致,写错不会报错,但拦截会静默失效。
import org.apache.ibatis.executor.Executor;import org.apache.ibatis.mapping.MappedStatement;import org.apache.ibatis.plugin.*;import org.apache.ibatis.session.ResultHandler;import org.apache.ibatis.session.RowBounds;import java.util.Properties;// 拦截 Executor 的 4 参数 query 方法(接口签名必须精确匹配)@Intercepts({@Signature(type = Executor.class,method = "query",args = {MappedStatement.class, Object.class,RowBounds.class, ResultHandler.class})})public class MyPlugin implements Interceptor {@Overridepublic Object intercept(Invocation invocation) throws Throwable {// 执行前逻辑System.out.println("SQL 执行前拦截...");// 放行:进入下一层拦截器,或直达目标对象(见 2.4)Object result = invocation.proceed();// 执行后逻辑System.out.println("SQL 执行后拦截...");return result;}@Overridepublic Object plugin(Object target) {// 注意:Plugin.wrap 内部会校验 target 是否匹配注解声明的接口,// 不匹配时直接返回原 target,不会生成代理return Plugin.wrap(target, this);}@Overridepublic void setProperties(Properties properties) {// 读取插件属性(如 PageHelper 的 helperDialect / reasonable)}}
2.1 代理对象是什么时候创建的?
面试常问:拦截器代理不是在 SQL 执行时才生成,而是在对象实例化完成的瞬间就套好。四大对象每次创建后,Configuration 都会立即调用 interceptorChain.pluginAll(target) 层层包代理,属于初始化阶段。
// org.apache.ibatis.session.Configuration#newExecutor(节选)// 先创建原始 Executor,立即套上插件代理,之后每次调用都走代理public Executor newExecutor(Transaction transaction, ExecutorType executorType) {Executor executor = new SimpleExecutor(...); // 原始对象// 实例化完成 → 立刻 pluginAll 套代理(初始化阶段)executor = (Executor) interceptorChain.pluginAll(executor);return executor;}
2.2 Plugin.wrap() 不是无条件生成代理
Plugin.wrap(target, interceptor) 内部会先解析 @Intercepts + @Signature 注解得到"目标接口 → 方法集合"的映射表,再检查当前 target 实现的接口里有没有匹配项:
若 target 实现了注解声明的接口 → 用 Proxy.newProxyInstance() 生成 JDK 动态代理
不匹配 → 直接返回原始 target,不生成代理对象
2.3 多个拦截器的执行顺序(高频追问)
interceptorChain.pluginAll() 按注册顺序(List 顺序)遍历:先注册的先进内层,后注册的包在外层。所以后注册的拦截器在最外层,intercept() 先执行;proceed() 逐层向内传递,最后才到达原始对象;返回时内层先返回、再逐层回到外层(后置逻辑顺序与前置相反)。
注册按顺序 addInterceptor:A → B → C
包装pluginAll:A 包最内层,C 包最外层(洋葱结构:C → B → A → 原始对象)
执行intercept 顺序:C → B → A → 原始对象(最后注册的先执行)
返回结果先回 A 的后置逻辑,再逐层回到 C,最终返回调用方
Spring Boot 场景:通过 @Bean 注册 Interceptor 时,可用 @Order 控制注册顺序(值越小越先注册、越靠内层)。mybatis-config.xml 中 <plugins> 里写在前面的先注册、在内层。
2.4 invocation.proceed() 做了什么?
invocation 封装了被拦截的目标对象、方法、参数
proceed() 本质是反射调用目标方法
若目标本身是下一层代理,反射调用会再次进入下一个拦截器的 intercept();若已到原始对象,则真正执行 MyBatis 逻辑
不调用 proceed() 就返回 → 拦截器短路,目标方法不会执行(可用作熔断/拦截校验)
三、PageHelper 分页原理
PageHelper 是一个 Interceptor,拦截 Executor.query(),在执行 SQL 前按数据库方言动态改写 SQL,实现物理分页。
// 使用方式PageHelper.startPage(1, 10); // 第1页,每页10条List<User> list = userMapper.selectAll(); // 自动分页PageInfo<User> pageInfo = new PageInfo<>(list);
PageHelper 执行流程
PageHelper.startPage() 将分页参数(pageNum、pageSize)存入当前线程的 ThreadLocal
执行 Mapper 查询 → 走到 Executor 的 4 参数 query(),被 PageInterceptor 拦截
从 ThreadLocal 取出分页参数 → 先执行 COUNT 统计(默认开启)→ 再根据方言生成分页 SQL(如 MySQL 拼 LIMIT ?, ?,Oracle 用 ROWNUM 包裹等)
用生成的分页 SQL 构造新的 BoundSql(追加 offset/limit 参数映射)替代原 SQL 执行,返回当前页数据
查询结束在 finally 中清理 ThreadLocal(见 3.3 细节)
核心逻辑示意(伪代码)
不要误解成"字符串拼接 LIMIT":BoundSql 的 sql 字段是 private final,不能修改原对象。真实 PageHelper 是:按方言生成分页 SQL → 复制/构造新的 SqlSource 与全新 BoundSql(追加分页参数映射)→ 替换入参后放行。直接拼 LIMIT 只适配 MySQL,Oracle、DB2 等方言完全不同。下方代码仅示意原理,不可照搬生产。
// PageInterceptor.intercept 逻辑(伪代码,仅示意流程)public Object intercept(Invocation invocation) throws Throwable {try {// 1. 从 ThreadLocal 取分页参数Page page = PageHelper.getLocalPage();if (page == null) {return invocation.proceed(); // 无分页参数,直接放行}// 2. 默认先执行 COUNT 统计(可关闭,见 5.3)// ... 执行 countSql ...// 3. 关键:根据方言(Dialect)生成分页 SQL。// MySQL -> SELECT ... LIMIT ?, ?// Oracle -> SELECT * FROM (SELECT t.*, ROWNUM rn FROM (...) t) WHERE rn > ? AND rn <= ?// PageHelper 通过 PageAutoDialect 按数据库类型选择方言String pageSql = dialect.getPageSql(originalSql, page);// 4. 构造全新 BoundSql / SqlSource,把分页参数拼进 parameterMappings// (原 BoundSql 的 sql 是 final,绝不原地修改)BoundSql newBoundSql = buildNewBoundSql(pageSql, extraParams);// 5. 替换入参后放行(短路则不执行 proceed 即熔断)return invocation.proceed();} finally {// 无论成功/异常,finally 中清理 ThreadLocal(见 3.3)PageHelper.clearPage();}}
3.3 ThreadLocal 清理细节(面试追问)
PageHelper 在查询执行完成的 finally 块中清理 ThreadLocal,即使 SQL 抛异常也会清理(防止影响下一次查询)
极端场景:若拦截器逻辑没有执行到清理路径(例如其他插件短路未放行、缓存直接命中未走该 query 分支、或 startPage 后没有紧跟查询),ThreadLocal 可能残留
业务侧兜底:可手动调用 PageHelper.clearPage() 主动清理,避免线程池复用时"串页"
3.4 Executor 有两个 query 重载,拦哪个?
Executor 接口有 4 参数和 6 参数两个 query() 重载。PageInterceptor 拦截的是 4 参数入口方法(MappedStatement, Object, RowBounds, ResultHandler);6 参数版本是缓存内部调用(多传 CacheKey 和 BoundSql),位于 4 参数方法内部、缓存层之下,不会被 PageHelper 拦截。
四、高频陷阱:PageHelper 只对紧跟的查询生效
经典面试题。根本原因是 分页参数存在 ThreadLocal,且查询结束会被 finally 清理——本质与 3.3 的清理机制是同一件事。
错误写法:PageHelper.startPage(1, 10);userMapper.selectAll(); ← 生效orderMapper.selectAll(); ← 不生效!ThreadLocal 已被 finally 清理
正确写法:每条查询前各自调用 startPage(),或用支持分页参数的方法PageHelper.startPage(1, 10); userMapper.selectAll();PageHelper.startPage(1, 10); orderMapper.selectAll();
五、RowBounds vs PageHelper:内存分页 vs 物理分页
这是高频对比题。MyBatis 自带的 RowBounds 参数做的是逻辑分页(应用层分页),和 PageHelper 的物理分页(SQL 层分页)完全不同:
六、插件注册与配置
方式一:JavaConfig 注册(Spring Boot 推荐)
mybatis-spring-boot-starter 会自动收集容器中实现 Interceptor 接口的 Bean,注入拦截器链。
import com.github.pagehelper.PageInterceptor;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.core.annotation.Order;import java.util.Properties;@Configurationpublic class MyBatisPluginConfig {// 注册为 Bean,starter 自动加入拦截器链;// @Order 值越小越先注册(在代理链中越靠内层)@Bean@Order(0)public PageInterceptor pageInterceptor() {PageInterceptor interceptor = new PageInterceptor();Properties props = new Properties();props.setProperty("helperDialect", "mysql");props.setProperty("reasonable", "true");interceptor.setProperties(props);return interceptor;}}
方式二:mybatis-config.xml + <plugins> 注册
在 mybatis.config-location 指向的 mybatis-config.xml 中用 <plugins> 标签注册(对应 MyBatis 原生的 <plugins> 节点,不是 <settings> 下的配置项):
# application.yml(mybatis-config.xml 方式)mybatis:config-location: classpath:mybatis-config.xml
<!-- mybatis-config.xml --><configuration><settings><!-- 这里只有 mapUnderscoreToCamelCase 等 settings 项,没有 interceptor 配置项 --><settingname="mapUnderscoreToCamelCase"value="true"/></settings><!-- 插件必须配置在 plugins 标签下 --><plugins><plugininterceptor="com.github.pagehelper.PageInterceptor"/></plugins></configuration>
常见错误:下面这种写法 不生效——mybatis.configuration 对应 mybatis-config.xml 的 <settings> 标签,settings 下根本没有 interceptor 配置项:mybatis: configuration: interceptor: com.github.pagehelper.PageInterceptor
pagehelper 顶级配置是有效的:pagehelper.helper-dialect、reasonable 等是 PageHelper 自己读取的配置(引入 pagehelper-spring-boot-starter 时自动装配生效),放在 yml 的 pagehelper: 下没问题。提醒:若已用 pagehelper-spring-boot-starter,PageInterceptor 已被自动装配,无需再手动注册,避免重复。
开发注意事项(含 count / pageSizeZero)
@Signature 必须精确:type 写接口 Class(不能写实现类),method 名与参数列表严格匹配;写错不报错但拦截失效,排查很隐蔽
count 查询压力:PageHelper 默认每次分页都额外执行一次 COUNT 统计。若列表接口只需要"下一页"不需要总条数,可用 PageHelper.startPage(pageNum, pageSize, false) 关闭本次 count,或配置 countSuffix / countSql 自定义 count 语句
pageSizeZero:配置 pagehelper.page-size-zero=true 后,pageSize=0 时不分页、直接返回全部数据(结果仍是 Page 类型),适合导出场景
多插件顺序:后注册的拦截器在代理外层、intercept 先执行(见 2.3);需要控制先后时,用注册顺序 / @Order 调整,避免与 PageHelper 互相干扰(如 SQL 打印插件与分页插件的先后)
自定义插件注意:别在 intercept 里对四大对象做重操作(每次查询都走代理);插件自身若用 ThreadLocal 传参,务必 finally 清理防泄漏
七、面试速答
Q:MyBatis 插件能拦截哪些对象?什么时候包上代理?
A:四大接口对象——Executor、StatementHandler、ParameterHandler、ResultSetHandler。代理在对象实例化完成的瞬间通过 interceptorChain.pluginAll() 生成,属于初始化阶段,不是 SQL 执行时才包。
Q:多个插件谁先执行?Plugin.wrap() 会无条件生成代理吗?
A:pluginAll 按注册顺序遍历,后注册的在代理外层、intercept 先执行;proceed 逐层向内,最终到原始对象。Plugin.wrap() 会先解析 @Intercepts/@Signature 校验 target 是否实现声明接口,不匹配直接返回原对象,不生成代理。
Q:PageHelper 为什么只对紧跟的查询生效?
A:分页参数存 ThreadLocal,第一次查询结束在 finally 中清理(抛异常也清理)。第二次查询时 ThreadLocal 已空,不生效。极端场景可手动 PageHelper.clearPage() 兜底。
Q:RowBounds 和 PageHelper 分页有什么区别?
A:RowBounds 是内存/应用层分页,SQL 不加 LIMIT,全量结果从数据库传到应用再跳过,大数据量性能差;PageHelper 是物理分页,拦截 SQL 按方言生成分页语句,数据库只返回当前页。
Q:Executor 有两个 query 重载,PageHelper 拦截哪个?
A:拦截 4 参数入口 query(MappedStatement, Object, RowBounds, ResultHandler)。6 参数版本是缓存内部调用,多传 CacheKey 和 BoundSql,不会被 PageHelper 拦截。
— END —
如果觉得有帮助,欢迎点赞 · 在看 · 分享
深入 Java 开发 · 持续更新中