连续写了五篇 MySQL 表操作的教程,今天这篇轮到基本查询的核心部分。前面我们聊过建表、字段类型和插入数据,好多朋友已经能把数据塞进去,但一到查询这里,满屏查询语法在眼前飘,真正落地时总是各种不顺手。其实 MySQL 表的基本查询,真正要理解的东西并不多,但它的威力恰恰体现在“看似基础”的写法上:同样的结果,写法不同,在数据量上来之后可能是毫秒和分钟的差别。这篇文章我会结合这些年排查报表慢查询的经验,把 SELECT 执行顺序、WHERE 条件与索引、GROUP BY 聚合、JOIN/UNION/子查询选择、排序与分页这几块完整过一遍。适合刚学完增删改、准备系统掌握查询的读者,也适合写了几年 SQL 但想回头补一补底层逻辑的朋友。
1. SELECT 执行顺序:先搞清楚 SQL 是“怎么想”的
我刚带新人那会儿,经常看到有人写出这样的 SQL:先查出一个临时结果,再在外面包一层去筛选。写起来绕,跑起来还慢。根子在于大家习惯按书写顺序理解 SQL,以为 SELECT 先执行、WHERE 后执行。实际上服务端拿到一条 SQL 之后,大脑完全不是按你打字的顺序运作的。
1.1 真实执行顺序不是书写顺序
一条完整的 SELECT 查询,MySQL 内部的执行顺序大致是这样的:
| 顺序 | 执行阶段 | 对应子句 | 作用 |
|---|---|---|---|
| 1 | 确定数据来源 | FROM / JOIN | 从哪张表取数,先做表的关联,生成中间结果集 |
| 2 | 行级过滤 | WHERE | 对中间结果集的每一行做条件判断,过滤掉不满足的行 |
| 3 | 分组 | GROUP BY | 按指定列把行聚合成组 |
| 4 | 聚合函数计算 | 聚合函数 | 在组内执行 SUM、COUNT、AVG 等计算 |
| 5 | 组级过滤 | HAVING | 对分组聚合后的结果做条件过滤 |
| 6 | 投影/表达式计算 | SELECT | 计算目标列、别名、表达式,生成最终结果字段 |
| 7 | 去重 | DISTINCT | 对最终结果去重 |
| 8 | 排序 | ORDER BY | 对结果排序 |
| 9 | 行数限制 | LIMIT / OFFSET | 截取指定行数,完成分页 |
很多人看这个表觉得“记不住也没关系”,但下面这几个问题都会从这张表里找到答案:为什么 WHERE 里不能用 SELECT 里定义的别名?为什么 HAVING 能用聚合函数而 WHERE 不能?为什么 GROUP BY 的排序结果不稳定?理解了执行顺序,这些坑都能少踩一大半。
1.2 WHERE 与别名冲突:一个经典报错场景
我经常用下面这个例子给新人讲“别名在何时生效”:
-- 这段 SQL 会报错 SELECT id, price * quantity AS total FROM order_items WHERE total > 500;报错信息大概是 Unknown column 'total' in 'where clause'。原因很简单:WHERE 在执行的第二步,而 SELECT 别名是在第六步才生成的。WHERE 阶段压根还没见到 total 这个字段,自然无法引用。正确写法是把表达式在 WHERE 里重写一遍:
SELECT id, price * quantity AS total FROM order_items WHERE price * quantity > 500;但 ORDER BY 就可以用别名,因为排序发生在 SELECT 投影之后,字段已经生成:
SELECT id, price * quantity AS total FROM order_items ORDER BY total DESC;这个差异不是 MySQL 故意为难人,而是 SQL 标准的执行模型决定的。你在日常开发里如果经常写子查询过滤别名,那就说明还没吃透这条顺序链路。
1.3 执行顺序对窗口函数的影响
MySQL 8.0 之后窗口函数越来越常用。窗口函数(比如 ROW_NUMBER、RANK、SUM OVER)是在 SELECT 阶段计算的,所以 WHERE 阶段不能用窗口函数的结果过滤。如果你要“按窗口函数排名取前 N”,必须套一层子查询:
SELECT * FROM ( SELECT user_id, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) AS rn FROM orders ) t WHERE rn = 1;这也是为什么我会把执行顺序放在查询系列第六篇的第一节来讲。后面所有分组、去重、分页、排序优化,本质上都是围绕这张顺序表在转。先把这个顺序焊死在脑子里,再往下看条件过滤和索引利用,你会突然觉得很多东西都串起来了。
2. WHERE 条件里的细节:等于、IN、LIKE 和 NULL 的取舍
WHERE 是查询里最朴素、最常写的子句,但恰恰是最容易被优化器“悄悄改变计划”的地方。很多慢查询不是表太大,而是 WHERE 条件写得太随意,直接让索引失效。这一节我把日常工作中最典型的几个坑逐一拆开。
2.1 NULL 判断:千万别用 “= NULL”
接触过几个转行来的朋友,写筛选条件时第一反应是WHERE score = NULL,然后查出来的结果是空集,也不报错,就很困惑。原因在于 SQL 里 NULL 不是一个值,而是一种“未知”状态。任何 NULL 参与的比较运算结果都不是 TRUE,而是一个不确定结果,所以score = NULL永远不会命中行。
正确写法只有两种:
-- 判断是 NULL SELECT * FROM t WHERE score IS NULL; -- 判断非 NULL SELECT * FROM t WHERE score IS NOT NULL;我见过最隐蔽的 NULL 问题出现在复合条件里:比如统计订单金额时,WHERE discount = 0 OR discount IS NULL和WHERE discount = 0的结果可能差很多。前者把没有折扣信息的单子算进来,后者只算明确无折扣的单子。业务含义完全不同,写之前一定要确认清楚“你没填”和“你填了0”是两种状态。
2.2 IN、BETWEEN 与隐式类型转换
IN 和 BETWEEN 是查询高频写法,但有几个细节值得注意。
首先是类型问题。假设手机号存在 varchar 字段里,你用数字去匹配:
SELECT * FROM users WHERE phone = 13800001111;MySQL 会尝试把字段类型转换成数值再比较,这个隐式转换会让 phone 字段上的索引失效。正确写法是带上引号:
SELECT * FROM users WHERE phone = '13800001111';BETWEEN 是包含边界的,WHERE age BETWEEN 18 AND 30等价于age >= 18 AND age <= 30,这和许多编程语言里“左闭右开”的习惯不太一样。如果你要写“大于等于18且小于30”,千万别用 BETWEEN 18 AND 29 这种取巧方式,直接写成age >= 18 AND age < 30更清晰。
2.3 LIKE 前缀匹配与模糊搜索
LIKE 可能是索引优化里最常聊的话题。三句话概括:
LIKE 'abc%':前缀固定,索引可以从根节点往下定位,一般能走索引。LIKE '%abc':后缀匹配,索引最左侧部分未知,通常无法走索引。LIKE '%abc%':前后都模糊,基本只能全表扫描。
我处理过一个几千万行大表的搜索需求,业务方想在商品名里模糊找关键词,直接WHERE name LIKE '%蓝牙%',扫一次全表要几十秒。后来把方案改成两段式:先按首字拼音或前缀建冗余字段做前缀过滤,再用 LIKE 缩小结果集;更极端的场景直接引入全文索引或者搜索引擎。基本查询虽然支持 LIKE,但你要明白它在底层是“怎么跑的”,才能知道什么时候改表结构、什么时候上外部组件。
2.4 覆盖索引与回表:大表查询的关键收益
搜索热词里一直有“辅助索引如何避免回表”,这其实和 WHERE 条件密切相关。InnoDB 的辅助索引叶子节点存的是索引列 + 主键值,如果你查询的字段恰好都在辅助索引里,MySQL 读完索引就能直接返回结果,不需要再拿着主键回主表取其他列。
比如有复合索引idx(user_id, order_time),下面这条查询:
SELECT user_id, order_time FROM orders WHERE user_id = 100;索引覆盖,整个过程都在辅助索引这颗 B+ 树上完成,不需要回表。但如果 SELECT 后面加上order_amount,而order_amount不在索引里,MySQL 就得对每个命中的索引条目回表取一次数据。在数据量小的时候感受不到,到了千万级,回表次数直接变成磁盘随机读次数,性能差距可能是几个数量级。
所以写 WHERE 之前,先看看 SELECT 出来的是哪些列,再判断这条查询到底是在“索引里挑数据”还是“索引定位后再回表搬数据”,这个思考习惯能帮你提前发现很多慢查询隐患。
3. GROUP BY 聚合与 HAVING 过滤:分组查询不是“先查出来再分组”
GROUP BY 看起来好像很简单:把相同值的行放一起,然后对每组做聚合。但它踩坑的点非常集中,而且一旦出错往往是“逻辑错了但 SQL 没报错”,新手很难发现自己写错了。
3.1 COUNT(*) 和 COUNT(字段) 不是一回事
写聚合查询时我见过太多人混用 COUNT。两者差别就一句:COUNT(*) 统计的是行数,包括 NULL 值行;COUNT(字段) 统计的是该字段非 NULL 的行数。
-- 统计总行数 SELECT COUNT(*) FROM orders; -- 统计有优惠券编号的订单数,可能比上面小 SELECT COUNT(coupon_id) FROM orders;如果业务上要统计“有优惠信息的订单”,第二个写法才正确。COUNT(DISTINCT 字段) 是去重统计,比如统计有多少个不同用户下单:
SELECT COUNT(DISTINCT user_id) FROM orders;这三个函数在热词里经常一起出现,真要分清适用场景,最简单的办法是拿一条包含 NULL 的记录去试一遍结果,你立刻能感受到差异。
3.2 ONLY_FULL_GROUP_BY 模式下的列选择限制
MySQL 5.7 之后默认开启ONLY_FULL_GROUP_BYSQL 模式,在这个模式下,SELECT 后面的非聚合列必须出现在 GROUP BY 子句里。我见过一个真实项目,有人想查每个部门工资最高的人,写成了:
SELECT department_id, employee_name, MAX(salary) FROM employees GROUP BY department_id;在关闭 ONLY_FULL_GROUP_BY 的旧环境里能跑出来,而且返回的 employee_name 是“不确定的某个人”。到了默认开启的 MySQL 5.7 上直接报错。其实数据库是对的:同一个 department_id 可能有多个人,MAX(salary) 返回的是一个聚合值,那 employee_name 到底对应哪一行?数据库没有义务帮你做这个决定,所以直接拒绝执行。
这类需求的正解是用子查询或窗口函数:先查每个部门的最高工资,再回原表匹配员工姓名,或者用 ROW_NUMBER() 按部门分组排序后取第一条。没有第三种“看似聪明、实则拼运气”的写法。
3.3 先 WHERE 再 GROUP BY,还是先 GROUP BY 再 HAVING?
执行顺序已经告诉我们,WHERE 发生在分组之前,HAVING 发生在分组之后。这意味着:能用 WHERE 过滤的数据,尽量在分组前干掉,因为分组处理的行数越少越快。
举个统计场景:要查 2024 年入职员工里平均工资大于 10000 的部门。错误但常见的写法是把所有部门先分组,再在 HAVING 里过滤入职年份:
SELECT department_id, AVG(salary) FROM employees GROUP BY department_id HAVING AVG(salary) > 10000 AND MIN(hire_date) >= '2024-01-01';这个写法不是不行,而是让 MySQL 必须先对所有部门做聚合,再判断年份条件,白白浪费大量计算。正确姿势:
SELECT department_id, AVG(salary) FROM employees WHERE hire_date >= '2024-01-01' GROUP BY department_id HAVING AVG(salary) > 10000;WHERE 提前把 2024 年之前的数据扔掉了,参与分组和聚合的行数大幅减少。一句话原则:能前移的过滤条件绝不放后面,尤其 HAVING 只负责聚合后的组级条件。
3.4 每组最新一条的两种写法
订单系统、日志系统里几乎天天遇到“取每个用户最近一笔订单”这种需求。最直观的子查询写法是:
SELECT o.* FROM orders o JOIN ( SELECT user_id, MAX(order_time) AS max_time FROM orders GROUP BY user_id ) t ON o.user_id = t.user_id AND o.order_time = t.max_time;但如果同一个用户在同一个秒数下了多单,这个写法会返回多行,还需要额外加唯一性约束。另一种更稳妥的写法是用窗口函数:
SELECT * FROM ( SELECT o.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) AS rn FROM orders o ) t WHERE rn = 1;窗口函数把“组内排序”的责任交给数据库,逻辑上更贴近业务意图。MySQL 8.0 之前的版本没有窗口函数,所以子查询 JOIN 仍然是兼容方案,但 8.0 之后我强烈推荐优先用窗口函数,代码可读性强,也少踩“时间字段重复”的坑。
4. JOIN、UNION 与子查询:跨表合并数据的选型逻辑
搜索热词里有“跨表合并”,恰恰是查询里最容易绕晕的部分。我经常在答疑时收到类似问题:“两个表的数据要合在一起,用 JOIN 还是 UNION?为什么我用了 JOIN 结果行数暴涨?”这一节把几个主要合并方式的边界理清楚。
4.1 JOIN 和 UNION 的本质区别
很多人把 JOIN 和 UNION 混为一谈,因为感觉“都是把表连起来”。其实方向完全不同:
| 操作 | 方向 | 行数影响 | 列数影响 | 典型场景 |
|---|---|---|---|---|
| JOIN | 横向扩展 | 可能增多,可能不变 | 增加另一张表的列 | 订单表关联用户表,取用户姓名 |
| UNION / UNION ALL | 纵向扩展 | 增加行数 | 列数不变,要求列结构一致 | 合并一月份和二月份的销售记录 |
如果你要把两个结构相同的查询结果“拼成一张长表”,用 UNION。如果你要把两张不同结构的表“按某个关联键放在一行里”,用 JOIN。这个方向感先对了,后面写起来就不会别扭。
4.2 LEFT JOIN 里 ON 和 WHERE 的经典陷阱
我最常拿出来讲的项目经验,是 LEFT JOIN 中 ON 和 WHERE 位置不同导致结果完全错误。看这条查询:
SELECT u.id, u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.status = 1;表面意思是“查所有用户及其状态为 1 的订单”。但实际执行时,WHERE 条件在 JOIN 完成之后对整行做过滤,如果一个用户只有状态为 0 的订单,这个用户的订单行会被过滤掉;如果这个用户没有任何订单,LEFT JOIN 后右表是 NULL,WHEREo.status = 1对 NULL 判断不为真,整行也被过滤掉。最终结果和 INNER JOIN 没区别,Left Join 的左表保底语义完全丢失。
想要保留全部用户,把过滤条件挪到 ON 里:
SELECT u.id, u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.status = 1 ORDER BY u.id;这个差异我在实际报表排查里至少遇到五六次,每次的现场都是“数据好像少了一批,又好像没错”,非常迷惑。记住:LEFT JOIN 时右表的过滤条件写在 ON 里才不改变连接的主表语义。
4.3 UNION 与 UNION ALL 的去重开销
UNION 默认执行去重,而去重这个动作背后要么是排序、要么是哈希操作,有额外代价。UNION ALL 直接把两段结果拼接,不做任何去重。
如果两段查询结果不可能重复,比如按日期分区、按业务类型拆分的表,果断用 UNION ALL。就算可能重复,也要先估算重复量:重复率极低,用 UNION ALL 后在应用层做一次去重,往往比数据库层排序去重更快。我遇到过一个跨年报表,两段子查询按年份分开,年份之间主键根本不可能相同,业务却默认写了 UNION,白白多跑了好几次排序。改成 UNION ALL 后,查询时间直接掉到原来的三分之一。
4.4 IN、EXISTS 与 JOIN:别盲信“IN 一定慢”
早年网上有个传播很广的说法:子查询用 IN 慢,应该改成 EXISTS。这个结论在特定版本、特定数据分布下成立,但 MySQL 优化器早就做了很多改写,不能一概而论。
我自己的经验是分场景判断:
- 子查询结果集非常小(比如就几十个 id),用 IN 直观且足够快。
- 外层表大、子查询小,EXISTS 能让优化器先查外层表每条记录时做快速判断。
- 两张表都大,且关联查询需要返回大量字段,JOIN 往往更清晰,必要的时候配合 DISTINCT 或去重逻辑。
真正的坑不是用哪个,而是子查询里忘记加过滤条件,导致子句返回了百万级数据集,不管 IN 还是 EXISTS 都会变成灾难。写跨表查询前先单独跑一遍子查询,看返回行数,是最简单却最有效的习惯。
4.5 大表 JOIN 的现代优化:哈希连接与预过滤
MySQL 8.0.18 之后引入了哈希连接,等值关联的场景下优化器可能自动选择 hash join,不再像以前那样只依赖索引嵌套循环。但这不代表你就可以在大表上随意 JOIN。搜索热词里提到的“哈希表”“几千万行大表”,落到实操上还是那几句:能提前过滤就提前过滤,能在子查询里缩小范围就缩小范围。
我的一个常见做法是:两个几千万行的大表做 JOIN 前,先各自执行一次 WHERE 过滤,把一个月前的数据、无效状态数据扔掉,再让两个小结果集做关联:
SELECT * FROM ( SELECT * FROM orders WHERE status = 1 AND create_time >= '2025-01-01' ) a JOIN ( SELECT * FROM users WHERE is_deleted = 0 ) b ON a.user_id = b.id;虽然 MySQL 优化器本身也会尽量下推条件,但这种显式书写在排查问题时更直观,也能避免因为统计信息失真导致优化器误判。查询先是给人读的,其次才是给机器优化的。
5. 排序和分页在大表上的实战:深分页为什么慢,怎么优化
基本查询里“查出来再排序、再分页”看似无脑,实际是慢查询重灾区。尤其是搜索热词里反复出现的“mysql排序”和“几千万行大表”,叠加在一起时,你会发现绝大多数问题都出在ORDER BY配合LIMIT写得太随意。
5.1 ORDER BY 背后的 filesort 机制
当 MySQL 无法用索引直接满足排序时,就会触发 filesort。注意“filesort”不一定表示用了磁盘文件,它是“排序操作”的统称,内存里排得下就在内存排,排不下才写临时文件。判断方式很简单,执行 EXPLAIN,Extra 列出现 Using filesort 就说明排序没走索引。
举个例子,联合索引idx(user_id, order_time):
-- 能利用索引排序:等值条件在前,排序字段在后 SELECT * FROM orders WHERE user_id = 100 ORDER BY order_time DESC; -- 无法利用索引排序:排序字段不是索引最左前缀 SELECT * FROM orders WHERE order_time > '2025-01-01' ORDER BY user_id;索引是 B+ 树结构,只有排序方向和索引方向一致才能“边读边有序”。如果你有几百行的小表,filesort 根本无所谓;到了千万行,一次 filesort 就可能拖垮接口。设计索引时心里要有一个顺口溜:等值条件列放前面,排序列紧跟其后,最后再加需要查询覆盖的列。
5.2 深分页:LIMIT 越深越慢
“深分页”是基本查询里最容易被低估的问题。写过LIMIT 500000, 20的朋友应该都有体会:第一页秒开,翻到几十万页的时候卡到怀疑人生。原因是 MySQL 必须先按 ORDER BY 找到排好序的前 500020 行,然后丢弃前 500000 行,只返回最后 20 行。这些被丢弃的行依然要真实地取出来、参与排序,OFFSET 越大,白白消耗的资源越多。
我接手过一个报表接口,线上用户翻到第 200 页时响应时间飙到 8 秒。用 EXPLAIN 一看,扫描行数已经接近两百万。这种问题不是加索引能解决的,索引帮助排序,但排序后的前 N 行它依然要一行行跳过去。
5.3 延迟关联:先查主键,再回原表取列
一个通用优化手段叫延迟关联,想法很简单:排序分页阶段不要拖上整行大字段,先在索引里把主键查出来,跳过大量无关行之后,再拿主键回原表取完整数据。
SELECT t1.* FROM orders t1 JOIN ( SELECT id FROM orders WHERE status = 1 ORDER BY id LIMIT 100000, 20 ) t2 ON t1.id = t2.id;子查询里 SELECT 的只有主键,排序和跳过行的过程在索引上进行,数据体积小很多。等到确定最终要返回哪 20 行主键,再关联回原表取整行,瞬间把“搬运两百万行”变成“搬运 20 行”,响应速度提升非常明显。
这个写法尤其适合表格里有很多冗余字段、大字段(备注、JSON、长文本)的场景。如果 SELECT 列本身就在覆盖索引里,连延迟关联都不需要,直接返回即可。
5.4 游标分页:放弃 OFFSET,用 WHERE 定位
延迟关联可以降低单次查询成本,但依旧没解决“每次都要跳很多行”的本质。更彻底的方案是游标分页,也就是不依赖 OFFSET,而是记住上一页最后一条数据的位置,下一页直接用 WHERE 把范围往后推:
-- 第一页 SELECT * FROM orders ORDER BY id LIMIT 20; -- 第二页:以上一页最后一条 id 作为游标 SELECT * FROM orders WHERE id > 10020 ORDER BY id LIMIT 20;这里有个前提:排序字段必须唯一。如果只按order_time排序,而同一时间有多条记录,分页边界就可能出现重复或遗漏。稳妥做法是排序字段加主键组合游标:
SELECT * FROM orders WHERE (order_time, id) > ('2025-06-01 10:00:00', 10020) ORDER BY order_time, id LIMIT 20;游标分页的局限是用户不能随意跳页,只能一页一页往下翻。恰好绝大多数“翻页报表”场景就是连续翻页,这个方案几乎完美。我前面提到的那次 8 秒优化,最终就是改成了游标分页,接口稳定在几十毫秒。
5.5 排序设计不是越多越好
聊到最后要泼一盆冷水:不是所有排序都必须走索引,也不是所有常用查询字段都要建索引。索引本身占空间、拖慢写入,建多了是负担。我的经验是三个条件同时满足才专门为 ORDER BY 建索引:查询频率高、数据量大、排序列有清晰的业务含义。如果只是偶尔跑一次统计报表,数据量又不大,filesort 反而更省事,因为你不用为一次低频查询付出持续的写入代价。
排序和分页是基本查询里最接近“系统设计”的部分,它考验的不是语法熟不熟练,而是你能不能从磁盘和索引的角度去想象数据是怎么流动的。多拿真实大表做几次 EXPLAIN,比背一百条优化口诀都有用。