我们在需要分页查询的时候,往往会使用mp或者mybatis的pageHepler来做分页查询
但是其底层还是几个关键字:offset和pagesize
sql
select * from user limit #{offset}, #{pageSize}
其中mp和mybatis为我们做了层封装,我们只需要输入页数,每页查询数和总数据量即可
其中分页底层分为几种模式:
底层逻辑
1、逻辑分页------RowBounds(了解即可)
MyBatis 内置了RowBounds支持逻辑分页:
java
// offset:起始位置(从 0 开始),limit:每页条数
// 查第 3 页,每页 10 条 → offset = 20, limit = 10
List<User> users = sqlSession.selectList(
"com.example.mapper.UserMapper.selectAll",
null,
newRowBounds(20,10) // 跳过前 20 条,取 10 条
);
底层原理 :MyBatis 执行完整的 SQL 查询,将所有结果加载到内存后,通过DefaultResultHandler跳过offset条记录,只取limit条。
致命缺陷 :100 万条数据只看 10 条,却要把 100 万条全加载到内存。生产环境绝对不能用。
2、物理分页------手写 SQL
最原始的方式,直接在 SQL 中手写LIMIT:
XML
<selectid="selectByPage"resultType="User">
SELECT * FROM t_user
ORDER BY id DESC
LIMIT #{offset}, #{pageSize}
</select>
java
// 调用
intpageNum =3; // 第 3 页
intpageSize =10; // 每页 10 条
intoffset = (pageNum -1) * pageSize; // 偏移量 = 20
List<User> users = userMapper.selectByPage(offset, pageSize);
缺点 :每次分页都要手算offset,还要单独写COUNT(*)查询获取总数,代码重复且容易出错。
3、物理分页------PageHelper 插件(重点)
PageHelper 是国内最流行的 MyBatis 分页插件,使用非常简单:
java
// 使用 PageHelper 分页
PageHelper.startPage(3,10); // 第 3 页,每页 10 条
List<User> users = userMapper.selectAll(); // 正常查询,自动分页
PageInfo<User> pageInfo =newPageInfo<>(users);
System.out.println("总记录数:"+ pageInfo.getTotal()); // 100
System.out.println("总页数:"+ pageInfo.getPages()); // 10
System.out.println("当前页数据:"+ pageInfo.getList().size());// 10
System.out.println("是否有下一页:"+ pageInfo.isHasNextPage());// true
核心 API:
注意 :PageHelper.startPage()只对紧跟其后的第一条 查询生效。这是通过ThreadLocal实现的------调用startPage()后将分页参数存入ThreadLocal,下一次查询消费后自动清除。
4、PageHelper 的底层原理
PageHelper 的核心原理是MyBatis 插件(Interceptor)机制 +SQL 改写:
PageHelper 的完整执行流程可以拆解为以下关键步骤:
-
步骤一(设置分页参数) :调用
PageHelper.startPage(3, 10),将分页参数封装为Page对象,存入当前线程的ThreadLocal。这一步和查询方法是分开调用的,通过ThreadLocal在两者之间传递分页信息。 -
步骤二(拦截查询) :
PageInterceptor实现了 MyBatis 的Interceptor接口,通过@Signature注解拦截了Executor的query方法。当查询执行时,插件介入。 -
步骤三(SQL 改写) :这是核心。插件从
ThreadLocal取出分页参数,使用 SQL 解析器(JSqlParser)解析原始 SQL 的 AST(抽象语法树),根据数据库方言自动拼接LIMIT(MySQL)、ROWNUM(Oracle)、TOP(SQL Server)等分页子句。 -
步骤四(COUNT 查询) :如果需要总数(默认会查),插件会将原始 SQL 改写为
SELECT COUNT(*) FROM ...的形式,去掉ORDER BY、LIMIT、LEFT JOIN等不影响总数的部分,单独执行一次获取总记录数。 -
步骤五(清理 ThreadLocal) :查询完成后,自动清除
ThreadLocal中的分页参数,确保不影响后续查询。
问题优化
使用分页查询时,特别时数据量比较大的时候,会遇到深分页问题
limit 100000,10 这种深分页 ,数据库会先扫描 100010 条数据,丢掉前 10 万条,只返回 10 条,越往后翻页越慢,甚至卡死。
1、深分页为什么慢?
sql
select * from user limit 100000,10;
执行过程:
- 从第 1 条查到第 100010 条
- 扔掉前 100000 条
- 返回最后 10 条
回表 + 大量无效扫描 = 巨慢
2、4 种最优优化方案(生产必用)
1. 延迟关联(最推荐、最简单、90% 场景用它)
原理:先只查主键 ID,再用 ID 去关联查数据。
慢 SQL:
sql
SELECT * FROM user LIMIT 100000,10;
优化后:
sql
SELECT u.* FROM user u
JOIN (SELECT id FROM user ORDER BY id LIMIT 100000,10) AS temp
ON u.id = temp.id;
提升: 快 5~10 倍以上
2. 主键游标翻页(性能最强,禁止跳页)
适合:APP 列表下滑加载、无需跳页的场景(性能天花板)
原理:记住上一页最后一条 ID,下一页从 ID 开始查。
sql
-- 第1页
SELECT * FROM user ORDER BY id LIMIT 10;
-- 第2页(记住上一页最后id=10)
SELECT * FROM user WHERE id > 10 ORDER BY id LIMIT 10;
-- 深分页(比如最后id=100000)
SELECT * FROM user WHERE id > 100000 ORDER BY id LIMIT 10;
性能: 无论多少页,速度完全一样,极速!
缺点:不能跳页(不能点 "第 1000 页")
3. 禁止深分页(产品层优化)
对于大厂来说:
- 最多只允许查前 100/200/500 页
- 超过直接提示:"请缩小搜索范围"
- 深分页本身无业务意义
这是成本最低、效果最好的方案。
4. ES 搜索引擎(海量数据必用)
如果数据量 百万~千万以上 MySQL 深分页怎么优化都吃力,直接用 Elasticsearch
- 天然适合搜索
- 深分页极快
- 支持复杂条件
3、必须加的关键优化(必做)
1. 排序字段必须加索引
ORDER BY id -> id 必须是主键/索引
ORDER BY create_time -> create_time 必须加索引
没索引 = 深分页一定卡死
2. 绝对不要用 SELECT *
只查需要的字段,减少 IO。
4、方案选择总结
| 场景 | 最优方案 |
|---|---|
| 后台管理、需要跳页 | 延迟关联(JOIN 子查询) |
| APP 列表、下滑加载 | 主键游标(WHERE id > ?) |
| 超大数据量 | 改用 ES |
| 简单快速 | 限制最大页数 |
1. 延迟关联(支持跳页)
XML
<select id="findByPage" resultType="User">
SELECT u.* FROM user u
JOIN (
SELECT id FROM user
ORDER BY id
LIMIT #{offset}, #{pageSize}
) temp ON u.id = temp.id
</select>
2. 游标分页(性能最高)
XML
<select id="findNextPage" resultType="User">
SELECT * FROM user
WHERE id > #{lastId}
ORDER BY id
LIMIT #{pageSize}
</select>
总结
- 深分页不要直接 limit big,10
- 优先用 延迟关联 或 游标分页
- 排序字段必须加索引
- 数据量超大 → 上 ES