---
title: 深度分页介绍及优化建议
description: 深度分页是指查询偏移量过大导致性能下降的场景，本文详解深度分页产生的原因及四种优化方案：范围查询、子查询优化、INNER JOIN 延迟关联、覆盖索引，并分析各方案的适用场景与优缺点。
category: 高性能
head:
  - - meta
    - name: keywords
      content: 深度分页,分页优化,LIMIT优化,MySQL分页,延迟关联,覆盖索引,游标分页
---

## 什么是深度分页？怎么导致的？

查询偏移量过大的场景我们称为深度分页，这会导致查询性能较低，例如：

```sql
# MySQL 在无法利用索引的情况下跳过1000000条记录后，再获取10条记录
SELECT * FROM t_order ORDER BY id LIMIT 1000000, 10
```

当查询偏移量过大时，MySQL 的查询优化器可能会选择全表扫描而不是利用索引来优化查询。

**深度分页变慢的根本原因**在于 MySQL 的执行机制：对于 `LIMIT offset, N`，MySQL 并非直接跳到 `offset` 处，而是必须从头扫描 `offset + N` 条记录。如果查询依赖二级索引且不满足覆盖索引，这意味着 MySQL 需要对前 `offset` 条记录执行毫无意义的**回表查询（产生海量的随机 I/O）**，最后再将这些辛苦查出的数据丢弃。即便优化器最终因代价过高退化为全表扫描，顺序扫描百万行的成本依然巨大。

![深度分页问题](https://oss.javaguide.cn/github/javaguide/mysql/deep-pagination-phenomenon.png)

不同机器上这个查询偏移量过大的临界点可能不同，取决于多个因素，包括硬件配置（如 CPU 性能、磁盘速度）、表的大小、索引的类型和统计信息等。

![转全表扫描的临界点](https://oss.javaguide.cn/github/javaguide/mysql/deep-pagination-phenomenon-critical-point.png)

MySQL 的查询优化器采用基于成本的策略来选择最优的查询执行计划。它会根据 CPU 和 I/O 的成本来决定是否使用索引扫描或全表扫描。如果优化器认为全表扫描的成本更低，它就会放弃使用索引。不过，即使偏移量很大，如果查询中使用了覆盖索引（covering index），MySQL 仍然可能会使用索引，避免回表操作。

## 深度分页优化建议

> **本文基于 MySQL 8.0 + InnoDB 存储引擎**，不同版本优化器行为可能存在差异。

### 范围查询（游标分页）

通过记录上一页最后一条记录的 ID，使用 `WHERE id > last_id LIMIT n` 获取下一页数据：

```sql
# 通过记录上次查询结果的最后一条记录的 ID 进行下一页的查询
SELECT * FROM t_order WHERE id > 100000 ORDER BY id LIMIT 10
```

**游标分页的核心优势**：**不依赖 ID 的连续性**。MySQL 只需要在 B+ 树上定位到 `last_id` 的位置，然后顺序向后读取 `n` 条记录即可，中间是否有断层（如 ID 被删除）完全不影响结果的准确性和性能。

这种方式的限制：

1. **不支持跳页**：无法直接跳转到第 N 页，只能逐页向后（或向前）翻页。
2. **排序字段受限**：如果查询需要按照其他字段（如创建时间）排序而非 ID 排序，需使用联合游标 `(sort_field, id)` 保证唯一性和顺序。
3. **并发场景**：当分页查询期间有新数据插入或删除时，可能出现：
   - **数据遗漏**：查询第二页时，有新数据插入到第一页范围内，导致该数据被“挤”到第二页，但第二页查询已基于旧的最后 ID 跳过它。
   - **数据重复**：查询第二页时，第一页末尾有数据被删除，原第二页的第一条数据“升”到第一页末尾，导致第二页查询再次返回它。

### 子查询

我们先查询出 limit 第一个参数对应的主键值，再根据这个主键值再去过滤并 limit，这样效率会更快一些。

阿里巴巴《Java 开发手册》中也有对应的描述：

> 利用延迟关联或者子查询优化超多分页场景。
>
> ![](https://oss.javaguide.cn/github/javaguide/mysql/alibaba-java-development-handbook-paging.png)

```sql
-- 先通过子查询在主键索引上进行偏移，快速找到起始ID
SELECT * FROM t_order
WHERE id >= (
    SELECT id FROM t_order ORDER BY id LIMIT 1000000, 1
) ORDER BY id LIMIT 10;
```

**工作原理**:

1. 子查询 `(SELECT id FROM t_order ORDER BY id LIMIT 1000000, 1)` 利用主键索引扫描并跳过前 1000000 条记录，返回第 1000001 条记录的主键值。
2. 主查询 `SELECT * FROM t_order WHERE id >= ... ORDER BY id LIMIT 10` 以该主键为起点，获取后续 10 条完整记录。

不过，某些情况下子查询可能会产生临时表，影响性能，因此在复杂查询中建议优先考虑延迟关联。

> **复杂过滤场景**：在包含复杂过滤条件的分页场景中（如 `WHERE status = 1 ORDER BY id LIMIT 1000000, 10`），符合条件的 ID 往往是离散的。此时子查询的优势更加明显：通过在子查询中利用联合索引（如 `(status, id)`）实现覆盖索引扫描，可以高效地跳过前 100 万条符合条件的记录，定位到目标 ID 后，主查询只需回表 10 次。

当然，我们也可以利用子查询先去获取目标分页的 ID 集合，然后再根据 ID 集合获取内容，但这种写法非常繁琐，不如使用 INNER JOIN 延迟关联。

### 延迟关联

延迟关联与子查询的优化思路类似，都是通过将 `LIMIT` 操作转移到主键索引树上，减少回表次数。相比直接使用子查询，延迟关联通过 `INNER JOIN` 将子查询结果集成到主查询中，避免了子查询可能产生的临时表。在执行 `INNER JOIN` 时，MySQL 优化器能够利用索引进行高效的连接操作（如索引扫描或其他优化策略），因此在深度分页场景下，性能通常优于直接使用子查询。

```sql
-- 使用 INNER JOIN 进行延迟关联
SELECT t1.*
FROM t_order t1
INNER JOIN (
    -- 这里的子查询可以利用覆盖索引，性能极高
    SELECT id FROM t_order ORDER BY id LIMIT 1000000, 10
) t2 ON t1.id = t2.id;
```

**工作原理**:

1. 子查询 `(SELECT id FROM t_order ORDER BY id LIMIT 1000000, 10)` 利用主键索引扫描并跳过前 1000000 条记录，返回目标分页的 10 条记录的 ID。
2. 通过 `INNER JOIN` 将子查询结果与主表 `t_order` 关联，获取完整的记录数据。

除了使用 INNER JOIN 之外，还可以使用逗号连接子查询。

```sql
-- 使用逗号进行延迟关联
SELECT t1.* FROM t_order t1,
(SELECT id FROM t_order ORDER BY id LIMIT 1000000, 10) t2
WHERE t1.id = t2.id;
```

**注意**: 虽然逗号连接子查询也能实现类似的效果，但为了代码可读性和可维护性，建议使用更规范的 `INNER JOIN` 语法。

### 覆盖索引

索引中已经包含了所有需要获取的字段的查询方式称为覆盖索引。

**覆盖索引的好处：**

- **避免 InnoDB 表进行索引的二次查询，也就是回表操作**：InnoDB 是以聚集索引的顺序来存储的，对于 InnoDB 来说，二级索引在叶子节点中所保存的是行的主键信息，如果是用二级索引查询数据的话，在查找到相应的键值后，还要通过主键进行二次查询才能获取我们真实所需要的数据。而在覆盖索引中，二级索引的键值中可以获取所有的数据，避免了对主键的二次查询（回表），减少了 IO 操作，提升了查询效率。
- **减少回表带来的随机 IO**：通过覆盖索引直接返回数据，避免了根据二级索引的主键值回表查询聚簇索引的随机 IO 操作。回表时每次按主键值查找聚簇索引，本质上是随机 IO。

假设建立了 `(code, type)` 联合索引，下面的查询即可使用覆盖索引：

```sql
# 在 InnoDB 中，辅助索引天然包含主键 id
# 如果只需要查询 id, code, type 这三列，只需建立 (code, type) 的联合索引即可实现覆盖
SELECT id, code, type FROM t_order
ORDER BY code
LIMIT 1000000, 10;
```

**⚠️注意**:

- 当查询的结果集占表的总行数的很大一部分时，MySQL 查询优化器可能选择放弃使用索引，自动转换为全表扫描。
- 虽然可以使用 `FORCE INDEX` 强制查询优化器走索引，但这种方式可能会导致查询优化器无法选择更优的执行计划，效果并不总是理想。

## 生产落地建议

分页优化首先要和产品形态对齐。后台管理系统常常需要跳页和精确总数，信息流、评论列表、订单列表通常只需要“上一页/下一页”或“加载更多”。如果业务不需要跳到第 10000 页，就不要为了兼容传统分页牺牲查询性能。

常见选择如下：

| 业务形态               | 推荐方案         | 原因                             |
| ---------------------- | ---------------- | -------------------------------- |
| 信息流、评论、消息列表 | 游标分页         | 只需要向前/向后翻页，性能稳定    |
| 后台列表、运营查询     | 延迟关联或子查询 | 兼容传统页码，但要限制最大页数   |
| 固定字段榜单           | 覆盖索引         | 查询字段少，可减少回表           |
| 复杂搜索、多条件排序   | 搜索引擎或 OLAP  | MySQL 不适合承担大跨度检索和排序 |

### 监控与告警

- **慢查询监控**：监控慢查询日志中 `LIMIT` 偏移量过大的 SQL，及时发现问题。
- **阈值告警**：设置 `long_query_time` 阈值捕获深度分页查询。
- **执行计划检查**：使用 `EXPLAIN` 定期检查关键分页 SQL 的执行计划，确保优化器按预期使用索引。
- **接口限流**：对深页码请求单独限流，避免爬虫或异常请求拖垮数据库。
- **最大页数限制**：例如只允许查询前 100 页，更深的数据引导用户缩小搜索条件。

### 常见误区

| 误区                              | 事实                                                 |
| --------------------------------- | ---------------------------------------------------- |
| 认为 `FORCE INDEX` 能解决所有问题 | 强制索引可能阻止优化器选择更优计划，应谨慎使用       |
| 认为覆盖索引适用于所有场景        | 字段过多时索引维护成本高，且大结果集仍可能走全表扫描 |
| 认为游标分页能解决所有问题        | 游标分页不支持跳页，且只能按特定字段顺序翻页         |

## 总结

深度分页问题的根本原因在于：当 `LIMIT` 的偏移量过大时，MySQL 需要扫描并跳过大量记录才能获取目标数据，查询优化器可能放弃索引而选择全表扫描。此时即使有索引，也无法避免大量的回表操作，导致查询性能急剧下降。

本文介绍了四种常见的深度分页优化方案，各方案的特点及适用场景对比如下：

| 优化方案     | 核心思路                                                            | 适用场景                       | 限制                                             |
| ------------ | ------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------ |
| **范围查询** | 记录上一页最后一条 ID，通过 `WHERE id > last_id LIMIT n` 获取下一页 | 按 ID 排序、允许游标式翻页     | 不支持跳页、非 ID 排序需使用联合游标             |
| **子查询**   | 先通过子查询获取起始主键，再根据主键过滤                            | 需要支持传统 OFFSET 翻页       | 子查询可能产生临时表、依赖排序字段的索引         |
| **延迟关联** | 用 `INNER JOIN` 将分页转移到主键索引，减少回表                      | 大数据量分页、需要传统翻页逻辑 | SQL 相对复杂                                     |
| **覆盖索引** | 建立包含查询字段的联合索引，避免回表                                | 查询字段固定、可建立合适索引   | 字段较多时索引维护成本高、大结果集可能走全表扫描 |

**方案选择建议**：

- **优先使用延迟关联**：对于大多数需要支持传统 `LIMIT offset, size` 翻页逻辑的场景，延迟关联是性能和可维护性较好的选择。
- **考虑范围查询（游标分页）**：如果业务允许使用“下一页”式的游标翻页（如社交媒体 feed 流、无限滚动），范围查询性能最佳且稳定。
- **覆盖索引作为补充**：当查询字段固定且数量不多时，可配合其他方案建立覆盖索引进一步优化。

**注意事项**：

- 无论采用哪种方案，都应注意监控实际执行计划（`EXPLAIN`），确保优化器按预期使用索引。
- 对于超深分页（如百万级偏移量），应从业务层面评估是否真的需要支持，考虑限制最大翻页数或采用其他检索方式（如搜索引擎）。

## 参考

- 聊聊如何解决 MySQL 深分页问题 - 捡田螺的小男孩：<https://juejin.cn/post/7012016858379321358>
- 数据库深分页介绍及优化方案 - 京东零售技术：<https://mp.weixin.qq.com/s/ZEwGKvRCyvAgGlmeseAS7g>
- MySQL 深分页优化 - 得物技术：<https://juejin.cn/post/6985478936683610149>
