44.9 执行计划与慢查询

一句话

执行计划是优化器用“统计信息 + 代价模型”猜出来的:猜错行数,就会选错算子(索引 vs 全表、嵌套循环 vs 哈希连接),而慢查询治理的第一步不是加索引,是把慢日志按“指纹”聚合,找出代价最大的那一类。

优化器只做一件事:比代价

计划 代价模型 什么时候赢
全表扫描 页数 × 顺序读代价 匹配行占比高(> 百分之几)
索引扫描 + 回表 树高 + 匹配行数 × 随机读代价 匹配行很少(< 千分之几)
覆盖索引扫描 树高 + 索引页数 列都被索引覆盖
嵌套循环连接 外表行数 × 内表一次查找 外表很小、内表有索引
哈希连接 两表各扫一遍 + 建哈希表 大表连大表、无索引
排序合并连接 两边排序 + 归并 两边都已有序

🔑 优化器的输入是统计信息(行数、NDV、直方图),输出是计划。统计信息陈旧(大批量导入/删除后没 analyze),代价估算就会偏几个数量级,计划随之翻转。

代码:代价翻转点与“统计信息错误”的代价

package main

import (
	"fmt"
	"sort"
)

const (
	tableRows  = 1_000_000
	tablePages = 10_000
	treeHeight = 3
	randCost   = 4.0 // 随机读一页相当于 4 次顺序读
)

// 全表扫描代价:顺序读所有页
func scanCost() float64 { return float64(tablePages) }

// 索引扫描代价:树高 + 回表(每行一次随机读)
func indexCost(matched float64) float64 { return treeHeight + matched*randCost }

func main() {
	fmt.Println("=== 计划翻转:匹配行数与两种计划的代价 ===")
	fmt.Printf("%-12s %-12s %-14s %-14s %s\n", "选择率", "匹配行数", "全表扫描", "索引+回表", "优化器选谁")
	for _, sel := range []float64{0.0001, 0.001, 0.002, 0.0025, 0.005, 0.01, 0.05, 0.2} {
		m := sel * tableRows
		s, i := scanCost(), indexCost(m)
		chosen := "索引"
		if s < i {
			chosen = "全表"
		}
		fmt.Printf("%-12s %-12.0f %-14.0f %-14.0f %s\n",
			fmt.Sprintf("%.2f%%", sel*100), m, s, i, chosen)
	}

	fmt.Println("\n=== 统计信息错误:优化器以为 50 行,实际 50,000 行 ===")
	est := 50.0
	actual := 50_000.0
	chosenByEst := indexCost(est) // 估算:50*4+3 = 203 < 10000 → 选索引
	realCost := indexCost(actual) // 实际:50000*4+3 = 200003
	fmt.Printf("按估算:索引代价 %.0f vs 全表 %.0f → 选索引\n", chosenByEst, scanCost())
	fmt.Printf("按实际:索引代价 %.0f vs 全表 %.0f → 慢了 %.0f 倍\n",
		realCost, scanCost(), realCost/scanCost())
	fmt.Println("这就是“加了索引反而更慢”的典型成因:不是索引坏,是优化器估错了行数")

	fmt.Println("\n=== 慢日志按指纹聚合(按总耗时排序)===")
	type entry struct {
		fp   string
		ms   float64
		rows int
	}
	logs := []entry{
		{"SELECT * FROM orders WHERE user_id = ?", 120, 1},
		{"SELECT * FROM orders WHERE user_id = ?", 95, 1},
		{"SELECT * FROM orders WHERE user_id = ?", 210, 1},
		{"SELECT * FROM items WHERE category = ? ORDER BY created_at", 80, 5000},
		{"SELECT * FROM items WHERE category = ? ORDER BY created_at", 60, 5000},
		{"UPDATE stock SET n = n - 1 WHERE sku = ?", 400, 1},
		{"SELECT count(*) FROM logs WHERE ts > ?", 3000, 1},
		{"SELECT count(*) FROM logs WHERE ts > ?", 2800, 1},
	}
	type agg struct {
		fp      string
		count   int
		total   float64
		max     float64
		avgRows float64
	}
	m := map[string]*agg{}
	for _, l := range logs {
		a, ok := m[l.fp]
		if !ok {
			a = &agg{fp: l.fp}
			m[l.fp] = a
		}
		a.count++
		a.total += l.ms
		if l.ms > a.max {
			a.max = l.ms
		}
		a.avgRows += float64(l.rows)
	}
	var list []*agg
	for _, a := range m {
		a.avgRows /= float64(a.count)
		list = append(list, a)
	}
	sort.Slice(list, func(i, j int) bool { return list[i].total > list[j].total })
	fmt.Printf("%-52s %-6s %-10s %-8s %s\n", "SQL 指纹", "次数", "总耗时ms", "最大ms", "平均扫描行")
	for _, a := range list {
		fmt.Printf("%-52s %-6d %-10.0f %-8.0f %.0f\n", a.fp, a.count, a.total, a.max, a.avgRows)
	}
}

本机实测(macOS,Apple M1,Go 1.27):

=== 计划翻转:匹配行数与两种计划的代价 ===
选择率          匹配行数         全表扫描           索引+回表          优化器选谁
0.01%        100          10000          403            索引
0.10%        1000         10000          4003           索引
0.20%        2000         10000          8003           索引
0.25%        2500         10000          10003          全表
0.50%        5000         10000          20003          全表
1.00%        10000        10000          40003          全表
5.00%        50000        10000          200003         全表
20.00%       200000       10000          800003         全表

=== 统计信息错误:优化器以为 50 行,实际 50,000 行 ===
按估算:索引代价 203 vs 全表 10000 → 选索引
按实际:索引代价 200003 vs 全表 10000 → 慢了 20 倍
这就是“加了索引反而更慢”的典型成因:不是索引坏,是优化器估错了行数

=== 慢日志按指纹聚合(按总耗时排序)===
SQL 指纹                                               次数     总耗时ms      最大ms     平均扫描行
SELECT count(*) FROM logs WHERE ts > ?               2      5800       3000     1
SELECT * FROM orders WHERE user_id = ?               3      425        210      1
UPDATE stock SET n = n - 1 WHERE sku = ?             1      400        400      1
SELECT * FROM items WHERE category = ? ORDER BY created_at 2      140        80       5000

读数:

现象 含义 动作
选择率 0.25% 附近翻转 低于它索引赢,高于它全表赢 别对“查 10% 行”的查询建索引,优化器不会用
估算 50 行、实际 50,000 行 索引代价从 203 变 200,003,比全表慢 20 倍 大批量变更后 ANALYZE;用 EXPLAIN ANALYZE 看真实行数
慢日志聚合后,count(*) 一条占 5,800ms 单条最慢的 UPDATE 只有 400ms,但总耗时被高频查询主导 按“总耗时”排序,而不是按“单次最慢”

🔑 慢查询治理的顺序:按指纹聚合并按总耗时排序 → 看 rows_examined/rows_sent 比值 → EXPLAIN ANALYZE 看估算行数与实际行数 → 再决定加索引/改 SQL/改统计信息。跳过第一步,就会去优化那条“偶尔 3 秒”的查询,而放过每天跑 10 万次的 50ms 查询。

执行计划里最该看的三列

列 看什么 异常信号
type / 访问方式 ALL(全表)、index(全索引扫描)、range、ref、eq_ref ALL 且行数大
rows / actual rows 估算行数 vs 实际行数 差 10 倍以上 = 统计信息问题
Extra Using filesort、Using temporary、Using index、Using where filesort + 大结果集 = 排序溢出到磁盘

⚠️ Using index 是好事(覆盖索引),Using index condition 是索引下推,Using temporary 才是要命的——它意味着中间结果落到了临时表(可能写磁盘)。

反模式

反模式 后果
只看“单次最慢”的 SQL 优化了长尾,放过了总耗时最大的高频查询
不看估算行数,只凭 type 判断 ref 也可能因为估算错误扫了几十万行
大批量导入/删除后不 ANALYZE 计划劣化,加索引也没用
用 SELECT * 导致覆盖索引失效、临时表 回表 + filesort,慢一个数量级
对低选择率列建索引 优化器不用,白付写放大
慢日志不按指纹聚合 同一类 SQL 的 100 条记录被当成 100 个问题

自检

Q1 为什么“匹配 10% 行”的查询,加索引反而更慢?

▸ 答案:因为索引扫描的代价是“树高 + 匹配行数 × 随机读”,10 万行回表就是 10 万次随机 I/O(本节模型 100000 × 4 = 400,000,远超全表顺序扫的 10,000);而全表扫描是顺序读,页数固定。优化器会算出这一点并选全表,于是索引不但没用,还多了维护成本。经验阈值:匹配行超过表的 1%–5%,全表往往更划算(取决于行宽与聚簇程度)。

Q2 EXPLAIN 显示 rows=50,但 EXPLAIN ANALYZE 显示实际扫了 50,000 行,问题出在哪?

▸ 答案:统计信息与实际数据分布不符。常见原因:① 大批量导入/删除后没有重新收集统计信息;② 数据倾斜严重,直方图没覆盖;③ 用了函数或隐式转换,优化器无法用索引统计;④ 参数化查询的“参数敏感”问题(同一 SQL 不同参数,选择率差几个数量级)。修法是先 ANALYZE,再看是否需要直方图/索引调整;必要时用 hint 或改写 SQL 强制计划。

Q3 慢日志里有一类 SQL 每次只跑 50ms,但每天 10 万次;另一类每天 3 次、每次 3 秒。先优化哪个?

▸ 答案:先算总耗时:前者 50ms × 100000 = 5,000 秒/天,后者 3s × 3 = 9 秒/天——前者是后者的 500 倍。而且高频 SQL 往往影响更多用户、占用更多连接。正确顺序是按“总耗时(次数 × 平均耗时)”排序,再结合“是否在关键路径”决定。单次最慢的查询适合做容量与超时保护,不一定是第一优先优化对象。

Q4 为什么会出现 “昨天还快、今天突然变慢”,而且 SQL 一个字没改?

▸ 答案:因为统计信息变了:数据量增长、数据分布变化(如某城市订单暴涨)会让优化器低估/高估行数,从而选错连接顺序或索引。EXPLAIN 里 rows 估计与实际差几个数量级,基本就是统计信息问题;更新统计信息或改写查询可解。

Q5 为什么 “加 hint 强制走某个索引” 只能当最后手段?

▸ 答案:因为 hint 把 “当时的执行计划” 固化了:数据分布变化后,它可能从 “救火” 变成 “拖累”,而且没人会记得回来删。正确顺序是:修统计信息 → 改查询/加合适索引 → 调优化器参数 → 最后才 hint,并给 hint 加注释与到期 review。

关联