← 返回题目列表

MyBatis 怎么分页?RowBounds 和 PageHelper 有什么区别?

高频 中等 第 10 / 24 题 更新于 2026/07/26
分页RowBoundsPageHelper物理分页

简化版

MyBatis 分页有两种:RowBounds 逻辑分页——MyBatis 内置,把全部数据查出来再在内存里截取指定区间(offsetlimit),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 优化」。