文章目录
-
- 一、什么是深分页
- 二、深分页问题的分析
-
- [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))的叶子节点存储的是主键值。
当我们通过二级索引查询数据时,通常需要经历 "回表" 过程:
- 先在二级索引中找到符合条件的主键值;
- 再拿着主键值去聚簇索引中查找完整的数据行。
2.2 LIMIT 的执行逻辑
对于 LIMIT 99990, 10,MySQL 的执行逻辑是:
- 扫描满足
WHERE user_id = 123的记录,按照create_time DESC排序; - 跳过前 99990 条记录;
- 返回接下来的 10 条记录。
关键点在于:
为了跳过前 99990 条记录,数据库实际上需要先扫描并读取这 99990 条记录(即使它们会被丢弃)。如果这 99990 条记录都需要回表,那么开销是巨大的。
三、深分页问题的解决方案
方案一:延迟关联(Deferred Join)
核心思想:先利用二级索引找到需要的主键,减少回表次数。
我们可以将查询拆分为两步:
- 先在二级索引上查询出需要的 10 个主键 ID(这一步不需要回表);
- 再通过主键 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 跳过记录,而是记住上一页最后一条记录的位置,下一次查询直接从该位置开始。
这是目前性能最好的深分页解决方案,特别适合"无限滚动"或"上一页/下一页"的场景。
实现步骤:
- 第一页查询 :正常查询,记录最后一条记录的
create_time和id(用于排序和去重)。
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。
- 第二页及之后的查询:使用上一页的最后一个值作为"游标"。
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 AfterAPI,其原理类似于上述的"游标法")。 - 可以将数据同步到 ES,所有的列表查询、统计查询都走 ES,MySQL 只负责数据的写入和详情查询。
4.3 数据冷热分离
将历史数据(如一年前的订单)归档到"历史表"或"数仓"中。用户默认只查询"热数据"(最近一年),如果确实需要查询历史数据,再走专门的历史数据查询流程(可能会慢一些,但可以接受)。
四、方案对比
| 方案 | 性能 | 支持跳页 | 开发成本 | 适用场景 |
|---|---|---|---|---|
| 延迟关联 | 中 | 支持 | 低 | 数据量中等,需要支持跳页 |
| 游标法 (Keyset) | 极高 | 不支持 | 中 | 无限滚动、上/下一页、性能要求极高 |
| 覆盖索引 | 高 | 支持 | 中 | 查询字段固定且较少 |
| 业务限制/ES | - | - | 高 | 超大数据量、复杂查询、多条件筛选 |
我的建议:
- 优先考虑业务优化:如果能通过产品设计避免深分页(如限制页数、引导搜索),这是最一劳永逸的方案。
- 次选游标法:如果业务场景适合(如无限滚动),游标法是性能最优的技术方案。
- 配合延迟关联:如果必须支持跳页,使用"延迟关联"+"覆盖索引"的组合,通常能满足大部分场景。
- 引入 ES:当数据量达到亿级,且查询条件复杂时,果断引入 Elasticsearch。