MySQL 分页查询优化

目录

    • 前言
    • [1. LIMIT offset, count 的性能陷阱:为什么它慢?😩](#1. LIMIT offset, count 的性能陷阱:为什么它慢?😩)
    • [2. 优化策略一:基于排序字段的"跳跃式"查询 (Seek Method) 🚀](#2. 优化策略一:基于排序字段的“跳跃式”查询 (Seek Method) 🚀)
    • [3. 优化策略二:利用子查询优化 OFFSET 扫描 (ID Subquery)](#3. 优化策略二:利用子查询优化 OFFSET 扫描 (ID Subquery))
    • [4. 基础优化:为 ORDER BY 列创建索引](#4. 基础优化:为 ORDER BY 列创建索引)
    • [5. 优化总页数/总记录数计算 (COUNT(*) 问题)](#5. 优化总页数/总记录数计算 (COUNT(*) 问题))
    • [6. EXPLAIN 分析分页查询](#6. EXPLAIN 分析分页查询)
    • [7. 总结与选择最佳策略](#7. 总结与选择最佳策略)

🌟我的其他文章也讲解的比较有趣😁,如果喜欢博主的讲解方式,可以多多支持一下,感谢🤗!

其他优质专栏: 【🎇SpringBoot】【🎉多线程】【🎨Redis】【✨设计模式专栏(已完结)】...等

如果喜欢作者的讲解方式,可以点赞收藏加关注,你的支持就是我的动力

✨更多文章请看个人主页: 码熔burning


前言

你好呀,正在与大量数据打交道的开发者们!👋 分页,这个再寻常不过的功能,背后却隐藏着一个性能杀手------那就是 LIMIT offset, count 中不断增大的 offset 值。随着用户翻页越来越深,查询速度却越来越慢,这不仅影响用户体验,还可能给数据库带来巨大的压力。

今天,我们就来揭露 LIMIT offset, count 的性能问题,并学习几种有效的优化策略,让你的分页查询在数据量和深度增加时依然保持高性能。

1. LIMIT offset, count 的性能陷阱:为什么它慢?😩

我们常用的分页查询语句通常长这样:

sql 复制代码
SELECT * FROM articles ORDER BY publish_time DESC LIMIT 10000, 10; -- 查找第 10001 到 10010 条记录

这条语句的意图很明确:跳过前面 10000 条记录,然后返回接下来的 10 条。

问题就出在这个 offset (10000) 上。

MySQL 在处理 LIMIT offset, count 时,如果不借助优化手段,它的基本行为是:

  1. 首先,找到所有符合 WHERE 条件(如果没有 WHERE 就是全表)的记录。
  2. 然后,对这些记录进行 ORDER BY 排序(如果 ORDER BY 的列没有合适的索引,这里还会发生 Using filesort,进一步加剧性能问题)。
  3. 接下来,跳过前 offset 条记录! MySQL 必须把这些记录一条条地读出来,然后再把它们抛弃掉。
  4. 最后,返回接下来的 count 条记录。

想象一下,如果 offset 是 100万,count 是 10。数据库需要找到并排序至少 100万零 10 条记录,然后丢弃掉 100万条,只返回最后的 10条。这前 100万条记录的读取和处理,就是白白浪费的性能开销! 而且 offset 越大,浪费的开销就越大,查询就越慢。这就是深层分页的痛点。

EXPLAIN 中,虽然 LIMIT 子句会体现在执行计划中,但你无法直接从 rows 这一列看出 offset 带来的巨大开销。rows 显示的是优化器预估需要扫描的行数,而不是实际跳过的行数。LIMIT 的影响体现在它强制优化器必须找到 offset + count 条记录才能结束扫描。

2. 优化策略一:基于排序字段的"跳跃式"查询 (Seek Method) 🚀

这是最常用且效果最好的优化方法,特别是对于只能一页一页往后翻(或往前翻)的场景。它的核心思想是:不再使用 OFFSET 跳过,而是记录上一页最后一条记录的排序字段值,然后通过 WHERE 条件直接定位到下一页的起始位置。

适用场景:

  • 你希望实现"下一页"、"上一页"这样的分页导航,而不是直接跳转到任意页码。
  • 你的排序字段(或联合排序字段)是有索引的。

原理:

假设你按照 id 升序排序,每页显示 10 条记录。

  • 第一页: SELECT * FROM articles ORDER BY id ASC LIMIT 10;
    • 获取第一页的 10 条记录,并记住最后一条记录的 id 值,比如是 10
  • 第二页: 不再使用 LIMIT 10, 10。而是使用上一页最后的 id 值作为起点:
    • SELECT * FROM articles WHERE id > 10 ORDER BY id ASC LIMIT 10;
    • MySQL 可以直接利用 id 索引,快速找到 id > 10 的第一条记录,然后顺序扫描索引和数据行,直到找到 10 条记录为止。完全避免了扫描和丢弃前 10 条记录的开销。
  • 第三页: 记住第二页最后一条记录的 id 值,比如是 20。查询:
    • SELECT * FROM articles WHERE id > 20 ORDER BY id ASC LIMIT 10;

以此类推,无论翻到多深,每一页的查询都只基于上一页的终点进行简单的索引查找和顺序扫描 count 条记录,性能非常稳定,不受 offset 影响。

处理非唯一排序字段:

如果你的排序字段不是唯一的(比如按发布时间 publish_time 排序,可能同一时间发布多篇文章),仅仅使用 WHERE publish_time > last_time 是不够的,可能会漏掉或重复记录。这时需要增加一个次级排序字段作为"决胜"条件,通常是主键 ID:

假设按 publish_time 倒序,然后按 id 倒序(确保唯一性),每页 10 条。

  • 第一页: SELECT * FROM articles ORDER BY publish_time DESC, id DESC LIMIT 10;
    • 获取第一页数据,记住最后一条记录的 publish_timeid,比如是 ('2024-05-07 10:00:00', 120)
  • 第二页: WHERE 条件需要同时考虑这两个字段:
    • SELECT * FROM articles WHERE (publish_time < '2024-05-07 10:00:00') OR (publish_time = '2024-05-07 10:00:00' AND id < 120) ORDER BY publish_time DESC, id DESC LIMIT 10;
    • 这个条件的意思是:找发布时间早于上一页最后一条的记录,或者发布时间与上一页最后一条相同但 ID 更小的记录。
    • 为了让这个 WHERE 条件高效,你需要一个覆盖 (publish_time, id) 的联合索引,且顺序和方向与 ORDER BY 匹配。

优点:

  • 性能极高,不受 offset 大小影响。
  • 避免了扫描和丢弃大量记录的开销。
  • 如果排序字段有索引,MySQL 可以高效定位起始点。

缺点:

  • 无法直接跳转到任意页码。 只能从第一页开始,一页一页往后翻(或往前翻,但 WHERE 条件和 ORDER BY 方向需要反过来)。
  • 需要客户端或应用端记录并传递上一页最后一条记录的关键字段值。
  • 需要排序字段是索引列。

EXPLAIN 对比:

  • 原始查询 EXPLAIN SELECT * FROM articles ORDER BY id LIMIT 10000, 10;rows 可能会很大(预估扫描 10010 条),Extra 可能有 Using filesort (如果 id 不是主键且没索引)。
  • 优化后查询 EXPLAIN SELECT * FROM articles WHERE id > 10000 ORDER BY id LIMIT 10;rows 会小很多(预估扫描 10 条,因为 WHERE 条件已经缩小了范围),type 可能是 rangeExtra没有 Using filesort

3. 优化策略二:利用子查询优化 OFFSET 扫描 (ID Subquery)

如果你需要支持用户直接跳转到任意页码,或者排序字段没有合适的索引(虽然强烈建议为排序字段建索引!),可以考虑这种方法。它的核心是先在一个子查询中查出当前页的行的主键 ID,然后在外层查询中根据这些 ID 去获取完整的行数据。

原理:

sql 复制代码
SELECT t1.*
FROM articles t1
JOIN (
    SELECT id
    FROM articles
    ORDER BY publish_time DESC
    LIMIT 10000, 10 -- 在子查询中进行低成本的 OFFSET 扫描
) as t2 ON t1.id = t2.id
ORDER BY t2.publish_time DESC; -- 注意:如果外层需要保持排序,这里可能需要再次 ORDER BY
                                -- 或者如果子查询的 ORDER BY 列已经包含在 SELECT id 中,可以直接用 t2 的列排序

解释:

  1. 子查询 (SELECT id FROM articles ORDER BY publish_time DESC LIMIT 10000, 10)
    • 这个子查询仍然使用了 LIMIT offset, count,会扫描并丢弃前 offset 条记录。
    • 但它只查询了主键 ID。主键 ID 通常很小,且主键本身就是索引。相比于查询整个宽行并进行排序,只查询和排序 ID 列表的开销要小得多。
    • 如果 ORDER BY publish_time DESC 可以利用 publish_time 的索引,那么子查询的速度会更快(避免 Filesort)。
  2. 外层查询 SELECT t1.* FROM articles t1 JOIN ... ON t1.id = t2.id
    • 外层查询根据子查询返回的少数(10个)ID,通过主键索引 (id) 去 articles 表中精确地找到并获取这 10 行完整的记录。这是高效的等值 JOIN。
    • 最后的 ORDER BY t2.publish_time DESC 确保结果集按照正确的顺序返回。如果 publish_time 也在子查询的 SELECT 列表中,可以直接用 t2.publish_time 排序。

优点:

  • 支持跳转到任意页码。
  • 相比直接对全行进行 LIMIT offset, count,子查询对更小的 ID 集合进行 OFFSET 扫描和排序,性能有所提升(尤其当原始表的行很宽,或者 ORDER BY 的列没有索引时)。

缺点:

  • 性能仍然会随着 offset 增大而下降,只是下降的速度可能比直接 LIMIT offset, count 慢。
  • SQL 语句更复杂。
  • 引入了 JOIN 的开销。

EXPLAIN 分析:

EXPLAIN 这个子查询 JOIN 语句,你会看到子查询 (DERIVED) 的执行计划,它会显示 LIMIT 和可能的 Filesort (如果 publish_time 没索引)。外层 JOIN 会显示通过 id 进行 JOIN 的类型(通常是 eq_refref)。

4. 基础优化:为 ORDER BY 列创建索引

无论你选择哪种分页策略,ORDER BY 子句中使用的列创建索引都是至关重要的一步。

  • 如果你的 ORDER BY 列没有索引,MySQL 就会进行 Using filesort,这本身就是一个巨大的性能开销。分页只会加剧这个开销,因为它需要对 offset + count 这么多行进行排序。
  • 如果 ORDER BY 列有索引,MySQL 可以避免 Filesort,直接按照索引顺序读取数据。这使得上面的策略一 成为可能,也使得策略二中的子查询更快。

请回顾之前关于 ORDER BY 优化的部分,确保你的排序字段有合适的索引,特别是联合索引的列顺序和方向。

5. 优化总页数/总记录数计算 (COUNT(*) 问题)

分页界面通常需要显示总记录数或总页数。计算这个值通常需要执行一个独立的 SELECT COUNT(*) 语句,而且这个语句需要和分页查询使用相同的 WHERE 条件。

sql 复制代码
SELECT COUNT(*) FROM articles WHERE status = 'published';

对于大表,COUNT(*) 也可能非常慢,因为它通常需要扫描大量数据(至少是索引)。频繁执行这个查询会给数据库带来压力。

优化 COUNT(*) 的策略:

  • 牺牲精确性,提供估算值: 告诉用户"大约有xxx条记录",而不是精确数字。可以使用 EXPLAIN SELECT ...rows 估算值(不准确),或者维护一个近似的计数器。
  • 限制总页数: 只显示前几十页或前一百页的页码,更深的页只提供"下一页"按钮。这样可以避免计算一个巨大的 COUNT(*) 值。
  • 异步加载计数: 先加载数据,后在后台异步计算总数。
  • 缓存计数: 将计数结果缓存一段时间,而不是每次都实时查询。
  • 维护计数器表: 对于非常频繁且需要精确计数的场景,可以考虑通过触发器或业务逻辑维护一个独立的计数器表。
  • SQL_CALC_FOUND_ROWSFOUND_ROWS() (谨慎使用): 这是一个 MySQL 特性,在执行完 LIMIT 查询后,可以通过 SELECT FOUND_ROWS() 获取如果没有 LIMIT 的总行数。看起来很方便,但底层实现并不总是高效(尤其在 InnoDB 中),它可能还是需要扫描大量行来确定总数。在很多情况下,单独执行一个优化好的 COUNT(*)(可能利用覆盖索引)会比 SQL_CALC_FOUND_ROWS 更快。通常不推荐在新的设计中使用它。

选择哪种策略取决于你的业务需求和对精确度的要求。 对于大多数用户来说,知道有"下一页"或者一个大致的数字就足够了,不需要精确到个位的总数。

6. EXPLAIN 分析分页查询

在优化分页查询时,EXPLAIN 是必不可少的工具。

  • 分析原始查询 (LIMIT offset, count): 查看 type, key, rows, Extra。特别关注 Extra 中的 Using filesortrows 的大小。rows 会告诉你为了得到这 count 行,优化器预估扫描了多少行,这个数字通常接近 offset + count 或更多。
  • 分析子查询优化方案 (JOIN (SELECT ID ... LIMIT offset, count)): 分别 EXPLAIN 外层查询和子查询。查看子查询的 type, key, rows, Extra,看看 ID 的扫描是否高效,是否有 Filesort。查看外层 JOIN 的类型和使用的索引。
  • 分析跳跃式查询方案 (WHERE sort_key > last_value LIMIT count): 查看 type ( ideally range), key, rows (应该很小,接近 count), Extra (没有 Using filesort,可能有 Using index condition 如果有额外 WHERE 条件)。

通过对比不同方案的 EXPLAIN 输出,你可以直观地看到哪种方法减少了扫描的行数,消除了 Filesort,从而提高了效率。

7. 总结与选择最佳策略

  • 首要任务: 永远为你的 ORDER BY 列创建合适的索引。这是所有高效分页的基础。
  • 首选方案(对于"下一页/上一页"导航): 使用基于索引排序字段的跳跃式查询(Seek Method)。实现方式是记录上一页最后一条记录的排序字段值,下一页查询使用 WHERE sort_key > last_value ORDER BY sort_key LIMIT count。处理非唯一排序字段时,记得加上主键作为次级排序字段和 WHERE 条件的组合判断。
  • 备选方案(对于任意页码跳转): 如果必须支持任意页码跳转,可以考虑子查询方案,先在子查询中利用索引(特别是主键或排序字段索引)快速获取当前页的 ID 列表,再 JOIN 回原表。性能优于直接 LIMIT offset, count,但仍会随 offset 增加而性能下降。
  • 优化总数: 重新评估是否需要精确的总记录数。如果不需要,考虑使用估算、限制页数或异步计算等方法,避免昂贵的 COUNT(*) 查询。
  • 实践验证: 任何优化都需要在实际环境和数据下用 EXPLAIN 和性能测试来验证效果。

分页查询的优化是一个常见但关键的环节。通过理解 LIMIT OFFSET 的原理并应用基于索引的优化策略,特别是跳跃式查询,你可以显著提升数据库的查询性能,为用户带来更流畅的体验。

希望这篇详细的讲解能够帮助你彻底理解并解决分页查询的性能问题!实战出真知,赶紧在你的项目中试试这些方法吧!💪

相关推荐
gsfl1 小时前
Redis 缓存
数据库·redis·缓存
恒悦sunsite7 小时前
Ubuntu之apt安装ClickHouse数据库
数据库·clickhouse·ubuntu·列式存储·8123
奥尔特星云大使7 小时前
MySQL 慢查询日志slow query log
android·数据库·mysql·adb·慢日志·slow query log
来自宇宙的曹先生8 小时前
MySQL 存储引擎 API
数据库·mysql
间彧8 小时前
MySQL Performance Schema详解与实战应用
数据库
间彧8 小时前
MySQL Exporter采集的关键指标有哪些,如何解读这些指标?
数据库
weixin_446260858 小时前
Django - 让开发变得简单高效的Web框架
前端·数据库·django
mpHH8 小时前
babelfish for postgresql 分析--todo
数据库·postgresql
zizisuo8 小时前
解决在使用Lombok时maven install 找不到符号的问题
java·数据库·maven
老苏畅谈运维9 小时前
Oracle的connect by level在MySQL中的华丽变身
mysql·oracle