【Java项目技术亮点】深度分页优化游标分页与子查询

写在前面 :说实话,这个坑我踩过。去年做电商后台订单查询,运营同事点了个"跳到第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](#场景题 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 的执行逻辑是:

  1. 从第一行开始扫描
  2. 数够 offset 行,全部扔掉
  3. 再往后读 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?欢迎在评论区聊聊你的实战经验。


十二、参考资料

  1. MySQL 官方文档:LIMIT Optimization
  2. MySQL 延迟关联优化详解 - 掘金

如果这篇文章对你有帮助,麻烦点个赞收藏一下。有问题欢迎在评论区留言,我看到都会回复。