慢 SQL 优化实战:从 8 秒到 30 毫秒,我修的不是代码,是索引

2026-11-02 6 0

先说结论:那个跑了 8 秒的接口,代码一行没改,SQL 一个字没动,只加了一个复合索引和一个覆盖索引,它就变成了 30 毫秒。不是玄学,是数据库的工作原理一直在那儿,只是我们没读。

这篇不讲玄学,讲我踩过的坑:怎么用 EXPLAIN 看出数据库到底干了什么活,怎么把一条"看起来很合理"的 SQL 拆成它真实的样子,以及为什么很多优化建议其实是噪音。

一、第一步:让数据库自己招供

绝大多数人优化 SQL 的方式是:改一个字段,加一个索引,跑一下,好像变快了,收工。这不叫优化,这叫抽奖。

真正的第一步是让数据库把执行计划吐出来。

EXPLAIN ANALYZE
SELECT o.id, o.amount, u.name
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 1
  AND o.created_at >= '2026-01-01'
ORDER BY o.created_at DESC
LIMIT 20;

看三列就够:

  • type:从 ALL(全表扫)往下走,ref > range > index > ALL。只要主干上出现 ALL,优化就有活干。
  • rows:预估扫描行数。这个数字如果和实际差了上百倍,说明统计信息过期了,跑一句 ANALYZE TABLE 比乱加索引更管用。
  • Extra:出现 Using filesort、Using temporary,就说明数据库在内存里排序或者建临时表了。

关键点:EXPLAIN 是估算,EXPLAIN ANALYZE 是真跑一遍并给出每步耗时。新版本 MySQL 才有 ANALYZE,老版本只能靠 SHOW PROFILE 或慢查询日志凑合。

注意:别在生产上随手对大表跑 EXPLAIN ANALYZE,它是真执行,代价就是真的要执行。

二、最左前缀:索引不是许愿池

复合索引 idx(status, created_at) 能被用上的前提是查询条件从最左列连续命中。写 WHERE created_at >= ... AND status = 1 是用不上的——这不是数据库笨,是 B+ 树按顺序存的,你从第二列开始找,等于拿着一本按姓氏排序的通讯录查名字。

但这不代表条件书写顺序无所谓。MySQL 会做条件重排,真正要看的是执行计划里 key_len 的长度:它直接告诉你索引实际吃掉了几个字段。

还有一种更隐蔽的坑:范围查询会"截断"后续列的利用。假设索引是 (status, created_at, amount),当条件写成 status = 1 AND created_at > X ORDER BY amount 时,一旦 created_at 用了范围,amount 的有序性就用不上了,排序只能落到 filesort。

解法不是硬怼,而是把顺序改成 (status, amount, created_at),让等值列在前、排序列紧跟其后——这叫"最左前缀 + 等值先行"原则。

三、覆盖索引:把 IO 砍到零

如果查询需要的列全都在索引里,数据库根本不用回表找原始行。这就是覆盖索引,是低基数场景下性价比最高的一招。

-- 原查询
SELECT id, user_id, amount FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;

-- 加一个索引,列顺序即查询顺序
CREATE INDEX idx_user_created ON orders (user_id, created_at DESC, amount);

改完之后执行计划里会出现 Using index,Extra 里也会少掉一堆回表操作。IO 从磁盘随机读变成索引顺序读,几十毫秒和几秒的差距就是这么来的。

四、那些"优化建议"其实是噪音

说点得罪人的。

一、给每个字段都加索引。 索引不是越多越好。每次 INSERT、UPDATE、DELETE 都要维护所有索引,写入被拖慢十倍去换几毫秒的读加速,是典型的亏本买卖。真实项目里,一张表三到五个精心设计的复合索引,通常就够了。

二、SELECT * 是原罪。 部分正确。它确实拖慢查询,但它更大的问题是把网络传输、序列化、应用内存一起拖下水,而且会让你随手加的索引"意外失效"——因为你 SELECT 了索引里没有的列,回表跑不掉。正确姿势不是别写 SELECT *,而是写清楚你到底要哪几列。

三、深分页用 OFFSET 是没救的。

-- 第 100000 页:OFFSET 1000000 让数据库先扫完前面那一百万行再丢掉
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;

-- 游标分页:只走索引,真正取那 20 行
SELECT * FROM orders WHERE id > ? ORDER BY id LIMIT 20;

这类优化对"无限滚动"的页面立竿见影,代价是拿不到页码跳转。但说实话,多数产品根本不需要跳到第 50000 页,用户自己也不想去。

四、函数包在字段上,索引当场去世。

WHERE DATE(created_at) = '2026-01-01'   -- 索引失效
WHERE created_at >= '2026-01-01'
  AND created_at <  '2026-01-02'     -- 索引生效

原因很简单:索引里存的是原始值,数据库得先算函数才能比较,那就只能全捞出来算。如果业务上必须对列做运算,答案不是改查询,而是加一列生成列(generated column)或者函数索引(functional index),让函数结果本身进索引。

五、慢查询日志是你真正的朋友

别猜。打开慢查询日志,让数据库告诉你它最烦的 N 条语句是什么。

slow_query_log = 1
long_query_time = 0.2
slow_query_log_file = /var/log/mysql/slow.log
min_examined_row_limit = 1000

min_examined_row_limit 这一行特别重要,它能过滤掉"扫描行少但慢"的噪音,只留下真正扫了上万行还慢的语句——那才是索引缺失的现场证据。

然后用 mysqldumpslow 或 pt-query-digest 做归并,按耗时总时长排序,而不是按单次耗时排序。你要找的是"总时间杀手",不是"最慢的那一次"。

六、别忘了那些不是 SQL 的问题

我见过至少三次"数据库慢"的案子,最后查出来是应用层在循环里发查询——典型 N+1,一次页面渲染打了 200 条 SQL。这种事加多少索引都救不了,因为瓶颈根本不在数据库。

所以排查顺序我一般固定成这四步:

  1. 看 EXPLAIN,确认扫描行数和索引使用情况;
  2. 看慢查询日志,确认到底是哪条语句、哪个接口;
  3. 看应用侧调用次数,确认不是 N+1;
  4. 最后才动手改结构:加索引、改写 SQL、调参数。

顺序反了,你会花两天时间给一张无辜的表加索引。

最后

数据库优化没什么玄学,它更像侦探活:先收集证据(执行计划 + 慢日志),再定位嫌疑人(缺失的索引、失效的写法、N+1),最后才是量刑(重建索引、改写查询)。

我越来越喜欢的一个习惯是:优化完必须用真实的业务数据量重新压一遍。因为开发库里一千行数据的表,加什么索引都快得飞起,等上了生产两千万行,那句"我加了索引"就会变成一场事故复盘会的主题。

索引的本质,是用空间和写入成本,换查询时少走的路。这笔账没有标准答案,只有你的业务知道该往哪边倾斜。所以别抄别人的索引方案,去看自己的数据。

相关文章

你的监控把你自己监控崩了:Prometheus 标签基数爆炸,一个 user_id 标签吃掉 8G 内存
备份全都在,恢复还是失败了:PITR 实战、恢复演练,以及那些没人告诉你的备份真相
你的服务不慢,是卡住了:eBPF 火焰图实战,5 分钟定位线上 P99 飙升
你的服务每秒在做几千次加密握手,而 99% 的握手都是白干的:TLS 连接复用的真相
你的接口重试三次,数据库就多扣三次钱:幂等性设计的正确姿势
数据库慢得像个拖拉机?先别骂数据库,这 7 个坑有一半是你自己挖的

发布评论