MySQL查询优化实战:从执行计划到索引设计,解决慢查询与多表JOIN难题
2026/9/13 6:46:25 网站建设 项目流程

经常有人问我,说自己在Navicat里写mysql查询数据,平时一张表select几行记录没什么感觉,一遇到线上慢查询、多表JOIN结果翻倍、数据对不上号这类问题,就完全没方向。说实话,我刚开始写SQL那几年也是这个状态:能用,但不明白为什么能用。后来把MySQL执行一条查询的完整过程搞清楚,再回头看那些“玄学”问题,十有八九是同一批根因。这篇文章就把这些年我实际排查和优化查询的思路整理出来,从单表查询的隐蔽坑、多表关联的取舍,一直聊到索引、执行计划和工程化模板,不是教科书式的罗列,全是能直接上手的经验。适合正在学MySQL、或者写了不少SQL但总在性能和数据准确性上栽跟头的人。

1. 先把一条查询的完整路径刻在脑子里

1.1 从提交SQL到返回结果,MySQL内部经历了什么

很多人以为SELECT提交之后,MySQL就是“直接去表里翻数据”。真实路径要长得多。客户端把SQL发到服务端,先过连接器,连接器负责校验账号和权限,这一步在查询开始之前就完成了。接着进入分析器,做词法分析和语法分析,把select * from users where id = 1拆成token,再检查语法结构对不对。语法通过后,优化器登场,决定这条SQL具体怎么执行:选哪个索引、先关联哪张表、需不需要临时表。最后执行器拿着优化器给的执行计划,去InnoDB存储引擎一层层取数据,再把结果返回给客户端。

拿点外卖做类比:连接器是你下单时验证账号,分析器是商家确认菜单,优化器是外卖平台规划最优配送路线,执行器是骑手,InnoDB就是后厨。路线选得对不对,直接决定这一单是20分钟到还是两小时到。很多慢查询,问题不是出在“后厨没菜”,而是优化器没选对路,或者你的SQL写得太绕,优化器想选好路都难。

MySQL 8.0和之前版本的架构略有差异,但核心链路是一致的:连接管理、分析器、优化器、执行器、存储引擎。8.0把查询缓存彻底移除了,因为缓存每次表更新就要失效,命中率低还拖累并发,这个模块弊大于利。对我们写查询的人来说,知道“8.0没有查询缓存”就够了,不要指望靠缓存掩盖烂查询。

1.2 “通用查询”说的是标准套路,不是一条万能SQL

有些朋友希望我给他一条“通用查询SQL”,能直接套用到所有业务表。说实话,不存在这种东西。业务千差万别,但背后的操作组合就那么几类:过滤、排序、分组、关联、分页。所谓“通用”,指的是面对任意一张表、任意一条查询需求,你都能快速拆解出它属于哪几类操作的组合,然后按固定顺序去写、去排查、去优化。

我自己的拆解顺序是四个问题:要哪些字段、什么过滤条件、要不要分组聚合、以什么顺序和粒度返回。前两个问题决定数据范围,后两个决定结果形态。很多查询结果“看着不对”,根源往往是把粒度和关联关系搞混了。比如“查用户订单”,你要的是“每个用户一行,附带订单数”,还是“每个订单一行,附带用户信息”,这两条SQL的写法完全不同,查出来的行数也完全不同。

如果查询出了问题,常规排查顺序是:先确认单表条件下数据是否正确,再检查多表关联是否产生重复或缺失,然后用EXPLAIN看执行计划,最后回到数据分布看是不是统计信息不准。这个顺序我用了很多年,90%的问题都能定位到具体环节。

2. 单表查询里那些“查得出来但查不对”的细节

2.1 WHERE条件里的隐形陷阱

单表查询是最基础的场景,但恰恰是这里,新手和老手都会踩坑。先说NULL。SQL里NULL的意思是“未知”,不是“空字符串”,也不是0。用等号比较NULL永远不成立:where name = NULL查不到任何行,必须用IS NULLIS NOT NULL。这是经典错误,但老手也会栽,因为有时查询结果少了几行,一看SQL发现条件里用了= '',而实际数据是NULL,两者根本不相等。

第二个坑是隐式类型转换。MySQL会自动把“看起来是数字的字符串”转成数字比较,反之亦然,这非常危险。看这个例子:where phone = 13800138000,如果phone字段是varchar类型,MySQL会把phone从字符串转成数字再去比较,转换后索引失效,全表扫描,甚至可能因为精度问题匹配到错误数据。手机号、订单号这类长数字,查询参数一律用字符串传,不要图省事写数字。

字符集问题也很隐蔽。不同表甚至同一张表不同字段的字符集不一致,比如utf8和utf8mb4混用,关联或比较时可能乱码或匹配不上。utf8mb4才是完整的UTF-8,emoji和生僻字必须用它,新库建议直接统一utf8mb4,避免后续痛苦的迁移。大小写敏感性也由排序规则决定,ci结尾不区分大小写,cs区分,bin是二进制比较。MySQL默认的utf8mb4_0900_ai_ci不区分大小写,所以where name = 'mysql''MySQL'都能查到;如果业务上要求严格区分,要调整排序规则或用BINARY。遇到“数据明明在表里却查不到”的怪事,先去查字符集和排序规则,大概率有答案。

时间字段的查询也是个重灾区。最稳的写法是半开区间:create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00'。不要用between '2024-01-01' and '2024-01-01 23:59:59',容易被日志时间的秒级精度坑;更不要写DATE(create_time) = '2024-01-01',因为函数包裹索引列会让索引失效。

2.2 排序、分页、去重、聚合,这些常规操作里的隐形门槛

ORDER BY的多个排序字段是从左到右逐级生效的,order by status desc, create_time asc不是按两个字段的“综合排序”,而是先按status排,status相同再按create_time排。这个顺序必须提前想清楚。排序尽量用数字类型字段,字符串排序的结果可能和你想的不一样,字符串比较是逐字符的,“10”会排在“9”前面。

深分页是性能和体验的双重杀手。limit 100000, 20这种写法,MySQL会扫描前面100020行再丢掉前100000行,越往后越慢。优化思路有两种:一是延迟关联,先查出id,再回表取数据;二是书签法,记录上一页最后一个id,下一页直接where id > 100000 limit 20。书签法适合数据不频繁变动的列表,体验最好,但要求排序字段是唯一的、递增的。

DISTINCT是对整行所有返回列的组合去重,不是只对某一列去重。想看“有多少个不同用户”,要写select distinct user_id;如果写select distinct user_id, status,那是看这两个字段组合后的不同值。粒度不同,结果完全不同。GROUP BY也有类似问题,常见写法select user_id, max(score) from t group by user_id是查每个用户的最高分,它返回的user_id和max(score)是配对的,但如果你多select一个非聚合、非分组的字段,在MySQL旧版本可能查出随机值,新版本直接报错。聚合函数和NULL也要注意:count(*)统计行数,count(字段)统计该字段非NULL的个数;sum(字段)遇到全是NULL时返回NULL而不是0,业务上经常需要ifnull(sum(amount), 0)兜底。

3. 多表JOIN与子查询,结果集和性能的双重考验

3.1 JOIN的连接逻辑,以及ON和WHERE过滤位置的差异

JOIN的过程本质是拿驱动表的每一行,去匹配被驱动表的行。MySQL优化器通常会选小表驱动大表,把循环次数降到最低。对LEFT JOIN来说,左表是驱动表,被驱动的右表能不能快速被找到,靠的是右表连接字段上的索引。右表连接字段没索引,基本就是全表扫描,这是大多数联表查询慢的根源。

LEFT JOIN里,过滤条件放ON还是WHERE,结果天差地别。看这两条:

-- 保留所有用户,只有status=1的订单行会关联上来 select u.*, o.amount from users u left join orders o on u.id = o.user_id and o.status = 1; -- 先LEFT JOIN出所有用户订单,再用WHERE把status=1以外的行过滤掉 select u.*, o.amount from users u left join orders o on u.id = o.user_id where o.status = 1;

第二条SQL实际上把LEFT JOIN变成了INNER JOIN,没有订单或者只有已删除订单的用户会被丢掉。这个点面试必考、实战必踩。对RIGHT JOIN同理,过滤条件放ON和放WHERE的效果也不一样。

JOIN结果翻倍的根因,几乎都是一对多关联导致的结果集放大。用户表和订单表一对多,一个用户有5个订单,LEFT JOIN之后用户行就变成5行。如果还继续JOIN另一张一对多的表,结果会继续放大。排查这类问题,最简单的办法是分别看每张表在关联键下的行数,确认关联键是否唯一。如果历史原因必须关联,可以在子查询或派生表里先聚合去重再JOIN,避免直接放大。

3.2 子查询的三种形态,IN、EXISTS和JOIN怎么选

子查询按位置分三种:WHERE子查询、FROM子查询(也叫派生表)、SELECT子查询(标量子查询)。

-- WHERE子查询 select * from orders where user_id in (select id from users where status = 1); -- FROM子查询,注意派生表必须有别名 select t.user_id, count(*) from (select user_id from orders where create_time >= '2024-01-01') t group by t.user_id; -- SELECT子查询 select u.name, (select count(*) from orders o where o.user_id = u.id) as order_cnt from users u;

IN和EXISTS的选择,在MySQL 5.x时代是个经典话题。老版本里,IN适合子查询结果集小、外层表大的场景;EXISTS适合外层表小、子查询结果集大的场景。因为IN会先执行子查询生成临时表,外层逐行去临时表匹配;EXISTS是外层每行去判断子查询有没有命中。MySQL 5.6之后优化器做了大量改写,8.0会把很多IN自动转成semi join或者物化,性能差异没那么大了。但理解这个演进仍然有用:当你遇到一条SQL换一种写法性能就变好,多半是优化器被“哄”得选对了执行路径。

热搜词里那个“mysql中更新子查询”也很经典。MySQL不允许在update或delete语句的子查询里直接引用同一张目标表,会报错:You can't specify target table 't' for update in FROM clause。惯用解法是包一层派生表:

update t set status = 1 where id in ( select id from ( select id from t where status = 0 and create_time < '2024-01-01' ) tmp );

派生表必须要有别名,这个tmp就是别名,MySQL硬性要求,不写就报错。实际项目里,能用JOIN表达的关联查询,我建议尽量用JOIN,执行计划更直观、索引利用更明确,也更容易用EXPLAIN分析。子查询在部分场景会被优化器物化成临时表,临时表没有合适索引,也可能成为性能瓶颈。

3.3 学生课程成绩的经典三表关联案例

热搜词里有一个“学生课程成绩信息实体表设计mysql”,正好是练手三表关联的典型场景。三张表:student存学生,course存课程,score存成绩。表结构可以简化为:

create table student ( id int primary key, name varchar(50) ); create table course ( id int primary key, name varchar(50) ); create table score ( student_id int, course_id int, score decimal(5,2), primary key (student_id, course_id) );

查每个学生的总分和平均分,按总分降序排列:

select s.id, s.name, count(sc.student_id) as course_count, ifnull(sum(sc.score), 0) as total_score, round(ifnull(avg(sc.score), 0), 2) as avg_score from student s left join score sc on s.id = sc.student_id group by s.id, s.name order by total_score desc;

注意这里用了LEFT JOIN而不是INNER JOIN,因为要保留没有成绩的学生。count用的是student_id,和count(*)效果一样,但语义更明确:统计的是成绩条数。sum和avg外面套了ifnull,防止没有成绩的学生算出NULL。

如果还要把课程名带出来,就得再关联course表。因为student到course是多对多,score表是中间表,正确的关联路径是student先关联score,再关联course,而不是student直接和course关联,否则会产生大量无意义的笛卡尔积。这个案例能让你直观理解:关联查询不是JOIN越多越好,而是要看清楚表之间的关系和查询粒度。

4. 让查询变快,EXPLAIN、索引和慢查询日志怎么配合

4.1 B+树、聚簇索引和回表,索引优化前必须知道的三件事

InnoDB默认索引结构是B+树,数据按主键顺序存在叶子节点上,叶子节点之间用链表串联,范围查询非常高效。每个节点大小默认16KB,树高一般2到3层,这意味着查几亿行的表最多也就几次磁盘IO。这也是为什么MySQL能用索引快速定位数据。

InnoDB表的主键索引是聚簇索引,叶子节点存的是整行数据。二级索引,包括普通索引、联合索引、唯一索引,叶子节点存的是主键值。所以用二级索引查数据时,要拿着查出来的主键值再去聚簇索引查一次整行,这个过程叫回表。这就是“覆盖索引”重要的原因:如果查询的字段都在二级索引里,比如联合索引是(a, b),查询也只select a和b,那在二级索引树上就能拿到全部需要的数据,EXPLAIN里Extra会显示Using index,根本不用回表。

建索引的原则,我总结成四条:WHERE、JOIN、ORDER BY里频繁出现的列优先考虑;区分度太低的列比如性别、状态只有两三个值,单独建索引意义很小;联合索引字段顺序要把区分度高的放前面,同时考虑实际查询条件;写多读少的表索引要克制,每多一个索引,写入代价就高一份。联合索引还有个最左前缀原则,联合索引(a, b, c),能走索引的条件组合是a、a,b、a,b,c,只查b或只查c基本走不了。MySQL 8.0支持索引跳跃扫描,能在一定程度上让联合索引的中间列“跳过去”匹配,但这是优化器的附加能力,不能作为设计依据。

4.2 EXPLAIN到底怎么看,索引失效的常见场景

EXPLAIN是排查慢查询的第一工具,在SQL前面加EXPLAIN,MySQL会返回一行执行计划,不用真的执行。关键列就那几个:

含义关注点
type访问类型const > eq_ref > ref > range > index > ALL,看到ALL且表大,基本就是慢查询头号嫌疑
key实际用到的索引为NULL说明没用索引
rows优化器估算扫描行数不是精确值,但数量级能说明问题
Extra附加信息重点看Using filesort、Using temporary、Using index

Extra里出现Using filesort,说明排序没走索引,一定要警惕;Using temporary说明用了临时表,往往是GROUP BY或去重导致;Using index是覆盖索引,最理想;Using where说明过滤条件在存储引擎层之上处理。

索引失效的高频场景,我列一份自查清单:

  1. 对索引列使用函数或表达式,比如where year(create_time) = 2024
  2. 隐式类型转换,比如varchar字段直接和数字比较。
  3. 左侧模糊,like '%keyword'走不了索引,因为B+树按前缀有序,前缀不确定就没办法定位;但like 'keyword%'可以走。
  4. or连接的非索引条件,可能让优化器放弃索引。
  5. 对索引列做运算,比如where price + 1 > 100

遇到这些,先改SQL,再考虑换写法。

4.3 用慢查询日志和EXPLAIN定位一条慢SQL的实操过程

开启慢查询日志的命令:

set global slow_query_log = 1; set global long_query_time = 1;

执行后注意,long_query_time对当前会话不生效,要重开一个连接或等新连接建立。生产环境里我一般把阈值设为1秒,找到日志里那条SQL,复制出来前面加EXPLAIN,看type、rows、Extra。

说一个我实际碰到的例子。某订单列表页,按用户查最近订单,SQL很简单:

select order_id, amount, create_time from orders where user_id = 123 order by create_time desc limit 20;

数据量到500万后,这个查询耗时超过1秒。EXPLAIN一看,type是ALL,rows把全表都扫了。虽然orders表上有user_id单列索引,但排序需要额外filesort,数据分布又不均匀,优化器认为回表代价太高,干脆全表扫描。改成联合索引(user_id, create_time)之后,查询变成range,Extra不再有Using filesort,耗时从1秒降到几毫秒。这个案例说明一个道理:不要只盯“有没有索引”,要盯“索引设计是否符合查询模式”。单列索引适合等值过滤,但如果查询还有排序、范围、覆盖需求,就要考虑联合索引。

深分页优化也放一起说。limit 100000, 20这种写法慢,优化方式是延迟关联:

select o.* from orders o join ( select id from orders where user_id = 123 order by create_time desc limit 100000, 20 ) tmp on o.id = tmp.id;

子查询里只扫主键id,能最大程度走索引,回表只发生在最后取回的20行上。这个优化在数据量大时提升非常明显,是深分页场景的标配解法。

5. 把“查询”沉淀成模板和工具习惯

5.1 参数化查询与Java侧查询的工程化写法

日常开发里,查询往往不是手敲一条SQL跑完就结束,而是集成在服务端代码中。最核心的一条原则:永远不要用字符串拼接SQL。直接拼接不仅容易被SQL注入,MySQL每次拿到一条新SQL都要重新解析,性能也吃亏。用PreparedStatement或MyBatis的#{},SQL结构是固定的,参数用占位符传递,安全性和性能都照顾到了。

JDBC的写法:

PreparedStatement ps = conn.prepareStatement( "select id, name from users where status = ? and age > ? order by id desc limit ?" ); ps.setInt(1, 1); ps.setInt(2, 18); ps.setInt(3, 20); ResultSet rs = ps.executeQuery();

MyBatis的动态查询,对应热搜词里那个“java对mysql的搜索语句”,实际项目里长这样:

<select id="searchUsers" resultType="User"> select id, name, age, create_time from users <where> <if test="name != null and name != ''"> and name like concat('%', #{name}, '%') </if> <if test="minAge != null"> and age &gt;= #{minAge} </if> </where> order by create_time desc </select>

动态SQL里有个经验:排序字段不能直接拼接前端传值,必须做白名单校验。比如页面传orderBy=create_time,程序里通过Map映射成固定的列名,防止order by后面拼接用户输入导致注入,也避免前端传一个不存在的字段导致SQL报错。分页也不要每个查询手写limit,统一封装PageHelper或者自己拼limit参数,保持代码整洁。

5.2 运维和日常使用中高频的查询SQL清单

除了业务查询,运维和日常开发里还有一批查询SQL,建议记熟。查看表结构用desc users,或者查information_schema.columns;查看索引用show index from users;查看当前正在跑的会话用show processlist,如果发现长时间运行、state是Sending data的SQL,基本就是慢查询现场。

锁等待和事务问题也常见,热搜词里有“mysql锁表”。排查锁等待可以查这些系统表:

-- 查看当前正在运行的事务 select * from information_schema.innodb_trx\G -- 查看锁等待关系 select * from sys.innodb_lock_waits\G

查到阻塞源头后,kill掉对应的事务id,注意评估影响面。这些命令在Workbench和Navicat里都能直接执行,Navicat的“模型”和“查询”功能对这种排查也够用。

工具连接方面,MySQL 8.0默认的认证插件是caching_sha2_password,老版本客户端可能连不上,报错提示认证方式不受支持。这种情况要么升级客户端驱动,要么在服务端调整认证插件。如果你是在Docker里部署的MySQL,还要检查端口映射和网络模式,容器内端口默认3306,宿主机映射端口别弄混。

MySQL 8.0还有一个值得优先掌握的特性:窗口函数。分组取前N条这种查询,以前要写临时表变量,现在一条SQL搞定:

select student_id, course_id, score, row_number() over (partition by course_id order by score desc) as rn from score;

拿到rn=1就是每科最高分,rn <= 3就是每科前三名。窗口函数把很多复杂查询的写法简化了一大截,是8.0版本最值得学习的语法之一。自动备份这类运维需求,用mysqldump加计划任务就能实现,Windows下用bat脚本,Linux下用crontab,核心命令就是mysqldump -u用户 -p密码 db > backup.sql,恢复用mysql -u用户 -p密码 db < backup.sql。密码不要明文写在脚本里,至少用--defaults-extra-file把密码放进权限600的配置文件中。

我自己习惯在动手写任何一条查询前,先问三个问题:我要的粒度是什么?条件能不能命中索引?结果集会不会因为关联被放大?这三个问题想清楚,再动手写SQL,基本不会出大偏差。这套思路帮我扛过了不少线上排查的场面,希望也能变成你的肌肉记忆。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询