1. 先搞清楚 Limit 到底解决什么问题
干了这么多年后端,我几乎每天都在跟 SQL 打交道。说句实话,Limit 是我用得最频繁、也最容易被忽视的一条子句。很多人觉得它不就是“取前几条数据”嘛,没啥可讲的。但真到了分页慢、取数不对、内存爆炸的时候,往回一查,大概率都是 Limit 用得不够讲究。
Limit 子句的核心作用是对查询结果集做行数约束。它解决的问题非常朴素:数据量一大,数据库不可能、也不应该把全部结果一次性返回给应用层。举个生活中的例子,你查快递物流记录,一次查一年的单子,好几千条,人眼根本看不完,也没必要全部加载到前端页面。Limit 就是把这条“结果管道”的出水口收小,只放前 N 条出来。
它的适用面极广:排行榜取 Top 10、列表分页、抽样检查、批处理任务分批捞数据、删除或更新时限制操作范围……几乎每一个正经业务系统里都能看到它的影子。适合谁读?如果你是刚接触 SQL 的初学者,这篇可以帮你把 Limit 的用法彻底吃透;如果你已经写了两三年 SQL,我也建议你花几分钟看看后面优化的部分,有些坑真的只有踩过才会懂。
有一点必须在开头说清楚:Limit 不是 SQL 标准语法里的通用功能,它属于各数据库方言的一部分。也就是说,MySQL 支持LIMIT offset, count,SQL Server 不支持,PostgreSQL 支持但又跟 MySQL 略有不同。所以你写 SQL 的时候,先确认自己面对的是什么数据库,再决定怎么写。这篇文章我以 MySQL 为主讲透细节,最后单开一节把主流数据库的差异和替代方案理一遍。
2. Limit 的基础语法与执行逻辑
2.1 两种标准写法
MySQL 里的 Limit 有两种写法,我直接展示。
第一种是只限制返回行数:
SELECT * FROM orders LIMIT 10;含义很简单:从结果集的第一行开始,最多返回 10 行。
第二种是带偏移量的写法:
SELECT * FROM orders LIMIT 10, 20;这里注意,MySQL 这种写法里,第一个参数是偏移量(跳过多少行),第二个参数是返回的行数。LIMIT 10, 20表示跳过前面 10 行,从第 11 行开始取 20 行。
同样功能的另一种等价格式是:
SELECT * FROM orders LIMIT 20 OFFSET 10;这种写法的可读性更好,OFFSET关键字明确告诉你偏移量是多少。如果说LIMIT 10, 20还能让人愣一下到底哪个是数量哪个是偏移,那LIMIT 20 OFFSET 10基本不会产生歧义。
我个人的习惯是:能写OFFSET就写OFFSET。尤其是团队协作的项目里,代码是给人看的,多写几个字母换来的是少几次误解,值。
2.2 执行顺序和内存表现
要理解 Limit 的性能问题,必须知道它在 SQL 执行流程中的位置。一条查询的完整逻辑执行顺序大致是这样的:
FROM -> WHERE -> GROUP BY -> HAVING -> ORDER BY -> LIMITLimit 是最后一步才发生的。这里藏着两个关键点:
第一,Limit 作用于已经排好序的结果集之上。如果查询带有ORDER BY,数据库必须先把所有满足条件的数据排好序,然后才轮到 Limit 砍掉一部分。这个“先排序后截断”的逻辑,是大数据量下分页慢的根本原因。
第二,Limit 并不等于只读 N 行。在很多执行计划里,数据库可能会扫描比 N 多得多的行数才能确定最终结果。极端情况下,比如LIMIT 100000, 10,数据库得从结果集里先“读走”前面的 100000 行,再取后面的 10 行。虽然这些被跳过的行最终不会返回给客户端,但扫描和排序的开销一点都不会少。
有个经典的比喻:Limit 分页像是去一本厚书里找第 10001 页到第 10010 页的内容。你可以直接翻到第 10001 页看十页,但如果这个“翻到”的动作本身是从第 1 页开始一页页数的,那前 10000 页的时间成本无论如何都省不掉。
2.3 动态构造 Limit 参数的注意点
很多情况下,Limit 的数值并不是写死的,而是由应用程序传进来的。比如前端传了一个pageSize和pageNow。
比较差的写法是直接拼 SQL 字符串,注入风险先不说,遇到非数字输入还会引发语法错误。比较稳妥的做法是在应用层做参数类型强制转换,把pageSize转成整数,并且加上合理的上限保护。
我在项目里通常这么处理:
const pageSize = Math.min(parseInt(req.query.pageSize) || 20, 100); const pageNow = Math.max(parseInt(req.query.pageNow) || 1, 1); const offset = (pageNow - 1) * pageSize;这样做的目的有两个:一是防止pageSize被传成 100000 这种恶意值导致数据库压力过大,二是保证offset永远是非负整数。Limit 的参数一旦是负数,在 MySQL 里会直接报错,这个坑后面排查篇里会详细讲。
3. 分页查询:Limit 最核心的应用场景
3.1 经典分页公式和写法
分页是个永恒的话题。先说公式,假设每页显示pageSize条数据,当前页码从 1 开始计数:
offset = (pageNow - 1) * pageSize对应的 SQL:
SELECT * FROM products ORDER BY id DESC LIMIT pageSize OFFSET offset;举个实际例子。每页 20 条,第 3 页对应的偏移量是 40,SQL 就是:
SELECT * FROM products ORDER BY id DESC LIMIT 20 OFFSET 40;这个公式本身非常简单,难点在于它背后的性能退化问题。
3.2 深分页性能问题的根因与优化
随着页码越来越深,Offset 越来越大,这条 SQL 会越来越慢。我见过一个实际案例,某个后台管理系统的订单列表,数据量到了几十万行,翻到第 5000 页的时候,接口响应时间从开始的几十毫秒一路涨到了十几秒。
深分页慢的根因上面说过:数据库要先把offset + pageSize行数据都找出来,然后丢掉前 offset 行。这个“找出来”的过程可能涉及扫描大量无效行和排序,成本自然高。
针对这个问题,行业内常用的优化手段有三种。
第一种是延迟关联。先通过覆盖索引查出目标行的主键,再回表取完整数据。
SELECT * FROM products p INNER JOIN ( SELECT id FROM products ORDER BY id DESC LIMIT 20 OFFSET 40000 ) t ON p.id = t.id ORDER BY p.id DESC;这个方案的思路是,子查询只查主键列,完全不碰那些大字段(比如商品描述、图片 URL),扫描的数据量小得多。等拿到了这 20 个主键,再用主键精准回表,最终结果就是 20 行完整数据。实测下来,在深分页场景下这种写法通常能比直接 Limit 快一个数量级。
第二种是基于上一页最大 id 的游标分页。
SELECT * FROM products WHERE id < 40001 ORDER BY id DESC LIMIT 20;前提是你正在按id降序排列。客户端拿到上一页最后一条数据的 id,下一次查询直接把这个 id 传回来,作为过滤条件。这种写法完全不需要 Offset,数据库顺着索引扫到 40001 就停,性能非常稳定。无论你翻到第几页,耗时都基本一致。
这个方案本质上是把“页码”换成了“游标”。它有个明显缺点:不能随机跳页,只能一页一页往下翻。但大多数移动端、Web 端的“加载更多”场景根本不需要跳页,游标分页是真香。我以前优化过一个消息流列表,从 3 秒降到 50 毫秒,就是这个思路。
第三种是禁用深页码。产品上直接限制用户只能翻到前 100 页。很多大厂的后台系统就是这么干的,因为这既符合用户习惯,又能从源头上掐掉深分页问题。
3.3 分页总数 Count 查询的隐患
做分页的接口,通常还要返回总条数,前端才能画出总页数。于是又多了一条COUNT(*)的查询。
SELECT COUNT(*) FROM products WHERE status = 1;这条查询本身可能不慢,但如果和列表查询并行执行,多一次全表扫描或索引扫描,响应时间会叠加。数据量到千万级时,COUNT可能要几百毫秒,整体接口就变慢了。
对于这个问题的处理取决于业务场景。中小数据量下,直接COUNT就行了,不需要过度设计。大数据量下,我有两个常用方案:一是用单独的统计表,每次写入增删数据时同步维护总数字段;二是用近似估值,很多大系统的分页数据根本不显示精确总条数,只显示“共约 10 万余条”。后者能省掉一次实时计算,代价是用户看到的数据没那么精确,但体验几乎无差。
4. Limit 的进阶玩法:Top-N 分析、抽样与写操作
4.1 Top-N 查询的典型场景
Limit 不只是分页用,它另一个高频场景是取 Top-N。所谓 Top-N,就是从数据集里按某个排序规则取前 N 条。
最大的 Top-N 场景有两个:排行榜和最新动态。
排行榜的例子:
SELECT user_id, SUM(score) AS total_score FROM game_records WHERE game_date >= '2024-01-01' GROUP BY user_id ORDER BY total_score DESC LIMIT 10;这段 SQL 的作用是拿出本年度积分最高的前 10 名玩家。注意这里LIMIT 10是在ORDER BY之后执行的,所以它取的是排序后的前 10 行,而不是随机 10 行。这个执行顺序必须牢牢记住,很多人写错 Top-N 就是因为搞混了ORDER BY和LIMIT的执行先后。
最新动态的典型写法:
SELECT title, created_at FROM articles ORDER BY created_at DESC LIMIT 5;这个就是首页常见的“最新 5 篇文章”接口。
4.2 每组取 Top-N:窗口函数与 Limit 的配合
业务里还有个更难的需求:按组取 Top-N。比如每个分类下评分最高的 3 件商品。这个用单纯 Limit 做不了,需要先给数据编排名,再过滤。
使用窗口函数配合子查询:
SELECT category_id, product_name, score FROM ( SELECT category_id, product_name, score, ROW_NUMBER() OVER ( PARTITION BY category_id ORDER BY score DESC ) AS rank_num FROM products ) ranked WHERE rank_num <= 3;这里ROW_NUMBER()负责在每个分类内部按分数降序编号,外层过滤只保留前 3 名。这种写法兼容 MySQL 8.0、PostgreSQL、SQL Server、Oracle,是标准 SQL 能力范围内的方案。在 MySQL 8.0 之前不支持窗口函数,那就只能用用户变量或三层嵌套子查询,写法非常绕,我早期写过,体验很差,后来版本升级到 8.0 直接换掉了。
4.3 随机抽样取数
有时候需要从表里随机取几条数据做测试或抽样审核。不同数据库的写法不太一样,MySQL 的写法很耿直:
SELECT * FROM users ORDER BY RAND() LIMIT 5;这种写法扔进生产环境要当心。ORDER BY RAND()会让数据库对每一行生成随机数,然后整体排序,数据量一大直接爆炸。10 万行数据可能还能忍,百万行以上这条 SQL 发起查询时,DBA 会想打人。
改进方案有几种。
数据量大但主键连续时,可以先生成一个随机偏移量再去取:
SELECT * FROM users WHERE id >= FLOOR(RAND() * (SELECT MAX(id) FROM users)) LIMIT 5;这个方案基于一个假设:id 分布相对均匀且连续。如果主键有大量删除空洞(比如删过很多用户),取出来的数据会偏向某个区间,但有胜于无。
更稳的做法是表里加一个随机排序的辅助列,或者用程序语言预先生成若干随机主键再查询。像 Python 里可以先random.sample(range(1, max_id), 5)取出 5 个 id,再执行:
SELECT * FROM users WHERE id IN (...)配合上索引,性能比ORDER BY RAND()好得多。
4.4 Limit 用于 UPDATE 和 DELETE 的限制范围
Limit 不仅可以配合 SELECT,还能用在 UPDATE 和 DELETE 上(MySQL 支持)。
批量删除的场景,每次只删一小批,避免长事务锁表:
DELETE FROM logs WHERE created_at < '2024-01-01' LIMIT 1000;这段 SQL 的含义是:删除符合条件的前 1000 行。你可以把它放在一个循环里反复执行,直到影响行数为 0。这种方式比一次性DELETE几百万行要安全得多,不会造成大事务长时间持有锁,也不会把 binlog 撑爆。
批量更新同理:
UPDATE coupons SET status = 2 WHERE status = 1 LIMIT 500;这个用法在后端定时任务里很常见。一批一批地更新,每批之间稍作停顿,给数据库喘口气。
需要注意两点。第一,UPDATE和DELETE的 Limit 在 MySQL 里不支持偏移量,也就是说你不能写DELETE ... LIMIT 10, 20,只能限制处理的行数。第二,在没有明确ORDER BY时,被选中的到底是哪几行是不确定的。如果业务上对处理顺序有要求,一定要配合ORDER BY一起用:
DELETE FROM logs WHERE created_at < '2024-01-01' ORDER BY created_at ASC LIMIT 1000;这样至少保证每次删除的是最早的那批数据,语义清晰,可预测。
5. 各数据库 Limit 语法差异对照
这个问题你在网上随手一搜,能搜到一堆零散的说法,但很少有一篇把差异讲得明明白白。我把主流数据库的情况整理了一下。
5.1 MySQL / MariaDB / PostgreSQL / SQLite
这四家的基础语法非常接近,都能用LIMIT count OFFSET offset这种标准式写法。
-- MySQL / MariaDB / PostgreSQL / SQLite SELECT * FROM table_name ORDER BY id LIMIT 10 OFFSET 20;MySQL 和 MariaDB 还额外支持简化版的LIMIT offset, count:
-- 仅 MySQL / MariaDB SELECT * FROM table_name LIMIT 20, 10;PostgreSQL 和 SQLite 认第二种写法,也会报错,因为它们只接受LIMIT count OFFSET offset。写代码的时候要记住这个差异,尤其是在做多数据库兼容的框架时,统一用标准写法最稳。
5.2 SQL Server
SQL Server 的情况要单独说。早先版本的 SQL Server 没有 Limit,实现分页用的是ROW_NUMBER()加子查询,写法非常繁琐:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS row_num FROM table_name ) t WHERE row_num BETWEEN 21 AND 30;SQL Server 2012 之后引入了OFFSET ... FETCH语法,这其实是标准 SQL 的写法:
SELECT * FROM table_name ORDER BY id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;注意两点。一是 SQL Server 里OFFSET ... FETCH必须跟着ORDER BY,没有ORDER BY直接写会被拒绝。二是FETCH不能单独使用,它的前提是已经有OFFSET了,所以如果只想取前 10 条,写法是:
SELECT * FROM table_name ORDER BY id OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY;5.3 Oracle
Oracle 经典的写法是ROWNUM伪列:
SELECT * FROM ( SELECT t.*, ROWNUM AS rn FROM table_name t ) WHERE rn BETWEEN 21 AND 30;这个写法有个大坑:WHERE ROWNUM <= 10没问题,但WHERE ROWNUM BETWEEN 21 AND 30是不行的,因为ROWNUM是在行被返回时依次编号的,大于 1 的条件在编号还没成立时就已经把行排除了。所以必须先用子查询把 ROWNUM 固定下来,再在外面做范围过滤。
Oracle 12c 之后也支持了OFFSET ... FETCH,写起来跟 SQL Server 几乎一样:
SELECT * FROM table_name ORDER BY id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;5.4 兼容性对比速查表
| 数据库 | 基础写法 | Offset 写法 | 对 UPDATE/DELETE 支持 |
|---|---|---|---|
| MySQL / MariaDB | LIMIT 10 | LIMIT 10 OFFSET 20或LIMIT 20, 10 | 支持,不能带偏移量 |
| PostgreSQL | LIMIT 10 | LIMIT 10 OFFSET 20 | 不支持 |
| SQLite | LIMIT 10 | LIMIT 10 OFFSET 20 | 不支持 |
| SQL Server | SELECT TOP 10 * | OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY | 不支持 |
| Oracle | FETCH FIRST 10 ROWS ONLY | OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY | 不支持 |
在这里单独提一下SELECT TOP。SQL Server 和 SQL Server 以外的某些数据库(比如老版本的 SQL Server、Access、Sybase)用SELECT TOP 10来取前 N 条。这个写法不能配 Offset,只解决“取前 N 条”的需求。
如果你想做兼容多数据库的底层查询封装,结论很明确:优先用OFFSET ... FETCH这种标准 SQL 语法,这是各个主流数据库新版本共同支持的方向。老系统实在改不了,再考虑各库方言。
6. 性能优化与潜藏陷阱
6.1 Order By 字段的索引匹配问题
Limit 的优化几乎都和索引有关。最基本的准则是:ORDER BY的字段必须能利用索引,否则深分页就是灾难。
以一个常见查询为例:
SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 10;最理想的索引是组合索引(user_id, created_at)。这样数据库可以先按 user_id 定位到该用户的订单,再沿着已经按 created_at 排好的索引顺序取前 10 条,完全不需要额外排序。执行计划里出现Using index condition或Backward index scan都是正常的快速路径。
如果只建立了user_id单列索引,数据库就需要把该用户的全部订单查出来,在内存或磁盘上做一次排序,再取前 10 条。数据一多,临时文件排序(filesort)就会出现,性能断崖式下跌。
判断是否需要组合索引,有个简单标准:看你的WHERE条件列和ORDER BY列是否各自独立。独立的话,就值得建一个包含两者的组合索引。这个组合索引对所有“按用户查最新列表”的场景都有效。
6.2 LIMIT 100000, 10 为什么特别慢
这背后的道理前面已经解释过,我再补充一个实际执行的细节。
你写:
SELECT * FROM comments ORDER BY id LIMIT 100000, 10;数据库内部的执行大致是这样:扫描索引,从第一行开始数,数到第 100000 行,继续往下读 10 行,然后返回这 10 行。那前 100000 行真的被“读了”,只是被丢弃了。如果表数据在磁盘上是分散存放的,每一行都可能触发一次随机 I/O,那就相当于白白做了 10 万次磁盘访问。不慢才怪。
优化方式在分页一节里聊过,这里再给一个基于WHERE id > 上一页最大值的完整示例。假如上一页最后一条记录的 id 是 100005:
SELECT * FROM comments WHERE id > 100005 ORDER BY id LIMIT 10;没有 Offset,没有深度跳页,每次查询都是走主键索引继续往后扫 10 条。只要客户端能记住上一页的位置,这种写法在任何数据量下都能保持稳定的毫秒级响应。
6.3 Limit 参数非法值和溢出问题
写程序久了就会明白,用户传上来的任何值都不能信。Limit 参数如果处理不当,轻则报错,重则拖垮数据库。
构造异常的位置主要有三个:
第一,pageSize被传为负数。MySQL 会返回You have an error in your SQL syntax之类的错误。应用层如果没做异常处理,接口直接 500。
第二,offset被传为极大值。比如某条恶意请求把 offset 传成2000000000,数据库会尝试跳过 20 亿行。虽然结果集没有这么多行,最终返回空列表,但这次查询可能让数据库扫描很久,CPU 直接飙高。
第三,pageSize被传为 0。LIMIT 0在语法上合法,返回空结果集,但如果你在程序里拿它做除法或者参与后续逻辑,可能引发别的问题。
针对这些情况,我在后端接口里统一做了参数收敛。具体的处理策略前面 2.3 节写过,核心原则是:上限封顶,下限兜底,非法值取默认值。凡是前端能把页大小传到 200 的这种接口,背后一定有个没做限制的后端接口在裸奔。
6.4 Limit 与 COUNT(*) 的组合拳
分页接口经常需要同时返回总条数和当前页数据。很多新人会写成两条独立查询,这没问题,但如果表很大,每次都执行COUNT(*)就不行。
我处理过一张 800 万行的订单表,WHERE 条件命中 300 万行,列表查询优化后只有 30 毫秒,但COUNT(*)还是得扫 300 万行索引,耗时 600 多毫秒。整页接口就变成了 630 毫秒,用户体验明显卡顿。
方案根据业务容忍度来选:
如果是总数据量小,直接COUNT(*)没问题。如果是量大且条件复杂,考虑加缓存。缓存失效策略可以简单一点:每次发生 INSERT、UPDATE、DELETE 时清掉对应查询条件的缓存。
如果是量大且条件固定,可以考虑用“汇总表+定时计算”的方式。每天凌晨算一次总量,写入统计表,页面展示时读取汇总值。缺点是数据实时性差。
这里想强调一个思路:页面展示的“总条数”不一定要精确。很多 C 端产品的列表页,底部显示的是“共 12.6 万条”,而不是“共 126342 条”。如果你也负责这类系统,可以考虑对超大数据量的统计做近似处理,把系统压力省下来,用户体验反而没区别。
7. 常见问题与排查技巧实录
这里把我实际开发中遇到过的典型问题整理出来,按“现象—原因—解法”的方式写。你碰到类似问题可以直接对照排查。
7.1 分页重复或漏数据
现象:用户反馈翻页时,某些数据重复出现了,或者某条数据一直没看到。
原因:排序字段不唯一。比如你只按created_at排序,同一天内有很多条数据,数据库在不稳定排序的情况下可能返回不同的顺序。第一页取了某几条,第二页再查时,顺序变化导致数据错位。
解法:排序时增加一个唯一字段做次级排序。最稳妥的方案是ORDER BY created_at DESC, id DESC。id 是主键,天然唯一,这样排序结果就是严格确定的,翻页稳定。
这同时也解释了为什么很多分页查询都带主键排序:不仅是性能偏好,更是数据一致性要求。
7.2 Limit 0 引起的循环死锁
现象:批处理脚本一直在跑,但数据没减少,日志里没有任何报错,就是不死不活。
原因:有可能脚本写的是先查符合条件的记录数,如果大于 0 再删除。LIMIT 0返回了空集,但程序里判断用的是“影响行数”而不是“是否存在记录”,误以为还有数据,一直执行。
解法:检查循环退出条件。删除的循环必须以“本次影响行数是否大于 0”为退出依据,而不是以“查询结果是否为空”为依据。多出来的一条SELECT COUNT(*)反而成了隐患。
7.3 Group By 后 Limit 取的不是想要的数据
现象:想查每个用户的最近一条订单,写了:
SELECT user_id, order_id, created_at FROM orders GROUP BY user_id ORDER BY created_at DESC LIMIT 10;结果数据看着没问题,但仔细一核对,某些用户取出来的不是他最新的订单。
原因:MySQL 开启ONLY_FULL_GROUP_BY的情况下,上面这种写法在 select 列表上会直接报错;如果没开启,取出的非聚合列是“随机选择的”。哪怕在 5.7 之前的版本里,这个“随机”也完全不可控。
解法:正确的写法是用窗口函数或者子查询先标记排名,再过滤。参考 4.2 节的做法。简单说就是ROW_NUMBER()OVER (PARTITION BY user_id ORDER BY created_at DESC) = 1,再取外层数据。
7.4 Order By 不生效、Limit 数量不对
现象:写了ORDER BY id DESC LIMIT 10,但返回的结果顺序完全不对,或者返回条数不足 10 条。
原因排查顺序:
第一步看ORDER BY的列是否加了索引。如果列上有函数操作,比如ORDER BY DATE(created_at),索引会失效,排序可能改成 filesort,但顺序仍然是对的。
第二步看是否多表联查。多表关联时,LIMIT对最终结果生效,但如果你用了LATERAL或某些特殊 JOIN 类型,语义会出现变化。这类问题少见,但遇到后很难一眼看出。
第三步看是不是 Offset 和 Count 传反了。LIMIT 10, 20是按“偏移 10,取 20 行”解释的,如果你从文档或网上抄来的代码里写成LIMIT pageSize, pageNow,那第一页就会变成跳过 pageSize 行、只取 pageNow 行,数据条数完全不对。
7.5 排查思路的通用套路
不管遇到什么 Limit 相关的问题,我建议按以下顺序排查:
- 先确认 SQL 的语义:把 SQL 里的参数换成具体数字,拿同样的数据在测试环境跑一遍,看返回结果是否符合预期。
- 再看执行计划:MySQL 里用
EXPLAIN SELECT ...,重点看type、key、rows三个字段。rows如果远大于你预期的数据量,说明索引没用好。 - 最后看参数值:应用层打印出来的 SQL 参数值,确认有没有被截断、转换错误或者注入修改。
- 如果问题只在特定数据量下出现,构造同样规模的数据复现,很多时候“大数据量才报错”的问题都跟 filesort、临时表有关。
8. 从实践角度总结的几条心得
最后不写什么高大上的总结了,就分享几条我这些年实际操作的体会。
第一,Limit 永远不要写死。无论取前 10 条还是前 100 条,都要从需求出发判断这个“N”到底该多大。很多页面一次加载几百条数据,用户其实只看前几条,白白浪费带宽和数据库资源。合理做法是限制默认加载条数,上拉加载或翻页时再去查。
第二,分页方案的选择要结合产品形态。如果你的产品是“加载更多”式的信息流,游标分页远胜传统页码分页。如果你的产品必须支持“跳到第 88 页”,那只能用 Offset 分页,但可以通过合理建索引、限制最大页码数来压制性能问题。方案没有绝对好坏,只有适合不适合。
第三,LIMIT和ORDER BY永远要成对考虑。没有排序的 Limit 毫无意义,因为返回哪些行是不确定的。我说的“不确定”不是随机,而是数据库优化器觉得怎么拿都行那就怎么拿。它可能这次给你这几行,下次给你另外几行。所有业务上对“前 N 条”有理解的场景,都必须搭配确定的排序规则。
第四,只要涉及批量更新和删除,优先考虑分批 Limit。杀鸡用牛刀的场景不多,但用一句话的DELETE FROM logs WHERE ... LIMIT 1000代替一次性删百万行,能避免很多线上故障。这类“慢活的智慧”,在真实生产环境里比任何花哨的写法都重要。
第五,测试环境必须覆盖大数据量。小数据量下,LIMIT 10, 20和LIMIT 20 OFFSET 10写反了也不报错,但一到生产环境,性能差异和结果误差就会暴露出来。新写分页接口的时候,建议直接生成十万行测试数据验证一遍,这不是小题大做。
SQL 的 Limit 听起来简单,用好了是普通功能,用不好就是性能事故。希望这篇把原理、语法、优化和排查串起来之后,能帮你少走几条弯路。