【MySQL深入详解】第21篇:MySQL优化器——为什么你的SQL没有走索引

0 阅读7分钟

开篇引入

"我明明建了索引,为什么查询还是全表扫描?" "EXPLAIN显示type=ALL,但我的WHERE条件明明可以用索引啊!"

这不是MySQL的bug,是优化器的选择。MySQL优化器基于成本模型选择执行计划,它认为全表扫描比用索引更快,所以选了全表扫描。

《高性能MySQL》第8章讲查询执行,这篇文章帮你理解MySQL优化器的工作原理。

MySQL执行查询的过程

查询执行流程

SQL语句
    ↓
语法解析器 → 生成解析树
    ↓
预处理器 → 检查表/列是否存在,权限
    ↓
查询优化器 → 生成执行计划
    ↓
存储引擎 → 执行计划,返回结果

两类优化策略

-- 静态优化:基于规则的优化
-- 编译时完成,与数据无关
SELECT * FROM users WHERE id = 1;
-- 优化器知道id是主键,直接用const访问

-- 动态优化:基于成本的优化
-- 执行时分析,需要读取统计信息
SELECT * FROM users WHERE age > 25;
-- 优化器需要知道有多少用户满足age>25

成本模型

成本计算的组成

-- MySQL优化器计算每个执行计划的总成本
-- 成本 = CPU成本 + IO成本

-- CPU成本:
-- - 读取和计算每行的成本
-- - 排序的成本

-- IO成本:
-- - 读取页面的成本
-- - 随机读 vs 顺序读

查看优化器决策

-- MySQL 8.0+: 使用optimizer_trace
SET optimizer_trace = 'enabled=on';
SET optimizer_trace_max_mem_size = 1048576;

SELECT * FROM orders WHERE user_id = 1;

SELECT * FROM information_schema.optimizer_trace;

成本参数

SHOW VARIABLES LIKE 'optimizer_cost%';

-- 成本参数(可调整)
-- optimizer_trace显示的cost_*值就是基于这些参数计算的

常见不走索引的原因

原因1:统计信息不准

-- 表数据变化后,统计信息可能过时
-- 导致优化器估算错误

-- 解决:ANALYZE TABLE更新统计信息
ANALYZE TABLE orders;

-- InnoDB采样页面数
SHOW VARIABLES LIKE 'innodb_stats_sample_pages';
-- 默认8,可以调大
SET GLOBAL innodb_stats_sample_pages = 64;

原因2:选择性太低

-- 假设status只有3个值:pending, paid, cancelled
-- 30%的记录是paid

-- 优化器认为:
-- - 索引扫描:需要读取30%数据 + 回表
-- - 全表扫描:顺序读取100%数据
-- - 结论:全表扫描可能更快

-- 解决:联合索引增加选择性
CREATE INDEX idx_user_status ON orders(user_id, status);

原因3:范围查询阻断

-- 索引:(user_id, created_at)
-- 查询:WHERE user_id > 100 AND created_at > '2024-01-01'

-- 问题:
-- user_id > 100 是范围查询
-- created_at > '2024-01-01' 无法利用索引

-- 解决:重写查询或调整索引
-- 改写为:
SELECT * FROM orders 
WHERE user_id > 100 
  AND (created_at > '2024-01-01' OR created_at IS NULL);

原因4:数据量太小

-- 表只有100行
-- 优化器认为全表扫描更快
-- 索引维护成本 > 扫描成本

-- 解决:数据量增长后会自然使用索引
-- 或者FORCE INDEX强制使用
SELECT * FROM orders FORCE INDEX (idx_user_id) WHERE user_id = 1;

原因5:函数/运算阻断

-- WHERE YEAR(created_at) = 2024
-- 索引列被函数包装,无法使用

-- 解决:改写查询
WHERE created_at >= '2024-01-01' 
  AND created_at < '2025-01-01'

-- 或者:使用生成列+索引
ALTER TABLE orders ADD COLUMN year INT GENERATED ALWAYS AS (YEAR(created_at));
CREATE INDEX idx_year ON orders(year);

optimizer_trace分析

开启追踪

SET optimizer_trace = 'enabled=on', 
    optimizer_trace_max_mem_size = 1048576;

-- 执行查询
SELECT * FROM orders WHERE user_id = 1 AND status = 'paid';

-- 查看追踪结果
SELECT * FROM information_schema.optimizer_trace\G

追踪结果解读

{
  "steps": [
    {
      "join_preparation": {
        "select#": 1,
        "steps": [...]
      }
    },
    {
      "join_optimization": {
        "cost_before_conditions": 1234.56,
        "condition_processing": [...],
        "substitute_derived_conditions": [...],
        "table_dependencies": [...],
        "ref_optimizer_key_candidates": [...],
        "considered_execution_plans": [
          {
            "plan_prefix": [],
            "plan": {
              "rows_estimation": [
                {
                  "table": "orders",
                  "range_analysis": {
                    "chosen_range_access_summary": {
                      "range_scan_alternatives": [
                        {
                          "type": "ref",
                          "index": "idx_user_status",
                          "rows": 100,
                          "cost": 120
                        }
                      ]
                    }
                  }
                }
              ]
            },
            "best_access_path": {
              "considered_access_paths": [
                {
                  "access_type": "ref",
                  "estimated_cost": 120,
                  "rows": 100,
                  "use_extended_summary": true
                },
                {
                  "access_type": "scan",
                  "cost": 500,
                  "rows": 50000
                }
              ]
            }
          }
        ]
      }
    }
  ]
}

关键信息

  • considered_execution_plans: 所有被考虑的执行计划
  • rows_estimation: 每个计划的行数估算
  • best_access_path: 优化器选择的方案

优化器开关

控制优化器行为

SHOW VARIABLES LIKE 'optimizer_switch';

-- 常用开关:
-- index_merge: 索引合并
-- engine_condition_pushdown: 引擎条件下推
-- index_condition_pushdown: 索引条件下推(ICP)
-- derived_merge: 派生表合并
-- materialization: 物化

禁用某个优化

-- 禁用索引合并
SET optimizer_switch = 'index_merge=off';

-- 禁用ICP
SET optimizer_switch = 'index_condition_pushdown=off';

强制使用索引

USE INDEX

-- 建议优化器使用某个索引
SELECT * FROM orders USE INDEX (idx_user_id) WHERE user_id = 1;

FORCE INDEX

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

-- 场景:优化器选错了索引
-- 可能是因为统计信息不准

IGNORE INDEX

-- 忽略某个索引(让优化器不选它)
SELECT * FROM orders IGNORE INDEX (idx_status) WHERE user_id = 1;

STRAIGHT_JOIN

-- 强制按查询写的顺序JOIN
SELECT * FROM orders STRAIGHT_JOIN users ON orders.user_id = users.id;

-- 告诉优化器:orders是驱动表,users是被驱动表

统计信息管理

InnoDB统计信息

-- InnoDB通过采样估算统计信息
-- 采样页面数由innodb_stats_sample_pages控制

-- 手动设置统计信息
ANALYZE TABLE orders;

-- 持久化统计信息(MySQL 8.0+)
ALTER TABLE orders STATS_PERSISTENT = 1;

-- 查看表统计信息
SELECT * FROM information_schema.tables 
WHERE table_name = 'orders';

统计信息触发条件

-- InnoDB在以下情况更新统计信息:
-- 1. ANALYZE TABLE
-- 2. 表首次打开
-- 3. 表大小变化超过1/16
-- 4. 大数据删除(>表的1/16)

-- 可以关闭自动更新
SET GLOBAL innodb_stats_auto_recalc = OFF;

优化器限制

无法感知的因素

-- 优化器不知道:
-- 1. 数据在内存还是磁盘
-- 2. 其他查询的并发
-- 3. 应用缓存情况
-- 4. 网络延迟

优化器错误

-- 优化器不是完美的,可能选错计划
-- 常见原因:
-- 1. 统计信息过时
-- 2. 成本模型不准确
-- 3. 多表JOIN时组合爆炸

-- 解决:
-- 1. ANALYZE TABLE
-- 2. 调整成本参数
-- 3. 使用optimizer hints
-- 4. 改写查询

优化器Hints

表级Hints

-- 告诉优化器表扫描顺序
SELECT * FROM orders STRAIGHT_JOIN users ON orders.user_id = users.id;

-- 设置join buffer大小
SELECT /*+ JOIN_BUFFER_SIZE(1024 * 1024) */ * FROM orders o JOIN users u ON o.user_id = u.id;

索引级Hints

-- 使用指定索引
SELECT /*+ INDEX(orders idx_user_id) */ * FROM orders WHERE user_id = 1;

-- 忽略指定索引
SELECT /*+ NO_INDEX(orders idx_status) */ * FROM orders WHERE user_id = 1;

优化器参数Hints

-- 设置优化器成本参数
SELECT /*+ SET_VAR(optimizer_switch = 'index_merge=off') */ 
    * FROM orders WHERE user_id = 1 OR status = 'paid';

常见问题解答

Q: 为什么LIKE '%abc'不能走索引?

-- LIKE 'abc%' 可以走索引(前缀匹配)
-- LIKE '%abc' 不能走索引(前导通配符)
-- LIKE '%abc%' 也不能走索引

-- 解决方案:
-- 1. 使用全文索引
-- 2. 使用搜索引擎(如Elasticsearch)
-- 3. 反转字符串+前缀索引

Q: 为什么OR两边都要建索引才能走索引?

-- WHERE a = 1 OR b = 2
-- 需要同时建(a)索引和(b)索引
-- MySQL会用Index Merge合并两个索引结果

-- 如果只有(a)索引,b条件会变成全表扫描

Q: 为什么优化器选择全表扫描而不是索引?

-- 原因可能是:
-- 1. 表太小(<100行)
-- 2. 索引选择性低(返回大部分数据)
-- 3. 统计信息不准
-- 4. 范围查询导致回表成本高

-- 解决:ANALYZE TABLE 或 FORCE INDEX

小结

  1. 优化器基于成本选择执行计划:不是基于规则
  2. 统计信息是成本估算的基础:过时会导致错误决策
  3. ANALYZE TABLE修复统计信息:很多"不走索引"问题可以解决
  4. optimizer_trace是分析神器:可以看到优化器的完整决策过程
  5. 选择性低的索引不受青睐:优化器认为全表扫描更快
  6. 范围查询阻断后续列:设计索引时考虑WHERE顺序
  7. optimizer hints强制优化器行为:USE INDEX / FORCE INDEX
  8. 优化器不是完美的:在特殊场景下可能选错计划

理解优化器的工作原理,才能在它"犯错"时正确干预。


延伸阅读

  • 《高性能MySQL》第8章 查询性能优化
  • MySQL 8.0 Reference Manual: Optimizer Hints
  • MySQL 8.0 Reference Manual: Controlling the Optimizer
  • optimizer_trace: dev.mysql.com/doc/refman/…