ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

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的过程是这样的:

  1. 从存储引擎读取第一行数据,依次向后扫描;
  2. 一路扫描到第 10000 行,期间把所有读到的数据全部丢弃;
  3. 继续读取 20 行,返回给客户端;
  4. 执行完成。

也就是说,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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

模拟数据这里就不演示具体插入过程了,实战环境可以自己用存储过程灌数据,保证数据分布尽量贴近真实: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 传给了 LIMIT,MySQL 会直接报错:

ERROR 1064 (42000): You have an error in your SQL syntax

如果是LIMIT -1, 10,同样报错。这让很多人误以为LIMIT不支持负数参数。MySQL 确实不支持负数偏移量和负数行数,这是语法层面的限制。所以后端在接收分页参数时,一定要做数字校验,负数直接拦截或归一化成 0。我之前见过一个真实案例,前端翻页控件传入了负数页码,结果后端没有做校验,直接把数据查崩了,错误日志刷了一整屏。

5.2 参数超过 MAX_JOIN_SIZE 或者极大值

如果把非常大的数字传进去,比如LIMIT 999999999, 10,MySQL 不会报错,但会尝试做一次超大扫描。这种场景虽然不是常规业务,但用来做一个系统性的自我保护很有价值。很多大厂在做分页时,都会直接在数据库层面用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 的真正执行逻辑,后面无论是深入学习查询优化还是其它数据库的分页方案,都会轻松很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进