资讯动态

MySQL LIMIT 分页深挖:OFFSET 陷阱与索引优化实战

发布时间:2026/10/17 5:21:14 来源:尧图企业网站定制
MySQL里最不起眼却又最容易被写错的查询子句我第一个提名LIMIT。它写在 SQL 末尾就一行看着谁都会但凡是做过后台管理系统、做过移动端信息流、做过报表分页的人几乎都被它坑过。深分页变慢、排序结果重复、和大JOIN一起用的时候内存暴涨、LIMIT 0在代码里埋雷——这些问题我全都实际踩过。这篇就围绕 MySQL 的LIMIT做一次完整的实操拆解从基础语法讲到深分页的索引优化全程用真实场景和 SQL 说话看完可以直接拿回去用。1. 用好LIMIT从理解它解决的真实问题开始1.1 一个最常见的翻页场景先还原一下大家最熟悉的场景你在做一个订单列表页用户点第 5 页接口要返回第 5 页的 20 条数据。MySQL 这边的 SQL 大概长这样SELECT * FROM t_order ORDER BY order_id LIMIT 80, 20;这种写法在数据量小的时候完全没问题几千条数据随便翻。但一旦单表数据量到了百万甚至千万级你会发现页码越往后接口响应越慢。第 1 页可能 20ms翻到第 10000 页直接卡到几秒钟甚至超时。这不是你服务器配置不行也不是查询条件写错而是LIMIT在深分页场景下执行计划里有一个非常消耗性能的动作。理解这个动作之前得先把LIMIT本身的语义彻底搞清楚。很多人以为LIMIT就是取前 N 条实际上它要干两件事跳过 OFFSET 指定的行数再取 ROW_COUNT 指定的行数。1.2 LIMIT在MySQL里的两种标准写法MySQL 的LIMIT有两种传参方式效果完全等价但容易让从其它数据库转过来的开发产生混淆。第一种是双参数形式SELECT * FROM t_user LIMIT 0, 20;第二个数字是返回的最大行数。0 表示从第一条开始取取 20 条。这种写法取自其它数据库的习惯但很多人在写分页的时候会把两个参数的顺序搞混。我之前在代码评审里看到过有人把LIMIT 20, 0当作取第一页 20 条来写直接导致查出来的结果为空。原因很简单前面是偏移量后面是返回行数LIMIT 20, 0的真实含义是跳过 20 条返回 0 条。第二种是单参数形式SELECT * FROM t_user LIMIT 20;它等价于LIMIT 0, 20表示从第一条开始取 20 条。这种形式通常用在取 Top N的场景比如查询最近 10 条公告、最新 5 条评论。还有第三种写法OFFSET 关键字显式声明偏移量是 PostgreSQL 风格但 MySQL 也是支持的SELECT * FROM t_user LIMIT 20 OFFSET 0;注意LIMIT 20 OFFSET 0和前面的双参数形式参数顺序是反的LIMIT 后面跟的是返回行数OFFSET 后面跟的是偏移行数。凑巧和第一种写法刚好颠倒所以如果两套风格混着写非常容易出错。风格统一很重要。我个人建议团队内部统一使用LIMIT offset, row_count这种双参数形式并且强制要求offset从 0 开始避免语义含糊也方便后来者在代码评审时一眼看出分页逻辑。2. 核心细节解析LIMIT背后的计算逻辑与排序陷阱2.1 OFFSET与ROW_COUNT到底是怎么算的LIMIT的核心是两件事跳过多少行、取多少行。这个逻辑本身不复杂但真正影响性能的是 MySQL 在底层执行这两个动作的方式。在没有合适索引的情况下MySQL 执行LIMIT 10000, 20的过程是这样的从存储引擎读取第一行数据依次向后扫描一路扫描到第 10000 行期间把所有读到的数据全部丢弃继续读取 20 行返回给客户端执行完成。也就是说MySQL 为了拿到第 10001 到 10020 这 20 行数据实际扫描了 10020 行前 10000 行全部白读。你翻页越深OFFSET 越大MySQL 白读的行数就越多耗时自然线性上涨。这不是某个配置参数能解决的问题而是执行逻辑决定的。所以深分页慢的根源是 OFFSET 的丢弃开销而不是LIMIT本身。这也解释了为什么第二页、第三页通常感觉不明显一旦翻到几千页之后性能断崖式下跌。2.2 ORDER BY与LIMIT组合的排序陷阱LIMIT单独用很简单一旦和ORDER BY组合在一起问题就来了。MySQL 的排序策略大体上有两种情况第一种情况ORDER BY 字段恰好是索引字段且查询条件能够完全走到这个索引。这种情况下MySQL 可以直接按索引顺序读取数据取前 N 条就返回效率很高。比如主键索引本身就是有序的SELECT * FROM t_order ORDER BY order_id LIMIT 10;这个查询 MySQL 会直接利用主键索引的顺序从第一条开始读 10 条返回。执行计划里往往能看到Using index非常快。第二种情况ORDER BY 字段不是索引字段或者在多个表 JOIN 之后排序。这种时候 MySQL 必须先做一次完整的排序操作把所有满足 WHERE 条件的行都排好序然后才能执行LIMIT截断。比如SELECT * FROM t_order WHERE status 1 ORDER BY create_time LIMIT 10;如果create_time上没有索引MySQL 需要先把所有status 1的行都读出来放到排序缓冲区sort buffer里排序排完再取前 10 条返回。这里的排序动作会消耗 CPU 和内存数据量大时还会用到磁盘临时文件整个过程非常重。这就是为什么很多人说加了 LIMIT 就能限制查询时间是个错误认知。LIMIT只限制最终返回的行数并不限制执行过程中需要处理的数据量。MySQL 必须先干活后截断。还有一个更隐蔽的坑ORDER BY的排序不稳定导致分页结果重复或丢失。如果你 ORDER BY 的字段有重复值MySQL 在每一页执行时可能会返回不同的行顺序。比如按status排序状态字段大量重复第一页查出 ID 1~10第二页查出的可能是 ID 3~12于是用户看到同一条记录在两个页面里都出现了而有些记录两页都没出现。要解决这个问题最稳妥的做法是在 ORDER BY 里加上一个具有唯一性的字段作为次级排序条件SELECT * FROM t_order ORDER BY status, order_id LIMIT 10, 20;加上唯一字段之后排序结果就完全确定了分页才真正稳定。这一点在写任何分页接口时都值得当作默认规范执行。2.3 LIMIT 0的隐藏行为LIMIT 0在实际开发中经常出现而且有两个完全不同的使用场景。第一个场景是建表或者模拟数据结构。CREATE TABLE ... AS SELECT ... LIMIT 0可以快速复制表结构而不复制数据很多开发会用这个技巧。第二个场景反而是个坑。有些人在代码里拼接 SQL 时用LIMIT 0作为不查询数据的开关。比如根据参数动态拼 SQL当某个参数为 false 时就把LIMIT 0拼上去希望返回空结果。这个逻辑本身是通的但它容易让后来维护的人误解 SQL 意图而且一旦拼接出错比如 LIMIT 拼到了其它 SQL 后面就可能产生很诡异的行为。更需要注意的是LIMIT 0并不会真的跳过全部查询。MySQL 在执行时确实会立刻停止读取数据但这个 SQL 仍然会被解析、优化、生成执行计划如果写在存储过程里调用性能代价虽然小却并非零成本。如果只是为了判断记录是否存在直接SELECT 1 ... LIMIT 1更合适语义也更清晰。3. 实操过程从慢查询到索引优化的完整调优路线3.1 一个模拟订单表的完整建表与试验为了让下面的优化讨论落到实地我建了一个模拟场景。假设一张线上订单表数据总量约 500 万行表结构如下CREATE TABLE t_order ( order_id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, trade_no varchar(64) NOT NULL, status tinyint NOT NULL DEFAULT 0, total_amount decimal(12,2) NOT NULL, create_time datetime NOT NULL, PRIMARY KEY (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;模拟数据这里就不演示具体插入过程了实战环境可以自己用存储过程灌数据保证数据分布尽量贴近真实user_id有大量重复create_time在不同月份之间均匀分布status集中在 0、1、2 三个值。最常见的业务查询是按创建时间倒序查看订单列表每一页 20 条。SQL 如下SELECT * FROM t_order ORDER BY create_time DESC LIMIT 100000, 20;在没有索引的情况下这个查询的执行过程发生了什么通过EXPLAIN能看得比较清楚------------------------------------------------------------------------------------------------------------------- | id | select_type | table | type | key | rows | Extra | ------------------------------------------------------------------------------------------------------------------- | 1 | SIMPLE | t_order | ALL | NULL | 4965000 | Using filesort | -------------------------------------------------------------------------------------------------------------------type 是 ALL全表扫描Extra 是 Using filesort说明执行了文件排序。这属于最差的执行计划500 万行数据全部读出来排序再截断 20 行返回。加索引是第一步ALTER TABLE t_order ADD INDEX idx_create_time (create_time);加完索引再 EXPLAIN 同样的 SQL------------------------------------------------------------------------------------------------------------------------------ | id | select_type | table | type | key | rows | Extra | ------------------------------------------------------------------------------------------------------------------------------ | 1 | SIMPLE | t_order | index | idx_create_time | 100020 | Using index | ------------------------------------------------------------------------------------------------------------------------------type 变成了 index通过索引顺序扫描rows 估算从 496 万降到 10 万说明 MySQL 只需要读取到第 100020 行就能拿到结果。这个优化已经非常明显实测下来查询时间从 1.8 秒降到了 300ms 左右。但注意问题还没根除OFFSET 到了 10 万MySQL 依然要扫过 10 万条索引记录再丢弃这个成本只是比全表扫描小并没有从机制上绕开。3.2 深分页性能实测为什么OFFSET到了100万就崩了继续把页码往后推直接模拟高深度分页。查询第 5 万页也就是 OFFSET 100 万每页 20 条SELECT * FROM t_order ORDER BY create_time DESC LIMIT 1000000, 20;这个 SQL 的耗时在我本机测试环境普通 SSD、8GB 内存、MySQL 8.0跑了接近 3 秒。同一台机器上OFFSET 10000 时只需要 150ms 左右。差别就在于 MySQL 需要扫描丢弃 100 万行索引记录。而且这里还叠加了回表成本每一行SELECT *都要根据主键去聚簇索引里取完整行数据。这时候索引优化已经到头了得换思路。3.3 三种常用优化方案对比与完整SQL示范优化方案一延迟关联延迟连接核心思路是先用覆盖索引快速定位到需要返回的主键 ID 集合再用主键 ID 去关联回原表取完整数据。这样前期的 OFFSET 扫描全部发生在索引覆盖范围内不需要回表大大减少了随机 IO。SELECT o.* FROM ( SELECT order_id FROM t_order ORDER BY create_time DESC LIMIT 1000000, 20 ) tmp JOIN t_order o ON tmp.order_id o.order_id ORDER BY o.create_time DESC;子查询里只查order_id字段而order_id和create_time都在 idx_create_time 这个索引里所以这个过程可以完全走覆盖索引不需要回表。等到拿到 20 个主键 ID 之后再用 JOIN 去聚簇索引里把完整行取出来只取 20 行。这个方案实测下来OFFSET 100 万的查询时间从 3 秒降到了 600ms 左右效果非常明显。优化方案二基于索引字段的游标分页游标分页的核心思想是不直接用 OFFSET 跳行而是记住上一页最后一条记录的排序字段值下一页查询用 WHERE 条件直接定位到那个位置往后取。这样可以彻底绕开深 OFFSET 的扫描开销。以create_time order_id作为游标为例客户端拿到上一页最后一条记录的create_time和order_id下一页查询SELECT * FROM t_order WHERE (create_time, order_id) (:last_create_time, :last_order_id) ORDER BY create_time DESC, order_id DESC LIMIT 20;这个方案用到了 MySQL 的行值语法查询条件直接定位到游标位置之后LIMIT 只需要取 20 条不需要跳行。实测下来无论翻到多深响应时间都稳定在 20ms 以内。不过游标分页有一定的使用限制它只适合下一页/上一页这种交互模式不适合跳转到第 N 页的需求。一旦产品需要显示页码跳转游标方案就用不了。这是产品形态决定的技术只能围绕产品需求展开。优化方案三记录最大最小ID的区间分页这个方案只适用于连续自增 ID 且没有删除操作的表。核心是利用索引直接定位到目标区间期间没有任何跳行浪费。SELECT * FROM t_order WHERE order_id BETWEEN 1000000 AND 1000020 ORDER BY order_id;这个方案本质上是用主键定位代替 OFFSET 跳行速度极快但限制也非常大。只要业务里有删除操作导致 ID 空洞翻页就会出现错漏。所以只适合那些明确知道 ID 连续且没有删改的特殊场景不具备通用性。三种方案的对比表格方案适用场景性能表现局限性延迟关联深分页 需要跳页OFFSET 100万时约600msSQL 略复杂仍需扫描索引游标分页信息流、APP 列表任意深度稳定 20ms 级别不支持页码跳转主键区间分页ID 连续、无删除最快删除/空洞时失效从我个人的实际情况看做后台管理系统、报表查询这种需要页码跳转的场景首选延迟关联。做 C 端信息流、搜索下拉、移动端列表优先用游标分页。这两个基本上能覆盖绝大多数业务场景。4. 常见问题与排查技巧实录4.1 分页越翻越慢到底卡在哪一步如果线上系统出现分页越翻越慢的问题先别急着加缓存按照下面的排查顺序走一遍往往能直接定位到根因。第一步EXPLAIN 看执行计划。重点看 type 列和 Extra 列type 是 ALL 说明全表扫描Extra 是 Using filesort 说明排序没走索引。这两项是深分页慢的典型元凶。第二步看 SELECT 的字段。如果SELECT后面是*在深分页场景下会产生大量的随机回表 IO。有时候改成只查询需要的列配合覆盖索引性能提升就非常明显甚至不需要改 SQL 结构。第三步看 OFFSET 大小。如果 OFFSET 已经到了几十万上百万单纯加索引已经到极限了需要直接改用延迟关联或游标分页。第四步看分页参数是否暴露给了客户端。如果客户端传了 page 和 size 两个参数后端直接拼进 SQL那这是一个非常容易被人恶意利用的攻击点。设想一下客户端传一个 page 等于 1 亿size 等于 1 亿整个数据库会被直接压垮。商用系统一定要限制单次最大的 size并且对过大的 page 做拦截或默认封顶。4.2 ORDER BY字段值重复时的分页重复/缺失问题这个问题在真实业务里特别常见。举一个我实际遇到的案例一个订单列表接口按status排序状态只有三个值待支付、已支付、已取消。大量订单状态相同分页查询时第一页返回了订单 A第二页又返回了订单 A同时漏掉了订单 B。用户反馈就是数据乱了一会儿有一会儿没有。排查思路很简单排序字段重复MySQL 在页与页之间的扫描位置是不稳定的。尤其是 InnoDB 的默认隔离级别下多行相同排序值时MySQL 返回哪一行先并不确定。除非排序字段是唯一的否则这个随机性会一直存在且数据量越大越明显。解决方式已经很明确ORDER BY 中补上主键字段主键绝对唯一组合排序后每一行在全局顺序里只有一个确定的位置。这样分页稳定性就完全保证了。SELECT * FROM t_order ORDER BY status, order_id LIMIT 20, 20;4.3 LIMIT配合JOIN时容易被忽略的大偏移问题LIMIT和JOIN组合的时候有个容易忽略的点。MySQL 执行 JOIN 查询可能先把两张表关联之后产生一个很大的中间结果集然后才应用 LIMIT。如果被关联的表数据量很大OFFSET 又很深这个中间结果集可能会大到撑爆内存触发磁盘临时表和大量文件 IO。举个例子你查订单表 JOIN 用户表订单表 500 万行用户表 200 万行直接SELECT * FROM t_order o JOIN t_user u ON o.user_id u.user_id ORDER BY o.create_time DESC LIMIT 100000, 20;排查这类问题时可以直接在子查询层面限定范围再 JOIN 回原表取数据SELECT * FROM t_order o JOIN t_user u ON o.user_id u.user_id WHERE o.order_id IN ( SELECT order_id FROM t_order ORDER BY create_time DESC LIMIT 100000, 20 ) ORDER BY o.create_time DESC;如果只想优化 JOIN 本身也可以先用 LIMIT 截断驱动表数据量再做 JOIN。但要注意驱动表顺序问题MySQL 有自己的一套代价估算逻辑用STRAIGHT_JOIN可以强制指定驱动顺序但通常不建议在生产环境里依赖它。4.4 一个经常被忽略的写法问题LIMIT 8, 20 和 LIMIT 8 OFFSET 20前面讲过双参数形式和 OFFSET 关键字形式参数顺序是相反的这里再强调一次。很多人从不同项目里粘贴代码一会儿LIMIT 8, 20一会儿LIMIT 20 OFFSET 8看起来好像差不多实际含义完全不同-- 跳过8条取20条 SELECT * FROM t_user LIMIT 8, 20; -- 取20条从第8行之后开始等价于上面 SELECT * FROM t_user LIMIT 20 OFFSET 8;两个语法本身都对但写混了就是灾难。我有一个非常务实的建议项目初始化时约定统一用法代码评审时专门盯一下 LIMIT 参数顺序并把这类问题纳入团队质量门禁。4.5 快速排查清单把上面这些常见的坑整理成一张速查表现象可能原因快速验证Recommended Fix分页翻页越深越慢OFFSET 过大扫描丢弃太多行EXPLAIN 看 rows使用延迟关联或游标分页分页结果重复/缺失ORDER BY 字段存在重复值查排序字段是否唯一加主键字段作为次级排序查询超时、服务器CPU飙升排序字段无索引EXPLAIN 看 Using filesort加索引或改写为覆盖索引查询翻到超深页直接久到卡死客户端传超大 page 参数检查传入参数限制 page/size 最大值使用游标分页JOIN 查询内存膨胀LIMIT 先于 JOIN 附加在中间结果集上查看临时表状态先子查询截断再 JOIN这套清单我用过很多次凡是分页相关的性能问题按照这个表逐项排查通常不会漏掉根因。4.6 LIMIT的边界场景补充LIMIT 与 COUNT 的区别还有一个容易混淆的点就是LIMIT和COUNT(*)在统计场景下的区别。有人统计总行数直接用SELECT COUNT(*) FROM t_user LIMIT 5意图是只统计前 5 行。这种写法实际返回的是第一行的统计值而不是统计前 5 行。COUNT(*)是聚合函数它对整个查询结果集做聚合LIMIT只是约束返回给客户端的行数。这两个是完全不同的执行层级不能混用。真正需要统计前 5 行数量只能通过子查询或者 UNION 等方式实现一般业务里没有这种需求。遇到这种需求先和产品确认一下需求本身是否合理大概率是统计口径理解错了。5. 从参数变化看 LIMIT 的边界处理5.1 参数为负数时会发生什么LIMIT的参数可以是变量这就带来了一个容易在动态 SQL 里爆发的边界问题。如果把 -1 传给了 LIMITMySQL 会直接报错ERROR 1064 (42000): You have an error in your SQL syntax如果是LIMIT -1, 10同样报错。这让很多人误以为LIMIT不支持负数参数。MySQL 确实不支持负数偏移量和负数行数这是语法层面的限制。所以后端在接收分页参数时一定要做数字校验负数直接拦截或归一化成 0。我之前见过一个真实案例前端翻页控件传入了负数页码结果后端没有做校验直接把数据查崩了错误日志刷了一整屏。5.2 参数超过 MAX_JOIN_SIZE 或者极大值如果把非常大的数字传进去比如LIMIT 999999999, 10MySQL 不会报错但会尝试做一次超大扫描。这种场景虽然不是常规业务但用来做一个系统性的自我保护很有价值。很多大厂在做分页时都会直接在数据库层面用SQL_SAFE_UPDATES类似的机制或者网关层拦截超大风控参数。对常规业务来说最常用的控制方式还是代码层强制设置if page 10000 { page 10000 } if size 200 { size 200 }这个策略虽然粗暴但非常有效能在源头挡住异常流量。5.3 LIMIT 与 SQL 注入风险分页参数极容易成为注入点。尤其是有些老项目我见过不少这样的代码String sql SELECT * FROM t_order ORDER BY order_id LIMIT offset , size;如果 offset 或 size 从请求参数里直接透传这里就是标准的 SQL 注入入口。攻击者直接把 offset 改成注入 payload整张表的数据甚至整个库都可能被拖走。这个点很多教程不会提但它比性能问题严重得多。正确的做法永远是参数化查询。不管是 JDBC 的PreparedStatement、Go 的?占位符还是 Python 的%s都要把分页参数作为绑定参数传进去绝不能做字符串拼接。另外ORDER BY 后面的字段名如果要客户端传入也需要做白名单校验只允许传入允许列表里的字段名并且不允许拼接语句。ORDER BY 后面不能使用占位符直接绑定字段名所以这块特别容易踩注入坑。6. LIMIT 在不同版本 MySQL 中的表现差异最近帮一个老系统做迁移顺手验证了一下 LIMIT 在 MySQL 5.7 和 8.0 上的差异。有些行为还挺有意思。MySQL 5.7 及更早版本LIMIT的优化器行为固定OFFSET 很大时基本都会走上面的扫描丢弃逻辑没有更好的执行策略。你的优化空间主要在 SQL 写法上也就是我们前面说的延迟关联、游标分页这些。MySQL 8.0 对单表简单分页并没有引入太多革命性变化在深分页场景下依然依赖优化过的执行计划。8.0 有窗口函数可以用ROW_NUMBER()做分页但实测下来窗口函数做深分页在多数场景下反而比LIMIT OFFSET更慢因为它需要整个分区排序。所以我不建议把ROW_NUMBER()当作分页优化手段来使用。MySQL 8.0 的另一个特性是支持 LATERAL 派生表和OFFSET ... FETCH语法SELECT * FROM t_order ORDER BY create_time OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;这个语法是 SQL 标准风格MySQL 从 8.0.16 开始才支持。它的执行逻辑和LIMIT 10 OFFSET 10完全一致只是换了写法。对于从其它数据库迁到 MySQL 的团队用这个语法可以降低迁移成本但性能没有差异。一旦线上实例版本低于 8.0.16这个语法会直接语法报错所以用之前先确认版本。MariaDB 用户要注意MariaDB 对 OFFSET 的支持和 MySQL 也有细节差异。MariaDB 的某些版本还实现了LIMIT rows OFFSET offset的兼容写法但底层优化器行为类似。如果你手头同时维护 MySQL 和 MariaDB 两套环境建议查一下各自的版本文档再决定统一的 LIMIT 写法。我的个人最终建议我把这篇内容里最关键的经验浓缩成几句话。第一个分页 SQL 里 ORDER BY 字段一定补上一个唯一字段这是我在多次线上事故里用血换来的教训。不要觉得可有可无等到用户报告数据重复的时候再补就已经被投诉一轮了。第二个OFFSET 超过 10 万就要警惕超过 100 万基本就是事故前兆。遇到需要翻几十万页的业务需求先跟产品对齐真实意图确认是不是真的需要页码跳转如果只需要上一页下一页直接游标分页把性能拉满。第三个LIMIT 参数的合法性校验一定要做在前端和后端两层。EF Core 这种 ORM 会替你生成分页语句但也正因为如此很多人反而不理解底层到底发生了什么。一旦遇到复杂的深分页场景OR M生成的 SQL 往往不是最优的还是要回到原生 SQL 分析这一层。希望这篇内容能帮你少走一些弯路。如果你看懂了 LIMIT 的真正执行逻辑后面无论是深入学习查询优化还是其它数据库的分页方案都会轻松很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑