Explain一对比才知道,我之前写的SQL有多烂

凌晨三点,线上告警响了。打开监控一看,核心接口响应时间从200毫秒飙到了9秒。整个团队被从被窝里薅起来,排查了两个小时,最后发现就是一条SQL惹的祸。改了一个索引,9秒变30毫秒。这种事我经历过不止一次,每次都让我更加确信一件事:不会看Explain的开发者,写出来的SQL就是定时炸弹。今天这篇文章,我不讲课本上的理论,全部用真实场景里的Explain执行计划做对比,让你一眼就能看出好坏SQL的差距到底在哪。

数据库工程与Explain执行计划对比分析

一、为什么我说不看Explain就写SQL,等于闭着眼睛开车

很多开发者写SQL有个习惯:能跑就行,跑得快不快先不管。等出了问题再去改,改完也不知道为什么快了。这种做法在小项目里也许能蒙混过关,但数据量一上来,迟早要出事。

我之前带过一个实习生,他写了一条查询用户订单的SQL,数据量只有几千条的时候跑得飞快,上了生产之后数据量涨到了两百万,直接把数据库CPU干到了90%以上。我让他跑了一遍Explain,他看完之后脸色都变了——type是ALL,也就是全表扫描,扫描了两百多万行。

Explain这个命令,说白了就是MySQL给你的一张"体检报告"。它会告诉你这条SQL到底是怎么执行的,用了哪个索引,扫描了多少行,有没有做额外的排序和临时表。你不看这张报告,就跟医生不看化验单就开药一样,纯属碰运气。

☆ 我给自己定了一条规矩:任何一条要上线的SQL,必须先过Explain这一关。type低于range的,直接打回重写,没有商量余地。

二、Explain核心字段逐个拆解:看懂这张表你就赢了一半

下面我用一个真实的场景来讲解。假设我们有一张用户订单表,结构如下:

sql

CREATE TABLE user_orders (

id BIGINT PRIMARY KEY AUTO_INCREMENT,

user_id BIGINT NOT NULL COMMENT '用户ID',

order_status TINYINT NOT NULL COMMENT '订单状态',

order_amount DECIMAL(10,2) NOT NULL COMMENT '订单金额',

create_time DATETIME NOT NULL COMMENT '下单时间',

pay_time DATETIME DEFAULT NULL COMMENT '支付时间',

INDEX idx_user_id (user_id),

INDEX idx_status (order_status),

INDEX idx_create_time (create_time)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

现在有一条查询语句,要找某个用户在2024年下的所有已支付订单:

sql

SELECT id, order_no, order_amount, create_time

FROM user_orders

WHERE user_id = 10086

AND order_status = 3

AND create_time >= '2024-01-01 00:00:00'

AND create_time < '2025-01-01 00:00:00';

执行 EXPLAIN 之后,拿到的结果如下:

id select_type table type possible_keys key key_len rows Extra

1 SIMPLE user_orders ALL idx_user_id,idx_status,idx_create_time NULL NULL 1560000 Using where

下面我把关键字段一个一个拆开讲:

1、type:这是整张表里最核心的字段,代表MySQL访问表的方式。从最优到最差的排列顺序是:system > const > eq_ref > ref > range > index > ALL。上面这条SQL的type是ALL,意味着全表扫描,也就是把整张表从头到尾翻了一遍。156万行,一条一条地过滤,能不慢吗?

2、possible_keys:优化器认为可能用到的索引。这里列出了三个,但实际一个都没用上,说明这三个单列索引都不匹配这条SQL的查询条件。

3、key:实际使用的索引。这里是NULL,说明压根没走索引。

4、rows:预估需要扫描的行数。156万行,对于一条按用户ID查询的语句来说,这个数字大得离谱。

5、Extra:额外信息。这里只有 Using where,说明MySQL在存储引擎层拿到数据之后,在服务层又做了一次过滤。如果出现 Using filesort 或者 Using temporary,那就更麻烦了,意味着还额外做了排序或者建了临时表。

☆ 给大家一个快速判断的标准:如果type不是range及以上,或者Extra里出现了Using filesort、Using temporary,这条SQL基本上有问题,必须动手优化。

三、优化前后的Explain对比:差距一目了然

上面那条SQL的问题出在哪?三个单列索引都不匹配查询条件。user_id 单独用可以,但加上 order_status 和 create_time 之后,优化器发现任何一个单列索引都覆盖不了全部条件,干脆就不用了,直接全表扫描。

解决方案也很直接:建一个联合索引。

sql

ALTER TABLE user_orders ADD INDEX idx_user_status_time (user_id, order_status, create_time);

建完索引之后,再跑一次Explain:

id select_type table type possible_keys key key_len rows Extra

1 SIMPLE user_orders range idx_user_status_time idx_user_status_time 27 12 Using index condition

把两次Explain放在一起对比,差距一目了然:

对比项 优化前 优化后

type ALL(全表扫描) range(范围扫描)

key NULL idx_user_status_time

rows 1560000行 12行

Extra Using where Using index condition

扫描行数差距 156万行 12行

从156万行降到12行,这不是提升了一点半点,而是提升了十几万倍。实际响应时间也从6秒多降到了20毫秒以内。

这就是Explain对比的威力。你不需要去猜SQL为什么慢,Explain直接把答案拍在你脸上。

四、最左前缀原则:联合索引不是随便建的

上一节建的联合索引 idx_user_status_time (user_id, order_status, create_time) 之所以有效,靠的就是最左前缀原则。

这个原则说的是:联合索引在匹配查询条件的时候,必须从最左边的字段开始匹配,一旦中间断了,后面的字段就全部失效。

举个例子,假设我把索引改成 (order_status, user_id, create_time),然后查询条件还是 user_id = 10086 AND order_status = 3。这时候优化器会怎么处理?

它会发现查询条件里 user_id 在最前面,但索引的最左边是 order_status,对不上。所以这个索引大概率不会被使用,又回到全表扫描的老路上去了。

再举一个更极端的例子。索引是 (user_id, order_status, create_time),但查询条件只有 order_status = 3。根据最左前缀原则,最左边的 user_id 没出现在查询条件里,所以整个索引都不会被使用。

☆ 建联合索引的时候,字段顺序非常关键。我的经验是:把区分度最高的、最常出现在等值查询里的字段放在最左边。什么叫区分度高?比如user_id有几百万个不同的值,区分度就高;order_status只有几个状态值,区分度就低。所以user_id放最前面是对的。

五、覆盖索引:让查询连回表都省了

还是上面那个例子,如果我的查询只需要返回 user_id、order_status、create_time 这几个字段:

sql

SELECT user_id, order_status, create_time

FROM user_orders

WHERE user_id = 10086

AND order_status = 3

AND create_time >= '2024-01-01 00:00:00';

这时候Explain的Extra字段会出现 Using index,说明用了覆盖索引。什么意思呢?就是查询需要的所有字段都在索引树上,MySQL根本不需要回表去查聚簇索引,直接从二级索引上就把数据拿全了。

回表这个操作,很多人不当回事,但在高并发场景下,回表的开销是非常大的。InnoDB的二级索引里只存了索引字段的值和主键ID,真正的行数据在聚簇索引里。所以如果查询的字段不在二级索引里,MySQL就得拿着主键ID再去聚簇索引里查一遍,这就是回表。扫描1000行就要回表1000次,这开销能小吗?

下面是覆盖索引场景下的Explain结果:

id select_type table type possible_keys key key_len rows Extra

1 SIMPLE user_orders range idx_user_status_time idx_user_status_time 27 12 Using index condition; Using index

注意最后那个 Using index,这就是覆盖索引的标志。看到它,你就知道这条SQL已经优化到位了。

☆ 实战中我有个习惯:写完SELECT之后,先看一眼查询的字段是不是都在联合索引里。如果不是,要么把字段加到索引里,要么考虑这条查询是否真的需要那么多字段。很多时候,前端根本用不到那么多数据,是后端偷懒一次全查出来了。

六、索引失效的那些坑:建了索引也不一定快

很多人觉得建了索引就万事大吉了,其实不然。我在实际项目中遇到过太多"明明建了索引却没生效"的情况,下面这几个坑你一定要避开:

1、对索引字段使用函数:

sql

-- 索引失效,因为对create_time用了YEAR函数

WHERE YEAR(create_time) = 2024

-- 索引生效,直接用范围查询

WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01'

2、隐式类型转换:

sql

-- user_id是BIGINT,但这里传了字符串,索引失效

WHERE user_id = '10086'

-- 改成数字,索引生效

WHERE user_id = 10086

3、LIKE以通配符开头:

sql

-- 索引失效

WHERE nickname LIKE '%三'

-- 索引生效

WHERE nickname LIKE '张%'

4、使用NOT IN或者!=:

sql

-- 索引大概率失效

WHERE order_status != 3

-- 改成IN,索引可以生效

WHERE order_status IN (1, 2, 4)

5、OR连接的条件中只要有一个字段没索引,就可能全表扫描:

sql

-- user_id有索引,但order_status没有,整个查询可能走全表扫描

WHERE user_id = 10086 OR order_status = 3

☆ 这些坑看起来简单,但在实际业务代码里出现的频率非常高。我的做法是写完SQL之后,一定要跑一遍Explain确认索引确实被用到了。养成这个习惯,能帮你避免80%的性能问题。

七、一个完整的查询优化案例:从Explain对比到上线

最后讲一个我在上家公司做过的真实优化案例。

背景:我们有一个运营后台的报表接口,需要查询某个时间段内所有已完成订单的汇总数据。原始SQL如下:

sql

SELECT DATE(create_time) AS order_date,

COUNT(*) AS order_count,

SUM(order_amount) AS total_amount

FROM user_orders

WHERE order_status = 4

AND create_time >= '2024-01-01'

AND create_time < '2025-01-01'

GROUP BY DATE(create_time)

ORDER BY order_date DESC;

原始Explain结果:

id select_type table type possible_keys key rows Extra

1 SIMPLE user_orders ALL idx_status,idx_create_time NULL 2100000 Using where; Using temporary; Using filesort

问题非常严重:全表扫描210万行,还出现了 Using temporary 和 Using filesort,说明MySQL又建了临时表又做了文件排序,性能差到了极点。

优化过程:

1、首先解决 GROUP BY DATE(create_time) 的问题。对create_time用了函数,索引肯定失效。改成范围查询配合日期字段:

sql

SELECT create_time,

COUNT(*) AS order_count,

SUM(order_amount) AS total_amount

FROM user_orders

WHERE order_status = 4

AND create_time >= '2024-01-01 00:00:00'

AND create_time < '2025-01-01 00:00:00'

GROUP BY create_time

ORDER BY create_time DESC;

2、建立联合索引:

sql

ALTER TABLE user_orders ADD INDEX idx_status_time_amount (order_status, create_time, order_amount);

3、优化后的Explain:

id select_type table type possible_keys key key_len rows Extra

1 SIMPLE user_orders range idx_status_time_amount idx_status_time_amount 12 8500 Using index condition

对比数据:

对比项 优化前 优化后

type ALL range

扫描行数 210万行 8500行

Using temporary 有 无

Using filesort 有 无

响应时间 12秒+ 80毫秒

从12秒降到80毫秒,提升了150倍。而且因为用了覆盖索引,连回表都省了。

八、写在最后

SQL优化这件事,说到底就三板斧:看Explain找问题,建索引提速度,改语句消浪费。听起来简单,但要真正做好,需要大量的实战积累。

我的建议是:从今天开始,每写一条SQL,都跑一遍Explain。一开始你可能看不懂,没关系,先把type和rows这两个字段盯紧了。type低于range的,回去改;rows超过一万的,回去改。坚持一个月,你对SQL性能的感觉就完全不一样了。

别觉得优化是DBA的事。写SQL的人不懂优化,就像开车的人不懂引擎一样,迟早要抛锚。

💡注意:本文所介绍的软件及功能均基于公开信息整理,仅供用户参考。在使用任何软件时,请务必遵守相关法律法规及软件使用协议。同时,本文不涉及任何商业推广或引流行为,仅为用户提供一个了解和使用该工具的渠道。

你在生活中时遇到了哪些问题?你是如何解决的?欢迎在评论区分享你的经验和心得!

希望这篇文章能够满足您的需求,如果您有任何修改意见或需要进一步的帮助,请随时告诉我!

感谢各位支持,可以关注我的个人主页,找到你所需要的宝贝。

博文入口:https://blog.csdn.net/Start_mswin 复制到【浏览器】打开即可,宝贝入口:https://pan.quark.cn/s/b42958e1c3c0 宝贝:https://pan.quark.cn/s/1eb92d021d17

作者郑重声明,本文内容为本人原创文章,纯净无利益纠葛,如有不妥之处,请及时联系修改或删除。诚邀各位读者秉持理性态度交流,共筑和谐讨论氛围~

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐