Mybatis17- 分页原理
2026/7/24 15:45:02 网站建设 项目流程

一、分页原理

第一步:没有分页时,系统会怎么死?

假设你有一张用户表,初期只有100条数据:

List<User> list = session.selectList("selectAll");

这没问题,100条数据内存轻松装下。

但业务增长了,表里有100万条数据。此时selectAll会发生什么?

  1. 数据库端:全表扫描,磁盘IO爆炸,数据库连接长时间被占用

  2. 网络层:100万条数据在网络上传输,带宽被打满

  3. 应用内存:JVM堆内存被撑爆,直接OutOfMemoryError

  4. 用户体验:页面卡死,请求超时

所以分页不是"优化项",而是大数据场景下的"生存项"。


第二步: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, 10

Oracle:

SELECT * FROM ( SELECT t.*, ROWNUM rn FROM user t WHERE ROWNUM <= 10 ) WHERE rn > 0

SQL 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 到达数据库之前,把它改成了带LIMITROWNUM的物理分页 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 > 0

5.执行完后清理 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条)生产环境大数据量

总结:逻辑链条

  1. 因为数据量增大后,全量查询会导致数据库IO、网络带宽、应用内存全面崩溃;

  2. 所以必须做分页,只查当前页的数据;

  3. 因为不同数据库的分页 SQL 语法完全不同(MySQL 用LIMIT,Oracle 用ROWNUM),MyBatis 作为通用框架无法在底层硬编码某一种方言;

  4. 所以MyBatis 只提供了RowBounds做逻辑分页(内存筛选),保证跨数据库兼容,但无法解决大数据量的性能问题;

  5. 因为生产环境必须使用物理分页(修改 SQL,让数据库只返回少量数据),而 MyBatis 提供了拦截器机制允许第三方扩展;

  6. 所以分页插件(如 PageHelper)通过拦截ExecutorStatementHandler,在执行前动态改写 SQL,添加对应数据库的分页语法;

  7. 因为分页参数需要跨方法传递(从startPage传到拦截器),但又不能污染方法签名;

  8. 所以PageHelper 使用ThreadLocal隐式传递分页参数,这也导致了"必须紧跟查询"和"必须清理 ThreadLocal"的约束;

  9. 因为前端需要知道总页数才能渲染分页组件;

  10. 所以PageHelper 会自动执行一次COUNT查询,再执行分页查询,两次 SQL 共同封装成PageInfo

一句话:MyBatis 原生只负责"统一接口",物理分页这种"方言相关且重逻辑"的能力,通过插件机制交给你按需选择。


二、MyBatis 提供了插件机制

MyBatis 允许开发者编写插件,对内部组件进行拦截。

通过:

@Intercepts @Signature

指定:

  • 拦截哪个对象
  • 拦截哪个方法

MyBatis 中可以被拦截的对象主要有:

  • Executor
  • StatementHandler
  • ParameterHandler
  • ResultSetHandler

分页插件一般选择拦截:

StatementHandler

原因:

StatementHandler 负责处理 SQL。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询