你的 SQL 为什么慢?数据库不想让你知道的 6 个真相

2026-04-07 9 0

做过数据库性能优化的人都知道,看 EXPLAIN 是基本功。但问题在于——你看到的.plan,根本不是数据库实际执行的那一个。

这不是 Bug,这是 Feature。而且这个 Feature,坑了 80% 的后端开发者。


真相一:索引还在,但查询已经不走索引了

很多人以为建了索引就万事大吉。实际上 MySQL 的索引选择是个黑箱——它会根据统计信息判断走哪个索引,但如果统计信息过期了,或者数据分布突然变化,优化器会优雅地选择放弃你的索引。

-- 你以为会走 idx_created_at
SELECT * FROM orders WHERE created_at > 2024-01-01 AND status = 1;

-- 实际执行:全表扫描,因为优化器估算返回行数太多
-- 原因:orders 表最近做了一次大促活动,数据分布完全变了
-- 但 ANALYZE TABLE 上次执行是三个月前

解决方案?别只依赖自动统计信息,对于大表定期 ANALYZE TABLE,或者直接用 USE INDEX 强制指定。

吐槽:就像你列好了复习计划,结果学霸室友悄悄改了考试范围,你还以为胸有成竹。


真相二:EXPLAIN 显示的「rows」字段,误差可以超过 100 倍

EXPLAIN 输出里有个 rows 字段,表示优化器估算的扫描行数。很多人用它来判断查询性能——这是个巨大的误区。

mysql> EXPLAIN SELECT * FROM users WHERE age > 25;
+------+-------------+-------+------+---------------+------+---------+
| id   | select_type| table |type  | rows          | key  |...|
+------+-------------+-------+------+---------------+------+---------+
|  1   | SIMPLE     | users | ALL  | 9888234       | NULL |...|
+------+-------------+-------+------+---------------+------+---------+

-- 显示要扫 988 万行,你吓得加了索引
-- 加上索引后再次 EXPLAIN:
+------+-------------+-------+-------+------+----------+-------+
| id   | select_type| table | type  | rows | key      |...|
+------+-------------+-------+-------+------+----------+-------+
|  1   | SIMPLE     | users | range | 5000 | idx_age  |...|
+------+-------------+-------+-------+------+----------+-------+

好,索引生效了,只扫描 5000 行。但实际执行呢?因为 age 字段的选择性极低(25-35岁人群占 70%),MySQL 实际扫了 680 万行。

这就是索引失效最常见的原因之一:字段区分度太低,加了等于没加。MySQL 优化器的统计估算和实际情况之间的 Gap,有时候大到让人怀疑人生。


真相三:JOIN 顺序不是你写的那样子

SQL 里表的出现顺序,不等于 JOIN 的执行顺序。MySQL 优化器会重新排列 JOIN,以它认为最优的顺序执行。

SELECT * FROM a
JOIN b ON a.id = b.a_id
JOIN c ON b.id = c.b_id;

-- 你以为先查 a,再 join b,再 join c
-- 实际执行顺序:优化器根据统计信息,可能先扫 c,用 c 的结果驱动 b,最后再关联 a
-- 如果 c 数据量最小,这样确实更快——但有时候优化器判断错了

STRAIGHT_JOIN 是给你的权利,但不是所有场景都要用。正确的做法是:先用 EXPLAIN FORMAT=JSON 看实际执行计划,确定优化器确实选错了,再动手。

吐槽:JOIN 顺序这事,就像点菜——你点了前菜主菜甜点,结果厨师根据心情调换了上菜顺序。有时候是惊喜,有时候是灾难。


真相四:覆盖索引救了命?先看看你的 SELECT *

都说要利用覆盖索引避免回表,性能能提升好几倍。但问题是——很多人一边讲覆盖索引,一边写 SELECT *。

-- 建了覆盖索引
CREATE INDEX idx_cover ON orders(user_id, status, created_at);

-- 错误写法:触发了回表
SELECT * FROM orders WHERE user_id = 123;

-- 正确写法:只查索引包含的字段,不回表
SELECT user_id, status, created_at FROM orders WHERE user_id = 123;

SELECT * 是性能问题的万恶之源之一。它不仅浪费网络带宽,还让覆盖索引的优势完全消失。你以为自己用了索引,实际上每行都在回表查主键数据。


真相五:IN 查询在子查询里,可能是全表扫描的伪装

这条坑了无数人。

SELECT * FROM orders WHERE user_id IN (
    SELECT id FROM users WHERE city = 北京
);

-- MySQL 5.6 及之前的版本:这条 SQL 会先执行子查询查出所有北京用户,
-- 然后对 orders 表做全表扫描,逐行比较 user_id 是否在结果集中
-- EXPLAIN 看到 type=ALL,一点不奇怪
-- 解决:改用 JOIN,或者升级 MySQL 8.0

MySQL 5.7 之后有了优化,子查询改写为 JOIN。但并不是所有场景都会被优化,某些复杂子查询仍然会触发老路线。养成习惯——写了 IN 子查询,第一时间看 EXPLAIN。


真相六:锁不是在表级别,是在页级别的

很多人以为 SELECT FOR UPDATE 锁的是整张表。实际上,InnoDB 的锁是加在索引记录上的,不是表本身。

-- 会锁住哪些行?
SELECT * FROM orders WHERE id = 100 FOR UPDATE;

-- 如果 id 是主键:只锁 id=100 这一条记录
-- 如果 id 不是索引:锁住的是 id=100 所在的整个索引页
-- 页面上其他记录也被连带锁定——这就是「间隙锁」的威力

更狠的是,间隙锁(Gap Lock)会锁定索引之间的空隙,防止其他事务在空隙中插入新记录。你以为只锁了一行,实际锁了一个范围。高并发场景下,这会导致大量事务阻塞,吞吐量断崖式下跌,而你从监控上只能看到一堆等待锁的连接。

吐槽:MySQL 的锁机制就像租房子——你以为只租了一个房间,实际上房东把整层楼都给你锁了,还不告诉你。


怎么破?给认真做性能优化的人几条建议

第一,别只看 EXPLAIN,要看实际执行时间。 EXPLAIN ANALYZE(MySQL 8.0+)会显示实际运行时的数据,比纯估算靠谱得多。

第二,监控慢查询日志,但别只盯着执行时间。关注 rows_examinedrows_sent 的比值——这个比值越大,说明扫描了无用数据的比例越高。

第三,pt-query-digest 或者 MySQL Workbench 的可视化执行计划,对慢查询做 EXPLAIN,对比优化前后的差距。

第四,建立数据库巡检机制:定期检查索引使用率、慢查询趋势、连接池状态。别等用户投诉了才知道数据库出了问题。

数据库不是玄学,但它的行为确实比大多数后端开发者想象的更复杂。你以为你掌控了查询,实际上你只是和优化器达成了某种脆弱的默契。

下次写 SQL 之前,先问自己三个问题:这个查询扫了多少行?这个索引真的适合这个查询吗?这个锁会不会阻塞其他请求?

问完这三个问题,你会发现——数据库其实一直在说实话,只是你之前没学会怎么听。

相关文章

连接超时设置成30秒,我收获了一个愤怒的CTO
写API一时爽,维护火葬场:我踩过的那些RESTful天坑
AI圈最近太热闹了!Gemma 4 开源、Perplexity 翻车、游戏大厂裁 AI 团队…这波资讯有点猛
别让你的API成为同事的噩梦:RESTful设计踩坑实录
你的后端正在被”超时”慢慢杀死:80%的人都在犯同一个致命错误
上线五分钟,排查两小时:你需要分布式链路追踪

发布评论