news 2026/9/13 4:20:16

MySQL深分页优化:从LIMIT OFFSET到游标分页与延迟关联的实战对比

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL深分页优化:从LIMIT OFFSET到游标分页与延迟关联的实战对比

1. 深分页问题从哪来:LIMIT OFFSET 的代价有多大

做后端开发的同学,只要跟 MySQL 打过交道,大概率都写过类似SELECT * FROM orders ORDER BY id LIMIT 1000000, 20这样的分页查询。早期数据量小的时候没什么感觉,等表里数据涨到千万级别,你会在某次上线后突然发现:接口超时了,数据库 CPU 飙高,慢查询日志里全是同一条 SQL。

我见过不少团队第一次遇到这个问题的反应——先加索引,结果发现加了也没用;再查执行计划,发现明明走了索引,却还是慢得离谱。问题根源其实不在索引,而在LIMIT OFFSET这个语法的工作方式:数据库拿到 OFFSET 之后,不是直接跳过去读取第 1000020 条数据,而是老老实实把前面 1000000 行全部扫描出来,再丢弃掉。翻的页数越深,丢弃的行越多,扫描成本毫无意义地膨胀。

这就是所谓的深分页问题。简单说:数据量越大、页码越靠后,LIMIT OFFSET的性能就越差,而且是线性衰减。在这篇文章里,我会结合具体的表结构和真实执行计划,把四种常见的优化方案拆开揉碎——传统方案为什么慢、游标方案怎么用、延迟关联的原理、以及子查询/JOIN 的取舍,最后再给出一张选型对照表,告诉你什么场景该用哪种。想跳过“慢查询救火”阶段、提前把分页做对的同学,这篇内容应该对你有用。

先说明一下,这篇文章的实验环境是 MySQL 8.0.34,InnoDB 引擎,测试表数据量约 200 万行。不同版本、不同数据分布下数据会略有差异,但结论和优化思路是通用的。另外,全文会多次提到“覆盖索引”“回表”“执行计划”这几个概念,不熟悉的读者可以先把它们理解成:覆盖索引是索引本身就带齐了你要的字段、不用再回表查一次;回表是查到索引记录后还要再去主键索引拿整行数据;执行计划则是 MySQL 告诉你这条 SQL 它会怎么执行的说明书。

2. 四种方案的原理拆解与核心实现

2.1 方案一:LIMIT OFFSET 直接翻页,为什么慢到无法接受

先看看最原始的写法长什么样:

-- 第 50000 页,每页 20 条 SELECT id, order_no, user_id, amount, create_time FROM orders ORDER BY create_time DESC, id DESC LIMIT 1000000, 20;

这条 SQL 的问题在于,MySQL 必须扫描前 1000020 行,再丢弃前 1000000 行。你可以把 InnoDB 的索引想象成一本书的目录,你想读第 100 页的内容,但 MySQL 的做法是从第 1 页开始逐页翻过去,一直数到第 100 页。虽然最终结果里那条 SQL 返回给客户端的只有 20 行,但为了找到这 20 行,MySQL 在存储引擎层面做了大量无比浪费的扫描和回表操作。

为什么会“回表”?上面这个例子中,排序字段是create_time,但查询需要返回order_nouser_idamount这些字段。如果create_time上的索引只包含create_timeid,MySQL 就得用索引排序后,拿着每一行的主键 id 再去聚簇索引里找完整数据,这个过程就是回表。OFFSET 是 100 万时,意味着 MySQL 回表了至少 100 万次,这里面的大部分回表都是无用功。

我看过太多人试图用“加索引”来解决这个问题——给create_time加索引当然能提升排序效率,让ORDER BY create_time不至于走 filesort,但无法解决 OFFSET 本身造成的无谓扫描。LIMIT 1000000, 20这种写法,无论索引怎么优化,MySQL 都得扫描 100 万行之后才取那 20 行。索引优化只能让每一行的扫描稍微快一点,但扫描总量不变,所以整体性能没有本质提升。

有个 DBA 同事跟我聊天时给过一个很形象的类比:LIMIT OFFSET就像去电影院找座位,你买的是第 10 排的票,正常应该是看一眼票就直接走到第 10 排,但这条 SQL 非要你先从第 1 排开始,每个座位都看一眼座椅编号,一直看到第 10 排才坐下。如果全场只有 10 排座位还好,要是有 100 排呢?效率可想而知。

2.2 方案二:游标分页/书签法,用 WHERE 条件代替 OFFSET

第二种方案在行业里有好几个名字:游标分页、键集分页、书签法、seek method。核心思想就一句话:不告诉 MySQL “我要跳过多少行”,而是告诉它 “我要从哪一行开始取”。

举个例子,假设上一页最后一条数据的 id 是 800000,那么下一页的 SQL 可以写成:

SELECT id, order_no, user_id, amount, create_time FROM orders WHERE id < 800000 ORDER BY id DESC LIMIT 20;

这里的关键变化是:用WHERE id < 800000代替LIMIT 1000000, 20。MySQL 可以根据主键索引直接定位到 id 为 800000 的那条记录,然后从它往前扫 20 行就结束,扫描的量从 100 万行骤降为 20 行。理论上这 20 行全部命中,速度当然快得飞起。

这个方案的适用前提是排序字段具备唯一性,最理想的就是主键 id。如果你需要按create_time排序,那就要保证create_time不会重复,否则可能出现同一时间戳的数据被重复显示或者漏掉的问题。解决方案是组合条件:WHERE create_time < '2024-06-01 12:00:00' OR (create_time = '2024-06-01 12:00:00' AND id < 123456)。这个写法略显啰嗦,但可以精确表达“如果时间相同,就按 id 继续往下比”的语义。

游标分页最大的优势是:不管翻到第几页,性能都保持稳定,几乎不受数据总量影响。它适合“列表无限往下滚”的场景,比如 App 的评论列表、消息列表、订单流水。用户不会去点第 100 页,而是一直往下滑动,每次请求带上上一页最后一条数据的位置即可。

但它也有明显的限制:第一,你没法直接跳到任意一页,因为你需要知道上一页最后一条记录的位置;第二,如果排序条件可以变化(比如用户可以在“按时间排序”“按价格排序”之间切换),那你就得为每一种排序条件都维护一套游标逻辑,复杂度会成倍上升。第三,如果表里有数据被删除,游标分页的表现跟 OFFSET 分页会有差异——到底是只显示“当前位置之后的数据”还是“从第 N 条开始的数据”,取决于业务怎么理解分页。很多业务其实无所谓,但如果你做的功能是只能依赖页码跳页的(比如后台管理系统的批量操作),这个方案就不适合。

2.3 方案三:延迟关联,先取主键再回表

从上面的分析可以看出,深分页慢的焦点有两个:一个是 OFFSET 导致的无谓扫描,另一个是排序后逐条回表带来的开销。延迟关联这个方案,核心思路就是摧毁第二个焦点——先通过覆盖索引把所有需要排序过滤的查询跑完,拿到这批数据的主键 id,再用 join 或者子查询的方式去主表回表取完整数据,把回表的次数压缩到最小。

标准的写法有两种,效果几乎一样:

-- 写法 A:子查询 SELECT o.id, o.order_no, o.user_id, o.amount, o.create_time FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY create_time DESC, id DESC LIMIT 1000000, 20 ) t ON o.id = t.id; -- 写法 B:直接 JOIN 临时结果集 SELECT o.id, o.order_no, o.user_id, o.amount, o.create_time FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY create_time DESC, id DESC LIMIT 1000000, 20 ) t USING (id);

这段 SQL 的神奇之处在于,最内层的子查询只需要id这一列,于是 MySQL 可以走覆盖索引——索引里已经包含了id和排序字段,扫描索引时不需要回表取其他字段,扫描的成本大幅下降。等拿到 20 个主键 id 之后,再用它们去主表取完整数据,这时候只回表 20 次。

打个比方:你以前每次翻页都要从书架上把 1000 本书拿下来翻一遍封面,现在你只需要先把书架上的标签目录扫一遍,确定 20 本目标书的编号,再精准地去抽那 20 本书出来。

这个方案跟方案二结合,就有了一个比较经典的“深分页终极形态”:先用游标条件过滤掉无效数据,再配合覆盖索引取 id,最后回表:

SELECT o.id, o.order_no, o.user_id, o.amount, o.create_time FROM orders o INNER JOIN ( SELECT id FROM orders WHERE create_time <= '2024-06-01 12:00:00' ORDER BY create_time DESC, id DESC LIMIT 20 ) t ON o.id = t.id;

在实际优化深分页的时候,我一般先把方案三作为首选推荐——它不需要改动接口协议,不需要传入上一页游标参数,业务改动量小,性能提升却非常明显,适用于大多数后台管理系统、报表分页等“必须支持页码跳转”的场景。

2.4 方案四:子查询优化与 JOIN 变体,从执行计划角度看效果差异

前三节其实已经覆盖了最常见的三种优化,但你在网上还经常能看到另一种方案——“用子查询优化深分页”,本质上跟方案三是同源,只是有时候写法会用EXISTSIN或者LEFT JOIN等方式来表达。我这里单独拎出来讲,是因为它们的执行计划可能差异很大,不能盲目照抄,尤其是IN的写法在 MySQL 优化器下会做查询转换,未必能拿到你想要的效果。

先看一个网上流传很广的写法:

SELECT id, order_no, user_id, amount, create_time FROM orders WHERE id >= ( SELECT id FROM orders ORDER BY create_time DESC, id DESC LIMIT 1000000, 1 ) ORDER BY create_time DESC, id DESC LIMIT 20;

这个方案的逻辑是:先从索引里查出“第 1000001 条的 id”,然后主查询用id >= 这个值直接定位起点,再取 20 行。如果排序字段和 id 的递增顺序完全一致,也就是说create_timeid单调相关,那么这种写法非常高效,相当于把深分页转化成了主键范围扫描。但如果create_time不是单调的(比如用户创建订单后可以修改时间、补单等),这个“第 1000001 行的 id 一定恰好是第 1000001 个时间点的那一行”的假设就不成立了,结果集就会出现偏差——实际取到的 20 行可能不是你想按时间排序得到的顺序。

再看JOIN的变体。很多人把方案三写成了JOIN (SELECT ... LIMIT 1000000, 20) t ON o.id = t.id,这时候你一定要留意 MySQL 实际怎么执行这个 JOIN。理想情况是:驱动表是那个只有 20 行的子查询临时表,被驱动表是订单主表,用主键一一匹配,快得离谱。但如果你在子查询里没有保证只查id或者漏掉了排序,MySQL 优化器有可能会把 JOIN 顺序倒过来,先扫描订单主表的 100 万行再去做匹配,那就彻底废了。

想确认你的 SQL 到底怎么跑,拿到一个真实执行计划非常重要。直接看这条 SQL 的 key、rows、extra 三列。如果 key 显示走了主键索引、extra 没有 Using filesort、rows 估算只有 20 行左右,那基本就是最优执行路径。我看到过不少人把方案三写出来却依然很慢,一查执行计划,果然是被优化器改了执行顺序,或者出现了临时表排序。

所以这节要给你一个明确建议:方案四不要单独作为独立方案来选,它更像是方案三的变体和进阶版。真正决定效果的不是 SQL 长得像哪种写法,而是执行计划最终走向哪条路径。优化深分页的时候,多花一点时间读执行计划,比在论坛上求一个“万能 SQL”靠谱得多。

3. 实操演练:200 万行真实数据下的四种方案性能对比

3.1 建表与造数:让数据分布尽量贴近业务真实情况

为了让你对四种方案的差距有直观感受,我专门造了一张orders表,结构和真实电商订单表比较接近。表结构如下:

CREATE TABLE `orders` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL DEFAULT '', `user_id` bigint unsigned NOT NULL DEFAULT '0', `amount` decimal(12,2) NOT NULL DEFAULT '0.00', `status` tinyint NOT NULL DEFAULT '0', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_create_time` (`create_time`, `id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意这里加了联合索引idx_create_time,包含了create_timeid两列。为什么要带id?因为 InnoDB 的二级索引叶子节点本来就会自动带上主键,但你在建索引的时候把id显式写进去,可以让部分查询直接走覆盖索引,减少回表。这个细节在方案三里至关重要。

造数据我直接用存储过程,插了 200 万行,create_time按业务时间从 2022 年 1 月 1 日开始递增,但为了模拟真实业务里“同一秒内有多条订单”的情况,我在时间上做了一点随机偏移。如果你自己测试,可以照这个写:

DELIMITER $$ CREATE PROCEDURE insert_orders() BEGIN DECLARE i INT DEFAULT 1; DECLARE base_time DATETIME DEFAULT '2022-01-01 00:00:00'; START TRANSACTION; WHILE i <= 2000000 DO INSERT INTO orders (order_no, user_id, amount, status, create_time) VALUES ( CONCAT('ORDER', LPAD(i, 10, '0')), FLOOR(RAND() * 100000), ROUND(RAND() * 1000, 2), FLOOR(RAND() * 5), DATE_ADD(base_time, INTERVAL FLOOR(RAND() * 100000) SECOND) ); IF i % 10000 = 0 THEN COMMIT; START TRANSACTION; END IF; SET i = i + 1; END WHILE; COMMIT; END$$ DELIMITER ; CALL insert_orders();

插完之后,我分别跑了四种方案,翻到第 100 万行之后的位置,用EXPLAIN看执行计划,并用SHOW PROFILE或者SET profiling = 1抓取各条 SQL 的执行耗时。下面这是实验结果。

3.2 四种方案在同一环境下的性能实测数据

方案一:原始 LIMIT OFFSET

SELECT id, order_no, user_id, amount, create_time FROM orders ORDER BY create_time DESC, id DESC LIMIT 1000000, 20;

实测执行时间大约 4.2 秒。执行计划里能看到idx_create_time被使用,但 Extra 里有明显的Using index condition,因为没有覆盖所有回表字段,所以每一行都要回表拿完整记录。也可以用OPTIMIZER_TRACE看更细的代价估算,结论是扫描行数接近 100 万加 20。

到了第 150 万行的位置,这条 SQL 耗时涨到了 6 秒以上。如果你在线上接口里这么写,用户的体验就是转圈很久,最后还可能超时。

方案二:游标分页

-- 模拟上一页最后一条数据 id = 999980 SELECT id, order_no, user_id, amount, create_time FROM orders WHERE id < 999980 ORDER BY id DESC LIMIT 20;

实测执行时间 8 毫秒左右。执行计划 key 走 PRIMARY,rows 估算值远小于 OFFSET 方案,Extra 干净利落没有 filesort。你别被这个简单的 SQL 迷惑,它的前提是必须把上一页最后一行的 id 传到后端,由后端拼条件。这个方案在“瀑布流加载”场景里效果极佳,不管用户翻了多少页,耗时基本恒定在个位数毫秒级别。

方案三:延迟关联

SELECT o.id, o.order_no, o.user_id, o.amount, o.create_time FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY create_time DESC, id DESC LIMIT 1000000, 20 ) t ON o.id = t.id;

实测执行时间 0.9 秒左右,相比方案一的 4.2 秒提升了大约 4.5 倍。为什么还有 0.9 秒?因为内层子查询依然要扫描并丢弃 100 万行索引记录,虽然这一步不需要回表,但索引扫描也有开销。内层子查询的执行计划显示 Extra 是Using index,也就是覆盖索引扫描,这已经是 MySQL 能做到的最优路径了。

如果你把 OFFSET 拉到 150 万行,方案三耗时大约 1.2 秒,相比方案一的 6 秒,提升非常明显。不过要注意,方案三依然会随着 OFFSET 增大而缓慢变慢,因为它只是减少了回表开销,没有消除 OFFSET 扫描成本。

方案四:子查询定位 + 主键范围

SELECT id, order_no, user_id, amount, create_time FROM orders WHERE id >= ( SELECT id FROM orders ORDER BY create_time DESC, id DESC LIMIT 1000000, 1 ) ORDER BY create_time DESC, id DESC LIMIT 20;

实测执行时间 0.05 秒左右,比方案三还要快一个数量级。但我在前面已经提醒过:这个方案要求create_time的排序顺序和id递增顺序严格一致,否则结果集可能有偏。我这次造的数据里create_time是随机偏移的,所以这个方案取出来的 20 行,严格按时间排序的话并不是“第 1000001 到第 1000020 条”,而是从“第 1000001 个 id 对应的时间点附近”往后取的,业务上如果对排序精确性有要求,这个方案要谨慎使用。

为了让你更直观地对比,我把四个方案跑出来的关键数据整理成下面的表格:

方案核心写法第 100 万行耗时第 150 万行耗时能否支持页码跳转排序字段要求建议场景
方案一LIMIT OFFSET约 4.2 秒约 6 秒支持无特殊要求很少用,仅适合数据量小或测试
方案二WHERE id < 游标约 8 毫秒约 8 毫秒不支持排序字段必须唯一App 列表、无限滚动
方案三延迟关联 JOIN约 0.9 秒约 1.2 秒支持无特殊要求后台管理、报表分页
方案四子查询定位 + 主键范围约 50 毫秒约 55 毫秒支持排序字段最好与 id 单调一致特定业务,需验证结果集

这张表的数据来自我本机 MySQL 8.0 环境,机器配置中等偏下,不同硬件下绝对数字会变,但相对快慢关系基本稳定。方案四虽然最快,但限制条件也最苛刻,选型不能只看速度。

3.3 从执行计划反推为什么快:读懂 key、rows、Extra 三列

前面说了一堆执行计划,我猜不少读者会想:到底怎么才能真正看懂 EXPLAIN?这块不难,你只需要抓住三列核心字段:key、rows、Extra。

先看 key。key表示 MySQL 最终选用了哪个索引。深分页优化里,你希望看到 PRIMARY 或者覆盖索引的名称,而不是 NULL。如果 key 是 NULL,说明 MySQL 只能全表扫描,那性能大概率是灾难级别的。

再看 rows。rows是 MySQL 估算的需要扫描的行数,注意这是个估算值,不是真实值。方案一里 rows 可能估算为 1000020,方案三里内层子查询的 rows 也是约 1000020,但两者含义不同——方案一这 100 万行都要回表聚簇索引,方案三内层子查询只需要扫 idx_create_time 这个二级索引,索引记录比聚簇索引记录瘦得多,单位时间能扫描的行数完全不同。所以执行计划里 rows 相同的时候,要结合 Extra 判断代价。

最后看 Extra。这一列经常会冒出来Using filesortUsing temporaryUsing indexUsing index condition。前两个是性能杀手,能避免就避免。Using index是好消息,表示查询可以被覆盖索引满足,不需要回表。Using index condition表示索引下推优化,也算正面信号,但通常意味着还是需要回表拿最终数据。

方案三的执行计划里,内层子查询 Extra 显示Using index,外层连接走主键,Extra 没有额外排序。方案一执行计划 Extra 里可能同时出现Using index condition和一个主键上的回表操作,整体代价就上来了。我用 EXPLAIN 分析的时候,习惯先看 rows 和 Extra,再用EXPLAIN ANALYZE(MySQL 8.0.18+ 支持)看真实耗时和循环次数,比单纯看估算值靠谱得多。比如EXPLAIN ANALYZE SELECT ...会输出实际执行时间、扫过的行数和回表次数,一眼就能看到瓶颈在哪。

4. 方案选型决策指南:不同业务场景到底该选哪一种

4.1 后台管理系统与报表分页:优先延迟关联

后台管理系统、报表中心这类场景有个共同特点:必须支持页码跳转,用户可能点“第 87 页”,也可能点“末页”。这种需求天然排除了游标分页,因为游标分页需要上一条数据位置,没有办法直接跳页。

那是不是无脑上延迟关联?也不一定。如果表的数据量只有几万行,LIMIT OFFSET 完全够用,没必要为了一个不存在的性能问题增加 SQL 复杂度。从我个人的判断标准来看,单表数据超过 100 万行、或者 OFFSET 经常超过 10 万行,才建议做优化;否则过于复杂的 SQL 反而增加维护成本。如果到了必须优化的情况,延迟关联是后台系统里投资回报率最高的方案——SQL 改动量小,保留页码语义,性能提升 4 倍以上。再配合“限制最大翻页深度”,比如大于 2000 页就不允许查询,直接提示用户使用筛选条件,这是很多大厂后端的通用策略,简单粗暴但非常有效。

4.2 移动端和 Web 端无限滚动:游标分页是天然答案

移动端消息列表、订单流、商品评论、Feed 流这类“上拉加载更多”的功能,逻辑上完全不需要页码,只需要“上一页最后一条的位置”。这种场景下,游标分页是最匹配的解法,因为:

  • 性能稳定,跟翻页深度无关,永远扫描几十行;
  • 用户体验好,边滑边加载,响应速度是毫秒级;
  • 天然避免重复数据。在 OFFSET 分页中,如果用户翻页过程中表里新增了记录,下一页的结果可能包含上一页已经看到的数据;而游标分页以“最后一条的位置”为界,新插入的记录只会出现在已经翻过去的位置之后,体验上是“往下刷的时候能看到新内容,但不会让你重复看到旧内容”。

当然,游标分页需要后端接口做特殊设计:请求参数不再是pageNumpageSize,而是lastIdlastCreatedAt。前端要在列表数据里记录最后一条的标识,每次请求带上。改造量不大,但对前端感知是有的。如果你是第一次做这种接口,建议把游标字段统一命名为cursor,返回值里额外返回一个has_more标记,这样前端逻辑最清爽。

4.3 高并发实时性强的场景:方案组合远比单一方案可靠

真正的生产环境里,我很少看到只用一种方案“一条路走到黑”的。原因很简单,业务形态往往是混合的——同一个订单列表接口,在 App 端需要无限滚动,在管理后台需要页码跳转,那你就得在同一个查询服务里做不同分支。更常见的组合是“游标 + 延迟关联”:

-- 游标过滤 + 覆盖索引取主键 + 回表取详情 SELECT o.id, o.order_no, o.user_id, o.amount, o.create_time FROM orders o INNER JOIN ( SELECT id FROM orders WHERE create_time < '2024-06-01 12:00:00' OR (create_time = '2024-06-01 12:00:00' AND id < 500000) ORDER BY create_time DESC, id DESC LIMIT 20 ) t ON o.id = t.id;

这种写法把方案二的高效定位和方案三的低回表成本结合到了一起,性能最佳。但我也要提醒你,并不是每个团队都有精力把分页做得这么精细。如果你的表数据量不大、并发压力有限,最简单的方案反而是加一层 Redis 缓存,把热点页数据提前缓存,DB 的深分页问题根本到不了你面前。优化是一个系统性的取舍,不是把所有高级技巧都堆上去才叫好方案。

5. 深分页优化避坑指南:这里面的坑我基本都踩过

5.1 加了索引却不生效,先检查数据类型和排序规则

有次帮一个团队排查慢查询,他们给日期列加了索引,但 SQL 还是全表扫描。看了表结构才发现,查询条件里传入的日期变量是字符串,而表字段是 datetime,MySQL 为了比较隐式地把字段做了转换,索引直接失效。这类问题在深分页优化中尤其坑——你辛辛苦苦选了优化方案,结果底层查询条件本身就没走索引,再花哨的写法也白搭。

另外要留意联合索引的顺序。如果你要排序的字段是create_timeid,那联合索引必须把create_time放前面,id放后面。反过来建索引,排序时大概率还是 filesort。如果没有特殊理由,给深分页相关的索引都加上尾部主键,这是 InnoDB 二级索引结构决定的,可以利用到覆盖索引的特性。

5.2 分页排序的稳定性:缺少唯一排序依据会导致数据重复或丢失

深分页里一个隐蔽的坑是排序不稳定。假设你现在 SQL 只写ORDER BY create_time DESC,而不带id DESC,当 create_time 有大量重复值时,MySQL 返回的行顺序是不确定的。第一页返回了 id 为 100、200 的记录,翻到第二页时,扫描到的数据顺序可能已经变了,于是你看到两条第一页出现过的数据,另一条本该出现的数据反而被挤到下一页——这就是用户反馈的“列表数据乱了”。

解决方式很简单:排序字段里补一个全局唯一字段,通常是主键。也就是说,任何分页 SQL,排序都应该写成ORDER BY create_time DESC, id DESC。这不是可选项,而是写分页 SQL 的默认底线。对游标分页而言,这个要求更加苛刻——因为你要用上一页最后一条的位置做下一页的起点,如果排序不稳定,位置就完全失真了。

5.3 不要盲目使用 IN 子查询和 JOIN 的等价改写

方案三里用到了JOIN (SELECT id ...) t,很多读者可能会想:那WHERE id IN (SELECT id ...)是不是也可以?用是可以的,但你要做执行计划验证。MySQL 对IN (SELECT ...)的优化可能会产生临时表去重,或者被改写成 semi-join,执行路径未必是你想要的。我在 8.0 环境里测试过同样语义的 IN 写法,性能比 JOIN 写法差了不少,有些版本甚至走了全表扫描。

所以我的建议是:如果你决定用方案三,直接复制上面那两段 JOIN 写法的标准模板,不要去尝试各种等价改写。改写的风险在于你无法控制优化器的行为,它跟你 MySQL 版本、数据分布、统计信息都有关。同一个 SQL 在 5.7 上很快,升级到 8.0 之后优化器改了策略,可能突然变慢。反观 JOIN 模板的执行路径相对稳定,因为它把“子查询只有 id”这个事实写得很明确,优化器很难犯傻。

5.4 深分页的替代手段:查询条件前移和数仓冷热分层

最后说一个经常被人忽略的思路:深分页问题,除了在 SQL 层面优化,还能从业务设计层面绕过。很多分页需求本身就不合理——比如订单管理后台默认列表,用户真的会翻到 1000 页吗?大多数情况下不会,他们只是偶尔找一个历史订单,与其让数据库把 1000 页都翻出来,不如提供更强的筛选条件(时间范围、订单号、用户 ID),把查询范围大大缩小。

如果确实需要全面查历史数据,比如财务月底对账、运营拉全量数据,那就不该直接在业务库上翻页了,而应该走数仓或离线分析引擎,或者把数据按时间做冷热分层。热数据放 MySQL,冷数据放归档库或对象存储。用一句我在团队里反复强调的话:最好的深分页优化,就是让深分页根本不会发生。

我一直觉得深分页问题的价值不在那四种 SQL 写法本身,而在于它逼迫你去理解 MySQL 的索引结构、执行计划、回表成本,理解一个操作在数据库底层到底做了什么。很多性能问题排查到最后,拼的都是对底层机制的理解深度。这套方法学会了,以后遇到慢查询、锁等待、索引失效,你都会有更清晰的排查路径。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 4:17:29

企业SEO外包服务全解析:流程、定价与选择策略

1. 网站SEO外包服务概述在当今数字化营销环境中&#xff0c;搜索引擎优化(SEO)已成为企业线上获客的核心渠道。根据最新行业数据&#xff0c;超过70%的用户点击集中在搜索结果第一页&#xff0c;这使得专业SEO服务需求持续增长。SEO外包服务是指企业将搜索引擎优化工作委托给专…

作者头像 李华
网站建设 2026/9/13 4:14:01

本地化部署Claude AI助手:从环境搭建到性能优化

1. 为什么需要自建Claude AI助手最近两年AI助手市场呈现爆发式增长&#xff0c;但主流商业产品存在三个痛点&#xff1a;首先是地域限制问题&#xff0c;像Claude官方明确提示"App unavailable in region"&#xff0c;很多地区的用户根本无法使用&#xff1b;其次是隐…

作者头像 李华
网站建设 2026/9/13 4:13:22

BEV-former核心设计拆解:Transformer如何重塑自动驾驶鸟瞰感知

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 4:11:07

Python构建工业级邮政业务目录爬虫系统实战

1. 项目概述与核心价值邮政业务目录作为公共服务领域的重要数据资源&#xff0c;包含了全国范围内的邮政网点、业务类型、服务标准等关键信息。传统的人工收集方式效率低下且容易出错&#xff0c;而通过Python构建工业级爬虫系统可以实现高效、准确的数据采集。这个项目将带你从…

作者头像 李华