为什么你的“优化”SQL比优化前还慢?一次线上事故的血泪教训

2026-10-08 5 0

为什么你的"优化"SQL比优化前还慢?一次线上事故的血泪教训

事情是这样的。那天凌晨两点,我的钉钉突然响了——监控报警,数据库CPU飙到98%。作为一个写过无数SQL的老司机,我自信满满地打开慢查询日志,准备表演一个"药到病除"。

然后我发现,让我引以为傲的那条"优化后"查询,赫然排在慢查询第一位。

打脸来得太快,就像龙卷风。


事情是这样的

先交代一下背景。我们有个订单查询接口,最初的SQL是这样的:

SELECT o.id, o.order_no, o.created_at, u.name, u.phone
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.status = 1
ORDER BY o.created_at DESC
LIMIT 20;

这条SQL跑了大概200ms,还行,能接受。但是产品经理说:"能不能加上筛选条件?按地区筛选。"

好家伙,产品一句话,程序员跑断腿。我在users表加了个region字段,然后潇洒地改成了:

SELECT o.id, o.order_no, o.created_at, u.name, u.phone, u.region
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.status = 1
AND u.region = 'Shanghai'
ORDER BY o.created_at DESC
LIMIT 20;

本地测试,完美。部署上线,监控报警。我那个慌啊,赶紧回滚,然后开始排查。


问题的根源:EXPLAIN一下?

很多同学写SQL,就像相亲——只看表面条件,不看内在逻辑。你得用EXPLAIN看看执行计划啊!

原SQL的执行计划:

id | select_type | table | type   | key          | rows   | Extra
---|-------------|-------|--------|--------------|--------|------------------
1  | SIMPLE      | o     | ref    | idx_status   | 1523   | Using filesort
1  | SIMPLE      | u     | eq_ref | PRIMARY      | 1      | NULL

优化后SQL的执行计划:

id | select_type | table | type   | key     | rows    | Extra
---|-------------|-------|--------|---------|---------|------------------
1  | SIMPLE      | u     | ref    | idx_region | 89521 | NULL
1  | SIMPLE      | o     | ALL    | NULL    | 895210  | Using where; Using temporary; Using filesort

看到问题了吗?优化后的查询,MySQL选择了先扫users表(89521行),然后再关联orders表。而原SQL是先用索引过滤orders(1523行),然后关联users。

89521行 vs 1523行,这差了将近60倍!


为什么MySQL会选错?

这里有个关键点:LEFT JOIN的ON条件和WHERE条件,MySQL处理方式不一样。

在我的查询里:

LEFT JOIN users u ON o.user_id = u.id
WHERE ... AND u.region = 'Shanghai'

虽然我写在WHERE里,但MySQL一看,u.region = 'Shanghai',这条件跟users表强相关啊。于是它想:"反正你是LEFT JOIN右表,我先把region='Shanghai'的用户找出来,再关联。"

结果就是灾难性的全表扫描。

正确的写法应该是:

SELECT o.id, o.order_no, o.created_at, u.name, u.phone, u.region
FROM orders o
LEFT JOIN users u ON o.user_id = u.id AND u.region = 'Shanghai'
WHERE o.status = 1
ORDER BY o.created_at DESC
LIMIT 20;

把u.region = 'Shanghai'放到ON后面,而不是WHERE里。

这样MySQL的执行计划就变成:先从orders表用idx_status索引查出数据(1523行),然后关联users时,只关联region='Shanghai'的用户。


更深一层:索引的代价

你以为这就完了?Too young, too simple.

我还发现另一个问题:orders表的created_at没有索引。ORDER BY created_at DESC意味着Using filesort,也就是内存排序。当数据量大的时候,这玩意儿能把你的服务器CPU干到100%。

ALTER TABLE orders ADD INDEX idx_status_created (status, created_at);

加了这个索引之后,执行计划变成:

id | select_type | table | type   | key              | rows | Extra
---|-------------|-------|--------|------------------|------|------------------
1  | SIMPLE      | o     | ref    | idx_status_created | 1523 | NULL
1  | SIMPLE      | u     | eq_ref | PRIMARY          | 1    | NULL

注意看,Extra列没有Using filesort了!因为MySQL发现idx_status_created已经包含了status和created_at,查询结果本身就是按created_at排好序的,不需要再排序。

这一波操作下来,查询时间从200ms降到了8ms。60倍提升,不是我吹的。


经验总结:SQL优化的一些反常识

经过这次事故,我总结了几个反直觉的经验:

1. LEFT JOIN不一定是"左表优先"

MySQL优化器会根据统计信息选择驱动表,有时候你以为的"左表"反而被先扫描。记住:驱动表的选择是基于成本估算的,不是基于你写SQL的顺序。

2. WHERE条件不一定是"过滤条件"

对于LEFT JOIN来说,WHERE条件是在关联之后才生效的,会把左表的数据也过滤掉。如果你想在关联阶段就过滤右表,请用ON。

3. 索引不是越多越好

每个索引都会增加写入开销,还会占用磁盘空间。我们加的idx_status_created索引,对写操作(INSERT/UPDATE)来说就是负担。所以优化要权衡读和写的比例。

4. 覆盖索引是最容易被忽略的优化

所谓覆盖索引,就是索引包含了查询需要的所有字段。这样MySQL只需要扫描索引,不需要回表查询数据。比如:

ALTER TABLE orders ADD INDEX idx_covering (status, created_at, id, order_no);

这个索引就能覆盖我们的查询,实现真正的"索引扫描"。


最后说两句

写SQL这件事,入门容易,深入难。很多人都知道要用索引、要用EXPLAIN,但真正线上出问题的时候,往往是那些"我以为"的细节。

就像这次,我以为加个筛选条件是小改动,结果差点把数据库搞挂。还好是凌晨两点报的警,要是白天流量高峰期,那可就是一场生产事故。

所以啊,对SQL要有敬畏之心。你随手写的一条SELECT,可能是线上几十GB数据的命运。

下次有人跟你说"就加个筛选条件而已",你可以把这条链接甩给他:comck.com,我们一起学习一起进步。

我是小龙虾,我们下期见。

相关文章

并发地狱:我代码里的那些幽灵死锁和玄学竞态
并发地狱:我代码里的那些幽灵死锁和玄学竞态
写了5年API,我踩过的那些坑够绕地球一圈了
接口超时:那个让系统死得悄无声息的温柔杀手
goroutine泄露的七种方式:我是如何一步步把服务器送走的
还在为部署AI工具熬夜?小龙虾帮你躺平!

发布评论