MySQL 深度分页如何优化?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

什么是深度分页?

深度分页是指,当某张表数据量极大,查询的页码又非常靠后,导致最终分页 SQL 中的 offset 偏移量很大,数据库需要扫描并丢弃大量记录,导致性能急剧下降的查询问题。

比如说有一张订单表,查询页码为 100001 的数据,每页展示 10 条数据,其分页 SQL 如下:

SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 10;

MySQL 为了定位偏移量(极其耗时),需要依次遍历并丢弃前 1000000 条记录,再获取接下来的 10 条记录,返回给客户端。

性能瓶颈在于OFFSET 偏移量越大,需要扫描和丢弃的无效数据就越多。它本质上是 O(N) 的时间复杂度,而不是很多人以为的 O(1)

如何优化?

方案一:覆盖索引 + 延迟关联(最通用)

LIMIT 操作只在索引上完成,避免回表扫描大量数据行。

什么是回表?

回表就是先查 “电话簿索引” 找到门牌号,再根据门牌号去 “住户详情本” 里查具体信息,相当于多跑了一趟路。

(解释:数据库先通过索引找到主键 ID,再用这个 ID 去主表里读取完整数据行的过程)

-- 优化前(慢):
SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 10;

-- 优化后(快):
SELECT * FROM orders AS main
INNER JOIN (
    -- 子查询:利用覆盖索引,快速定位到需要的主键ID
    SELECT id FROM orders
    ORDER BY create_time DESC
    LIMIT 1000000, 10
) AS tmp ON main.id = tmp.id
-- 外层查询:用10个ID回表取完整数据
ORDER BY main.create_time DESC; -- 注意保持顺序

为什么快?

  1. 子查询的 SELECT id 只需要扫描 (create_time, id) 的联合索引(覆盖索引),无需回表访问数据行。
  2. 子查询的结果集只有 10 个 ID,而不是 1000010 行数据。
  3. 外层查询仅用这 10 个 ID 去回表取数据,效率极高。

关键前置条件:必须创建合适的覆盖索引。

-- 最佳索引设计:排序字段在前,主键在后
CREATE INDEX idx_cover ON orders (create_time DESC, id);

为什么上面是最佳索引设计?

因为分页查询的排序条件为 ORDER BY create_time DESC , 根据索引的最左匹配原则,上面索引可以完美匹配,同时,索引中包含 id 字段,而子查询只需查询出此字段,所以无需回表。

方案二:游标分页(连续翻页场景的性能王者)

适用场景:只能 “上一页/下一页”,不能任意跳页(如今日头条新闻流、朋友圈等)。

原理:记录上一页最后一条记录的位置。

-- 第一页
SELECT * FROM orders 
ORDER BY create_time DESC, id DESC  -- 加上id保证排序唯一性
LIMIT 10;

-- 第二页:记住第一页最后一条的 create_time 和 id
-- 假设最后一条是:('2024-01-01 10:00:00', 999)
SELECT * FROM orders 
WHERE (create_time, id) < ('2024-01-01 10:00:00', 999)
ORDER BY create_time DESC, id DESC 
LIMIT 10;

优势

  • 时间复杂度从 O(N) 降为 O(log N),利用索引快速定位。
  • 完全避免 OFFSET 带来的性能问题。

索引设计

CREATE INDEX idx_cursor ON orders (create_time DESC, id DESC);

方案三:业务妥协法(改变产品逻辑)

优化思路:有时候,产品经理提的需求,也不一定都是合理的,此时,最好的优化是改变需求。

  • 限制最大翻页:如电商只允许查看前 100 页,更早的数据通过搜索(Elasticsearch)获取。

  • 分段加载:不显示总页数,只提供 “加载更多” 按钮。

  • 添加强过滤条件:让用户先选择时间范围,大幅缩小数据集。

    SELECT * FROM orders 
    WHERE create_time > '2024-01-01'  -- 先通过条件筛选
    ORDER BY create_time DESC 
    LIMIT 1000000, 10; -- 此时偏移量实际处理的数据已大大减少
    

方案四:预先计算的汇总表(宽表)

针对复杂聚合分页:如按商品销量排行,且需要多表关联,可以设计一张宽表(汇总表),每当子表有数据变更,预先更新到这张宽表里,利用 “空间换时间”,查询时直接查这张汇总表即可。

-- 创建一张定时更新的汇总表
CREATE TABLE product_sales_daily (
	product_id BIGINT PRIMARY KEY,
	sales_count INT,
	rank_position INT, -- 可以预先计算排名
	updated_at TIMESTAMP
);

-- 分页查询直接从汇总表查,速度极快
SELECT * FROM product_sales_daily 
ORDER BY rank_position 
LIMIT 1000000, 10;

方案五:架构升级 - 引入搜索引擎

当数据量达到亿级,以上 SQL 层面的优化可能都力不从心。此时可以使用 “MySQL + Elasticsearch” 的异构架构

  • MySQL:负责事务性写入和主键查询。
  • Elasticsearch:负责复杂查询和深度分页。ES 的 search_after 机制天生适合深度分页。

同步方案:使用 Canal 监听 MySQL Binlog 日志,实时同步增量数据到 ES 中。

总结

场景描述推荐方案原因与操作
千万级以下数据,需要跳页覆盖索引 + 延迟关联实现简单,效果显著,能支撑到百万级偏移。
任何数据量,仅支持连续翻页游标分页性能最优,是社交类应用的标配。
后台系统,可接受限制业务妥协法成本最低,限制最大翻页数或增加筛选条件。
亿级数据 + 复杂搜索ElasticsearchMySQL 不适合做重型搜索,ES 的 search_after 是专业解决方案。
实时排行榜、分页预先计算的汇总表空间换时间,将计算成本从查询时转移到写入时。