SQL优化之LIMIT语法:LIMIT n与LIMIT n,m的底层差异与深分页优化
2026/9/18 1:24:15 网站建设 项目流程

SQL优化之LIMIT语法,limit n,m 和 limit n 到底差在哪儿?

先说一下我为什么要专门写一篇LIMIT的文章。前不久在压测一个同事做的报表查询接口,这个接口只按创建时间倒序取最近一条数据,SQL写得很朴素:SELECT ... ORDER BY create_time DESC LIMIT 1。跑起来没有任何问题,毫秒级返回,大家都没在意。但同一天下午,另一个运营后台的分页列表翻到第30多页的时候,页面上那个加载圈转了四五秒,DBA抓出慢查询一看,SQL长这样:

SELECT * FROM order_list ORDER BY create_time DESC LIMIT 700000, 20;

两条SQL都用了LIMIT,一条是LIMIT 1,一条是LIMIT n,m。差距不是几倍,是几千倍。这个对比就是今天这篇博文的引子:LIMIT语法看起来只有一行,但它内部的行为逻辑完全不同,用对了是神器,用歪了就是定时炸弹。这篇文章适合谁看?后端开发、数据分析、还在学校写课程设计的同学都适用,只要你写过带分页的SQL,就值得花十分钟把这里面的底层机制捋一遍。

1. 先把语法擦干净:LIMIT n 和 LIMIT n,m 到底各表达什么

很多人在写SQL的时候其实没认真想过这两个形态的差异。LIMIT n是取前n条,LIMIT n,m是从第n条往后取m条。这个"第n条"是从0开始还是从1开始?偏移量算不算第n条本身?这些问题如果靠猜,早晚会在边界条件上翻车。

1.1 从语义层面拆解

LIMIT n等价于LIMIT 0, n,意思是:从第0条开始(也就是从第一条开始),取n条记录,最多返回n行。

LIMIT n,m的意思是:跳过前n条记录,然后取m条记录。比如说LIMIT 100, 20,表达的就是跳过前100条,取第101条到第120条,一共20条。

这个n在两种写法里的位置特别容易混淆:

  • LIMIT n里,n是"返回的行数"
  • LIMIT n,m里,n是"偏移量",m才是"返回的行数"

所以LIMIT 20LIMIT 0, 20结果完全一样,都是返回前20条。但很多人写成LIMIT 20, 20,本意是想取第21条到第40条,这个语义没问题,问题是他们没意识到这个写法和LIMIT 20 OFFSET 20是等价的——两个参数第一位永远先看偏移量。

-- 下面三种写法结果一致:返回第11条到第30条 SELECT * FROM t LIMIT 10, 20; SELECT * FROM t LIMIT 20 OFFSET 10; SELECT * FROM t LIMIT 0, 20 OFFSET 10; -- 不建议,太绕

还有个容易踩的坑:LIMIT m OFFSET nLIMIT n, m参数顺序是相反的。LIMIT 10, 20是偏移10取20,而LIMIT 20 OFFSET 10同样是偏移10取20。OFFSET这个写法更接近自然语言,所以可读性上更优,但很多老系统里全是LIMIT n,m的遗留写法。

1.2 偏移量的起点是从0开始

这一点必须单独拎出来说,因为它直接决定分页公式怎么写。假设每页20条,第page页(page从1开始)的数据应该怎么写?

-- 正确写法:偏移量 = (page - 1) * pageSize SELECT * FROM t ORDER BY id LIMIT (page - 1) * 20, 20; -- 等价写法 SELECT * FROM t ORDER BY id LIMIT 20 OFFSET (page - 1) * 20;

第一条数据偏移量是0,不是1。如果第1页你写了LIMIT 1, 20,那就从第二条开始取了,第1条数据永远看不见。这个错误在实习生代码里出现频率非常高,甚至在GitHub上搜都能搜到很多公开项目犯这个毛病。

1.3 两种形态的适用场景天生不同

LIMIT n最常见的用途是Top N查询。比如取最近登录的10个用户、取价格最高的前5个商品、判断某个用户是否存在(加LIMIT 1)等。这类查询的特点是:只关心最有代表性的那几条,不关心"翻页"。

LIMIT n,m则是为分页而生的,目的是在完整结果集里切出"第几页"这一段。它隐含了一个前提:你得先能拿到全部符合条件的数据,再从中切页。问题恰恰出在这个"先拿到全部"上,后面第2节会详细讲。

关键结论先放这儿:LIMIT n是"拿够就停",LIMIT n,m是"明明只要m条,却得先跑完全部n+m条再扔掉前n条"。前者是线性开销的一部分,后者是隐藏的线性开销乘数。

2. 执行层视角:多写一个OFFSET,数据库到底多干了多少活

理解了语义,下一步就是看MySQL内部是怎么执行这两种LIMIT的。很多人以为LIMIT只是最后返回前的"截断"动作,执行引擎前期的扫描还是照常扫完的。实际上不是,MySQL在扫描过程中就会实时判断"要不要继续扫",这个判断逻辑才是性能差异的根源。

2.1 提前停止 vs 扫完再扔

对于一个最简单的全表扫描场景(没有WHERE条件、没有ORDER BY):

SELECT * FROM t LIMIT 5,InnoDB从第一条记录开始读,读到第5条就立即停止,不再继续扫剩下的数据。这种机制叫提前终止(early termination),是引擎层面在迭代器里实现的一个很朴素且很有效的优化。

SELECT * FROM t LIMIT 100000, 5,情况完全不同。引擎不知道"第100000条之后"在哪,它必须从第一条开始,一条一条往后数,数到第100005条,然后把前面这100000条全部扔掉,只把最后5条返回给客户端。对于引擎来说,它扫描了100005行,但"有效输出"只有5行,前面100000行的磁盘IO、Buffer Pool访问、行格式解析全部白干。

用一句话概括就是:LIMIT n的扫描成本是O(n),LIMIT n,m的扫描成本是O(n+m),但因为引擎不知道偏移量后面还有没有数据,它必须把偏移量这一段完整扫完,成本随偏移量线性增长,永远不会提前停止。

2.2 排序场景下问题直接放大

如果查询里还有ORDER BY,事情就更麻烦了。

SELECT * FROM t ORDER BY create_time DESC LIMIT 5这种Top N查询,MySQL可以使用优先队列排序(Priority Queue),只需要在内存里维护一个大小为n的堆,边遍历边和堆顶元素比较,最终直接得到前n条。这个优化在优化器里叫"top N sort",内存占用只和n有关。

SELECT * FROM t ORDER BY create_time DESC LIMIT 100000, 5呢?它必须把排序后的完整结果集算出来,然后才能知道第100000条之后是哪5条。这意味着:

  • 如果排序字段能用索引,索引本来就是有序的,扫描部分依然要扫到100005条
  • 如果排序字段没有索引,必须对全表(或大范围)做filesort,把排序结果全部落盘或放内存,再扔掉前100000条

注意一下,ONDER BY加上LIMIT 1这种写法,本质是降级成了一个"找出最大/最小值"的问题,属于可以被索引加速的Top N查询;但一旦偏移量变大,Top N优化基本失效,又退回全量排序的老路。

2.3 explain里的rows字段会告诉你真相

不看执行计划谈性能都是耍流氓。用EXPLAIN看一下这两种SQL,你会很直观地看到扫描行数的差异:

EXPLAIN SELECT * FROM t ORDER BY id LIMIT 10; EXPLAIN SELECT * FROM t ORDER BY id LIMIT 100000, 10;

对于第二种,rows这一列会显示一个很大的估算值,大约在100010左右。这个值越大,MySQL需要访问的行越多,IO成本越高。

但别只盯着rows,还要看Extra列。如果出现了Using filesort,意味着排序无法走索引,这两条SQL都会先产生一个完整的排序结果集。哪怕LIMIT 10那条同样可能产生全量filesort,只是代价相对可控,数据量大了照样慢。

我把这两种形态的执行成本对比整理成一个表格,方便你里面记:

场景LIMIT nLIMIT n,m
无排序扫描约n行后停止扫描约n+m行,丢弃前n行
有排序列索引按索引顺序扫描n行,可提前停按索引顺序扫描n+m行
有排序但无索引可能全量filesort,用堆取前n全量filesort,再切偏移量
内存压力较小,堆排序省内存较大,filesort结果集大
查询耗时曲线基本稳定随n线性增长

3. 深分页现场:当LIMIT偏移量到了百万级别,慢得让人绝望

前面是原理层面的分析,下面给一个具体的压测数据,看看默认分页写法在真实生产环境里会慢到什么程度。

3.1 一个1000万行表的压测记录

我用MySQL 8.0,InnoDB引擎,一张订单表,1000万行数据,表结构大致是:

CREATE TABLE `order_list` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL, `user_id` bigint NOT NULL, `amount` decimal(10,2) NOT NULL, `status` tinyint NOT NULL DEFAULT '0', `create_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB;

查询需求:按创建时间倒序,每页20条,翻到第35000页。

默认写法:

SELECT * FROM order_list ORDER BY create_time DESC, id DESC LIMIT 700000, 20;

实际压测结果:平均耗时3.8秒。这个数字在压测环境还算好的,生产环境如果并发高点、磁盘IO慢点,直接奔着5秒以上去。而如果只查第一页,也就是LIMIT 20,耗时是8毫秒。差了将近475倍。

关键问题来了:同样是查20条数据,为什么偏移量大了之后就差了三个数量级?因为这条SQL完整做了以下几件事:

  1. 从二级索引idx_create_time从右往左扫(倒序);
  2. 对每个索引项,用主键回表去拿order_nouser_idamountstatus这些完整行数据;
  3. 一路扫到第700020个索引项,同时回表700020次;
  4. 把前面700000行数据全部丢弃;
  5. 只把最后20行返回给应用层。

由于create_time有重复值的可能,排序上还需要加id DESC做稳定排序,MySQL在扫描到大量相同的create_time时还要做额外比较,实际成本比理论值更高。

3.2 要命的不是那20条,而是前面的700000条

很多人优化分页时,眼睛只盯着"我要的20条能不能快一点",很少去想"那700000条才是成本大头"。深分页的本质问题在于:MySQL在拿到offset之前,无法跳过前面的行,必须全部扫描+回表一遍。这个成本完全取决于偏移量,和你要取多少条关系不大。

所以在极端场景下,你甚至能看到LIMIT 9990000, 1这种只取一条却要扫一千万行的SQL。对于数据库来说,它这"一条"的成本比很多人想象的贵得多。

另外还有一个容易忽略的点:回表。二级索引里存的只是索引字段和主键值,SELECT * 需要的其他列都在聚簇索引(主键索引)里。每扫描到一个索引项,就要用主键到聚簇索引里再找一次完整行,这就是回表。深分页场景下这种回表不是20次,而是700020次,光这部分的随机IO就能把磁盘拖垮。

3.3 业务隔离:分页不是原罪,无界分页才是

这里得说句公道话:深分页慢,不完全是因为LIMIT语法写得不对,而是分页需求本身在超大偏移量下就是反数据库直觉的。用户真的会翻到第35000页吗?大概率不会。但爬虫、定时任务、数据导出这类无界遍历场景,是真实存在的。

所以在讲优化方案之前,先明确一条准则:能改需求,就先改需求;改不了需求,再谈SQL优化。比如前台产品上限制最多翻100页,比如导出任务放到离线系统用流式游标处理,这种业务侧的截断能力比任何SQL优化都彻底。后面第4节的方案,更多是给"必须支持大偏移量"的接口兜底用的。

4. 三个能打的分页优化方案,按真实场景排序

如果业务就是绕不开大偏移量,下面这三招是我强烈推荐的,每一招都比"加索引"这种空泛建议实在得多。

4.1 延迟关联:先用覆盖索引把主键捞出来,再回表取数据

这是深分页优化里性价比最高的一招。核心思想是:别让数据库带着SELECT * 的所有列去深海里捞鱼,先派一个轻量级的侦察兵把主键捞出来,再用主键精准回表拿完整数据

侦察兵SQL长这样:

SELECT id FROM order_list ORDER BY create_time DESC, id DESC LIMIT 700000, 20;

这个查询只查idcreate_time两个字段,而idx_create_time这个二级索引里恰好包含了这两个字段。也就是说,执行这个查询的时候MySQL只需要扫二级索引,不用回表,扫描700020个索引项的成本比扫描+回表700020个完整行低一个量级。

拿到这20个主键id之后,再join回原表拿完整数据:

SELECT t.* FROM order_list t INNER JOIN ( SELECT id FROM order_list ORDER BY create_time DESC, id DESC LIMIT 700000, 20 ) tmp ON t.id = tmp.id ORDER BY t.create_time DESC, t.id DESC;

实测下来,前面那个3.8秒的查询,用延迟关联改写后能压到0.6秒左右。提升还是很大的。

这里有个小细节必须注意:内层查询LIMIT出来的顺序,在join之后不一定能保持。你必须在外层也写上同样的ORDER BY子句,否则翻页顺序会乱。很多人改写完之后发现数据顺序不对,就是漏了这一步。

4.2 基于主键的游标分页:彻底告别OFFSET

延迟关联能解决一部分问题,但它不是万能的——偏移量特别大的时候(比如百万条以上),哪怕只扫二级索引,扫描成本也很可观。这时候我就建议换一种分页思路:不做偏移量分页,做游标分页

原理很简单:你既然能拿到上一页最后一条数据的id,那下一页就直接从它后面开始查:

-- 第一页:取20条 SELECT * FROM order_list ORDER BY create_time DESC, id DESC LIMIT 20; -- 第二页:假设上一页最后一条记录的id是100200 SELECT * FROM order_list WHERE (create_time < '2025-06-01 10:00:00') OR (create_time = '2025-06-01 10:00:00' AND id < 100200) ORDER BY create_time DESC, id DESC LIMIT 20;

这里的WHERE条件用了一个(create_time, id)的联合游标来避免跳数据和重复数据。由于id是主键、create_time是二级索引,如果建一个(create_time, id)的联合索引,这个查询可以直接走索引定位,引擎从游标位置开始扫20条就停,扫描行数恒等于20,跟翻到第几页完全无关。

这个方案的唯一限制是:它不支持"跳页"。用户想从第1页直接跳去第100页,不给偏移量,游标分页做不了。所以它特别适合无限滚动、信息流这种只能一页页往下刷的场景。而且改造成本很低,前端只需要记住上一页最后一条记录的游标值传回来即可。

4.3 加索引能不能救深分页?一半能,一半不能

"给排序字段加个索引不就行了"——这个建议我经常在群里看到,但它只答对了一半。

索引能解决的是排序成本。如果ORDER BY字段没有索引,MySQL要filesort;加了索引之后,引擎可以按索引顺序直接扫描,省掉排序这一步。我们前面4.1的延迟关联能跑得快,很大程度也是因为create_time有索引,否则内层查询还得全量filesort,虽然不用回表,排序本身仍然贵。

但索引救不了"从第一条开始数偏移量"这个本质行为。哪怕排序字段有索引,LIMIT 700000, 20依然要沿着索引扫700020个索引项。索引让每次扫描更快,但扫描的次数没变,大偏移量依然是大偏移量。

再说句实在话:有些排序字段加了索引也没用。比如按amount排序分页,如果amount索引的区分度不高、或者查询里还有复杂的WHERE条件走不了这个索引,排序照样回落到filesort。索引不是银弹,它是必要不充分条件。

5. 容易被忽略的细节:ORDER BY、覆盖索引与LIMIT 1的微妙关系

很多人写完分页SQL就跑了,根本没认真想过排序和LIMIT在优化器里是联动处理的。这一节说三个高频但容易被忽略的细节。

5.1 ORDER BY + LIMIT 1 的特殊意义

LIMIT 1在优化器里是个特殊存在。对于"取最大值/最小值"这类需求,优化器能走索引直接定位到边界的首条记录,几乎零成本。

-- 取最新一条,利用索引天然降序 SELECT * FROM order_list ORDER BY create_time DESC LIMIT 1;

如果create_time有索引,这条SQL的扫描行数严格等于1,引擎只要找到索引最右边那条记录就停了,根本不回看其他任何记录。这也是为什么开头提到同事那条LIMIT 1的报表SQL毫秒级返回的原因。

但如果把LIMIT 1换成LIMIT n,在无索引排序场景下,引擎就需要在filesort里维护一个大小为n的优先队列,n越大堆操作越多。虽然比全量排序好,但也不是零成本。所以如果业务只需要"最新一条"或者"是否存在",那就明确写LIMIT 1,别写LIMIT 10然后让代码自己取第一条——这既浪费数据库资源,也会让查询时间悄悄变长。

5.2 覆盖索引的含义:连回表都省了

前面延迟关联的核心就是覆盖索引,这里把概念展开讲清楚。所谓覆盖索引,指的是查询所需的全部字段都包含在某个索引里,这样引擎只扫描索引,不用回表拿其他列。

举个实际场景。如果订单表经常要做这样的分页查询:

SELECT id, order_no, create_time FROM order_list ORDER BY create_time DESC LIMIT ?, ?;

如果只建idx_create_time,那引擎扫描索引拿到idcreate_time之后,还得因为order_no回一次表。但如果你把索引改成idx_create_time_order_no (create_time, order_no),这个查询的id(主键)、create_timeorder_no都在索引里,引擎完全不用回表,直接在索引覆盖范围内完成扫描和裁切。

覆盖索引优化的关键注意事项是:别把所有字段都塞进索引。索引也是数据,维护它也有成本,字段越多占用磁盘越大,写入越慢。一般只在查询频率极高的分页接口上,把固定查询的SELECT字段精准塞进联合索引。

5.3 LIMIT 1在EXISTS/IN场景里的优化价值

LIMIT 1不止能用在主查询,在子查询里也是个优化利器。经常有人写这样的SQL判断"某用户是否有下单":

SELECT * FROM user_info u WHERE EXISTS ( SELECT 1 FROM order_list o WHERE o.user_id = u.id );

如果order_listuser_id有索引,MySQL在执行EXISTS的时候会自动做半连接优化,不需要显式加LIMIT。但某些复杂查询下,优化器不一定能识别出"只需要判断存在性",这时候你可以在子查询里手动加LIMIT 1,帮助优化器理解"只要找到一条就可以停了":

SELECT * FROM user_info u WHERE EXISTS ( SELECT 1 FROM order_list o WHERE o.user_id = u.id LIMIT 1 );

这条SQL的执行计划里,order_list的访问会变成"扫描到第一条匹配就停止",扫描行数从可能的上千行直接降到1行。写不写LIMIT 1对整个查询的开销差异很大,尤其在order_list数据量大的时候。

6. 实战中我踩过的坑,和现在的分页落地习惯

最后一部分,分享几个真实的翻车现场和我整理的分页检查清单,算是给这篇文章收个尾,也都是能用得上的经验。

6.1 三个真实的翻车现场

第一个翻车现场:LIMIT参数传负数。有些后台系统让用户自定义每页条数,前端没做校验传了个-1过来,SQL变成了LIMIT -1, 20,MySQL直接报错。不要指望数据库帮你做参数校验,分页参数的合法性必须在上游就拦住。

第二个翻车现场:LIMIT的偏移量超过了INT上限LIMIT 2147483648, 20这种写法在老版本的MySQL里会直接报错或者产生不可预期行为,因为偏移量被当作有符号整数处理。现在的8.0版本对超大偏移量的支持要好一些,但这种查询除了等着超时没有任何意义,业务阈值必须卡死。

第三个翻车现场:分页接口的所有参数都能被用户传进来,包括排序字段。比如用户传了个ORDER BY amount DESC,而amount没有索引,整个分页查询就变成全表filesort。更严重的是,如果排序字段可以被注入成任意表达式,这并不是传统SQL注入,但也可能拖垮数据库。分页接口的排序字段最好是白名单制,后端代码里写死允许排序的字段列表。

6.2 我现在的分页接口测试清单

每次写分页接口,我都会在测试环境过一遍下面这组场景,测完基本心里就有底了:

检查项期望结果
第1页、第2页、中间页数据是否有重叠/遗漏无重复,无跳变
最后一页不足pageSize时是否正常返回返回剩余行数,不报错
page填0、负数、null拒绝请求或按第1页处理
pageSize填0、超大数据拒绝或限制上限
排序字段传不存在的列直接报错或返回固定排序
并发翻页时有新数据插入允许轻微顺序抖动,不得报错
大数据量下翻到最后一页延迟可接受或改用游标分页

还有一点:写分页SQL之前,养成EXPLAIN看一眼的习惯。重点看三个地方:type是不是range/ref/eq_ref而不是ALLrows估算值是不是跟你预期的扫描范围匹配;Extra里有没有Using filesort。这三项全绿,这条SQL基本不会翻车。

6.3 一句话版本的LIMIT分页口诀

我在团队内部经常用一句话总结今天的全部内容:限制返回行数用LIMIT n,分页用LIMIT n,m但要警惕大偏移量;大偏移量分页优先游标,游标不可行就延迟关联,延迟关联之前先确认索引覆盖了你需要的一切

回到文章开头那个报表SQL,LIMIT 1LIMIT 700000, 20之间的差距,本质上不是语法写法不同,而是"快速拿到边界"和"遍历整个历史数据再扔掉"的差距。希望这篇东西能让你在下次写分页SQL的时候,多想一想那句老话:数据库不是不能干活,但你别让它在OFFSET的海里白游一公里只为了捞20条鱼。

最后给个我个人的经验:如果业务里真的有大偏移量分页需求,先去找产品经理聊一聊这个需求存在的意义。多数时候,"翻到第几万页"并不是真实的用户路径,它只是某种导出任务或者定时任务被硬生生塞进了同步接口里。这种问题,架构和产品上的收敛,永远比SQL上的调优更划算。

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

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

立即咨询