ARTICLE DETAIL

资讯详情

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

MySQL基本查询实战详解:从执行顺序到性能优化

MySQL基本查询实战详解:从执行顺序到性能优化 写SQL查数据这件事入门容易写好却没那么简单。很多人在MySQL表的基本查询上栽跟头不是不会写SELECT而是没搞清楚一条查询语句背后的执行逻辑、条件过滤的边界、分组聚合的语义以及排序分页在真实数据量下的表现。这篇文章不聊安装、不扯集群就聚焦在“把一张表查明白”这件事上结合我实际写SQL、调慢查询的经验从最简单的SELECT骨架讲到索引利用、跨表合并、大表分页这些躲不开的坑适合正在补基本功的开发者和刚接触MySQL的同学。1. 先读懂一条查询语句的执行顺序1.1 SELECT语法的完整形态很多新手写查询脑子里只有SELECT 字段 FROM 表这一个形状但MySQL的SELECT实际支持的子句比想象中多得多。完整的语法骨架大概是这样的SELECT [DISTINCT] 列名或表达式 FROM 表名 [INNER JOIN | LEFT JOIN 其他表 ON 连接条件] [WHERE 行级过滤条件] [GROUP BY 分组字段] [HAVING 分组后的过滤条件] [ORDER BY 排序字段 [ASC|DESC]] [LIMIT 偏移量, 行数]这个骨架是日常业务里百分之九十九的查询都会用到的。难点不在于记住每个子句能干什么而在于理解它们的执行顺序。举个非常典型的例子如果一张订单表有几千万行要统计每个客户今年的订单总金额且只保留总金额超过一万的客户很多人会把客户过滤条件一股脑塞进WHERE又把金额过滤塞进HAVING结果执行计划一团糟。正确理解顺序比死记硬背重要得多。MySQL拿到一条SELECT后大致的执行过程是这样的先根据FROM确定数据源然后通过ON和JOIN把多张表的数据拼起来接着用WHERE把行一级的条件过滤掉之后再GROUP BY分组并执行聚合计算接着用HAVING过滤分组结果再对最终结果做ORDER BY排序最后LIMIT从排序后的集合里取指定的行。很多人没意识到SELECT子句中写的列别名在执行顺序里非常靠后所以不能在WHERE或GROUP BY里直接引用别名这就是一个高频报错点。1.2 逻辑顺序和书写顺序的差异我用一个真实发生过的场景来说明这个差异。假设有一张员工表employee里面记录了员工所属部门、薪资和入职日期。我需要统计各部门的薪资总额并且只统计入职日期在2023年之后的员工最后只要总薪资超过50万的部门。第一版SQL很容易写成这样SELECT department, SUM(salary) AS total_salary FROM employee WHERE hire_date 2023-01-01 GROUP BY department HAVING total_salary 500000 ORDER BY total_salary DESC;这个SQL是正确的。但如果换一种写法把hire_date的过滤条件放到HAVING里SELECT department, SUM(salary) AS total_salary FROM employee GROUP BY department HAVING hire_date 2023-01-01 AND SUM(salary) 500000;表面区别不大实际逻辑完全不同。HAVING是在分组之后才执行的它在过滤时面对的是分组里的所有员工而不是每一行。MySQL会用整个分组里员工的hire_date来作为条件判断这既不符合需求还会把不需要的行先做聚合白白消耗CPU和内存。正确理解执行顺序之后写SQL犯这类错误的概率会大幅下降。这里还顺带说一个经验能用WHERE过滤掉的数据绝对不要留到HAVING阶段。WHERE在聚合前过滤意味着分组的数据量更小聚合效率更高。这点在千万行大表上尤其明显过滤越早扫描和计算量越小整个查询的响应时间差距能到十倍以上。2. WHERE条件过滤决定查询快慢的第一道闸门2.1 条件优先级与括号的必要性WHERE子句支持AND、OR、NOT、IN、BETWEEN、LIKE、比较运算符、子查询等一大堆操作符。优先级的规则其实不复杂NOT优先级最高其次是AND最后是OR。没有括号的时候条件会按照这个优先级来结合而不是从左到右依次计算。我见过一个报表SQL本来想查两个部门的员工或者入职满十年的老员工结果因为没有括号写成了SELECT * FROM employee WHERE department 研发部 OR department 市场部 AND hire_date 2015-01-01;这个条件的真实含义是研发部的所有员工加上市场部里入职满十年的员工。如果业务期望是“两个部门的员工同时入职满十年”那么这个查询结果就是错的。解决方式非常简单且没有任何歧义就是加括号SELECT * FROM employee WHERE (department 研发部 OR department 市场部) AND hire_date 2015-01-01;只要条件里同时存在AND和OR我强烈建议一律加括号。别去赌自己和同事的记忆力代码的可读性与正确性比少敲两个字符重要得多。2.2 NULL判断是新手最难看透的细节在MySQL里NULL不是一个值而是一个“不确定”的标记。它不等于空字符串也不等于0更不等于NULL。如果你试图用column NULL来筛选空值结果永远是空的。正确判断NULL的方式必须用IS NULL或IS NOT NULL。这一点几乎每一本SQL书都会提但没有书会提醒你在业务场景里很多数据表字段的默认值不是NULL而是空字符串两者混在一起的时候查询会频繁出现数据对不上的情况。我处理过一个客户表电话号码字段一部分是真正的NULL一部分是空字符串还有一部分是填充了默认值的0。写查询时如果不把这些情况分开讨论统计出来的缺号率就是错的。后来我总结出一个稳妥的写法先通过COALESCE函数把字段统一成可判断的形态再做过滤SELECT * FROM customer WHERE COALESCE(phone, ) ;这样不管底层存的是NULL还是空字符串都能一次性捞出来。反过来如果要查有电话的客户就写COALESCE(phone, ) 。这类看似不起眼的细节恰恰是查询结果是否可信的关键。2.3 过滤条件怎么写才不容易让索引失效WHERE条件不光影响结果正确性还直接影响查询速度。如果一张表有上千万行过滤条件走不到索引MySQL就得全表扫描查询时间会从毫秒级变成秒级甚至分钟级。最容易让索引失效的几个习惯我挨个说。第一对索引列使用函数比如WHERE YEAR(hire_date) 2024这时候即使hire_date上有索引MySQL也没法直接定位数据只能把每一行的年份算出来再比较。更好的写法是直接写成范围条件WHERE hire_date 2024-01-01 AND hire_date 2025-01-01。第二索引列参与了数值运算比如WHERE salary * 12 600000同样的道理可以把运算换到等号另一边写成WHERE salary 50000。第三使用LIKE模糊查询时如果把通配符放在最前面比如LIKE %研发%索引基本失效。如果业务允许尽量用模糊后缀比如LIKE 研发%这样还能利用索引的前缀匹配特性。顺带说一个大家都关心的话题那就是“辅助索引如何避免回表”。什么是回表简单理解就是一个辅助索引上只存了索引字段和主键值查询时如果还需要其他字段MySQL要拿着主键值回到聚簇索引的叶子节点再去读取整行数据这个过程叫回表。只要查询需要的所有字段都能在辅助索引里找到MySQL就不用回表这种状态叫“覆盖索引”。比如我建了一个辅助索引idx_department(department)但查询是SELECT department, COUNT(*) FROM employee GROUP BY department那这个查询只用到department字段走索引就够用了不需要回表速度自然快。反过来如果select里还带了salary索引里没有这个字段MySQL就必须回表取数据。理解回表机制后写查询时会有意识地去减少不必要的字段查询不是为了少打几个字符而是为了减少回表次数。3. 分组与聚合GROUP BY、聚合函数和HAVING的配合3.1 COUNT(*)和COUNT(列名)不能盲目替换分组聚合是基本查询里最容易出逻辑偏差的部分偏差往往出在细节上。拿最常见的COUNT为例COUNT(*)统计的是行数不管这行里的字段是不是NULLCOUNT(column)统计的则是该列非NULL的数量。两者看起来差不多结果可能差得远。有一张业务表字段remark允许为空。我想知道总共有多少条业务记录同时想知道有多少条记录填写了备注。这时候SELECT COUNT(*) AS total_rows, COUNT(remark) AS remark_filled FROM business_log;这种写法之所以可靠是因为COUNT(remark)天然跳过NULL正好可以用来统计非空数量。如果反过来我在写代码时随意把COUNT(*)替换成COUNT(某字段)很可能因为该字段存在NULL值而得到偏小的统计结果。聚合函数还有SUM、AVG、MAX、MIN需要记住的是AVG和SUM都会忽略NULL但不会忽略0。如果字段存在NULL求平均的时候会有一种微妙的偏差因为NULL被跳过了而不是被当成0处理。理解这个原理后遇到类似需求时可以用COALESCE把NULL先转成0再做聚合保证结果符合业务预期。3.2 HAVING的定位它是分组的过滤器很多人分不清WHERE和HAVING这里再强调一次WHERE过滤的是行HAVING过滤的是分组。WHERE在数据分组之前执行所以不能使用聚合函数HAVING在分组之后执行可以引用聚合函数的结果。举个例子统计各部门人数并且只要人数大于100的部门SELECT department, COUNT(*) AS emp_count FROM employee GROUP BY department HAVING COUNT(*) 100;把COUNT(*) 100放进WHERE里就会直接报错因为MySQL在执行WHERE时还不知道分组的存在。更值得注意的是HAVING虽然能做但能不用就尽量不用。如果这个分组后的过滤条件实际上可以用WHERE先做行级过滤查询性能会好很多。比如要统计2024年以后入职的员工按部门的分布就先把入职时间在WHERE阶段过滤掉再做分组和计数这样参与分组的数据量会小很多占用的临时表和内存也少。3.3 几千万行大表上的聚合经验聊到几千万行的大表聚合查询的优化就不是一句“加索引”能解决的了。MySQL在做GROUP BY时如果无法利用索引直接完成分组就需要创建临时表来存放分组数据数据量一大临时表可能被写到磁盘上性能断崖式下跌。实战中如果必须对超大表做分组统计我的经验是尽量缩小扫描范围。业务上几乎不存在需要每天全量统计的场景通常都有时间维度作为天然边界比如“近半年”“上个月”等。先把范围通过WHERE锁死再分组命中索引的概率也会高很多。另一个思路是利用覆盖索引来做分组这是比较实在的优化方式。比如要统计员工表各部门的人数如果有一个包含department字段的辅助索引那么SELECT department, COUNT(*) FROM employee GROUP BY department可以通过索引顺序扫描直接完成不需要临时表速度非常快。再一个被很多人忽略的点如果聚合任务实在太大可以把结果明细层先落成汇总表查询时直接查汇总表而不是每次都跑原始大表。这不是投机取巧而是数据仓库领域最常见的“以空间换时间”策略。业务系统里的统计报表不可能每一次都实时扫几千万行按天或按小时预聚合是合理且可靠的方法。4. 排序和分页ORDER BY与LIMIT里藏的坑4.1 ORDER BY到底按什么排排序看起来最简单实际最容易翻车。ORDER BY默认按升序ASC排列但字符串的排序规则取决于字段的排序规则也就是collation。在MySQL 8.0默认的utf8mb4字符集下中文字段的排序并不一定按照拼音或笔画而是按照Unicode编码顺序这一点会让很多人惊讶。对中文排序有严格要求的场景比如按部门名称的拼音排序不能直接依赖ORDER BY department可以考虑ORDER BY CONVERT(department USING gbk)这样的方式这是利用GBK编码的顺序近似拼音顺序的做法。但说实话我更建议在应用层处理这种排序需求数据库管数据正确性应用层管展示逻辑MySQL的编码转换排序在小数据量下好使大数据量下会影响索引使用。另一个常见问题多字段排序的写法。需要明确的是ORDER BY后面可以跟多个字段每个字段独立指定方向SELECT * FROM employee ORDER BY department ASC, salary DESC;这个查询先按部门升序排同一部门内再按薪资降序排。很多业务逻辑里“跨组对比”的效果就是这么实现的但要注意多个排序字段的先后顺序直接影响结果形态写之前想清楚到底哪个是主排序哪个是次排序。4.2 LIMIT分页的偏移量陷阱分页查询是基本查询的一个重要应用几乎每个后台管理系统都离不开。常规写法是这样的SELECT * FROM employee ORDER BY emp_no LIMIT 0, 20;第一页、第二页都没什么问题但页码大了之后比如要查第10000页的数据偏移量就变成了(10000-1) * 20 199980MySQL得先把前面199980行都读出来扔到结果集里再往后跳过20行返回数据。这个过程极其浪费数据量一大就是灾难。有经验的开发者会用“延迟关联”或者“基于主键范围”的方式来优化。延迟关联的意思是先从索引上取出目标主键再关联回原表取完整数据SELECT e.* FROM employee e INNER JOIN ( SELECT emp_no FROM employee ORDER BY emp_no LIMIT 199980, 20 ) t ON e.emp_no t.emp_no;这个SQL里子查询走的索引是主键或覆盖索引不需要回表读取完整行所以取20个主键的速度快得多然后外层只对20行做回表整体开销小很多。如果业务表有连续的自增主键还可以直接用WHERE emp_no 上一页最大主键 ORDER BY emp_no LIMIT 20这是性能最好的分页方式缺点是不能随意跳页只能通过下一页按钮逐页访问。4.3 排序列和索引的匹配关系ORDER BY能不能用到索引取决于排序字段是否和索引的字段顺序完全匹配。如果一个复合索引是(department, hire_date)那么ORDER BY department, hire_date可以走索引但ORDER BY hire_date, department就未必了因为索引的排列顺序是department在前hire_date在后。排序方向也要注意MySQL的索引大部分场景下是按升序存储的8.0开始支持倒序索引但使用起来依然有限制。我自己的经验是如果业务上经常要按某个时间字段倒序查看最新的记录就在这个时间字段上建单列索引查询时ORDER BY create_time DESC LIMIT 10通常能利用索引直接倒序扫描效率很高。还有一点容易被忽略ORDER BY的字段如果同时出现在WHERE条件里并且WHERE条件本身也走索引MySQL有机会通过索引本身的有序性直接避免额外排序这叫“Using index condition”配合“filesort优化”。具体的执行计划可以通过EXPLAIN查看看到Using filesort时就要警醒因为这意味着MySQL真的把数据拿出来排了一遍序数据量大时很费时间。5. 多表查询基础JOIN、UNION与跨表合并的区别5.1 INNER JOIN和LEFT JOIN的语义要抓准基本查询一旦涉及多张表就绕不开JOIN。很多初学者会机械地认为只要表之间有关联就使用LEFT JOIN这种思维定式经常导致数据翻倍。理解多表连接的关键在于JOIN是根据关联条件把一张表的行和另一张表的行“拼起来”如果右边有多行匹配左边的行就会重复出现多次。假设员工表employee里有员工所属部门编号部门表department里有部门信息。我想查出每个员工及其部门名称使用INNER JOINSELECT e.name, d.dept_name FROM employee e INNER JOIN department d ON e.dept_id d.id;这样能查到有部门信息的员工没有匹配部门的员工会被过滤掉。如果业务上需要把所有员工都列出来部门为空就显示空那就必须用LEFT JOINSELECT e.name, d.dept_name FROM employee e LEFT JOIN department d ON e.dept_id d.id;但LEFT JOIN的坑主要在于关联条件的唯一性。如果部门表里存在两个相同id的记录员工表里每个员工就会拼接出两条结果导致数据翻倍。所以在写连接查询之前要确认关联键在右边这张表上是唯一的如果业务上不唯一就要先做去重再做关联。忽略了这一点查询结果就会悄悄多出一堆重复行而且还很难察觉。5.2 UNION和UNION ALL跨表合并的正确打开方式很多人看到“跨表合并”这个词就想到JOIN但它们在语义上完全不同。JOIN是横向拼接把多张表的列合并到一起跨表合并指的是纵向合并把多张表的行拼到一个结果集里这时候要用的是UNION或UNION ALL。比如有三张月度销售报表结构完全一样都需要合并到一张结果里做年度分析SELECT order_no, amount FROM sales_january UNION ALL SELECT order_no, amount FROM sales_february UNION ALL SELECT order_no, amount FROM sales_march;UNION会对结果集自动去重所以比较慢UNION ALL则完全不去重直接合并所有行。如果业务上知道这些表里没有重复数据或者重复数据不影响统计一定要用UNION ALL因为它避免了排序去重执行速度快得多。我在实际使用中凡是做数据合并第一选择永远是UNION ALL只有明确要求结果集不能有重复时才用UNION。使用UNION还有一个细节每个SELECT的列数和顺序必须完全一致否则会报错。字段类型可以不同MySQL会做隐式转换但转换结果可能不符合预期所以最好在查询时手动通过CAST统一类型避免出现把字符串拼成数字这种低级问题。5.3 多表查询中的字段歧义与分组口径多表查询里的字段歧义是另一个高频问题。如果两张表都有name字段直接在SELECT里写name会让MySQL报错必须用表前缀或表别名限定比如e.name和d.name。在SQL语句里给表起简短别名不只是为了少打字更是为了清晰地区分每个字段的来源。分组口径在多表连接后特别容易出问题。还是拿员工和部门来举例如果要统计每个部门的薪资总和直接GROUP BY d.dept_name很直观但要注意如果部门表某一行在员工表中有很多行匹配分组时MySQL会先把所有拼接结果都放进临时表再聚合数据量大了性能很差。更合理的做法是先分别聚合员工表得到部门汇总再关联部门表补充部门名称也就是“先聚合再连接”而不是“先连接再聚合”。先聚合再连接的写法示意如下SELECT d.dept_name, t.total_salary FROM department d LEFT JOIN ( SELECT dept_id, SUM(salary) AS total_salary FROM employee GROUP BY dept_id ) t ON d.id t.dept_id;这样每个员工只会被聚合一次不会因为部门表的数据重复而错误放大统计结果。很多报表数据对不上账排查到最后都是这样的连接顺序问题。6. 查询性能自查与常见问题实录6.1 用EXPLAIN看穿一条查询的执行路径写基本查询不是写完就结束了一定要会看执行计划。在SELECT前面加EXPLAINMySQL会展示这条查询的执行路径包括用了哪个索引、扫描了多少行、是否需要临时表、是否要排序等。EXPLAIN输出里的几个关键列我实际工作中看得最多的是type、key、rows和Extra。type从好到差大致是systemconsteq_refrefrangeindexALL其中ALL就是全表扫描是性能最差的一种。看到ALL时基本可以断定这条查询该加索引了或者条件写法有问题。key列显示实际上用到的索引名如果为NULL说明没有用到索引。rows是MySQL估算要扫描的行数这个数字越大越危险。Extra里如果出现Using filesort说明排序没有用上索引出现Using temporary则说明用到了临时表这两种情况在大数据量下都是性能隐患。查看执行计划的意义在于你不需要靠猜来判断SQL写得好不好执行计划会把真相直接摆在你面前。每次写完一条稍复杂的查询随手敲一遍EXPLAIN看两个核心指标形成习惯之后写出来的SQL质量会明显提升。6.2 常见查询问题速查表与思考路径我在答疑过程中整理过一张基本查询问题对照表很多重复出现的问题都能在里面找到影子现象可能原因排查思路查询结果行数比预期多JOIN时关联键不唯一或条件缺少去重检查关联字段是否唯一考虑先分组再去重查询结果比预期少使用了INNER JOIN导致未匹配行被过滤改为LEFT JOIN同时确认过滤条件位置查询很慢但数据量不大索引失效或LIKE前置通配符EXPLAIN查看type与key改写条件格式分页越翻越慢LIMIT偏移量过大MySQL扫描过多行改为基于主键范围或延迟关联分页统计值对不上NULL被聚合函数跳过或WHERE/HAVING用错用COALESCE统一NULL检查聚合口径字段排序混乱中文字段排序依赖编码规则应用层排序或显式指定排序规则这张表里的每一个问题我在实际项目中都遇到过。它们看起来不算难但一旦出现在生产环境排查起来往往要花不少时间。与其事后Debug不如写SQL的时候就在脑子里过一遍这几个维度过滤是否最早生效、关联是否唯一、索引是否可用、排序分页是否会全表扫。6.3 三个我最想提醒的实操经验第一查询语句的每个字段尽量显式写清楚。SELECT *虽然打字少但在生产环境里害处很大一是回表次数更多二是当表结构增加字段时应用代码可能收到意料之外的数据。很多人觉得多查几个字段无所谓实际上在宽表里多出来的字段会白白占用数据库和网络IO。第二时间范围查询别用BETWEEN和LIKE解决一切。如果字段是datetime类型BETWEEN 2024-01-01 AND 2024-12-31会漏掉2024-12-31 23:59:59之后的数据所以更严谨的写法是create_time 2024-01-01 AND create_time 2025-01-01左闭右开区间永远不会漏边界数据。第三对基本查询做任何改动之前先备份原始SQL结果。这个建议听起来很基础但实际开发中我见过太多人因为改了一个过滤条件导致线上报表数字变动却找不到是谁改的。把验证前后的结果集行数、关键字段汇总值都记录一下能避免绝大多数低级翻车事故。写查询这件事本质上是把业务逻辑翻译成一套有序的、可预测的计算流程。理解SELECT的执行顺序把WHERE、GROUP BY、JOIN、ORDER BY这些基础子句的语义吃透再配合执行计划验证性能基本查询就不会成为业务系统的短板。如果非要给一点个人体会那就是不要小看任何一条“简单查询”它就是整个数据体系的根基根基扎实了后面做再复杂的功能都不怕。
返回列表