这个系列写到现在,终于到了最硬核的一讲。前面几讲咱们把建库建表、INSERT、UPDATE、DELETE,还有最基础的单表SELECT都过了一遍,能应付日常八九成的开发需求。但一旦碰上要拉报表、统计用户订单、从多张业务表里拼数据,单表查询就完全不够用了。这一讲我打算把MySQL数据操纵语句里的复杂查询一次讲透,重点覆盖JOIN连接、子查询、聚合分组、排序分页,以及怎么用EXPLAIN定位慢查询。无论你是写着写着就卡壳的开发同学,还是正在刷MySQL面试题、想把线上慢SQL搞明白的运维,这篇都能给你一份可以直接照着做的实战笔记。
我会用一套电商订单数据贯穿全文,每段SQL都保证你能在自己的机器上跑出来。数据库这东西光看不练是记不住的,所以强烈建议你一边看一边敲,遇到问题再看文末的避坑速查表,比死记硬背语法高效得多。
1. 先搞清楚复杂查询到底在解决什么问题
1.1 什么是真正意义上的“复杂查询”
复杂查询不等于“SQL语句写得很长、很绕”,而是指一个查询结果需要从多张表、多个条件、多个维度中推导出来。举个例子:你在管理后台想看一眼“每个用户最近一个已支付订单的金额和商品明细”,这条需求单靠一张表根本完成不了,它涉及用户表、订单表、订单明细表、商品表四张表,还要做过滤、排序、去重、关联。这样一条SELECT语句,才配叫复杂查询。
再说透一点,复杂查询通常由四类能力叠加而成:多表连接(JOIN)、子查询(Subquery)、聚合分组(GROUP BY)、排序分页(ORDER BY + LIMIT)。这些能力单个拿出来都不难,难的是它们组合在一起时,你会不会搞混连接条件、搞错过滤时机、写出一个逻辑正确但性能极差的语句。所以这一讲咱们不只讲语法,更会把每个动作背后的执行逻辑讲明白。
1.2 一套能反复练习的示例数据
为了后面每个案例都能直接跑,我这里先建一套最小可用的电商表结构,推荐你也照着建一份,后面所有SQL你都亲手执行一遍。
CREATE TABLE users ( id INT PRIMARY KEY, name VARCHAR(50), city VARCHAR(20) ); CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, amount DECIMAL(10,2), status VARCHAR(10), created_at DATETIME ); CREATE TABLE order_items ( id INT PRIMARY KEY, order_id INT, product_id INT, quantity INT, price DECIMAL(10,2) ); CREATE TABLE products ( id INT PRIMARY KEY, name VARCHAR(50), category VARCHAR(20), price DECIMAL(10,2) );再插入几条有代表性的数据:
INSERT INTO users VALUES (1, '张三', '杭州'), (2, '李四', '北京'), (3, '王五', '上海'), (4, '赵六', '深圳'); INSERT INTO orders VALUES (101, 1, 156.00, 'paid', '2024-05-01 10:00:00'), (102, 2, 89.50, 'paid', '2024-05-02 11:30:00'), (103, 1, 200.00, 'pending', '2024-05-03 09:15:00'), (104, 3, 320.00, 'paid', '2024-05-04 14:20:00'), (105, 2, 450.00, 'closed', '2024-05-05 16:45:00'); INSERT INTO order_items VALUES (1001, 101, 1, 2, 50.00), (1002, 101, 2, 1, 56.00), (1003, 102, 3, 1, 89.50), (1004, 104, 1, 4, 50.00), (1005, 104, 4, 2, 60.00); INSERT INTO products VALUES (1, '机械键盘', '外设', 50.00), (2, '游戏鼠标', '外设', 56.00), (3, '显示器支架', '配件', 89.50), (4, 'USB扩展坞', '配件', 60.00);注意,这里故意让赵六没有订单,让王五的订单没有明细,留点“脏数据”给后面的JOIN案例当陪练,这样各种边界情况都覆盖到了。
1.3 学习路径怎么安排
我自己的经验是,学复杂查询不要一上来就背各种语法,要按“从取数到分析再到调优”的顺序来。先学会JOIN,因为绝大多数复杂查询都绕不开多表关联;再学子查询,它能帮你实现JOIN不太好表达的“嵌套逻辑”;接着是聚合分组,这是做统计报表的必修课;最后才是排序分页和性能优化。把这条主线走完,你再看别人写的复杂SQL,一眼就能拆出它的骨架。
2. 多表连接JOIN:复杂查询的基石
2.1 连接的底层逻辑:从笛卡尔积说起
很多新手不理解JOIN为什么能查出数据,其实就是高中排列组合里的笛卡尔积。两张表没有任何连接条件时做JOIN,会把左表的每一行和右表的每一行都组合一遍,得到一个行数等于两者乘积的巨大结果集。比如users有4行、orders有5行,直接“SELECT * FROM users JOIN orders”,结果就是20行。
我见过不少人用JOIN时疯狂卡顿,就是因为忘了写ON条件,把两张十多万行的表做了一次笛卡尔积,生产环境直接打满CPU。所以你要在脑子里建立一个观念:JOIN是先做“配对”,再用ON条件筛掉不需要的配对。ON后面写的关联条件,本质就是“这两行能合成一行”的规则。
2.2 三种常用JOIN对比
MySQL里最常用的连接类型是INNER JOIN、LEFT JOIN、RIGHT JOIN,它们处理“左表有记录但右表没有匹配”的方式完全不同。
| 连接类型 | 返回结果 | 典型场景 |
|---|---|---|
| INNER JOIN | 只返回两表能匹配上的行 | 订单表和用户表都有记录的关联查询 |
| LEFT JOIN | 返回左表全部行,右表没有匹配则补NULL | 查所有用户及其订单,没下过单的也保留 |
| RIGHT JOIN | 返回右表全部行,左表没有匹配则补NULL | 和LEFT JOIN对称,实际用得少 |
你可能会问,为什么RIGHT JOIN用得少?因为写SQL时大家习惯把“主表”放在左边,把“要补全的表”放左边就写LEFT JOIN,所以就少用RIGHT JOIN了。从执行结果看,RIGHT JOIN完全可以通过交换表顺序改写成LEFT JOIN,没必要给自己增加阅读负担。
2.3 实战:查出每个订单对应的用户信息
咱们直接上手跑一个最简单的连接,把订单表跟用户表关联起来,拿到每笔订单的下单人姓名和城市。
SELECT o.id AS order_id, o.amount, u.name, u.city FROM orders o INNER JOIN users u ON o.user_id = u.id;这条SQL的执行过程我拆开给你讲:第一步,先从orders表读出一行;第二步,拿这一行的user_id去users表里找id相等的那一行;第三步,找到就把两行拼接起来,放进结果集;找不到就跳过。所以查出来是5条数据,因为orders表里只有1、2、3号用户的下单记录,赵六压根没订单,自然不会被INNER JOIN带出来。
2.4 最容易踩的坑:LEFT JOIN中ON和WHERE的区别
如果你想把“所有用户”都列出来,包括没下过单的用户,就要用LEFT JOIN保留左表的全部行。这个大家都能理解,但坑往往出在过滤条件的摆放位置。
假设我想查所有用户,顺便把他们的已支付订单也显示出来:
-- 推荐写法:把“已支付”条件放在ON里 SELECT u.name, o.id AS order_id, o.amount FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';这样写,没下过单的赵六依然会出现在结果里,只是订单字段都是NULL。因为ON条件是在连接阶段使用的,它决定“哪些右表行可以拼进左表行”,不会把左表行本身过滤掉。
但如果你把status条件挪到WHERE里:
SELECT u.name, o.id AS order_id, o.amount FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.status = 'paid';结果会怎样?LEFT JOIN先把所有用户都保存下来,但WHERE是在连接完成之后对整行统一过滤。赵六因为orders字段全是NULL,o.status也等于NULL,NULL = 'paid'不成立,整行被滤掉,LEFT JOIN就悄悄退化成了INNER JOIN。这是实战中最容易出问题的地方,很多报表数据莫名变少,十有八九都是这个原因。
2.5 三张表以上的连接怎么想
真实业务很少只有两张表关联。比如你要看“每笔订单都买了哪些商品,以及下单人是谁”,就得users、orders、order_items、products四张表一起JOIN。我的建议是别想一步到位,每次只加一张表,像搭积木一样一段段拼起来:
SELECT o.id AS order_id, u.name AS user_name, p.name AS product_name, oi.quantity, oi.price FROM orders o JOIN users u ON o.user_id = u.id JOIN order_items oi ON oi.order_id = o.id JOIN products p ON p.id = oi.product_id;这里有个很容易被忽视的细节:orders和order_items是一对多关系,一个订单可能有多条明细,所以JOIN之后结果集会“膨胀”——订单101会出现两行,分别对应它买的两件商品。如果你同时还把用户也JOIN进来,人数不变,还是2行,因为用户和订单是一对一对应关系。但如果你在order_items这层先JOIN错了方向,行数就会翻倍,统计金额时直接算错。这是很多人写多表JOIN后SUM结果对不上的真正原因。
3. 子查询:把一条SQL当成一个“变量”来用
3.1 子查询的三种形态
子查询说白了就是“SQL里套SQL”,内层查询的结果当作外层查询的输入。按返回结果的不同,可以分成三种形态:
| 形态 | 返回值 | 语法特征 |
|---|---|---|
| 标量子查询 | 单个值 | 放在SELECT列表或WHERE比较条件里 |
| 行子查询 | 一行多列 | 配合IN、比较运算符 |
| 表子查询 | 多行多列 | 放在FROM后面当派生表,或配合IN/EXISTS |
标量子查询最直观,比如我想在订单列表里顺便展示下单人所在城市,可以在SELECT后面直接写一个“只能返回一行一列”的子查询:
SELECT o.id, o.amount, (SELECT u.city FROM users u WHERE u.id = o.user_id) AS user_city FROM orders o;这里每一行外层查询都会执行一次内层查询,取出对应的city。这种写法只适合数据量不大、子查询结果很确定的情况,如果外层表很大,性能问题就来了,后面EXPLAIN部分我再展开说。
3.2 IN、EXISTS与NULL的致命陷阱
子查询最常见的用法是配合IN,比如查“下过单的用户名”:
SELECT name FROM users WHERE id IN (SELECT user_id FROM orders);这条SQL很直白,查出来是张三、李四、王五。但你要是把它改成NOT IN,想查“没下过单的用户”,就很容易掉坑。假设orders表里意外混进一条user_id为NULL的数据,NOT IN的结果会直接变成空集,一条都查不出来。
原因在于SQL对NULL的判定规则:WHERE id NOT IN (1, 2, 3, NULL),等价于id != 1 AND id != 2 AND id != 3 AND id != NULL。而任何值和NULL做比较,结果都是“未知”,条件永远不成立,所以整条数据被过滤掉。这就是为什么我一直跟团队成员说:能不用NOT IN就不用,优先考虑NOT EXISTS。
-- 更稳妥的写法 SELECT name FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id );NOT EXISTS是逐行去外部表判断“内层有没有匹配行”,它不关心内层返回的是NULL还是具体值,判断逻辑是纯布尔结果,天然免疫NULL问题。这也是面试官特别爱考的知识点,你可以拿这个例子去回答“IN和EXISTS有什么区别”。
3.3 关联子查询:外层和内层的联动
关联子查询的特别之处在于,内层查询会引用外层查询的列,内外两层“联动”执行。典型例子是“找出金额高于该用户平均订单金额的订单”:
SELECT o.id, o.user_id, o.amount FROM orders o WHERE o.amount > ( SELECT AVG(o2.amount) FROM orders o2 WHERE o2.user_id = o.user_id );注意这里内外都用到了o.user_id,内层的o2.user_id = o.user_id把子查询限定在了“同一个用户”的订单范围。执行时MySQL会对orders表每一行,都去计算一次该用户的平均金额,再做比较。逻辑上很好理解,但性能上会有压力。所以在数据量大时,这类SQL要特别关注执行计划,如果外层表很大,优化器不一定会自动帮做成最优语义。
3.4 子查询改写JOIN:派生表与重复行的取舍
子查询也可以出现在FROM后面,作为一张临时“派生表”。比如想统计“每个用户的下单量,再看哪些用户下单超过1次”:
SELECT t.user_id, t.cnt FROM ( SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id ) t WHERE t.cnt > 1;这种写法清晰易读,但要注意两点:一是派生表在MySQL 5.7之前会被物化(也就是先落到临时表),数据量大时有额外IO开销;二是给派生表起的别名(上面的t)在MySQL里必须写,否则直接报语法错误。
还有一个常见需求是“子查询改JOIN后结果重复”。比如查“每件商品的总销量”,你用子查询写没问题,改JOIN时如果关联到order_items后又关联到orders的某个一对多字段,数量就会翻倍。所以改写时一定要先想清楚这个JOIN会不会产生一对多膨胀,必要时用DISTINCT或者GROUP BY来兜底。
3.5 扩展:UPDATE和DELETE里的子查询
前面说复杂查询主要围绕SELECT,但其实数据操纵语句里的UPDATE和DELETE也经常搭配子查询,尤其是做批量更新。例如把“从未下过单的用户”标记为休眠状态:
UPDATE users SET status = 'sleep' WHERE id NOT IN ( SELECT user_id FROM orders );这时候就有一个MySQL特有的坑:如果子查询引用的表和UPDATE的目标表是同一张表,MySQL会直接报“You can't specify target table for update in FROM clause”。解决办法是给子查询再包一层派生表,让MySQL觉得你没在同一张表上同时读写:
UPDATE users SET status = 'sleep' WHERE id NOT IN ( SELECT user_id FROM (SELECT user_id FROM orders) t );这个“包一层”的技巧同样适用于DELETE。很多同学在Navicat里一执行就报错,多半就是没明白MySQL对同表子查询的限制。
4. 聚合分组:从明细数据到统计报表
4.1 聚合函数与NULL的关系
聚合函数是统计报表的基础,常用的有COUNT、SUM、AVG、MAX、MIN。列一下它们对NULL的态度:
| 函数 | 对NULL的处理 |
|---|---|
| COUNT(*) | 统计行数,不关心是否有NULL |
| COUNT(列名) | 只统计该列非NULL的行数 |
| SUM | 忽略NULL,直接求和 |
| AVG | 忽略NULL参与计算 |
| MAX/MIN | 忽略NULL参与比较 |
这个区别特别容易踩坑。最常见的就是统计订单数时,有人写COUNT(user_id),如果orders表里恰好有几条user_id是NULL的脏数据,统计出来的数字就和COUNT()对不上。严格来说,计数用COUNT()更语义化,因为订单表中一行就意味着一个订单存在,不应该因为某个字段为NULL就把这行丢掉。
4.2 GROUP BY的底层逻辑
GROUP BY的底层可以理解成“分组-聚合”两个阶段。第一步,MySQL按GROUP BY后指定的列把数据分成若干组,同一组里的行拥有相同的“分组键”;第二步,在每个组内执行聚合函数,每组输出一行结果。所以GROUP BY之后,SELECT列表里只能出现分组键和聚合函数,其他列在MySQL 5.7版本开始会直接报错,因为同一组里该列的多个取值你让数据库显示哪一条?这是逻辑上的必然限制,不是MySQL故意刁难。
比如这句会报错:
SELECT user_id, city, COUNT(*) FROM users GROUP BY user_id;city没出现在GROUP BY里,也不是聚合函数,MySQL不知道要显示同组的哪个城市。有些老版本默认关了ONLY_FULL_GROUP_BY能出结果,但那是碰运气,结果不可控,不要依赖。
4.3 实战:统计每个用户的订单情况
来看一个典型的需求:统计每个用户的订单数、累计金额、最大单笔金额:
SELECT user_id, COUNT(*) AS order_cnt, SUM(amount) AS total_amount, MAX(amount) AS max_amount FROM orders GROUP BY user_id;结果里user_id为1、2、3分别一行,分别是2单256元、2单539.5元、1单320元。注意,赵六因为压根没订单,不会出现在结果里,因为GROUP BY是先把orders的行分组,users里没有订单的人根本没进入这个过程。如果你想把“没下单的用户也呈现出来并且统计为0”,就得先LEFT JOIN再用GROUP BY,这个思路在报表开发里很常见。
4.4 HAVING和WHERE的职责边界
WHERE和HAVING看起来很相似,都是加条件过滤,但执行时机完全不同。WHERE在分组之前对原始行逐行过滤,而HAVING在分组聚合之后对“组”进行过滤。想查“下单总金额超过200的用户”,不能用WHERE,因为SUM是在分组之后才计算出来的:
SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id HAVING SUM(amount) > 200;有个优化原则:能用WHERE过滤的条件,千万别放到HAVING里。比如你要统计“已支付订单的金额”,先WHERE status = 'paid',让进入分组的行数变少,后续聚合就轻快很多;如果你非要把status的判断写进HAVING,MySQL就得把所有订单都分组聚完再过滤,性能白白浪费。
4.5 顺手提一句GROUP_CONCAT
GROUP_CONCAT能把组内多行的某个字段拼接成一个字符串,非常适合做“一个用户的所有订单ID列表”这种需求:
SELECT user_id, GROUP_CONCAT(id ORDER BY id) AS order_ids FROM orders GROUP BY user_id;它默认的分隔符是逗号,想换分隔符可以写SEPARATOR ';'。需要注意,GROUP_CONCAT有默认长度限制(默认1024字节),拼接很长的文本时会被截断,生产环境要按需求用SET SESSION group_concat_max_len适当调大。
5. 排序、分页、去重:让结果集更可控
5.1 ORDER BY的排序规则与性能陷阱
排序看起来最简单,其实也藏着细节。ORDER BY默认ASC升序,多列排序时从左到右依次生效,想实现“先按状态排序,状态相同的再按时间倒序”:
SELECT * FROM orders ORDER BY status ASC, created_at DESC;这里有一条经验:排序字段不要瞎选。执行计划里如果出现Using filesort,说明MySQL要把数据先加载出来再在内存或磁盘里额外排序,数据量大时很耗时。反过来,如果ORDER BY的字段和索引顺序匹配,MySQL可以直接按索引顺序读取,省掉一次排序动作。所以排序不是一个单纯写在末尾的动作,它会影响你的索引设计。
还有一个容易被忽略的点:NULL在排序时的位置。MySQL默认NULL被当作最小值,升序时排最前,降序时排最后。MySQL 8.0支持NULLS LAST语法,可以显式控制NULL排到末尾,比如ORDER BY created_at IS NULL ASC, created_at DESC这种写法也能达到类似效果。
5.2 LIMIT分页的深坑:翻到后面越来越慢
LIMIT分页是后端接口的基本功,例如每页20条:
SELECT * FROM orders ORDER BY id LIMIT 20, 20;这条语法本身没问题,但它背后发生的事情很“笨”。MySQL会先读出来前40行,然后把前20行丢掉,只返回后面的20行。当页码逐渐变大,写到LIMIT 1000000, 20时,MySQL要扫描1000020行才能给你20条结果,能不慢吗?这就是“深分页”问题。
业内常用的优化手段叫“延迟关联”,或者叫“子查询先行定位”。先用子查询快速定位到本页需要的主键ID,再去关联原表取完整数据:
SELECT o.* FROM orders o JOIN ( SELECT id FROM orders ORDER BY id LIMIT 1000000, 20 ) t ON o.id = t.id;这个写法的核心思想是:内层子查询只扫主键索引,不会回表读完整行,大大减少了随机IO。如果业务上允许,更彻底的做法是“基于游标分页”,也就是记住上一页最后一个ID,用WHERE id > last_id LIMIT 20代替OFFSET,靠索引直接定位,翻到几百万页也快。
5.3 DISTINCT去重和UNION组合查询
DISTINCT用来去重,比如看下用户都分布在哪些城市:
SELECT DISTINCT city FROM users;它是对整行数据去重,不是只对某一列去重。如果你想“查每个城市有多少用户”,应该用GROUP BY而不是DISTINCT。DISTINCT用多了也有隐患,尤其配合ORDER BY时,如果去重字段和排序字段不一致,SQL会直接报错。
再说一个容易被混淆的点:UNION也是“去重”,但它和JOIN完全是两码事。JOIN是横向拼接,把两张表的列拼成更宽的结果;UNION是纵向拼接,把两个SELECT结果的行上下堆在一起。比如想查“杭州和北京的用户”,可以这样写:
SELECT name, city FROM users WHERE city = '杭州' UNION SELECT name, city FROM users WHERE city = '北京';UNION默认会对结果去重,如果你明确知道两个结果集不会有重复,或者不在乎重复,改用UNION ALL。UNION ALL少了一次去重排序,性能更好,这是SQL性能优化里很常见的一条小优化。
6. 复杂查询的性能排查:学会看EXPLAIN
6.1 我给每个新人都会讲的EXPLAIN入门
SQL写得再多,最终还是要落到执行性能上。我判断一条复杂查询好不好,第一件事就是看执行计划:
EXPLAIN SELECT o.id, u.name FROM orders o JOIN users u ON o.user_id = u.id WHERE o.amount > 100;EXPLAIN会输出一张表,对你帮助最大的四列我给你总结一下:
| 列名 | 含义 | 你该关注什么 |
|---|---|---|
| type | 访问类型 | 出现了ALL基本就是全表扫描,要警惕 |
| key | 实际用到的索引名 | 值为NULL说明没走索引 |
| rows | 预估扫描行数 | 数值越大,成本通常越高 |
| Extra | 附加信息 | 出现Using filesort、Using temporary要重点优化 |
type列从好到差大致是system、const、eq_ref、ref、range、index、ALL。前几级说明能快速定位到行,最后两个经常意味着全表或全索引扫描。我在实际排查慢查询时,先看type,再看rows,最后看Extra,基本能定位八九成的问题。
6.2 复杂查询为什么慢:三个典型原因
第一个典型问题是连接条件没走索引。JOIN的ON字段如果没有索引,MySQL就要对每一行都做全表扫描匹配,复杂度直接变成两表行数的乘积。所以在orders.user_id上建索引,几乎是所有订单查询优化方案里的标配。
第二个典型问题是子查询被物化。把子查询放在FROM后面作为派生表时,MySQL可能先把子查询结果落到临时表,再和外表关联。数据量大时这个“落地”过程很消耗磁盘IO。遇到这类情况,我会先把子查询单独执行一遍看时间,如果子查询本身很重,就考虑改写JOIN,让优化器有更多选择。
第三个典型问题是隐式类型转换导致索引失效。比如users.id是INT类型,但你用字符串'1'去匹配,MySQL内部会做类型转换,导致索引无法正常使用。还有在WHERE条件里对列做函数运算,比如WHERE DATE(created_at) = '2024-05-01',如果created_at有索引也会失效,因为MySQL必须先对每一行算一次DATE函数,没办法直接走索引。正确写法是改成范围条件:
WHERE created_at >= '2024-05-01 00:00:00' AND created_at < '2024-05-02 00:00:00';6.3 JOIN的顺序:小表驱动大表
多表JOIN时,MySQL优化器大部分情况下会帮我们选出比较好的连接顺序,但理解“小表驱动大表”的原则依然有用。所谓小表驱动大表,就是先用行数少的表作为驱动表,再用它的关联列去大表里精确查找,这样大表每次查询都能借助索引快速命中;反过来如果用大表驱动小表,大表每一行都要去小表里找匹配,总代价会高很多。
遇到MySQL选错执行计划的情况,我一般会用STRAIGHT_JOIN来强制指定连接顺序,但这属于“下策”,生产环境很少用。更靠谱的做法是检查两张表关联字段是否都有合适的索引,索引补齐后优化器通常能自动选出最优连接顺序。另外,给每张表都加有意义的别名,别用SELECT *,复杂查询的结果集本来就大,只取需要的列既是好习惯,也有利于覆盖索引生效。
7. 常见问题与排查技巧实录
7.1 一张速查表帮你快速定位问题
我把实际开发里经常遇到的复杂查询故障整理成了表格,你遇到相同问题可以直接对照排查。
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
| NOT IN查出来是空集 | 子查询结果里包含NULL | 改用NOT EXISTS |
| LEFT JOIN结果行数反而变少 | 把右表过滤条件写进了WHERE | 把需要保留左表行的条件移到ON里 |
| GROUP BY后SELECT报错 | 选择了非分组键且非聚合列 | 按业务需求改组或增加聚合函数 |
| JOIN后SUM金额翻倍 | 连接关系存在一对多膨胀 | 先聚合明细再JOIN,或用子查询 |
| 分页越翻越慢 | 大OFFSET导致扫描大量无关行 | 用延迟关联或基于ID游标分页 |
| 加了ORDER BY后LIMIT结果不稳定 | 排序字段有重复值,顺序不确定 | 增加唯一键(如id)作为次级排序 |
| UPDATE时子查询报错 | 目标表和子查询来源表是同一张表 | 子查询再包一层派生表 |
| EXPLAIN出现Using filesort | ORDER BY没有索引支撑 | 为排序字段设计合适索引或调整排序方式 |
| 查询结果出现重复行 | 多表连接条件不足或JOIN方向不对 | 检查关联字段,必要时加DISTINCT或GROUP BY |
7.2 一个我印象深刻的线上案例
之前有个同事写了一段报表SQL,查“每个用户的订单总额”,前端页面报表里面的金额总比后台对账少。我一看他的SQL,问题出在LEFT JOIN之后又对orders表做了WHERE过滤,把没有订单的用户全部滤掉了。他迷惑的点是“我明明用了LEFT JOIN,为什么没订单的用户没了呢”,这就是典型的没理解WHERE和ON执行机制的问题。
还有一次线上慢查询,一条只涉及三张表的JOIN跑了几秒。EXPLAIN一看,关联字段上居然没有索引,优化器只能全表扫描加临时表排序。后来给orders.user_id和order_items.order_id补上索引,查询从秒级降到毫秒级。这再次说明,复杂查询的性能瓶颈绝大多数不在SQL写法本身,而在索引没建对。
7.3 平时要怎么练才能记得牢
我一直建议团队新人不要死磕面试题,而是拿一套真实业务数据反复练复杂查询。你可以自己造一张订单表、商品表、用户表,然后试着回答这些问题:每个用户的订单数和总金额是多少?哪些用户最近30天没有下单?每个商品类别的销量排名?每笔订单中金额占比最高的商品是哪个?这些问题覆盖了JOIN、子查询、聚合、开窗函数、排序,答完一遍你的SQL水平会有质的提升。
还有一个小技巧:每写完一条复杂查询,不要只看结果对不对,一定要跑一次EXPLAIN,观察它扫描了多少行、有没有用上索引、有没有文件排序。坚持一段时间,你写SQL时就会自然地带出“这条语句在数据库里是怎么执行的”的意识,这种意识才是从“会写”到“写好”的分水岭。
说到底,复杂查询的每一块知识点都不难,难的是把它们组合在一起时,你还能保持头脑清醒,知道自己每一步操作会产生什么样的中间结果集。JOIN是横向拼接结果集,GROUP BY是压缩行数,WHERE是逐行过滤,HAVING是按组过滤,子查询是嵌套执行——把这些“变换”想明白,绝大多数SQL问题都能迎刃而解。这套示例数据你建好后可以反复折腾,每改一个条件、每看一次EXPLAIN,都比单纯背十条语法收获更大。