执行计划里的暗坑:MySQL 优化器选错索引的根因与应对 执行计划里的暗坑MySQL 优化器选错索引的根因与应对一、慢查询暴增背后的索引选择失灵线上监控告警频繁触发P99 延迟从 50ms 飙升到 1200ms。排查慢查询日志后发现同一条 SQL 在不同时间段走了不同的索引白天走二级索引响应 3ms凌晨走主键全表扫描响应 1800ms。这不是偶发事件而是 MySQL 优化器基于统计信息做代价估算时因数据分布变化导致选错执行路径的典型问题。核心痛点在于优化器的索引选择依赖mysql.innodb_index_stats中的 Cardinality 估值而 InnoDB 通过随机采样 8 个叶子页来估算该值采样率极低。当数据分布倾斜或近期有大批量写入时Cardinality 偏差可达 40% 以上直接导致代价模型计算出错误的执行计划。生产环境中这类问题往往不是单次出现而是随着数据增长周期性复发。手动ANALYZE TABLE只能临时缓解无法根治。二、代价模型与索引选择机制的底层拆解MySQL 优化器的索引选择流程可以拆解为三个阶段语法解析、代价计算、执行计划生成。其中代价计算是核心决策环节。flowchart TD A[SQL 文本] -- B[Parser 语法解析] B -- C[Preprocessor 语义检查] C -- D[Optimizer 代价计算] D -- D1[计算全表扫描代价] D -- D2[计算各索引 Range Scan 代价] D -- D3[计算 Index Merge 代价] D1 -- E[代价比较与最优计划选择] D2 -- E D3 -- E E -- F{代价差异是否显著?} F --|是| G[选择最优索引] F --|否| H[启发式规则兜底] G -- I[生成执行计划] H -- I I -- J[Executor 执行]代价计算的核心公式为Total_Cost IO_Cost CPU_Cost IO_Cost 数据页数 * io_block_read_cost CPU_Cost 评估行数 * eval_cost 行比较次数 * row_evaluate_cost其中评估行数rows字段来自索引统计信息。InnoDB 对每个索引维护n_diff_pfx01到n_diff_pfxNN表示不同前缀列的基数。这些值通过采样估算采样逻辑位于ha_innobase::records_in_range()中对范围查询仅采样 BTree 的左右边界页中间分布完全靠插值猜测。关键问题当索引列存在数据倾斜如状态字段 90% 为同一值采样无法感知分布的偏斜度导致rows估值严重偏低优化器误以为该索引过滤性好。三、生产级索引选择修正与执行计划固化3.1 精准诊断从 EXPLAIN 到 Optimizer Trace-- 开启 optimizer_trace获取完整的代价计算过程 SET optimizer_traceenabledon; SELECT * FROM orders WHERE status PAID AND create_time 2026-01-01; SET optimizer_traceenabledoff; -- 查看优化器对每个索引的代价评估详情 SELECT trace FROM information_schema.OPTIMIZER_TRACE\Goptimizer_trace的输出中range_scan_alternatives数组列出了每个可用索引的评估行数和代价。对比实际执行行数通过Handler_read_next状态变量即可定位偏差来源。3.2 统计信息修正与自动维护import pymysql import logging from datetime import datetime, timedelta class IndexStatsMaintainer: 自动检测 Cardinality 偏差并修正的守护进程 def __init__(self, host, port, user, password, db): self.conn pymysql.connect( hosthost, portport, useruser, passwordpassword, dbdb, charsetutf8mb4 ) # 偏差阈值Cardinality 与实际 DISTINCT 值差异超过 30% 则触发修正 self.deviation_threshold 0.30 def check_cardinality_drift(self, table: str, index: str, column: str): 对比统计信息中的 Cardinality 与实际 DISTINCT 计数。 为什么不直接 ANALYZE全量 ANALYZE 在大表上会锁表 需要先判断偏差是否值得修正避免无谓的锁表开销。 with self.conn.cursor() as cur: # 获取优化器记录的 Cardinality cur.execute( SELECT stat_value FROM mysql.innodb_index_stats WHERE database_name %s AND table_name %s AND index_name %s AND stat_name n_diff_pfx01 , (self.conn.db, table, index)) row cur.fetchone() if not row: logging.warning(f索引 {index} 无统计信息跳过) return False estimated_card row[0] # 实际精确计数生产环境建议在从库执行或采样 cur.execute( fSELECT COUNT(DISTINCT {column}) FROM {table} ) actual_card cur.fetchone()[0] if actual_card 0: return False deviation abs(estimated_card - actual_card) / actual_card logging.info( f表 {table} 索引 {index}: f估值{estimated_card}, 实际{actual_card}, f偏差{deviation:.2%} ) if deviation self.deviation_threshold: logging.warning( f偏差超阈值触发 ANALYZE TABLE {table} ) # 在业务低峰期执行避免锁表影响 cur.execute(fANALYZE TABLE {table}) self.conn.commit() return True return False def run_periodic_check(self, tables_config: list): 定期巡检遍历核心表的索引统计信息。 tables_config 格式: [{table: orders, indexes: [...]}] for cfg in tables_config: for idx in cfg[indexes]: try: self.check_cardinality_drift( cfg[table], idx[name], idx[column] ) except Exception as e: logging.error( f检查 {cfg[table]}.{idx[name]} 失败: {e} )3.3 执行计划强制固化当统计信息修正无法及时生效时通过optimizer_switch或FORCE INDEX确保关键查询走正确索引-- 方案一关闭范围优化器的索引合并避免选错组合 SET SESSION optimizer_switchindex_mergeoff; -- 方案二对高频查询使用 FORCE INDEX需配合变更管理流程 SELECT * FROM orders FORCE INDEX(idx_status_createtime) WHERE status PAID AND create_time 2026-01-01;四、FORCE INDEX 的反噬与代价模型的固有局限4.1 硬编码索引的维护成本FORCE INDEX是一把双刃剑。它绕过优化器强制使用指定索引但当业务查询模式变化后硬编码的索引可能不再是最优选择。生产案例某订单表FORCE INDEX(idx_status)在 status 字段新增枚举值后查询性能下降 70%因为新枚举值的数据分布与原设计完全不同。4.2 代价模型的天花板MySQL 的代价模型本质上是基于统计信息的静态估算无法感知以下场景数据倾斜同一列不同值的分布差异巨大但 Cardinality 只记录整体基数。相关性衰减索引列与主键的物理相关性Correlation随写入逐渐降低导致范围扫描的 IO 代价估算失真。缓存命中代价模型假设每次 IO 都是磁盘读取但 Buffer Pool 命中率可达 99%实际 IO 远低于估算值。4.3 适用边界场景推荐策略不推荐策略数据分布均匀、查询模式稳定依赖优化器自动选择无需干预数据倾斜严重、高频查询固定FORCE INDEX 变更管理频繁 ANALYZE多租户隔离查询条件分区 局部索引全局索引 FORCE查询模式频繁变化optimizer_trace 监控 动态调整静态 FORCE INDEX五、总结MySQL 优化器选错索引的根因是统计信息采样率低与代价模型对数据分布不敏感。生产环境应建立三层防线第一层通过optimizer_trace定期巡检高频查询的执行计划偏差第二层对偏差超阈值的表自动触发ANALYZE TABLE第三层对核心查询链路使用FORCE INDEX固化执行计划但必须纳入变更管理流程定期复审。代价模型的改进方向是引入直方图统计MySQL 8.0 已支持和相关性跟踪但这些特性需要显式创建和维护。建议在 MySQL 8.0 环境中对数据倾斜列主动创建直方图ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 256 BUCKETS让优化器获取更精确的分布信息从源头减少索引选择失误。