扫描行数降1200万倍:优化天花板

你有没有遇到过这种情况——线上一个查询跑了8秒,整个页面白屏,老板在群里@你,用户在投诉,而你盯着那条SQL却不知道从哪下手?别慌,这种事我经历过太多次了。今天这篇文章,我不讲空洞的理论,直接拿真实案例,从定位慢查询、分析执行计划、到索引策略调整,一步一步带你走完整个SQL优化的全流程。看完这篇,下次遇到慢查询,你心里就有谱了。

一、先别急着加索引——慢查询的根因往往不在索引上
很多人一看到慢查询,第一反应就是"加个索引不就完了?"这话对,但只对了一半。
我之前接手过一个订单系统的优化项目,有一条查询语句耗时超过12秒:
sql
SELECT o.id, o.order_no, u.nickname, p.product_name
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN products p ON o.product_id = p.id
WHERE o.status = 3 AND o.created_at > '2025-01-01'
ORDER BY o.created_at DESC
LIMIT 20;
表面上看,这条SQL涉及三张表的关联,orders表有800万条数据,users表200万条,products表50万条。团队第一反应就是给orders.status和orders.created_at加联合索引。
加完之后呢?查询时间从12秒降到了9秒。
没错,加了索引反而只快了3秒。问题出在哪?
后来用EXPLAIN一看才发现:这条SQL虽然走了索引,但因为LEFT JOIN的存在,MySQL需要先扫描orders表中所有status=3且created_at > '2025-01-01'的记录,然后逐条去users表和products表做关联。数据量一大,嵌套循环的代价就非常高。
所以第一个教训:加索引之前,先看执行计划,搞清楚瓶颈到底在哪。

二、EXPLAIN到底怎么看?别只盯着type列
很多教程教你看EXPLAIN,就说看type列,ref比ALL好,range比ref好。这话没错,但远远不够。
我来拆解一下EXPLAIN输出中真正值得关注的几个字段:
字段名 含义 关注点
id 查询的执行序号 id相同表示同时执行,id越大越先执行
select_type 查询类型 SIMPLE最好,DERIVED表示有子查询,要警惕
type 访问类型 ALL是全表扫描,必须优化;index是全索引扫描,也不理想
key 实际使用的索引 和possible_keys对比,看有没有用上合适的索引
key_len 索引使用的字节长度 越短越好,说明索引利用率高
rows 预估扫描行数 这个数字越接近真实返回行数越好,差太多说明统计信息不准
Extra 额外信息 Using filesort、Using temporary都是性能杀手
回到刚才那个案例,EXPLAIN的输出是这样的:
id select_type table type possible_keys key key_len rows Extra
1 SIMPLE o range idx_status_created idx_status_created 5 320000 Using where; Using filesort
1 SIMPLE u eq_ref PRIMARY PRIMARY 4 1 NULL
1 SIMPLE p eq_ref PRIMARY PRIMARY 4 1 NULL
问题一目了然:orders表扫描了32万行,而且出现了Using filesort——也就是说,虽然走了索引,但MySQL还是额外做了一次文件排序来处理ORDER BY created_at DESC。
这就是为什么加了索引只快了3秒的原因:索引解决了WHERE条件的过滤问题,但没有解决ORDER BY的排序问题。

三、索引策略不是越多越好——这三条原则请记住
做了这么多年数据库优化,我总结了三条索引设计的核心原则:
1、联合索引要遵循最左前缀原则,而且列的顺序决定了索引的效率。
比如idx_status_created (status, created_at)这个联合索引,对于WHERE status = 3 AND created_at > '2025-01-01'这种查询是完美匹配的。但如果你的查询条件变成了WHERE created_at > '2025-01-01' AND status = 3,虽然结果一样,但从索引利用的角度,顺序不影响最终效果。可如果查询条件只有created_at > '2025-01-01',这个联合索引照样能用上——因为created_at是第二列,按照最左前缀,第一列status没出现在条件里,索引其实只能用到一部分。
更关键的是,如果你的查询是WHERE status = 3 ORDER BY created_at DESC,而索引是(status, created_at),那么这个索引可以同时解决过滤和排序,避免filesort。但如果索引是(created_at, status),虽然也能用,但排序方向可能和索引顺序不一致,MySQL还是可能退化为文件排序。
2、覆盖索引能极大减少回表开销。
所谓覆盖索引,就是查询需要的所有字段都包含在索引里,MySQL不需要再回主键索引去查数据。
刚才那个案例,如果我们建一个覆盖索引:
sql
ALTER TABLE orders ADD INDEX idx_status_created_cover
(status, created_at, id, order_no);
注意这里把id和order_no也加进去了。这样EXPLAIN的Extra字段就会出现Using index,表示完全走覆盖索引,不需要回表。
改完之后,查询时间直接从9秒降到了0.3秒。
3、不要给区分度低的列单独建索引。
比如status字段只有0、1、2、3四个值,区分度极低。单独给status建索引,MySQL大概率不会用,因为走索引的代价比全表扫描还高。但如果和created_at组成联合索引,情况就完全不同了——因为联合索引的区分度是各列区分度的乘积,一下子就上去了。

四、一个真实的查询优化案例——从45秒到200毫秒
说个我去年处理过的真实案例。某电商平台的商品搜索接口,核心SQL大概长这样:
sql
SELECT p.id, p.name, p.price, c.category_name
FROM products p
INNER JOIN categories c ON p.category_id = c.id
WHERE p.name LIKE '%手机%'
AND p.is_active = 1
ORDER BY p.sales DESC
LIMIT 20;
这条SQL的问题非常典型:
LIKE '%手机%'是前置模糊匹配,普通B+Tree索引完全失效。
ORDER BY sales DESC需要排序。
products表有1200万条数据。
原始查询耗时45秒。
我的优化思路分三步走:
第一步,把LIKE '%手机%'改成全文检索。MySQL自带的FULLTEXT索引支持自然语言搜索:
sql
ALTER TABLE products ADD FULLTEXT INDEX ft_name (name);
然后把查询改成:
sql
WHERE MATCH(p.name) AGAINST('手机' IN NATURAL LANGUAGE MODE)
这一步直接把全表扫描变成了全文索引扫描,扫描行数从1200万降到了不到5000。
第二步,给(is_active, sales)建联合索引,解决WHERE过滤和ORDER BY排序的问题:
sql
ALTER TABLE products ADD INDEX idx_active_sales (is_active, sales DESC);
第三步,考虑到categories表只有不到200条数据,JOIN的代价极低,不需要额外优化。
最终优化后的SQL:
sql
SELECT p.id, p.name, p.price, c.category_name
FROM products p
INNER JOIN categories c ON p.category_id = c.id
WHERE MATCH(p.name) AGAINST('手机' IN NATURAL LANGUAGE MODE)
AND p.is_active = 1
ORDER BY p.sales DESC
LIMIT 20;
结果:查询时间从45秒降到了200毫秒,提升了225倍。

五、Explain对比——优化前后的执行计划差异有多大
光说数字可能没感觉,我把优化前后的EXPLAIN结果做个对比:
优化前:
id select_type table type key rows Extra
1 SIMPLE p ALL NULL 12000000 Using where; Using filesort
1 SIMPLE c eq_ref PRIMARY 1 NULL
优化后:
id select_type table type key rows Extra
1 SIMPLE p fulltext ft_name 1 Using where; Using filesort
1 SIMPLE c eq_ref PRIMARY 1 NULL
看到区别了吗?type从ALL(全表扫描)变成了fulltext(全文索引扫描),rows从1200万变成了1。虽然Extra里还有Using filesort,但因为扫描的行数极少,排序的代价几乎可以忽略。

六、几个容易被忽略的优化细节
1、LIMIT偏移量过大时,性能会急剧下降。
sql
SELECT * FROM orders ORDER BY created_at DESC LIMIT 1000000, 20;
这条SQL要先扫描1000020行,然后丢弃前1000000行,只返回20行。优化方案是用WHERE id > 上一页最大ID来代替LIMIT偏移:
sql
SELECT * FROM orders
WHERE id < 上一页最小ID
ORDER BY id DESC LIMIT 20;
2、JOIN的顺序很重要。
MySQL的优化器会自动调整JOIN顺序,但如果你用的是STRAIGHT_JOIN强制指定顺序,或者优化器统计信息不准,就可能出现小表驱动大表的情况,性能会非常差。定期执行ANALYZE TABLE来更新统计信息,是个好习惯。
3、批量操作别用循环,用SET代替。
sql
— 慢
FOR each id IN (SELECT id FROM orders WHERE status = 0):
UPDATE orders SET status = 1 WHERE id = id;
— 快
UPDATE orders SET status = 1 WHERE status = 0;
这个看似简单,但我见过太多人在代码里写循环单条更新,一条SQL能解决的事,愣是跑了几千次。

七、总结:SQL优化的核心不是背八股文,而是建立分析思维
回过头来看,SQL优化这件事,说到底就是三个字:看、想、改。
看:用EXPLAIN看执行计划,找到真正的瓶颈。
想:想清楚数据分布、索引原理、MySQL的执行逻辑,再动手。
改:改SQL、改索引、改表结构,每一步改完都要对比执行计划,用数据说话。
别迷信任何一个"银弹"技巧。索引不是越多越好,联合索引不是随便建的,EXPLAIN不是看一眼就完事的。真正的优化能力,来自于你对每一条慢查询的认真分析,而不是对某个参数的盲目调整。
希望这篇文章能帮你在下次遇到慢查询时,少走一些弯路。

💡注意:本文所介绍的软件及功能均基于公开信息整理,仅供用户参考。在使用任何软件时,请务必遵守相关法律法规及软件使用协议。同时,本文不涉及任何商业推广或引流行为,仅为用户提供一个了解和使用该工具的渠道。
你在生活中时遇到了哪些问题?你是如何解决的?欢迎在评论区分享你的经验和心得!
希望这篇文章能够满足您的需求,如果您有任何修改意见或需要进一步的帮助,请随时告诉我!
感谢各位支持,可以关注我的个人主页,找到你所需要的宝贝。
博文入口:山峰哥-CSDN博客 复制到【浏览器】打开即可,宝贝入口:常用软件 宝贝:精品文件
作者郑重声明,本文内容为本人原创文章,纯净无利益纠葛,如有不妥之处,请及时联系修改或删除。诚邀各位读者秉持理性态度交流,共筑和谐讨论氛围~


