KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 执行计划精读:四列之外还要看什么 — keel 龙骨
这一章回答:为什么 EXPLAIN 显示走了索引,这条 SQL 还是慢。
这一章回答:为什么 EXPLAIN 显示走了索引,这条 SQL 还是慢。
先修课给了四列判据(type / key / rows / Extra),那套能解决"走没走索引"的问题,但解决不了"走了索引却依然慢"。差的就是这几列:key_len、filtered、以及预估与实际之间的偏差。
一、把关键列读全
EXPLAIN SELECT id, status, created_at
FROM orders
WHERE tenant_id = 42 AND status = 'PAID' AND created_at > '2026-01-01'
ORDER BY created_at DESC LIMIT 20\G
| 列 | 它在告诉你什么 | 怎么用 |
|---|---|---|
possible_keys |
优化器考虑过的索引 | 与 key 不同 = 优化器主动放弃了某个索引,值得追问为什么 |
key |
最终选用的索引 | 为 NULL 是全表扫描 |
key_len |
实际使用的索引字节数 | 判断联合索引用到了几列(见下节) |
ref |
与索引列比较的值来源 | const 最好;出现 func 说明比较值来自函数 |
rows |
预估扫描行数 | 与实际返回行数差距越大,过滤效率越低 |
filtered |
存储层返回后,经条件过滤剩余的比例(百分比) | 很低说明索引只完成了粗筛,大量行在 server 层被丢弃 |
Extra |
附加信息 | Using index(覆盖)、Using filesort、Using temporary、Using index condition(索引下推) |
二、key_len:联合索引到底用了几列
这是最容易被跳过、但最有用的一列。假设 idx_tsc (tenant_id BIGINT, status VARCHAR(16) utf8mb4, created_at DATETIME):
tenant_id BIGINT NOT NULL→ 8 字节status VARCHAR(16) utf8mb4→ 16×4 + 2(变长长度前缀)= 66 字节created_at DATETIME→ 5 字节(MySQL 5.6+)
于是:
key_len |
说明 |
|---|---|
| 8 | 只用了 tenant_id |
| 74 | 用了 tenant_id + status(8 + 66) |
| 79 | 三列全用(8 + 66 + 5) |
用途:你以为走了完整联合索引,key_len 却只有 8 ——说明第二列在第一列之后就用不上了(典型原因:第一列是范围查询,或第二列条件被函数包住)。这解释了大量"明明有索引却不快"的现象。
⚠️ 记得加上 NULL 标志位:允许为 NULL 的列多占 1 字节。
三、filtered:索引只完成了粗筛
rows=50000, filtered=1.00 意味着:存储引擎扫了 5 万行,server 层过滤后只剩 500 行。4.95 万行被白扫了。
这类问题的标准解法不是"再加个索引",而是问:被丢弃的那 99% 是哪种条件筛掉的? 如果是一个低选择性的等值条件(如 status),把它并进联合索引就能把过滤下推到存储层:
-- 改前:idx_tenant (tenant_id) → rows 50000, filtered 1.00
-- 改后:idx_tenant_status_time (tenant_id, status, created_at) → rows 500, filtered 100.00
四、预估 vs 实际:优化器为什么会错
EXPLAIN 给的是预估,EXPLAIN ANALYZE 给的是实际执行:
EXPLAIN ANALYZE SELECT ...;
-- 输出里会有 actual time=... rows=... loops=...
-- 注意:它会真的执行这条 SQL(DML 谨慎使用)
两者差距大时,通常是三种原因:
- 统计信息过期:InnoDB 的统计是采样估算,大批量写入/删除后偏差会变大。
ANALYZE TABLE orders; -- 重新采样 SHOW INDEX FROM orders; -- 看 Cardinality SELECT * FROM mysql.innodb_table_stats WHERE table_name='orders'; - 数据分布倾斜:
status='PAID'占 90%,优化器按平均值估算,实际完全不同。8.0 的直方图可以缓解:ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 10 BUCKETS; - 关联查询的连锁误差:多表 JOIN 时,前一张表的行数预估错会传给下一张,
rows呈乘积放大。
诊断工具:EXPLAIN FORMAT=JSON 能看到 cost_info 与每一步的预估;SET optimizer_trace='enabled=on' 后执行 SQL 再查 information_schema.optimizer_trace,能看到优化器考虑过哪些索引、为什么选了这一个。后者是解释"为什么它不用我的索引"的终极手段。
五、多表 JOIN 怎么看
看执行顺序:从上往下,第一行是驱动表(最先访问)
看每张表的 type:驱动表至少 range,被驱动表的连接列最好 eq_ref/ref
看 rows 乘积:驱动表 rows × 被驱动表 rows ≈ 总共要处理的行数量级
常见错误:大表做驱动表。驱动表每返回一行,就要去被驱动表查一次。让结果集更小的表做驱动表,通常比加索引更有效。
Bad: orders(100万) 驱动 users(1万) → 每行 orders 都要查 users
Good: 先过滤 orders 到 100 行,再去 users 查
动手:可观察结果
| 产出 | 判断标准 |
|---|---|
一份完整 EXPLAIN 解读 |
能逐列说明含义,并指出 key_len 表明用到了联合索引的第几列 |
| 一次"走了索引但慢"的定位 | 用 filtered 或 rows 与实际返回数的差距说明瓶颈在哪一层 |
| 一次预估偏差分析 | EXPLAIN 与 EXPLAIN ANALYZE 对比,给出偏差原因(统计信息/倾斜/JOIN 连锁) |
一条 optimizer_trace 记录 |
能指出优化器"考虑过但放弃"的索引及原因 |
完成标志:拿到任意一条慢 SQL,你能在 5 分钟内说出瓶颈发生在哪一层(索引选择 / 存储层扫描 / server 层过滤 / 排序临时表 / JOIN 顺序),而不是"加个索引试试"。
故障注入
| 注入方式 | 观察 |
|---|---|
| 联合索引第二列用范围查询 | key_len 是否停在范围列之前 |
批量灌入数据后不跑 ANALYZE |
EXPLAIN 预估 rows 与实际行数的偏差 |
| 让某状态值占比 90% 再查该状态 | 优化器是否仍然选了索引;加直方图后是否改变 |
| 大表 JOIN 小表,强制大表做驱动 | rows 乘积与耗时的变化 |
对覆盖索引的查询改成 SELECT * |
Extra 中 Using index 是否消失、耗时变化 |
自测题
key_len=74对于索引(tenant_id BIGINT, status VARCHAR(16) utf8mb4, created_at DATETIME)说明什么?rows=50000, filtered=1.00与rows=500, filtered=100.00,哪个更快?为什么前者说明索引没干完活?EXPLAIN与EXPLAIN ANALYZE的区别是什么?后者有什么使用风险?- 优化器不用你建的索引,你会按什么顺序排查?
- 多表 JOIN 中,驱动表选错会造成什么后果?怎么判断当前谁在驱动?