返回 筑基・数据元府藏真

查询优化器揭秘:统计信息与执行计划

博主
大约 14 分钟

查询优化器揭秘:统计信息与执行计划

问题引入:优化器选错了执行计划

-- 同样的查询,有时快有时慢
SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID';

-- 有时用user_id索引(快,返回10条)
-- 有时用status索引(慢,返回100万条)
-- 为什么优化器会做出错误的选择?

这个案例让我深入了解了MySQL查询优化器的工作原理,以及统计信息对执行计划的影响。

现象描述:执行计划异常的表现

场景1:索引选择错误

-- 表结构
CREATE TABLE orders (
    order_id BIGINT PRIMARY KEY,
    user_id BIGINT,
    status VARCHAR(20),
    created_at TIMESTAMP,
    INDEX idx_user_id (user_id),
    INDEX idx_status (status)
);

-- 数据分布
-- user_id = 100 的订单:10条
-- status = 'PAID' 的订单:100万条

-- 查询
SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID';

-- 错误的执行计划:使用idx_status
-- 扫描100万行,再过滤user_id,耗时5秒

-- 正确的执行计划:使用idx_user_id
-- 扫描10行,再过滤status,耗时10ms

场景2:统计信息过期

-- 刚插入大量数据后查询
INSERT INTO orders SELECT * FROM orders_backup; -- 插入1000万条

-- 立即查询
SELECT * FROM orders WHERE user_id = 100;
-- 优化器认为只有100条,实际有10万条
-- 选择了错误的执行计划

-- 分析表后查询
ANALYZE TABLE orders;
SELECT * FROM orders WHERE user_id = 100;
-- 执行计划正确,性能提升100倍

原因分析:查询优化器工作原理

1. 查询优化器架构

SQL查询 -> 解析器 -> 预处理器 -> 优化器 -> 执行器 -> 结果
                      |
                      v
                统计信息(Statistics)
                      |
                      v
                成本模型(Cost Model)
                      |
                      v
                执行计划(Execution Plan)

2. 统计信息详解

表统计信息

-- 查看表统计信息
SHOW TABLE STATUS LIKE 'orders'\G

*************************** 1. row ***************************
           Name: orders
         Engine: InnoDB
        Version: 10
     Row_format: Dynamic
           Rows: 10000000          -- 预估行数
 Avg_row_length: 150
    Data_length: 1500000000
Max_data_length: 0
   Index_length: 500000000
      Data_free: 0
 Auto_increment: 10000001
...

索引统计信息

-- 查看索引统计信息
SHOW INDEX FROM orders\G

*************************** 1. row ***************************
        Table: orders
   Non_unique: 1
     Key_name: idx_user_id
 Seq_in_index: 1
  Column_name: user_id
    Collation: A
  Cardinality: 1000000    -- 基数(不同值的数量)
     Sub_part: NULL
       Packed: NULL
         Null: YES
   Index_type: BTREE
...

-- 计算选择性
-- 选择性 = Cardinality / Rows = 1000000 / 10000000 = 0.1
-- 选择性越接近1,索引价值越高

直方图统计(MySQL 8.0)

-- 创建直方图
ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 100 BUCKETS;

-- 查看直方图
SELECT 
    COLUMN_NAME,
    BUCKET_NUMBER,
    BUCKET_BOUNDARY,
    BUCKET_FREQUENCY
FROM information_schema.COLUMN_STATISTICS
WHERE TABLE_NAME = 'orders' AND COLUMN_NAME = 'status';

-- 直方图作用:帮助优化器了解数据分布
-- 例如:status='PAID'占80%,status='CANCELLED'占2%

3. 成本模型

成本计算因素

-- MySQL成本模型考虑的因素:
-- 1. IO成本:读取数据页的代价
-- 2. CPU成本:比较、排序的代价
-- 3. 网络成本:传输数据的代价(分布式场景)

-- 查看成本参数
SHOW VARIABLES LIKE 'optimizer_cost%';

-- 主要成本参数:
-- optimizer_cost_constants_disk_io_cost: 磁盘IO成本权重
-- optimizer_cost_constants_memory_io_cost: 内存IO成本权重
-- optimizer_cost_constants_cpu_cost: CPU成本权重

索引选择成本计算

-- 场景:SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID'

-- 选项1:使用idx_user_id
-- 成本 = 索引IO成本 + 回表IO成本 + CPU成本
--      = (10行 * 索引页成本) + (10行 * 数据页成本) + (10行 * 比较成本)
--      ≈ 10 * 1 + 10 * 1 + 10 * 0.1 = 21

-- 选项2:使用idx_status
-- 成本 = 索引IO成本 + 回表IO成本 + CPU成本
--      = (100万行 * 索引页成本) + (100万行 * 数据页成本) + (100万行 * 比较成本)
--      ≈ 1000000 * 1 + 1000000 * 1 + 1000000 * 0.1 = 2100000

-- 优化器会选择成本更低的idx_user_id

解决方案:优化器调优实战

1. 统计信息维护

自动统计信息更新

-- MySQL自动更新统计信息的阈值:
-- 当表中10%的数据发生变化时,自动更新统计信息

-- 查看统计信息自动更新配置
SHOW VARIABLES LIKE 'innodb_stats%';

-- 关键参数:
-- innodb_stats_auto_recalc: 是否自动重新计算统计信息(默认ON)
-- innodb_stats_persistent: 是否持久化统计信息(默认ON)
-- innodb_stats_sample_pages: 采样页数(默认20)

手动更新统计信息

-- 手动分析表(更新统计信息)
ANALYZE TABLE orders;

-- 分析多个表
ANALYZE TABLE orders, users, products;

-- 在业务低峰期执行
-- 对于大表,ANALYZE TABLE会短暂锁表

-- MySQL 8.0支持在线分析
ANALYZE TABLE orders UPDATE HISTOGRAM ON status, user_id;

2. 执行计划分析

EXPLAIN详解

-- 基础EXPLAIN
EXPLAIN SELECT * FROM orders WHERE user_id = 100\G

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: idx_user_id,idx_status
          key: idx_user_id
      key_len: 9
          ref: const
         rows: 10
     filtered: 100.00
        Extra: NULL

EXPLAIN字段解读

字段含义优化建议
id查询标识相同id表示同一层,不同id表示嵌套
select_type查询类型SIMPLE简单查询,SUBQUERY子查询等
table访问的表注意派生表和临时表
type访问类型至少range,最好是ref或const
possible_keys可能使用的索引检查是否包含预期索引
key实际使用的索引确保使用了正确的索引
key_len索引长度越长表示使用索引列越多
ref与索引比较的列const表示常量,列名表示列比较
rows预估扫描行数越小越好
filtered过滤比例越高越好
Extra额外信息避免Using filesort, Using temporary

EXPLAIN FORMAT=JSON

-- JSON格式更详细的执行计划
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE user_id = 100\G

-- 输出包含:
-- - 详细的成本估算
-- - 每个操作的具体信息
-- - 索引使用详情
-- - 临时表和文件排序信息

3. 优化器提示(Hints)

索引提示

-- 强制使用某个索引
SELECT * FROM orders USE INDEX(idx_user_id) WHERE user_id = 100;

-- 强制忽略某个索引
SELECT * FROM orders IGNORE INDEX(idx_status) WHERE user_id = 100;

-- 强制使用某个索引,如果没有则报错
SELECT * FROM orders FORCE INDEX(idx_user_id) WHERE user_id = 100;

优化器开关

-- 查看优化器开关
SHOW VARIABLES LIKE 'optimizer_switch';

-- 临时关闭某些优化
SET SESSION optimizer_switch = 'index_merge=off';

-- 常用优化器开关:
-- index_merge: 索引合并优化
-- index_merge_union: 索引合并UNION优化
-- index_merge_sort_union: 索引合并排序UNION优化
-- index_merge_intersection: 索引合并交集优化
-- engine_condition_pushdown: 引擎条件下推
-- index_condition_pushdown: 索引条件下推

4. 直方图应用(MySQL 8.0)

创建和维护直方图

-- 创建直方图
ANALYZE TABLE orders 
UPDATE HISTOGRAM ON status, pay_type, source 
WITH 100 BUCKETS;

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

-- 查看直方图信息
SELECT 
    COLUMN_NAME,
    HISTOGRAM->>'$."number-of-buckets-specified"' as buckets
FROM information_schema.COLUMN_STATISTICS
WHERE TABLE_NAME = 'orders';

直方图优化案例

-- 场景:订单状态分布不均匀
-- PAID: 80%, PENDING: 15%, CANCELLED: 4%, REFUNDED: 1%

-- 没有直方图时,优化器假设均匀分布
-- 估计每个状态占25%

-- 有直方图后,优化器知道真实分布
-- 对于status='REFUNDED'的查询,会选择更优的执行计划

-- 创建直方图前
EXPLAIN SELECT * FROM orders WHERE status = 'REFUNDED';
-- type: ALL, rows: 10000000 (全表扫描)

-- 创建直方图后
ANALYZE TABLE orders UPDATE HISTOGRAM ON status;
EXPLAIN SELECT * FROM orders WHERE status = 'REFUNDED';
-- type: ref, rows: 100000 (使用索引)

实施步骤:执行计划优化流程

步骤1:发现问题

-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;

-- 或使用Performance Schema
UPDATE performance_schema.setup_instruments 
SET ENABLED = 'YES', TIMED = 'YES' 
WHERE NAME LIKE '%stage/sql%';

步骤2:分析执行计划

-- 获取执行计划
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE user_id = 100\G

-- 关注要点:
-- 1. type列:避免ALL(全表扫描)
-- 2. rows列:预估扫描行数是否合理
-- 3. key列:是否使用了正确的索引
-- 4. Extra列:是否有Using filesort, Using temporary

步骤3:诊断问题

-- 检查统计信息是否过期
SHOW TABLE STATUS LIKE 'orders';
-- 对比实际行数和Rows字段

-- 检查索引 Cardinality
SHOW INDEX FROM orders;
-- 对比Cardinality和实际不同值数量

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

步骤4:优化实施

-- 方案1:更新统计信息
ANALYZE TABLE orders;

-- 方案2:创建直方图(MySQL 8.0)
ANALYZE TABLE orders UPDATE HISTOGRAM ON status;

-- 方案3:使用优化器提示
SELECT * FROM orders USE INDEX(idx_user_id) WHERE user_id = 100;

-- 方案4:调整优化器参数
SET SESSION optimizer_switch = 'index_merge=off';

步骤5:效果验证

-- 对比优化前后执行计划
EXPLAIN SELECT * FROM orders WHERE user_id = 100\G

-- 对比性能
-- 优化前:扫描100万行,耗时5秒
-- 优化后:扫描10行,耗时10ms

效果验证:优化前后对比

案例1:统计信息过期导致的性能问题

指标优化前优化后提升
查询时间5s50ms99% ↓
扫描行数1000万100099.99% ↓
使用索引idx_user_id新增
统计信息过期已更新修复

优化措施

  1. 执行ANALYZE TABLE更新统计信息
  2. 建立定期ANALYZE TABLE的维护计划

案例2:直方图优化索引选择

查询条件优化前执行计划优化后执行计划效果
status='PAID' (80%)全表扫描全表扫描正确
status='REFUNDED' (1%)全表扫描索引扫描提升100倍

优化措施

  1. 创建status列的直方图
  2. 优化器根据数据分布选择最优执行计划

经验总结:优化器调优最佳实践

✅ 应该做的

  1. 定期更新统计信息:ANALYZE TABLE
  2. 使用EXPLAIN分析执行计划
  3. 关注rows和type字段
  4. MySQL 8.0使用直方图优化
  5. 监控慢查询日志
  6. 理解成本模型原理
  7. 必要时使用优化器提示
  8. 建立统计信息维护计划

❌ 不应该做的

  1. 从不更新统计信息
  2. 忽略执行计划分析
  3. 过度依赖优化器提示
  4. 不了解数据分布就创建索引
  5. 不监控统计信息过期
  6. 盲目调整优化器参数
  7. 忽视直方图的作用(MySQL 8.0)
  8. 不验证优化效果

深度案例分析:优化器问题排查

项目背景

某电商平台出现间歇性慢查询:

  • 同样的SQL有时10ms,有时10秒
  • 高峰期性能急剧下降
  • 重启数据库后暂时好转

问题诊断

-- 问题SQL
SELECT * FROM orders 
WHERE user_id = 100 
AND status = 'PAID' 
AND created_at > DATE_SUB(NOW(), INTERVAL 7 DAY);

-- 查看执行计划(慢的时候)
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID'\G
-- type: ALL, rows: 10000000, key: NULL
-- 全表扫描!

-- 查看执行计划(快的时候)
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID'\G
-- type: ref, rows: 100, key: idx_user_id
-- 使用索引

-- 检查统计信息
SHOW TABLE STATUS LIKE 'orders';
-- Rows: 1000000 (实际1000万)
-- 统计信息严重过期!

根因分析

  1. 大量数据导入后统计信息未更新
  2. 优化器基于过期的统计信息选择执行计划
  3. 有时运气好选对了索引,有时选错

解决方案

-- 立即修复
ANALYZE TABLE orders;

-- 长期方案:定期维护计划
-- 创建事件定时更新统计信息
CREATE EVENT update_stats 
ON SCHEDULE EVERY 1 DAY
DO
    ANALYZE TABLE orders, users, products;

-- 启用事件
SET GLOBAL event_scheduler = ON;

优化效果

指标优化前优化后提升
平均查询时间5s20ms99.6% ↓
性能稳定性波动大稳定大幅改善
统计信息准确度10%误差<1%误差精准

查询优化器决策树

查询性能问题
    ↓
是否使用了索引?
    ↓ 否
检查统计信息是否过期
    ↓
执行ANALYZE TABLE
    ↓
再次检查执行计划
    ↓
是否选择了正确的索引?
    ↓ 否
检查数据分布是否均匀
    ↓
是否需要创建直方图?
    ↓ 是
ANALYZE TABLE UPDATE HISTOGRAM
    ↓
是否仍有问题?
    ↓ 是
考虑使用优化器提示
    ↓
优化完成

常见误区与避坑指南

❌ 误区1:优化器总是对的

问题:优化器基于统计信息做决策,统计信息过期会导致错误选择。

正确做法:定期更新统计信息,监控执行计划变化。

❌ 误区2:优化器提示是万能药

问题:过度使用hints会导致代码难以维护,且可能不是最优解。

正确做法:先尝试修复统计信息,hints作为最后手段。

❌ 误区3:忽略直方图的作用

问题:MySQL 8.0的直方图可以显著提升非均匀分布数据的查询性能。

正确做法:为数据分布不均匀的列创建直方图。

❌ 误区4:不关注执行计划变化

问题:数据量变化后,原来的最优执行计划可能不再最优。

正确做法:建立执行计划监控,及时发现异常。

执行计划分析检查清单

□ 是否使用了预期的索引?
□ type列是否至少为range?
□ rows预估是否准确?
□ 是否存在Using filesort?
□ 是否存在Using temporary?
□ 统计信息是否过期?
□ 是否需要创建直方图?
□ 是否可以使用覆盖索引?
□ 是否可以使用索引条件下推?
□ 执行计划是否稳定?

附录

A. EXPLAIN type字段速查

type效率说明
system最高表只有一行
const极高通过主键或唯一索引一次找到
eq_ref极高JOIN中通过主键或唯一索引关联
ref使用非唯一索引
range索引范围扫描
index全索引扫描
ALL最低全表扫描

B. EXPLAIN Extra字段速查

Extra含义优化建议
Using index使用覆盖索引好事
Using where使用WHERE过滤正常
Using filesort需要额外排序添加索引避免
Using temporary需要临时表优化查询或添加索引
Using join buffer使用连接缓冲考虑添加索引
Impossible WHEREWHERE条件永远为假检查逻辑
Select tables optimized away优化器确定只返回一行好事

C. 统计信息维护脚本

-- 定期维护脚本
DELIMITER $$

CREATE PROCEDURE MaintainStatistics()
BEGIN
    DECLARE done INT DEFAULT FALSE;
    DECLARE table_name VARCHAR(64);
    DECLARE cur CURSOR FOR 
        SELECT TABLE_NAME 
        FROM information_schema.TABLES 
        WHERE TABLE_SCHEMA = DATABASE() 
        AND TABLE_TYPE = 'BASE TABLE';
    DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
    
    OPEN cur;
    read_loop: LOOP
        FETCH cur INTO table_name;
        IF done THEN
            LEAVE read_loop;
        END IF;
        
        SET @sql = CONCAT('ANALYZE TABLE ', table_name);
        PREPARE stmt FROM @sql;
        EXECUTE stmt;
        DEALLOCATE PREPARE stmt;
    END LOOP;
    
    CLOSE cur;
END$$

DELIMITER ;

-- 创建定时事件
CREATE EVENT maintain_stats_event
ON SCHEDULE EVERY 1 DAY
STARTS CURRENT_TIMESTAMP
DO CALL MaintainStatistics();

读者练习

  1. 思考题:分析你项目中的慢查询,检查是否存在统计信息过期问题。

  2. 实践题:为以下查询分析执行计划并优化:

SELECT o.*, u.username 
FROM orders o
JOIN users u ON o.user_id = u.user_id
WHERE o.status = 'PAID'
AND o.created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY o.total_amount DESC
LIMIT 100;
  1. 挑战题:实现一个执行计划监控工具,自动发现执行计划变化。

系列上一篇索引深度解析:B+树、哈希与覆盖索引

系列下一篇高级查询技巧:窗口函数与递归查询

知识点测试

读完文章了?来测试一下你对知识点的掌握程度吧!

评论区

使用 GitHub 账号登录后即可发表评论,支持 Markdown 格式。

如果评论系统无法加载,请确保:

  • 您的网络可以访问 GitHub
  • giscus GitHub App 已安装到仓库
  • 仓库已启用 Discussions 功能