
数据库慢查询治理从索引原理到执行计划的优化实践一、慢查询的隐性成本一个执行时间 500ms 的查询在低峰期可能不会引起注意但在高峰期可能成为数据库性能的瓶颈。更麻烦的是慢查询的影响会像滚雪球一样扩大——一个慢查询占用了大量 Buffer Pool 页导致其他查询的缓存命中率下降进而产生更多慢查询形成恶性循环。慢查询治理的难点不在于发现慢查询slow_query_log谁都会开而在于理解为什么慢和确定怎么优化。同一个慢查询可能是因为缺少索引也可能是因为索引选择错误还可能是因为数据分布倾斜导致优化器误判。只有理解了数据库索引的底层结构和查询优化器的工作机制才能做出正确的优化决策。本文将从 B 树索引的物理结构出发拆解 MySQL 查询优化器的执行计划选择逻辑给出系统性的慢查询治理方案。二、B 树索引的物理结构为什么索引能加速查询MySQL InnoDB 的索引基于 B 树实现。理解 B 树的物理结构是理解索引优化原理的前提。graph TD subgraph B 树索引结构3层 R[Root Pagebr/key: 100, 200, 300] R -- L1[Level 1: key 100] R -- L2[Level 1: 100 ≤ key 200] R -- L3[Level 1: 200 ≤ key 300] R -- L4[Level 1: key ≥ 300] L1 -- D1[Leaf: [1,5,23,45,67,89]] L2 -- D2[Leaf: [100,123,145,167,189]] L3 -- D3[Leaf: [200,223,245,267,289]] L4 -- D4[Leaf: [300,323,345,367,389]] D1 --|双向链表| D2 D2 --|双向链表| D3 D3 --|双向链表| D4 end subgraph 索引查找过程 E[SELECT * FROM t WHERE id 145] -- F[Root: 145 200 → L2] F -- G[L2: 100 ≤ 145 200 → D2] G -- H[D2: 二分查找 → 找到 145] end style R fill:#fff3e0 style D2 fill:#e8f5e9 style H fill:#e8f5e9B 树的关键特性三层结构Root Page → Intermediate Pages → Leaf Pages。InnoDB 的 Page 大小默认 16KB一个 Page 大约存储 1000 个键值假设 key 为 8 字节 int。三层 B 树可以索引约 10 亿行数据1000 × 1000 × 1000。这意味着任何一次索引查找最多 3 次 I/O。叶子节点双向链表范围查询WHERE id BETWEEN 100 AND 200只需定位到起始叶子节点然后沿链表顺序扫描无需回溯上层节点。这是 B 树比 B 树更适合数据库索引的核心原因。聚簇索引 vs 二级索引聚簇索引主键索引的叶子节点存储完整的行数据二级索引的叶子节点存储主键值。通过二级索引查找数据时需要先在二级索引上找到主键值再回到聚簇索引查找行数据——这就是回表操作。回表的代价是额外的 B 树查找一次回表等于一次随机 I/O。索引选择性与基数索引的选择性 COUNT(DISTINCT col) / COUNT(*)。选择性越高索引过滤效果越好。性别字段的选择性约 0.5只有两个值不适合建索引用户 ID 的选择性约 1.0每个值唯一是最理想的索引列。三、慢查询治理框架从发现到优化的完整链路slow_query_analyzer.py —— 慢查询分析与优化建议引擎 import re from dataclasses import dataclass, field from enum import Enum from typing import Optional class QueryIssue(Enum): FULL_TABLE_SCAN full_table_scan # 全表扫描 INDEX_MISS index_miss # 缺少索引 INDEX_MISUSE index_misuse # 索引使用不当 FILESORT filesort # 文件排序 TEMPORARY_TABLE temporary_table # 临时表 RANGE_SCAN_TOO_LARGE range_scan_too_large # 范围扫描行数过多 SUBQUERY_DEPENDENT subquery_dependent # 相关子查询 class OptimizationAction(Enum): CREATE_INDEX create_index MODIFY_INDEX modify_index REWRITE_QUERY rewrite_query FORCE_INDEX force_index PARTITION_TABLE partition_table ARCHIVE_DATA archive_data dataclass class ExplainRow: EXPLAIN 输出一行 id: int select_type: str table: str partitions: Optional[str] type: str # ALL, index, range, ref, eq_ref, const... possible_keys: str key: str # 实际使用的索引 key_len: int # 索引长度字节 ref: str rows: int # 预估扫描行数 filtered: float # 过滤比例 extra: str # Using index, Using filesort, Using temporary... dataclass class SlowQuery: 慢查询记录 query: str execution_time_ms: float rows_examined: int rows_sent: int explain_rows: list[ExplainRow] field(default_factorylist) dataclass class OptimizationSuggestion: 优化建议 issue: QueryIssue action: OptimizationAction description: str sql: Optional[str] None # 建议执行的 SQL如 CREATE INDEX estimated_improvement: str # 预估改善效果 class SlowQueryAnalyzer: 慢查询分析器——基于 EXPLAIN 结果自动诊断问题 def analyze(self, slow_query: SlowQuery) - list[OptimizationSuggestion]: 分析慢查询返回优化建议列表 suggestions [] for row in slow_query.explain_rows: # 检查 1全表扫描 if row.type ALL: suggestions.append(self._diagnose_full_scan(row, slow_query)) # 检查 2索引使用不当possible_keys 有值但 key 为空 if row.possible_keys and not row.key: suggestions.append(self._diagnose_index_not_used(row, slow_query)) # 检查 3文件排序 if Using filesort in row.extra: suggestions.append(self._diagnose_filesort(row, slow_query)) # 检查 4临时表 if Using temporary in row.extra: suggestions.append(self._diagnose_temporary_table(row, slow_query)) # 检查 5范围扫描行数过多 if row.type range and row.rows 10000: suggestions.append(self._diagnose_large_range(row, slow_query)) # 检查 6相关子查询 if row.select_type DEPENDENT SUBQUERY: suggestions.append(self._diagnose_dependent_subquery(row, slow_query)) return [s for s in suggestions if s is not None] def _diagnose_full_scan( self, row: ExplainRow, query: SlowQuery ) - OptimizationSuggestion: 诊断全表扫描建议创建索引 # 从 WHERE 子句中提取可能的索引列 where_columns self._extract_where_columns(query.query) if where_columns: index_columns , .join(where_columns) return OptimizationSuggestion( issueQueryIssue.FULL_TABLE_SCAN, actionOptimizationAction.CREATE_INDEX, descriptionf表 {row.table} 全表扫描扫描 {row.rows} 行, sqlfCREATE INDEX idx_{row.table}_{_.join(where_columns)} fON {row.table} ({index_columns}), estimated_improvementf预计扫描行数从 {row.rows} 降至 100, ) return OptimizationSuggestion( issueQueryIssue.FULL_TABLE_SCAN, actionOptimizationAction.CREATE_INDEX, descriptionf表 {row.table} 全表扫描扫描 {row.rows} 行无法自动提取索引列, ) def _diagnose_index_not_used( self, row: ExplainRow, query: SlowQuery ) - OptimizationSuggestion: 诊断索引未被使用 return OptimizationSuggestion( issueQueryIssue.INDEX_MISUSE, actionOptimizationAction.FORCE_INDEX, descriptionf表 {row.table} 有可用索引 ({row.possible_keys}) 但未使用 f可能是优化器判断全表扫描更快, sqlfSELECT ... FROM {row.table} FORCE INDEX ({row.possible_keys}) ..., estimated_improvement强制使用索引可能减少扫描行数但需验证, ) def _diagnose_filesort( self, row: ExplainRow, query: SlowQuery ) - OptimizationSuggestion: 诊断文件排序建议创建覆盖 ORDER BY 的复合索引 order_columns self._extract_order_by_columns(query.query) if order_columns: # 复合索引应包含 WHERE 条件列 ORDER BY 列 where_columns self._extract_where_columns(query.query) all_columns where_columns [c for c in order_columns if c not in where_columns] index_columns , .join(all_columns) return OptimizationSuggestion( issueQueryIssue.FILESORT, actionOptimizationAction.CREATE_INDEX, descriptionf表 {row.table} 使用文件排序ORDER BY 列未走索引, sqlfCREATE INDEX idx_{row.table}_sort fON {row.table} ({index_columns}), estimated_improvement消除 filesort排序在索引扫描中完成, ) return OptimizationSuggestion( issueQueryIssue.FILESORT, actionOptimizationAction.REWRITE_QUERY, description文件排序无法通过索引优化考虑减少排序数据量, ) def _diagnose_temporary_table( self, row: ExplainRow, query: SlowQuery ) - OptimizationSuggestion: 诊断临时表 return OptimizationSuggestion( issueQueryIssue.TEMPORARY_TABLE, actionOptimizationAction.REWRITE_QUERY, descriptionf表 {row.table} 使用临时表通常由 GROUP BY 无索引引起, sqlNone, estimated_improvement为 GROUP BY 列创建索引可消除临时表, ) def _diagnose_large_range( self, row: ExplainRow, query: SlowQuery ) - OptimizationSuggestion: 诊断范围扫描行数过多 return OptimizationSuggestion( issueQueryIssue.RANGE_SCAN_TOO_LARGE, actionOptimizationAction.ARCHIVE_DATA, descriptionf范围扫描 {row.rows} 行数据量过大, estimated_improvement考虑数据归档或分区表减少单次扫描数据量, ) def _diagnose_dependent_subquery( self, row: ExplainRow, query: SlowQuery ) - OptimizationSuggestion: 诊断相关子查询建议改写为 JOIN return OptimizationSuggestion( issueQueryIssue.SUBQUERY_DEPENDENT, actionOptimizationAction.REWRITE_QUERY, description相关子查询对外层每行都执行一次性能极差, sql将 IN (SELECT ...) 改写为 JOIN, estimated_improvement子查询改写为 JOIN 通常可提升 10-100 倍, ) def _extract_where_columns(self, query: str) - list[str]: 从 SQL 中提取 WHERE 子句的列名简化实现 where_match re.search(rWHERE\s(.?)(?:GROUP|ORDER|LIMIT|$), query, re.IGNORECASE) if not where_match: return [] where_clause where_match.group(1) # 提取 col ... 或 col IN (...) 中的列名 columns re.findall(r(\w)\s*(?:|IN|||||LIKE|BETWEEN), where_clause, re.IGNORECASE) return list(dict.fromkeys(columns)) # 去重保序 def _extract_order_by_columns(self, query: str) - list[str]: 从 SQL 中提取 ORDER BY 子句的列名 order_match re.search(rORDER\sBY\s(.?)(?:LIMIT|$), query, re.IGNORECASE) if not order_match: return [] order_clause order_match.group(1) columns re.findall(r(\w), order_clause) # 过滤掉 ASC/DESC return [c for c in columns if c.upper() not in (ASC, DESC)]四、索引优化的代价写入性能与存储空间的权衡索引对写入的影响每个索引在 INSERT/UPDATE/DELETE 时都需要同步维护。一张表有 5 个索引每次 INSERT 就需要更新 6 棵 B 树1 个聚簇索引 5 个二级索引。在高写入场景下索引数量应控制在 3-5 个以内超过 5 个索引的表写入性能会显著下降。复合索引的最左前缀原则复合索引(a, b, c)只能支持a、a,b、a,b,c三种查询模式。查询条件只有b或b,c时无法使用该索引。这意味着复合索引的列顺序至关重要——区分度高的列放前面范围查询的列放后面。索引统计信息的时效性MySQL 优化器依赖索引统计信息Cardinality、Rows来选择执行计划。统计信息不是实时更新的而是通过采样估算。当数据分布发生显著变化时如大批量导入数据统计信息可能过时导致优化器选择错误的索引。手动执行ANALYZE TABLE可以更新统计信息。适用边界此治理方案适用于 MySQL 5.7/8.0 的 InnoDB 引擎。PostgreSQL 的查询优化器基于成本估算CBO且支持部分索引优化策略有所不同。对于分库分表场景慢查询治理还需要考虑跨分片的查询合并与排序问题。五、总结慢查询治理的核心是理解执行计划而非盲目加索引。B 树的物理结构决定了索引的加速原理优化器的成本估计算法决定了索引的选择逻辑。落地建议开启slow_query_log并设置合理阈值200ms定期用EXPLAIN分析慢查询根据扫描类型和 Extra 信息定位问题根因。索引不是越多越好每个索引都有写入代价。用数据说话只加必要的索引。