写在前面 :说实话,这个坑我踩过。去年做电商后台订单查询,运营同事点了个"跳到第10000页",整个页面卡了8秒直接超时。我当时还纳闷------数据量也不大啊,才几百万条。后来一查SQL,一个
LIMIT 1000000, 10差点把DBA吓出心脏病。今天就把我踩过的坑、试过的方案,掰开了揉碎了讲给你听。

文章目录
-
- 一、深度分页有多慢?
-
- [1.1 一个真实的惨案](#1.1 一个真实的惨案)
- [1.2 EXPLAIN 一眼看出问题](#1.2 EXPLAIN 一眼看出问题)
- [二、传统 LIMIT 分页的问题](#二、传统 LIMIT 分页的问题)
-
- [2.1 LIMIT 底层到底在干啥?](#2.1 LIMIT 底层到底在干啥?)
- [2.2 性能实测对比](#2.2 性能实测对比)
- [三、方案 1:游标分页(我最推荐)](#三、方案 1:游标分页(我最推荐))
-
- [3.1 核心思路](#3.1 核心思路)
- [3.2 优点与缺点](#3.2 优点与缺点)
- [3.3 完整 Java 代码实现](#3.3 完整 Java 代码实现)
- [3.4 问题与解答](#3.4 问题与解答)
- [四、方案 2:延迟关联(覆盖索引回表)](#四、方案 2:延迟关联(覆盖索引回表))
-
- [4.1 核心思路](#4.1 核心思路)
- [4.2 为什么能减少回表?](#4.2 为什么能减少回表?)
- [4.3 EXPLAIN 对比](#4.3 EXPLAIN 对比)
- [4.4 Java 代码实现](#4.4 Java 代码实现)
- [五、方案 3:子查询优化](#五、方案 3:子查询优化)
-
- [5.1 写法](#5.1 写法)
- [5.2 与延迟关联的对比](#5.2 与延迟关联的对比)
- 六、其他优化策略
-
- [6.1 产品层限制最大页码](#6.1 产品层限制最大页码)
- [6.2 搜索引擎替代(Elasticsearch)](#6.2 搜索引擎替代(Elasticsearch))
- [6.3 缓存热门页数据](#6.3 缓存热门页数据)
- [6.4 三种方案终极对比](#6.4 三种方案终极对比)
- 七、踩坑指南
- 八、问题与解答
- 九、面试高频考点汇总
-
- [考点 1:为什么 `LIMIT 1000000, 10` 很慢?](#考点 1:为什么
LIMIT 1000000, 10很慢?) - [考点 2:游标分页的原理是什么?有什么限制?](#考点 2:游标分页的原理是什么?有什么限制?)
- [考点 3:延迟关联优化的核心思想是什么?](#考点 3:延迟关联优化的核心思想是什么?)
- [考点 4:MySQL 的 ICP(索引下推)和深度分页有关系吗?](#考点 4:MySQL 的 ICP(索引下推)和深度分页有关系吗?)
- [考点 5:ES 的 `search_after` 和游标分页有什么区别?](#考点 5:ES 的
search_after和游标分页有什么区别?)
- [考点 1:为什么 `LIMIT 1000000, 10` 很慢?](#考点 1:为什么
- 十、模拟面试官提问与参考答案
-
- [场景题 1](#场景题 1)
- [场景题 2](#场景题 2)
- [场景题 3](#场景题 3)
- [场景题 4](#场景题 4)
- [场景题 5](#场景题 5)
- 十一、互动话题
- 十二、参考资料
一、深度分页有多慢?
1.1 一个真实的惨案
运营后台要导出订单,同事顺手点了最后一页。SQL 长这样:
sql
SELECT * FROM `order`
WHERE status = 1
ORDER BY create_time DESC
LIMIT 1000000, 10;
用户等了整整 8 秒,页面直接 504 超时。我去慢查询日志一查,MySQL 扫描了 1000010 行,最后只返回 10 行。这感觉就像------
快递员让你去仓库找第 10000 到 10010 个包裹。他非得让你从 1 号开始数,数到 10000 才能拿。这谁顶得住?
1.2 EXPLAIN 一眼看出问题
sql
EXPLAIN SELECT * FROM `order` ORDER BY create_time DESC LIMIT 1000000, 10;
| 字段 | 值 | 含义 |
|---|---|---|
| type | ALL | 全表扫描 |
| rows | 1000010 | 扫描了 100 多万行 |
| Extra | Using filesort | 还要额外排序 |
三件套凑齐了,慢是必然。
二、传统 LIMIT 分页的问题
2.1 LIMIT 底层到底在干啥?
sql
SELECT * FROM table LIMIT offset, size;
MySQL 的执行逻辑是:
- 从第一行开始扫描
- 数够
offset行,全部扔掉 - 再往后读
size行,返回给你
也就是说,LIMIT 1000000, 10 要扫 1000010 行,扔掉前 1000000 行。越往后翻,扔掉越多,越慢。
2.2 性能实测对比
我见过太多人凭感觉说"应该还行",咱直接用数据说话:
| offset | 返回行数 | 耗时(MySQL 8.0,500万数据) | 扫描行数 |
|---|---|---|---|
| 0 | 10 | 0.8 ms | 10 |
| 1,000 | 10 | 5 ms | 1010 |
| 10,000 | 10 | 35 ms | 10010 |
| 100,000 | 10 | 320 ms | 100010 |
| 1,000,000 | 10 | 8.2 s | 1000010 |
offset 翻 1000 倍,耗时翻了 1000 倍。这不是线性增长,这是灾难性增长。
三、方案 1:游标分页(我最推荐)
3.1 核心思路
不玩 offset 了,直接记住上一页最后一条的位置:
sql
SELECT * FROM `order`
WHERE id > #{lastId}
ORDER BY id
LIMIT 10;
利用主键 id 的有序性,数据库直接定位到 lastId 的位置,往后读 10 行就完事。不管翻到哪一页,永远只扫 10 行。
3.2 优点与缺点
| 维度 | 说明 |
|---|---|
| 优点 | 性能稳定,O(1) 复杂度;数据库压力极小 |
| 缺点 | 不支持跳页(不能直接输入页码跳转);要求排序字段唯一且有序 |
| 适用场景 | 瀑布流、无限滚动、APP 列表、下一页/上一页 |
3.3 完整 Java 代码实现
java
/**
* 游标分页请求DTO
*/
@Data
public class CursorPageRequest {
/** 上一页最后一条记录的ID,第一次传null */
private Long lastId;
/** 每页大小 */
private Integer pageSize = 10;
/** 翻页方向:next 下一页,prev 上一页 */
private String direction = "next";
}
/**
* 游标分页响应DTO
*/
@Data
public class CursorPageResult<T> {
private List<T> records;
/** 下一页的游标,null表示没有下一页 */
private Long nextCursor;
/** 上一页的游标,null表示没有上一页 */
private Long prevCursor;
private Boolean hasNext;
private Boolean hasPrev;
private Integer pageSize;
}
@Service
public class OrderCursorService {
@Autowired
private OrderMapper orderMapper;
/**
* 游标分页查询(支持上一页/下一页)
*/
public CursorPageResult<Order> queryByCursor(CursorPageRequest request) {
Integer pageSize = request.getPageSize();
List<Order> list;
if ("next".equals(request.getDirection())) {
// 下一页:取大于lastId的数据
list = orderMapper.selectNextPage(request.getLastId(), pageSize + 1);
} else {
// 上一页:取小于lastId的数据,然后反转
list = orderMapper.selectPrevPage(request.getLastId(), pageSize + 1);
Collections.reverse(list);
}
// 判断是否有多一条(用于判断hasNext/hasPrev)
boolean hasMore = list.size() > pageSize;
if (hasMore) {
list = list.subList(0, pageSize);
}
CursorPageResult<Order> result = new CursorPageResult<>();
result.setRecords(list);
result.setPageSize(pageSize);
if (list.isEmpty()) {
result.setHasNext(false);
result.setHasPrev(false);
return result;
}
// 设置游标
result.setNextCursor(list.get(list.size() - 1).getId());
result.setPrevCursor(list.get(0).getId());
// hasNext:如果查出来比pageSize多一条,说明有下一页
result.setHasNext(hasMore && "next".equals(request.getDirection()));
// hasPrev:不是第一页就有上一页(简化处理,实际需要更严谨判断)
result.setHasPrev(request.getLastId() != null);
return result;
}
}
对应的 Mapper XML:
xml
<!-- 下一页:按id正序,取大于lastId的数据 -->
<select id="selectNextPage" resultType="Order">
SELECT * FROM `order`
WHERE id > #{lastId}
ORDER BY id ASC
LIMIT #{limit}
</select>
<!-- 上一页:按id倒序,取小于lastId的数据 -->
<select id="selectPrevPage" resultType="Order">
SELECT * FROM `order`
WHERE id < #{lastId}
ORDER BY id DESC
LIMIT #{limit}
</select>
踩坑提醒:如果业务需要按
create_time排序而不是id,那游标字段也得换成create_time。而且create_time必须保证唯一,不然会出现数据重复或漏掉的情况。
3.4 问题与解答
Q1:游标分页能不能支持跳转到第 N 页?
不能。这是游标分页的先天限制。如果你业务上一定要跳页,那就得换方案(延迟关联或限制最大页码)。我见过太多人硬要把游标分页改成支持跳页,最后搞得不伦不类,性能也丢了。
Q2:如果排序字段不是主键 id,而是 create_time,需要注意什么?
两个点:第一,create_time 必须加唯一索引,否则两条记录时间一样,翻页时可能重复或漏掉;第二,游标值要传 create_time 而不是 id,SQL 改成 WHERE create_time > #{lastTime}。
Q3:数据插入会导致游标分页漏数据或重复吗?
会。用户正在看第 2 页,这时候插入了一条新数据排到了前面,用户点"下一页"就可能看到重复数据。解决方案:游标值带上时间戳过滤,或者产品层容忍这种轻微不一致(大多数场景都能接受)。
四、方案 2:延迟关联(覆盖索引回表)
4.1 核心思路
先通过覆盖索引把主键 ID 查出来,再用 ID 关联查详情。这样子查询只扫索引,不用回表,速度快得多。
sql
SELECT * FROM `order` o
JOIN (
SELECT id FROM `order`
ORDER BY create_time DESC
LIMIT 1000000, 10
) t ON o.id = t.id;
4.2 为什么能减少回表?
- 子查询
SELECT id FROM order ... LIMIT:只查id字段,如果create_time上有索引,MySQL 直接走覆盖索引,不需要回表拿整行数据。 - 外层关联:只用 10 个 ID 去查完整记录,回表次数从 1000010 次降到 10 次。
4.3 EXPLAIN 对比
优化前:
sql
EXPLAIN SELECT * FROM `order` ORDER BY create_time DESC LIMIT 1000000, 10;
-- type: ALL, rows: 1000010, Extra: Using filesort
优化后:
sql
EXPLAIN SELECT * FROM `order` o
JOIN (SELECT id FROM `order` ORDER BY create_time DESC LIMIT 1000000, 10) t
ON o.id = t.id;
-- 子查询: type: index, Extra: Using index(覆盖索引)
-- 外层: type: eq_ref, rows: 10
4.4 Java 代码实现
java
@Service
public class OrderDelayJoinService {
@Autowired
private OrderMapper orderMapper;
/**
* 延迟关联分页查询
*/
public List<Order> queryByDelayJoin(int pageNum, int pageSize) {
int offset = (pageNum - 1) * pageSize;
// 第一步:子查询只拿ID(走覆盖索引)
List<Long> ids = orderMapper.selectIdsByPage(offset, pageSize);
if (ids.isEmpty()) {
return Collections.emptyList();
}
// 第二步:用ID批量查详情(只回表10次)
return orderMapper.selectByIds(ids);
}
}
xml
<!-- 子查询:只取ID,走覆盖索引 -->
<select id="selectIdsByPage" resultType="java.lang.Long">
SELECT id FROM `order`
ORDER BY create_time DESC
LIMIT #{offset}, #{limit}
</select>
<!-- 批量查详情 -->
<select id="selectByIds" resultType="Order">
SELECT * FROM `order`
WHERE id IN
<foreach collection="list" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
踩坑提醒:延迟关联要求子查询的排序字段必须有索引。如果
create_time没索引,子查询也会全表扫描,优化就白做了。这个坑我踩过,加索引前一定要先EXPLAIN确认。
五、方案 3:子查询优化
5.1 写法
sql
SELECT * FROM `order`
WHERE id IN (
SELECT id FROM `order`
ORDER BY create_time DESC
LIMIT 1000000, 10
);
5.2 与延迟关联的对比
| 维度 | 子查询 IN | 延迟关联 JOIN |
|---|---|---|
| MySQL 5.6 及以下 | 子查询性能较差,可能物化临时表 | JOIN 更可控 |
| MySQL 5.7+ | 优化器自动优化为 SEMI-JOIN,性能接近 | 差异不大 |
| 可读性 | 简单直观 | 稍复杂 |
| 大数据量 | 可能内存临时表溢出 | 更稳定 |
MySQL 5.7 之后优化器聪明了不少,子查询也能自动优化。但如果你还在用 5.6(我见过不少老项目),老老实实写 JOIN 更稳妥。
六、其他优化策略
6.1 产品层限制最大页码
这是成本最低的方案。直接告诉产品:"咱不支持翻到 10000 页,最多 100 页。"
java
public static final int MAX_PAGE_NUM = 100;
public void validatePage(int pageNum) {
if (pageNum > MAX_PAGE_NUM) {
throw new BizException("页码过大,请缩小查询范围或使用筛选条件");
}
}
百度、淘宝的搜索结果都是这样干的------翻到几十页就告诉你"没有更多结果了"。
6.2 搜索引擎替代(Elasticsearch)
如果业务一定要支持深度跳页 + 复杂筛选,那就别折腾 MySQL 了,上 ES:
java
// ES 的 from/size deep paging 用 search_after
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.size(10);
sourceBuilder.sort("createTime", SortOrder.DESC);
sourceBuilder.searchAfter(new Object[]{lastCreateTime, lastId});
ES 的 search_after 跟游标分页原理一样,性能也很稳。
6.3 缓存热门页数据
前 10 页的数据访问量可能占总量的 90%。把这 10 页缓存到 Redis,直接挡掉大部分请求:
java
@Cacheable(value = "orderPage", key = "#pageNum + '_' + #pageSize")
public List<Order> queryHotPage(int pageNum, int pageSize) {
// 只缓存前10页
if (pageNum <= 10) {
return orderMapper.selectPage(pageNum, pageSize);
}
// 后面的走正常查询
return queryDeepPage(pageNum, pageSize);
}
6.4 三种方案终极对比
| 方案 | 适用场景 | 性能 | 是否支持跳页 | 复杂度 |
|---|---|---|---|---|
| 游标分页 | APP 瀑布流、无限滚动 | 极快(O(1)) | 不支持 | 低 |
| 延迟关联 | 后台管理、支持跳页 | 较快 | 支持 | 中 |
| 产品限制 | 快速止血、配合其他方案 | 无改动 | 限制后支持 | 极低 |
| Elasticsearch | 复杂搜索、海量数据 | 快 | 支持 | 高 |
七、踩坑指南
坑 1:游标分页时数据插入导致漏数据/重复数据
用户正在浏览,新数据插入排到了当前页前面。下一页可能看到重复,或者漏掉新插入的。建议产品层允许轻微不一致,或者游标值用"创建时间+ID"联合保证唯一性。
坑 2:排序字段不唯一导致分页结果不稳定
ORDER BY create_time但 create_time 不唯一,每次查询顺序可能不一样。解决办法:ORDER BY create_time, id,加上唯一字段兜底。
坑 3:延迟关联时主键 ID 不连续有人误以为延迟关联要求 ID 连续。其实不是,IN 或 JOIN 只关心你传了哪些 ID,跟连不连续没关系。但游标分页确实要求排序字段有序。
坑 4:多表 JOIN 后分页的复杂度
SELECT ... FROM order o JOIN user u ON ... LIMIT 100000, 10这种更惨,MySQL 可能先 JOIN 出巨表再分页。建议先单表分页取 ID,再关联。
八、问题与解答
Q1:游标分页和 LIMIT 分页在数据一致性上有什么区别?
LIMIT 分页在翻页过程中,如果数据有增删改,会出现重复或跳过。游标分页也一样,但因为不依赖 offset,只要排序字段不变,相对更稳定。如果要求强一致,得加时间戳快照或版本号控制。
Q2:延迟关联的子查询一定要用主键 id 吗?
不一定。子查询选什么字段,取决于你后面怎么关联。通常是选主键,因为主键有聚簇索引,关联最快。但理论上选任何有索引的字段都可以。
Q3:业务上必须支持跳页,但又不想上 ES,怎么办?
两条路:一是延迟关联,虽然 offset 大的时候还是会慢,但比直接 SELECT * 快 10 倍以上;二是产品层限制最大页码,配合筛选条件让用户缩小范围。我见过一个项目把最大页码限制在 50,深度查询改成按时间范围筛选,体验反而更好了。
九、面试高频考点汇总
考点 1:为什么 LIMIT 1000000, 10 很慢?
答案: MySQL 需要扫描 offset + size 行,然后扔掉前 offset 行。offset 越大,扫描和丢弃的行越多,性能越差。时间复杂度近似 O(offset)。
考点 2:游标分页的原理是什么?有什么限制?
答案: 利用有序字段(如主键 ID)记录上一页最后一条的位置,下一页查询 WHERE id > lastId LIMIT size。优点是不受页码深度影响,永远只扫 size 行。缺点是不支持跳转到任意页码,且要求排序字段唯一。
考点 3:延迟关联优化的核心思想是什么?
答案: 子查询先通过覆盖索引只查主键 ID,避免大量回表;外层再用 ID 关联查完整记录,将回表次数从 offset+size 降到 size。
考点 4:MySQL 的 ICP(索引下推)和深度分页有关系吗?
答案: ICP 主要用于减少回表次数,对 WHERE 条件过滤有效。但 LIMIT 的深度分页瓶颈在于扫描和丢弃大量行,ICP 解决不了这个核心问题。深度分页还是要靠游标或延迟关联。
考点 5:ES 的 search_after 和游标分页有什么区别?
答案: 原理本质上一样,都是记录上一页的最后位置作为游标,下一页从这个位置继续查。ES 的 search_after 要求排序字段唯一,跟 MySQL 游标分页的限制相同。
十、模拟面试官提问与参考答案
场景题 1
面试官:你们系统有一个千万级订单表,运营后台要支持分页查询,页码可能很深。你怎么设计?
参考答案 :分场景。如果是 APP 端瀑布流,用游标分页,基于 create_time + id 的联合游标,性能稳定。如果是后台管理必须支持跳页,用延迟关联优化:子查询只取 ID 走覆盖索引,外层 JOIN 查详情。同时产品层限制最大页码不超过 100,避免无意义深度翻页。
场景题 2
面试官:游标分页不支持跳页,如果产品坚持要做"跳到第 N 页",你怎么处理?
参考答案:首先跟产品沟通,说明跳页的使用频率和性能代价。大多数用户不会翻到 50 页以后。如果必须支持,可以:1)延迟关联方案,允许跳页但牺牲性能;2)缓存前 N 页的热门数据;3)上 Elasticsearch 用 search_after。极端情况下,可以让用户先选时间范围或筛选条件,缩小数据量后再分页。
场景题 3
面试官 :延迟关联的子查询 SELECT id ... ORDER BY create_time LIMIT offset, size,如果 create_time 没有索引会怎样?
参考答案:子查询会走全表扫描 + filesort,优化失效,性能比原来更差。因为子查询外面还包了一层 JOIN,多了临时表开销。所以延迟关联的前提是排序字段必须有索引,执行前必须用 EXPLAIN 验证子查询的 Extra 列出现 Using index。
场景题 4
面试官:游标分页时,用户正在看列表,突然有一条新数据插入到了当前页前面,会出现什么问题?怎么解决?
参考答案:会出现数据重复或遗漏。比如用户看了第 1 页(id 1-10),插入了一条 id=5 的新数据,原来的 id=5-10 都后移了一位。用户点下一页取 id>10,就会漏掉新的 id=5,且 id=10 会重复出现在第 2 页。
解决办法:1)产品层容忍这种轻微不一致(推荐,大部分场景可接受);2)游标字段用"时间戳+自增ID"联合唯一值;3)查询时带上快照时间戳,只查某个时间点之前的数据。
场景题 5
面试官:多表关联查询后分页,比如订单表 JOIN 用户表再 LIMIT 深度分页,怎么优化?
参考答案:千万不要先 JOIN 再 LIMIT,这样 MySQL 可能先生成巨大的中间结果集再分页。正确做法是:先在单表(通常是主表)上分页取 ID,然后用这些 ID 去关联其他表查详情。也就是"先分页,后关联"。如果关联条件复杂,可以考虑冗余字段或宽表设计,避免深度分页时的多表 JOIN。
十一、互动话题
你项目中有没有遇到过深度分页导致的性能问题?最后是怎么解决的?是硬上缓存、改产品需求,还是说服老板上了 Elasticsearch?欢迎在评论区聊聊你的实战经验。
十二、参考资料
如果这篇文章对你有帮助,麻烦点个赞收藏一下。有问题欢迎在评论区留言,我看到都会回复。