文章目录
- 前言
- 一、深分页问题的根源剖析
-
- [1.1 执行流程还原](#1.1 执行流程还原)
- [1.2 深分页的性能瓶颈](#1.2 深分页的性能瓶颈)
- [1.3 性能对比数据](#1.3 性能对比数据)
- 二、核心解决方案
-
- [2.1 游标分页(Keyset Pagination)](#2.1 游标分页(Keyset Pagination))
- [2.2 延迟关联(Deferred Join)](#2.2 延迟关联(Deferred Join))
- [2.3 覆盖索引(Covering Index)](#2.3 覆盖索引(Covering Index))
- [2.4 子查询定位 + IN/JOIN](#2.4 子查询定位 + IN/JOIN)
- 三、方案对比与选型指南
-
- [3.1 方案对比矩阵](#3.1 方案对比矩阵)
- [3.2 场景选型建议](#3.2 场景选型建议)
- 四、兜底策略与架构演进
-
- [4.1 业务层限制](#4.1 业务层限制)
- [4.2 引入搜索引擎](#4.2 引入搜索引擎)
- [4.3 分库分表](#4.3 分库分表)
- 五、总结与最佳实践
-
- [5.1 优化路线图](#5.1 优化路线图)
- [5.2 核心原则](#5.2 核心原则)
- [5.3 面试加分点](#5.3 面试加分点)
- 写在最后:
前言
"查询第100页很快,但查询第10000页为什么慢了几百倍?"这是很多开发者在面对海量数据时都会遇到的困惑。在电商订单列表、用户管理后台、日志查询系统等场景中,随着分页深度的增加,查询性能呈指数级下降,这就是典型的深分页问题。
本文将深入剖析深分页的性能瓶颈,并给出完整的解决方案体系:
- 问题根源:MySQL执行LIMIT 1000000, 10时到底做了什么?
- 核心方案:游标分页、延迟关联、子查询定位等实战技巧
- 场景适配:不同业务场景该选哪种方案?
- 兜底策略:当优化无法满足需求时,还有哪些出路?
一、深分页问题的根源剖析
1.1 执行流程还原
LIMIT 10000, 10 执行流程
是
否
索引定位
找到第一条满足条件的记录
回表
获取完整数据行
扫描计数
count = 1
count < 10010?
取下一条记录
丢弃前10000条
返回最后10条
关键问题:MySQL执行LIMIT offset, size时,并不是直接跳转到第offset行,而是:
- 从索引中读取offset + size条记录
- 将前offset条记录全部丢弃
- 返回最后size条记录
这意味着,查询第10000页(offset=100000, size=10)时,MySQL实际需要扫描100010条记录,然后丢弃前100000条。随着offset增大,扫描行数线性增长,性能急剧下降。
1.2 深分页的性能瓶颈
| 瓶颈维度 | 说明 | 影响程度 |
|---|---|---|
| 回表开销 | 每条记录都要通过主键回表获取完整数据,深分页时回表次数巨大 | 🔴 严重 |
| 扫描行数 | 扫描行数 = offset + size,随分页深度线性增长 | 🔴 严重 |
| 随机IO | 回表产生的随机IO,在机械硬盘时代是致命伤 | 🟡 中等 |
| 排序开销 | 如果ORDER BY字段不是索引列,还需要文件排序 | 🟡 中等 |
1.3 性能对比数据
以一个500万记录的表为例,查询age=18的学生列表
| 查询语句 | 执行时间 | 扫描行数 |
|---|---|---|
| LIMIT 10 | 0.04秒 | 10行 |
| LIMIT 5000, 10 | 4.05秒 | 5010行 |
| LIMIT 50000, 10 | 40+秒 | 50010行 |
结论:深分页的性能与offset大小成正比,这是由MySQL的执行机制决定的,无法通过简单调优彻底解决。
二、核心解决方案
2.1 游标分页(Keyset Pagination)
游标分页通过记录上一页最后一条记录的锚点,直接定位到下一页的起始位置,完全避免了offset的开销 。
游标分页流程
第一页查询
SELECT * FROM table ORDER BY id LIMIT 10
记录最后一条ID=1000
第二页查询
SELECT * FROM table WHERE id > 1000 ORDER BY id LIMIT 10
记录最后一条ID=2000
第三页查询
SELECT * FROM table WHERE id > 2000 ORDER BY id LIMIT 10
适用场景:
- 无限滚动(如抖音、微博信息流)
- "加载更多"按钮
- 无需跳页的连续浏览
优缺点:
- ✅ 性能极高,时间复杂度O(log n + m)
- ✅ 不受分页深度影响
- ❌ 无法跳转到任意页码
- ❌ 需要保证排序字段唯一且有序
2.2 延迟关联(Deferred Join)
延迟关联的核心思想是先通过覆盖索引快速定位主键,再回表获取完整数据,将回表次数从offset+size降低到size 。
延迟关联优化前后对比
原始查询
扫描索引定位5000条记录
回表5000次
丢弃4990条
回表浪费
优化后查询
子查询只查主键
SELECT id FROM table LIMIT 4990,10
仅回表10次
通过主键INNER JOIN
返回10条完整数据
两种实现方式:
| 方式 | SQL示例 | 特点 |
|---|---|---|
| 子查询 | SELECT * FROM table WHERE id >= (SELECT id FROM table ORDER BY id LIMIT 100000,1) LIMIT 10 |
简洁,但需要主键有序 |
| JOIN | SELECT t1.* FROM table t1 INNER JOIN (SELECT id FROM table ORDER BY id LIMIT 100000,10) t2 ON t1.id = t2.id |
更通用,支持无序主键 |
性能提升:500万数据测试中,原始查询4.05秒,优化后0.033秒,提升120倍 。
2.3 覆盖索引(Covering Index)
如果查询只需要少数几个字段,可以创建包含所有查询字段的索引,彻底避免回表 。
sql
-- 原始查询(需要回表)
SELECT * FROM student WHERE age = 18 LIMIT 50000, 10; -- 4.05秒
-- 覆盖索引查询(无需回表)
SELECT id, age, name FROM student WHERE age = 18 LIMIT 50000, 10; -- 0.034秒
适用场景:
- 列表页只展示关键字段
- 导出功能只需要部分字段
- 查询字段固定不变
局限性:无法解决扫描offset行的问题,只是大幅降低了单行开销。
2.4 子查询定位 + IN/JOIN
对于排序字段不唯一、需要跳页的场景,可以采用子查询定位 + IN或JOIN的组合方案 。
sql
-- IN + 子查询(需要封装临时表)
SELECT * FROM student
WHERE age = 18
AND id IN (
SELECT id FROM (
SELECT id FROM student WHERE age = 18 LIMIT 50000, 10
) tmp
);
-- 联表查询 + 子查询(推荐)
SELECT s.* FROM student s
INNER JOIN (
SELECT id FROM student WHERE age = 18 LIMIT 50000, 10
) tmp ON s.id = tmp.id;
优势:
- ✅ 支持任意排序字段
- ✅ 支持跳页
- ✅ 回表次数固定为size
三、方案对比与选型指南
3.1 方案对比矩阵
| 方案 | 是否支持跳页 | 是否依赖有序字段 | 性能提升 | 实现复杂度 |
|---|---|---|---|---|
| 游标分页 | ❌ | ✅ | ⭐⭐⭐⭐⭐ | 低 |
| 延迟关联 | ✅ | ❌ | ⭐⭐⭐⭐ | 中 |
| 覆盖索引 | ✅ | ❌ | ⭐⭐⭐ | 低 |
| 子查询+JOIN | ✅ | ❌ | ⭐⭐⭐⭐ | 高 |
3.2 场景选型建议
| 业务场景 | 推荐方案 | 理由 |
|---|---|---|
| 移动端无限滚动 | 游标分页 | 体验好,性能极致 |
| 后台管理系统 | 延迟关联 | 支持跳页,实现相对简单 |
| 报表导出 | 覆盖索引 + 限制 | 导出字段固定,可配合限制 |
| 搜索引擎类 | Elasticsearch | 复杂查询+深分页,专业工具处理 |
| 订单列表(按时间倒序) | 游标分页 + 时间戳 | 时间戳可作为游标 |
四、兜底策略与架构演进
4.1 业务层限制
当技术优化无法满足需求时,可以从业务层面进行约束 :
| 策略 | 实现方式 | 适用场景 |
|---|---|---|
| 限制最大页码 | 只允许查看前100页 | 管理后台 |
| 引导精准查询 | 提示"超过1000条,请缩小查询范围" | 查询页面 |
| 时间范围限制 | 只能查询最近3个月数据 | 日志系统 |
4.2 引入搜索引擎
对于数据量大、查询维度复杂的场景,可以考虑引入Elasticsearch等搜索引擎 。
引入搜索引擎后的架构
常规查询
深分页/复杂查询
业务应用
分页查询
MySQL
Elasticsearch
数据同步
binlog监听
搜索引擎的优势:
- 倒排索引天生适合多维筛选
- 分布式架构支持海量数据
- 提供scroll、search_after等专用深分页API
- 代价:引入额外组件,增加系统复杂度和成本。
4.3 分库分表
通过分库分表将数据分散到多个物理库,每个分片的数据量变小,深分页问题自然缓解 。
关键点:
- 分片键选择直接影响查询效率
- 跨分片的分页需要中间件支持(如ShardingSphere)
- 业务层需配合改造
五、总结与最佳实践
5.1 优化路线图
是
否
是
否
是
是
遇到深分页
能否改用游标?
游标分页
性能最优
查询字段少?
覆盖索引
避免回表
延迟关联
减少回表
仍有性能问题?
业务层限制
最大页数/时间范围
数据量巨大?
引入搜索引擎
或分库分表
5.2 核心原则
- 避免OFFSET:能不用就不用,游标分页是第一选择
- 减少回表:用覆盖索引或延迟关联控制回表次数
- 提前过滤:在业务层限制查询范围,不给数据库压力
- 监控预警:对慢查询中的深分页及时告警
5.3 面试加分点
- 深分页的本质:不是跳转,而是扫描+丢弃
- 游标分页的实现细节:如何处理排序字段重复的情况(添加唯一字段作为第二排序)
- 延迟关联的两种写法:子查询和JOIN的性能差异
- Elasticsearch的深分页:为什么from+size也有深度限制,scroll和search_after的区别
写在最后:
深分页是MySQL在高数据量下的必然挑战,理解其原理是优化的第一步。没有万能方案,只有根据业务场景做出合理取舍------要么牺牲跳页能力换取极致性能,要么增加系统复杂度换取查询灵活性。