索引失效场景分析
候选人小王参加字节跳动 P6 面试,面试官看了他的简历,问了一道经典题:
"有个 SQL 查询很慢,SELECT * FROM orders WHERE status = 1 AND create_time > '2024-01-01',走了全表扫描,你怀疑是索引失效,怎么排查?"
小王说:"可能是索引没建对?"面试官:"那你说什么情况下索引会失效?"
小王开始列举:"like 开头是%会导致索引失效..."面试官打断:"那这条 SQL 为什么失效?status 和 create_time 都有索引。"
小王愣住了。
【面试官心理】
这道题我用来筛选有没有实际优化经验的候选人。能背出"索引失效"几条规则的占 80%,能结合具体 SQL 分析为什么失效的占 30%,能说出 MySQL 优化器行为和执行计划怎么看的占 10%。索引失效不是背规则,是理解底层原理。
一、最常见的索引失效场景 🔴
1.1 计算和函数导致索引失效
-- ❌ 索引失效:左函数/计算
SELECT * FROM orders WHERE YEAR(create_time) = 2024;
SELECT * FROM orders WHERE LEFT(user_name, 3) = 'zhang';
SELECT * FROM orders WHERE id + 1 = 1001;
-- ✅ 正确写法
SELECT * FROM orders WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';
SELECT * FROM orders WHERE user_name LIKE 'zhang%';
SELECT * FROM orders WHERE id = 1000;
为什么失效?因为索引树是按原始值排序的。一旦对索引列做了计算,MySQL 必须把每一行都拿出来计算一遍才能判断,相当于全表扫描。
-- 查看执行计划
EXPLAIN SELECT * FROM orders WHERE YEAR(create_time) = 2024;
-- type=ALL 表示全表扫描
1.2 隐式类型转换
-- orders.order_id 是 VARCHAR 类型
-- ❌ 索引失效:字符串和数字比较
SELECT * FROM orders WHERE order_id = 1001; -- 1001 是数字,MySQL 会把 order_id 转成数字
-- 等价于 SELECT * FROM orders WHERE CAST(order_id AS SIGNED) = 1001
-- ✅ 正确写法
SELECT * FROM orders WHERE order_id = '1001';
⚠️
这条规则坑了很多人。Java 开发者习惯用 id = 1001,在 MySQL 里如果 id 是 VARCHAR,就会触发隐式类型转换。解决方案:保持 SQL 参数类型和字段类型一致。
1.3 like 左侧通配符
-- orders.user_name 有索引
-- ❌ 索引失效:%在左边
SELECT * FROM orders WHERE user_name LIKE '%zhang';
SELECT * FROM orders WHERE user_name LIKE '%zhang%';
-- ✅ 正确写法
SELECT * FROM orders WHERE user_name LIKE 'zhang%';
为什么 %zhang 没法用索引?因为字典序是从左到右排的,%zhang 相当于让你在字典里找一个以"zhang"结尾的词,字典不支持这种查法。
-- 如果确实需要模糊查询,考虑全文索引或 ES
ALTER TABLE orders ADD FULLTEXT INDEX ft_user_name (user_name);
SELECT * FROM orders WHERE MATCH(user_name) AGAINST('zhang');
1.4 复合索引违反最左前缀原则
这是最容易被忽略的索引失效场景。
-- 创建了复合索引 (status, create_time, user_id)
-- ❌ 索引部分失效
SELECT * FROM orders WHERE create_time > '2024-01-01'; -- 跳过了 status
-- ✅ 正确写法
SELECT * FROM orders WHERE status = 1 AND create_time > '2024-01-01';
复合索引按从左到右的顺序构建 B+Tree。跳过第一列,就没法二分查找了。
graph TD
subgraph 复合索引 (status, create_time, user_id) 结构
A[根节点: status 排序]
A --> B1[status=0]
A --> B2[status=1]
B2 --> C1[create_time 排序]
B1 --> C2[create_time 排序]
end
1.5 OR 连接不同列
-- user_id 和 status 分别有索引
-- ❌ 索引失效:OR 断了索引链
SELECT * FROM orders WHERE user_id = 1001 OR status = 1;
-- ✅ 正确写法:用 UNION 代替 OR
SELECT * FROM orders WHERE user_id = 1001
UNION ALL
SELECT * FROM orders WHERE status = 1 AND user_id <> 1001;
OR 的问题是:如果一列有索引另一列没有,MySQL 不知道哪个代价更小,往往选择全表扫描。
二、MySQL 优化器的判断逻辑 🟡
2.1 优化器何时选择不走索引
MySQL 优化器不是傻的,它会估算走索引和全表扫描哪个更快。
-- 表 orders 有 1000 万数据,status=1 的有 900 万条
-- MySQL 认为全表扫描更快,不走索引
SELECT * FROM orders WHERE status = 1; -- type=ALL
这就是开篇那道题:WHERE status = 1 AND create_time > '2024-01-01',如果 status=1 的数据占大多数,优化器选择全表扫描。
2.2 如何强制使用索引
-- 强制使用某个索引(不推荐,优化器通常是对的)
SELECT * FROM orders USE INDEX (idx_create_time) WHERE status = 1 AND create_time > '2024-01-01';
-- 忽略某个索引
SELECT * FROM orders IGNORE INDEX (idx_status) WHERE status = 1;
💡
生产环境中,强制索引通常用来绕过优化器的错误判断。但如果优化器不走索引,先问自己:是不是统计信息过期了?运行 ANALYZE TABLE orders; 更新统计信息。
三、组合拳:如何系统排查索引失效 🟡
3.1 排查流程
graph TD
A[慢查询发现] --> B[EXPLAIN 看 type]
B --> C{type = ALL?}
C -->|是| D[检查索引是否存在]
C -->|否| E[检查 Extra 列]
D --> F{索引存在?}
F -->|是| G{满足最左前缀?}
F -->|否| H[新建索引]
G -->|否| I[调整查询顺序/建新索引]
G -->|是| J[检查是否有函数/类型转换]
J -->|是| K[修改 SQL]
J -->|否| L[检查数据分布/优化器判断]
L --> M[强制索引/更新统计]
3.2 实战:拆解开篇那道题
-- 表结构
-- orders: id, status, create_time, user_id
-- 索引: idx_status (status), idx_create_time (create_time)
EXPLAIN SELECT * FROM orders WHERE status = 1 AND create_time > '2024-01-01';
假设结果:
问题分析:
- type=ALL,说明走了全表扫描
- 两个索引都有,但优化器认为都不够好
- 原因:status=1 的数据太多,走索引还要回表,全表扫描反而更快
解决方案:
-- 方案1:建复合索引
ALTER TABLE orders ADD INDEX idx_status_time (status, create_time);
-- 方案2:用 covering index
ALTER TABLE orders ADD INDEX idx_status_time_cover (status, create_time, id);
-- 覆盖索引,不需要回表,优化器可能选择走索引
四、生产避坑
4.1 模糊查询的正确姿势
// ❌ Java 代码中的坑
String keyword = request.getParameter("keyword");
// 直接拼,会导致索引失效
String sql = "SELECT * FROM orders WHERE order_no LIKE '%" + keyword + "%'";
// ✅ 正确写法:用覆盖索引或者全文索引
String sql = "SELECT * FROM orders WHERE order_no LIKE ?";
preparedStatement.setString(1, keyword + "%"); // 右侧%才不走索引
4.2 日期范围查询的陷阱
-- ❌ 陷阱: BETWEEN 包含计算
SELECT * FROM orders WHERE create_time BETWEEN '2024-01-01' AND '2024-12-31';
-- 如果 create_time 有时间部分,'2024-12-31' 不包含 12-31 23:59:59
-- ✅ 正确写法
SELECT * FROM orders WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';
【面试官心理】
我问他"怎么排查",其实是在看他有没有系统化的思路。能背规则的占 60%,能结合 EXPLAIN 输出的占 30%,能说出优化器行为和统计信息过期问题的占 10%。这道题是 P6 的分水岭。