一句话总结
MyBatis 分页三种方式:RowBounds 逻辑分页(查出全部数据,内存截取,性能差)、PageHelper 物理分页(MyBatis 插件拦截 Executor.query(),自动追加 LIMIT,推荐)、MyBatis-Plus 分页插件(PaginationInnerInterceptor,与 MP 深度集成)。核心原理都是 拦截器 + SQL 改写。深分页优化:游标分页(WHERE id > lastId LIMIT N)替代 OFFSET,避免大 OFFSET 性能问题。
初级理解
三种分页方式对比
| 方式 | 原理 | 性能 | 推荐度 |
|---|---|---|---|
| RowBounds | 查出全部,内存截取 | 差(全量查询) | 不推荐 |
| PageHelper | 拦截器改写 SQL,追加 LIMIT | 好(物理分页) | 推荐 |
| MyBatis-Plus | 内置 PaginationInnerInterceptor | 好(物理分页) | 推荐(MP项目) |
RowBounds 逻辑分页
# RowBounds 使用方式
RowBounds rowBounds = new RowBounds(0, 10); // offset=0, limit=10
List<User> users = session.selectList(
"com.example.mapper.UserMapper.selectAll",
null, rowBounds);
# RowBounds 原理:
# 1. 执行 SELECT * FROM user(查全部数据)
# 2. ResultSetHandler 处理结果时
# 3. 根据 RowBounds.offset 和 limit 截取 List
# 4. 返回截取后的子列表
# 致命问题:
# 1. 查出全部数据到内存,数据量大时 OOM
# 2. 网络传输全部数据,浪费带宽
# 3. 数据库压力大(全表扫描)
# 结论:生产环境绝对不要用 RowBounds!
中级深入
PageHelper 原理
# PageHelper 核心流程:
# 1. PageHelper.startPage(1, 10)
# → 创建 Page 对象,设置 pageNum=1, pageSize=10
# → 存入 ThreadLocal(PageHelper.localPage)
# 2. 执行 userMapper.selectAll()
# → Executor.query() 被 PageInterceptor 拦截
# → 从 ThreadLocal 获取 Page 对象
# → 判断是否需要分页
# 3. 查询总记录数
# → 解析原 SQL:SELECT * FROM user
# → 生成 COUNT SQL:SELECT COUNT(*) FROM user
# → 执行 COUNT 查询,设置 page.setTotal(count)
# 4. 执行分页查询
# → 获取数据库方言(MySQL/Oracle/PostgreSQL)
# → MySQL:原 SQL + LIMIT offset, pageSize
# → Oracle:嵌套子查询 + ROWNUM
# → 执行分页 SQL,返回结果
# 5. 清除 ThreadLocal(finally 块)
# → PageHelper.clearPage()
# 使用示例:
PageHelper.startPage(1, 10);
List<User> users = userMapper.selectAll();
PageInfo<User> pageInfo = new PageInfo<>(users);
// pageInfo.getTotal() 总记录数
// pageInfo.getPages() 总页数
// pageInfo.getList() 当前页数据
PageHelper 注意事项
# 1. startPage 只对紧随其后的第一个查询生效
PageHelper.startPage(1, 10);
List<User> users = userMapper.selectAll(); // 分页生效
List<Order> orders = orderMapper.selectAll(); // 分页不生效!
# 2. 需要分页的查询和 COUNT 查询之间不要有其他查询
# 错误示例:
PageHelper.startPage(1, 10);
userMapper.selectAll(); // 分页查询
userMapper.count(); // 会清空 ThreadLocal,导致分页失效
# 3. 手动分页(不依赖 startPage)
PageHelper.offsetPage(0, 10); // offset=0, limit=10
# 4. 安全分页(限制最大 pageSize)
PageHelper.startPage(1, 10, true); // 第三个参数=true 开启安全检查
# 或配置:
pagehelper:
reasonable: true # 页码<=0 查第一页,页码>总页数查最后一页
高级进阶
深分页性能问题
# 问题:OFFSET 越大,MySQL 需要扫描的行越多
SELECT * FROM user ORDER BY id LIMIT 1000000, 10;
# MySQL 需要扫描 1000010 行,丢弃前 1000000 行
# 执行时间随 OFFSET 线性增长
# 解决方案1:游标分页(推荐)
# 记录上一页最后一条的 ID,下一页从该 ID 之后开始
SELECT * FROM user WHERE id > #{lastId}
ORDER BY id LIMIT 10;
# 利用主键索引,性能恒定
# 实现:
public PageResult<User> selectByCursor(Long lastId, int pageSize) {
List<User> list = mapper.selectByCursor(lastId, pageSize + 1);
boolean hasMore = list.size() > pageSize;
if (hasMore) list.remove(list.size() - 1);
Long nextLastId = list.isEmpty() ? null :
list.get(list.size() - 1).getId();
return new PageResult<>(list, hasMore, nextLastId);
}
# 游标分页的局限:
# 1. 不支持跳页(只能上一页/下一页)
# 2. 排序字段必须是唯一且有序的
# 3. 适合 App 端无限滚动场景
# 解决方案2:子查询优化
# 先查 ID,再关联查详情
SELECT * FROM user WHERE id >= (
SELECT id FROM user ORDER BY id LIMIT 1000000, 1
) ORDER BY id LIMIT 10;
# 解决方案3:ES 搜索替代
# 对于复杂搜索+分页,用 Elasticsearch
# ES 的 from+size 或 search_after 分页性能远优于 MySQL
MyBatis-Plus 分页插件
# 配置
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor pagination =
new PaginationInnerInterceptor(DbType.MYSQL);
pagination.setMaxLimit(500L); // 单页最大500条
pagination.setOverflow(true); // 溢出处理
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
# 使用
Page<User> page = new Page<>(1, 10);
// 方式1:selectPage
Page<User> result = userMapper.selectPage(page, wrapper);
// 方式2:自定义 SQL + Page 参数
Page<User> result = userMapper.selectByCondition(page, condition);
# 自定义 SQL 分页(Mapper.xml)
<select id="selectByCondition" resultType="User">
SELECT * FROM user
<where>
<if test="condition.name != null">
AND name = #{condition.name}
</if>
</where>
ORDER BY id DESC
</select>
# 注意:不需要手动写 LIMIT,插件自动追加
实战场景
# 场景1:通用分页查询封装
@Data
public class PageQuery {
private Integer pageNum = 1;
private Integer pageSize = 10;
private String orderBy;
private String orderDirection = "DESC";
}
public PageInfo<User> pageQuery(PageQuery query) {
PageHelper.startPage(query.getPageNum(), query.getPageSize());
if (StringUtils.hasText(query.getOrderBy())) {
PageHelper.orderBy(query.getOrderBy() + " "
+ query.getOrderDirection());
}
List<User> users = userMapper.selectByCondition(condition);
return new PageInfo<>(users);
}
# 场景2:大数据量导出(流式查询)
# 不用分页,用游标逐批读取
int pageSize = 1000;
Long lastId = 0L;
while (true) {
List<User> batch = mapper.selectByCursor(lastId, pageSize);
if (batch.isEmpty()) break;
// 处理这批数据
processBatch(batch);
lastId = batch.get(batch.size() - 1).getId();
}
# 场景3:多表关联分页
# 先分页查主表 ID,再关联查详情
PageHelper.startPage(1, 10);
List<Long> ids = userMapper.selectIdsByCondition(condition);
// SELECT id FROM user WHERE ... ORDER BY id LIMIT 0,10
List<User> users = userMapper.selectByIds(ids);
// SELECT * FROM user WHERE id IN (1,2,3,...)
面试模拟
面试官:MyBatis 有哪些分页方式?PageHelper 原理是什么?
你:三种:RowBounds 逻辑分页(查出全部内存截取,不推荐)、PageHelper 物理分页(推荐)、MyBatis-Plus 分页插件。PageHelper 原理:通过 MyBatis 插件拦截 Executor.query(),从 ThreadLocal 获取分页参数,先执行 COUNT 查询总记录数,再根据数据库方言改写 SQL 追加 LIMIT 子句,最后清除 ThreadLocal。
面试官:深分页(OFFSET 很大)怎么优化?
你:用游标分页替代 OFFSET:WHERE id > lastId ORDER BY id LIMIT N,利用主键索引,性能恒定。缺点是只能上一页/下一页,不支持跳页。也可以用子查询先定位 ID 再关联查详情,或者用 ES 的 search_after 替代深分页。