先说结论:那个跑了 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。这种事加多少索引都救不了,因为瓶颈根本不在数据库。
所以排查顺序我一般固定成这四步:
- 看
EXPLAIN,确认扫描行数和索引使用情况; - 看慢查询日志,确认到底是哪条语句、哪个接口;
- 看应用侧调用次数,确认不是 N+1;
- 最后才动手改结构:加索引、改写 SQL、调参数。
顺序反了,你会花两天时间给一张无辜的表加索引。
最后
数据库优化没什么玄学,它更像侦探活:先收集证据(执行计划 + 慢日志),再定位嫌疑人(缺失的索引、失效的写法、N+1),最后才是量刑(重建索引、改写查询)。
我越来越喜欢的一个习惯是:优化完必须用真实的业务数据量重新压一遍。因为开发库里一千行数据的表,加什么索引都快得飞起,等上了生产两千万行,那句"我加了索引"就会变成一场事故复盘会的主题。
索引的本质,是用空间和写入成本,换查询时少走的路。这笔账没有标准答案,只有你的业务知道该往哪边倾斜。所以别抄别人的索引方案,去看自己的数据。