MySQL 深分页:性能优化

在日常开发中,分页查询是非常常见的需求,通常我们会使用 LIMIT offset, size的方式来实现。例如:

复制代码
SELECT * FROM table_name ORDER BY id LIMIT 1000000, 10;

这条语句的意图是从表 table_name中查询第 1000001 到 1000010 条记录。然而,当 offset数值很大时,比如百万级别,这个查询会变得异常缓慢。这便涉及到了数据库开发中的一个经典问题 ------ ​深分页 问题

本文将从原理出发,深入分析 MySQL 深分页为何性能低下,介绍几种常见且实用的优化方案,并在文章最后整理一些 Java 秋招高频面试题,供读者参考。


一、什么是深分页?

深分页,指的是在使用 LIMIT offset, size进行分页查询时,offset(偏移量)值非常大的情况。例如:

复制代码
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;

该语句的含义是:​跳过前 1000000 行,返回之后的 10 行数据

MySQL 在执行该查询时,实际上会先执行全表扫描,跳过前 1000000 行,然后再返回接下来的 10 行。当数据量很大时,跳过大量行的操作会消耗大量的 CPU 和 IO 资源,导致查询性能急剧下降。

这就是所谓的 ​深分页问题,是数据库查询性能优化中的一个经典难题。


二、深分页为何性能差?

要理解深分页为何慢,我们需要了解 MySQL 执行 LIMIT offset, size查询时的内部机制:

  1. 全表扫描或索引扫描 :MySQL 会根据查询条件以及排序规则,对数据进行排序或扫描。
  2. 跳过 offset 行 :MySQL 会先读取 offset + size 行数据,然后丢弃前 offset 行,只返回后面的 size 行。
  3. 高 offset 导致大量无效数据扫描 :当 offset 很大时,MySQL 必须扫描并跳过大量无用的行,即使你只需要其中的少量数据。

例如,当执行 LIMIT 1000000, 10时,MySQL 可能要扫描 1000010 行,然后丢弃前 1000000 行,这样的操作代价非常高昂,尤其是在数据量达到百万、千万甚至上亿级别时。


三、深分页的常见优化方案

针对深分页问题,业内已经总结出多种优化手段,下面介绍几种最为实用和常见的优化方案。


方案一:延迟关联(Deferred Join / 子查询优化)

核心思想:

先通过子查询查找出目标页所需的 ​主键 ID​(或其他索引字段),然后再通过这些 ID 去关联原表,查询完整的数据行。

原理:
  • 避免了直接在原表上进行大 offset 扫描。
  • 利用了主键或索引字段的查询高效性。
  • 只查询需要的列,减少数据传输与处理开销。
示例 SQL:

假设我们有一张订单表 orders,有字段 id, user_id, amount, create_time,并且 id是主键,我们想按 id排序做分页:

复制代码
SELECT o.* 
FROM orders o
JOIN (
    SELECT id 
    FROM orders
    ORDER BY id
    LIMIT 1000000, 10
) tmp ON o.id = tmp.id;
说明:
  • 子查询 SELECT id FROM orders ORDER BY id LIMIT 1000000, 10会利用主键索引快速定位到第 1000001 ~ 1000010 条记录的 ID。
  • 外层查询再通过 id关联原表,获取完整数据。
  • 由于主键索引查询非常快,整体查询性能会有显著提升。
优化扩展:

如果你的排序字段不是主键,比如是 create_time,那么可以创建联合索引 (create_time, id),并调整排序与子查询逻辑:

复制代码
SELECT o.* 
FROM orders o
JOIN (
    SELECT id 
    FROM orders
    ORDER BY create_time, id
    LIMIT 1000000, 10
) tmp ON o.id = tmp.id;

同时,为 create_timeid创建联合索引:

复制代码
CREATE INDEX idx_createtime_id ON orders(create_time, id);

方案二:使用覆盖索引优化查询

核心思想:

如果查询的字段能够被某个索引完全覆盖,那么 MySQL 就可以直接从索引中获取数据而 ​无需回表,从而减少 IO 消耗,提高查询效率。

原理:
  • 覆盖索引(Covering Index)是指查询所需的所有字段都包含在某个索引中,因此 MySQL 可以直接从索引中返回结果,无需再到数据行中查找。
  • 但需要注意:覆盖索引本身并不能解决深分页的 offset 问题 ,它更多是配合其他优化手段一起使用,比如延迟关联。
示例:

假设你查询的字段是 id, user_id, create_time,而你为这些字段建立了一个联合索引:

复制代码
CREATE INDEX idx_covering ON orders(user_id, create_time, id);

如果你的查询是:

复制代码
SELECT id, user_id, create_time
FROM orders
ORDER BY create_time, id
LIMIT 1000000, 10;

并且这个查询字段正好是索引 idx_covering的一部分或全部,那么 MySQL 可能只需要扫描索引而不用回表,从而提升性能。

但依然要搭配 ​延迟关联 ​ 或者 ​游标分页​ 才能真正解决深分页问题。


方案三:基于游标的分页(Cursor-based Pagination / Seek Method)

核心思想:

不使用传统的 LIMIT offset, size,而是基于上一页最后一条记录的某个唯一且有序的字段(通常是主键 id或时间戳 create_time),查询 ​比该字段更大(或更小)的值,从而获取下一页数据。

原理:
  • 通过记录上一页最后一条数据的 ID(或时间),下次查询时只查比该 ID 更大的数据。
  • 避免了使用 OFFSET,也就避免了扫描和跳过大量行。
  • 查询效率高,性能稳定,适合无限滚动或"加载更多"类型的分页。
示例 SQL:

假设每页 10 条数据,且按照 id正序排序:

  • 第一页:

    SELECT * FROM orders
    ORDER BY id
    LIMIT 10;

  • 假设第一页最后一条数据的 id是 100,那么第二页:

    SELECT * FROM orders
    WHERE id > 100
    ORDER BY id
    LIMIT 10;

  • 以此类推,第 N 页只需要知道第 N-1 页最后一条的 ID 即可。

优点:
  • 查询性能极高,无需扫描和跳过任何行。
  • 适合大数据量表,尤其是用户频繁翻页的场景。
缺点:
  • 不支持随机跳页 ,比如用户想直接访问第 1000 页,系统无法直接定位。
  • 需要前端或客户端配合保存"上一页最后一条的 ID"。

四、如何选择合适的优化方案?

场景 推荐方案
用户可能随机访问某一页(如点击第 100 页) 延迟关联(子查询优化)
数据量大,且分页是顺序的(如"加载更多") 游标分页(基于 ID 或时间)
查询字段多,希望减少回表 覆盖索引 + 延迟关联
排序字段无索引 优先为排序字段建立索引,再考虑上述优化