ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL查询优化实战:从执行计划到索引设计,解决慢查询与多表JOIN难题

MySQL查询优化实战:从执行计划到索引设计,解决慢查询与多表JOIN难题 经常有人问我说自己在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 NULL或IS NOT NULL。这是经典错误但老手也会栽因为有时查询结果少了几行一看SQL发现条件里用了 而实际数据是NULL两者根本不相等。第二个坑是隐式类型转换。MySQL会自动把“看起来是数字的字符串”转成数字比较反之亦然这非常危险。看这个例子where phone 13800138000如果phone字段是varchar类型MySQL会把phone从字符串转成数字再去比较转换后索引失效全表扫描甚至可能因为精度问题匹配到错误数据。手机号、订单号这类长数字查询参数一律用字符串传不要图省事写数字。字符集问题也很隐蔽。不同表甚至同一张表不同字段的字符集不一致比如utf8和utf8mb4混用关联或比较时可能乱码或匹配不上。utf8mb4才是完整的UTF-8emoji和生僻字必须用它新库建议直接统一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结果天差地别。看这两条-- 保留所有用户只有status1的订单行会关联上来 select u.*, o.amount from users u left join orders o on u.id o.user_id and o.status 1; -- 先LEFT JOIN出所有用户订单再用WHERE把status1以外的行过滤掉 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 cant 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前面加EXPLAINMySQL会返回一行执行计划不用真的执行。关键列就那几个列含义关注点type访问类型const eq_ref ref range index ALL看到ALL且表大基本就是慢查询头号嫌疑key实际用到的索引为NULL说明没用索引rows优化器估算扫描行数不是精确值但数量级能说明问题Extra附加信息重点看Using filesort、Using temporary、Using indexExtra里出现Using filesort说明排序没走索引一定要警惕Using temporary说明用了临时表往往是GROUP BY或去重导致Using index是覆盖索引最理想Using where说明过滤条件在存储引擎层之上处理。索引失效的高频场景我列一份自查清单对索引列使用函数或表达式比如where year(create_time) 2024。隐式类型转换比如varchar字段直接和数字比较。左侧模糊like %keyword走不了索引因为B树按前缀有序前缀不确定就没办法定位但like keyword%可以走。or连接的非索引条件可能让优化器放弃索引。对索引列做运算比如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是ALLrows把全表都扫了。虽然orders表上有user_id单列索引但排序需要额外filesort数据分布又不均匀优化器认为回表代价太高干脆全表扫描。改成联合索引(user_id, create_time)之后查询变成rangeExtra不再有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 idsearchUsers resultTypeUser select id, name, age, create_time from users where if testname ! null and name ! and name like concat(%, #{name}, %) /if if testminAge ! null and age gt; #{minAge} /if /where order by create_time desc /select动态SQL里有个经验排序字段不能直接拼接前端传值必须做白名单校验。比如页面传orderBycreate_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;拿到rn1就是每科最高分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基本不会出大偏差。这套思路帮我扛过了不少线上排查的场面希望也能变成你的肌肉记忆。
返回列表