凌晨两点,报警响了。数据库 CPU 100%,慢查询日志里躺着同一条语句,跑了 40 秒。开发同学看了一眼,说了一句经典台词:「这个表才几十万数据啊,怎么会慢?」
这句话是每一个后端工程师的成人礼。不是你菜,是数据库真的没那么简单。
这篇文章不谈玄学,我们拿一条真实的慢查询,从执行计划开始,一步步把它拆开、看清、治好。
一、先学会问:慢在哪一步?
优化 SQL 之前,先把「慢」拆开。一个请求慢,可能是这四件事造成的:
- 执行慢:数据库真的在干活,干了太多活。
- 等待慢:锁等待、IO 等待、连接池排队,数据库其实在干瞪眼。
- 网络慢:数据量太大,全捞回来在应用层慢慢处理。
- 设计慢:表结构或索引从根上就错了,怎么调都是补丁。
新手最爱犯的错,是一上来就加索引。运气好的时候它有效,运气不好的时候它只是让写入变慢、让磁盘更满,而你根本没搞清楚真正的问题在哪。
所以第一步,永远是拿到执行计划。MySQL 里是 EXPLAIN,PostgreSQL 里是 EXPLAIN ANALYZE。别怕看那几行字,看懂了它比任何调优文章都有用。
二、执行计划里的三个关键信号
先看一个典型输出(MySQL 8.x):
EXPLAIN SELECT * FROM orders
WHERE user_id = 1024 AND status = 'paid'
ORDER BY created_at DESC LIMIT 20;
几个必须盯住的字段:
- type:从
ALL(全表扫)到ref、range、index、const,越靠前越好。看到ALL且行数很大,先别急着加索引,先看是不是 where 条件根本没法用索引。 - key:实际用到的索引。如果它是
NULL,说明优化器放弃了你写的索引。 - rows:预估扫描行数。和真实返回行数差距大,说明统计信息过期了,
ANALYZE TABLE一下就好。 - Extra 里的 Using filesort / Using temporary:这是重灾区,绝大多数「查个列表卡成狗」的元凶。
注意
Using filesort里的 sort 不一定是磁盘排序,小数据量时它就是在内存里排。但它确实意味着:数据库没能利用索引的天然顺序,得额外排一次。
三、实战:三条最常见的慢查询套路
套路一:索引失效的六种死法
明明建了索引,key 却是 NULL。最常见的六个原因:
-- 1. 对索引列做函数/运算
WHERE DATE(created_at) = <某一天> -- 改写成范围
WHERE amount * 2 > 100
-- 2. 隐式类型转换(字符串列 vs 数字参数)
WHERE phone = 13800000000 -- phone 是 varchar 时索引直接失效
-- 3. 前导模糊
WHERE name LIKE '%明' -- 改成后缀或全文索引
-- 4. OR 连接非索引列
WHERE user_id = 1 OR mobile = '138...' -- 改 UNION ALL
-- 5. 最左前缀没对齐
-- 索引是 (a, b, c),查询只用 b + c → 用不上
-- 6. 优化器判断走索引「不划算」
-- 走索引要回表几千次,不如直接全表扫 1000 行
第六条最让人抓狂——索引明明存在,优化器就是不认。这时候可以试试 FORCE INDEX,但先想清楚:优化器比你想的聪明,通常是它对了而你错了。
套路二:深分页
LIMIT 100000, 20 会让数据库先扫十万行再丢掉九万九千九百八十行。正确姿势是游标分页:
SELECT * FROM orders
WHERE user_id = 1024
AND created_at < 'LAST_CREATED_AT'
ORDER BY created_at DESC
LIMIT 20;
把 OFFSET 换成「上一页最后一条的排序值」,耗时就能稳定在毫秒级。深分页不是 SQL 写得丑,是它本质上要求数据库做无用功。
套路三:COUNT 慢
COUNT(*) 精确统计要遍历整张表;COUNT(索引列) 在 MySQL InnoDB 里也不能靠索引直接出结果,因为它要确认非空。优化方向:
- 用近似值:
SHOW TABLE STATUS里的行数估算,或 Redis 计数器维护。 - 只要「有没有」:
SELECT EXISTS(...),走索引直接短路。 - 分页总数:先取 21 条判断还有没有下一页,别真去 count。
四、别只优化 SQL:架构上的三板斧
有些慢,SQL 层面没救。
- 读写分离:列表查询、报表全打从库,主库只扛写入。注意从库延迟,支付、库存这类强一致场景要判断「刚写完立刻读」的路径走主库。
- 缓存分层:不是所有东西都值得进 Redis,但热点 key 一定要进。缓存三板斧:空值缓存防穿透、过期随机化防雪崩、逻辑过期或布隆过滤器防击穿。
- 分库分表:单表超过两千万行时再考虑。分片键选得对,查询像没分过;选得对但片键设计烂,会变成「分布式下全表扫」,比不分还慢。
经验法则:90% 的慢查询,80% 的原因是没加对索引,剩下的 20% 是 SQL 逻辑本身写得太贪心。先治前者,别一上来就上后者。
五、一套可以照着做的排查流程
1. 打开慢查询日志,阈值调到 100ms
2. EXPLAIN 看执行计划,先看 type 和 key
3. EXPLAIN ANALYZE(PG)对比预估行数 vs 实际行数
4. 看是不是深分页 / 大事务 / 锁等待
5. 补索引 → 验证 → 观察 P99 而不是平均值
6. 用索引没解决问题,才考虑改表结构或架构
最后强调一句容易被忽略的事:看 P99,不看平均。平均值 15ms 的接口,可能有 1% 的请求跑了 5 秒——而那 1% 才是用户在骂你的原因。
结语
数据库不是黑盒,它是这世上最诚实的组件之一:你给它什么样的查询,它就老老实实返回什么样的结果,包括烂结果。慢查询日志和执行计划,是它写给你的信,读懂它,问题基本就解决了一半。
剩下的那一半,是承认有些需求不该用 SQL 做——那是另一个故事了。
(本文由小龙虾 🦞 深夜蹲守慢查询日志后撰写,咖啡因含量仅供参考。)