你的 SQL 为什么这么慢:一次把「索引自杀现场」挖了个底朝天

2026-11-11 14 0

你的 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。

改法有两种:

  1. 查询改成 WHERE user_id = ? AND status = 1 ORDER BY created_at DESC
  2. 或者单独建 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 个精心设计的索引,剩下的都是负担。

八、一条可落地的排查流程

遇到慢查询,我一般这么走:

  1. 拿到真实的慢 SQL 和参数(不是参数化前的样子)
  2. EXPLAIN 看执行计划,确认是否全表扫描、预估行数是否离谱
  3. EXPLAIN ANALYZE 对比预估与实际行数,验证是不是统计信息问题
  4. 检查索引列上有没有函数、隐式转换、类型不匹配
  5. 看能否改成覆盖索引,把回表干掉
  6. 检查排序/分页,改游标分页或让排序走索引
  7. 数据量再大就上分区表或分库分表,但这是最后一步,不是第一反应

结语

数据库优化真正的难点从来不是「背下所有索引失效的场景」,而是建立一个成本直觉:

扫描多少行、回表多少次、排序多大、写入被拖慢多少。每一个优化动作,都是在用空间和写入代价换查询时间。

而最贵的成本,往往是「我不确定这样对不对,于是保守地写法让优化器也保守了」。

所以,多花五分钟跑一次 EXPLAIN。它不会告诉你答案,但它会告诉你,你以为自己知道答案的那个瞬间,其实你并不知道。


如果这篇对你有点用,欢迎留言聊聊你在生产环境踩过最离谱的索引坑。有时候最贵的学费,是别人教的那一节。

相关文章

你的 Go 服务正在偷偷漏 goroutine:context 取消、channel 关闭与那些没人告诉你的并发真相
你的定时任务跑了三遍,还把数据库打挂了:分布式调度里六个没人愿意告诉你的真相
为什么你的 Rust 团队,三个月后又写回了 Go?—— 所有权、生命周期与「编译期错误」的谎言
Pod 被 OOMKilled,代码里一行 OOM 都没有:Kubernetes 内存账本的四笔糊涂账
扩容一次,数据搬家一半:一致性哈希、虚拟节点,以及我踩过的那个三倍流量坑
「数据过千万就得分表」——这句话害了多少人?分库分表的四个坑与正解

发布评论