MyBatis 分页插件实现原理(Interceptor 机制 + SQL 改写)
目标:把
SELECT ...变成两件事1)可选的
COUNT(*)统计总数2)真正的分页查询:MySQL 用
LIMIT offset, size(其他数据库用各自方言)
这篇讲 MyBatis 插件(Plugin/Interceptor)是怎么"插进去"的,以及分页插件通常拦截哪一层、怎么改 SQL、怎么处理参数、缓存、以及坑。
1. MyBatis 插件体系:Interceptor 到底拦截了什么?
MyBatis 的插件机制基于 JDK 动态代理,核心接口是:
org.apache.ibatis.plugin.Interceptororg.apache.ibatis.plugin.Pluginorg.apache.ibatis.plugin.Invocation
1.1 插件能拦截的 4 个点(官方支持)
插件只允许拦截下列四类核心组件的方法:
Executor(执行器,负责 query/update、缓存等)StatementHandler(生成 JDBC Statement、拼 SQL、设置参数)ParameterHandler(把参数塞进 PreparedStatement)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 为例,分页插件一般做:
- 判断这次查询要不要分页
- 传了
RowBounds? - 或者参数里有
pageNum/pageSize? - 或者 ThreadLocal 里标记了分页(PageHelper 常用)
- 传了
- (可选)执行 count
- 把原 SQL 改造成
SELECT COUNT(1) FROM (原SQL) tmp - 或者做更聪明的改写(去掉 order by)
- 把原 SQL 改造成
- 改写分页 SQL
- MySQL:
... LIMIT offset, size - PostgreSQL:
... LIMIT size OFFSET offset - Oracle:ROWNUM/窗口函数
- MySQL:
- 把改写后的 BoundSql 传回去继续执行
- 把结果包装成 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 改不了?------常见做法
BoundSql 的 sql 字段通常没有 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. 工程级分页插件还需要补哪些东西?
上面的"教学版"少了很多生产必须项:
- count 总数
- 生成 countSql 并执行
- 把 total 塞回 Page 对象
- 方言 Dialect
- 同一套插件支持 MySQL/Postgres/Oracle/SQLServer
- 参数占位绑定
LIMIT ?, ?需要追加 ParameterMapping
- 复杂 SQL 的 count 优化
- 去 order by、处理 distinct/group by/union
- 多插件链路兼容
- 别把 BoundSql 改坏导致后续插件(例如审计、租户、数据权限)出问题
- 二级缓存与 CacheKey
- 线程上下文
- PageHelper 常用 ThreadLocal 来在 mapper 方法外注入分页信息(
PageHelper.startPage(...))
- PageHelper 常用 ThreadLocal 来在 mapper 方法外注入分页信息(
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 个点(实战很常见)
- WHERE 不走索引:分页 + 大表 = 慢到怀疑人生
- 双重分页:没把 RowBounds 置回 DEFAULT
- count 很慢:没去掉 order by,或子查询包太深
- group by/distinct:count 改写不正确
- union:count 改写不正确
- 二级缓存:CacheKey 没考虑分页参数
- 多插件顺序:租户插件/数据权限插件与分页插件顺序会影响 SQL
- 参数追加:LIMIT 参数没追加 ParameterMapping 导致报错
- for each 参数:additionalParameters 丢失导致参数绑定失败
- 分页过深 :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>