Skip to content

MySQL EXPLAIN ​

EXPLAIN 用来查看 SQL 的执行计划。它不会直接告诉你“最佳优化方案是什么”,但会告诉你 MySQL 准备怎么访问表、选哪个索引、估计扫描多少行、是否需要额外排序或临时表。

一句话理解:

EXPLAIN 是慢 SQL 排查的 X 光片,它能看到执行路径,但最终怎么治还要结合数据量、索引、业务查询一起判断。

为什么必须学 EXPLAIN ​

很多 SQL 在小数据量时都很快,但上线后数据量一大就慢。只看 SQL 文本很难判断数据库实际怎么执行。

mermaid
flowchart TD
    A["SQL 看起来没问题"] --> B["线上数据量变大"]
    B --> C["扫描行数暴涨"]
    C --> D["排序 / 回表 / 锁等待变重"]
    D --> E["接口超时"]
    E --> F["用 EXPLAIN 定位执行路径"]

如果不会看 EXPLAIN,优化常常会变成:

  1. 看见慢就随便加索引。
  2. 加了索引但 SQL 还是不用。
  3. 不知道慢在扫描、排序、回表还是临时表。
  4. 不知道优化前后是否真的变好。

基本用法 ​

sql
explain
select id, title
from article
where author_id = 1001 and status = 1
order by publish_time desc
limit 20;

MySQL 8 还可以使用:

sql
explain analyze
select id, title
from article
where author_id = 1001 and status = 1
order by publish_time desc
limit 20;

EXPLAIN 是优化器的预估计划,EXPLAIN ANALYZE 会实际执行 SQL 并给出真实耗时,所以生产环境要谨慎使用,尤其是会返回大量数据或修改数据的语句。

阅读顺序 ​

不要从左到右机械读字段。建议按这个顺序看:

mermaid
flowchart TD
    A["1. 看表访问顺序<br/>id / select_type / table"] --> B["2. 看访问类型<br/>type"]
    B --> C["3. 看索引选择<br/>possible_keys / key / key_len"]
    C --> D["4. 看扫描规模<br/>rows / filtered"]
    D --> E["5. 看额外动作<br/>Extra"]
    E --> F["6. 结合 SQL 和业务判断是否要改"]

核心判断不是“有没有用索引”这么简单,而是:

  1. 扫描行数是否合理。
  2. 使用的索引是否符合查询条件和排序。
  3. 是否产生大量回表。
  4. 是否需要额外排序或临时表。
  5. 优化器估算是否可能偏差。

关键字段 ​

字段说明关注点
id查询执行层级标识子查询、联合查询时重点看
select_type查询类型SIMPLE、PRIMARY、SUBQUERY、DERIVED
table当前访问的表多表 join 时看访问顺序
type访问类型判断扫描方式和索引利用程度
possible_keys可能使用的索引有候选索引不代表最终使用
key实际使用的索引为空通常表示没选中索引
key_len使用索引的长度可大致判断联合索引用到哪几列
ref哪个列或常量与索引比较看连接条件和等值条件
rows预估扫描行数越小通常越好,但只是估算
filtered过滤后剩余比例估算与 rows 一起估算结果规模
Extra额外执行信息排序、临时表、覆盖索引等

EXPLAIN 参数到底从哪里来 ​

EXPLAIN 不是 MySQL 执行 SQL 后“统计出来”的结果,而是优化器在真正执行前,根据 SQL 语义、表结构、索引、统计信息和成本模型推导出来的执行计划。

mermaid
flowchart TD
    A["SQL 文本"] --> B["解析器生成语法树"]
    B --> C["预处理器解析表、列、权限"]
    C --> D["优化器枚举访问路径"]
    D --> E["读取统计信息和索引信息"]
    E --> F["估算 rows、filtered、cost"]
    F --> G["选择成本较低的计划"]
    G --> H["输出 EXPLAIN 字段"]

可以把每个字段来源理解成下面这张表:

字段主要来源怎么产生是否精确
idSQL 查询块结构解析 SQL 后按查询块、子查询、UNION 分配编号结构性字段,基本确定
select_typeSQL 语法结构判断是 SIMPLE、PRIMARY、SUBQUERY、DERIVED、UNION 等结构性字段,基本确定
table优化后的访问对象可能是真实表、派生表、临时结果、物化子查询基本确定
partitions分区表和条件分区裁剪后决定访问哪些分区依赖分区条件
type优化器选择的访问路径根据索引、条件、Join 方式选择 const/ref/range/ALL 等计划结果,可能受统计信息影响
possible_keys表结构和可用条件根据 WHERE/JOIN 中能利用索引的条件列列出候选索引候选,不代表会用
key成本模型选择结果优化器比较候选路径成本后选中的索引计划结果,可能选错
key_len索引定义和使用前缀根据索引列类型、长度、是否可空、字符集估算使用字节数计算值,不代表实际数据长度
ref等值匹配右侧来源显示索引列和常量、列、函数结果怎么比较计划结果
rows统计信息和选择性估算根据表行数、索引基数、采样、直方图、范围估算得出估算值,经常不准
filtered剩余条件选择性估算估算存储引擎取出的行经过 Server 层条件后剩多少比例估算值
Extra优化器和执行器策略输出覆盖索引、ICP、临时表、排序、Join Buffer 等附加动作计划提示

所以看 EXPLAIN 要有一个前提:结构类字段通常比较确定,成本和行数类字段都是估算。

统计信息从哪里来 ​

rows、filtered、key 是否选择某个索引,核心都依赖统计信息。InnoDB 不会为了一个普通 EXPLAIN 把整张表扫一遍,它会用已保存或采样得到的统计信息来估算。

常见统计信息来源:

来源作用查看或维护方式
表行数估算判断全表扫描成本show table status like 'table_name'
索引基数 Cardinality判断某个索引列区分度show index from table_name
InnoDB 采样页估算索引不同值数量和数据分布analyze table table_name 更新
持久化统计信息保存 InnoDB 表和索引统计innodb_stats_persistent
直方图 HistogramMySQL 8 用于记录非索引列或低区分字段分布analyze table ... update histogram
表结构元数据字段类型、长度、是否可空、索引列顺序show create table

查看索引基数:

sql
show index from article;

重点看:

字段含义
Key_name索引名
Seq_in_index当前列在联合索引中的位置
Column_name索引列
Cardinality该索引前缀的不同值数量估算

例如表有 100 万行,idx_status(status) 的 Cardinality = 2,优化器会认为这个索引区分度很低,单独走它可能要读取大量记录再回表,所以可能不选。

更新统计信息:

sql
analyze table article;

MySQL 8 可以创建直方图,帮助优化器理解数据分布:

sql
analyze table article
update histogram on status, author_id with 100 buckets;

查看直方图:

sql
select *
from information_schema.column_statistics
where schema_name = database()
  and table_name = 'article';

直方图适合数据分布很不均匀、优化器经常估错的字段。但不要无脑给所有字段建直方图,维护成本和统计信息复杂度也会上升。

rows 是怎么来的 ​

rows 是优化器预估“访问当前表这一步大概要读取多少行候选记录”。它不是实际扫描行数。

全表扫描时 ​

如果 type = ALL,rows 通常接近表行数估算值:

sql
explain
select *
from article
where title like '%mysql%';

如果 title 没有可用全文索引或前缀索引,这类条件可能无法利用普通 B+Tree 精确定位,于是优化器估算需要扫描整张表。

主键或唯一索引等值时 ​

sql
explain
select *
from article
where id = 100;

如果 id 是主键:

字段常见值
typeconst
keyPRIMARY
rows1

因为主键唯一,最多匹配一行。

普通索引等值时 ​

假设:

sql
create index idx_author on article(author_id);

查询:

sql
explain
select *
from article
where author_id = 1001;

如果表估算 100 万行,idx_author 的 Cardinality 约为 1 万,优化器可能粗略认为每个作者平均:

text
1000000 / 10000 = 100 行

此时 rows 可能接近 100。

但真实业务可能很不均匀:头部作者有 10 万篇,普通作者只有几篇。如果没有更精确的统计信息,rows 就可能估错。

范围扫描时 ​

sql
explain
select *
from article
where publish_time >= '2026-01-01'
  and publish_time < '2026-02-01';

如果使用 idx_publish_time,优化器会估算这个索引范围覆盖多少索引记录。范围越大,rows 越大。

联合索引时 ​

sql
create index idx_author_status_time
on article(author_id, status, publish_time);

查询:

sql
explain
select *
from article
where author_id = 1001
  and status = 1
  and publish_time >= '2026-01-01';

优化器会按联合索引前缀估算:

text
先估 author_id = 1001 命中多少
再估 author_id = 1001 and status = 1 命中多少
再估 publish_time 范围覆盖多少

如果统计信息只知道平均分布,而真实数据强烈倾斜,估算仍然可能偏差。

filtered 是怎么来的 ​

filtered 表示:存储引擎按访问路径取出候选行后,MySQL Server 层再应用剩余条件,预计还能保留多少百分比。

可以粗略理解为:

text
当前表预计输出行数 ≈ rows * filtered / 100

例如:

typekeyrowsfiltered
rangeidx_author_status_time1000010.00

含义是:优化器预计先从索引范围读 10000 行候选记录,再经过剩余条件过滤后,大约保留 10%,也就是约 1000 行。

哪些条件会影响 filtered:

条件类型说明
索引没用到的 WHERE 条件例如联合索引范围列后面的条件
函数条件例如 date(created_at)
非索引列过滤存储引擎取行后再过滤
Join 条件被驱动表关联后剩余过滤
统计信息值分布估计会影响过滤比例

filtered 很低通常说明:虽然用了索引,但取出的候选行里大部分会被过滤掉,索引可能不够贴合查询条件。

key_len 是怎么来的 ​

key_len 是优化器计划使用的索引前缀字节长度。它常用来判断联合索引大概用到了几列。

它不是索引文件真实读取了多少字节,也不是字段实际值长度,而是根据字段定义计算出来的最大可能使用长度。

影响 key_len 的因素:

因素影响
字段类型int 4 字节,bigint 8 字节,datetime 5 字节左右
字符串长度varchar(50) 要按字符集最大字节数估算
字符集utf8mb4 一个字符最多 4 字节
是否允许 NULL可空列通常额外占 1 字节
变长字段varchar 还需要长度字节
联合索引用到几列多用一列,key_len 增加对应列长度

示例:

sql
create table user_info (
  id bigint primary key,
  age int not null,
  city varchar(20) not null,
  status tinyint not null,
  key idx_city_status_age(city, status, age)
) engine = InnoDB default charset = utf8mb4;

查询 1:

sql
explain
select *
from user_info
where city = '杭州';

查询 2:

sql
explain
select *
from user_info
where city = '杭州'
  and status = 1;

如果查询 2 的 key_len 比查询 1 更长,通常说明联合索引多用到了 status 这一列。

注意:key_len 只能辅助判断,不能只靠它断定性能好坏。真正还要看 type、rows、filtered 和 Extra。

possible_keys 和 key 为什么不同 ​

possible_keys 是“语法和索引结构上可能用的索引”,key 是优化器成本比较后最终选择的索引。

mermaid
flowchart TD
    A["WHERE 和 JOIN 条件"] --> B["找出可能匹配的索引"]
    B --> C["possible_keys"]
    C --> D["估算每个索引成本"]
    D --> E["选择成本最低的访问路径"]
    E --> F["key"]

成本会考虑:

  1. 预计扫描多少行。
  2. 是否需要回表。
  3. 是否能利用索引顺序避免排序。
  4. 是否覆盖索引。
  5. Join 顺序和被驱动表访问成本。
  6. 统计信息估算结果。

所以 possible_keys 有值但 key = NULL 并不矛盾。优化器可能认为全表扫描更便宜。

type 是怎么决定的 ​

type 是优化器选择访问路径后的结果,不是 SQL 语法直接决定的。

type产生条件来源判断
system表只有一行或系统表特殊情况表统计和优化规则
const主键或唯一索引等值匹配常量唯一性约束 + 等值条件
eq_refJoin 时用唯一索引或主键匹配,每次最多一行Join 条件 + 唯一索引
ref普通非唯一索引等值匹配普通索引 + 等值条件
range使用索引做范围扫描范围条件、between、>、in 等
index扫描整棵索引覆盖索引或按索引顺序全扫
ALL全表扫描无合适索引或全表更便宜

type 的好坏要结合数据量。range 扫 500 行很好,扫 500 万行也会很慢;index 覆盖索引扫少量页还行,扫完整大索引也可能很慢。

ref 是怎么来的 ​

ref 显示当前索引和谁进行比较。

常见值:

ref含义
const和常量比较,例如 where user_id = 1001
db.table.columnJoin 中和另一张表的列比较
func和函数或表达式结果比较
NULL没有等值引用,常见于范围扫描或全表扫描

示例:

sql
explain
select *
from orders o
join user_info u on o.user_id = u.id
where o.status = 1;

如果 u.id 是主键,被驱动表 u 的 ref 可能显示 o.user_id,表示每条订单用 user_id 去用户表主键索引里查。

Extra 是怎么来的 ​

Extra 是优化器和执行器对附加动作的描述。它不是单一来源,而是执行计划中的各种策略提示。

Extra来源说明
Using index覆盖索引判断查询字段都能从索引拿到,不需要回表
Using where剩余条件过滤存储引擎返回后还要在 Server 层过滤
Using index conditionICP 优化在存储引擎层用索引条件先过滤一部分
Using filesort排序策略不能完全按索引顺序返回,需要额外排序
Using temporary分组或去重策略需要临时表处理中间结果
Using join bufferJoin 策略被驱动表没有很好利用索引,使用 Join Buffer

Extra 要结合 rows 看。小数据量 Using filesort 可能没问题;大数据量 Using temporary; Using filesort 就要重点关注。

EXPLAIN 和真实执行为什么不一样 ​

普通 EXPLAIN 是预估,不执行 SQL。真实执行可能因为缓存、锁等待、IO、数据分布、参数值不同而表现不同。

工具是否执行 SQL看到什么
EXPLAIN不真正执行普通 SELECT优化器预估计划
EXPLAIN ANALYZE会实际执行实际耗时、实际行数、循环次数
慢日志已执行 SQL 的记录执行时间、锁时间、扫描行数等
optimizer_trace不一定执行完整查询优化器为什么这么选

MySQL 8 可以用:

sql
explain analyze
select id, title
from article
where author_id = 1001 and status = 1
order by publish_time desc
limit 20;

生产环境谨慎使用,因为它会真的执行 SQL。

如果想看优化器为什么选某个索引,可以打开 optimizer_trace:

sql
set optimizer_trace = 'enabled=on';

select id, title
from article
where author_id = 1001 and status = 1
order by publish_time desc
limit 20;

select trace
from information_schema.optimizer_trace;

optimizer_trace 会比较详细,适合排查“为什么有索引但不用”“为什么选错索引”。

怎么验证 rows 估算是否靠谱 ​

面试和生产排查里经常会出现这种情况:

EXPLAIN rows 看起来不大,但接口还是慢;或者 rows 很大,但实际查询很快。

原因是 rows 只是优化器执行前的估算。要判断它靠不靠谱,需要把“预估计划”和“真实执行证据”对上。

推荐按下面的顺序验证:

mermaid
flowchart TD
    A["发现 SQL 慢"] --> B["先看普通 EXPLAIN"]
    B --> C["看 rows / filtered / key / Extra"]
    C --> D{"估算是否符合直觉"}
    D -- "不符合" --> E["查统计信息和 optimizer_trace"]
    D -- "符合但仍慢" --> F["看慢日志和实际耗时"]
    F --> G["低风险环境执行 EXPLAIN ANALYZE"]
    G --> H["对比 estimated rows 和 actual rows"]
    H --> I["结合 Handler_read 判断访问模式"]
    I --> J["调整 SQL、索引或统计信息"]

1. 用 EXPLAIN ANALYZE 看真实行数和耗时 ​

MySQL 8 的 EXPLAIN ANALYZE 会真的执行 SQL,并输出实际耗时、实际行数、循环次数。它能回答普通 EXPLAIN 回答不了的问题:

普通 EXPLAINEXPLAIN ANALYZE
预计读多少行实际读了多少行
预计执行路径实际按该路径执行后的耗时
看不到每步循环次数能看到 loops
风险较低会执行 SQL,生产要谨慎

示例:

sql
explain analyze
select id, title
from article
where author_id = 1001
  and status = 1
order by publish_time desc
limit 20;

重点看三类信息:

信息怎么理解
estimated rows优化器预计行数
actual rows实际产生的行数
actual time每一步真实耗时

如果 estimated rows 和 actual rows 差距很大,通常说明统计信息、数据分布或条件选择性估算有问题。此时不要急着改 SQL,先确认统计信息是否过期,是否需要直方图,是否存在极端热点值。

2. 用慢日志看真实扫描量 ​

慢日志记录的是已经执行过的 SQL,适合线上观察真实表现。

建议打开慢日志并关注:

sql
show variables like 'slow_query_log';
show variables like 'long_query_time';
show variables like 'log_queries_not_using_indexes';

慢日志里常见字段:

字段含义
Query_timeSQL 总耗时
Lock_time等锁耗时
Rows_sent返回给客户端的行数
Rows_examined扫描或检查的行数

Rows_examined 和 Rows_sent 差距很大时,说明 MySQL 为了返回少量结果检查了大量数据。此时即使 Extra 出现 Using index,也不能说明一定快。

例子:

text
Rows_examined = 1000000
Rows_sent = 20

这通常意味着索引不够贴合、深分页、排序过滤顺序不合理,或者查询范围本身太大。

3. 用 Handler_read 指标观察访问模式 ​

MySQL 会维护一些 Handler 计数器,可以粗略观察当前会话或全局的存储引擎访问方式。

查看当前会话计数:

sql
show session status like 'Handler_read%';

常见指标:

指标含义排查含义
Handler_read_key通过索引定位读取通常表示用了索引点查或范围起点
Handler_read_next按索引顺序读下一条范围扫描、索引扫描增多
Handler_read_rnd_next顺序读下一行全表扫描或临时表扫描增多
Handler_read_first读索引第一条索引全扫可能出现

可以在测试环境这样观察一个 SQL 前后的变化:

sql
show session status like 'Handler_read%';

select id, title
from article
where author_id = 1001
  and status = 1
order by publish_time desc
limit 20;

show session status like 'Handler_read%';

如果执行后 Handler_read_rnd_next 增长很大,可能存在全表扫描或临时表扫描;如果 Handler_read_next 增长很大,可能正在沿索引范围扫大量记录。

注意:Handler 指标不是万能真相,它是辅助证据。真正判断还要结合 EXPLAIN、慢日志、实际数据分布、SQL 条件和业务返回量。

4. 用 optimizer_trace 看优化器为什么这么选 ​

当你看到“明明有索引,MySQL 却不用”时,普通 EXPLAIN 只能告诉你结果,optimizer_trace 可以告诉你优化器比较过哪些路径、为什么放弃某个索引。

典型关注点:

trace 内容说明
candidate indexes候选索引有哪些
rows estimation每条路径估算扫描多少行
cost访问路径成本
chosen最终选择哪个路径
cause某些路径为什么被放弃

如果 trace 显示走某个索引成本比全表扫描更高,常见原因是:

  1. 条件选择性差,返回比例太高。
  2. 二级索引回表成本太高。
  3. 统计信息认为该索引区分度低。
  4. 排序、分页、Join 顺序导致另一个索引更合适。

5. 常见判断场景 ​

现象可能原因处理方向
rows 估算远小于实际统计信息过期、数据倾斜、直方图缺失analyze table,必要时建直方图
rows 不大但耗时高锁等待、IO 慢、回表随机读、排序重查慢日志、锁、Buffer Pool、磁盘
Using index 仍然慢覆盖索引全扫、深分页、大范围扫描缩小范围、游标分页、归档
possible_keys 有值但 key 为空优化器认为全表扫描更便宜看返回比例、选择性、回表成本
filtered 很低取出很多候选后才过滤调整联合索引,把高选择性条件前置
EXPLAIN 和线上表现不一致参数值不同、缓存状态不同、并发锁不同用真实参数、慢日志、压测验证

最终要形成一个习惯:EXPLAIN 给你优化器的想法,慢日志和 EXPLAIN ANALYZE 给你真实执行证据,optimizer_trace 给你选择原因,Handler_read 给你访问模式侧面证据。

type 访问类型 ​

type 反映 MySQL 访问表的方式。常见从好到差大致如下:

type含义示例评价
const主键或唯一索引等值,最多一行where id = 1很好
eq_refjoin 中通过唯一索引匹配一行订单 join 用户主键很好
ref普通索引等值匹配where user_id = ?常见可接受
range索引范围扫描where created_at >= ?可接受,注意扫描范围
index扫描整个索引覆盖索引全扫比全表小,但仍可能重
ALL全表扫描没有可用索引大表要重点关注

注意:type = ALL 不一定永远错误。小表、返回大部分数据、统计类查询可能全表扫描更划算。优化要看业务和数据量。

Extra 常见提示 ​

Extra含义是否一定要优化
Using index覆盖索引,不需要回表通常是好现象
Using where存储引擎返回后还要过滤常见,不一定有问题
Using index condition索引下推 ICP通常是优化
Using filesort额外排序大数据量要关注
Using temporary使用临时表分组排序场景要关注
Using join bufferjoin 没很好利用索引需要检查 join 条件

Using filesort 不是一定写磁盘,它表示不能直接利用索引顺序完成排序,需要额外排序过程。数据量小时可以接受,数据量大时要重点优化。

Demo:建索引前后对比 ​

准备表:

sql
create table article (
  id bigint primary key auto_increment,
  author_id bigint not null,
  status tinyint not null,
  publish_time datetime not null,
  title varchar(100) not null
) engine = InnoDB default charset = utf8mb4;

查询:

sql
explain
select id, title
from article
where author_id = 1001 and status = 1
order by publish_time desc
limit 20;

没有合适索引时,可能看到:

字段可能表现
typeALL
keyNULL
rows很大
ExtraUsing where; Using filesort

添加联合索引:

sql
alter table article
add index idx_author_status_time(author_id, status, publish_time);

再次查看:

sql
explain
select id, title
from article
where author_id = 1001 and status = 1
order by publish_time desc
limit 20;

重点观察:

字段期望变化
key使用 idx_author_status_time
type从 ALL 变成 ref 或 range
rows明显下降
Extra排序压力可能降低

为什么有索引但不用 ​

mermaid
flowchart TD
    A["SQL 有可用索引"] --> B{"优化器估算成本"}
    B -- "走索引更便宜" --> C["使用索引"]
    B -- "全表扫描更便宜" --> D["不使用索引"]
    D --> E["可能原因:返回比例高 / 统计信息偏差 / 索引选择性差"]

常见原因:

原因示例解释
返回比例太高where status = 1,90% 都满足走索引后大量回表,可能不如全表扫描
索引选择性差性别、布尔字段区分度太低
对索引列做函数date(created_at) = ?B+Tree 不能直接按函数结果定位
隐式类型转换字符串字段用数字比较可能导致索引不能正常使用
统计信息不准数据分布变化大优化器估算 rows 偏差

更新统计信息:

sql
analyze table article;

不要一看到没用索引就强制 force index。强制索引可能短期有效,但数据分布变化后反而变慢。优先从 SQL、索引设计、统计信息判断。

Demo:函数导致索引效果变差 ​

准备索引:

sql
alter table article
add index idx_publish_time(publish_time);

不推荐:

sql
explain
select *
from article
where date(publish_time) = '2026-01-01';

推荐:

sql
explain
select *
from article
where publish_time >= '2026-01-01 00:00:00'
  and publish_time < '2026-01-02 00:00:00';

为什么推荐范围写法:索引里保存的是完整时间值,范围条件可以沿 B+Tree 找到起点并顺序扫描;date(publish_time) 需要对列计算后比较,破坏了直接定位能力。

多表 join 怎么看 ​

多表查询时,重点看:

  1. 谁先被访问。
  2. 连接字段是否有索引。
  3. 被驱动表是否通过索引查找。
  4. 是否出现 Using join buffer。

示例:

sql
explain
select o.id, o.amount, u.username
from orders o
join user_profile u on o.user_id = u.id
where o.created_at >= '2026-01-01'
  and o.created_at < '2026-02-01';

建议索引:

sql
alter table orders add index idx_created_user(created_at, user_id);
-- user_profile.id 是主键,天然有索引

如果 orders 按时间范围先过滤出少量订单,再通过 user_id 关联用户主键,通常比先扫用户再查订单更合理。

商业案例:资产列表慢 SQL 怎么从 EXPLAIN 推到优化方案 ​

下面用一个商业系统常见场景把 EXPLAIN 串起来:医疗资产平台的资产列表页,需要按租户、医院、状态、创建时间分页查询。

1. 表结构和原始 SQL ​

sql
create table asset_info (
  id bigint primary key auto_increment,
  tenant_id bigint not null,
  hospital_code varchar(32) not null,
  asset_name varchar(100) not null,
  asset_type varchar(32) not null,
  status tinyint not null,
  created_at datetime not null,
  updated_at datetime not null,
  key idx_tenant_status (tenant_id, status),
  key idx_created_at (created_at)
) engine = InnoDB default charset = utf8mb4;

业务 SQL:

sql
select id, asset_name, asset_type, status, created_at
from asset_info
where tenant_id = 10001
  and hospital_code = 'H001'
  and status = 1
order by created_at desc
limit 20;

可能看到的执行计划:

字段可能值说明
typeref用了普通索引等值匹配
possible_keysidx_tenant_status,idx_created_at两个索引从语义上都可能相关
keyidx_tenant_status优化器选择了租户 + 状态索引
rows120000预计该租户该状态有 12 万候选行
filtered10.00预计再经过 hospital_code 过滤剩 10%
ExtraUsing where; Using filesort还要过滤医院,并额外排序

这条 SQL 的问题不是“完全没用索引”,而是“索引只解决了一部分条件”。它能按 tenant_id,status 找到候选,但还要在候选行里过滤 hospital_code,并对大量候选按 created_at 排序。

2. 为什么 idx_created_at 不一定更好 ​

初学者容易想:既然 order by created_at desc,那走 idx_created_at 不就不用排序了吗?

不一定。因为 idx_created_at 是全表时间索引,它能按时间顺序读,但无法直接限定租户、医院和状态。它可能要从最新资产开始一路扫描,边扫边判断:

text
tenant_id 是否等于 10001
hospital_code 是否等于 H001
status 是否等于 1

如果系统里很多租户、很多医院,当前租户医院的数据只占很小比例,那么沿 idx_created_at 扫描可能要跳过大量不相关数据。

mermaid
flowchart TD
    A["选择 idx_created_at"] --> B["按全表时间倒序扫描"]
    B --> C["逐行判断 tenant / hospital / status"]
    C --> D{"是否凑够 20 行?"}
    D -->|"否"| B
    D -->|"是"| E["返回结果"]

优化器会在“按过滤条件缩小范围再排序”和“按排序索引扫描再过滤”之间估算成本。统计信息不准或数据分布倾斜时,它可能选错。

3. 正确索引应该贴合过滤和排序 ​

这个列表页的稳定访问模式是:

text
tenant_id 等值
hospital_code 等值
status 等值
created_at 排序
limit 20

更合适的联合索引:

sql
alter table asset_info
add index idx_tenant_hospital_status_created
(tenant_id, hospital_code, status, created_at);

为什么这个顺序合理?

索引列对应 SQL作用
tenant_id等值先缩小到当前租户
hospital_code等值再缩小到当前医院
status等值再缩小到当前状态
created_at排序在小范围内按时间顺序读取

优化后可能看到:

字段期望表现说明
typeref 或 range能按联合索引定位
keyidx_tenant_hospital_status_created使用贴合业务的索引
key_len比原来更长说明联合索引用到了更多前缀列
rows明显下降候选范围更小
Extra不再出现大范围 Using filesort能利用索引顺序返回

注意:如果 created_at 前面都是等值条件,MySQL 可以利用联合索引中 created_at 的顺序。如果在 created_at 前出现范围条件,例如 status > 0,后续排序利用就会受到影响。

4. 为什么不要把所有字段都放进索引 ​

有人会继续问:既然查询字段有 asset_name、asset_type,要不要都放进索引做覆盖索引?

可以评估,但不要无脑加。覆盖索引能减少回表,但索引越宽,写入成本、页分裂成本、缓存占用都会增加。

text
收益 = 少回表带来的查询提升
成本 = 更大的索引空间 + 更慢的写入 + 更低的 Buffer Pool 命中率

适合考虑覆盖索引的条件:

条件说明
查询频率很高高频列表页、核心接口
返回列很少只返回 id、状态、时间、短字段
写入频率可接受不会因为索引变宽拖垮写入
数据量大且回表多回表成本明显

不适合把大字段放进覆盖索引,例如描述、JSON、大文本、长名称字段。资产列表如果 asset_name 很长、写入频繁,可以先只建过滤排序索引,再用压测和慢日志判断是否需要覆盖。

5. 优化不是只看 EXPLAIN 一次 ​

完整优化闭环应该是:

mermaid
flowchart TD
    A["慢日志发现资产列表慢"] --> B["用真实参数 EXPLAIN"]
    B --> C["判断 key、rows、filtered、Extra"]
    C --> D["设计贴合过滤和排序的联合索引"]
    D --> E["测试环境加索引并 ANALYZE TABLE"]
    E --> F["再次 EXPLAIN 对比计划"]
    F --> G["压测或 EXPLAIN ANALYZE 验证真实耗时"]
    G --> H["观察上线后慢日志和 Rows_examined"]

上线后还要观察:

指标目的
Rows_examined 是否下降验证扫描量真的变小
P95/P99 是否下降验证用户体验
写入耗时是否上升验证新增索引副作用
Buffer Pool 命中率验证索引变宽是否挤占缓存
磁盘空间验证索引空间成本

6. 面试怎么答这类慢 SQL ​

标准回答可以这样组织:

text
我不会只看 SQL 有没有用索引,而是先看业务访问模式。比如资产列表按 tenant_id、hospital_code、status 过滤,并按 created_at 排序分页。EXPLAIN 如果显示只用了 tenant_id,status 索引,rows 很大且 Extra 有 Using filesort,说明索引没有同时覆盖过滤和排序。优化方向是设计联合索引 tenant_id,hospital_code,status,created_at,让等值条件缩小范围,再利用 created_at 顺序返回。优化后还要用真实参数重新 EXPLAIN,看 key、key_len、rows、filtered、Extra 是否改善,再结合慢日志 Rows_examined、P95/P99 和写入成本验证,而不是盲目加索引。

排查模板 ​

以后看到慢 SQL,可以按这个模板分析:

text
1. SQL 的业务目标是什么?
2. where 条件有哪些?等值、范围、模糊、函数分别是什么?
3. order by / group by 字段是什么?
4. EXPLAIN 中 key 用了哪个索引?
5. type 是什么?rows 估算多少?
6. Extra 有没有 filesort、temporary、join buffer?
7. 查询字段是否导致大量回表?
8. 返回行数是否本身就很大?
9. 是否需要调整联合索引顺序或改写 SQL?
10. 优化后重新 EXPLAIN,并对比 rows、type、Extra。

小结 ​

EXPLAIN 的重点不是背字段,而是建立判断链路:

  1. 先看访问方式 type。
  2. 再看索引选择 key。
  3. 再看扫描规模 rows。
  4. 再看额外动作 Extra。
  5. 最后结合业务数据决定优化方案。

不要为了让执行计划“看起来漂亮”盲目加索引。真正目标是让线上 SQL 在真实数据量、真实访问频率下稳定、可解释、可维护。