REJAVA / LEARNING NOTES
慢 SQL 定位与优化
按“日志→计划→原因→改写→验证”排查慢 SQL,含索引失效与深分页。
根据课程与真实学习交流整理;不是逐字稿
本次更新说明
- 整理为公开学习笔记,含 5 张图示、练习与隔日复测。
- 附课后问答:隐式转换、漏传条件、EXPLAIN 与 EXPLAIN ANALYZE、排查五步、延迟关联。
热身 · 一 先找到慢的那条 · 二 读执行计划 · 三 索引用不上 · 四 额外排序 · 五 深分页 · 六 怎样证明改好了 · 七 应用 · 检验 · 表达 · 追问 · 隔日复测
本课要解决什么问题
学完本课,你拿到一条慢SQL时,能按“找证据 → 读计划 → 说原因 → 改写 → 验证”的顺序处理它。具体是:从慢查询日志看出它检查的行数是否远多于返回的行数;从执行计划的type、key(实际使用的索引)、rows、Extra四列说出数据库打算怎么查;认出索引用不上、额外排序、大偏移分页这三类常见原因,并给出改法;最后用同样的数据和条件,拿实际执行结果证明改好了。
“慢SQL怎么排查、怎么优化?”是Java后端开发里很常见的问题,本课也是MySQL(关系型数据库)部分的最后一课。第10课讲了索引怎样帮查询缩小范围;本课反过来看:索引明明建了,为什么没帮上忙,以及怎样拿证据说话。
前置: 第10课的联合索引、最左前缀、回表、覆盖索引。 必学: 第一至七节、六道选择题和四问表达。第三节后可暂停。函数索引和分组产生临时表放在折叠拓展里。本课不要求在真实库上实验;执行计划里的数字都是标明“示意”的教学设定,不是实测结果。
主读: 官方执行计划输出格式文档(EXPLAIN Output Format)。先读其中 EXPLAIN Join Types 一节的const、ref、range、index、ALL五项,再读 EXPLAIN Extra Information 一节的Using filesort(额外排序)、Using index、Using where。不用通读所有列。
热身:先回忆第14课的三个易混点
前三题对应第14课课后问答里三个容易说偏的地方。先凭记忆回答,再展开核对;刚看完就答只算即时回忆,隔一段时间再答更能看出是否记住。
第14课:buffer pool(缓冲池)和log buffer(日志缓冲区)各放什么?断电重启后,数据库从哪里拿redo log(重做日志)补回已提交的修改?
缓冲池放表和索引的数据页,例如20号所在、已经改成“已通过”的那一页;日志缓冲区放还没写出去的redo。断电时两块内存都会丢,重启后数据库从磁盘上的redo日志文件读出已经可靠保存的记录,把数据文件里还缺的修改补回页面。参见第14课课后问答。
第14课:binlog(二进制日志)是不是数据的备份?要恢复到上午9点15分,需要哪两样东西?
不是。备份是某个时间点整个数据库的副本;binlog(二进制日志)按事务记下数据变更。恢复时先恢复9点的备份,再从备份对应的位置开始重放binlog(二进制日志),直到9点15分。
第14课:崩溃时一笔事务在InnoDB(事务存储引擎)里处于准备状态。数据库按什么决定提交还是回滚?
看binlog(二进制日志)里有没有这笔事务的完整记录:有就补完提交,没有就回滚。文件完整、存储健康只是题目前提,不是判断依据。
第10课:二级索引 (dept_id, status, submitted_at) 里存了哪些值?查询还要申请人姓名时,为什么要回表?
二级索引里存这三列的值,加上该行的主键 id。申请人不在这个索引里,所以数据库先从二级索引拿到 id,再按 id 到聚簇索引取整行,这一步叫回表。只查索引里有的列时,不需要回表,这叫覆盖索引。
一、先找到慢的那条:慢查询日志记下了什么
“线上列表页很慢,你第一步做什么?”
本课沿用第10课的办件表 case_item,假设有100万行。字段和索引如下,数字都是教学设定:
| 字段或索引 | 含义 |
|---|---|
id | 主键,办件的唯一编号 |
case_no | 对外显示的办件号,VARCHAR(20)(可变长字符串),例如 ‘2026100800123’ |
dept_id、status | 部门;状态(0待审批、1已办结) |
submitted_at、applicant | 提交时间;申请人姓名 |
idx_dept_status_time | 联合索引 (dept_id, status, submitted_at),第10课建的那个 |
idx_case_no | 办件号上的普通索引 |
经办人反映“列表页打开要好几秒”。这句话还不能直接告诉我们是哪条SQL慢:一个页面可能执行好几条查询,慢的也可能是应用代码或网络。所以第一步是找证据,找出数据库里到底哪条语句执行得慢。
MySQL(关系型数据库)提供 slow query log(慢查询日志):执行时间超过 long_query_time 秒、并且检查行数不少于 min_examined_row_limit 的语句,会被写进这份日志。它默认是关闭的;long_query_time 默认是10秒,最小可以设到0。实际系统里阈值一般要调低,具体设多少由团队按业务决定,本课不给固定数字。[1]
日志文件里,每条慢语句的SQL文本上方有一行以 # 开头的统计信息。下面是一条示意记录:
# Query_time: 2.8 Lock_time: 0.0001 Rows_sent: 20 Rows_examined: 61200
SELECT id, case_no, applicant, submitted_at FROM case_item
WHERE dept_id = 10 ORDER BY submitted_at DESC LIMIT 19980, 20;
示意说明:字段名和含义来自官方手册,数值是教学设定,不是实测结果。验证思路:在你能查看的测试实例上,确认慢查询日志是否开启、阈值是多少,再对照下表读一条真实记录。
| 字段 | 官方含义 | 这条记录说明什么 |
|---|---|---|
Query_time | 语句执行时间(秒) | 执行了2.8秒 |
Lock_time | 获取锁花的时间(秒) | 取锁只花了约0.0001秒,通常可以先排除第13课讲的锁等待 |
Rows_sent | 发给客户端的行数 | 只返回了20行 |
Rows_examined | 服务器层检查过的行数 | 为了这20行,检查了约6.1万行 |
最值得注意的是最后两项的对比: 返回20行,却检查了约6.1万行。 数据库做了大量“看过但没用上”的工作,这通常说明它没能用索引把范围缩小,或者为了排序、跳过前面的行读了很多数据。下一步要看数据库打算怎么执行这条语句。[1]
两点注意:官方说明,判断是否超过 long_query_time 时,不计开始执行前获取锁的时间;日志在语句执行完、锁释放后才写入,所以日志里的先后顺序可能和执行顺序不同,不能按日志先后推断谁先执行、谁阻塞了谁。[1]
先试着自己解释,再看参考回答
我不会先凭感觉加索引,而是先找证据。先在慢查询日志里找到这个页面对应的慢语句,看执行时间、等锁时间、检查行数和返回行数。如果返回20行却检查了几十万行,说明数据库做了大量无用的读取;如果等锁时间很长,就要按锁等待去查。确定是哪条语句以后,再看它的执行计划。
二、读执行计划:先看四列
“EXPLAIN(查看执行计划的语句)的结果你一般看哪些字段?”
在SQL前面加上 EXPLAIN(查看执行计划的语句),MySQL(关系型数据库)不返回查询结果,而是返回 execution plan(执行计划):它打算用哪个索引、按什么方式找行、估计要检查多少行、还要额外做什么。输出有十几列,排查时先看四列就够了。[2]
| 列 | 回答什么问题 | 怎么读 |
|---|---|---|
type(访问方式) | 数据库怎样找到这些行 | 见下一张表;ALL表示全表扫描 |
key(实际使用的索引) | 最终用了哪个索引 | NULL表示没用索引;possible_keys只是“可能用”的候选 |
rows(估计检查行数) | 估计要检查多少行 | 对InnoDB(事务存储引擎)是估计值,不一定准确 |
Extra(额外信息) | 除了找行,还做了什么 | 重点看Using filesort(额外排序)、Using index、Using temporary |
type:从好到差,先认五种
官方把type按“从最好到最差”排列。用办件表举例,先认下面五种:[2]
| type | 官方含义 | 办件表里的例子 |
|---|---|---|
| const | 最多一行匹配,用主键或唯一索引的全部列等值比较 | WHERE id = 103 |
| ref | 用非唯一索引(或索引的最左前缀)等值查找,可能匹配多行 | WHERE dept_id = 10 AND status = 0 |
| range | 用索引只取某个范围内的行 | WHERE dept_id = 10 AND status = 0 AND submitted_at >= '2026-10-08' AND submitted_at < '2026-10-09' |
| index | 和ALL一样逐个读,只是读的是整棵索引树 | 把整个索引从头读到尾 |
| ALL | full table scan(全表扫描) | 100万行逐行检查 |
两个容易误读的地方:
- type=index 不等于“用上索引就好”。 它表示把整棵索引树从头读到尾,只是索引通常比整张表小。
- type=ref 也要看rows。 官方说,ref用的索引值如果只匹配少量行,就是好的访问方式。反过来,如果部门10有3万件待审批,ref也要检查3万行。
Extra:认五个常见值
| Extra 里出现 | 官方含义 | 怎么理解 |
|---|---|---|
| Using index | 只读索引树就拿到了所需列,不用再读整行 | 第10课的covering index(覆盖索引),不需要回表 |
| Using index condition | 先用索引里的值检查条件,必要时才读整行 | 第10课的ICP(索引条件下推) |
| Using where | 用WHERE条件筛选读到的行 | type为ALL或index、又没有Using where时,没有任何条件筛掉行,全部行都交给后面;除非本来就要全部数据,否则查询可能写错了 |
| Using filesort(额外排序) | 需要额外一轮才能把行按要求排好序 | 第四节专门讲 |
| Using temporary | 需要建临时表存放中间结果 | 常见于GROUP BY和ORDER BY的列不同 |
官方依据:[3]。Extra里还有很多其他值,遇到再查,不用背全。
先试着自己解释,再看参考回答
我先看四列。type看它怎样找行,ALL是全表扫描,range是按索引取一段;key(实际使用的索引)看实际用了哪个索引,NULL就是没用;rows看估计要检查多少行,对InnoDB(事务存储引擎)是估计值;Extra看有没有额外工作,比如Using filesort(额外排序)表示要额外排序,Using index表示覆盖索引不用回表。type不能单独看,ref如果匹配几十万行一样慢,所以要和rows一起判断。
三、索引明明建了,为什么没用上
“哪些情况会导致索引失效?”
“索引失效”是常见的笼统说法,更准确的说法是: 某些条件写法,让数据库没法利用索引的排序顺序,找到要读的第一条记录。 先记住索引的本质:B树索引按列的原始值排好序,查询能用索引,是因为数据库能在这个顺序里找到一个起点,连续往后读一段就结束。下面三种写法,都让数据库找不到这个起点。
1.对索引列套函数:索引里存的是原值,不是函数结果
经办人要看“部门10、待审批、10月8日当天提交的办件”。同事写成:
-- 写法B:对索引列套函数
SELECT id, case_no, submitted_at FROM case_item
WHERE dept_id = 10 AND status = 0 AND DATE(submitted_at) = '2026-10-08';
-- 写法A:改成半开时间范围,结果相同
SELECT id, case_no, submitted_at FROM case_item
WHERE dept_id = 10 AND status = 0
AND submitted_at >= '2026-10-08' AND submitted_at < '2026-10-09';
验证思路:两种写法返回的行相同(10月8日0点起、9日0点前,第10课讲过这种左闭右开的边界)。在测试表上分别执行 EXPLAIN(查看执行计划的语句),比较 type 和 rows;本课没有实测输出。
索引 idx_dept_status_time 里,部门10、待审批的那一段,是按 submitted_at 的 原始时间 排好的。写法A的条件就是“原始时间落在某个区间”,数据库能在这一段里找到10月8日0点的位置,往后读到9日0点前停止。写法B的条件是“DATE()算出来的结果等于某天”,索引里没有存DATE()的结果,数据库只能把部门10、待审批的每一条取出来,算一遍DATE()再比较。前两列 dept_id、status 仍然能用来定位,失去作用的是第三列。[12]
改法: 把对列的计算挪到常量那一边,比如把“DATE(列)=某天”改成“列在某天0点到次日0点之间”。同理,YEAR(submitted_at) = 2026 改成 submitted_at >= '2026-01-01' AND submitted_at < '2027-01-01';列 + 1 = 10 改成 列 = 9。
拓展:一定要按函数结果查怎么办?
MySQL(关系型数据库)8.0.13起支持 functional index(函数索引):普通索引存的是列值或列值前缀,函数索引存的是表达式的计算结果,用来给“表里没有直接存的值”建索引。查询里的表达式要和索引定义一致,优化器才可能使用它。它和普通索引一样要占空间、拖慢写入,能改写成范围条件时优先改写。[7] [12]
2.字符串列拿数字去比:很多字符串都等于同一个数字
case_no 是VARCHAR(20)(可变长字符串),上面有索引 idx_case_no。Java代码里,有人把办件号声明成Long传进SQL,数据库收到的条件就相当于下面的写法B:
-- 写法B:字符串列和数字比较,列上的索引不能用于快速查找
SELECT id, applicant FROM case_item WHERE case_no = 2026100800123;
-- 写法A:传字符串,类型和列一致
SELECT id, applicant FROM case_item WHERE case_no = '2026100800123';
验证思路:在测试表上分别执行 EXPLAIN(查看执行计划的语句),看写法B的key(实际使用的索引)是否为NULL、type是否为ALL,写法A是否用上 idx_case_no。本课没有实测输出;结论依据官方类型转换说明。
官方手册说得很直接:字符串列和数字比较时,MySQL(关系型数据库)不能用这一列上的索引快速查找。原因是比较时要把字符串转换成数字,而很多不同的字符串都会转换成同一个数字,例如 ‘1’、’ 1’、‘1a’ 都等于1。这种自动转换叫 implicit type conversion(隐式类型转换)。[5]
改法: 让参数类型和列类型一致。Java里办件号既然存成字符串,DTO(数据传输对象)和Mapper(映射器)方法的参数就用String(字符串类型),不要用Long。这是Java项目里很常见、也很隐蔽的一类慢SQL:SQL文本看起来完全正常,问题出在传进去的参数类型上。[12]
3.LIKE以通配符开头:找不到起点
按申请人模糊搜索时,applicant LIKE '张%' 和 applicant LIKE '%张%' 不一样。官方说明:LIKE的参数是常量、且不以通配符开头时,B树索引可以使用,例如 '张%' 只需要看“以张开头”的那一段;以通配符开头的 '%张%' 不能使用索引,因为“张”可能出现在名字的任何位置,索引顺序帮不上忙。[6]
改法: 先问业务是否真的需要“包含”。如果按姓氏或前缀查就够了,改成前缀匹配;确实需要任意位置搜索、而数据量又大,就要另外评估全文搜索等方案,本课不展开。
4.也可能是优化器认为不用索引更划算
写法完全没问题,key(实际使用的索引)仍可能是NULL。官方在讲排序时说明:如果索引不包含查询要的全部列,只有在走索引比其他方式更便宜时才会用它。比如条件匹配了表里一大半的行,每行都要回表,逐行回表可能比直接扫全表还贵,optimizer(查询优化器)就会选择全表扫描。所以看到没用索引,先看rows和数据分布,再下结论。[8] [12]
先试着自己解释,再看参考回答
B树索引是按列的原始值排好序的,能用索引是因为数据库能找到一个起点连续读一段。对索引列套函数,索引里没有函数结果,只能逐条计算;字符串列拿数字比,很多字符串会转换成同一个数字,分散在不同位置;LIKE以百分号开头,不知道从哪里开始读。改法分别是把计算挪到常量一侧、参数类型和列保持一致、能用前缀就用前缀。另外,写法没问题也可能因为回表代价太高,优化器主动选择全表扫描,要结合rows判断。
这里可以暂停。先能说清“为什么这几种写法让数据库找不到起点”,再继续看排序和分页。
四、Using filesort(额外排序):数据库要自己再排一次序
“Extra里出现Using filesort(额外排序)是什么意思?怎么避免?”
列表页要“按提交时间倒序,显示最新20条”。如果索引本身就按需要的顺序排好了,数据库顺着索引读20条就可以停;如果不是,就要先把符合条件的行都找出来,再自己排一次序,这一步就是 filesort(额外排序)。名字里有“file”,但不一定写磁盘:结果集放得进内存就在内存里排,放不下才会用临时磁盘文件。[8]
看两条只差一个条件的查询:
-- 查询A:部门和状态都是等值条件
SELECT id, case_no, submitted_at FROM case_item
WHERE dept_id = 10 AND status = 0
ORDER BY submitted_at DESC LIMIT 20;
-- 查询B:只限定部门,不限定状态
SELECT id, case_no, submitted_at FROM case_item
WHERE dept_id = 10
ORDER BY submitted_at DESC LIMIT 20;
验证思路:在测试表上分别 EXPLAIN(查看执行计划的语句),看Extra里是否出现Using filesort(额外排序)。下面的判断依据官方ORDER BY优化说明,本课没有实测输出。
查询A里,dept_id 和 status 都是等值条件,索引里“部门10、待审批”这一段正好按 submitted_at 排好。官方说明:WHERE把索引前面的列固定成常量后,可以用后面的列给出顺序,避免排序;倒序也可以从另一端读。再加上LIMIT 20,用索引给出顺序时读够20条就能停,所以很快。[8] [9]
官方的规则是:索引中排在排序列前面、没有用来排序的列,都要在WHERE里被等值条件固定成常量,才能用索引给出顺序。查询B没有固定 status,所以不满足。这时LIMIT 20也救不了多少:官方说明,需要filesort(额外排序)时,要先选出所有符合条件的行、排好其中大部分,才能找到前20条。部门10所有状态的办件都要读出来。[8] [9]
改法要先问业务:
- 列表其实只看待审批。 把
status = 0补进条件。结果更符合业务,也能用上现有索引的顺序。 - 列表确实要所有状态混在一起按时间排。 现有索引给不出这个顺序,可以评估新建 (dept_id, submitted_at) 索引。新索引要占空间、拖慢写入(第10课讲过),建之前用同样条件对比改前改后的执行情况。
拓展:Using temporary 又是什么?
官方说明:查询需要建临时表存放中间结果时出现,常见于GROUP BY和ORDER BY用了不同的列,比如按部门分组统计数量、再按数量排序。它和filesort(额外排序)经常一起出现。本课不展开分组优化,遇到时先确认分组和排序的列能否和索引顺序对上。[3]
先试着自己解释,再看参考回答
Using filesort(额外排序)表示数据库不能直接按索引顺序拿到结果,要先把符合条件的行找出来,再额外排一次序,不一定写磁盘。联合索引 (dept_id, status, submitted_at) 在部门和状态都是等值条件时,剩下的这一段正好按时间有序,可以倒着读20条就停;如果跳过了status,只按部门查再按时间排,就要先取出部门所有行再排序。避免的办法是让WHERE的等值条件和ORDER BY的列一起对上索引的顺序;如果业务确实需要别的顺序,再评估新索引的写入代价。
五、深分页:翻到第1000页为什么越来越慢
“百万数据分页,LIMIT 100000, 20 为什么慢?怎么优化?”
回到第一节日志里那条语句的结构:LIMIT 19980, 20 表示跳过前19980行,返回接下来的20行,偏移量从0开始数。官方用 LIMIT 5,10 举例:返回第6到第15行。[10]
问题在“跳过”二字。数据库不能凭空知道第19981行在哪里,它要按顺序把前19980行一行一行地数过去。JavaGuide的说明是:MySQL(关系型数据库)对 LIMIT offset, N 要从头扫描offset+N行;如果查询的列不全在二级索引里,前面这些被跳过的行也要回表,读完再丢掉。下面用已经补上 status = 0、能按索引顺序读的列表页说明:
-- 第1000页,每页20条:跳过前19980条
SELECT id, case_no, applicant, submitted_at FROM case_item
WHERE dept_id = 10 AND status = 0
ORDER BY submitted_at DESC LIMIT 19980, 20;
验证思路:第六节用 EXPLAIN ANALYZE(实际执行并分析)看各步骤的实际行数。注意 EXPLAIN(查看执行计划的语句)的rows只是估计,看不出“读了多少行又丢掉”。本课没有实测输出。
即使不用filesort(额外排序),数据库也要沿索引读过20000条;applicant 不在索引里,按JavaGuide的说明,这20000条都要回表取整行,最后只留下20条。页码越大,白读的行越多。[11]
改法一:deep pagination(深分页)改成 cursor pagination(游标分页)
不再告诉数据库“跳过多少行”,而是告诉它“从上一页最后一条之后开始”。应用把上一页最后一条的提交时间和id记下来,下一页这样查:
-- 上一页最后一条:submitted_at = '2026-10-08 08:15:00',id = 5012
SELECT id, case_no, applicant, submitted_at FROM case_item
WHERE dept_id = 10 AND status = 0
AND (submitted_at < '2026-10-08 08:15:00'
OR (submitted_at = '2026-10-08 08:15:00' AND id < 5012))
ORDER BY submitted_at DESC, id DESC LIMIT 20;
验证思路:先在小数据上核对第2页的结果,和 ORDER BY submitted_at DESC, id DESC LIMIT 20, 20 的结果相同(原写法没有id作第二排序键,同一时间的行顺序不固定,不能直接对比),再用 EXPLAIN ANALYZE(实际执行并分析)对比两种写法实际读了多少行。这条改写是否走索引、有没有filesort(额外排序),必须在你的数据上核对;本课没有实测。
为什么要带上id?提交时间可能重复。如果只记时间,两条同一秒提交的办件可能一条出现在两页、一条一页都不出现;加上id作为第二排序键,顺序就唯一了。JavaGuide也建议按其他字段排序时用(排序字段, id)作联合游标。[11]
id不在 (dept_id, status, submitted_at) 这三列里,为什么排序还可能用上这个索引?官方说明:InnoDB(事务存储引擎)会自动在每个二级索引末尾追加主键列,这个索引在内部相当于 (dept_id, status, submitted_at, id),优化器做排序优化时会考虑这些主键列。所以提交时间相同的记录,在索引里本来就按id排好。实际是否用上,仍以EXPLAIN(查看执行计划的语句)为准。[13]
限制: 游标分页适合“下一页/加载更多”,不能直接跳到第537页。“上一页”要把比较方向反过来再处理顺序,本课不展开。
改法二:deferred join(延迟关联),保留页码
办件管理后台常常需要按页码跳转。这时可以先只在索引里翻页、只拿id,再用这20个id回表取整行:
SELECT c.id, c.case_no, c.applicant, c.submitted_at
FROM case_item c
JOIN (SELECT id FROM case_item
WHERE dept_id = 10 AND status = 0
ORDER BY submitted_at DESC LIMIT 19980, 20) t ON c.id = t.id
ORDER BY c.submitted_at DESC;
验证思路:先核对改写前后返回的20行和顺序相同,再用 EXPLAIN ANALYZE(实际执行并分析)比较实际行数与耗时。效果取决于数据和版本,本课没有实测,不给提升倍数。
子查询只要id,而 idx_dept_status_time 里本来就存着id(第10课:二级索引存索引列+主键),所以子查询是覆盖索引,跳过前19980条时不需要回表;外层只对最后20个id回表。它仍然要在索引里数过19980条,只是省掉了这些行的回表。外层的 ORDER BY 只给这20行排序,执行计划里看到 Using filesort(额外排序)也不必担心,代价很小。[11]
先和产品对齐: JavaGuide的生产建议是,分页方式先跟产品形态对齐。只需要“下一页”就用游标分页;后台必须跳页,就用延迟关联,并限制最大页数或要求先加筛选条件,没有人真的需要翻到第5万页。[11]
回到第一节那条日志。 它同时有两个问题:没有限定 status,所以部门10约6.1万行都要读出来再filesort(额外排序);又要跳过19980行。两处都要改,第七节练习B就是同一类问题。
先试着自己解释,再看参考回答
LIMIT 100000, 20 不是直接跳到第100001行,数据库要按顺序读过前面10万行再丢掉;如果查询列不在索引里,这些行还要回表,所以页码越大越慢。如果业务只需要下一页,我用游标分页:记住上一页最后一条的排序字段和id,下一页从它之后读20条,每页代价基本固定。如果必须按页码跳转,就用延迟关联:子查询只在覆盖索引上翻页拿id,外层只对这20个id回表,同时和产品约定最大页数。改完用实际执行结果对比验证。
六、改完以后,怎样证明真的改好了
“你怎么知道优化有效果?”
EXPLAIN(查看执行计划的语句)给的是 计划和估计 ,rows对InnoDB(事务存储引擎)只是估计值。要知道实际读了多少行、花了多久,用 EXPLAIN ANALYZE(实际执行并分析)。官方说明:它会 真正执行这条语句 ,并对执行的每一步给出估计的代价和行数、实际返回行数、实际耗时(毫秒)和循环次数。[4]
官方手册里的一个例子,表t3只有5行,查 i > 8:
-> Filter: (t3.i > 8) (cost=0.75 rows=1.67) (actual time=0.0168..0.0182 rows=1 loops=1)
-> Table scan on t3 (cost=0.75 rows=5) (actual time=0.015..0.0167 rows=5 loops=1)
演示来源:这是官方手册的示例输出,不是本课的实测结果。验证思路:从缩进最深的一行往上读,每行先比较估计rows和实际rows,再看实际耗时。
从下往上读:最里层是“Table scan on t3”,全表扫描,实际读了5行;外层“Filter”按 i > 8 筛选,估计留下1.67行,实际留下1行。括号里前一组是估计,“actual”开头的是实际:actual time=0.0168..0.0182 的前一个数是返回第一行用的时间,后一个数是这一步总共用的时间,单位毫秒;loops是这一步执行了几次,loops大于1时,耗时是每次的平均值。读慢SQL时,重点找 实际rows特别大、而上一层只留下很少几行 的那一步,那里就是白读的地方。
验证时的四条规矩:
- 只在测试环境、只对查询语句用。 EXPLAIN ANALYZE(实际执行并分析)会真的执行语句;官方说它也能用于多表UPDATE和DELETE,这对数据有什么影响本课没有核对,所以不要对修改语句使用,更不要在生产库上用。
- 同样的数据、同样的参数。 改前改后用同一份数据、同一组条件对比;换了一页、换了一个部门,数字就没有可比性。
- 一次只改一处。 同时改写SQL又加索引,就说不清是哪一处起了作用。
- 算上代价。 新建索引要占空间、拖慢写入;上线后再看慢查询日志里这条语句是否消失、耗时是否下降。
怎样讲清一次慢SQL优化
按图1的五步讲:在哪里发现的、执行计划显示什么、原因是什么、怎么改的、用什么数字验证的。 只引用实际测得、能够核对的数字。 没有亲手做过的优化,不要说成自己的经历;可以用本课的列表页例子,把排查方法完整讲一遍。
先试着自己解释,再看参考回答
EXPLAIN(查看执行计划的语句)只给计划和估计,我会在测试环境用EXPLAIN ANALYZE(实际执行并分析)对查询语句实际执行一次,看每一步实际读了多少行、花了多久,找出实际行数很大、上一层只留下几行的那一步。改前改后用同样的数据和参数对比,一次只改一处;加索引还要考虑空间和写入代价。上线后再看慢查询日志里这条语句的耗时有没有下降。
七、应用:从日志和计划判断原因
“给你一条慢SQL和它的执行计划,你能说出原因和改法吗?”
A.按工号查经办人,很慢
背景: 这是另一张表:经办人表 staff 有约20万行,工号 staff_no 是VARCHAR(10)(可变长字符串),值形如 ‘0012345’(前面补0),已建普通索引 idx_staff_no。Java代码按工号查经办人姓名。
观察到的证据(示意): 慢查询日志中这条语句 Query_time 0.9秒、Lock_time 0.0002秒、Rows_sent 1、Rows_examined约20万。Mapper(映射器)方法的工号参数类型是 Integer(整数包装类型),数据库收到的条件相当于 WHERE staff_no = 12345。EXPLAIN(查看执行计划的语句)显示 type = ALL、key = NULL。
- 日志里哪两个字段的对比说明“做了大量无用读取”?Lock_time这么小,可以先排除什么?
- 索引明明建了,为什么没用上?除了慢,工号用Integer(整数包装类型)传还可能带来什么问题(提示:‘0012345’ 和 ‘12345’ 的关系)?怎样改?
- 改完以后,你用什么证据确认已经用上 idx_staff_no?
完成后核对过程
Rows_sent只有1,Rows_examined约20万,说明为了1行检查了几乎整张表;Lock_time很小,通常可以先排除锁等待。工号是字符串列,条件却是数字:‘0012345’、‘12345’、’ 12345’ 转换成数字后都等于12345,在字符串顺序里分散在多处,数据库不能用 idx_staff_no 快速查找,只好全表扫描。用Integer(整数包装类型)传还会丢掉前面的0,如果表里同时有 ‘0012345’ 和 ‘12345’(数据不规范时可能出现),两条都会被查出来。改法:Mapper参数和DTO(数据传输对象)字段改成String(字符串类型),传 ‘0012345’。改完先EXPLAIN(查看执行计划的语句),看 key = idx_staff_no、rows降到1左右;再在测试环境用 EXPLAIN ANALYZE(实际执行并分析)看实际行数,上线后看这条语句是否从慢查询日志中消失。
B.列表页越翻越慢
背景: 经办人列表页显示本部门办件,按提交时间倒序,每页20条。页面只有“下一页”按钮,没有页码输入框,也没有“上一页”。产品确认:这个列表 只显示待审批 的办件。现在的SQL是:
SELECT id, case_no, applicant, submitted_at FROM case_item
WHERE dept_id = 10
ORDER BY submitted_at DESC LIMIT 4000, 20;
验证思路:这是练习题给定的SQL,下面的执行计划是示意。请先自己判断,再展开核对;有测试表时可以分别EXPLAIN(查看执行计划的语句)改前改后的语句验证。
观察到的证据(示意): EXPLAIN(查看执行计划的语句)显示 key = idx_dept_status_time,type = ref,Extra中有 Using filesort(额外排序)。
- 为什么会出现 Using filesort(额外排序)?在这个执行计划下,每一页都要读多少行,和页码有关吗?
- 只补上
status = 0以后,额外排序没有了,但页码越大还是越慢。为什么? - 结合产品给的两条信息(只看待审批、只有“下一页”),写出你的最终改法。
完成后核对过程
第一,WHERE只有 dept_id = 10,没有固定索引中间的 status,部门10的待审批和已办结两段各自按时间有序、合起来不有序,所以要把部门10的行都取出来再filesort(额外排序)。在这个计划下,每一页都要把部门10的行全部读出来排序,读的行数基本和页码无关,主要代价是“全部读出再排序”。
第二,补上 status = 0 后,部门和状态都是等值条件,索引这一段正好按时间有序,可以倒着读,不再额外排序。这时偏移量成了主要代价:LIMIT 4000, 20 要沿索引先读过前4000条、逐条回表取申请人,再丢掉,页码越大读得越多。
第三,只有“下一页”,就改成游标分页:记住上一页最后一条的提交时间和id,下一页用 submitted_at < 上一条时间 OR (submitted_at = 上一条时间 AND id < 上一条id),按 submitted_at DESC, id DESC 取20条。每一页都只读20条左右。最后用EXPLAIN(查看执行计划的语句)确认Using filesort(额外排序)消失,用EXPLAIN ANALYZE(实际执行并分析)对比改前改后的实际行数。
八、即时检验:先判断,再读解释
每题只问一个主要判断。先选出答案,再展开核对解释;不需要反复刷到全对才继续学习。
- 先读慢查询日志
一条语句的慢查询日志记录是:Query_time 2.8,Lock_time 0.0001,Rows_sent 20,Rows_examined 986000。
问题: 根据这四个字段,最先应该怀疑什么?
- A. 等待行锁拖慢了这条语句
- B. 检查行数远多于返回行数
- C. 返回给应用的行数太多了
查看答案与解释
答案:B
Lock_time接近0,不是等锁;只返回20行,也不算多。为了20行检查了约98.6万行,说明数据库做了大量无用读取,下一步看执行计划。
- 建了索引却没用上
case_no 是VARCHAR(20),表上已经建了 idx_case_no。查询写成 WHERE case_no = 2026100800123,EXPLAIN(查看执行计划的语句) 显示 type 为 ALL,key(实际使用的索引) 为 NULL。
问题: 最可能的原因是什么?
- A. 数字与字符串列比较
- B. 优化器认为全表更省
- C. 返回的列没有被覆盖
查看答案与解释
答案:A
优化器按代价放弃索引确实可能发生,但通常是匹配行很多;这里只找一件办件,条件却是数字。字符串列和数字比较时,很多字符串都会转换成同一个数字,官方说明这时不能用列上的索引快速查找。参数改成字符串即可。
- 让第三列也参与定位
联合索引是 (dept_id, status, submitted_at)。查询条件是 dept_id = 10 AND status = 0 AND DATE(submitted_at) = '2026-10-08',返回的行不能改变。
问题: 怎样改写,才能让 submitted_at 也用来缩小范围?
- A. 改写成十月八日当天的半开区间
- B. 把DATE换成其他的日期函数
- C. 把三个条件的书写顺序倒过来写
查看答案与解释
答案:A
索引按原始时间排序,不存函数结果。改成 submitted_at >= ‘2026-10-08’ AND submitted_at < ‘2026-10-09’,数据库就能在“部门10、待审批”这一段里定位到起点。换函数仍然套在列上;WHERE里条件的书写顺序不影响能否使用索引。
- Extra 中的 Using filesort(额外排序)
列表查询按 submitted_at 倒序取前20条,执行计划的 Extra 列出现 Using filesort(额外排序)。
问题: 它说明了什么?
- A. 已经用索引给出顺序
- B. 需要额外做一次排序
- C. 数据来自覆盖索引树
查看答案与解释
答案:B
Using filesort(额外排序)表示不能直接按索引顺序拿到结果,要把符合条件的行找出来再排序。名字里有file,但放得进内存就在内存里排,不一定写磁盘。数据来自覆盖索引时出现的是Using index。
- 越往后翻越慢
列表页已经能按索引顺序读,没有额外排序。第1000页写成 LIMIT 19980, 20,查询的列里有不在索引中的申请人姓名。
问题: 为什么页码越大越慢?
- A. 前面的行也要读过再丢
- B. 每一页返回的行数更多
- C. 后面几页的数据量更大
查看答案与解释
答案:A
数据库不能直接跳到第19981行,要按顺序读过前面的行再丢掉;申请人不在索引里,这些行还要回表。每页都只返回20条,不是返回得更多;页码越大,白读的行越多。
- 用实际执行结果验证
改写后想知道这条查询实际读了多少行、花了多久,准备使用 EXPLAIN ANALYZE(实际执行并分析)。
问题: 使用前最需要知道什么?
- A. 它的rows是估算值
- B. 它会真的执行这条语句
- C. 它只能用在更新语句上
查看答案与解释
答案:B
官方说明它会真正执行语句,再给出每一步的实际行数和耗时。所以只在测试环境、对查询语句使用;它会输出实际行数,不只是估算;只估算、不执行的是普通的EXPLAIN(查看执行计划的语句)。
九、用完整回答检验理解
可以写下来,也可以先口述再整理成文字。每问先说发生了什么,再解释为什么;没有亲手做过的优化,不要说成自己的经历。
第一问:列表页慢,你怎么排查
经办人反映办件列表页打开要好几秒,你能查看测试和线上的慢查询日志,也能在测试环境执行SQL。
请回答: 你按什么顺序排查?每一步看什么证据?慢查询日志里哪两个字段的对比最有用,为什么?
第二问:建了索引却没用上
办件表有联合索引 (dept_id, status, submitted_at) 和办件号索引 idx_case_no(办件号是字符串)。
请回答: 举出至少两种让这些索引用不上的写法,用“索引按原始值排序、要能找到起点”解释原因,并分别给出改法。再说一种写法正确、优化器仍然不走索引的情况。
第三问:深分页怎么优化
列表页按提交时间倒序,查询列里有不在索引中的申请人姓名,用户翻到第1000页时很慢。
请回答: 为什么页码越大越慢?如果只需要上一页/下一页,你怎么改?如果必须按页码跳转,你怎么改?两种改法各有什么限制?
第四问:怎样证明优化有效
你已经把列表页的SQL改写好,准备上线。
请回答: 上线前用什么方法、在什么环境验证?对比时要控制哪些条件?EXPLAIN(查看执行计划的语句) 和 EXPLAIN ANALYZE(实际执行并分析) 有什么区别?上线后还看什么?
练习:请在自己的笔记中写出答案(可附第七节的判断过程),再对照各节的参考回答核对。本课末尾的课后问答整理了这四问里容易说偏的地方,并给出第一问排查顺序的参考说法。
十、常见追问:把容易混的地方分开
先自己解释,再展开核对
type 是 index,是不是说明索引用得很好?
不是。index表示把整棵索引树从头读到尾,和ALL一样是全部读一遍,只是索引通常比整张表小。要的是range、ref这类只读一部分的访问方式,并且rows要小。
WHERE 里把条件的顺序写成 status、dept_id,会不会违反最左前缀?
不会。最左前缀看的是条件里用到了索引的哪些列,不是条件在WHERE里的书写顺序。两个等值条件都在,就能用上联合索引的前两列。
数字列拿字符串比,例如 dept_id = '10',也会让索引失效吗?
本课的官方依据讲的是“字符串列和数字比较”这个方向:很多字符串会转换成同一个数字,所以不能用字符串列上的索引查找。反方向本课没有核对官方原文,不下结论;遇到时用EXPLAIN(查看执行计划的语句)在你的数据上确认,代码里也应让参数类型和列类型一致。
EXPLAIN(查看执行计划的语句) 里 rows 是 30,实际却很慢,可能吗?
可能。对InnoDB(事务存储引擎)来说rows是估计值,不一定准确;深分页这类“读过再丢”的代价也不一定体现在估计里。所以验证要用实际执行结果,而不是只看估计。
游标分页能不能直接用 WHERE submitted_at < 上一条时间,不带id?
提交时间可能重复。只按时间比较,同一秒提交的几条办件可能被跳过或重复出现。加上id作为第二排序键,并在条件里处理“时间相等时比较id”,顺序才唯一。
十一、隔日复测:换一个业务对象
隔一天再用五分钟回答;约一周后可以换个场景再试。刚看完课就复述,只能算即时练习。
1. 用户表 user 的手机号 phone 是VARCHAR(20),已建索引。查询写成 WHERE phone = 13800000000,执行计划key(实际使用的索引)为NULL。原因和改法是什么?
字符串列和数字比较,很多字符串都会转换成同一个数字,数据库不能用 phone 上的索引快速查找。参数改成字符串 ‘13800000000’,Java代码里对应字段用String(字符串类型)。
2. 订单表有索引 (user_id, created_at)。查询 WHERE user_id = 7 ORDER BY created_at DESC LIMIT 10 会出现 Using filesort(额外排序) 吗?换成 ORDER BY amount DESC 呢?
第一条通常不会:user_id固定后,索引里这一段正好按created_at有序,可以倒着读10条就停。第二条amount不在这个索引里,要先取出用户7的所有订单,再按金额额外排序。都以EXPLAIN(查看执行计划的语句)实际输出为准。
3. 消息表有索引 (conversation_id, created_at),查询 WHERE conversation_id = 8 ORDER BY created_at DESC LIMIT 50000, 20,查询列里有不在索引中的消息正文。页面只有“加载更多”按钮。应该怎样改?为什么?
改成游标分页:记住上一批最后一条的 created_at 和id,下一批从它之后读20条。原写法要先读过前5万条再丢掉;游标分页每次只读20条左右。只有“加载更多”,不需要跳页,正适合游标分页。
第14课复习:binlog(二进制日志)和备份是什么关系?恢复到某个时间点要怎么做?
binlog(二进制日志)不是备份。备份是某个时间点的完整副本;binlog(二进制日志)按事务记下之后的变更。先恢复备份,再从备份对应的位置开始重放binlog(二进制日志),直到目标时间点。
官方来源与配套材料
本课速查表 · 完整术语表 · 上一课:第14课 · 下一课:第16课。日志记录、执行计划里的数字均为教学设定,不是实测结果;读者在自己的数据和版本上得到的结果会不同。
慢查询日志:记录条件、默认值、各字段含义。
执行计划输出格式(
EXPLAIN Output Format):rows、filtered、type的含义与好坏顺序。执行计划的Extra信息:Using filesort(额外排序)、Using index、Using where等。
EXPLAIN ANALYZE(实际执行并分析):真正执行语句,输出实际行数与耗时。
表达式求值中的类型转换:字符串列与数字比较不能用索引查找。
B树与哈希索引的比较:LIKE前缀可用索引,通配符开头不用。
CREATE INDEX:函数索引:普通索引存列值,函数索引存表达式值。
ORDER BY优化:何时用索引给出顺序、何时需要filesort(额外排序)。
LIMIT优化:排序加LIMIT时,索引顺序与filesort(额外排序)的差别。
SELECT语句:LIMIT偏移量与行数的含义。
JavaGuide:深度分页介绍及优化建议(第三方):读过再丢弃的原因、游标分页、延迟关联与生产建议。
JavaGuide:MySQL索引详解·避免索引失效(第三方):对索引列计算、隐式转换、前置通配符与优化器代价选择的归纳。
索引扩展:InnoDB(事务存储引擎)二级索引末尾自动追加主键列,可用于排序优化。
学完后,试着沿同一张办件表回答两个问题:“为什么补上status就不用排序了”“延迟关联为什么还要数过前面的行”。隔日回看时先尝试回答,再核对课程里的解释,不只背“索引失效的几种情况”。
课后问答:理解是怎样变化的
问题与结论默认可见。展开后可看理解过程;以下根据真实交流整理,并非逐字稿。
办件号 case_no 是 VARCHAR(20) 并建了索引,Java代码却把办件号当 Long 传进SQL。为什么索引用不上?
最终结论: 字符串列拿数字比较时,发生 implicit type conversion(隐式类型转换),能等于同一个数字的字符串在索引里不挨着,数据库找不到起点,于是不用这个索引查找。后果不是“多比较几次”,而是从“按索引找1行”变成“检查整张表”。改法:DTO(数据传输对象)和 Mapper(映射器)的参数都用 String(字符串类型),类型和列保持一致。
展开理解过程与易混点
最初理解(归纳): 曾把后果说成增加了“比对负担”,并用“多个对应一个”来解释原因;方向接近,但没有说清是哪一边多、为什么因此用不了索引。
纠正与解释: 要反过来说:是很多不同的字符串(‘2026100800123’、’ 2026100800123’、‘2026100800123a’……)转换成数字后都等于同一个数。这些字符串在索引里按字符排序,位置分散,数据库找不到一个连续区间。官方说明这种情况下不能用该列上的索引快速查找,只能全表扫描、逐行转换再比较。
最终结论: 字符串列拿数字比较时,发生 implicit type conversion(隐式类型转换),能等于同一个数字的字符串在索引里不挨着,数据库找不到起点,于是不用这个索引查找。后果不是“多比较几次”,而是从“按索引找1行”变成“检查整张表”。改法:DTO(数据传输对象)和 Mapper(映射器)的参数都用 String(字符串类型),类型和列保持一致。
现有复盘未逐条保留中间追问,本页不补写对话。
联合索引是 (dept_id, status, submitted_at)。查询只给 dept_id = 10、漏传 status 时,索引的哪一部分用不上?会有什么后果?
最终结论: 两个后果:按时间查一段时,时间范围不能用来缩小范围,只能把部门10整段读完、逐条检查时间(第10课的最左前缀);按时间排序时,索引给不出整体的时间顺序,要额外排序,即 Using filesort(额外排序)。改法“补上 status = 0”的前提是业务本来就只看待审批;如果列表确实要所有状态混在一起,补 status 会改变查询结果,这时要评估新建 (dept_id, submitted_at) 索引。
展开理解过程与易混点
最初理解(归纳): 曾举出“漏了status的等值条件”这种写法,写法本身正确,但没有说清它让索引的哪一部分失效。
纠正与解释: 联合索引按“部门 → 状态 → 时间”排序。只给 dept_id = 10 时,部门10里待审批和已办结是两段,每段内部按时间排好,合起来却不按时间排。于是第三列 submitted_at 帮不上忙:10月8日的记录分散在两段里,没有一段连续区间可读。
最终结论: 两个后果:按时间查一段时,时间范围不能用来缩小范围,只能把部门10整段读完、逐条检查时间(第10课的最左前缀);按时间排序时,索引给不出整体的时间顺序,要额外排序,即 Using filesort(额外排序)。改法“补上 status = 0”的前提是业务本来就只看待审批;如果列表确实要所有状态混在一起,补 status 会改变查询结果,这时要评估新建 (dept_id, submitted_at) 索引。
现有复盘未逐条保留中间追问,本页不补写对话。
SQL写法完全正确,执行计划里 key(实际使用的索引)仍为 NULL,可能是什么原因?
最终结论: optimizer(查询优化器)可能判断全表扫描更便宜,主动不走索引。所以看到 key = NULL 不一定是写错了,要先看 rows 和数据分布,再下结论。
展开理解过程与易混点
纠正与解释: B树索引能用,是因为数据库能在按原始值排好的顺序里找到起点、连续读一段。但能用不等于一定用:如果条件匹配了表里一大半的行,而每行都要回表,逐行回表可能比直接扫全表还贵。
最终结论: optimizer(查询优化器)可能判断全表扫描更便宜,主动不走索引。所以看到 key = NULL 不一定是写错了,要先看 rows 和数据分布,再下结论。
现有复盘未逐条保留中间追问,本页不补写对话。
改写后怎样证明慢SQL真的变快了?EXPLAIN(查看执行计划的语句)和 EXPLAIN ANALYZE(实际执行并分析)各给什么?上线后还看什么?
最终结论: 上线前在测试环境验证:先用 EXPLAIN 确认用上了预期的索引、Using filesort(额外排序)消失;再用 EXPLAIN ANALYZE 对查询语句实际执行,以同样的数据和参数、一次只改一处,比较改前改后的实际行数和耗时。因为 EXPLAIN ANALYZE 会真的执行,不在生产上对修改语句使用。上线后回到慢查询日志,看这条语句是否不再出现、耗时和检查行数是否下降;如果新建了索引,还要留意写入是否变慢。
展开理解过程与易混点
最初理解(归纳): 曾说“用EXPLAIN分析,然后对比花费时间”,把两个工具混在了一起;对“上线后还看什么”没有答出。同时已正确说出在测试环境、每次只改一处、对比改前改后,也知道 EXPLAIN ANALYZE 会真的执行SQL。
纠正与解释: EXPLAIN 只给计划和估计:type、key、估计的 rows、Extra,不执行,也不给耗时。EXPLAIN ANALYZE 会真正执行一次,给出每一步的实际行数和实际耗时。slow query log(慢查询日志)记录线上真实请求的耗时、检查行数和返回行数。对比时还要用同一份数据、同一组参数,换了部门或换了一页,数字就没有可比性;而且不只看耗时,耗时会受缓存和机器负载影响,实际检查行数(例如从6万降到20)更能说明问题。
最终结论: 上线前在测试环境验证:先用 EXPLAIN 确认用上了预期的索引、Using filesort(额外排序)消失;再用 EXPLAIN ANALYZE 对查询语句实际执行,以同样的数据和参数、一次只改一处,比较改前改后的实际行数和耗时。因为 EXPLAIN ANALYZE 会真的执行,不在生产上对修改语句使用。上线后回到慢查询日志,看这条语句是否不再出现、耗时和检查行数是否下降;如果新建了索引,还要留意写入是否变慢。
现有复盘未逐条保留中间追问,本页不补写对话。
列表页打开很慢,应该按什么顺序排查?
最终结论: 按五步:① 在慢查询日志里找到这个页面对应的语句,看执行时间、等锁时间、返回行数和检查行数;② 对这条语句执行 EXPLAIN(查看执行计划的语句),看 type、key、rows、Extra;③ 说出原因;④ 改写SQL或调整索引,一次只改一处;⑤ 在测试环境用 EXPLAIN ANALYZE(实际执行并分析)以同样的数据和参数对比实际行数和耗时,上线后再看慢查询日志确认。没有变快就回到第②步重新看计划。
展开理解过程与易混点
纠正与解释: 关键是先找证据,不凭感觉加索引。慢查询日志里返回行数和检查行数的对比最有用:返回20行却检查了几万行,说明大量无用读取;等锁时间很长,则应按锁等待去查。执行计划用来判断原因是全表扫描、索引用不上、额外排序还是深分页。
最终结论: 按五步:① 在慢查询日志里找到这个页面对应的语句,看执行时间、等锁时间、返回行数和检查行数;② 对这条语句执行 EXPLAIN(查看执行计划的语句),看 type、key、rows、Extra;③ 说出原因;④ 改写SQL或调整索引,一次只改一处;⑤ 在测试环境用 EXPLAIN ANALYZE(实际执行并分析)以同样的数据和参数对比实际行数和耗时,上线后再看慢查询日志确认。没有变快就回到第②步重新看计划。
现有复盘未逐条保留中间追问,本页不补写对话。
deferred join(延迟关联)多了一层子查询,为什么反而能让深分页变快?
最终结论: 延迟关联快在省掉了被跳过那些行的回表;实际效果取决于数据和版本,要用实际执行结果验证。但它仍要在索引里数过前面的行,页码越大代价仍越高,所以还要和产品约定最大页数;只需要“下一页”时,cursor pagination(游标分页)更合适,但不能跳页。
展开理解过程与易混点
最初理解(归纳): 曾认为延迟关联“加了子查询,也许会增加开销”;作为取舍判断没有问题,但没有说出它快在哪里。
纠正与解释: 子查询只取 id,而 (dept_id, status, submitted_at) 索引末尾本来就带着主键 id,所以子查询是 covering index(覆盖索引),跳过前面那些行时不用回表;外层只对最后20个 id 回表。
最终结论: 延迟关联快在省掉了被跳过那些行的回表;实际效果取决于数据和版本,要用实际执行结果验证。但它仍要在索引里数过前面的行,页码越大代价仍越高,所以还要和产品约定最大页数;只需要“下一页”时,cursor pagination(游标分页)更合适,但不能跳页。
现有复盘未逐条保留中间追问,本页不补写对话。