第七期:查询优化器与执行计划 —— 如何读懂与干预

0 阅读8分钟

### 第七期:查询优化器与执行计划 —— 如何读懂与干预

#### 1. 查询优化器的工作流程

SQL Server 执行一条查询的完整路径:


T-SQL 语句

    ↓

【解析】语法检查 → 生成解析树

    ↓

【绑定】代数化(Algebrizer)→ 绑定到对象(表、视图、列),生成逻辑树(查询树)

    ↓

【优化】Query Optimizer → 基于代价生成多个候选计划 → 选择代价最低的

    ↓

【生成】执行计划 → 存入计划缓存

    ↓

【执行】Query Executor + Storage Engine → 返回结果

**优化器的核心任务**:在有限时间内找到“足够好”的执行计划(不是绝对最优,否则优化本身成本太高)。

**三个阶段**:

| 阶段 | 策略 | 时间 | 搜索范围 |

|------|------|------|----------|

| 阶段0 | 事务处理(Trivial Plan) | 极快 | 只生成最简单的计划(如行数=1时直接扫描) |

| 阶段1 | 探索阶段(Exploration) | 快 | 搜索部分有希望的变换规则 |

| 阶段2 | 完全优化(Full Optimization) | 慢 | 搜索全部变换规则(并行计划也在此生成) |

**现象解释**:

- 为什么简单查询几乎瞬间返回执行计划?因为命中了阶段0,直接生成Trivial Plan。

- 为什么复杂查询第一次编译时间长?因为优化器需要搜索多个候选计划(阶段2)。

#### 2. 执行计划基础:读懂图形化计划

**三种获取执行计划的方式**:

| 方式 | 命令 | 特点 |

|------|------|------|

| 估计执行计划 | SET SHOWPLAN\_XML ON | 不执行查询,基于统计信息估算 |

| 实际执行计划 | SET STATISTICS XML ON | 执行查询,包含实际行数、运行时信息 |

| 实时执行计划 | sp\_WhoIsActive 或 DMV | 查看正在执行的计划的实时进度(2016+) |

**核心运算符(从数据流向角度理解)**:

| 运算符 | 作用 | 图标特征 |

|--------|------|----------|

| **Clustered Index Seek** | 在聚集索引中精确查找 | 黄色箭头+放大镜 |

| **Clustered Index Scan** | 扫描整个聚集索引(表扫描) | 黄色箭头+表格 |

| **Index Seek** | 在非聚集索引中精确查找 | 绿色箭头+放大镜 |

| **Index Scan** | 扫描整个非聚集索引 | 绿色箭头+表格 |

| **Key Lookup(Bookmark Lookup)** | 从非聚集索引回到聚集索引/堆取行 | 小书签图标 |

| **Nested Loops** | 循环嵌套连接(适用于小表驱动大表) | 两个圆套在一起 |

| **Hash Match** | 哈希连接(适用于大表无索引) | 两个圆相交 |

| **Merge Join** | 合并连接(适用于两个有序输入) | 齿轮状 |

| **Sort** | 显式排序 | 带A-Z的箭头 |

| **Filter** | 筛选行 | 漏斗 |

| **Compute Scalar** | 计算标量表达式 | 计算器 |

**读取顺序**:**从右到左、从下到上**(执行顺序:子节点先执行,结果返回父节点)。

**关键属性**:

- **估计行数**:优化器根据统计信息预估的行数

- **实际行数**:实际执行返回的行数(实际计划中才有)

- **估计I/O/CPU代价**:相对单位,用于比较计划

- **估计子树代价**:该节点及以下的总代价(优化器选计划的核心依据)

**现象解释**:

- 为什么估计行数和实际行数相差很大?统计信息过期或采样不准,优化器基于错误数据选了错误计划。

#### 3. 统计信息:优化器的“眼睛”

**统计信息的内容**:

- 密度向量(Density):列上重复值的分布(=1/唯一值数)

- 直方图(Histogram):最多200步,记录列值范围和行数分布

- 字符串摘要(String Summary):用于LIKE查询的估算

**统计信息自动维护**:

- 自动创建(Auto Create):当查询引用无统计信息的列时

- 自动更新(Auto Update):当行数变化超过阈值时

- 行数 < 500:变化超过500行触发

- 行数 > 500:变化超过 500 + 20% 行数触发

**手动管理统计信息**:


\-- 查看统计信息

DBCC SHOW\_STATISTICS ('Orders', 'IX\_Orders\_OrderDate');



\-- 更新统计信息(全表扫描)

UPDATE STATISTICS Orders IX\_Orders\_OrderDate WITH FULLSCAN;



\-- 更新表上所有统计信息

UPDATE STATISTICS Orders WITH FULLSCAN;



\-- 更新数据库所有统计信息

EXEC sp\_updatestats;

**统计信息问题现象**:

| 现象 | 统计信息问题 |

|------|--------------|

| 实际行数远小于估计行数 | 统计信息过高估计 |

| 实际行数远大于估计行数 | 统计信息过低估计 |

| 参数嗅探导致错误计划 | 统计信息准确但优化器基于特定参数值做了错误决策 |

| 查询突然变慢,重建统计后恢复 | 统计信息过期 |

#### 4. 计划缓存与重用

**计划缓存位置**:内存中(属于缓冲池之外的计划缓存区域)

**计划缓存的优势**:

- 节省编译时间(复杂查询可节省70%~90%编译开销)

- 减少CPU使用

**计划不能被重用的情况**:

- 查询未被参数化(Ad-hoc查询使用文字常量)

- 参数值差异大导致嗅探问题(强制重新编译)

- 表结构变化(索引增删)

- 统计信息更新

- OPTION (RECOMPILE) 显式指定

**管理计划缓存**:


\-- 查看缓存计划

SELECT usecounts, cacheobjtype, objtype, size\_in\_bytes, sql\_handle, plan\_handle

FROM sys.dm\_exec\_cached\_plans;



\-- 清除所有缓存计划(谨慎)

DBCC FREEPROCCACHE;



\-- 清除特定计划

DBCC FREEPROCCACHE (plan\_handle);



\-- 清除特定数据库的计划

DBCC FLUSHPROCINDB (db\_id);

**参数嗅探(Parameter Sniffing)**:

- **定义**:优化器在第一次编译时使用传入的参数值生成计划,后续复用时即使参数不同仍用同一计划。

- **示例**:


\-- 第一次执行:@Status = 'Active'(有10000行,优化器选表扫描)

\-- 第二次执行:@Status = 'Archived'(只有10行,但沿用扫描计划 → 慢!)

CREATE PROC GetOrders @Status VARCHAR(20) AS

SELECT \* FROM Orders WHERE Status = @Status;

- **解决**:OPTION (RECOMPILE)OPTION (OPTIMIZE FOR UNKNOWN)、使用本地变量或OPTION (OPTIMIZE FOR (@Status='Active'))

#### 5. 常见执行计划问题与优化

| 问题 | 执行计划特征 | 根本原因 | 解决方案 |

|------|--------------|----------|----------|

| 全表扫描(死)| Clustered Index Scan 代价极高 | 无合适索引,或查询不SARG | 添加索引,重写查询 |

| 书签查找过多 | Key Lookup 占总代价80%+ | 非聚集索引未覆盖查询列 | 添加INCLUDE列,或使用覆盖索引 |

| 隐士转换 | Scan + CONVERT_IMPLICIT | 参数类型与列类型不匹配 | 统一参数类型(如传入'123'而非123) |

| 排序代价高 | Sort 占大量代价 | 无索引支持ORDER BY/GROUP BY | 在排序列上建索引 |

| 连接顺序错 | Hash Join 驱动表选错 | 统计信息不准 | 更新统计信息,或使用OPTION(FORCE ORDER) |

| 并行计划过多 | 多个并行运算符 | 查询复杂但数据量小 | 降低MAXDOP,或加OPTION(MAXDOP 1) |

**SARGable查询原则**(Search ARGument able):


\-- 坏(非SARG):函数包裹列

WHERE YEAR(OrderDate) = 2024



\-- 好(SARGable):列单独出现

WHERE OrderDate >= '2024-01-01' AND OrderDate < '2025-01-01'



\-- 坏(非SARG):列在表达式中

WHERE Price \* 1.1 > 100



\-- 好(SARGable)

WHERE Price > 100 / 1.1

#### 6. 干预优化器的方法

| 方法 | 语法 | 适用场景 |

|------|------|----------|

| 查询提示 | WITH (INDEX=IX\_Name) | 强制使用特定索引 |

| 连接提示 | OPTION (HASH JOIN, MERGE JOIN) | 强制连接算法 |

| 强制顺序 | OPTION (FORCE ORDER) | 按FROM顺序执行连接 |

| 重编译 | OPTION (RECOMPILE) | 防止参数嗅探,或每次参数差异大 |

| 优化未知 | OPTION (OPTIMIZE FOR UNKNOWN) | 参数嗅探,使用平均值估算 |

| 指定值 | OPTION (OPTIMIZE FOR (@Status='Active')) | 让优化器基于特定值优化 |

| MAXDOP | OPTION (MAXDOP 2) | 限制并行度 |

| 表提示 | WITH (NOLOCK, READPAST) | 控制锁行为 |

**提示使用原则**:

- 先验证问题(确认优化器选错了计划)

- 最小化干预(优先改查询/索引/统计信息)

- 测试所有场景(防止一个查询改好,其他查询变差)

- 代码注释原因(为什么强制这个提示)

#### 7. 执行计划分析实战步骤


1\. 获取实际执行计划(SET STATISTICS XML ON)

2\. 找出代价最高的运算符(通常占80%+代价)

3\. 检查估计行数 vs 实际行数差异

&#x20;  - 差异大 → 更新统计信息

&#x20;  - 差异小但计划仍差 → 索引或查询结构问题

4\. 检查是否有表扫描/索引扫描(如果是大表且非必要)

5\. 检查是否有大量书签查找(考虑覆盖索引)

6\. 检查连接算法(循环嵌套适用于小表驱动大表)

7\. 检查排序/Sort开销(考虑索引支持)

8\. 检查隐式转换(CONVERT\_IMPLICIT)

9\. 设计改进方案(索引/查询/统计信息)

10\. 验证改进效果(对比代价和解锁时间)

---

**第七期小结**

查询优化器基于代价在有限时间内选择“足够好”的执行计划,其决策质量高度依赖统计信息的准确性。读懂执行计划要学会从右到左、从下到上,找出高代价运算符,并判断根因(索引缺失/统计信息过期/参数嗅探)。干预优化器应遵循“先优化结构,后使用提示”的原则。核心目标:消除扫描、减少书签查找、确保连接顺序合理。

**下期预告**:存储引擎深度(四)—— 事务与并发控制的内部实现,深入理解行版本、日志序列号(LSN)和恢复过程。