夜雨聆风学习资料网

ARTICLE · 1044448

MyBatis 插件(Interceptor)原理与 PageHelper 分页实现

MyBatis 插件(Interceptor)原理与 PageHelper 分页实现

Interceptor:MyBatis 提供的插件接口,基于 JDK 动态代理拦截四大核心对象(Executor、StatementHandler、ParameterHandler、ResultSetHandler),实现 SQL 改写、性能监控、分页等功能。

PageHelper:国内最常用的 MyBatis 分页插件,本质就是一个 Interceptor,通过拦截 Executor.query,在 SQL 执行前按数据库方言自动生成分页语句。

核心痛点:面试常问"PageHelper 为什么只对紧跟的第一次查询生效""插件是什么时候、怎么包上代理的""多个插件谁先执行"——不懂 Interceptor 代理链就答不上来。

一、四大可拦截对象

MyBatis 只允许拦截以下四个接口(注意是接口,不是实现类),每次查询中它们被调用的顺序为:Executor → StatementHandler → ParameterHandler → ResultSetHandler

拦截对象
职责
常见插件场景
Executor
SQL 执行核心,负责增删改查调度
分页(PageHelper)、性能监控、读写分离
StatementHandler
SQL 预处理,生成 PreparedStatement
SQL 改写、打印完整 SQL
ParameterHandler
参数设置,将 Java 对象映射为 JDBC 参数
参数脱敏、敏感字段加密
ResultSetHandler
结果集映射,JDBC ResultSet → Java 对象
结果脱敏、字段解密
⚠️

插件限制: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.classObject.class,                RowBounds.classResultHandler.class}    )})public class MyPlugin implements Interceptor {    @Override    public Object intercept(Invocation invocation) throws Throwable {        // 执行前逻辑        System.out.println("SQL 执行前拦截...");        // 放行:进入下一层拦截器,或直达目标对象(见 2.4)        Object result = invocation.proceed();        // 执行后逻辑        System.out.println("SQL 执行后拦截...");        return result;    }    @Override    public Object plugin(Object target) {        // 注意:Plugin.wrap 内部会校验 target 是否匹配注解声明的接口,        // 不匹配时直接返回原 target,不会生成代理        return Plugin.wrap(target, this);    }    @Override    public 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 实现的接口里有没有匹配项:

1

若 target 实现了注解声明的接口 → 用 Proxy.newProxyInstance() 生成 JDK 动态代理

2

不匹配 → 直接返回原始 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() 做了什么?

1

invocation 封装了被拦截的目标对象、方法、参数

2

proceed() 本质是反射调用目标方法

3

若目标本身是下一层代理,反射调用会再次进入下一个拦截器的 intercept();若已到原始对象,则真正执行 MyBatis 逻辑

4

不调用 proceed() 就返回 → 拦截器短路,目标方法不会执行(可用作熔断/拦截校验)

三、PageHelper 分页原理

PageHelper 是一个 Interceptor,拦截 Executor.query(),在执行 SQL 前按数据库方言动态改写 SQL,实现物理分页

// 使用方式PageHelper.startPage(110);  // 第1页,每页10条List<User> list = userMapper.selectAll();  // 自动分页PageInfo<User> pageInfo = new PageInfo<>(list);

PageHelper 执行流程

1

PageHelper.startPage() 将分页参数(pageNum、pageSize)存入当前线程的 ThreadLocal

2

执行 Mapper 查询 → 走到 Executor 的 4 参数 query(),被 PageInterceptor 拦截

3

从 ThreadLocal 取出分页参数 → 先执行 COUNT 统计(默认开启)→ 再根据方言生成分页 SQL(如 MySQL 拼 LIMIT ?, ?,Oracle 用 ROWNUM 包裹等)

4

用生成的分页 SQL 构造新的 BoundSql(追加 offset/limit 参数映射)替代原 SQL 执行,返回当前页数据

5

查询结束在 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 清理细节(面试追问)

1

PageHelper 在查询执行完成的 finally 块中清理 ThreadLocal,即使 SQL 抛异常也会清理(防止影响下一次查询)

2

极端场景:若拦截器逻辑没有执行到清理路径(例如其他插件短路未放行、缓存直接命中未走该 query 分支、或 startPage 后没有紧跟查询),ThreadLocal 可能残留

3

业务侧兜底:可手动调用 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 层分页)完全不同:

对比项
RowBounds(原生)
PageHelper
分页方式
内存/应用层分页:SQL 不加 LIMIT,数据库返回全部符合条件的行,MyBatis 在结果映射时跳过 offset 行、只取需要的行
物理分页:拦截 SQL,按方言生成 LIMIT / ROWNUM,数据库只返回当前页
传输数据量
全量行都要从数据库传到应用,大数据量性能极差
只传当前页数据,性能好
实现机制
Mapper 方法传入 RowBounds 参数,ResultSetHandler 里 skipRows / limit
Interceptor 拦截 Executor.query,生成新 BoundSql 分页执行
总条数
不提供 count
默认自动执行 COUNT 统计(可关闭)

六、插件注册与配置

方式一: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-dialectreasonable 等是 PageHelper 自己读取的配置(引入 pagehelper-spring-boot-starter 时自动装配生效),放在 yml 的 pagehelper: 下没问题。提醒:若已用 pagehelper-spring-boot-starter,PageInterceptor 已被自动装配,无需再手动注册,避免重复。

开发注意事项(含 count / pageSizeZero)

1

@Signature 必须精确:type 写接口 Class(不能写实现类),method 名与参数列表严格匹配;写错不报错但拦截失效,排查很隐蔽

2

count 查询压力:PageHelper 默认每次分页都额外执行一次 COUNT 统计。若列表接口只需要"下一页"不需要总条数,可用 PageHelper.startPage(pageNum, pageSize, false) 关闭本次 count,或配置 countSuffix / countSql 自定义 count 语句

3

pageSizeZero:配置 pagehelper.page-size-zero=true 后,pageSize=0 时不分页、直接返回全部数据(结果仍是 Page 类型),适合导出场景

4

多插件顺序:后注册的拦截器在代理外层、intercept 先执行(见 2.3);需要控制先后时,用注册顺序 / @Order 调整,避免与 PageHelper 互相干扰(如 SQL 打印插件与分页插件的先后)

5

自定义插件注意:别在 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 开发 · 持续更新中

相关学习资料