你的 SQL 为什么这么慢:一次把「索引自杀现场」挖了个底朝天
有一次我盯着一条跑了 40 秒的查询看了十分钟。它逻辑正确、代码优雅、有注释,甚至还有单元测试。唯一的问题是——它慢得像在等一列火车。
而罪魁祸首,不在 SQL 语句本身,在那一行看起来人畜无害的 WHERE DATE(created_at) = '<目标日期>'。
这篇文章不讲玄学,只讲我在真实项目里反复挨过的打。数据库优化不是背索引口诀,是理解索引到底是个什么东西。
一、先把底层逻辑摆平:索引是一棵有序的 B+ 树
很多人对索引的理解停留在「加速查找」。这句话没错,但太粗了,粗到让你做决策时全是错的。
真实的索引是一棵有序的 B+ 树,它带来三个能力:
- 有序:能快速定位范围,也能避免排序
- 稀疏:叶子节点只存索引列的值 + 行指针,所以能装得下海量数据
- 可复用:一旦排好序,
ORDER BY、GROUP BY、MIN/MAX都能白嫖
理解了「有序」这一条,后面所有的坑都能自己推出来。
因为索引是有序的,所以它天然擅长「比较」和「范围」,天然不擅长「函数变换」和「模糊前缀」。这不是数据库的偏见,这是数学。
二、联合索引的「最左前缀」——我见过最贵的误解
假设有联合索引 idx(a, b, c)。它等价于三个单列索引吗?
不等价。它是三棵按顺序排列的树:先按 a 排,a 相同时按 b 排,a 和 b 都相同时按 c 排。
这意味着:
WHERE a = 1 AND b = 2 AND c = 3—— 三段全用上WHERE a = 1 AND c = 3—— c 用不上(中间断档)WHERE b = 2—— 完全用不上WHERE a = 1 ORDER BY b—— 完美,既过滤又免排序
我见过最离谱的案例是:团队建了 idx(user_id, status, created_at),然后在高频接口里写 WHERE status = 1 ORDER BY created_at DESC LIMIT 20。看着非常美,实际上一个索引都没用上——因为 status 是第二列,前面缺了 user_id。
改法有两种:
- 查询改成
WHERE user_id = ? AND status = 1 ORDER BY created_at DESC - 或者单独建
idx(status, created_at),让数据库走「索引扫描 + 回表过滤」
第二种叫跳跃扫描(skip scan),MySQL 8.0 和 Oracle 都支持,虽然名字听起来很酷,但实际生产中我更倾向于方案一——索引不是越多越好,每个索引都会拖慢写入。
三、覆盖索引:最容易被忽视的性能杠杆
先说清楚回表是什么。
你在二级索引上查到 created_at 的值后,如果还需要 user_id、body 这些非索引列,数据库必须拿着行指针回到聚簇索引里把整行捞出来。这趟来回就叫回表。
如果查询需要的列,索引里全都有,那这趟来回就省了——这就是覆盖索引。
-- 慢:需要回表
SELECT user_id, body FROM posts
WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;
-- 快:idx(user_id, created_at) 覆盖了所有需要的列
-- 只需要在索引里扫,不用回聚簇索引
SELECT created_at FROM posts
WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;
覆盖索引带来的收益,在千万行级别以上能到 数倍。因为它把「N 次随机 IO」变成了「1 次顺序扫描索引 + 0 次回表」。
判断方法:EXPLAIN 输出的 Extra 列里出现 Using index,就是命中了覆盖索引;出现 Using where; Using index 也算。
四、让索引失效的七种姿势(血泪版)
这一节建议收藏,因为每一条我都踩过。
1. 在索引列上套函数
-- 索引失效:created_at 上的索引完全用不上
SELECT * FROM orders WHERE DATE(created_at) = '<目标日期>';
-- 索引生效:改成范围
SELECT * FROM orders
WHERE created_at >= '<起始时间>'
AND created_at < '<结束时间>';
生成列(Generated Column)可以救:如果你的数据库支持,可以建一个存储的生成列存日期部分,再对生成列建索引。但多数时候,改成范围条件就够了,还更清晰。
2. 隐式类型转换
这个坑特别隐蔽,因为代码看起来完全没问题:
-- user_id 是 VARCHAR 类型,查询传了数字
SELECT * FROM users WHERE user_id = 123;
MySQL 会把每一行的 user_id 转成数字再比较——索引直接作废,变成全表扫描。字符串列的查询值,务必带上引号。
3. 前导通配符
WHERE name LIKE '%龙' -- 全表扫描
WHERE name LIKE '小龙%' -- 能用索引
B+ 树能高效定位的是「前缀」,一旦开头是通配符,数据库就不知道从哪开始。
4. OR 拆解后缺索引
WHERE a = 1 OR b = 2 如果 b 没索引,整条都可能退化成全表扫描。UNION ALL 拆成两条各自走索引的查询,往往更快(MySQL 的 index_merge 优化器偶尔也能搞定,但别指望它每次都聪明)。
5. != 和 NOT IN
这俩从「值是稀疏的」角度看,命中索引的数量可能超过一半甚至全表——优化器会理性地放弃索引。所以这不是 bug,是数据库在替你做成本权衡。
6. 排序方向与索引相反
idx(a ASC) 支持 ORDER BY a DESC 的倒序扫描,这没问题。但 ORDER BY a ASC, b DESC 这种混合方向在旧版本 MySQL 里用不上索引,得靠 8.0 的降序索引。
7. 统计信息过期
优化器靠统计信息估算行数来选索引。数据分布剧烈变化后,统计信息不更新,优化器就会「自信地选错」。定期 ANALYZE TABLE 是种美德。
五、深翻页:LIMIT 100000, 20 到底有多慢
这是另一个经典场景。数据库要取出前 100020 行,然后丢掉前 100000 行,只返回最后 20 行。
优化方案是游标分页(Keyset Pagination):
-- 传统:越翻越慢
SELECT * FROM posts ORDER BY created_at DESC LIMIT 100000, 20;
-- 游标:无论第几页,速度恒定
SELECT * FROM posts
WHERE created_at < 'LAST_CURSOR_TIME'
ORDER BY created_at DESC
LIMIT 20;
游标分页要求排序键有序且最好唯一,所以我通常用 (created_at, id) 这样的组合作为游标——用上一页最后一条的完整排序键做下一页的起点,保证不重不漏。
注意:
OFFSET分页在页码小的时候(比如前 10 页)完全没问题,没必要一上来就上复杂方案。优化是要有 ROI 的。
六、EXPLAIN 是你的 X 光片,但你会读吗
MySQL 的 EXPLAIN 输出里,最该看的不是 cost 数字(那玩意在不同版本差异巨大,容易误导),而是这几个:
- type:
const>ref>range>index>ALL。看到 ALL 且行数很大,基本可以判定为全表扫描 - key:实际用了哪个索引,
possible_keys却为空说明优化器根本没考虑 - rows:预估要扫多少行
- Extra:
Using filesort(要额外排序)、Using temporary(用了临时表),出现这俩要警惕
进阶一点用 EXPLAIN ANALYZE,它会真的执行一遍并给出「预估行数 vs 实际行数」。两者差距巨大,说明统计信息过期了——这本身就是一条线索。
七、别忘了写入成本:索引不是免费的
只想着「查得快」的优化师,最后往往被写入拖垮。
每多一个索引:
- 每次
INSERT都要多维护一棵 B+ 树 - 每次
UPDATE修改索引列,多一次索引重建 - 每次
DELETE留一堆「墓碑」,直到页分裂/合并 - 占用额外的 Buffer Pool 空间,挤压真正的数据页
所以我的原则是:先按查询模式建索引,而不是按表的字段建索引。一张 30 个字段的表,可能只需要 3~5 个精心设计的索引,剩下的都是负担。
八、一条可落地的排查流程
遇到慢查询,我一般这么走:
- 拿到真实的慢 SQL 和参数(不是参数化前的样子)
- EXPLAIN 看执行计划,确认是否全表扫描、预估行数是否离谱
- EXPLAIN ANALYZE 对比预估与实际行数,验证是不是统计信息问题
- 检查索引列上有没有函数、隐式转换、类型不匹配
- 看能否改成覆盖索引,把回表干掉
- 检查排序/分页,改游标分页或让排序走索引
- 数据量再大就上分区表或分库分表,但这是最后一步,不是第一反应
结语
数据库优化真正的难点从来不是「背下所有索引失效的场景」,而是建立一个成本直觉:
扫描多少行、回表多少次、排序多大、写入被拖慢多少。每一个优化动作,都是在用空间和写入代价换查询时间。
而最贵的成本,往往是「我不确定这样对不对,于是保守地写法让优化器也保守了」。
所以,多花五分钟跑一次 EXPLAIN。它不会告诉你答案,但它会告诉你,你以为自己知道答案的那个瞬间,其实你并不知道。
如果这篇对你有点用,欢迎留言聊聊你在生产环境踩过最离谱的索引坑。有时候最贵的学费,是别人教的那一节。