07. 查询优化与执行计划

深入数据库查询优化器:基于代价的优化(CBO)原理、EXPLAIN 执行计划分析、索引选择策略与慢 SQL 优化实战。

1. 查询执行流程

SQL 语句
  → 词法/语法分析 → 解析树(Parse Tree)
  → 语义检查(表/列存在性、权限)
  → 查询重写(视图展开、子查询优化、常量传播)
  → 生成候选执行计划
  → 代价估算(统计信息 + 代价模型)
  → 选择最优计划
  → 执行引擎执行

2. EXPLAIN 详解

2.1 EXPLAIN 输出列

EXPLAIN ANALYZE
SELECT e.name, d.name, SUM(s.amount)
FROM employees e
JOIN departments d ON e.dept_id = d.id
JOIN sales s ON e.id = s.emp_id
WHERE s.date > '2024-01-01'
GROUP BY e.id, d.name;
列说明关键值
id执行顺序编号相同 id 从上到下执行
select_type查询类型SIMPLE/PRI/ SUBQUERY/DERIVED
table访问的表别名/子查询
type访问类型** ALL < index < range < ref < eq_ref < const **
possible_keys可能用的索引
key实际用的索引
key_len索引使用长度越短越好(但需完整匹配)
ref索引对比值const/列名
rows估算扫描行数越小越好
filtered过滤后剩余比例越高越好
Extra额外信息Using index 好,Using filesort 差

2.2 type 列优化目标

type说明场景
system系统表,仅一行极罕见
const主键/唯一索引等值WHERE id = 1
eq_refJOIN 主键/唯一索引JOIN 条件为主键
ref普通索引等值WHERE status = 'active'
range索引范围WHERE id BETWEEN 1 AND 100
index全索引扫描索引覆盖 SELECT 列
ALL全表扫描无索引或索引未命中

优化目标:至少要达到 range,最好是 ref 或 eq_ref。

2.3 Extra 列关注

值含义好坏
Using index覆盖索引✅ 好
Using whereWHERE 过滤⚠️ 正常
Using filesort额外排序❌ 差(内存/磁盘排序)
Using temporary临时表❌ 差(GROUP BY/DISTINCT)
Using join buffer连接缓存⚠️ 大表 JOIN 需要
Impossible WHERE永远为假✅ 优化器直接返回空

3. 统计信息与代价模型

3.1 统计信息类型

表级统计:
  - 行数(rows)
  - 数据页数
  - 平均行长度

列级统计(直方图/采样):
  - 不同值数量(Cardinality)
  - 最小/最大值
  - NULL 比例
  - 数据分布(直方图,8.0+)

索引统计:
  - 索引页数
  - 索引 Cardinality

3.2 代价估算

代价 = I/O 代价 + CPU 代价

单表查询估算:
  - 全表扫描代价 = 数据页数 × 每页 I/O 代价
  - 索引扫描代价 = 索引页数 × I/O 代价 + 回表行数 × I/O 代价

多表 JOIN 估算:
  - Nested Loop:外表行数 × 内表单次查找代价
  - Hash Join:构建哈希表代价 + 探测代价
  - Merge Join:排序代价 + 合并代价

4. 优化技巧

4.1 常见慢查询优化

-- 1. 避免 SELECT *
SELECT id, name FROM users WHERE status = 'active';
  -- 比 SELECT * 更可能走覆盖索引

-- 2. 范围查询后的列用不了索引
CREATE INDEX idx_a_b ON t(a, b);
SELECT * FROM t WHERE a > 1 AND b = 2;
  -- a 是范围,b 不走索引
  -- 解决:INDEX(a) 或改写为多个 = 条件

-- 3. 大偏移分页优化(1000000, 10)
-- ❌ 慢:扫描 1000010 行,取 10 行
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;

-- ✅ 快:先查 id,再关联
SELECT o.* FROM orders o
JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 10) tmp
ON o.id = tmp.id;

-- 4. 隐式转换导致索引失效
WHERE phone = 13800138000  -- phone 是 varchar,先转 int
-- ✅ WHERE phone = '13800138000'

-- 5. IN vs EXISTS
-- 大表 IN 小表 → EXISTS 更优
-- 小表 IN 大表 → IN 更优

4.2 JOIN 优化

-- 驱动表选择:小表驱动大表
SELECT * FROM small_table s
JOIN big_table b ON s.id = b.small_id;
  -- small_table 作为驱动表(外表)
  -- 对 big_table 走索引查找

-- 避免笛卡尔积
SELECT * FROM a, b;  -- 如果没有连接条件 → CROSS JOIN
SELECT * FROM a JOIN b ON a.id = b.a_id;  -- ✅ 正确

5. 慢查询分析流程

1. 发现慢查询
   - 开启慢查询日志:slow_query_log = ON, long_query_time = 1
   - 或 performance_schema.events_statements_summary_by_digest

2. 获取执行计划
   EXPLAIN ANALYZE SELECT ...;

3. 分析瓶颈
   - type = ALL?→ 加索引
   - Extra = Using filesort?→ 排序字段加入索引
   - Extra = Using temporary?→ 优化 GROUP BY
   - rows 很大?→ 减少扫描范围或加索引

4. 验证优化效果
   - 对比优化前后的 EXPLAIN
   - 使用 bench 测试真实性能

6. 查询优化器深入:代价估算模型

6.1 基数估计(Cardinality Estimation)

查询优化器的核心是代价估算,而代价估算的准确性取决于基数估计——即估算每个操作符的输出行数。基数估计不准是导致执行计划劣化的首要原因。

基数估计原理:

表 T 有 100 万行,列 col 有 1000 个不同值(Cardinality=1000)

WHERE col = 'X' 的基数估计:
  假设均匀分布 → 1000000 / 1000 = 1000 行

但实际情况往往不均匀(如 80-20 法则):
  col = 'A' → 80万行(80% 的数据,最常见的值)
  col = 'Z' → 1行(非常罕见的值)
  
优化器估算 1000 行 → 选择索引查找
实际 80万行 → 全表扫描才是最优的!

MySQL 8.0 直方图(Histogram):

-- 为列创建直方图,解决非均匀分布问题
ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 128 BUCKETS;

-- 查看直方图
SELECT COLUMN_NAME, HISTOGRAM
FROM information_schema.COLUMN_STATISTICS
WHERE TABLE_NAME = 'orders';

-- 删除直方图
ANALYZE TABLE orders DROP HISTOGRAM ON status;

-- 持久化统计信息(重启后不丢失)
SET GLOBAL innodb_stats_persistent = ON;
SET GLOBAL innodb_stats_auto_recalc = ON;

直方图将列的数据分布划分为 N 个桶(buckets),每个桶记录行数范围,优化器估算 WHERE col = 'X' 时直接查直方图,而非简单假设均匀分布。

6.2 统计信息过时问题

当表数据频繁变更(增删改)时,统计信息会过时,导致基数估计偏差:

-- 查看表的最后分析时间
SELECT last_update, n_rows
FROM mysql.innodb_table_stats
WHERE table_name = 'orders';

-- 手动更新统计信息
ANALYZE TABLE orders;

-- PostgreSQL 中查看统计信息
SELECT relname, reltuples, relpages
FROM pg_class
WHERE relname = 'orders';

-- PostgreSQL 更新统计
ANALYZE orders;

自动统计更新策略:

数据库自动更新机制配置
MySQL变更行数超过 (10% + 更快)innodb_stats_auto_recalc
PostgreSQL基于 autovacuum_analyze_thresholdautovacuum_analyze_scale_factor = 0.1
SQL Server触发阈值(20% 变更)AUTO_UPDATE_STATISTICS

6.3 选择错误的执行计划

有时即使统计信息准确,优化器也会选择次优计划:

-- 典型场景:IN 子查询优化器选择物化子查询(慢)
SELECT * FROM orders
WHERE customer_id IN (
  SELECT id FROM customers WHERE country = 'CN'
);

-- 优化器可能选择:
-- 1) 物化子查询 → 创建临时表 → 与 orders 做 JOIN(内存/磁盘临时表)
-- 2) 半连接(Semi Join) → EXISTS 方式逐行检查(通常更优)

-- 强制优化器使用半连接(MariaDB/PostgreSQL)
SELECT * FROM orders
WHERE customer_id IN (
  SELECT id FROM customers WHERE country = 'CN'
) SEMI JOIN customers USING (customer_id);

-- 或改写为 EXISTS(多数数据库优化得更好)
SELECT * FROM orders o
WHERE EXISTS (
  SELECT 1 FROM customers c
  WHERE c.id = o.customer_id AND c.country = 'CN'
);

7. MySQL 8.0 查询优化新特性

7.1 不可见索引(Invisible Indexes)

-- 创建不可见索引(优化器不会使用,但会维护)
CREATE INDEX idx_test ON orders(created_at) INVISIBLE;

-- 测试索引效果(强制使用)
SELECT /*+ INDEX(orders idx_test) */ * FROM orders WHERE created_at > '2024-01-01';

-- 确认有效后设为可见
ALTER TABLE orders ALTER INDEX idx_test VISIBLE;

用途:

  • 在生产环境安全测试新索引效果,不影响现有查询
  • 渐进式评估索引收益,避免贸然添加导致写性能下降

7.2 降序索引(Descending Indexes)

-- 支持显式 DESC 索引(8.0 之前是升序索引反向扫描,效率较低)
CREATE INDEX idx_created_desc ON orders(created_at DESC);

-- 优化 ORDER BY DESC 场景
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10;
-- 8.0 之前:升序索引反向扫描(低效)
-- 8.0+:直接利用降序索引(高效)

7.3 函数索引(Functional Indexes)

-- 为函数表达式创建索引
CREATE INDEX idx_year ON orders( (YEAR(created_at)) );

-- 查询时直接使用
SELECT * FROM orders WHERE YEAR(created_at) = 2024;
-- 走函数索引,而非全表扫描

-- JSON 路径索引
CREATE INDEX idx_json ON products( (data->>'$.category') );
SELECT * FROM products WHERE data->>'$.category' = 'electronics';

7.4 哈希表连接(Hash Join)

MySQL 8.0 引入了真正的 Hash Join(此前仅支持 Nested Loop Join):

-- 当连接条件没有索引、且两个表数据量都较大时
SELECT * FROM large_A a
JOIN large_B b ON a.hash_col = b.hash_col;

-- MySQL 8.0 选择 Hash Join:
-- 1. 扫描小表 B,构建哈希表
-- 2. 扫描大表 A,探测哈希表
-- 3. 时间复杂度 O(m + n),优于嵌套循环 O(m × n)

8. 深度分页优化

当数据量达到数千万时,传统的 LIMIT offset, size 分页严重退化:

-- ❌ 深度分页(越往后越慢)
SELECT * FROM logs ORDER BY id DESC LIMIT 1000000, 10;
-- MySQL 需要扫描 1000010 行,丢弃前 1000000 行

-- ✅ 方案一:书签式分页(推荐)
-- 利用上一页最后一条的 id 作为游标
SELECT * FROM logs
WHERE id < 9900_000_000  -- 上一页最后一条的 id
ORDER BY id DESC
LIMIT 10;
-- 时间复杂度 O(LIMIT),与页数无关!

-- ✅ 方案二:延迟关联
SELECT l.* FROM logs l
JOIN (
  SELECT id FROM logs
  ORDER BY id DESC LIMIT 1000000, 10
) t ON l.id = t.id;
-- 先查 id(覆盖索引),再关联完整行

-- ✅ 方案三:搜索引擎(海量数据)
-- 使用 Elasticsearch / OpenSearch 的 from/size 或 search_after

分页方案对比:

方案第一页第 1000 页数据一致性适用
LIMIT offset快极慢实时小表、浅分页
书签游标快快读取时固定大表、非跳页
延迟关联快中实时中等规模
搜索引擎快快延迟海量数据

延伸阅读


9. 查询优化实战案例分析

9.1 案例一:索引选择性不足导致全表扫描

场景:用户查询系统,按手机号前缀模糊搜索 WHERE phone LIKE '138%'。

-- 表结构
CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    phone VARCHAR(20),
    name VARCHAR(50),
    INDEX idx_phone (phone)   -- B+树索引
);

-- 查询
SELECT * FROM users WHERE phone LIKE '138%';
-- 执行计划:type=range, key=idx_phone ✅ 走索引范围扫描

-- 但如果改为
SELECT * FROM users WHERE phone LIKE '%138%';
-- 执行计划:type=ALL, key=NULL ❌ 前导通配符无法走索引

-- 解决方案:倒排索引或搜索引擎
-- MySQL 8.0.13+ 支持降序索引 + 虚拟列
ALTER TABLE users ADD COLUMN phone_reverse VARCHAR(20) AS (REVERSE(phone)) VIRTUAL;
CREATE INDEX idx_phone_rev ON users(phone_reverse);

-- 改写查询
SELECT * FROM users WHERE phone_reverse LIKE REVERSE('%138');
-- 利用后缀匹配走索引(REVERSE('138%') = '%831' 仍是前导通配符,
-- 实际应使用全文索引或 Elasticsearch 倒排索引)

对于全文搜索场景,更推荐 Elasticsearch 或 MySQL 全文索引:

-- MySQL 全文索引
CREATE FULLTEXT INDEX ft_idx ON users(name, description);
SELECT * FROM users WHERE MATCH(name, description) AGAINST('搜索关键词' IN BOOLEAN MODE);

9.2 案例二:隐式类型转换导致索引失效

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    phone VARCHAR(20),        -- 字符串类型
    status INT,
    INDEX idx_phone (phone)
);

-- ❌ 错误:整数与字符串比较,发生隐式转换
SELECT * FROM orders WHERE phone = 13800138000;
-- 执行计划:type=ALL(全表扫描),Extra=Using where

-- 原理:phone 是 VARCHAR,比较时 MySQL 将 phone 列的每个值转为 INT
-- 导致无法使用索引,且函数转换本身消耗 CPU

-- ✅ 正确:使用字符串常量
SELECT * FROM orders WHERE phone = '13800138000';
-- 执行计划:type=ref, key=idx_phone ✅

更多隐式转换陷阱:

场景问题正确写法
VARCHAR = INT列转 INTVARCHAR = ‘字符串’
DATETIME = 20240801列转 INT 或字符串DATETIME = ‘2024-08-01’
ENUM = ‘value’ENUM 内部存储是 INTENUM = ‘value’(正常)
JSON = ‘string’JSON 提取后类型不确定显式 CAST

9.3 案例三:JOIN 顺序选择不当

-- 表:orders (1000万行), customers (100万行), order_items (5000万行)

-- ❌ 低效:大表驱动小表(Nested Loop 遍历 5000万行)
SELECT o.*, c.name
FROM order_items oi          -- 5000万行(驱动表)
JOIN orders o ON oi.order_id = o.id
JOIN customers c ON o.customer_id = c.id
WHERE c.country = 'CN';

-- 优化器可能错误选择驱动表,此时需要 STRAIGHT_JOIN 强制顺序

-- ✅ 高效:小结果集驱动大表
SELECT o.*, c.name
FROM customers c             -- 过滤后约 10 万行
STRAIGHT_JOIN orders o ON o.customer_id = c.id
STRAIGHT_JOIN order_items oi ON oi.order_id = o.id
WHERE c.country = 'CN';

-- 或者使用子查询先过滤
SELECT o.*, c.name
FROM (
    SELECT id FROM customers WHERE country = 'CN'  -- 先找 CN 客户
) c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id;

9.4 案例四:NOT IN 的性能问题

-- ❌ 低效:NOT IN 遇到 NULL 返回空结果集,且通常不走索引
SELECT * FROM orders
WHERE customer_id NOT IN (
    SELECT id FROM customers WHERE status = 'banned'
);

-- ✅ 改写为 NOT EXISTS(更语义化,通常性能更好)
SELECT * FROM orders o
WHERE NOT EXISTS (
    SELECT 1 FROM customers c
    WHERE c.id = o.customer_id AND c.status = 'banned'
);

-- ✅ 或 LEFT JOIN + IS NULL
SELECT o.* FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id AND c.status = 'banned'
WHERE c.id IS NULL;

-- 性能对比(1亿行 orders,10万 banned customers):
-- NOT IN: ~15s(全表扫描子查询)
-- NOT EXISTS: ~200ms(索引半连接)
-- LEFT JOIN: ~300ms(索引 JOIN)

10. 查询优化方法论总结

系统化地优化慢查询应遵循以下步骤:

1. 发现慢查询
   └── slow_query_log / performance_schema.events_statements_summary_by_digest

2. 获取执行计划
   └── EXPLAIN ANALYZE / EXPLAIN FORMAT=JSON

3. 分析瓶颈
   ├── type = ALL? → 检查索引、WHERE 条件选择性
   ├── Extra = Using filesort? → 排序字段加入索引(或优化查询)
   ├── Extra = Using temporary? → 简化 GROUP BY / DISTINCT
   ├── rows 很大? → 减少扫描范围(时间窗口、分页)
   └── key = NULL? → 创建合适索引或改写查询

4. 制定优化方案
   ├── 索引优化(最常用):添加 / 调整 / 删除索引
   ├── 查询改写:避免前导通配符、隐式转换、不必要的 SELECT *
   ├── 架构优化:读写分离、缓存(Redis)、搜索引擎(ES)
   └── 数据治理:归档历史数据、分区表

5. 验证效果
   ├── 对比优化前后的 EXPLAIN
   ├── 压测验证(sysbench / mysqlslap)
   └── 监控生产环境指标回归

索引设计原则:

  1. 最左前缀:复合索引 (a, b, c) 只能用于 a、ab、abc 的查询,不能单独用 b 或 bc
  2. 选择性优先:对 WHERE 条件中区分度高的列优先建索引(如手机号 > 性别)
  3. 避免冗余:(a) 和 (a, b) 同时存在时,如果 (a) 很少单独使用,可删除 (a)
  4. 覆盖索引:查询列全在索引中时无需回表,SELECT 只取需要的列
  5. 写放大权衡:每个索引都增加 INSERT/UPDATE/DELETE 的写入开销,索引并非越多越好。需要持续监控索引使用情况,清理长期不使用的低价值索引。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. PostgreSQL 锁与阻塞分析
  2. COPY 与批量数据加载优化
  3. pgvector 向量检索与混合查询