04-慢SQL排查实战:count、order-by、索引失效与JOIN优化全解析

📅 2026/7/29 19:29:28 👁️ 阅读次数 📝 编程学习
04-慢SQL排查实战:count、order-by、索引失效与JOIN优化全解析

04-慢SQL排查实战:count、order-by、索引失效与JOIN优化全解析

参考丁奇《MySQL实战45讲》第14讲(count)、第16讲(order by)、第34讲(JOIN 算法)、第35讲(JOIN 优化)、第18讲(隐式转换/索引失效)。
本文所有命令与回显均取自真实云服务器实验的完整留档日志,未做任何编造,宁缺毋假。文末附「8.0 排序机制演进」增量小节,超越传统 45 讲内容。


一、引言:半夜被一条 SQL 叫醒

运维群里一句“数据库 CPU 打满”,点开慢查询日志,八成是下面这几类老面孔:

  • select count(*) from t跑了老半天——有人问“count(1) 是不是更快?”
  • 列表页order by create_time desc越翻越慢,数据一多就卡死。
  • where tradeid = 110717明明建了索引却全表扫。
  • 两张表join之后慢得离谱,执行计划里出现Using join buffer

这一讲,我们把这些“名场面”在实验机上逐个复现,用profilingEXPLAINOPTIMIZER_TRACESHOW WARNINGS把真实数字抓出来。环境和上一篇同源(Ubuntu 24.04,8C/14G,MySQL 8.0.46)。


二、实验环境说明

$ uname -a Linux ecs-fb52-0002 6.8.0-106-generic #106-Ubuntu SMP PREEMPT_DYNAMIC Fri Mar 6 07:58:08 UTC 2026 x86_64 $ free -h | head -2 total used free shared buff/cache available Mem: 14Gi 510Mi 14Gi 2.5Mi 476Mi 14Gi $ mysql -uroot -e "select version();" version() 8.0.46-0ubuntu0.24.04.3

本讲用于慢查询实验的表:

  • t:沿用 03 篇的b2.t(10 万行,id主键,a/brand()随机值,index(a))。
  • t_order:新建t_order(id pk auto_increment, city varchar(16), name varchar(16), age int, addr varchar(128), key(city)),灌1 万行全是city='杭州'的数据:
$ mysql -uroot b2 -e ' drop table if exists t_order; create table t_order(id int primary key auto_increment, city varchar(16), name varchar(16), age int, addr varchar(128), key(city)); set cte_max_recursion_depth=20000; insert into t_order(city,name,age,addr) with recursive s(seq) as (select 1 union all select seq+1 from s where seq<10000) select "杭州", concat("name", lpad(floor(rand()*100000),6,"0")), floor(rand()*80)+18, concat("浙江省杭州市西湖区文一西路", seq, "号") from s; select count(*) from t_order;' count(*) 10000

合规说明:本文不出现任何密码;如涉及公网 IP 一律打码为124.70.***.***(本次日志中出现的登录来源 IP 均已打码)。


三、count 怎么写最快?——profiling 实测三轮

一个经典误区:“count(*)慢,count(1)快”。我们用profiling把四种常见写法计时,每种跑三轮,消除波动(均返回 100000):

$ mysql -uroot b2 -e 'set profiling=1; select count(*) from t; select count(1) from t; select count(id) from t; select count(a) from t; show profiles;' count(*) 100000 count(1) 100000 count(id) 100000 count(a) 100000 === 第一轮 === Query_ID Duration Query 1 0.00819050 select count(*) from t 2 0.00747175 select count(1) from t 3 0.00913975 select count(id) from t 4 0.00947625 select count(a) from t === 第二轮 === Query_ID Duration Query 1 0.00819275 select count(*) from t 2 0.00758725 select count(1) from t 3 0.00923525 select count(id) from t 4 0.00949250 select count(a) from t === 第三轮 === Query_ID Duration Query 1 0.00805825 select count(*) from t 2 0.00739350 select count(1) from t 3 0.00900725 select count(id) from t 4 0.00935900 select count(a) from t

取三轮均值:

写法平均耗时 (s)实测结论
count(1)0.007484最快
count(*)0.008147count(1)几乎相同
count(id)0.009127略慢(需确认主键非空)
count(a)0.009443最慢(需逐行排除 NULL)

原理(为什么是这个排序):

  • InnoDB没有像 MyISAM 那样缓存行数,所以count必须真实扫一遍;优化器会挑最小的索引来扫以省 IO。
  • count(*)count(1)在优化器层面被等价处理,不关心具体值,因此最快。本实验count(1)略快于count(*),但差距仅在微秒级(~0.0007s),可视为等价。
  • count(主键)需要确认主键非空(实际仍走最小索引 + 判断),稍多一点点工作。
  • count(普通列)必须逐行判断该列是否为 NULL、并排除 NULL,因此比count(*)多一步判断。本实验a是可空二级索引列,count(a)最慢(平均 0.009443s,比count(*)慢约 0.0013s),正印证了“判 NULL”的额外开销。

结论很朴素:能写count(*)就写count(*),语义清晰、性能不输count(1);别再无脑把它改成count(1),更别用count(列)去统计“有多少行”(它语义是“非 NULL 的行数”,与行数不等价)。

分页总条数的替代方案:InnoDB 的SELECT COUNT(*)要真扫一遍,数据一大就慢。实战里常见替代:用EXPLAINrows估算“约 N 条”;或维护一张计数表在写入时同步增减;或用「覆盖索引 + 游标分页(WHERE id > ? LIMIT ?)」避免算总数。


四、order by 为什么会慢?——排序模式与 OPTIMIZER_TRACE

4.1 先跑一次真实的排序

t_order上只有key(city)where city='杭州' order by name的排序键name不在任何索引里,必然 filesort:

$ mysql -uroot b2 -e 'explain select city,name,age from t_order where city="杭州" order by name limit 1000; explain select city,name,age from t_order where city="杭州" order by id limit 1000;' id select_type table type possible_keys key key_len ref rows filtered Extra 1 SIMPLE t_order ref city city 67 const 5000 100.00 Using filesort <-- order by name 无法利用 city 索引顺序 → filesort id select_type table type possible_keys key key_len ref rows filtered Extra 1 SIMPLE t_order ref city city 67 const 5000 100.00 NULL <-- order by id:city 二级索引叶子按 (city,id) 存,id 有序 → 免 filesort

注意第二个查询:order by id居然Extra=NULL(无 filesort)!因为city这个二级索引的叶子节点是按(city, id)排序的——在city='杭州'过滤后,行天然按id有序,所以order by id直接吃索引顺序,无需额外排序。这正是“排序键要尽量落在索引顺序里”的实机证据。

4.2 外部排序实锤:Sort_merge_passes=6

sort_buffer_size压到32K,让内存根本排不下 1 万行,触发磁盘外部归并排序:

$ mysql -uroot b2 -e 'set sort_buffer_size=32768; flush status; select city,name,age,addr from t_order where city="杭州" order by name; show session status like "Sort_merge_passes"; show session status like "Sort_rows";' city name age addr 杭州 name000025 24 浙江省杭州市西湖区文一西路9302号 ...(按 name 升序返回 1 万行)... Variable_name Value Sort_merge_passes 6 Variable_name Value Sort_rows 10000

Sort_merge_passes=6Sort_rows=10000——确认发生了磁盘外部排序,且归并了 6 趟。这正是order by突然变慢的典型信号:内存排序缓冲不够,MySQL 把中间结果写到磁盘、再分趟归并。

4.3 用 OPTIMIZER_TRACE 看真正的排序参数

打开OPTIMIZER_TRACE,直接看filesort_summary(默认sort_buffer_size即 256K 档):

$ mysql -uroot b2 -e 'set optimizer_trace="enabled=on"; set optimizer_trace_max_mem_size=1000000; select city,name,age from t_order where city="杭州" order by name limit 1000; select trace from information_schema.OPTIMIZER_TRACE\G' \ | grep -E 'memory_available|row_size|max_rows_per_buffer|num_rows_found|num_initial_chunks|peak_memory|sort_mode' "filesort_summary": { "memory_available": 262144, "row_size": 404, "max_rows_per_buffer": 648, "num_rows_estimate": 5060, "num_rows_found": 10000, "num_initial_chunks_spilled_to_disk": 3, "peak_memory_used": 262144, "sort_mode": "<varlen_sort_key, packed_additional_fields>"

关键字段解读:

字段含义
memory_available262144排序可用内存(即sort_buffer_size,256K)
row_size404单行排序记录长度(字节)
max_rows_per_buffer648一个 sort buffer 能装约 648 行
num_rows_found10000实际参与排序的行数
num_initial_chunks_spilled_to_disk3初始阶段就向磁盘溢出了 3 个 chunk
peak_memory_used262144峰值内存用满
sort_mode<varlen_sort_key, packed_additional_fields>排序模式

注意sort_mode已经是<varlen_sort_key, packed_additional_fields>——这是 MySQL 8.0 的统一排序模式。接下来第八节的“8.0 排序机制演进”会重点拆解它,以及为什么传统 45 讲里“全字段排序 vs rowid 排序”的二分法在 8.0 已经过时。


五、8.0 排序机制演进(增量价值点,超越传统 45 讲)

传统《MySQL实战45讲》(基于 5.6/5.7)讲 filesort 时,会强调两套sort_mode

  • <sort_key, rowid>:单行只存“排序键 + 行指针(rowid)”,排完按 rowid 回表取数据(多一次回表);
  • <sort_key, 附加字段>:单行含“排序键 + 查询需要的其它字段”,免回表但单行更宽。

并说由max_length_for_sort_data决定:单行总长超过它,就退回<sort_key, rowid>。但在MySQL 8.0,这套说法已经过时。我们用今天(07-29)的实验日志逐条验证三个关键变化。

5.1max_length_for_sort_data已被废弃(真实 Warning 1287)

直接设置这个参数,MySQL 8.0 明确给出废弃警告:

$ mysql -uroot b2 -e 'set max_length_for_sort_data=16; show warnings;' Level Code Message Warning 1287 '@@max_length_for_sort_data' is deprecated and will be removed in a future release.

Warning 1287说得很清楚:这个变量已被废弃,未来版本会移除。所以任何“调大max_length_for_sort_data来避免 rowid 排序”的 5.7 时代经验,在 8.0 上既无效(参数还在但无意义)又会触发废弃警告。

5.2 8.0 的 sort_mode 已统一为packed_additional_fields

我们分别用“默认参数”和“强制设max_length_for_sort_data=16”跑同一个排序,看 trace 的sort_mode

-- 默认(不设 max_length_for_sort_data) "sort_mode": "<varlen_sort_key, packed_additional_fields>" -- 强制 set max_length_for_sort_data=16 之后 "sort_mode": "<varlen_sort_key, packed_additional_fields>"

两者完全一致,仍然是<varlen_sort_key, packed_additional_fields>。说明 8.0 已经不再用max_length_for_sort_data去切换“全字段/rowid”两种模式——它统一成了“变长排序键 + 紧凑附加字段”的实现,无论你怎么设那个废弃参数都不变。

5.3 加 TEXT 列也还是packed_additional_fields

再进一步:给t_order加一个note TEXT列(每行塞 200 个字符),让单行变得很大,再看 trace:

$ mysql -uroot b2 -e 'alter table t_order add column note text; update t_order set note=repeat(char(97+floor(rand()*26)), 200) where city="杭州" or 1=1;' $ mysql -uroot b2 -e 'set optimizer_trace="enabled=on"; set optimizer_trace_max_mem_size=1000000; select city,name,age,note from t_order where city="杭州" order by name limit 1000; select trace from information_schema.OPTIMIZER_TRACE\G' \ | grep -E 'row_size|sort_mode' "row_size": 65941, "sort_mode": "<varlen_sort_key, packed_additional_fields>"

row_size从 404 暴涨到65941(因为 TEXT 大字段被纳入排序记录),但sort_mode依然是<varlen_sort_key, packed_additional_fields>——8.0 用紧凑的变长编码 + 溢出页机制处理大字段,不再退化成传统的 rowid 排序。这正是 8.0 排序子系统的关键改进:排序模式不再因“行长超限”而二选一,而是统一走紧凑打包

5.4 内存不够时:磁盘溢出 79 个 chunk(真实 trace)

sort_buffer_size压到32K(32768),且查询带 TEXT 大字段(row_size=65941),内存彻底装不下,看 trace 的溢出情况:

$ mysql -uroot b2 -e 'set optimizer_trace="enabled=on"; set optimizer_trace_max_mem_size=1000000; set sort_buffer_size=32768; select city,name,age,note from t_order where city="杭州" order by name limit 1000; select trace from information_schema.OPTIMIZER_TRACE\G' \ | grep -E 'memory_available|row_size|num_rows_found|num_initial_chunks|peak_memory|sort_mode' "memory_available": 32768, "row_size": 65941, "num_rows_estimate": 4930, "num_rows_found": 10000, "num_initial_chunks_spilled_to_disk": 79, "peak_memory_used": 33792, "sort_mode": "<varlen_sort_key, packed_additional_fields>"
字段32K + TEXT 场景对比(256K 场景 §4.3)
memory_available32768262144
row_size65941404
num_initial_chunks_spilled_to_disk793
peak_memory_used33792262144
sort_mode<varlen_sort_key, packed_additional_fields>同左

num_initial_chunks_spilled_to_disk=79——初始阶段就向磁盘溢出了79 个 chunk,这正是 §4.2 里Sort_merge_passes=6的底层成因:内存只 32K、单行却 64KB+,一个 buffer 连一行都快装不下,只能疯狂落盘、再分趟归并。这把“内存不足 → 磁盘外部排序 → 慢”的链路用实机 trace 钉死了。

8.0 filesort 流程图(与 5.7 的本质区别): 5.7 时代(二分法,已过时): row_size 小 ── max_length_for_sort_data 够大 ──▶ <sort_key, 附加字段> (免回表) row_size 大 ── 超过阈值 ────────────────────▶ <sort_key, rowid> (多一次回表) 8.0 时代(统一,实测): 无论参数怎么设、无论是否带 TEXT 大字段 ── 永远 ──▶ <varlen_sort_key, packed_additional_fields> 内存不够时 → num_initial_chunks_spilled_to_disk 飙升 → 磁盘外部排序 (max_length_for_sort_data 已被废弃,Warning 1287)

给排查者的结论

  • 在 8.0 上别再调max_length_for_sort_data(已废弃、无效)。
  • 想要order by快,优先级是:① 让排序键走索引(根本免排序,见 §4.1 的order by id免 filesort)> ② 适当调大sort_buffer_size减少落盘(看num_initial_chunks_spilled_to_diskSort_merge_passes
  • 出现Using filesortSort_merge_passes在涨,就是磁盘排序的明确信号。

六、索引失效排查(三类典型)

6.1 对列做函数运算 —— 索引直接失效

实机回显如下:

$ mysql -uroot b2 -e 'explain select * from t where id+1=1000; explain select * from t where id=999;' id select_type table type possible_keys key key_len ref rows filtered Extra 1 SIMPLE t ALL NULL NULL NULL NULL 100256 100.00 Using where <-- id+1=1000:全表扫描,索引没用上 id select_type table type possible_keys key key_len ref rows filtered Extra 1 SIMPLE t const PRIMARY PRIMARY 4 const 1 100.00 NULL <-- id=999:走主键 const,仅扫 1 行

where id+1=1000在列id上套了函数,优化器无法用 B+树的有序性定位,只能全表扫 10 万行;等价写成id=999立刻走主键、只扫 1 行。任何对索引列的函数/运算(DATE(create_time)id+1a*2等)都会让索引失效,应把运算移到常量侧。

6.2 字符串列传数字 —— 隐式转换让索引“假命中”

建一张tsvvarchar但被当成数字查:

$ mysql -uroot b2 -e 'create table ts(id int primary key auto_increment, v varchar(20), d datetime, key(v), key(d)); insert into ts(v,d) values("123",now()),("456","2026-01-15 10:00:00"),("789","2026-03-20 11:00:00");'

(1)把数字传给 varchar 列(错误写法):

$ mysql -uroot b2 -e 'explain select * from ts where v=123; show warnings;' id select_type table type possible_keys key key_len ref rows filtered Extra 1 SIMPLE ts ALL v NULL NULL NULL 3 33.33 Using where <-- key=NULL,全表扫描! Level Code Message Warning 1739 Cannot use ref access on index 'v' due to type or collation conversion on field 'v' Warning 1739 Cannot use range access on index 'v' due to type or collation conversion on field 'v' Note 1003 /* select#1 */ select `b2`.`ts`.`id` AS `id`,`b2`.`ts`.`v` AS `v`,`b2`.`ts`.`d` AS `d` from `b2`.`ts` where (`b2`.`ts`.`v` = 123)

key=NULLtype=ALL——索引彻底没用上。SHOW WARNINGS里的Warning 1739直接点明:因为类型/排序规则转换,v索引无法用于 ref/range 访问。优化器重写的 SQL 仍是v = 123(数字),MySQL 在“字符串列 vs 数字常量”比较时,会按类型转换规则把列侧转成数字上下文,于是v上的索引无法用于等值定位,退化成全表扫描。

(2)把值用引号包成字符串(正确写法):

$ mysql -uroot b2 -e 'explain select * from ts where v="123";' id select_type table type possible_keys key key_len ref rows filtered Extra 1 SIMPLE ts ref v v 83 const 1 100.00 NULL <-- type=ref、rows=1,走索引等值查找

(3)对日期列用函数(month())vs 日期范围

$ mysql -uroot b2 -e 'explain select * from ts where month(d)=1; explain select * from ts where d>="2026-01-01" and d<"2026-02-01";' id select_type table type possible_keys key key_len ref rows filtered Extra 1 SIMPLE ts ALL NULL NULL NULL NULL 3 100.00 Using where <-- month(d)=1:函数套在列上,索引 d 失效,全表扫 id select_type table type possible_keys key key_len ref rows filtered Extra 1 SIMPLE ts range d d 6 NULL 1 100.00 Using index condition <-- 日期范围:走索引 d 的 range 扫描,rows=1

month(d)=1在列上套函数 →type=ALL全表扫;改成d>='2026-01-01' and d<'2026-02-01'范围查询type=rangekey=drows=1Using index condition。规律一致:函数作用在索引列上 → 索引失效;把函数挪到常量侧、用范围等价改写 → 索引恢复

隐式转换导致索引失效流程图: where v = 123 (v 是 varchar, 123 是 int) MySQL 规则: 字符串列 与 数字比较 -> 把"列"转成数字 => 相当于 where CAST(v AS signed)=123 => 列上套了函数 -> 索引无法定位 -> 全表扫描 (type=ALL, Warning 1739) 正确: where v = '123' (两边都是字符串) => 直接等值匹配 -> ref 查找 (type=ref, rows=1)

同类坑:用数字查char/varchar主键、用字符串查int列、联表时两表关联字段类型/字符集不一致(一张utf8mb4一张utf8),都会触发隐式转换、索引失效。建表时让关联字段类型严格一致,能从根上避免。


七、JOIN 优化:INLJ 还是 Hash Join?

7.1 实验准备

t1t的前 1000 行(都有index(a)),t本身 10 万行:

$ mysql -uroot b2 -e 'create table t1(id int primary key, a int, b int, index(a)); insert into t1 select id,a,b from t limit 1000; select count(*) from t1;' count(*) 1000

7.2 INLJ:被驱动表能用上索引

关联字段t1.a = t.ata上有索引,走Index Nested-Loop Join(INLJ)

$ mysql -uroot b2 -e 'explain select * from t1 straight_join t on t1.a=t.a; set profiling=1; select count(*) from (select t.id from t1 straight_join t on t1.a=t.a) x; show profiles;' id select_type table type possible_keys key key_len ref rows filtered Extra 1 SIMPLE t1 ALL a NULL NULL NULL 1000 100.00 Using where 1 SIMPLE t ref a,ab a 5 b2.t1.a 1 100.00 NULL <-- t 通过 t1.a 去自己的索引 a 做 ref 查找,再回表 count(*) 1945 Query_ID Duration Query 1 0.00133175 select count(*) from (select t.id from t1 straight_join t on t1.a=t.a) x

7.3 Hash Join:被驱动表无可用索引(8.0 默认)

把关联条件改成t1.b = t.btb没有索引)。MySQL 8.0 不会退化成老式 BNL 的“驱动表每行 × 被驱动表全表扫”,而是用内存Hash Join

$ mysql -uroot b2 -e 'explain select * from t1 straight_join t on t1.b=t.b; set profiling=1; select count(*) from (select t.id from t1 straight_join t on t1.b=t.b) x; show profiles; show variables like "join_buffer_size";' id select_type table type possible_keys key key_len ref rows filtered Extra 1 SIMPLE t1 ALL NULL NULL NULL NULL 1000 100.00 NULL 1 SIMPLE t ALL NULL NULL NULL NULL 100256 10.00 Using where; Using join buffer (hash join) <-- t 是 type=ALL 且 Using join buffer (hash join) count(*) 2058 Query_ID Duration Query 1 0.01265925 select count(*) from (select t.id from t1 straight_join t on t1.b=t.b) x Variable_name Value join_buffer_size 262144
JOIN 方式执行计划特征关联行数耗时 (s)
INLJ(被驱动表t.a有索引)tref索引a19450.00133175
Hash Join(被驱动表t.b无索引)Using join buffer (hash join)20580.01265925

实测差距约 9.5 倍(0.0127 / 0.0013 ≈ 9.5)。INLJ 下t1每行去t的索引a里做对数级查找(命中才回表),总成本极低;而 Hash Join 必须把t的 10 万行扫一遍、装进 262144 字节的join_buffer建哈希表再探查。被驱动表越大、越该补索引转 INLJ。

INLJ (Index Nested-Loop Join): for r in t1: -- 驱动表(小) 用 r.a 去 t 的 index(a) 查 -- 走索引, 对数级, 快 回表取 t.* -- 命中行才回表 Hash Join (8.0, 被驱动表无索引时): build: 把 t1 装进 join buffer, 建哈希索引 probe: 流式扫描 t(10万行), 每行算 hash 找 t1 匹配 -- 内存中完成, 但被驱动表全扫描 + 内存压力

大表 JOIN 的真实代价:假设驱动表 1 万行、被驱动表 1000 万行。INLJ 下,每行去被驱动表索引查一次(B+树 3~4 层),总几万次逻辑读,毫秒级;若被驱动表无索引走 Hash Join,则必须把 1000 万行扫一遍装进 join buffer——既撑爆内存又要海量 CPU,轻则慢查询、重则 OOM。所以「给被驱动表关联字段建索引」是 JOIN 优化里投入产出比最高的一件事。此外,驱动表应当选小表(必要时用straight_join手动指定),让外层循环次数最少。

7.4 关于 join_buffer_size(实机参数)

本实验 Hash Join 用的是默认join_buffer_size=262144(256K)。这个值决定了单趟能装多少驱动表数据进内存建哈希表

  • 若驱动表很小(本例t1仅 1000 行),256K 绰绰有余,Hash Join 一次建表完成;
  • 若驱动表很大,buffer 装不下,MySQL 会分批(block)处理:每批把一部分驱动表装进 buffer 建哈希、再扫描一遍被驱动表探查匹配——这会把“扫描被驱动表”的次数放大成“批数”倍,性能急剧下降。

因此 Hash Join 的真实成本 =被驱动表扫描次数 × 批数。当被驱动表本身就有亿级行数时,哪怕只分几批,也是灾难。这也从另一角度印证了结论:能用 INLJ(被驱动表有索引)就别依赖 Hash Join;Hash Join 只是“被驱动表暂时没索引”时的兜底,且依赖足够大的join_buffer_size,绝不是大表 JOIN 的终极方案。


八、慢 SQL 排查 SOP(实战流程)

把前面几类问题收敛成一套可复用的排查流程,遇到慢 SQL 按图索骥即可:

  1. EXPLAIN看执行计划:重点盯type(ALL/Index/Range/Ref/Const)、key(实际用没用索引)、rows(估计扫描行数)、ExtraUsing filesort/Using temporary/Using where/Using index/Using join buffer是最该警惕的信号)。
  2. 判断是不是全表扫type=ALL且数据量大,先怀疑没走索引,或索引列被函数/隐式转换处理掉了(第六.1 / 第六.2 节)。
  3. 看排序与临时表Extra出现Using filesort,用Sort_merge_passes/OPTIMIZER_TRACEnum_initial_chunks_spilled_to_disk确认是否落盘(本讲 §4.2 / §5.4 实机数据)。
  4. 看 JOIN 算法:被驱动表type=ALLUsing join buffer,说明缺索引,按第七.3 节补索引转 INLJ。
  5. 怀疑隐式转换:数值/字符串混用、联表字符集/类型不一致时,立刻SHOW WARNINGS看优化器重写的 SQL(第六.2 节的Warning 1739)。
  6. profiling/OPTIMIZER_TRACE:需要量化各阶段耗时、看优化器决策细节时,用这两把扳手(本讲第三、四、五节均用到)。
  7. 必要时force index验证:确认是优化器误判后,用force index对比耗时,但记住它只是「验证手段」,长期方案还是修索引设计或避免长事务快照(见 03 篇第八节)。

九、踩坑记录(真实报错/反直觉点)

  1. max_length_for_sort_data已废弃:在 8.0 上设置它只换来一条Warning 1287,且sort_mode永远是<varlen_sort_key, packed_additional_fields>,调它纯属无用功(§5.1 / §5.2)。
  2. order by真落盘长这样sort_buffer_size=32K+ 1 万行 →Sort_merge_passes=6;带 TEXT 大字段时 trace 里num_initial_chunks_spilled_to_disk=79(§4.2 / §5.4)。内存不够,磁盘排序没跑。
  3. 隐式转换“假命中”索引v=123(数字)的EXPLAINkey=NULLtype=ALL直接全表扫;SHOW WARNINGS暴露Warning 1739(类型转换导致无法用索引)。正确写法必须加引号(v='123')。
  4. count(*)不慢,count(a)最慢:三轮实测均值count(a)=0.00944s>count(id)=0.00913s>count(*)=0.00815scount(1)=0.00748scount(列)因要判 NULL 而最慢。
  5. 小表 JOIN,Hash Join 比 INLJ 慢约 9.5 倍:别一看到Using join buffer就恐慌,但要认清——被驱动表无索引时,大表场景它远不如 INLJ(§7.3)。

十、面试高频问答

Q1:count(*)count(1)count(列)性能差异?
InnoDB 不缓存行数,都要真扫。count(*)count(1)被优化器等价处理、最快(本实验均值分别 0.00815s、0.00748s);count(主键)count(普通列)需确认非空/排除 NULL,略慢(本实验count(a)均值 0.00944s,最慢)。

Q2:order by慢一般怎么排查?
先看EXPLAIN有没有Using filesort;有则看Sort_merge_passesOPTIMIZER_TRACEnum_initial_chunks_spilled_to_disk是否涨(内存不够落盘)。最优解是在order by列上建索引走有序扫描——本讲 §4.1 的order by idcity二级索引叶子按(city,id)有序而免 filesort(Extra=NULL)。

Q3:8.0 里 filesort 的 sort_mode 是什么?max_length_for_sort_data还有用吗?
8.0 统一为<varlen_sort_key, packed_additional_fields>,无论是否带 TEXT 大字段都不变(实测row_size从 404 到 65941 都同此模式)。max_length_for_sort_data已被废弃(设置即Warning 1287),不要再调它。

Q4:为什么where tradeid=110717不走索引?
tradeid是 varchar,传数字触发隐式转换,MySQL 把列侧转成数字比较,等效于对列用了函数,索引无法定位,全表扫描(type=ALLSHOW WARNINGSWarning 1739)。必须写成tradeid='110717'(字符串)才走ref等值查找。

Q5:对索引列做函数/运算会怎样?
where id+1=1000在列上套函数,优化器放弃索引走全表扫描(type=ALL,扫 10 万行);等价改成id=999走主键const只扫 1 行。运算应放在常量侧。month(d)=1同理失效,改用日期范围d>='2026-01-01' and d<'2026-02-01'即恢复range索引扫描。

Q6:JOIN 的 INLJ 和 Hash Join 怎么选?
被驱动表关联字段有索引 → INLJ(每行走索引查找,大表友好);无索引 → 8.0 走 Hash Join(小表进 join buffer)。本实验:INLJ 0.00133s vs Hash Join 0.01266s,差约 9.5 倍。大表无索引时务必补索引转 INLJ。

Q7:磁盘外部排序是怎么被实机证实的?
sort_buffer_size=32K下 1 万行order bySort_merge_passes=6;带 TEXT 字段(单行 64KB+)时OPTIMIZER_TRACE显示num_initial_chunks_spilled_to_disk=79peak_memory_used=33792——内存只 32K、单行却 64KB,只能疯狂落盘再归并。


十一、总结

慢 SQL 排查不是玄学,每一步都能落到实机数字:

  • count:写count(*)最快;count(列)因判 NULL 最慢(实测均值差约 0.0013s)。三轮 profiling 消除了单次波动,结论稳定。
  • order by:先确认是否Using filesort;有索引可走有序扫描时根本不排序(本讲order by id免 filesort)。filesort 落盘看Sort_merge_passes(实测 32K 下 =6)和 trace 的num_initial_chunks_spilled_to_disk(带 TEXT 时 =79)。
  • 8.0 排序机制演进(增量价值点):传统“全字段 vs rowid”二分法已过时;8.0 统一为<varlen_sort_key, packed_additional_fields>max_length_for_sort_data被废弃(Warning 1287),加 TEXT 大字段也不退化。调优优先级:索引免排序 > 调大sort_buffer_size减少落盘。
  • 索引失效:对列做函数(id+1=1000)必失效;字符串列传数字触发隐式转换,全表扫(Warning 1739),SHOW WARNINGS一查便知;month(d)=1失效而日期范围恢复range
  • JOIN:被驱动表有索引走 INLJ(0.00133s),无索引走 Hash Join(0.01266s),差约 9.5 倍;大表务必补索引。

慢 SQL 的本质,几乎都可以归结到三件事:扫了多少行、排没排序、回没回表。只要顺着EXPLAIN把这三个数字看穿,再配合本讲给出的实测数据(count 写法差异、order by 的 filesort 判定与 8.0 排序演进、隐式转换的Warning 1739、JOIN 的 INLJ 与 Hash Join 取舍),绝大部分性能问题都能在十分钟内定位到根因,而不是凭感觉加索引、盲目调参数。

EXPLAINSHOW WARNINGSprofilingOPTIMIZER_TRACE这几把扳手用熟,绝大多数慢 SQL 都能外科手术式定位。最后再强调一句:索引与 SQL 优化没有「背下来的标准答案」,只有「跑出来的真实数据」——本讲所有结论都来自实验机的实机回显,建议你在自己的库上把同样的命令再跑一遍,印象会比看十篇文章都深。


本文实验均在真实云服务器完成,输出为实机回显。