一、前言
在 MySQL 的日常开发与调优中,"OR 会导致索引失效"几乎是一句人尽皆知的"铁律"。然而,如果你仔细观察就会发现,这条铁律并不总是成立------有时 OR 前后都建了索引,查询也确实走了索引;有时却又眼睁睁看着 type=ALL,全表扫描把性能拖垮。为什么会出现这种"双标"行为?OR 到底在什么情况下才会让索引失效?我们又该如何写出稳定又高效 SQL?本文将通过两条经典的 SQL 执行计划对比,配合原理剖析与实战方案,一次性把这些问题讲透。
二、从一个"反常识"的现象说起
请看下面这两条结构和数据相似,但执行结果截然相反的 SQL:
例1:OR 导致全表扫描(失效)
表结构假设:
-
zz_users表 -
user_id为主键索引 -
user_name为联合索引unite_index的第一列EXPLAIN SELECT * FROM
zz_usersWHERE user_id = 1 OR user_name = "熊猫";
执行计划:
| id | select_type | table | type | possible_keys | key | Extra |
|---|---|---|---|---|---|---|
| 1 | SIMPLE | zz_users | ALL | PRIMARY,unite_index | NULL | Using where |
可以看到 type=ALL,明明所有查询条件都包含索引字段,却根本没有用到索引,直接全表扫描。
例2:OR 成功走索引(生效)
表结构假设:
-
t_user表 -
id为主键索引 -
age为普通索引index_ageEXPLAIN SELECT * FROM t_user WHERE id = 1 OR age = 18;
执行计划:
| id | select_type | table | type | possible_keys | key | Extra |
|---|---|---|---|---|---|---|
| 1 | SIMPLE | t_user | index_merge | PRIMARY,index_age | PRIMARY,index_age | Using union(PRIMARY,index_age) |
type=index_merge,说明 MySQL 同时对 id 和 age 两个索引进行了扫描,并将结果合并,最终避免了全表扫描。
同样是"OR 前后都有索引",为什么一个生效一个失效? 答案就藏在 MySQL 优化器的 索引合并(Index Merge) 机制当中。
三、先纠正一个流行误区:OR 不是必然导致索引失效
许多教程为了简单,直接抛出"OR 会让索引失效"的结论,其实是 不够准确 的。更准确的说法是:
- 当 OR 连接的条件 在同一个索引上 时,完全可以走索引(如范围扫描)。
- 当 OR 连接的条件 分属不同索引 时,MySQL 需要动用 索引合并(Index Merge) 优化才能生效;而优化器会根据代价判断是否真正启用它。
- 只有当优化器认为索引合并的代价比全表扫描更高时,才会放弃索引,选择全表扫描。
换句话说:OR 不是"索引杀手",而是"索引合并"的触发器;触发成功则用索引,失败则回退全表扫描。
深入辨析:什么是"在同一个索引上" vs "分属不同索引"?
这两种情况直接决定了 OR 能否简单走索引,还是必须依靠复杂的索引合并。
1. OR 的条件在同一个索引上(最理想)
只要 OR 连接的所有列都属于同一棵索引树 ,并且查询条件符合该索引的最左前缀原则,MySQL 就可以只使用这一个索引搞定查询,不需要跨索引合并。
典型情形是利用联合索引。例如有一张用户表,建有联合索引:
INDEX idx_name_age (user_name, age)
错误示例(看似同一索引,实则不然):
SELECT * FROM users WHERE user_name = '熊猫' OR age = 18;
虽然 user_name 和 age 同属一个联合索引,但 age 跳过了最左前缀 user_name,这个 OR 实际上无法只用这个联合索引完成,还是会走索引合并或者全表扫描。这里务必留意:同属一个索引不等于一定能简单生效,必须满足最左匹配原则。如果要用上这个联合索引,OR 的每个分支都必须能用到索引的"前缀"。
正确的"在同一个索引上"例子:
-- 联合索引 (user_name, age)
SELECT * FROM users WHERE user_name = '熊猫' OR user_name = '老虎';
这里两个 OR 条件都在索引的第一列 user_name 上,MySQL 可以将其转化为 user_name IN ('熊猫','老虎') 的等价形式,直接在联合索引上做范围扫描(type=range),完全不会失效。
再如:
-- 联合索引 (a, b, c)
SELECT * FROM t WHERE a = 1 OR (a = 2 AND b = 5);
条件都从索引最左列 a 开始,也属于同一索引树的范围扫描。
所以,严格来说:OR 的所有分支都能各自利用同一个索引的最左前缀,就叫作"在同一个索引上"。
2. OR 的条件分属不同索引(触发索引合并的场景)
当 OR 连接的两个列,分别**建在不同的索引(或不同索引树)**上时------比如一列是主键,另一列是单独的二级索引------MySQL 单走一个索引无法拿到另一个条件的数据,这时就必须用索引合并。
典型例子
-- id 是主键索引(聚簇索引)
-- age 列单独建了普通索引 idx_age
SELECT * FROM t_user WHERE id = 1 OR age = 18;
这里 id 走主键索引,age 走 idx_age,它们属于两棵独立的索引树,这就是"分属不同索引"。
要完成这个查询,MySQL 需要:
- 扫描主键索引拿到
id=1的行ID集合; - 扫描
idx_age拿到age=18的行ID集合; - 合并两个ID集合并去重;
- 再拿着最终ID回表取完整数据。
这正是 索引合并(Index Merge) 的典型场景,执行计划会显示 type=index_merge 和 Using union。
再举个复合索引与主键的情况:
-- 联合索引 idx_name_age (user_name, age)
-- id 是主键
SELECT * FROM users WHERE id = 100 OR user_name = '熊猫';
条件分属主键索引和联合索引,依然属于"分属不同索引",同样需要索引合并。
四、OR 索引失效的常见场景一览
在对"分属不同索引"的 OR 进行深入剖析之前,我们先系统地梳理一下:哪些情况下 OR 会导致索引直接失效(完全不触发索引合并)? 以下是几种经典的场景。
场景1:OR 的某一侧没建索引(最常见)
这是导致 OR 全表扫描的最普遍原因。只要 OR 连接的任一列没有索引,整个查询就只能全表扫描。因为 MySQL 无法只用一棵索引树找出所有满足条件的数据。
-- 假设 id 是主键,但 age 列没有索引
SELECT * FROM t_user WHERE id = 1 OR age = 18;
-- 执行计划:type=ALL,直接全表扫描
场景2:OR 的某一侧对索引列使用了函数或计算
即使索引列都建了索引,只要某一侧对列做了操作(函数、运算、隐式类型转换),该侧就失效了,进而导致整个 OR 回退到全表扫描。
-- id 是主键,create_time 也有索引
-- 但右侧用了函数,索引彻底失效
SELECT * FROM orders WHERE id = 1 OR YEAR(create_time) = 2025;
-- 执行计划:type=ALL
场景3:隐式类型转换导致失效
在 MySQL 中,当字符串类型的列与数字比较时,会发生隐式类型转换,从而导致索引失效。这一点在 OR 中同样适用。
-- phone 列是 varchar 类型,建了索引,但传入了数字
SELECT * FROM users WHERE id = 1 OR phone = 13800138000;
-- phone 索引因隐式类型转换而失效,type=ALL
场景4:OR 在联合索引中跳过了最左列
即便 OR 两端的列都存在于同一个联合索引中,但若其中一侧没有以最左列开头,索引同样无法使用。
-- 联合索引 (a, b)
SELECT * FROM t WHERE a = 1 OR b = 2;
-- b 跳过了 a,不符合最左前缀;尽管 b 在索引中,但无法使用该索引
场景5:OR 包含范围查询且结果集过大
即便 OR 两端都建了索引,但如果某侧是范围条件(如 >、LIKE '%xx%'),优化器可能直接判定"走索引不如全表扫描",主动放弃索引合并。
SELECT * FROM t_user WHERE id = 1 OR age > 10;
-- 若 age > 10 匹配数据占比很高,索引合并可能被放弃
**一句话总结:**只要 OR 有任何一侧无法独立走索引(无索引/函数/类型转换/跳过最左),整个查询的索引就会全部失效,直接全表扫描。
五、核心机制:跨索引的 OR 与索引合并(Index Merge)
当 OR 连接的两列分别属于不同的索引时,单靠一棵索引树无法拿到所有满足条件的数据,MySQL 必须走两条路径:
- 分别走每个索引,拿到符合条件的主键 ID 集合;
- 将多个 ID 集合做
UNION(取并集),去重; - 拿着最终的 ID 集合,再回聚簇索引获取完整的行记录。
这个过程就是 索引合并 (Index Merge)。在 EXPLAIN 输出中,我们会看到:
type = index_mergeExtra字段显示Using union(索引1,索引2)
它本质上是一种"用多个单列索引模拟复合条件查询"的优化策略。索引合并生效的条件包括但不限于:
- 查询条件使用
OR连接,且每个条件都有对应的索引; - 条件中等值、范围均可(不同合并算法支持不同操作);
- 优化器估算出「两次索引查找 + 合并 + 回表」的代价低于全表扫描。
六、为什么"OR 前后都有索引"还会失效?------优化器的算盘
在分析失效的具体原因之前,我们先理解执行计划中一个至关重要的字段------rows。
rows 是 MySQL 优化器估算的"为了执行查询,需要检查的行数",它是一个基于统计信息的预测值,不是实际扫描的行数。它的核心特点包括:
- 估算依据 :InnoDB 根据索引基数(Cardinality)、表大小、条件过滤性等统计信息计算得出。如果统计信息不准确(如未及时更新),
rows可能偏差很大。 - 代价计算的基础 :优化器主要根据
rows评估不同执行计划的 I/O 开销,选择预估扫描行数最小的路径。 - 不是实际值 :实际执行时可能扫描更少(如遇到
LIMIT提前终止)或更多(如统计信息过时)。
在我们的例子中:
- 例1 执行计划中
rows=3,表示优化器估计全表只有 3 行数据,扫描全表成本极低,因此直接放弃了索引合并,选择ALL全表扫描。这就是小表下 OR 即使有索引也常失效的直接证据。 - 例2 中
rows=9, filtered=20.99,表示估计需要扫描 9 行数据,经过条件过滤后结果集约占总扫描行数的 20.99%,即约 1.89 行,优化器据此判断索引合并更划算。
rows 是优化器决策的核心依据之一,能帮助我们快速判断 SQL 是否存在统计信息不准导致的执行计划走偏。
现在我们可以明确回答文章开头例1失效的根本原因了:优化器经过代价估算,认为走索引合并不如直接全表扫描划算。 具体来说,以下场景最容易触发这种"放弃"行为:
1. 表数据量极小
例1 的 rows=3 说明整张表只有 3 行数据。此时全表扫描的成本几乎为零;而索引合并需要两次索引查找、结果合并、再回表,流程反而更重。优化器很聪明,直接选择了成本更低的全表扫描。这是 延迟物化 和 索引深度 带来的天然代价。
2. 某一个 OR 条件的过滤性极差
如果 user_name="熊猫" 匹配了表中绝大多数行(比如 80% 的数据),那么走 user_name 索引后,拿到的 ID 集合非常庞大,和主键索引的结果合并、去重、回表这一系列操作的代价会急剧膨胀。此时直接全表扫描,顺序读效率远高于大量随机回表,优化器自然会选择全表扫描。
3. MySQL 版本与优化器限制
早期 MySQL 对索引合并的支持并不完善,部分场景下不会主动选择;某些版本对 OR 条件的索引合并采用更保守的成本模型,也容易导致"能合却不合"的情况。
4. 条件类型不兼容
例如一条是等值,另一条是范围,且范围查询的过滤性难以精确估算,导致合并后的结果集大小被高估,进而被优化器判定为不如全表扫描。
因此,OR 前后都有索引只是索引合并能够发生的"必要条件",而非"充分条件"。 最终决策权在优化器的代价估算模型手中。
七、实战中最稳妥的方案:用 UNION ALL 替代 OR
依赖优化器的"索引合并"决策终究有不确定性,生产环境数据分布和版本差异都可能让同一条 SQL 在不同的时间选择不同的执行计划。因此,最佳实践是 使用 UNION ALL 手动拆分查询,从根本上避免对索引合并的依赖。
改写示例
-
原始 SQL(可能走索引合并,也可能全表扫描):
SELECT * FROM t_user WHERE id = 1 OR age = 18;
-
改写为
UNION ALL的写法:SELECT * FROM t_user WHERE id = 1
UNION ALL
SELECT * FROM t_user WHERE age = 18;
为什么 UNION ALL 更优?
- 每个子查询都能独立、确定地走自己的索引,优化器不用再纠结是否启用索引合并。
- 如果两边的结果集不重复 ,
UNION ALL不去重,性能更高(如果担心重复,可改用UNION,但会增加排序去重开销)。 - 执行计划清晰可控,不会因为数据量、统计信息的变化而发生执行路径突变。
在绝大多数"单表多列 OR"查询场景下,UNION ALL 都提供了更稳定、更高效的性能表现。这也是诸多 MySQL 优化规范中强烈推荐的做法。
八、补充:什么情况可以放心使用 OR?
既然 UNION ALL 这么稳,是不是所有的 OR 都要改写?其实也不尽然。以下场景 OR 本身就是高效的,无需改写:
- OR 的列属于同一个索引且满足最左前缀
比如联合索引idx(user_id, user_name),查询WHERE user_id = 1 OR user_id = 2。此时 MySQL 可以直接在该索引上执行范围扫描,不会触发索引合并,也不会失效。 - 表行数极小
几十行的小表,全表扫描本身已是最优解,改写为 UNION ALL 反而增加复杂度,没有实际收益。 - 索引合并明确生效且经过基准测试确认更优
某些特殊场景下(如两个条件的选择度都极高),索引合并表现可能略优于 UNION ALL(减少了一次查询解析开销),但这种情况较少,且不稳定,不建议作为首选。
九、总结
- OR 并非天然导致索引失效,只有跨索引的 OR 且未能成功触发「索引合并」时,才会回退全表扫描。
- OR 导致索引失效的常见情况:某一侧无索引、对索引列使用函数或计算、隐式类型转换、联合索引中跳过最左列、范围查询结果集过大等。
- "在同一个索引上" 指 OR 的所有分支都能各自利用同一索引的最左前缀;"分属不同索引" 则是指条件列建在不同的索引树上,此时必须依靠索引合并。
rows字段是优化器估算的扫描行数,是其代价计算的核心依据,也是解读执行计划时不可忽视的指标。- 索引合并能否生效取决于 代价估算:数据量极小、条件过滤性差、版本限制等都可能导致其"未启用"。
- 开发过程中的最佳实践是:用
UNION ALL代替跨不同索引的 OR,让每个条件各自稳定走索引,避免依赖优化器的"黑盒"决策。