MyBatis 怎么分页?RowBounds 和 PageHelper 有什么区别?
简化版
MyBatis 分页有两种:① RowBounds 逻辑分页——MyBatis 内置,把全部数据查出来再在内存里截取指定区间(offset、limit),SQL 不带 limit。数据量大时会把整表数据加载进内存,有 OOM 风险,生产基本不用。② 物理分页(插件方式,如 PageHelper)——通过 MyBatis 插件拦截 SQL,动态给它拼上数据库的 LIMIT(MySQL)或 ROWNUM(Oracle),只查当前页的数据,性能好,是生产标准做法。核心区别:RowBounds 查全部在内存分页(伪分页)、PageHelper 改 SQL 只查一页(真分页)。
详细版
RowBounds 逻辑分页(内存分页):
// RowBounds(offset, limit) —— MyBatis 内置
RowBounds rowBounds = new RowBounds(0, 10); // 从第 0 条开始取 10 条
List<User> users = sqlSession.selectList("selectUsers", null, rowBounds);
// 底层:SELECT * FROM user(不带 limit!全查出来)
// → 把全部结果读进内存 → 在内存里跳过前 offset 条、取 limit 条
PageHelper 物理分页(改 SQL):
PageHelper.startPage(1, 10); // 第 1 页,每页 10 条(利用 ThreadLocal 存分页参数)
List<User> users = userMapper.selectAll(); // 你的 Mapper 方法不变
// PageHelper 插件拦截,改写 SQL:SELECT * FROM user LIMIT 0, 10(只查 10 条)
PageInfo<User> pageInfo = new PageInfo<>(users); // 拿到总数、总页数等分页信息
核心对比:
| 维度 | RowBounds(逻辑分页) | PageHelper(物理分页) |
|---|---|---|
| 分页位置 | 内存(查全部再截取) | 数据库(SQL 带 LIMIT) |
| 查询数据量 | 全表数据 | 只查一页 |
| 大数据量 | OOM 风险(全加载进内存) | 安全(只查一页) |
| 性能 | 差(数据多时) | 好 |
| SQL | 不带 limit | 动态拼 limit/rownum |
| 是否需要额外依赖 | 否(MyBatis 内置) | 是(PageHelper 插件) |
| 生产使用 | 基本不用 | 标准做法 |
⚠️
PageHelper.startPage()用 ThreadLocal 存分页参数,所以必须紧跟着就执行查询——startPage和查询之间不能有其他数据库操作,否则分页参数可能作用到错误的查询上,或因线程复用导致「分页参数泄漏」到下一次查询。规范写法:startPage下一行就是要分页的 Mapper 调用。
完整版教学
一、分页的本质:只要一部分数据
分页是「一次只展示一部分数据」——列表页每页 10 条、20 条。核心诉求是只把当前页需要的数据取出来,而不是把几百万行全查出来。这就引出分页的关键区分:在哪里「只取一部分」:
在数据库里只取一部分(物理分页/真分页):
SQL 带 LIMIT → 数据库只返回一页数据 → 传输少、内存少 → 好
在内存里只取一部分(逻辑分页/伪分页):
SQL 查全部 → 全部数据传到应用内存 → 再在内存里挑出一页 → 浪费、危险
理解「分页的关键是在数据库层就只查一页」,就理解了 RowBounds 和 PageHelper 的根本差异——RowBounds 是在内存里分(把全部数据先搬进来),PageHelper 是在数据库里分(只搬一页)。前者是「伪分页」,后者才是真正意义上的分页。
二、RowBounds:MyBatis 内置的内存分页
RowBounds 是 MyBatis 自带的分页方式,用法简单,但机制是「内存分页」:
RowBounds(offset=100, limit=10):
MyBatis 执行的 SQL:SELECT * FROM user ← 注意:没有 LIMIT!
→ 数据库把全部数据(假设 100 万行)返回
→ MyBatis 把结果集读取时,跳过前 100 行、取 10 行、丢弃剩下的
问题非常明显:它把全表数据都查出来了,只是在读取结果时「跳过前面、取中间一段」。如果表有 100 万行,这 100 万行都会被查询、传输、(部分)加载进内存——你只想要第 10 页的 10 条,却付出了查 100 万行的代价。
数据量小:勉强能用(几百行无所谓)
数据量大:灾难——
① 全表扫描,SQL 慢
② 大量数据传输,网络开销大
③ 结果集加载进内存,可能 OOM
所以 RowBounds 在生产环境基本不用——它只适合「数据量确定很小」的场景。它存在的意义更多是「MyBatis 提供了一个内置分页 API」,但实现是伪分页。
三、物理分页:在数据库层只查一页
物理分页(真分页)的思路是改写 SQL,让数据库只返回一页:
物理分页要做的:
原 SQL:SELECT * FROM user
改成: SELECT * FROM user LIMIT 90, 10 (MySQL,取第 91~100 条)
或: Oracle 用 ROWNUM、SQL Server 用 OFFSET FETCH
效果:数据库只扫描/返回 10 条 → 传输少、内存少、快
但手写物理分页很麻烦——每个查询都要手动在 SQL 里拼 LIMIT,还要处理不同数据库的分页语法差异(MySQL 的 LIMIT、Oracle 的 ROWNUM、SQL Server 的语法都不同),还要额外写一个「查总数」的 SQL。如果每个分页查询都手写这些,重复且易错。这就是分页插件(PageHelper)要解决的——自动改写 SQL 实现物理分页,屏蔽数据库差异。
四、PageHelper 的原理:插件拦截改 SQL
PageHelper 基于 MyBatis 的插件(拦截器)机制——它拦截 SQL 执行,动态给原 SQL 拼上分页语句:
PageHelper.startPage(pageNum=2, pageSize=10):
1. 把分页参数(第2页、每页10条)存进 ThreadLocal
2. 你调用 Mapper 方法(userMapper.selectAll()),SQL 是 SELECT * FROM user
3. PageHelper 插件拦截到这次执行:
- 从 ThreadLocal 取出分页参数
- 先执行 COUNT 查询:SELECT COUNT(*) FROM user(拿总数)
- 改写原 SQL:SELECT * FROM user LIMIT 10, 10(拼上 limit,只查第2页)
- 根据数据库类型(Dialect)选对应的分页语法
4. 返回当前页数据,并把总数等信息记录下来
好处:① 你的 Mapper SQL 完全不用改(还是 SELECT * FROM user,PageHelper 帮你拼 limit);② 自动适配不同数据库(内置各种 Dialect);③ 自动查总数(PageInfo 里有总数、总页数);④ 只查一页数据(真物理分页,性能好)。所以它是「非侵入、自动、跨数据库」的物理分页方案,成了 MyBatis 分页的事实标准。
五、ThreadLocal 的坑与正确用法
PageHelper 用 ThreadLocal 传递分页参数,这带来一个必须注意的坑:
// ✓ 正确:startPage 紧跟查询
PageHelper.startPage(1, 10);
List<User> users = userMapper.selectAll(); // 立即执行分页查询
// ✗ 危险:startPage 后没有立即查询,或中间有别的操作
PageHelper.startPage(1, 10);
if (someCondition) {
return; // ✗ 分页参数存进了 ThreadLocal 但没消费!
// 线程被复用时,这个残留参数会作用到下一次查询 → 意外分页
}
List<User> users = userMapper.selectAll();
原理:startPage 把分页参数放进 ThreadLocal,下一次 MyBatis 查询会消费它并清除。如果 startPage 之后没有立即查询(提前 return、抛异常、中间插入其他逻辑),参数就残留在 ThreadLocal 里,线程池复用该线程时,下一个请求的查询会莫名其妙被分页。所以铁律:startPage 的下一行必须是要分页的那个查询,中间不能有别的数据库操作或可能跳过查询的分支。PageHelper 有一定的自动清理机制,但依赖「紧跟查询」的规范能彻底避免这个坑。
六、分页方案的选型与深分页问题
分页方案选型很清晰:
| 场景 | 方案 |
|---|---|
| 数据量小、临时用 | RowBounds(内置,但仅限小数据) |
| 生产环境、通用分页 | PageHelper / MyBatis-Plus 分页插件 |
| 超大数据量深分页 | 游标/keyset 分页(基于上次最大 id) |
还有一个高频延伸——深分页问题:即使用了物理分页,LIMIT 1000000, 10(翻到很后面的页)也很慢,因为 MySQL 要先扫描并跳过前 100 万行才能取到那 10 行:
LIMIT 1000000, 10:
MySQL 要读 1000010 行、丢弃前 1000000 行、返回 10 行 → 越往后越慢
优化(keyset 分页 / 游标分页):
记住上一页最后一条的 id,用 WHERE id > 上次最大id LIMIT 10
→ 利用主键索引直接定位,不用扫描跳过 → 深分页也快
所以「大数据量、深翻页」场景,即便物理分页也要用 keyset 分页(基于游标/上次最大 id) 优化,避免 LIMIT offset 的深分页性能陷阱。这是分页的进阶考点。
记忆钩子:「RowBounds 逻辑分页=查全部再内存截取(伪分页、大数据 OOM、生产不用);PageHelper 物理分页=插件拦截改 SQL 拼 LIMIT 只查一页(真分页、自动查总数、跨数据库、生产标准),但 ThreadLocal 存参数所以 startPage 必须紧跟查询;深分页用 keyset(WHERE id>上次最大id)优化」。
七、常见误区与追问
- 误区:RowBounds 是真正的分页。 它是逻辑分页(伪分页)——SQL 不带 limit、查全部数据再在内存截取,大数据量会 OOM,生产基本不用。
- 误区:PageHelper 要改 Mapper 的 SQL。 不用,你的 Mapper SQL 不变,PageHelper 插件拦截后自动拼 limit 并查总数,非侵入。
- 误区:startPage 之后可以隔几行再查询。 危险——分页参数存在 ThreadLocal,必须紧跟查询消费掉;否则提前 return/异常会导致参数残留,线程复用时污染下次查询。
- 误区:用了物理分页深翻页就不慢了。 LIMIT 1000000,10 仍慢(要扫描跳过百万行),深分页要用 keyset 分页(WHERE id>上次最大id)基于索引定位优化。
- 追问:PageHelper 底层用什么机制实现? MyBatis 插件(拦截器)——拦截 Executor/StatementHandler,根据 ThreadLocal 里的分页参数,先执行 COUNT 查询、再按数据库方言改写原 SQL 拼上 limit。
- 追问:为什么 RowBounds 大数据量会 OOM? 它的 SQL 不带 limit,数据库返回全表数据,MyBatis 读取结果集时才在内存跳过/截取,全部数据都被加载进内存,数据量大就 OOM。
- 追问:深分页 LIMIT 1000000,10 为什么慢,怎么优化? MySQL 要扫描并丢弃前 100 万行才取到 10 行;用 keyset 分页——记住上页最大 id,WHERE id>该id LIMIT 10,利用主键索引直接定位,避免扫描跳过。
八、加强记忆
MyBatis 分页的核心区分是「在哪里只取一页」。RowBounds 逻辑分页:SQL 不带 limit、查出全表数据,再在内存里跳过 offset、取 limit——这是伪分页,大数据量会把整表加载进内存导致 OOM,生产基本不用。物理分页(PageHelper 插件):基于 MyBatis 插件拦截 SQL,从 ThreadLocal 取分页参数,先执行 COUNT 查总数、再按数据库方言(Dialect)改写原 SQL 拼上 LIMIT,只查一页——真分页,非侵入(Mapper SQL 不用改)、自动查总数、跨数据库,是生产标准。用 PageHelper 有个铁律:startPage 必须紧跟查询(参数存 ThreadLocal,不立即消费会残留、污染线程复用后的下次查询)。进阶考点是深分页:即使物理分页,LIMIT 1000000,10 仍要扫描跳过百万行而慢,要用 keyset 分页(WHERE id>上次最大id LIMIT 10)基于主键索引直接定位优化。一句话「RowBounds 查全部内存截取是伪分页会 OOM、PageHelper 改 SQL 只查一页是真分页但 startPage 要紧跟查询、深分页用 keyset 优化」。