☰
MySQL深度分页性能优化:从OFFSET慢查询到延迟关联与书签模式
2026/10/11 22:09:28 网站建设 项目流程

1. 先搞清楚:深度分页到底慢在哪

很多朋友一遇到列表页翻页变慢,第一反应就是加缓存、调超时,或者直接把limit调小一点。但真正的问题不在limit本身,而在 MySQL 执行limit m,n时的工作方式。这里先给一个我实际遇到过的情况:一张 500 万行的订单表,客户端做了一个带分页的管理后台,前 100 页飞快,翻到 5000 页以后接口直接超时。这绝不是个例,几乎所有做过后台系统的人都踩过这个坑。

1.1 什么是深度分页

所谓深度分页,简单说就是offset很大的分页查询。比如SELECT * FROM orders ORDER BY id LIMIT 1000000, 20,这里的 offset 是 100 万,已经属于深度分页的范围了。很多人把"深"理解成limit后面的数字大,其实不对。limit 20始终只要 20 条,真正的成本出在前面的偏移量上。

之前我看到的很多文章会直接告诉你"深分页慢是因为扫描了 1000020 行",这个说法没错,但不完整。MySQL 的 InnoDB 引擎在执行LIMIT m, n时,语义是先扫描前m+n行,然后把前m行丢弃,只返回最后n行。注意,这个"扫描"不是只扫索引,如果查询需要回表,那么每一行都要经历一次完整的数据行读取。

MySQL 的LIMIT有两个关键语义要理解清楚:一个是物理偏移,一个是逻辑筛选。物理偏移就是OFFSET后面的数字,它表示跳过多少行;逻辑筛选是WHERE条件过滤后再排序。判断一个分页是不是深分页,标准不是看limit的数量级,而是看offset + limit这条扫描路径的总代价。

从实用角度划分,我一般把深度分页定义为:offset 超过十万级,且扫描行数与返回行数的比值在百倍以上。在这个量级下,基于简单OFFSET的实现往往会让索引失去意义,优化器的成本预估也会出现偏差。

1.2 OFFSET 的成本模型:为什么翻页越深越慢

先看一条典型深度分页 SQL 在 InnoDB 下的执行过程:

SELECT * FROM t_order WHERE user_id = 123 ORDER BY id DESC LIMIT 100000, 20;

假设user_id上有索引,MySQL 的执行路径大致是:通过二级索引找到所有满足user_id = 123的主键,按主键倒序排列,然后从第 100001 条开始取 20 条。问题来了,前面的 100000 条数据虽然会被丢弃,但它们必须被完整地取出、排序、跳过。

这里就产生了一个被很多人忽略的成本模型:回表次数 = offset + limit。为了跳过前面十万行,MySQL 需要先把它们的完整数据行从聚簇索引里读出来,然后才能判断排序和跳过。哪怕你实际上只需要 20 行,代价却由 100020 行承担。

用表格对比一下深度分页和浅分页的差异:

对比项浅分页(offset=100)深度分页(offset=100000)
扫描索引行数120100020
回表次数120100020
随机 I/O少量极多
排序内存/临时表极小可能走临时文件
响应时间毫秒级秒级到分钟级

这里要特别解释一下"随机 I/O"。InnoDB 的聚簇索引按照主键顺序组织,二级索引的叶子节点存的是主键值。当你通过二级索引拿到一批主键后,再回聚簇索引取数据行,这 100020 次回表对应的主键是散乱分布的,意味着磁盘要不停地做随机读取。机械硬盘在随机 I/O 上的速度远低于顺序 I/O,这就是翻页越深越慢的物理根源。

如果查询里还带着ORDER BY非索引字段,情况会更糟。优化器可能选择先把所有满足条件的数据行读出来,做一次文件排序(filesort),再执行 limit。这时候你就是把整张表或整个结果集都读了一遍,别说十万,几万条都能让你明显感到卡顿。

1.3 一次性能劣化的完整链路还原

我经常用一个简化模型来解释深分页的性能劣化。假设你要查LIMIT 100000, 20:

第一步,MySQL 从二级索引定位起始位置。这一步很快,索引 B+ 树的查找是 O(log n)。 第二步,顺着索引顺序扫描 100020 条记录。这一步开始变重,因为索引节点要连续读取。 第三步,每条记录的二级索引里只有主键,要拿到完整行数据,就得回聚簇索引。这一步最致命,100020 次随机 I/O 基本把磁盘打满。 第四步,丢弃前 100000 行,返回最后 20 行。

整个过程下来,LIMIT后面的 20 只是一个表象,实际工作量几乎等同于把前面十万行全部读一遍。如果这条查询每秒被执行几十次,数据库的磁盘 I/O 立刻就会成为瓶颈,CPU 也会因为大量的行解析而居高不下。

有一类特殊场景连以上链路都走不了:ORDER BY的字段没有索引支持。比如ORDER BY create_time LIMIT 100000, 20而create_time上没有索引,InnoDB 就得走全表扫描加文件排序。这种情况下即使 offset 只有一万,性能也不容乐观,跟是否有深度分页都无关了,是排序本身的问题。

2. 四个立竿见影的优化方案与适用边界

深分页的优化思路本质上只有两种:一是减少回表次数,二是跳过"废弃扫描"的过程。下面这四个方案我都实际用过,有些适合全部场景,有些只能在特定场景下生效。

2.1 延迟关联:先取主键,再回表

延迟关联(Late Row Lookup)是我在线上用得最多、收益最稳定的一种写法。核心思路是让 SQL 的第一阶段只走索引覆盖,取出主键 ID,然后用这些 ID 去做回表关联。

优化前的写法:

SELECT * FROM t_order WHERE user_id = 123 ORDER BY id DESC LIMIT 100000, 20;

优化后的写法:

SELECT * FROM t_order INNER JOIN ( SELECT id FROM t_order WHERE user_id = 123 ORDER BY id DESC LIMIT 100000, 20 ) AS tmp ON t_order.id = tmp.id;

第二个 SQL 的关键变化在子查询里:SELECT id FROM t_order WHERE user_id = 123 ORDER BY id DESC LIMIT 100000, 20。这里只需要id一列,而id是主键,如果user_id上有索引,这整个子查询就是一次覆盖索引扫描——不需要回表,只遍历索引页。

子查询得到 20 个主键 ID 后,外层再用主键关联去取完整数据行。聚簇索引按主键排列,这 20 次回表是精确的点查,随机 I/O 从 10 万级别降到了 20 级别。实测下来,在同样 500 万行的表上,这个写法能把原先两三秒的查询压到 50 毫秒以内。

延迟关联能成立的另一个重要前提是排序字段可走索引。如果ORDER BY用的字段无法通过索引有序返回,子查询阶段照样可能触发文件排序,优化效果就会打折扣。我在后面第 4 节会专门说排序字段不唯一时怎么处理。

2.2 书签模式:记住上一页的位置

书签模式也叫 Seek Method 或 Keyset Pagination。它的核心逻辑是:不用OFFSET指定跳过多少行,而是用WHERE条件直接定位到上一页最后一条记录的位置,然后往后取。

-- 第一页 SELECT * FROM t_order WHERE user_id = 123 ORDER BY id DESC LIMIT 20; -- 第二页,用上一页最后一条的 id 作为书签 SELECT * FROM t_order WHERE user_id = 123 AND id < 987654321 ORDER BY id DESC LIMIT 20;

这个方案的性能不受页数影响。不管你翻到第 1 页还是第 10000 页,查询条件始终是id < 某个值加上LIMIT 20,MySQL 只需要通过索引定位到那个书签位置,再顺序扫描 20 行,扫描量和返回量基本一致。

书签模式的代价是不能随便跳页。你要想看第 3000 页,必须从第一页开始一页一页翻过来,因为每一页的起始位置依赖上一页的结果。这个特性决定了它最适合"加载更多"、"瀑布流"、"信息流"这类场景,对于需要页码跳转的传统后台列表就不太合适。

另一个细节是:书签字段必须唯一。实际业务中很多表没有单列唯一键,这时可以用(id, create_time)组合来做书签,保证排序稳定。如果只用create_time做书签,而create_time在同一秒内有多条记录,翻页就会漏数据或重复数据。

2.3 区间分页:用业务字段替代 offset

区间分页是书签模式在业务语义上的一种延伸,但比书签更灵活。它把"第 N 页"的概念替换成"某个业务区间内的第 N 批",比如按时间范围、按订单号区间、按用户维度切分。

举一个例子,后台导出某月所有订单,每月订单量可能在百万级。如果直接用LIMIT 0, 1000循环拉取,前半程还行,后半程就越来越慢。换成按天或按 id 区间的写法:

SELECT * FROM t_order WHERE order_date >= '2024-11-01' AND order_date < '2024-11-02' ORDER BY id LIMIT 1000;

每条 SQL 只处理特定区间内的数据,区间大小可控,不会出现越翻越慢的问题。对导出这类任务来说,批次之间不需要严格按页码衔接,最终把所有区间合并就是全量数据,逻辑上反而更清晰。

区间分页最大的坑是区间字段的边界重叠。比如按create_time区间切分,一批查[00:00:00, 00:59:59],下一批查[01:00:00, 01:59:59],如果同一条订单的create_time恰好落在边界那一秒,就可能漏查或重复。稳妥的做法是把时间区间放大到毫秒级(datetime(3)),或者改用自增主键 id 做区间,因为 id 天然单调且唯一,不会出现边界重叠。

2.4 覆盖索引与复合排序的配合

前面几个方案能生效,底层依赖其实是覆盖索引。先明确覆盖索引的定义:如果一个索引包含了查询所需的所有列,那么 InnoDB 可以直接从索引页取出数据,完全不需要回聚簇索引。

举个例子,(user_id, id)这个复合索引,对查询SELECT id, user_id FROM t_order WHERE user_id = 123 ORDER BY id DESC LIMIT 100000, 20来说就是覆盖索引。索引本身包含user_id和id,执行时只需要扫描索引页,性能会比回表快一个数量级。

所以很多深分页优化方案的第一步,就是检查能不能通过调整查询列(只取必要字段)把一个回表查询变成覆盖索引查询。这也是我排查深分页慢的第一优先动作——先用EXPLAIN看 Extra 列里有没有Using index,如果有,说明已经走了覆盖索引。

EXPLAIN SELECT id FROM t_order WHERE user_id = 123 ORDER BY id DESC LIMIT 100000, 20;

结果里Extra字段如果显示Using index; Using filesort,那说明索引覆盖了查询列,但排序仍然需要额外处理。这时要检查排序字段与索引列的顺序是否一致。MySQL 只能利用索引列的有序性进行排序,如果索引是(user_id, id),那么WHERE user_id = ? ORDER BY id就能直接利用索引顺序,避免 filesort。

复合索引的顺序调整是成本最低的优化手段,但对索引设计能力要求高。建索引前先想清楚:这个查询的等值条件是什么,排序字段是什么,是否能把排序字段放在等值字段之后形成索引的有序队列。如果条件允许,深分页的性能可以从秒级直接降到毫秒级,连延迟关联都未必需要。

3. 分页背后的相关概念:回表、覆盖索引、排序与一致性

深分页的优化绕不开几个基础概念。很多人看优化方案时只记住"要怎么写 SQL",但换个场景就不会用了,就是因为底层概念没吃透。这一节把几个跟分页强相关的概念串起来讲。

3.1 回表与覆盖索引:深分页慢的根源与解法

回表(Table Lookup)是 InnoDB 中二级索引查询数据行的必经步骤。二级索引叶子节点不存储完整数据行,只存索引列值和主键值。当查询需要索引列之外的数据时,InnoDB 必须拿着主键回到聚簇索引中读取完整行。

深分页之所以慢,根源就是大量回表。前面已经算过账,offset 越大,回表次数越多,而且这些回表是随机 I/O。要解决回表问题,要么让查询不需要回表,要么让回表次数缩小到可接受范围。覆盖索引解决前者,延迟关联解决后者。

我举个更直观的例子。假设表结构是id, user_id, amount, status,索引只有主键id和user_id的单列索引。查询:

SELECT id, user_id, amount, status FROM t_order WHERE user_id = 123 ORDER BY id LIMIT 100000, 20;

amount和status不在二级索引里,每一行满足条件的记录都要回表。这时候即使用user_id索引,也要回表十万次。但如果把索引改成(user_id, status, amount),或者干脆把amount, status加到索引里,那这个查询就变成覆盖索引查询,完全不回表,十万行记录全在索引页里顺序扫描,速度提升非常明显。

3.2 filesort 与排序优化:分页排序的真实成本

分页查询里最常见的性能陷阱之一是在排序上。ORDER BY字段如果没有索引支持,MySQL 需要把结果集先排序,这个操作叫 filesort。名字里有 file,但实际上可能是内存排序,也可能走磁盘临时文件,取决于数据量大小。

如果WHERE条件筛选出的数据量很大,比如十万行,filesort 可能把结果集写到磁盘的临时文件中再排序。这时候查询的瓶颈不是分页本身,而是排序。我之前排查过一条深分页慢查询,EXPLAIN里Extra显示的Using filesort,优化器的执行计划是先按条件收集所有数据、排序、再取 offset 之后的行,整个过程跟索引完全无关。

避免 filesort 的办法是让排序列参与索引。比如查询条件是WHERE user_id = ?,排序是ORDER BY create_time,那么建立(user_id, create_time)复合索引,MySQL 就能通过索引直接得到有序的数据流,完全省掉排序步骤。这是"为什么索引设计要先考虑排序字段"的根本原因。

如果实在无法通过索引满足排序,可以考虑把ORDER BY下推到应用层:先用一个轻量查询取出主键和排序字段,在内存中排序后再回表取数据。对小数据量的应用来说,内存排序比 filesort 更快也更可控,但这种方案需要谨慎评估数据量,避免把大量数据加载到应用内存里。

3.3 MVCC 与一致性读:翻页时数据不稳的隐患

分页优化到毫秒级之后,还有一个容易被忽略的问题:翻页过程中数据发生变化,会造成重复或漏读。这跟 MVCC 的一致性快照机制直接相关。

MySQL InnoDB 默认的隔离级别是REPEATABLE READ,在这个级别下,普通的SELECT是快照读。事务开启后,第一次查询会建立一个一致性快照,之后的查询都从这个快照读数据。但如果分页查询在多个独立的事务中执行,每一页都会产生新的快照,前一页和后一页之间的数据可能已经发生了变化。

举例:我用WHERE id > 上一页最大id的书签模式翻页,结果第一页读到 id=100 的记录,第二页执行时,id=100 的数据被更新过并且排序条件发生了变化,它可能不再出现在新快照的结果集里,也可能重复出现。这种问题不会体现在单条 SQL 的性能上,而是在业务侧造成数据错乱。

解决思路有几种。最干净的是用唯一且稳定的主键做排序字段,且查询过程中不允许更新排序字段的值。其次是如果对数据一致性要求极高,可以显式使用当前读(FOR UPDATE或LOCK IN SHARE MODE),但代价是锁竞争,会牺牲并发性能。大多数读写分离的后台系统不需要这个级别的一致性,容忍一定的误差即可。

另一个相关概念是事务快照与分页游标的配合,在复杂报表场景里,如果每一页的查询都在同一个事务里执行,那么整个翻页过程看到的是一个统一快照,这是最理想的情况。但现实中后台系统往往是无状态接口,没法保证翻页过程在同一事务中,所以设计分页接口时要明确:数据一致性是业务需求,不是数据库默认行为。

4. 排序字段不唯一引发的重复与丢数据:深分页最隐蔽的坑

如果说深分页的性能问题是明面上的坑,那排序字段不唯一带来的数据重复/丢失就是暗坑。这个坑我踩过不止一次,而且网上讨论很少,这里单独拿出来讲。

4.1 问题复现:为什么会出现重复数据和丢数据

假设表t_user中有字段create_time,我们希望按创建时间分页展示用户列表,SQL 如下:

SELECT * FROM t_user ORDER BY create_time DESC LIMIT 0, 20;

这里有个关键问题:create_time不是唯一字段。同一秒内可能创建了多名用户,这些用户在排序时可能会出现不确定顺序。MySQL 的排序算法在值相等时,返回顺序取决于索引扫描的物理顺序、优化器的执行策略等因素,并不保证稳定。

当 offset 为 0 时,查询返回的是"排序结果集"里的前 20 行。如果你翻到第二页,执行LIMIT 20, 20,MySQL 会重新扫描并排序,由于相等值记录的返回顺序可能变化,前两页就会产生交叉数据。体现在业务上,就是某些用户在两页中都出现,或者某些用户两页都没出现。

另一个更隐蔽的场景是在延迟关联的子查询中使用ORDER BY create_time而非ORDER BY create_time, id。子查询取出的 20 个主键集合本身是稳定的,但因为是做分页,必须保证排序顺序完全相同,否则外层 JOIN 出来后顺序错乱,页面显示就乱了。

4.2 修复方式:让排序结果唯一化

修复方法很直接:排序时附加一个唯一字段作为次级排序条件。最常用的是主键 id:

SELECT * FROM t_user ORDER BY create_time DESC, id DESC LIMIT 0, 20; SELECT * FROM t_user ORDER BY create_time DESC, id DESC LIMIT 20, 20;

这样,即使create_time相同,后续记录也会按id排序,每个记录在整个结果集中的位置是唯一的,分页就能稳定对齐。这个改动看起来很小,但对分页正确性是决定性的。

需要提醒的是,ORDER BY create_time DESC, id DESC比单独ORDER BY create_time DESC多了一个排序项,可能会丢失原有的索引优化机会。如果create_time上建了索引而id是主键,MySQL 可能仍然能利用索引顺序扫描,但需要注意DESC方向的问题。如果担心性能,可以建(create_time, id)复合索引,让排序完全走索引。

在使用书签模式时,这个原则同样适用。书签条件不要只写WHERE create_time < 上一页最后的create_time,而要写成:

WHERE (create_time < 上一页最后的create_time) OR (create_time = 上一页最后的create_time AND id < 上一页最后的id)

这样能确保从上一页断点位置严格继续,不重不漏。这类 SQL 因为多了 OR 条件,写起来不直观,但对于性能和数据正确性来说,值得忍一下这个复杂度。

4.3 排序稳定性与索引设计的前置思考

要彻底避开这个坑,最好在设计表结构时就考虑分页场景。我通常在建表时给自己定一条规矩:只要前端有列表分页需求,就默认排序字段必须包含主键。具体做法是:主键用自增 id,排序 SQL 一律写成ORDER BY <业务字段> DESC, id DESC。

这样做还有一个额外好处:排序结果唯一化之后,才能安全地使用书签模式做深分页优化。因为书签模式要求"上一页最后一条记录"能唯一标识一个位置,如果排序字段不唯一,书签本身就会失去锚点。

更深一层,排序稳定性还关系到 MySQL 在并行执行、主从切换、索引重建之后,物理上记录的扫描顺序是否一致。生产环境里主从切换后,同一张表的物理布局可能发生变化,如果 SQL 依赖隐式的物理顺序而不是显式排序,结果就可能不一样。这是我不建议在生产环境依赖"默认顺序"的原因——所谓默认顺序根本不存在,只在某次特定执行中碰巧稳定而已。

5. 方案选型与实战经验总结

写了这么多,最后聊聊怎么在实际项目里选方案。没有银弹,每家公司的情况不同,表结构不同,分页场景也不同。

5.1 不同场景下的方案对比

先把主流方案放在一张表里对比:

方案性能表现能否跳页改动成本适用场景
普通 LIMIT OFFSET越深越慢能无小数据量、内部分页
延迟关联稳定较快能SQL 改造大表通用分页
书签模式最快最稳不能接口改造加载更多、信息流
区间分页稳定较快部分能需设计区间规则导出、批处理、报表
覆盖索引 + 复合索引最快能索引设计成本所有有条件约束的场景

我的初步判断标准是这样:如果表数据量在十万以内,普通 LIMIT 基本够用,不需要折腾。如果数据量在百万级且翻页深度偶尔超过几百页,先用覆盖索引和延迟关联,这是改动最小、收益最高的组合。如果数据量在千万级且是 C 端场景,直接放弃跳页用书签模式。

区间分页适合批处理场景,比如导出和定时任务。这类场景不需要人看页面,只需要保证数据被完整处理一次,区间的边界控制反而更简单可靠。

5.2 排查深分页慢查询的标准步骤

每次遇到分页慢,我建议按固定顺序排查,别一上来就改 SQL:

第一步,用EXPLAIN查看执行计划。重点关注type(是否走索引)、rows(预估扫描行数)、Extra(是否有Using filesort、Using temporary)。 第二步,判断是深分页问题还是排序问题。如果rows只有几百行但查询慢,说明瓶颈在排序或回表;如果rows达到几十万,说明深分页的扫描代价占主导。 第三步,检查查询列是否可覆盖。尽量把 SQL 改成覆盖索引查询,减少回表。 第四步,如果没法覆盖,改用延迟关联。 第五步,如果延迟关联后仍然慢,考虑是否能用书签模式替代页码模式。

这套顺序的本质是:从成本最低的改动开始,逐步向业务层侵入。不要一上来就要求前端改交互方式,先把 SQL 层面的优化做足,往往已经能解决八成问题。

5.3 我实际踩过的一些细节坑

第一个坑是延迟关联中内外层排序不一致。有人把ORDER BY写在子查询里,外层 JOIN 时又想按另一个字段排序,导致最终结果顺序异常。子查询排序只影响主键集合的顺序,外层取回完整数据后如果还需要排序,务必保证排序条件和子查询一致,或者让外层只按主键关联、然后在应用层排序。

第二个坑是LIMIT和OFFSET的参数类型。PHP 和 Java 的 ORM 框架有时会把用户传入的 offset 拼进 SQL,如果前端传了字符串,底层的 prepared statement 可能会出现类型转换导致索引失效。最典型的例子是WHERE id IN ('1', '2', '3'),MySQL 8.0 之前会对字符串和整数之间的比较做隐式转换,索引可能失效。分页接口的入参一定要强类型校验。

第三个坑是 MySQL 8.0 的OFFSET下推优化。8.0 版本确实对LIMIT OFFSET在部分场景做了优化,比如可以在索引扫描阶段就跳过 offset,减少回表。但我在实测中发现,这个优化对多表 JOIN 和复杂WHERE场景不总是生效,不要因为版本新就放松对执行计划的检查。

5.4 最后补充一点:浅分页同样值得关注

深分页是极端情况,但浅分页的很多问题其实被忽略了。比如LIMIT 0, 20和LIMIT 20, 20的性能差异在某些表上也可能非常大,因为第二次查询可能多了排序或回表。我见过一个案例,接口第一页响应 30ms,第二页突然变成 800ms,当时直接蒙了,查了半天发现是第一页查询的结果被 InnoDB 的 buffer pool 缓存了,第二页的数据不在缓存里,触发了大量冷数据读取。

这个案例给我的教训是:分页性能排查要同时看页数和缓存命中率,不能只盯着LIMIT本身。数据库 warm-up 之后和 cold start 之前的性能指标完全是两回事,生产环境里用了几十年的缓存池会让深分页问题被掩盖,到了新环境部署时就立刻暴露。

所以做分页优化时,一定要在冷缓存条件下做基准测试,模拟新部署、大促流量、缓存失效等极端情况。否则你优化出的"峰值性能"可能只在缓存命中时成立,一旦缓存被冲掉,又打回原型。上面这套方案真正要解决的,就是让查询在冷缓存条件下也能保持稳定,而不是依赖数据库内存帮忙兜底。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询