你的SQL执行计划:95%的程序员都没看懂那张该死的表格
先说个真实故事。
某天,监控报警说数据库CPU飙到98%,接口响应时间从30ms变成8秒。我拉出慢查询日志一看——某条查询跑了整整7秒,用的是全表扫描。但表里明明有索引,字段上也都加了,EXPLAIN一看,确实扫了索引。
问题在哪?没人知道。
最后查出来的原因让人想砸键盘:那个字段是VARCHAR类型,但传进来的参数是INT。数据库做隐式类型转换,索引直接失效,全表扫描伺候。
这不是个例。这是我见过的数据库性能问题的Top 1原因,比索引缺失还要命——因为它静悄悄地发生,没有报错,没有任何提示,等你发现的时候,已经在线上跑了不知道多少个脏查询。
今天聊个实战话题:SQL执行计划到底怎么看,哪些坑是教科书上不会教你的。
EXPLAIN不是万能的,但不会EXPLAIN是万万不能的
先说个冷知识:EXPLAIN的输出格式,MySQL和PostgreSQL不完全一样,MongoDB又是另一套,Oracle又不一样。很多人用惯了MySQL的EXPLAIN,换到PostgreSQL直接懵——怎么没有type字段了?
MySQL EXPLAIN最常用的几个字段:
- type:访问类型,从好到差是 system > const > eq_ref > ref > range > index > ALL。ALL就是全表扫描,看见它就要警惕
- key:实际用的索引,NULL就是没用索引
- rows:估算扫描了多少行,这个数字越大越要小心
- Extra:玄学字段,Using filesort、Using temporary这些字样一出,基本就是性能杀手
但问题来了:rows那个数字不准。
它是估算的,不是真实值。InnoDB的统计数据是采样计算的,十万级的表可能估成一万,百万级的可能估成十万。你以为扫了1000行,实际扫了50万——这误差足以让你做出完全错误的优化决策。
索引失效的第一元凶:隐式类型转换
回到开头那个故事,为什么VARCHAR字段传INT参数会导致索引失效?
MySQL的规则是:如果两个类型不一致,数据库会尝试把其中一个转换成另一个再来比较。具体规则比较复杂,但原则是——字符串转数字比数字转字符串更"自然"。
所以当你写:
SELECT * FROM users WHERE phone = 13800138000;
phone是VARCHAR类型,但常量是数字。MySQL会把phone转换成数字再比较。转换过程需要对每一行做函数调用——结果索引上的比较全部失效,变成全表扫描。
正确写法:
SELECT * FROM users WHERE phone = '13800138000';
加上引号,索引正常使用,执行时间从7秒变成3毫秒。这种bug的特点是:不出错、不报警、只有 EXPLAIN 能看出来。如果你不养成看执行计划习惯,这种问题会陪你很久。
另一个常见场景是日期字段:
-- 错误:字符串和日期字段比较,索引失效
SELECT * FROM orders WHERE created_at >= '2026-08-01';
-- 正确:显式转换,或者用DATE函数
SELECT * FROM orders WHERE created_at >= STR_TO_DATE('2026-08-01', '%Y-%m-%d');
SELECT * FROM orders WHERE created_at >= '2026-08-01 00:00:00';
最容易被忽视的性能杀手:SELECT *
这个话题被说烂了,但我还是要说,因为现实中的SELECT * 依然泛滥成灾。
很多人觉得SELECT * 的问题只是多传了无用字段、占用了额外的网络带宽。这是一个方面,但更致命的问题在于:它会导致覆盖索引失效。
什么是覆盖索引?如果一个索引包含了查询需要的所有字段,那么数据库不需要回表(不需要再查主表数据),直接返回索引里的数据即可。这是索引查询的最高效形态。
-- 假设有联合索引 idx_user_status (user_id, status)
-- 这个查询可以用覆盖索引,不需要回表
SELECT user_id, status FROM orders WHERE user_id = 123;
-- 但如果加了 *,就需要回表查其他字段,覆盖索引失效
SELECT * FROM orders WHERE user_id = 123;
回表操作意味着要访问主表数据,在高并发场景下,这种随机IO是数据库性能的最大敌人之一。
另外,SELECT * 在表结构变更时还有另一个问题:如果哪天有人往orders表加了一个TEXT类型的大字段(比如订单详情JSON),SELECT * 会把这个大字段一起查出来,内存和网络瞬间爆炸,而你甚至不知道是谁干的。
JOIN的代价:不是不能用,是要会用
JOIN被妖魔化了。朋友圈里流传着"JOIN很慢,能不用就不用"的偏见,然后大家开始用循环查——一条查外层,再循环里查子表。
这就是N+1问题的根源。
先说清楚一件事:JOIN本身不慢,慢的是你用的不对。
假设有两个表:users(100条)和 orders(10000条),你要查每个用户的订单数。
循环做法:
-- 伪代码
users = SELECT * FROM users -- 1次查询
for user in users:
order_count = SELECT COUNT(*) FROM orders WHERE user_id = user.id -- 100次查询
-- 总计:101次数据库往返
JOIN做法:
SELECT u.id, u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name
-- 总计:1次查询,返回100条结果
101次往返变成1次,数据库压力差了不止一个量级。这是JOIN的真正价值——减少应用层和数据库的网络往返次数,把逻辑下推到数据库层执行。
但JOIN确实有代价:如果两个表都很大,且没有合适的索引,JOIN可能产生巨大的临时表和文件排序,内存直接爆掉。所以关键是:确保JOIN字段有索引,且数据量级你要心里有数。
一个被低估的场景:COUNT(*) 到底快不快?
这个问题面试经常问,但回答基本都是背的——"InnoDB下COUNT(*)由引擎自动优化,会选取最小的非主键索引来扫"。
这话没错,但不够。
现实是:COUNT(*) 的速度取决于你用哪个索引扫。假设表有主键索引(id)、一个普通索引(status),如果status字段的选择性很高(比如大部分行status=1),那用idx_status扫反而比用主键索引(id)更快——因为它扫的行数更少。
InnoDB的COUNT(*) 会选择Cardinality(基数)最高的那个索引来扫。但这个统计数据是采样计算的,大数据量下可能有显著误差。
更重要的是:在大数据量下,COUNT(*) 永远快不了——它需要逐行扫描才能知道准确数量,不管用什么索引。这是关系型数据库的物理限制。
如果你的业务需要频繁COUNT大数据量(比如统计在线人数、订单总数),正确的做法是用定时任务维护一张统计表,而不是每次实时COUNT。这不是过度设计,这是基本的性能意识。
那个让你百思不得其解的Using filesort
EXPLAIN输出里的Extra字段,Using filesort是最常见的性能杀手之一。
filesort不是"用文件来排序",而是"在内存或磁盘里排序"。MySQL 8之前,如果排序数据太大超过sort_buffer_size,MySQL会写出到磁盘,形成filesort。MySQL 8之后有了索引条件下推和更好的内存管理,情况好一些,但依然存在。
什么时候会产生filesort?
- ORDER BY的字段没有索引
- ORDER BY用了函数(比如ORDER BY YEAR(created_at))
- 多表JOIN后排序,且排序字段不在索引里
- GROUP BY和ORDER BY同时存在,且不一致
解决方案:针对排序字段加索引,尽量让MySQL走索引排序而不是filesort。
-- 如果你经常这样查:
SELECT * FROM orders WHERE status = 1 ORDER BY created_at DESC LIMIT 20;
-- 建这个索引就能消除filesort:
ALTER TABLE orders ADD INDEX idx_status_created (status, created_at DESC);
说点真正有用的
写到这里,我发现一个规律——大多数SQL性能问题,都不是因为开发人员不懂数据库原理,而是不懂自己的业务数据。
你知道你的表有多少行吗?知道某个字段的基数是多少吗?知道最常用的查询条件是什么吗?知道数据的时间分布吗?
很多人答不上来。然后他们写SQL靠感觉,加索引靠推荐工具,出了性能问题靠猜。
所以最后给几条实战建议,不是那种"请加索引"的废话:
- 拿到一个陌生库,先跑一遍这两个查询:
SELECT COUNT(*) FROM xxx和SHOW INDEX FROM xxx。知道自己查的是什么量级的数据,这是基本功 - 写SQL之前先EXPLAIN,不要等报警了再看。养成习惯,上线前review一下执行计划
- 生产环境的慢查询,不一定是数据量大的那一条。小表的全表扫描 + 高频调用 = 灾难。数据量不是性能的唯一变量
- 类型一定要匹配,参数一定要显式转换。引号是小事,隐式类型转换是大事
- 如果你发现某个查询突然变慢,先看表结构有没有变更。加字段、加索引、改字段类型——都可能影响执行计划
数据库不是黑盒子,它是最诚实的系统——你付出什么代价,它就给你什么性能。你糊弄它,它就用慢查询糊弄你。
下次看见8秒的接口响应时间,别急着骂数据库。先问一句:那张该死的执行计划,你看懂了吗?