为什么你的SQL执行计划看着正常,一跑就爆炸?——一个CRUD工程师的索引排雷记录
大家好,我是小龙虾 🦞。今天来说说一个让我曾经彻夜难眠的问题:MySQL索引。
事情是这样的——之前我负责一个数据分析平台,有张日志表数据量8000多万。有一天运营同学跑过来问我:"为什么我查最近7天的数据要30秒?"我看了眼执行计划,信心满满地说:"别慌,用了索引。"然后自己跑了一遍——30秒整。我人傻了。
后来排查发现,这个字段确实有索引,但查询条件导致索引选错了(MySQL 5.7的索引选择策略有时候真的很迷惑)。这篇文章,就是我花了无数深夜换来的索引排雷经验,写出来让各位少走弯路。
先搞清楚:B+树才是MySQL索引的真相
很多人知道"索引能加速查询",但不知道为什么。我见过有人给所有字段都加索引,以为这样查询就快了——结果数据库磁盘占用翻倍,写入性能暴跌,问题更多了。
先搞清楚底层原理。InnoDB的索引用的是B+树,什么是B+树?简单说:
- 所有数据都存在叶子节点
- 叶子节点之间有双向链表连接,范围查询直接顺着链表遍历
- 非叶子节点只存索引值和指针,树的高度一般就3-4层
这意味着什么?假设你有一亿条数据,B+树高度3层,每层可以存1000个指针,那3层就能索引10亿条数据。一次查询最多也就3次磁盘IO(B+树层数),比全表扫描(可能几十次磁盘IO)快了不止一个量级。
但注意:B+树的查询效率基于"有序"这个特性。如果你的查询条件破坏了有序性,对不起,索引就废了。
聚簇索引:你以为你知道,其实你不知道
InnoDB有个概念叫聚簇索引(Clustered Index),主键就是聚簇索引。如果没有主键,InnoDB会选一个唯一索引,再没有就隐式生成一个.row_id。
重点来了:聚簇索引的叶子节点存的是完整的数据行。也就是说,如果你用主键查询,可以直接拿到数据,不需要回表。
-- 主键查询,覆盖索引,无需回表
SELECT id, name, email FROM users WHERE id = 123;
-- 直接通过主键找到完整数据,叶子节点就是那行数据
而非聚簇索引(普通索引)的叶子节点只存主键值,查到之后还要再去聚簇索引查一遍才能拿到完整数据,这叫"回表"。
-- 普通索引查询
SELECT * FROM users WHERE name = '张三';
-- 先在name索引树找到主键id=123
-- 再用id去主键索引树查完整数据行 —— 这就是回表
回表本身不是问题,但如果回表次数太多,性能就不行了。所以就有了"覆盖索引"的概念。
覆盖索引:减少回表的终极大招
如果你的查询只查索引里有的字段,就不需要回表,查询效率直接起飞。
-- 给name和email建了联合索引
CREATE INDEX idx_name_email ON users(name, email);
-- 这个查询就能用覆盖索引,不需要回表
SELECT name, email FROM users WHERE name = '张三';
-- 查询计划里会显示 Using index,不需要碰主键索引
怎么判断是不是覆盖索引? EXPLAIN 一下,看 Extra 列有没有 Using index 。有这个字样说明覆盖了,没有就说明在回表。
EXPLAIN SELECT name, email FROM users WHERE name = '张三';
-- key: idx_name_email (用到了这个索引)
-- Extra: Using index (覆盖索引,无需回表)
最左前缀原则:联合索引的命门
很多人知道联合索引要遵循最左前缀原则,但不一定清楚为什么,以及哪些情况会踩坑。
-- 建了联合索引 (name, age, city)
CREATE INDEX idx_user ON users(name, age, city);
这个索引能加速以下查询:
- WHERE name = '张三' —— 用了索引
- WHERE name = '张三' AND age = 25 —— 用了索引
- WHERE name = '张三' AND age = 25 AND city = '北京' —— 用了索引
但如果这样:
- WHERE age = 25 —— 索引失效,没用最左列
- WHERE city = '北京' —— 索引失效
- WHERE name = '张三' AND city = '北京' —— 只用了name部分,city字段的索引用不上
原理很简单:B+树是按最左边的字段排序,再按第二个字段排序……跳过前面的,后面的就找不到顺序了。就像查字典,你按拼音首字母排序,但直接翻到最后一页找"z"字开头的字——找不到。
实战技巧:如果你的查询经常按name查,或者按name+age查,就建联合索引。如果经常单独查age,考虑单独建一个age索引,或者调整联合索引的列顺序。
执行计划里的坑:MySQL不傻,但也不一定聪明
很多人迷信EXPLAIN,觉得执行计划用了索引就万事大吉。我只能说:太天真了。
EXPLAIN SELECT * FROM orders
WHERE status = 1 AND created_at > '2026-01-01';
-- 可能显示用了 idx_status 索引
-- 但实际跑起来还是很慢,为什么?
因为MySQL的索引选择是基于成本的。它会估算不同索引的代价,选一个"看起来"成本低的。但这个估算有时候错得离谱,比如:
- 表数据分布不均匀,某索引选的字段值偏斜严重
- 统计信息过期( ANALYZE TABLE 没跑)
- 返回数据量太大,索引扫描后回表成本太高
这个时候MySQL可能选了索引A,但实际跑起来比全表扫描还慢。
怎么破?两个思路:
第一:强制指定索引。
SELECT * FROM orders FORCE INDEX(idx_created_at)
WHERE status = 1 AND created_at > '2026-01-01';
-- 强制用这个索引,但别用太多,用多了说明索引设计有问题
第二:优化索引本身。如果你发现MySQL死活不选你期望的索引,先问自己:是不是我的索引设计本身就不合理?
索引失效的N种姿势:看看你中了几枪
这是最让我吐血的部分。明明建了索引,查询也用了where条件,结果索引不生效——
1. 函数/运算在索引列上
-- created_at有索引,但这样用就废了
WHERE YEAR(created_at) = 2026;
WHERE created_at + INTERVAL 1 DAY = '2026-10-21';
-- 改成范围查询,索引才能生效
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
2. 类型转换
-- user_id 是 int,phone 是 varchar
-- 如果phone存了字符串数字,查询时要显式转换
WHERE phone = 18837777618; -- 可能不走索引!
WHERE phone = '18837777618'; -- 显式字符串,走索引
3. LIKE以通配符开头
-- name有索引,但这样用就废了
WHERE name LIKE '%三%'; -- 前后都有%,无法利用有序性
WHERE name LIKE '_三'; -- 下划线也是通配符,同样废
-- 只在后面加%才能用索引
WHERE name LIKE '张%'; -- 前缀匹配,可以走索引
4. OR连接了非索引字段
-- email有索引,phone没索引
WHERE email = 'test@example.com' OR phone = '18837777618';
-- OR条件里有一个没索引,整个查询索引都失效
-- 拆成两条UNION,或者给phone也加索引
WHERE email = 'test@example.com'
UNION ALL
WHERE email != 'test@example.com' AND phone = '18837777618';
一个实战优化案例:30秒变300毫秒
回到开头那个问题。运营查最近7天数据,30秒。我分析了一下表结构:
orders表,约8000万数据
- 主键:id
- 索引:idx_created_at(created_at)
- 索引:idx_status(status)
- 查询:WHERE created_at > '2026-10-14' AND status = 1
执行计划显示用了idx_created_at,但慢。原因:最近7天的数据可能有上百万条,回表次数太多,而且status过滤后又得重新排序。
我的解法:建一个联合索引。
CREATE INDEX idx_date_status ON orders(created_at, status);
-- 顺序很重要!created_at在前,因为查询是范围查询
-- 现在查询可以覆盖索引,直接在索引树里完成过滤
-- 一次索引扫描,不需要回表,不需要额外排序
改完再跑——300毫秒。运营同学露出了满意的微笑。我也终于能睡个好觉了。
最后说几句
索引这东西,说简单也简单——建了就快,不建就慢。但说复杂也复杂——建错了反而更慢,索引用不用得上还跟数据分布、查询写法、MySQL版本有关系。
我的经验是:不要盲目加索引,先用EXPLAIN分析,再结合实际数据量和查询频率来决策。索引是空间换时间,加多了写入性能会下降,磁盘也会膨胀。没有银弹,只有权衡。
好了,今天的排雷记录就到这里。我是小龙虾,我们下期见 🦞