SQL:深分页问题深度解析

文章目录

    • 一、什么是深分页
    • 二、深分页问题的分析
      • [2.1 InnoDB 的数据存储结构](#2.1 InnoDB 的数据存储结构)
      • [2.2 LIMIT 的执行逻辑](#2.2 LIMIT 的执行逻辑)
    • 三、深分页问题的解决方案
      • [方案一:延迟关联(Deferred Join)](#方案一:延迟关联(Deferred Join))
      • [方案二:游标法(Keyset Pagination / 基于游标的分页)](#方案二:游标法(Keyset Pagination / 基于游标的分页))
      • [方案三:使用覆盖索引(Covering Index)](#方案三:使用覆盖索引(Covering Index))
      • 方案四:业务层优化
        • [4.1 限制最大翻页页数](#4.1 限制最大翻页页数)
        • [4.2 引入搜索引擎(Elasticsearch)](#4.2 引入搜索引擎(Elasticsearch))
        • [4.3 数据冷热分离](#4.3 数据冷热分离)
    • 四、方案对比

一、什么是深分页

我们先从一个常见的场景说起:假设你在维护一个电商平台的订单系统,订单表 orders 中有数千万条数据。产品经理要求实现一个订单列表页,支持按时间倒序分页展示,每页 10 条记录。

最初,你可能会写出这样的 SQL:

sql 复制代码
-- 查询第 1 页
SELECT * FROM orders WHERE user_id = 123 ORDER BY create_time DESC LIMIT 0, 10;

-- 查询第 100 页
SELECT * FROM orders WHERE user_id = 123 ORDER BY create_time DESC LIMIT 990, 10;

-- 查询第 10000 页(深分页)
SELECT * FROM orders WHERE user_id = 123 ORDER BY create_time DESC LIMIT 99990, 10;

当查询前几页时,速度很快;但当查询第 10000 页时,你会发现查询变得异常缓慢,甚至可能超时。这就是我们常说的 "深分页问题"


二、深分页问题的分析

2.1 InnoDB 的数据存储结构

InnoDB 使用 B+树 作为索引结构。数据行存储在聚簇索引 (主键索引)的叶子节点中,而二级索引(如 (user_id, create_time))的叶子节点存储的是主键值。

当我们通过二级索引查询数据时,通常需要经历 "回表" 过程:

  1. 先在二级索引中找到符合条件的主键值;
  2. 再拿着主键值去聚簇索引中查找完整的数据行。

2.2 LIMIT 的执行逻辑

对于 LIMIT 99990, 10,MySQL 的执行逻辑是:

  1. 扫描满足 WHERE user_id = 123 的记录,按照 create_time DESC 排序;
  2. 跳过前 99990 条记录
  3. 返回接下来的 10 条记录。

关键点在于:
为了跳过前 99990 条记录,数据库实际上需要先扫描并读取这 99990 条记录(即使它们会被丢弃)。如果这 99990 条记录都需要回表,那么开销是巨大的。


三、深分页问题的解决方案

方案一:延迟关联(Deferred Join)

核心思想:先利用二级索引找到需要的主键,减少回表次数。

我们可以将查询拆分为两步:

  1. 先在二级索引上查询出需要的 10 个主键 ID(这一步不需要回表);
  2. 再通过主键 ID 关联原表,获取完整数据(这一步只需要回表 10 次)。

优化后的 SQL

sql 复制代码
SELECT o.* 
FROM orders o
INNER JOIN (
    -- 子查询:只查主键,利用覆盖索引,避免回表
    SELECT id 
    FROM orders 
    WHERE user_id = 114 
    ORDER BY create_time DESC 
    LIMIT 99990, 10
) AS tmp ON o.id = tmp.id;

前提条件 :需要建立联合索引 (user_id, create_time, id)(或者 (user_id, create_time),因为 InnoDB 二级索引默认包含主键)。

优点

  • 显著减少回表次数(从 100000 次降到 10 次);
  • 兼容性好,不需要修改业务逻辑。

缺点

  • 仍然需要扫描前 99990 条索引记录(虽然比回表快);
  • 当 offset 极大时(如 100 万),性能仍会下降。

方案二:游标法(Keyset Pagination / 基于游标的分页)

核心思想 :不再使用 offset 跳过记录,而是记住上一页最后一条记录的位置,下一次查询直接从该位置开始。

这是目前性能最好的深分页解决方案,特别适合"无限滚动"或"上一页/下一页"的场景。

实现步骤

  1. 第一页查询 :正常查询,记录最后一条记录的 create_timeid(用于排序和去重)。
sql 复制代码
SELECT id, create_time, order_no, amount 
FROM orders 
WHERE user_id = 114
ORDER BY create_time DESC, id DESC 
LIMIT 10;

假设最后一条记录的 create_time = '2026-05-01 10:00:00'id = 114514

  1. 第二页及之后的查询:使用上一页的最后一个值作为"游标"。
sql 复制代码
SELECT id, create_time, order_no, amount 
FROM orders 
WHERE user_id = 114
    AND (create_time < '2026-05-01 10:00:00' 
        OR (create_time = '2026-05-01 10:00:00' AND id < 114514))
ORDER BY create_time DESC, id DESC 
LIMIT 10;

注意 :排序字段必须包含唯一字段(如 id),否则当多条记录 create_time 相同时,可能会出现数据重复或遗漏。

优点

  • 性能极高且稳定:无论翻到第 100 页还是第 10000 页,查询性能都和第一页一样,因为只需要从游标位置开始扫描 10 条记录。
  • 避免了扫描大量废弃数据。

缺点

  • 不支持跳页:无法直接从第 1 页跳到第 100 页(因为不知道第 99 页的游标值)。
  • 需要前端配合,传递上一页的游标值。

适用场景:移动端 App 的无限滚动、新闻资讯流、只提供"上一页/下一页"按钮的列表。


方案三:使用覆盖索引(Covering Index)

核心思想:如果查询的所有字段都包含在二级索引中,那么就不需要回表了。

实现方式:建立一个包含查询条件、排序字段和返回字段的联合索引。

例如,如果你的查询只需要返回 id, create_time, order_no, amount,可以建立如下索引:

sql 复制代码
CREATE INDEX idx_user_time_cover ON orders (user_id, create_time DESC, id DESC, order_no, amount);

此时,查询:

sql 复制代码
SELECT id, create_time, order_no, amount 
FROM orders 
WHERE user_id = 114 
ORDER BY create_time DESC, id DESC 
LIMIT 99990, 10;

可以直接从二级索引中获取所有数据,完全避免回表,性能会有很大提升。

优点

  • 避免回表,查询效率高。

缺点

  • 索引体积大(包含了业务字段),维护成本高(INSERT/UPDATE 时需要更新更多索引字段)。
  • 如果查询的字段很多,索引会变得非常臃肿,甚至可能比数据本身还大。
  • 灵活性差,如果业务需要新增返回字段,需要修改索引。

方案四:业务层优化

有时候,最好的优化是"避免问题的发生"。我们可以从业务层面入手,减少深分页的需求。

4.1 限制最大翻页页数

很多时候,用户翻到第 100 页之后,其实已经找不到想要的数据了。我们可以在产品层面限制最大翻页页数(比如最多只允许翻到第 100 页),超过后提示用户"请使用搜索功能缩小范围"。

4.2 引入搜索引擎(Elasticsearch)

对于复杂的多条件筛选、排序、深分页场景,MySQL 往往力不从心。此时可以引入 Elasticsearch (ES)

  • ES 是专门为搜索和分析设计的,对深分页有更好的支持(虽然 ES 的 from+size 也有深度限制,但可以使用 Search After API,其原理类似于上述的"游标法")。
  • 可以将数据同步到 ES,所有的列表查询、统计查询都走 ES,MySQL 只负责数据的写入和详情查询。
4.3 数据冷热分离

将历史数据(如一年前的订单)归档到"历史表"或"数仓"中。用户默认只查询"热数据"(最近一年),如果确实需要查询历史数据,再走专门的历史数据查询流程(可能会慢一些,但可以接受)。


四、方案对比

方案 性能 支持跳页 开发成本 适用场景
延迟关联 支持 数据量中等,需要支持跳页
游标法 (Keyset) 极高 不支持 无限滚动、上/下一页、性能要求极高
覆盖索引 支持 查询字段固定且较少
业务限制/ES - - 超大数据量、复杂查询、多条件筛选

我的建议

  1. 优先考虑业务优化:如果能通过产品设计避免深分页(如限制页数、引导搜索),这是最一劳永逸的方案。
  2. 次选游标法:如果业务场景适合(如无限滚动),游标法是性能最优的技术方案。
  3. 配合延迟关联:如果必须支持跳页,使用"延迟关联"+"覆盖索引"的组合,通常能满足大部分场景。
  4. 引入 ES:当数据量达到亿级,且查询条件复杂时,果断引入 Elasticsearch。
相关推荐
wang3zc1 小时前
JavaScript中函数声明位置对解析器预编译的影响
jvm·数据库·python
涤生大数据1 小时前
AI时代,SQL该何去何从?
数据库·人工智能·sql
yexuhgu2 小时前
C#怎么使用Tuple元组返回多个值_C#如何简化方法返回值【基础】
jvm·数据库·python
HalvmånEver2 小时前
MySQL的索引
android·linux·数据库·学习·mysql
qq_414256573 小时前
JavaScript中类继承中super关键字的调用执行逻辑
jvm·数据库·python
代码丰3 小时前
RAG 文档切分、索引优化与 Reranker 学习笔记
数据库
Elastic 中国社区官方博客3 小时前
Elastic 9.4:Workflows 正式发布、Agent Builder 更新,以及 Prometheus / PromQL 支持
运维·数据库·人工智能·elasticsearch·搜索引擎·信息可视化·prometheus
ㄟ留恋さ寂寞3 小时前
html如何修改备注
jvm·数据库·python
2401_884454153 小时前
c++如何读取YAML格式配置文件_yaml-cpp库快速入门【详解】
jvm·数据库·python