一、分页原理
第一步:没有分页时,系统会怎么死?
假设你有一张用户表,初期只有100条数据:
List<User> list = session.selectList("selectAll");这没问题,100条数据内存轻松装下。
但业务增长了,表里有100万条数据。此时selectAll会发生什么?
数据库端:全表扫描,磁盘IO爆炸,数据库连接长时间被占用
网络层:100万条数据在网络上传输,带宽被打满
应用内存:JVM堆内存被撑爆,直接
OutOfMemoryError用户体验:页面卡死,请求超时
所以分页不是"优化项",而是大数据场景下的"生存项"。
第二步:MyBatis 原生提供的 RowBounds(逻辑分页)
MyBatis 内置了一个RowBounds类,试图解决这个问题:
public class RowBounds { private int offset; private int limit; }// 查询第1页,每页10条 RowBounds rowBounds = new RowBounds(0, 10); List<User> list = session.selectList("selectAll", null, rowBounds);它底层是怎么工作的?
RowBounds只有两个属性:offset(跳过多少行)和limit(取多少行)。
当这个参数被传到BaseExecutor后,MyBatis 会把它一路带到ResultSetHandler。在解析ResultSet时:
// 伪代码,在 DefaultResultSetHandler 中 while (rs.next()) { // 先跳过 offset 行 if (currentRow < rowBounds.getOffset()) { currentRow++; continue; } // 再取 limit 行 if (currentRow >= rowBounds.getOffset() + rowBounds.getLimit()) { break; } // 映射成对象 Object row = mapRow(rs); resultList.add(row); currentRow++; }关键问题:SQL 本身没有被修改!
数据库执行的仍然是:
SELECT * FROM user数据库返回了全部100万条数据到内存,MyBatis 只是在内存里帮你跳过了前offset行,只把后面的limit行封装成对象。
这就是"逻辑分页"——在内存中做筛选。
弊端:治标不治本
数据库仍然全表扫描
网络仍然传输了100万条数据
JVM 仍然加载了100万条结果集
只是最后封装成的 List 只有10个元素
对于大数据量,逻辑分页等于没有分页。
第三步:为什么 MyBatis 不直接做物理分页?
物理分页的意思是:修改 SQL 本身,让数据库只返回10条数据。
比如 MySQL:
SELECT * FROM user LIMIT 0, 10Oracle:
SELECT * FROM ( SELECT t.*, ROWNUM rn FROM user t WHERE ROWNUM <= 10 ) WHERE rn > 0SQL Server:
SELECT TOP 10 * FROM user问题来了:不同数据库的分页 SQL 语法完全不同。
MyBatis 是一个通用 ORM 框架,它不知道你用的是 MySQL、Oracle 还是 SQL Server。如果它在底层硬编码某种分页语法,就丧失了跨数据库能力。
所以 MyBatis 的设计选择是:
提供一个通用的
RowBounds(逻辑分页),保证跨数据库兼容性物理分页交给插件或开发者自己实现
第四步:分页插件的解决思路——拦截并改写 SQL
既然 MyBatis 不改 SQL,那能不能在执行 SQL 之前,动态地把 SQL 改成带分页语法的?
这就是MyBatis 插件(Interceptor)机制的设计来源。
MyBatis 插件的本质
MyBatis 允许你拦截四大核心对象:
Executor(执行器)StatementHandler(SQL 语句处理器)ParameterHandler(参数处理器)ResultSetHandler(结果集处理器)
分页插件通常拦截Executor.query()方法,在 SQL 执行前"偷梁换柱"。
分页插件的核心逻辑
// 伪代码:分页插件的拦截逻辑 public Object intercept(Invocation invocation) { // 1. 获取当前 SQL MappedStatement ms = (MappedStatement) invocation.getArgs()[0]; BoundSql boundSql = ms.getBoundSql(parameter); String originalSql = boundSql.getSql(); // 2. 判断是否需要分页(比如检查 ThreadLocal 里有没有分页参数) Page page = getPageFromThreadLocal(); if (page == null) { // 不分页,直接执行原 SQL return invocation.proceed(); } // 3. 获取数据库方言(MySQL? Oracle?) Dialect dialect = getDialect(); // 4. 改写 SQL 为分页 SQL String pageSql = dialect.getPageSql(originalSql, page.getOffset(), page.getLimit()); // 5. 创建新的 MappedStatement,把 SQL 替换掉 MappedStatement newMs = createNewMappedStatement(ms, pageSql); // 6. 执行分页查询 return executor.query(newMs, parameter, ...); }关键:插件在 SQL 到达数据库之前,把它改成了带LIMIT或ROWNUM的物理分页 SQL。
第五步:PageHelper 的完整工作流程(最主流的实现)
PageHelper是 MyBatis 生态中最流行的分页插件。它把上面的思路封装得非常易用,但内部逻辑很严谨:
1. 注册插件
<plugins> <plugin interceptor="com.github.pagehelper.PageInterceptor"> <!-- 指定数据库方言,或让插件自动检测 --> <property name="helperDialect" value="mysql"/> <!-- 是否自动执行 count 查询 --> <property name="offset-as-page-num" value="true"/> <property name="row-bounds-with-count" value="true"/> </plugin> </plugins>2. 使用方式
// 开启分页(把分页参数绑定到当前线程) PageHelper.startPage(1, 10); // 紧跟的这条查询会被分页 List<User> list = mapper.selectAll(); // 结果强转成 Page,获取分页信息 PageInfo<User> pageInfo = new PageInfo<>(list); System.out.println("总条数:" + pageInfo.getTotal()); System.out.println("总页数:" + pageInfo.getPages());3. 为什么必须"紧跟"查询?
因为PageHelper.startPage()内部用了ThreadLocal存储分页参数:
// PageHelper 源码逻辑 protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<>(); public static <E> Page<E> startPage(int pageNum, int pageSize) { Page<E> page = new Page<>(pageNum, pageSize); LOCAL_PAGE.set(page); // 绑定到当前线程 return page; }ThreadLocal 的特性是:同一个线程内共享,不同线程隔离。
当你调用mapper.selectAll()时,插件从ThreadLocal里取出Page对象,知道"哦,这条 SQL 需要分页"。
如果你 startPage 之后做了别的操作,再执行查询:
PageHelper.startPage(1, 10); // 中间插了别的逻辑,甚至别的查询 otherMapper.doSomething(); // 糟糕!这条 SQL 也会被分页! List<User> list = mapper.selectAll(); // 可能分页参数已经被清掉了所以 PageHelper 的设计约束是:startPage必须和需要分页的查询紧密相邻,且中间不能有其他查询。
4. 它到底执行了几次 SQL?
PageHelper 默认会执行两次SQL:
第一次:COUNT 查询
插件会自动把你的 SQL 改写成COUNT(*):
-- 你的原 SQL SELECT * FROM user WHERE status = 1 -- 被改写成 SELECT COUNT(0) FROM user WHERE status = 1这是为了计算total(总条数),这样前端才能显示"共XX页"。
第二次:分页查询
根据方言改写:
-- MySQL SELECT * FROM user WHERE status = 1 LIMIT 0, 10 -- Oracle SELECT * FROM ( SELECT TMP.*, ROWNUM ROW_ID FROM ( SELECT * FROM user WHERE status = 1 ) TMP WHERE ROWNUM <= 10 ) WHERE ROW_ID > 05.执行完后清理 ThreadLocal
// 在 finally 块中 LOCAL_PAGE.remove(); // 防止内存泄漏,防止影响下一个查询为什么必须 remove?
ThreadLocal如果不清理,线程归还线程池后,下一个请求复用这个线程时,会拿到脏的分页参数这会导致诡异的 bug:某个请求突然分页了,某个请求突然没分页
第六步:PageHelper 拦截的是哪个环节?
完整调用链:
你的代码: mapper.selectAll() │ ▼ MapperProxy.invoke() → 组装参数 │ ▼ SqlSession.selectList() → 进入 MyBatis 内核 │ ▼ CachingExecutor.query() → 查二级缓存(如果有) │ ▼ BaseExecutor.query() → 查一级缓存 │ ▼ SimpleExecutor.doQuery() → 创建 Statement │ ▼ RoutingStatementHandler → 路由到 PreparedStatementHandler │ ▼ 【PageHelper 拦截这里】→ 改写 SQL 文本 │ ▼ JDBC: PreparedStatement.execute() → 数据库执行物理分页 SQL │ ▼ 数据库只返回10条数据 → 网络传输大幅减少 │ ▼ ResultSetHandler → 映射成10个对象 │ ▼ 返回 List(实际是 Page 对象,继承 ArrayList)第七步:对比总结
| 特性 | RowBounds(原生逻辑分页) | PageHelper(插件物理分页) |
|---|---|---|
| SQL 是否被修改 | 否 | 是 |
| 数据库返回数据量 | 全部 | 仅当前页 |
| 内存占用 | 大(加载全部结果) | 小(只加载当前页) |
| 网络传输 | 全部数据 | 当前页数据 |
| 是否支持 count | 不支持 | 自动支持 |
| 跨数据库 | 完全兼容 | 需配置方言 |
| 适用场景 | 数据量极小(<1000条) | 生产环境大数据量 |
总结:逻辑链条
因为数据量增大后,全量查询会导致数据库IO、网络带宽、应用内存全面崩溃;
所以必须做分页,只查当前页的数据;
因为不同数据库的分页 SQL 语法完全不同(MySQL 用
LIMIT,Oracle 用ROWNUM),MyBatis 作为通用框架无法在底层硬编码某一种方言;所以MyBatis 只提供了
RowBounds做逻辑分页(内存筛选),保证跨数据库兼容,但无法解决大数据量的性能问题;因为生产环境必须使用物理分页(修改 SQL,让数据库只返回少量数据),而 MyBatis 提供了拦截器机制允许第三方扩展;
所以分页插件(如 PageHelper)通过拦截
Executor或StatementHandler,在执行前动态改写 SQL,添加对应数据库的分页语法;因为分页参数需要跨方法传递(从
startPage传到拦截器),但又不能污染方法签名;所以PageHelper 使用
ThreadLocal隐式传递分页参数,这也导致了"必须紧跟查询"和"必须清理 ThreadLocal"的约束;因为前端需要知道总页数才能渲染分页组件;
所以PageHelper 会自动执行一次
COUNT查询,再执行分页查询,两次 SQL 共同封装成PageInfo。
一句话:MyBatis 原生只负责"统一接口",物理分页这种"方言相关且重逻辑"的能力,通过插件机制交给你按需选择。
二、MyBatis 提供了插件机制
MyBatis 允许开发者编写插件,对内部组件进行拦截。
通过:
@Intercepts @Signature指定:
- 拦截哪个对象
- 拦截哪个方法
MyBatis 中可以被拦截的对象主要有:
- Executor
- StatementHandler
- ParameterHandler
- ResultSetHandler
分页插件一般选择拦截:
StatementHandler原因:
StatementHandler 负责处理 SQL。