资讯详情

资讯详情

MySQL零基础:LIMIT限制结果集与深翻页优化实战

写 MySQL 零基础系列以来被私信问得最多的不是怎么建表而是为什么我的查询越来越慢。排查到最后十有八九是同一件事查询结果集没有限制。SELECT 不带 LIMIT 时MySQL 会把所有命中的行全量捞出来再交给客户端几万条数据就能让页面卡到怀疑人生。这一篇零基础篇十一我们就专门把限制结果集查询这事说透LIMIT 的基本语法、分页公式、深翻页的性能坑以及它在 UPDATE、DELETE、子查询里的各种特殊行为。适合刚学会 SELECT 和 WHERE、想把查询写得更稳更快的初学者也适合日常写业务 SQL 的开发同学拿来查漏补缺。1. 为什么需要限制结果集一个被忽略的常识问题1.1 慢查询的源头往往不是查询本身先抛一个非常常见的场景。假设你负责维护一张订单表里面已经存了 500 万条记录前端页面只需要展示最新的 20 条。如果你写的查询是SELECT * FROM orders;那等于在强迫 MySQL 把这张表里所有数据都翻出来再通过客户端连接一股脑传过去。应用层拿到 500 万行之后代码里才做截断只保留 20 条。这个写法在小练习库里跑没什么感觉表只有几千行时 MySQL 瞬间就能返回。可一旦数据量到百万级同样的语句响应时间直接变成几秒甚至几十秒接口超时、页面白屏、内存溢出全跟着来。很多人一提到查询慢第一反应就是加索引、调参数、换服务器却忽略了最简单、也最前置的一道防线在 SQL 层面把返回行数先卡住。LIMIT 就是 MySQL 为限制结果集提供的最基础答案。它告诉 MySQL我只要前 N 行或者跳过前面的 M 行后取 N 行其余的行你根本不用白费力气去找。这一条语法能挡掉大量无谓的磁盘读和网络传输。1.2 LIMIT 到底限制了什么需要先分清一个概念LIMIT 限制的是最终返回给客户端的结果行数而不是MySQL 内部扫描的行数。这两者经常被混为一谈。SELECT * FROM orders ORDER BY created_at DESC LIMIT 20;如果 created_at 列上没有索引MySQL 很可能还是要先把整张表读一遍做一次文件排序把所有行按时间排好最后才取出前 20 行。也就是说LIMIT 帮你省去了向客户端传输 500 万行的开销但不一定能省掉内部处理 500 万行的开销。理解了这一点再去看后面的性能优化部分就不会迷糊。从实际使用场景来看LIMIT 主要解决三类问题应用层分页网页列表、手机 App 列表一次只加载一页Top N 报表比如销量前 10 的商品、最近 7 天访问量最高的文章资源保护把超大结果集挡在业务逻辑之前防止程序内存被打爆。这三种需求几乎出现在每一个正经项目里。所以 LIMIT 绝不是面试题里的冷门语法而是所有写 SQL 的人每天都要碰的日常工具。2. LIMIT 的基础语法与执行顺序先懂规则再谈优化2.1 两种写法与参数含义LIMIT 的完整语法有两种形式含义完全一样只是写法不同-- 写法一逗号分隔 LIMIT offset, count -- 写法二OFFSET 关键字 LIMIT count OFFSET offset用大白话解释offset 表示跳过多少行count 表示取多少行。看几个例子SELECT * FROM students LIMIT 5; -- 取前 5 行 SELECT * FROM students LIMIT 0, 5; -- 跳过 0 行取 5 行效果同上 SELECT * FROM students LIMIT 5, 5; -- 跳过前 5 行取第 6~10 行 SELECT * FROM students LIMIT 5 OFFSET 5; -- 效果同上语义更清晰零基础阶段最容易犯的错就是把逗号式的前后关系记反。LIMIT 10, 20 不是取 10 行而是跳过 10 行、取 20 行。你可以把 offset 想成翻书时先往前翻了几页把 count 想成这一页展示几条记录。我把常见写法和含义整理成了表格方便对比记忆写法真实含义容易犯的错LIMIT 20取前 20 行无LIMIT 0, 20跳过 0 行取 20 行有人误以为取 20 行再跳 0 行LIMIT 10, 20跳过 10 行取 20 行最容易被当成取 10 行LIMIT 20 OFFSET 10跳过 10 行取 20 行参数位置写反我在实际项目里更喜欢 LIMIT count OFFSET offset 这种写法因为两个参数的职责一眼就能看出来代码评审时也不容易误导别人。2.2 LIMIT 在 SQL 执行顺序中的位置一条 SELECT 语句在 MySQL 内部的执行顺序大概是这样的FROM 确定数据来源、WHERE 做行级过滤、GROUP BY 分组、HAVING 过滤分组、SELECT 计算目标列、DISTINCT 去重、ORDER BY 排序、最后才轮到 LIMIT 截断。LIMIT 是倒数第二个动作只有 ORDER BY 排在它前面。这意味着LIMIT 的截断发生在排序之后所以ORDER BY 加 LIMIT才能实现取排序后的前 N 名LIMIT 不会改变 WHERE 的过滤逻辑它是先过滤、再截断。举个容易踩的例子SELECT * FROM products LIMIT 10 ORDER BY price DESC;这条语句在 MySQL 里居然不会报错因为 MySQL 对关键字顺序的检查比较宽容。但它的真实语义是先取前 10 行再对这 10 行按价格排序和你想的全表价格最高的前 10 个商品差了十万八千里。顺序写反结果全错这是新手最容易踩的软坑——语法没报错数据却不对。2.3 关于 LIMIT 参数的几个边界细节LIMIT 的参数必须是整数而且在普通 SQL 里不能写表达式。像 LIMIT (page - 1) * size 这样的写法会直接报语法错误。正确做法是在应用层把页码换算成数字或者把数值放进变量里再通过预处理语句传参。LIMIT 0 表示返回空结果集MySQL 不会报错。以前有一些老项目会用 SELECT ... LIMIT 0 来测试查询语法是否正常因为 MySQL 会完成语法和元数据校验但不需要真正读取数据行执行极快。这个技巧了解即可现在很少有人这么用了。参数给成负数的情况在 MySQL 8.0 之前会被当成不限制并产生一个警告8.0 之后这种宽松行为被移除直接报错。所以分页接口在接受前端传参时最好对页码和每页条数做一层非负校验免得有人传入 -1 把查询范围搞得不可控。3. 分页查询的正确打开方式LIMIT OFFSET 实战3.1 页码转换公式网页和手机 App 里最常见的分页需求都可以用一个公式解决。假设每页显示 10 条那么第 1 页LIMIT 0, 10第 2 页LIMIT 10, 10第 3 页LIMIT 20, 10规律一目了然LIMIT (page - 1) * pageSize, pageSize。假如用 Java 写大概是这样的逻辑int page 2; int pageSize 10; int offset (page - 1) * pageSize; String sql SELECT * FROM products ORDER BY id LIMIT ?, ?; preparedStatement.setInt(1, offset); preparedStatement.setInt(2, pageSize);公式本身没有魔法但这个做法的前提是结果集顺序必须稳定。如果 ORDER BY 的字段不唯一比如只按 created_at 排而同一秒内产生了大量记录MySQL 内部对这些相同时间戳的记录怎么排是不确定的。结果就是两次查同一页返回的行可能不一样用户会看到数据在跳的诡异现象。解决办法是给排序增加一个唯一性兜底字段通常就是主键SELECT * FROM products ORDER BY created_at DESC, id DESC LIMIT 0, 10;这样每条记录在全排序里的位置是确定的分页结果才稳。3.2 翻页重复与漏数据的坑除了排序不稳定还有一个更容易被忽视的坑一边翻页一边有新数据写入。假设用户正在看第 1 页此时系统插入了一条新记录并且它排序后出现在第 1 页。用户再点第 2 页时因为偏移量还是按旧数据算的新记录会把原来第 2 页的某条记录顶到第 3 页用户直观感受就是看漏了一条。这个现象在 LIMIT OFFSET 分页的机制下很难彻底消除只能根据业务取舍对实时性要求不高的场景比如历史订单列表、文章归档页接受这种近似分页完全没问题对一致性要求高的场景比如消息中心、评论刷新建议换成游标式分页也就是下一节要讲的基于 WHERE 条件的翻页方式。3.3 总数统计与分页组件大多数分页组件都需要总共有多少条所以你通常会看到两条查询SELECT COUNT(*) FROM products WHERE status 1; SELECT * FROM products WHERE status 1 ORDER BY id LIMIT 0, 10;别小看第一条 COUNT()。在 InnoDB 引擎下MySQL 并没有维护一个全局的行数计数器哪怕有 WHERE 条件它也要真的扫码统计一遍。表数据量一大COUNT() 自己就可能成为慢查询。某些高并发的业务干脆不显示总页数改成有没有下一页的判断多查一行也就是 LIMIT pageSize 1如果返回了 pageSize 1 行就说明后面还有内容。移动端加载更多这种交互用这个技巧非常合适省掉一次昂贵的 COUNT。4. 深翻页的性能陷阱为什么翻到最后几页越来越慢4.1 LIMIT 大偏移量的真实开销LIMIT 100000, 20 看起来只是取 20 行MySQL 实际要做的事情是先找到排序后的第 100000 行再把后面 20 行取出来。而为了找到第 100000 行它必须从头开始一路数过去。这就像从图书馆书架的 1000 本书里拿第 1000 本你不能瞬移只能从第一本开始一本一本数。offset 越大扫描和丢弃的行就越多页面越到后面越慢这就是经典的深翻页Deep Pagination问题。有个朋友跟我反馈过类似现象列表接口第 1 页 10 毫秒返回翻到第 10000 页时响应时间涨到了 3 秒。加索引、调缓冲区都没用因为瓶颈根本不在查找快不快而在数行数要数多久。更直观地说LIMIT 1000000, 20 意味着 MySQL 要处理 1000020 行如果每行数据平均 1KB光是被看一眼再扔掉的数据就有 1GB 的读取开销。4.2 覆盖索引与延迟关联先介绍两个基础的优化手段。第一个是覆盖索引Covering Index如果查询只需要 id、名称、价格等少数几个字段可以建立一个包含这些字段的联合索引让 LIMIT 阶段的偏移查找完全在索引里完成避免回表去读完整行。第二个是延迟关联Deferred JoinSQL 写法如下SELECT p.* FROM products p JOIN ( SELECT id FROM products WHERE status 1 ORDER BY id LIMIT 100000, 20 ) tmp ON p.id tmp.id;这个思路的核心是先用一个只查 id 的小结果集完成深翻页的偏移动作再通过 JOIN 把完整行取回来。因为偏移查找针对的是紧凑的索引结构代价比直接 SELECT * 低很多。实测下来百万级数据量的表深翻页耗时经常能降一个量级。配合 (status, id) 这样的联合索引效果会更明显。4.3 游标式分页换一种统计思路如果产品交互允许上一页/下一页的浏览方式而不强制要求随便跳页跳页需求通常只有搜索引擎和后台管理系统才有那更推荐游标式分页。说白了就是把跳过 N 行换成从某一条记录之后开始找SELECT * FROM products WHERE status 1 AND id ? ORDER BY id LIMIT 20;应用层保存上一页最后一条记录的 id下一页查询时把它当条件传进去。这种方式完全不依赖 offset无论翻到多深MySQL 都能用主键索引精准定位起点然后只扫描 20 行性能非常稳定。代价是不能再直接跳到第 30 页而且排序字段必须能充当游标通常是自增主键或者严格递增的时间字段。对比一下两种方式对比项LIMIT OFFSET 分页游标式分页是否支持跳页支持不支持只能顺序翻深翻页性能offset 越大越慢稳定和深度无关实现复杂度低中等需要维护游标数据变动影响可能漏数据或重复更稳定新插入数据干扰小适用场景后台列表、管理页移动端加载更多、消息流实际项目里动态、评论、消息流这类按时间倒序加载更多的场景几乎都是游标式分页而需要页码导航的管理后台才退而求其次用 LIMIT OFFSET。两种方案没有绝对好坏取决于产品交互。4.4 深翻页与 SELECT * 的组合要慎用把 LIMIT 和 SELECT * 放在一起用在深翻页场景是双倍的浪费。SELECT * 会把行的所有列都读出来哪怕 LIMIT 最终只要 20 行前面的 10 万行也要完整读取再丢弃磁盘 I/O 非常吓人。前面说的延迟关联本质就是避免为了 20 行去读 10 万行完整数据。对零基础的读者我的建议是先学会用 EXPLAIN 看执行计划重点关注 type 和 rows 两列。如果 rows 估算值是几十万而 LIMIT 只写了 20基本可以判断这条语句在数据量继续增长后撑不住。优化方向无非是让 WHERE 后面的字段走索引让 ORDER BY 的字段也能借助索引尽量减少回表。5. 零基础容易踩的扩展坑LIMIT 在其他语句中的行为5.1 ORDER BY 与 LIMIT 的经典组合取价格最高的 10 个商品取评分最高的 5 篇文章这类需求在 MySQL 里的标准写法是SELECT * FROM products ORDER BY price DESC LIMIT 10;执行过程是先全表按价格排序再截取前 10 行。注意如果价格相同排序结果里它们的相对顺序是不确定的。为了结果稳定建议末尾补一个 id 排序SELECT * FROM products ORDER BY price DESC, id ASC LIMIT 10;另外还要再强调一次前面提过的顺序问题ORDER BY 必须写在 LIMIT 之前。有些同学手一抖写成 LIMIT 10 ORDER BY price DESCMySQL 不报错语义却从全表最贵的 10 个变成了表里随机前 10 行再排个序。语法没问题数据完全错这种问题在代码评审里最难发现。5.2 UPDATE 和 DELETE 也支持 LIMIT很多初学者不知道MySQL 的 UPDATE 和 DELETE 语句同样支持 LIMIT 子句这一点和其他数据库不太一样。比如DELETE FROM orders WHERE created_at 2020-01-01 LIMIT 1000;意思是删除满足条件的记录时最多处理 1000 行。别小看这个能力。在清理历史数据的大表场景里一次性 DELETE 几十万行会让 InnoDB 产生大量磁盘 I/O同时长时间持有行锁阻塞线上的其他读写。分批删除的标准姿势就是循环执行上面的语句检查受影响行数如果等于 1000 就继续直到某一次受影响行数小于 1000 为止。UPDATE 也是同理UPDATE orders SET flag 1 WHERE status 0 LIMIT 500;一批批地打标记避免一次锁太多行。这个小技巧在数据订正、定时任务里几乎天天用。5.3 子查询里的 LIMIT 限制MySQL 对子查询中使用 LIMIT 有一个历史悠久的限制在 IN、ANY、ALL、SOME 这类子查询中LIMIT 是不能用的。报错信息通常长这样This version of MySQL doesnt yet support LIMIT IN/ALL/ANY/SOME subquery。-- 这段在旧版 MySQL 会直接报错 SELECT * FROM products WHERE id IN (SELECT id FROM hot_products ORDER BY sales DESC LIMIT 3);写法看起来非常合理但确实跑不过。处理办法是改成 JOIN 一个临时结果集SELECT p.* FROM products p JOIN (SELECT id FROM hot_products ORDER BY sales DESC LIMIT 3) t ON p.id t.id;同样的需求用带 LIMIT 的派生表去 JOIN 就没有任何限制。遇到In 子句带 Limit 报错这类问题先往这个方向想。5.4 UNION 与 LIMIT 的小陷阱在 UNION 语句里LIMIT 的位置也有讲究。直接写在最后面作用对象是整个 UNION 合并后的结果集SELECT id FROM t1 UNION ALL SELECT id FROM t2 LIMIT 10;这行语句的 LIMIT 10 是对 UNION 之后的所有行生效而不是对 t1 或者 t2 分别取 10 行。如果想让每个分支各自取 10 行再合并需要加括号(SELECT id FROM t1 ORDER BY id LIMIT 10) UNION ALL (SELECT id FROM t2 ORDER BY id LIMIT 10);括号一加语义立刻不同。这种细节在拼复杂报表 SQL 时尤其容易翻车建议写之前先把LIMIT 到底归谁管想清楚。5.5 动态参数与 Prepared Statement分页参数通常来自用户传的页码实际项目里几乎都会走预处理语句。LIMIT 直接写问号在 MySQL 里有历史限制需要在存储过程或命令行环境里先 PREPARE 再 EXECUTEPREPARE stmt FROM SELECT * FROM products ORDER BY id LIMIT ?, ?; SET offset 10; SET pageSize 10; EXECUTE stmt USING offset, pageSize; DEALLOCATE PREPARE stmt;如果用编程语言的数据库驱动连接 MySQL驱动层一般已经替你处理了参数绑定你只需要把 LIMIT 问号当普通占位符用。但从命令行、脚本或者存储过程里直接写 SQL 时这套流程还是值得了解——它解释了很多 SQL 拼接工具里 LIMIT 参数为什么要单独小心处理也解释了为什么有的场景明明该用问号最后却还是把数字拼进了语句里。好了这篇把 LIMIT 从基础语法讲到了深翻页优化和各类边缘用例。我个人在实际开发里的习惯是任何 SELECT 语句在写出来之前先问自己一句这个结果集有没有可能超过 100 行只要有可能就默认加上 LIMIT 和明确的 ORDER BY任何 UPDATE 和 DELETE先想清楚一条语句最多碰多少行数据。这两个习惯比背一百条语法规则都管用。你在分页或者数据清理时遇到过其他奇怪的 LIMIT 行为吗欢迎在评论区一起聊聊。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →