MySQL 索引失效全攻略:从原理到实战,让你的查询快 10 倍!

在 MySQL 性能优化中,索引失效是最常见的 "性能杀手"。当精心设计的索引突然无法被查询使用,轻则导致慢查询,重则引发数据库负载飙升。

一、核心原则:理解索引如何工作(B+树)

MySQL 的 InnoDB 引擎默认使用 B+树 结构存储索引。理解其特性是避免失效的关键:

  1. 有序性: B+树中的数据(索引键值)是有序存储的(根据创建索引时列的顺序)。

  2. 最左匹配: 对于复合索引(多列索引),查询条件必须从索引的最左边列开始并且连续地(或部分连续地)使用索引中的列,才能有效利用索引。这是索引失效最常见的原因之一。

  3. 范围列阻断: 如果查询条件中对索引中的某一列使用了范围查询>, <, BETWEEN, LIKE 'prefix%' 等),那么它右侧的所有索引列将无法再使用索引进行检索(但可能用于排序或覆盖索引)。

  4. 前缀匹配: 对于字符串类型的索引(CHARVARCHARTEXT),索引存储的是列值的前缀(长度由索引定义决定)。查询条件也必须匹配这个前缀才能有效利用索引。

二、索引失效的常见场景及规避方案

场景 1:违背最左前缀原则 (Leftmost Prefix Rule)

  • 问题: 复合索引 idx_name_age (name, age)

    • 失效查询 1: SELECT * FROM users WHERE age = 25; (跳过了最左列 name)

    • 失效查询 2: SELECT * FROM users WHERE name LIKE '%张%' AND age = 25; (虽然用了 name,但 LIKE 以通配符开头导致 name 索引失效,进而 age 也无法利用索引)

  • 原因: B+树首先按 name 排序,在 name 相同的情况下再按 age 排序。直接查询 age 或 name 无法形成有效前缀匹配,数据库无法定位到有序的起始点。

  • 解决方案:

    • 确保查询条件包含复合索引的最左列。

    • 根据查询模式调整索引列的顺序,将最常用作查询条件的列放在左边。

    • 如果必须单独查询非最左列,考虑为其单独创建索引。

场景 2:在索引列上做计算、函数或类型转换

  • 问题:

    • 计算: SELECT * FROM orders WHERE YEAR(order_date) = 2023; (索引 idx_order_date (order_date))

    • 函数: SELECT * FROM users WHERE LOWER(name) = 'john doe'; (索引 idx_name (name))

    • 隐式类型转换: phone 列是 VARCHAR,索引 idx_phone (phone), 查询 SELECT * FROM contacts WHERE phone = 13800138000; (数字被隐式转为字符串,可能导致索引失效,取决于 MySQL 版本和设置)

  • 原因: 索引存储的是列的原始值。当对列应用计算、函数或发生类型转换时,MySQL 无法直接使用存储的索引值,它必须先计算/转换每一行的值,然后再进行比较。这等同于没有索引。

  • 解决方案:

    • 避免在索引列上使用函数或计算。 将操作移到常量端:

      • 计算: SELECT * FROM orders WHERE order_date >= '2023-01-01' AND order_date < '2024-01-01'; (利用范围查询,只要 order_date 是索引且范围查询是有效的)

      • 函数: 如果必须使用函数且无法避免,考虑使用函数索引(MySQL 5.7+ 支持虚拟列+索引,MySQL 8.0+ 直接支持函数索引 CREATE INDEX idx_lower_name ON users ((LOWER(name)));)。

    • 避免隐式类型转换。 确保查询条件中的类型与列定义严格匹配:

      • SELECT * FROM contacts WHERE phone = '13800138000'; (字符串常量)

场景 3:使用不等于 (!= 或 <>) 查询

  • 问题: SELECT * FROM products WHERE status != 'inactive'; (索引 idx_status (status))

  • 原因: 不等于操作是一个非常宽泛的条件。即使 status 有索引,数据库通常需要扫描索引中所有 'inactive' 和非 'inactive' 的条目。优化器可能会判断扫描大部分索引页不如直接全表扫描更快,尤其是在 'inactive' 记录占比较小或较大的情况下。不等于操作不一定总是导致索引失效,但失效风险极高,尤其是在表较大或选择性不高时。

  • 解决方案:

    • 尽量避免使用 != / <>。考虑用其他方式重写:

      • 明确列出等于的值: SELECT * FROM products WHERE status IN ('active', 'pending'); (如果状态值是离散且有限的)

      • 使用 OR: SELECT * FROM products WHERE status = 'active' OR status = 'pending'; (效果类似 IN)

      • 如果业务逻辑允许,使用 >< 或 BETWEEN 等范围查询可能更有效。

    • 如果无法避免,评估数据分布。如果 != 的值占比非常小(例如 status != 'deleted' 且 'deleted' 记录很少),索引 可能 被使用。

场景 4:使用 LIKE 以通配符 % 开头

  • 问题: SELECT * FROM articles WHERE content LIKE '%database%'; (索引 idx_content (content))

  • 原因: B+树索引是按列值从头到尾排序的。以 % 开头的 LIKE 查询意味着“包含任意前缀的字符串”,这破坏了索引的有序性,数据库无法定位查找的起点,必须扫描所有条目。LIKE 'database%' 可以利用索引(最左前缀匹配),LIKE '%database' 则不行。

  • 解决方案:

    • 尽量避免以 % 开头的模糊查询。 考虑是否能用后缀索引(MySQL 8.0+ 反转字符串+索引)或全文索引(FULLTEXT)替代。

    • 如果必须使用,确保查询有足够的选择性(返回结果集很小),否则性能代价很高。

    • 使用专门的全文搜索引擎(如 Elasticsearch)处理复杂的全文搜索需求。

场景 5:对索引列使用 OR 连接非索引列条件

  • 问题: SELECT * FROM employees WHERE dept_id = 10 OR salary > 5000; (索引 idx_dept (dept_id)salary 列无索引)

  • 原因: MySQL 在处理 OR 条件时,通常只能为 dept_id = 10 使用索引 idx_dept。对于 salary > 5000,因为没有索引,必须进行全表扫描。最终,MySQL 需要将使用索引查到的结果集和全表扫描的结果集合并去重。优化器通常会认为这种“部分索引+部分全表扫描再合并”的成本高于直接全表扫描,从而导致全表扫描。

  • 解决方案:

    • 为 OR 连接的所有列创建单独的索引或包含它们的复合索引。例如,创建索引 idx_salary (salary) 或 idx_dept_salary (dept_id, salary)。有了 idx_salary,MySQL 可能 会使用 idx_dept 和 idx_salary 分别查找,然后做 UNION(索引合并优化 Index Merge,通常是 type=index_mergeExtra=Using union(...); Using where),但这不一定比全表扫描快,取决于数据分布和选择性。

    • 更可靠的方法是重写查询为 UNION

      SELECT * FROM employees WHERE dept_id = 10
      UNION
      SELECT * FROM employees WHERE salary > 5000;

      这样,第一个子查询可以利用 idx_dept,第二个子查询如果 salary 有索引 idx_salary 就能利用。确保每个 SELECT 都能有效利用索引。

    • 评估是否能用 IN 替代某些 OR(当 OR 作用在同一列的不同值时)。

场景 6:索引列使用 IS NULL 或 IS NOT NULL

  • 问题: SELECT * FROM customers WHERE email IS NULL; (索引 idx_email (email))

  • 原因: 在 MySQL 中,IS NULL 通常可以利用索引(ref 或 range 访问),因为索引会记录 NULL 值。IS NOT NULL 则不一定。优化器需要评估表中 NULL 和非 NULL 值的比例。如果表中大部分记录 email IS NOT NULL,扫描整个索引的成本可能接近全表扫描,优化器可能选择全表扫描。如果 email 定义为 NOT NULL,则 IS NULL 条件永远为假,查询可能非常快但用不到索引(因为不需要查)。

  • 解决方案:

    • 对于 IS NULL:通常可以利用索引,无需特别处理。

    • 对于 IS NOT NULL

      • 如果该列允许 NULL 但实际 NULL 值非常少(例如,记录是否删除的标志位 deleted_at IS NOT NULL 表示有效数据),索引很可能被使用。

      • 如果 NULL 值很多,优化器可能选择全表扫描。考虑是否需要改变表设计(减少 NULL 列)或使用其他查询方式。

      • 确保列上有索引。

场景 7:连接查询(JOIN)中驱动表的连接条件字符集不同

  • 问题: 表 A (utf8mb4) 和表 B (latin1) 通过 varchar 列 name 连接,且 A.name 和 B.name 上都有索引。
    SELECT * FROM A JOIN B ON A.name = B.name;

  • 原因: 当两个连接列的字符集(CHARACTER SET)或排序规则(COLLATION)不同时,MySQL 需要先对其中一个列的值进行转换,才能进行比较。这种转换会使该列上的索引失效。

  • 解决方案:

    • 统一字符集和排序规则! 这是最根本的解决方案。在设计数据库时,确保相关联的列使用相同的字符集和排序规则(utf8mb4 和 utf8mb4_0900_ai_ci 是当前推荐选择)。

    • 如果无法修改表结构,可以在 ON 子句中使用 CONVERT() 函数强制转换,但这通常也会导致索引失效,应尽量避免。例如: ON A.name = CONVERT(B.name USING utf8mb4)

场景 8:全表扫描成本低于使用索引 (优化器决策)

  • 问题: 表很小,或者查询条件匹配了表中绝大部分记录(低选择性)。

  • 原因: 使用索引需要额外的 I/O 操作(读取索引页+回表读取数据页)。如果优化器预判需要扫描的数据行超过总行数的一定比例(通常认为是 20%-30%,但实际很复杂),它可能会认为直接顺序读取整个数据文件(全表扫描)比通过索引随机读取数据页更快,因为顺序 I/O 效率远高于随机 I/O。

  • 解决方案:

    • 这是优化器的合理行为,通常不需要强制使用索引。

    • 如果确信索引更优(例如,数据分布不均匀,优化器统计信息过时),可以使用 FORCE INDEX (index_name) 提示强制使用特定索引,但要谨慎使用,并监控效果。更新统计信息 (ANALYZE TABLE tablename;) 有时能帮助优化器做出正确决策。

    • 考虑是否需要该查询返回如此大量的数据,是否能通过更精确的条件或分页 (LIMIT) 减少结果集。

三、诊断索引失效:利器 EXPLAIN

EXPLAIN 是分析 SQL 执行计划、判断索引使用情况的神器。在 SQL 语句前加上 EXPLAIN 或 EXPLAIN FORMAT=JSON 即可。

关键字段解读:

  • type: 访问类型,性能从优到劣常见有:

    • const / system: 通过主键或唯一索引查到一行。

    • eq_ref: 关联查询,使用主键或唯一索引进行等值匹配。

    • ref: 使用普通索引等值匹配。

    • range: 使用索引进行范围扫描 (><BETWEENLIKE 'prefix%')。

    • index: 全索引扫描 (按索引顺序读取所有索引条目,通常比 ALL 好点)。

    • ALL: 全表扫描! 索引可能失效或未使用。这是需要重点优化的信号。

  • possible_keys: 查询可能使用到的索引。

  • key: 查询实际使用的索引。如果是 NULL,则未使用索引。

  • key_len: 实际使用的索引长度(字节数)。可用于判断复合索引使用了多少部分。

  • rows: MySQL 预估需要扫描的行数(不是精确值)。越小越好。

  • Extra: 额外信息,包含重要提示:

    • Using where: 在存储引擎检索行后,服务器层进行了额外的过滤。

    • Using index覆盖索引!查询所需数据仅从索引中即可获得,无需回表。性能极佳。

    • Using temporary: 使用了临时表,通常发生在排序 (ORDER BY) 或分组 (GROUP BY) 且无法利用索引排序时。

    • Using filesort: 使用了文件排序(外部排序),无法利用索引排序。需要优化 ORDER BY

    • Using join buffer (Block Nested Loop): 关联查询未使用索引,使用了连接缓冲区。性能较差。

使用步骤:

  1. 在需要分析的 SELECT 语句前加上 EXPLAIN

  2. 执行 EXPLAIN ... 语句。

  3. 分析结果,重点关注 typekeyrowsExtra

  4. 如果发现 type=ALLkey=NULL 或 Using filesortUsing temporary,说明存在性能问题,需要优化索引或 SQL。

四、最佳实践总结

  1. 理解最左前缀: 设计和使用复合索引时牢记此原则。

  2. 避免计算/函数/类型转换: 保持索引列在查询条件中的“纯洁性”。

  3. 慎用 !=<>, 开头 % 的 LIKE 评估数据分布,寻找替代方案。

  4. 谨慎处理 OR 优先考虑 UNION 或确保所有 OR 条件列都有合适索引。

  5. 统一字符集: 确保关联列字符集一致。

  6. 善用覆盖索引: 只查询索引包含的列 (SELECT col1, col2 FROM ... WHERE ...,其中 (col1, col2) 是索引),避免回表。

  7. 合理设计索引:

    • 只为查询频繁、过滤性好的列创建索引。

    • 避免创建过多索引,影响写性能。

    • 考虑使用前缀索引 (INDEX (column_name(length))) 节省空间,但注意选择合适的前缀长度以保证选择性。

  8. 定期使用 EXPLAIN: 分析关键查询的执行计划。

  9. 更新统计信息: 定期运行 ANALYZE TABLE (尤其在大量数据变更后),帮助优化器做出准确判断。

  10. 考虑索引下推 (Index Condition Pushdown, ICP): MySQL 5.6+ 支持,允许在存储引擎层利用索引过滤更多数据,减少回表。通常 EXPLAIN 显示 Using index condition 表示启用。确保 MySQL 版本支持并启用。

当遇到索引失效问题时,可按以下步骤排查:​

  1. 确认索引是否正确创建(SHOW INDEX FROM table)​
  2. 使用 EXPLAIN 分析执行计划,定位失效场景​
  3. 根据具体场景选择优化方案(调整 SQL / 新增索引 / 更新统计信息)​
  4. 验证优化效果(对比执行时间 / 扫描行数 / 索引使用次数)

你可能感兴趣的:(MySQL 索引失效全攻略:从原理到实战,让你的查询快 10 倍!)