我们团队最近排查了一个线上接口变慢的问题,慢得毫无征兆。翻了下慢查询日志,发现有一条语句执行了将近三秒,长这样:
sql复制SELECT * FROM orders
WHERE user_id = 10086
ORDER BY create_time DESC
LIMIT 100000, 20;
这是一个很常见的后台订单分页查询。数据量也不算大,orders表也就两百多万行,按user_id过滤后命中的结果集大概几十万。问题就出在那个 LIMIT 100000, 20 上。
我用 EXPLAIN 看了一眼执行计划,索引走的没问题,是 user_id 的二级索引,但扫描行数已经到了十万行。也就是说,MySQL 为了拿到第 100001 到 100020 这二十条数据,得先把前十万行全部扫出来,然后一个个丢掉。这不是咱们写错 SQL,也不是缺索引,而是 LIMIT 这种分页写法本身就带着一个性能陷阱。
这篇内容我把 LIMIT 相关的坑、原理和优化方案一次性说清楚,尤其是深分页、写操作加 LIMIT 的副作用、以及 FOR UPDATE SKIP LOCKED 这类锁查询组合,全部用实际场景拆开讲。
1. 别把 LIMIT 当成简单的"只取前 N 条":执行器远比你想的辛苦
LIMIT 的语法看起来简单到可以闭眼写:
sql复制SELECT * FROM table LIMIT 5;
很多人对它的理解停留在"返回前 5 行"这个层面。但你要是真这么理解,后面遇到 LIMIT 100000, 20 写法的性能问题就完全不知道从哪里排查。
1.1 LIMIT offset, count 的真实执行代价:拿到又丢掉
LIMIT 100000, 20 等价于 LIMIT 20 OFFSET 100000,意思是:跳过前十万行,从第 100001 行开始取二十行。
问题在于,MySQL 在执行的时候并不是"直接定位到第 100001 行",而是老老实实地从第一条满足 WHERE 条件的记录开始数,数到十万行,然后把这十万行全部丢弃,最后才取出你要的那二十行。用 MySQL 官网文档里的原话说:offset 那一堆行会被 server 层一条一条地读取并丢弃。
这就好比你在一本两百页的书里找第 100 页的内容,正常做法是翻到那一页,但 MySQL 的做法是从第 1 页开始,一页一页翻过去,翻到第 100 页的时候还要把前面 99 页的内容都过一遍脑子才能确定这就是你要的。单次这么干没问题,但你要是反复翻几百次,或者书的页数变成几百万页,体力消耗就完全不是一个量级了。
1.2 理解这个细节,才能解释线上那些奇慢的分页
我见过不少案例,SQL 优化之后索引全都到位了,type 是 ref,key 也对了,但深分页还是慢得离谱。原因就在这:索引帮 MySQL 定位到了 user_id = 10086 对应的第一条记录,但之后的十万次"找下一条"操作,每一次都是 B+ 树遍历、回表、逐行判断,这十万次循环省不掉,索引再合适也没用。
LIMIT 不是查询优化器能"智能跳过"的操作。它就是一个计数操作,跟 COUNT 一样,得一行行数。
所以在排查性能问题时,别一上来就盯着 EXPLAIN 看有没有用上索引,还要看看 LIMIT 的偏移量到底多大。偏移量过一万就已经需要警惕,过十万基本等于在扫一张小表。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 深分页为什么越往后翻越慢:B+树扫描与回表搬运的积少成多
要真正理解 LIMIT 的性能特征,得深入到 InnoDB 的索引结构里看一眼。
InnoDB 的索引是 B+ 树,叶子节点存的是完整的数据(聚簇索引)或者索引列加上主键值(二级索引)。当我们执行 SELECT * FROM orders WHERE user_id = 10086 ORDER BY create_time DESC LIMIT 100000, 20 的时候,执行过程大概是这样的:
- 根据
user_id这个二级索引定位到第一行满足条件的数据,此时拿到的是主键值。 - 用主键值去聚簇索引回表,把整行数据取出来。
- 判断是否满足
WHERE剩余条件(如果只按user_id过滤,这一步基本恒定满足)。 - 如果满足,计数加一,然后根据
ORDER BY create_time DESC维护排序。 - 重复以上过程,直到数够十万行。
- 丢弃前十万行,返回编号十万零一到十万零二十的数据。
这里每一步单看都不慢,但步骤 1 到 4 要循环十万次。每次循环都涉及两次 B+ 树查找(二级索引 + 聚簇索引回表),总共就是二十万次树查找。页面的随机 IO 就藏在这二十万次回表里,页不在内存就直接磁盘 IO。
有人会问,那我把 user_id 索引改成 (user_id, create_time) 联合索引,ORDER BY 是不是就不用排序了?对,排序确实可以用索引顺序避免 filesort,但 LIMIT 的十万次遍历和回表一个都省不掉。联合索引的叶子节点依然需要一个个数过去,数到十万条。
2.1 从 EXPLAIN 验证深分页的扫描行数
直接看执行计划最直观。假设有张表 t_user_orders,数据量 200 万行:
sql复制EXPLAIN SELECT * FROM t_user_orders
WHERE user_id = 1
LIMIT 100000, 20;
执行计划里 rows 那一列会显示一个很大的值(不同版本、不同统计信息下数值有差异),但真实的扫描行数远不止 LIMIT 后面写的 20。通过 OPTIMIZER_TRACE 或者直接看 SHOW STATUS LIKE 'Handler_read%' 也能验证:
sql复制FLUSH STATUS;
SELECT * FROM t_user_orders
WHERE user_id = 1
LIMIT 100000, 20;
SHOW STATUS LIKE 'Handler_read%';
Handler_read_next 这个值会飙到十万以上,这就是 InnoDB 存储引擎实际执行的"读取下一行"次数。你要是看到这个数字,就明白为什么深分页慢到让人想摔键盘了。
3. 深分页的三种优化方案:延迟关联、书签法、范围改写
深分页的问题核心在"为了取二十条,白白扫描了十万条"。优化的思路就两个方向:一是让数据库少回表、少搬运,二是把"偏移量跳转"改成"基于索引条件的定位跳转"。
3.1 延迟关联:先拿主键,再回表取数据
延迟关联的思想是:先用覆盖索引把主键 ID 取出来,避开大量无谓的回表,然后拿这些主键 ID 去关联原表取完整数据。
sql复制SELECT t1.*
FROM t_user_orders t1
INNER JOIN (
SELECT id
FROM t_user_orders
WHERE user_id = 1
ORDER BY create_time DESC
LIMIT 100000, 20
) t2 ON t1.id = t2.id;
子查询里只需要访问 (user_id, create_time, id) 这三个字段,如果索引是 (user_id, create_time),那么 id 会被自动带上(InnoDB 二级索引的叶子节点都会包含主键),所以整个子查询是覆盖索引扫描,不用回表。十万次循环仍然是十万次,但少了十万次"根据主键回表”的 IO,性能提升非常明显。
我实测过一张 500 万行的订单表,LIMIT 300000, 20 从 0.9 秒降到了 0.15 秒左右,提升幅度大约 6 倍。覆盖索引的威力就在这里。不过,如果业务上查询条件很多、需要回的字段也很多,延迟关联的子查询依然要扫描十万条索引记录,这个量级在高并发下还是比较吃紧。
3.2 书签法:记住上一页的位置,而不是偏移量
书签法在业界也叫“Keyset Pagination”或“Seek Method”。核心思路是:不用 OFFSET,而是用上一页最后一条数据的某个唯一有序字段作为查询条件,直接定位到下一页起点。
假设我们要按 create_time 排序分页,上一页最后一条记录的 create_time 是 '2024-06-01 10:30:00',且 id = 500123,那么下一页的查询条件就是:
sql复制SELECT *
FROM t_user_orders
WHERE user_id = 1
AND (create_time < '2024-06-01 10:30:00'
OR (create_time = '2024-06-01 10:30:00' AND id < 500123))
ORDER BY create_time DESC, id DESC
LIMIT 20;
这里有个关键点:为了避免两条记录 create_time 完全相同时的边界问题,我习惯把 id 也拼进排序和条件里,组成一个复合的比较条件。id 是主键,绝对唯一,所以整个扫描可以直接通过索引定位到 (create_time, id) 这个点,然后从这一点往后扫 20 条,完事。
书签法最大的好处是:不管翻到第几页,每次查询都只扫描固定的 20 条记录,不会因为页码变大而变慢。它的代价是:用户无法直接跳转到第 500 页,只能一页一页往下翻,而且翻页时参数要带上上一页的书签。对大多数 C 端业务的“加载更多”和“上一页/下一页”场景,这个方案完全够用,而且体验反而更好。
MySQL 8.0.31 开始在 ORDER BY 里支持了 FETCH FIRST n ROWS ONLY,但底层执行逻辑和我们直接用 LIMIT 没本质区别,深分页的问题它同样存在。
3.3 范围改写:把 ORDER BY + LIMIT 改成范围查询
如果业务排序字段本身是单调递增的,比如按 id 或 create_time 排序,并且你能在业务层拿到当前页的起始值,那可以不用 LIMIT 而是直接写成 WHERE id > ?:
sql复制SELECT *
FROM t_user_orders
WHERE user_id = 1
AND id > 100000
ORDER BY id ASC
LIMIT 20;
这个写法下,MySQL 可以借助主键索引直接跳到 id = 100000 之后的第一个位置,然后往下扫 20 条,十几万条记录的扫描量直接变为 20 条。这就是范围改写的核心收益。
它和书签法本质上是一类思路,都是把“跳过多少行”改成“从哪个位置开始取”。区别在于范围改写只适用于排序字段稳定且连续的情况,书签法则适配性更强。
哪个方案最适合你的场景?我自己的判断标准是:能改造成书签法就用书签法,改动可控、性能最稳;取数逻辑复杂、必须支持任意页码跳转的时候,用延迟关联兜底。
4. UPDATE、DELETE 加上 LIMIT 不是"省事",是给自己埋雷
很多人只在 SELECT 里用 LIMIT,忽略了 UPDATE 和 DELETE 也可以带 LIMIT。表面上看这是个“限流”的好功能,比如批量删除只删前 100 条,防止一次删太多拖垮数据库。但这个特性用起来要特别小心,一个不留神数据就少了,而且很难察觉。
4.1 DELETE ... LIMIT 的语义问题:根本不保证删哪些行
执行这条语句:
sql复制DELETE FROM t_orders
WHERE status = 'expired'
LIMIT 1000;
MySQL 不会告诉你删了哪一千条。没有 ORDER BY 的情况下,删除顺序取决于存储引擎的扫描顺序,而扫描顺序可能因为数据分布、索引选择、并发写入而发生变化。同一批数据,你在凌晨执行一次和白天高峰期执行一次,删掉的行大概率不一样。
如果 status = 'expired' 的数据有十万条,你分一百次执行 LIMIT 1000 删除,每次删的 1000 条会不会重复?理论上不会,因为删掉的行不会再被扫到。但删完那十万条之后,剩下来的是哪十万条?没有人能给一个确定性答案,全看执行计划。
所以我的建议是:生产环境的批量删除、批量更新,除非你能在业务层面接受“随便挑几条处理”的语义,否则不要用裸 LIMIT。如果非用不可,必须加上一个明确的 ORDER BY,让“删哪些行”变成可控的:
sql复制DELETE FROM t_orders
WHERE status = 'expired'
ORDER BY id ASC
LIMIT 1000;
这至少保证了每次从最早过期的那批开始删,行为可预期。但还要注意另一个坑:如果删除的量级很大,单条 DELETE ... LIMIT 会持有大量行锁,非但没起到平滑限流的作用,反而可能引发锁等待和主从延迟。更稳妥的做法是分批删除时每批中间加个 SLEEP() 或者控制执行频率,让 binlog 和从库有时间追赶。
4.2 UPDATE ... LIMIT 搭配排序,依然是"差不多先生"
同理:
sql复制UPDATE t_orders
SET settle_status = 1
WHERE account_id = 42
ORDER BY amount DESC
LIMIT 3;
看上去是想给这个账户金额最大的前三笔订单打标。但如果 amount 不是唯一索引列,有两笔金额完全一样的订单,MySQL 选哪两笔?它还是会按物理存储顺序或者索引顺序来,不会帮你去重。你需要的是“金额最大且唯一的前三笔”,那就要在条件里把唯一键考虑进去,或者先把主键查出来再更新:
sql复制UPDATE t_orders
SET settle_status = 1
WHERE id IN (
SELECT id FROM (
SELECT id FROM t_orders
WHERE account_id = 42
ORDER BY amount DESC, id ASC
LIMIT 3
) tmp
);
MySQL 不允许直接在 UPDATE 的子查询里引用同一张表,所以外面又套了一层临时表,这个写法虽然绕,但至少结果是确定性的。
4.3 LIMIT 在子查询、派生表里的语法边界
写惯了 SELECT ... LIMIT 的人,有时候会往子查询里塞一个 LIMIT:
sql复制SELECT * FROM (
SELECT * FROM t_orders WHERE status = 'pending' LIMIT 3
) tmp;
这种写法在 MySQL 8.0 里是可以执行的,但如果你在 UPDATE 的目标表上直接套类似结构,会有“You can't specify target table for update in FROM clause”这类报错。解决办法就是我上面给的临时表套一层。另外,LIMIT 和 ORDER BY 一起用于子查询时,MySQL 优化器有时候会把内层排序“推导”到外层,或者反过来把外层条件“下推”到内层,导致实际执行顺序和你的直觉不一样。要做确保性验证,直接 EXPLAIN 看执行计划,不要猜。
5. FOR UPDATE SKIP LOCKED 配合 LIMIT:任务队列场景的利器
聊完 LIMIT 的普通玩法,再说一个我最近在实际项目里用得很舒服的组合:LIMIT 1 FOR UPDATE SKIP LOCKED。这个组合在任务调度、消息消费、队列抢单这类场景下基本是标配。
5.1 为什么任务表抢单不能只用 SELECT ... LIMIT 1 FOR UPDATE
假设我们有一张任务表 t_task,多个 worker 进程同时抢 status = 'todo' 的任务。最直觉的写法是:
sql复制SELECT * FROM t_task
WHERE status = 'todo'
ORDER BY priority DESC
LIMIT 1
FOR UPDATE;
这条语句在并发下有个典型问题:两个 worker 同时执行,第一个拿到了行锁,第二个会被阻塞住,直到第一个事务提交或回滚。第二个 worker 才能继续,拿到同一条任务或者下一条任务。如果任务处理很快,阻塞的时间很短,高并发下看着还能跑;可一旦事务里还有后续的耗时操作(比如调外部接口、写日志),第二个 worker 就会一直干等着,任务队列的吞吐量直接降下来。
另一个更严重的问题是,如果 WHERE status = 'todo' 条件匹配的行很少,多个 worker 同时锁同一行,锁竞争会非常激烈。我们之前就是这个场景,四个 worker 抢任务,MySQL 的 Innodb_row_lock_current_waits 指标一直居高不下。
5.2 用 LIMIT 1 FOR UPDATE SKIP LOCKED 让并发各取所需
SKIP LOCKED 是 MySQL 8.0 加入的语法,它会直接跳过已经被其他事务锁定的行,而不是等着。配合上 LIMIT 1,每个 worker 拿到的都是"当前未被锁定的第一条任务",拿完就走,互不阻塞。
sql复制SELECT *
FROM t_task
WHERE status = 'todo'
ORDER BY priority DESC
LIMIT 1
FOR UPDATE SKIP LOCKED;
两个 worker 同时执行这条语句,worker A 锁住了第一行,worker B 扫描到这一行时发现已经被锁,直接跳过,拿第二行。两个 worker 立刻各回各家,没有任何等待。
实测下来的效果非常明显。原先四个 worker 并发抢任务,锁等待和重试逻辑再加上业务处理时间,高峰期平均每个 worker 处理任务的间隔在 800ms 左右;换成 SKIP LOCKED 之后,间隔直接降到 200ms 以内。任务积压数量肉眼可见地下降。
5.3 一个容易忽略的点:SKIP LOCKED 不能脱离事务使用
FOR UPDATE 的锁要等事务提交或回滚才释放,所以 SELECT ... FOR UPDATE SKIP LOCKED 必须包裹在事务里,并且拿到任务后的业务操作尽量放在同一个事务内,或者至少保证拿到行锁到释放锁之间的时间足够短。如果只是 SELECT 出来然后在应用层做异步处理,锁会保持到你显式提交事务为止,这个期间其他 worker 根本看不到这条任务,任务数量一大,应用连接池和事务时长都会吃紧。
另外要注意:SKIP LOCKED 在 MySQL 8.0 之前不可用。如果你的生产环境还是 MySQL 5.7,要么升级,要么只能用 FOR UPDATE 加上应用层重试来模拟,但异常处理代码会复杂不少。我建议有条件就直接上 8.0,这个语法在队列类业务里真的是省心省力。
提示:
FOR UPDATE SKIP LOCKED同样适用于UPDATE语句,比如批量更新一批状态为待处理的记录,每个并发实例更新各自拿到的 ID 集合,互不干扰。
5.4 注意 LIMIT 1 和 ORDER BY 的组合:命中哪一行要可预期
在线任务表里,一般我们都会希望优先处理优先级高的任务。所以 ORDER BY priority DESC 这个排序不能省。没有 ORDER BY 的情况下,LIMIT 1 返回哪一行完全取决于存储引擎的扫描路径,这个顺序不稳定,可能导致高优先级任务被反复跳过。
有一个细节:ORDER BY 的字段如果不是索引列,MySQL 需要做 filesort,排序本身有开销。好在任务表通常数据量不大,几百上千条待处理任务排序很快。如果任务表膨胀到百万级待处理,还是要给 priority 或者 create_time 建一个合适的索引,避免排序拖后腿。
6. 踩坑日志:LIMIT 相关的几个隐蔽问题
6.1 LIMIT 后面不能直接写表达式和变量
MySQL 有个历史悠久的"特性":LIMIT 子句后面的数字默认不允许使用表达式,严格模式下的 SQL 预编译也不允许把 LIMIT 参数直接绑定为字符串。
sql复制-- 这种写法大概率报错
SELECT * FROM t_orders LIMIT 10 + 5;
-- 这行在低版本 MySQL 里也可能报错
SET @page_size = 20;
SELECT * FROM t_orders LIMIT @page_size;
我当年在写分页接口的时候,想着图省事,直接把 pageSize * offset 拼到 SQL 里交给 MyBatis 执行。MyBatis 的 ${} 拼接是能跑通,但 #{} 占位符在 JDBC 的 setInt() 绑定下,MySQL 的 LIMIT 并不接受占位符里的表达式结果。
解决办法是让程序先算好 offset 和 pageSize,直接把最终整数传下去:
java复制int offset = (pageNum - 1) * pageSize;
然后再处理相应的字符串。这个不算是 MySQL 的 bug,它确实是个老限制,但很多人第一次踩到的时候完全摸不着头脑,因为报错信息不一定直接指向 LIMIT。
6.2 COUNT(*) 与 LIMIT 相遇时,优化器不一定帮你省事
有些开发者会用 LIMIT 1 来判断表中是否存在满足条件的记录,比如:
sql复制SELECT 1 FROM t_orders WHERE user_id = 10086 LIMIT 1;
这个写法在存在满足条件记录时,一旦找到一条就返回,效率很高。但如果没有任何记录满足条件,它依然要扫完整条索引。这个行为本身没什么问题,问题在于有些开发者会想当然地认为 LIMIT 1 一定比 COUNT(*) 快,于是把所有的存在性检查都改成了 LIMIT 1。
实际测试中,如果 WHERE 条件能命中索引且存在性检查经常命中的话,LIMIT 1 确实比 COUNT(*) 快很多(因为 COUNT(*) 要统计全部结果)。但如果满足条件的记录很少、甚至没有,LIMIT 1 和 COUNT(*) 的扫描量几乎没有差别。所以这个优化手段只能用在"大概率有数据"的场景,不要无脑套用。
6.3 LIMIT 0 的妙用:不碰数据,只查表结构
有一个容易被忽略的用法是 SELECT * FROM t_orders LIMIT 0。它不会返回任何数据,但会正常走元数据解析,所以你可以用它快速判断一张表是否存在、字段是否正确。某些 ORM 框架在生成实体的时候,底层就是这么干的。这类查询对数据库几乎没有压力,偶尔用来做连通性检查挺顺手。
6.4 高版本 MySQL 的窗口函数函数:ROW_NUMBER() 不一定是 LIMIT 的好替代
MySQL 8.0 引入了窗口函数,有人会把某些原本用自连接或子查询写复杂分页逻辑的场景改成 ROW_NUMBER() 来编号,然后取某一段编号范围。这种写法在语义上完全没问题,但性能上不一定比 LIMIT 好。
sql复制SELECT *
FROM (
SELECT *, ROW_NUMBER() OVER (ORDER BY create_time DESC) AS rn
FROM t_user_orders
WHERE user_id = 1
) tmp
WHERE rn BETWEEN 100001 AND 100020;
窗口函数会把所有满足 WHERE user_id = 1 的行都编号,然后再过滤出目标范围,本质上还是全量计算,而且额外多一层派生表的物化开销。实测下来,数据量大时这种写法通常比 LIMIT 100000, 20 还要慢。所以窗口函数适合的是那种需要行号参与业务计算的场景,单纯分页没必要拿来替代 LIMIT。
7. EXPLAIN 里的 rows 是怎么骗你的
之前提到用 EXPLAIN 看 LIMIT 的执行计划,这里再单独提醒一个容易误判的点。
EXPLAIN 输出的 rows 是优化器估算的扫描行数,它不等于实际扫描行数,而且它在不同版本 MySQL 里的估算逻辑也不一样。对于 LIMIT 100000, 20,优化器有时会把 rows 显示为 100020,看起来像是"只扫十万行左右",但实际 Handler_read_next 可能不止,因为排序、回表、临时表等操作都会增加读取量。
所以排查 LIMIT 相关慢查询的时候,不要只看 EXPLAIN 的 rows 列,务必结合以下几点:
- 慢查询日志里的
Rows_examined字段,这个是实际扫描行数。 SHOW PROFILE或 performance_schema 里的耗时分布,确认时间花在 Sending data 还是 Sorting result。- 如果
Rows_examined远大于预期,直接怀疑深分页的LIMIT offset问题。
我自己排查 SQL 的固定流程是:先看慢查询日志,找到 Rows_examined 明显偏大的语句,再 EXPLAIN 看索引命中情况,最后根据实际情况决定是用延迟关联、书签法还是范围改写。这三板斧下来,绝大多数分页类慢查询都能找到根因。
8. 其他高频问题速查
结合我自己的开发经验,再补充几个常被问到的 LIMIT 相关问题。
8.1 MySQL 中 LIMIT 可以用于 UNION 查询吗
可以。UNION 查询里可以使用 LIMIT,作用在整条 UNION 的结果集上。如果希望限制单个 SELECT 分支的行数,需要在每个分支内分别写 LIMIT,这时要注意配合括号,避免语法歧义:
sql复制(SELECT id FROM t_a ORDER BY create_time DESC LIMIT 10)
UNION
(SELECT id FROM t_b ORDER BY create_time DESC LIMIT 10);
不加括号的话,LIMIT 会被解析为对整个 UNION 结果集的限制。
8.2 LIMIT 与 SQL_CALC_FOUND_ROWS:这个老搭配值得放弃
SELECT SQL_CALC_FOUND_ROWS ... LIMIT 配合 SELECT FOUND_ROWS() 是老版本 MySQL 里做分页总条数统计的常见写法。但它在 MySQL 8.0.17 之后被标记为 deprecated,原因是它需要扫描全部满足条件的行,即使你只要 20 条,它也会把所有行数数一遍,性能开销极大。
如果业务需要获取总记录数,建议直接用单独的 SELECT COUNT(*) 语句,并且把这个 COUNT(*) 的结果做缓存,避免每个分页请求都去算总数。
8.3 大数据量分页时,OFFSET 是不是完全没有用?
也不能说完全没用。如果数据量很小(几百行几千行),用户确实可能需要跳转到任意页码,书签法会破坏这种交互,这时候用 OFFSET 分页没有什么性能压力,代码也简单。数据量上来了,交互上也可以接受"加载更多"的时候,再迁移到书签法。方案选型是跟着业务场景走的,没有银弹。
我在实际项目里的习惯是:两万行以内的业务表,OFFSET 随便用,加索引就行;两万行以上且分页深度经常超过几十页,必须评估书签法或延迟关联。这个阈值不是硬标准,但作为敏感线足够了。
