前阵子有个做电商运营的朋友找我,说他被一个报表需求卡了整整一下午:想知道每个商品分类里,销量排前三的商品是哪些。这个需求乍一听不复杂,可真动手写SQL的时候,单表查询根本拎不起来——商品、订单、分类散落在不同的表里,还要逐类排序取前几名。这就是典型的MySQL复合查询场景:多表关联,配合子查询,再叠加聚合统计。这篇指南我不打算堆概念,而是从实际需求出发,把复合查询的JOIN、子查询、聚合这三块组合在一起讲透,最后用一条真实慢查询的排查过程收尾。适合刚学完单表查询、准备进阶写业务报表的开发者,也适合被多表查询绕晕的数据同学。
1. 为什么单表查询会卡壳:复合查询解决的三个典型场景
先说个很多人忽略的前提:MySQL里数据为什么不能都放一张表里?因为业务系统在设计时必须做范式化——订单表只存订单,用户表只存用户,商品表只存商品。这么做的好处是数据不冗余、更新不冲突,但副作用就是查询的时候需要把分散在多张表的数据重新拼起来。类似"每个分类的销量前三商品"这种需求,涉及分类、商品、订单三张表,单表查询在语法层面就卡死了。
我把日常业务里真正需要复合查询的场景归成三类,后面对应的技术手段完全不同,先搞清楚需求是哪一类,SQL才不会写歪。
**跨表取字段,一张表的信息不够用。**这种最常见。订单表里有user_id,但报表里要显示用户姓名,姓名在用户表里,怎么办?JOIN,把两行拼成一行。再比如订单明细表只有product_id,商品名称在商品表里,同理。这类需求的本质是"行扩展"——一次查询的结果需要包含来自多张表的列。
**过滤条件不在本表,而在另一张表的计算结果里。**听上去绕,其实就是子查询的经典场景。比如"找出下单次数超过5次的用户",下单次数需要先对订单表做聚合统计,得到的结果才是一个id列表,你不能在users表上直接写"WHERE 订单数>5"。这类需求的本质是"条件扩展"——WHERE的判断依据来自另一段查询的输出。
**分组之后还要二次筛选,或者聚合结果要再和别表关联。**比如"找出每个分类里平均单价超过100元的分类",这要求先按分类分组算平均值,再对分组结果做HAVING过滤;又比如"查用户及其订单统计,但只保留下单超过3次的用户",聚合结果还要JOIN回用户表。这类需求的本质是"统计扩展"——GROUP BY、HAVING、子查询和JOIN经常拧在一起。
这三类场景在实际SQL里很少单独出现,多数是两两组合甚至三者同时出现。比如最典型的"用户订单汇总报表":用户表JOIN订单表拿订单金额(跨表取字段),再GROUP BY用户算总金额(聚合),最后HAVING过滤掉金额小于1000的用户(二次筛选)。理解了这个层次,你会明白复合查询并不是什么新语法,它只是把单表查询的四种基础能力——关联、过滤、分组、排序——组合起来用。下面我按关注度从高到低,逐一拆开讲。
2. 多表关联的三个层次:INNER JOIN、LEFT JOIN与驱动表
多表关联是整个复合查询的地基,地基不稳,后面的子查询和聚合全是空中楼阁。这一节我把JOIN的选型、ON与WHERE的分工、关联字段的规范讲清楚,最后一起来看一个三表关联的完整例子。
2.1 INNER JOIN与LEFT JOIN到底怎么选
JOIN的核心就一个:把两张表按某种逻辑拼成一张大表。INNER JOIN是取交集,两表都有匹配的行才会出现在结果里;LEFT JOIN是保住左表的所有行,右表没有匹配的就用NULL补齐。
实际业务里我用的最多的是LEFT JOIN,因为业务查询的"主实体"往往需要全量保留。比如你要列出一批用户的订单记录,用户表是主表,即使用户没有下过单,你也希望在结果里看到他,右表字段显示NULL就行,这种情况LEFT JOIN天然合适。INNER JOIN更常用于纯粹的内部关系,比如查"已下单的用户有哪些",没下过单的一律不要。
有一个容易翻车的点:当你在LEFT JOIN的ON条件里过滤右表,比如LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid',这个过滤只影响右表匹配逻辑,左表全量保留;但如果你把这个条件挪到WHERE里,写WHERE o.status = 'paid',MySQL会先完成JOIN,再用这个条件过滤整张结果表,凡是右表为NULL的行全部被清掉,LEFT JOIN就变成INNER JOIN了。我自己调试的时候踩过好几次这个坑,一旦发现LEFT JOIN的结果行数和左表对不上,第一反应就是去翻WHERE里有没有右表字段的条件。
2.2 ON与WHERE的分工,以及它背后的执行逻辑
ON和WHERE看似都是写条件,实际生效的时机完全不同。ON是在JOIN过程中、两表匹配时逐行判断;WHERE是在JOIN生成的结果集之上做二次过滤。听起来只是执行先后的问题,但在LEFT JOIN场景下,这个先后直接决定了结果的语义。
我举个具体例子,有用户表和订单表:
-- 这样写:保留全部用户,只有已支付的订单会匹配上 SELECT u.name, o.order_id, o.amount FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid'; -- 这样写:先JOIN出全部订单再过滤,结果里只剩有已支付订单的用户 SELECT u.name, o.order_id, o.amount FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.status = 'paid';两条SQL看似差不多,第一条返回的用户数≥第二条。理解了这个差异,你在设计报表时会变得从容很多:想保留主实体,条件就写ON里;想从JOIN结果里剔除数据,条件就写WHERE里。这个原则也适用于INNER JOIN,只是INNER JOIN场景下过滤时机不同不改变最终结果,很多人因此忽略了它的重要性,换到LEFT JOIN一下就暴露了。
2.3 关联字段的三条铁律
关联字段是整个JOIN的性能和正确性命门。我在团队里做Code Review时,看到JOIN写错,九成出在下面三个地方:
字段类型必须一致。users.id是int,orders.user_id是varchar,JOIN虽然不报错,但MySQL没法用索引快速匹配,会逐行做类型转换,数据量一大就全表扫描。建表时主外键类型不对齐,是很多慢查询的隐性根因。
**关联列必须有索引。**JOIN的本质是拿一张表的每一行去对方表里找匹配行,如果对方表的关联列没索引,每次匹配都要全表扫一遍。order表按user_id关联用户表,没索引时驱动表每扫一行,被驱动表就得全表扫一次,这个代价是相乘关系,几万行就能把查询拖垮。
**小表驱动大表。**理论上优化器会自动选驱动表,但前提是你给了它足够的统计信息和索引。你可以在SQL里人为引导,比如把过滤条件更严格的表放前面,或者在EXPLAIN里看执行计划,发现驱动表选错了,可以通过STRAIGHT_JOIN强制指定。
2.4 三表关联实例:订单、用户与商品明细
光讲概念不好消化,看一个真实的业务查询。要查"最近一周每个订单的用户姓名、订单号和商品名",涉及orders、users、order_items三张表:
SELECT u.name, o.order_id, o.order_time, i.product_name, i.quantity FROM orders o INNER JOIN users u ON o.user_id = u.id INNER JOIN order_items i ON o.order_id = i.order_id WHERE o.order_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) ORDER BY o.order_time DESC;这个查询的执行顺序是:先拿orders表按order_time过滤出一周内的订单(尽早缩小数据量),然后逐行去users表匹配用户信息,再逐行去order_items表匹配商品明细。表面只写了两个JOIN,实际是两张中间表依次拼接:第一轮拼出"订单+用户",第二轮再拼上商品明细。理解这个顺序会让你明白,JOIN的排布顺序并不一定是执行顺序,但尽早过滤、尽量缩小中间结果集永远是核心原则。至于每个关联列的类型一致性和索引,我在建表的时候就已经确认过了,这也是SQL能跑得快的前提。
3. 子查询的两副面孔:WHERE条件过滤与FROM派生表
子查询在复合查询里的地位,有点像瑞士军刀——单独用它解决不了大问题,但和JOIN、聚合组合起来就威力十足。这里必须区分它的两种形态,很多人搞混了,导致SQL要么写不出来,要么性能稀烂。
3.1 WHERE子查询的三种用法
第一种是把子查询放在WHERE里,用来产生过滤条件。最常见的是IN,依次列出命中集合:
SELECT name, email FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 500);这个SQL的意思是"找出所有下过500元以上订单的用户",子查询先独立跑一遍,得到一批user_id,外层再查用户。子查询只执行一次,属于非相关子查询,性能相对可控。
第二种是EXISTS,它跟IN的写法形态相反,逻辑也有区别:
SELECT name, email FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 500);注意这里子查询的WHERE里引用了外层表的u.id,这就是相关子查询——每处理一行用户,子查询都要执行一遍。理论上看像是个O(N×M)的慢查询,但实践里如果orders表的user_id有索引,匹配很快,EXISTS往往比IN更高效,尤其当子查询结果集很大时。
第三种是标量比较,子查询只返回一个值,配合>、<、=使用,比如"找出余额高于平均余额的用户":
SELECT name, balance FROM users WHERE balance > (SELECT AVG(balance) FROM users);这里子查询返回单行单列,是典型的非相关标量子查询。需要注意的是,标量子查询如果返回多行,MySQL会直接报错,写之前得确认业务逻辑只可能产生一行。
3.2 IN与EXISTS的选择,以及一个关于OR去重的误区
很多教程会告诉你"IN快还是EXISTS快要分情况",这话对,但不够具体。我实测下来的经验是:**子查询结果集小,选IN;外层表小且子查询有大表且有索引,选EXISTS。**MySQL 5.6之后引入了半连接优化(semi-join),IN子查询在多数情况下会被优化器改写成JOIN或物化方式执行,和EXISTS的性能差距在不断缩小。所以我的建议是优先写IN,逻辑直观、可读性好;只有在EXPLAIN中发现执行计划不对劲时,再考虑改写EXISTS。
顺带辟个谣,网上常有人问"mysql的or能去重吗"。答案是不能。OR只是连接两个或多个条件的逻辑运算符,它不具备任何去重能力。如果你在JOIN或复合查询的结果里用了OR条件,数据出现重复,那说明你的关联逻辑产生了多行匹配,这时该用DISTINCT去重,或者用UNION替代OR来改写:
-- OR写法:可能出现重复行 SELECT DISTINCT user_id FROM orders WHERE status = 'paid' OR amount > 1000; -- UNION写法:自动去重,语义更清晰 SELECT user_id FROM orders WHERE status = 'paid' UNION SELECT user_id FROM orders WHERE amount > 1000;UNION会对两个结果集合并去重,UNION ALL则保留所有行。如果你的OR条件只是想让两个集合合起来看,用UNION比用OR加DISTINCT更直观,优化器也更容易走索引。
3.3 FROM派生表:把子查询当临时表用
这是子查询的第二副面孔,也是很多人会忽略的高级玩法——把子查询写在FROM后面,产出一张"派生表",然后继续对它JOIN或聚合。派生表相当于MySQL替你造的临时表,注意必须起别名,否则语法直接报错:
SELECT d.category_id, COUNT(*) AS order_count FROM ( SELECT order_id, category_id FROM order_items WHERE quantity > 1 ) AS d GROUP BY d.category_id;这里先对order_items做一个子查询过滤出数量大于1的明细,把这个结果当作一张临时表d,再对它按分类聚合。实际业务中,派生表最常见的妙用是把"先算后关联"变成"先过滤再算",极大减少关联数据量。比如你要统计"最近7天内每个分类的销量",可以先在订单明细表上过滤日期,再JOIN商品表,而不是先JOIN商品表再全量过滤:
SELECT p.category_id, SUM(d.quantity) AS total_quantity FROM ( SELECT product_id, quantity FROM order_items WHERE create_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) ) AS d INNER JOIN products p ON d.product_id = p.id GROUP BY p.category_id;派生表和WHERE子查询的本质区别在于:WHERE子查询产生的是一个值或值集合,用来做条件判断;派生表产生的是"一张表",可以继续被当作数据源。这俩别混为一谈,你也不会再写出"SELECT * FROM (SELECT ...) WHERE..."却忘了给别名这种离谱错误。
3.4 实战案例:每个分类销量前3的商品
回到开头的那个需求——"每个分类里销量前3的商品",这是一道面试级复合查询题。我从实战角度给你两个版本。
如果你用的是MySQL 8.0,直接上窗口函数,这是最干净的写法:
SELECT category_name, product_name, sales FROM ( SELECT c.name AS category_name, p.name AS product_name, SUM(oi.quantity) AS sales, ROW_NUMBER() OVER (PARTITION BY c.id ORDER BY SUM(oi.quantity) DESC) AS rn FROM order_items oi INNER JOIN products p ON oi.product_id = p.id INNER JOIN categories c ON p.category_id = c.id GROUP BY c.id, p.id ) AS t WHERE t.rn <= 3;解释一下:最内层先JOIN三张表,GROUP BY分类和商品,算出每个商品的总销量;然后窗口函数ROW_NUMBER()按分类分组(PARTITION BY c.id),组内按销量倒序编号;最后外层过滤编号<=3。这个方案逻辑清晰,8.0用户无脑用。
如果还是MySQL 5.7,没有窗口函数,就用派生表加自关联计数的方案:
SELECT t.category_name, t.product_name, t.sales FROM ( SELECT c.name AS category_name, p.name AS product_name, SUM(oi.quantity) AS sales FROM order_items oi INNER JOIN products p ON oi.product_id = p.id INNER JOIN categories c ON p.category_id = c.id GROUP BY c.id, p.id ) AS t WHERE ( SELECT COUNT(*) FROM ( SELECT c2.id, p2.id, SUM(oi2.quantity) AS s2 FROM order_items oi2 INNER JOIN products p2 ON oi2.product_id = p2.id INNER JOIN categories c2 ON p2.category_id = c2.id GROUP BY c2.id, p2.id ) AS t2 WHERE t2.id = t.category_name AND t2.s2 > t.sales ) < 3;这个写法利用了"比自己销量高的商品数小于3"这个条件,逻辑等价于排名前三。它理解起来费劲一点,性能也一般,但对5.7用户确实靠谱。我当年在5.7环境就是靠这个思路顶上来的,后来升了8.0才敢把窗口函数铺开用。
4. 复合查询加聚合:GROUP BY与HAVING的配合细节
复合查询里一旦出现聚合函数(COUNT、SUM、AVG、MAX这些),就得格外小心。因为JOIN和GROUP BY的组合,经常会引发"数据看似对,实际是错的"这种隐蔽问题。
4.1 JOIN带来的数据翻倍陷阱
一张表JOIN另一张表,如果关联关系是一对多,结果行数会翻倍。比如用户表和订单表,一个用户有多条订单,LEFT JOIN之后,该用户会出现多行。这时候如果你直接对另一张表做COUNT,数字就会虚高。
举个最常见的错误:
-- 错误:统计每个用户的关联订单明细数 SELECT u.name, COUNT(i.item_id) AS item_count FROM users u LEFT JOIN orders o ON u.id = o.user_id LEFT JOIN order_items i ON o.order_id = i.order_id GROUP BY u.name;这条SQL看起来没毛病,实际上如果某用户有两个订单,每个订单有3条明细,JOIN后会产生6行,COUNT(i.item_id)返回6,而不是6——其实6确实是明细条数,这里COUNT(i.item_id)反而是对的。真正容易翻车的场景是COUNT(u.id)这种情况:多个订单JOIN后,用户行复制成多份,COUNT(u.id)会把同一个用户id数好几遍。所以聚合复合查询里,明确你要数的是哪个表的行,必要时用COUNT(DISTINCT u.id)来兜底。
我有一条实践铁律:JOIN之前,先想清楚"JOIN后行数会不会增加",如果会增加,COUNT的字段必须区分主表和从表。产品经理要"订单数",你就COUNT(orders.order_id);要"用户数",你就COUNT(DISTINCT users.id)。一字之差,报表差一截。
4.2 WHERE与HAVING的分工
聚合查询里,WHERE和HAVING经常被混用,实际上它们的执行顺序截然不同。WHERE在分组之前、聚合之前过滤原始行;HAVING在分组之后、聚合之后过滤分组结果。
看这个例子:
-- 查每个用户已支付订单的总金额,只保留总金额大于1000的 SELECT u.id, u.name, SUM(o.amount) AS total_amount FROM users u LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid' GROUP BY u.id, u.name HAVING total_amount > 1000;如果你想把o.status='paid'放到HAVING里去过滤,肯定不对,因为status是原始行字段,不参与分组聚合;而total_amount > 1000是聚合结果,WHERE压根访问不到它。最好的实践是:能用WHERE提前过滤掉的,绝不放HAVING。因为HAVING是在分组之后才执行,它要处理的行数已经是最小结果集了,而WHERE能帮你把进入GROUP BY的数据源头缩小,这直接影响聚合的速度。
4.3 一个统计实例:订单数量与总金额的正确写法
把用户、订单、明细三张表组合起来做统计,这是业务报表的常见出身。看这个需求:统计每个用户的下单次数和订单总金额,还要把没有下过单的用户一并显示。
SELECT u.id, u.name, COUNT(o.order_id) AS order_count, IFNULL(SUM(o.amount), 0) AS total_amount FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.name ORDER BY total_amount DESC;几个细节想重点说明。COUNT我用了o.order_id而不是COUNT(*),因为LEFT JOIN下,没有订单的用户右表字段全是NULL,COUNT(*)会把这个用户也数成1次下单,而COUNT(o.order_id)只数非NULL行,逻辑正确。SUM同理,NULL加进来结果还是NULL,我用IFNULL兜底成0,更符合"未下单金额为0"的业务预期。GROUP BY我老老实实写了u.id, u.name,注意SELECT里的非聚合字段都必须出现在GROUP BY里,MySQL默认开启ONLY_FULL_GROUP_BY后,少写一个就报错。
4.4 ORDER BY与LIMIT在复合查询里的表现
复合查询的排序和分页,很多人以为和单表一样,其实有独特讲究。ORDER BY可以引用别名,也可以引用聚合函数,但要注意排序字段别和GROUP BY字段冲突。LIMIT的坑更多是性能问题。
比如你要做分页报表,常见写法是:
SELECT u.id, u.name, COUNT(o.order_id) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.name ORDER BY order_count DESC LIMIT 20 OFFSET 2000;这个写法在大数据量下会越来越慢,因为MySQL要先排完所有行,再取第2000行之后的20行,前期计算全部浪费。深分页优化有个思路:先查出分页范围内的主键ID,再回表关联详情。子查询或派生表可以帮你实现这个"下推":
SELECT u.id, u.name, t.order_count FROM ( SELECT o.user_id, COUNT(*) AS order_count FROM orders o GROUP BY o.user_id ORDER BY order_count DESC LIMIT 20 OFFSET 2000 ) AS t INNER JOIN users u ON t.user_id = u.id;先在子查询里完成排序和分页,再回用户表取姓名,这样LIMIT的代价被压缩到了最小结果集里,外层关联只是补字段。这种"先缩小再关联"的写法,在所有复合查询里都是通用优化思路。
5. 一条慢查询的排查实录:复合查询性能优化的完整链路
理论讲再多,不如一次实战排查来得深刻。这一节我复盘一条真实慢查询的完整排查过程,把EXPLAIN、索引优化、子查询改写这些知识串起来。
5.1 慢查询现象与初步定位
有个统计报表SQL,业务方反馈"每次打开页面要等二三十秒"。我拿到手的原始SQL长这样:
SELECT u.name, o.order_id, o.amount, p.product_name FROM users u LEFT JOIN orders o ON u.user_id = o.user_id LEFT JOIN order_items i ON o.order_id = i.order_id LEFT JOIN products p ON i.product_id = p.id WHERE u.created_at >= '2023-01-01' ORDER BY o.order_time DESC LIMIT 100;从格式看,这是一个典型的多表关联加排序分页。users表大概50万行,orders表200万行,order_items表500万行,products表2万行。数据量不大,但查询慢得出奇。
第一步永远是看执行计划,EXPLAIN的结果让我立刻锁定了问题:
EXPLAIN SELECT ...(同上);三个关键信号出现在EXPLAIN结果里:type列出现ALL,说明驱动表或中间阶段发生了全表扫描;key列是NULL,说明关联字段没用上索引;Extra列出现Using join buffer,说明MySQL在JOIN时用了内存缓冲区做块嵌套循环连接,这是典型的关联字段无索引表现。再看rows列,优化器估算扫描了十几万行,实际执行时还要乘以关联层数,慢是必然的。
5.2 根因确认:关联字段缺索引与过滤时机错位
我把EXPLAIN一行行看下来,发现orders表上的user_id没有索引,导致每一个user去匹配orders时,都要全表扫一遍200万行。这就是Type=ALL的直接原因。order_items的order_id同理,500万行全表匹配。一组JOIN全表扫,数据量相乘,复杂度直接爆炸。
第二个问题是过滤时机。原SQL里对users表有created_at过滤,应该尽早把不需要的用户排除掉,但是LEFT JOIN的语义是"以users为驱动表,即使过滤后的用户都没订单也要保留",这没问题;问题在于过滤条件只写在了users表上,对orders表没有提前做任何过滤。如果订单量巨大,JOIN的中间结果会非常臃肿。我可以在ON条件里加上订单时间的过滤,把订单表的数据源先缩小。
5.3 修复方案:加联合索引与改写关联顺序
先解决最大的痛点——关联字段索引:
ALTER TABLE orders ADD INDEX idx_user_id (user_id); ALTER TABLE order_items ADD INDEX idx_order_id (order_id); ALTER TABLE products ADD INDEX idx_id (id);orders表200万行加索引,MySQL会在后台建,业务低峰期执行很快。再加一个过滤字段的联合索引,因为WHERE和ON里都用到了user_id和order_time:
ALTER TABLE orders ADD INDEX idx_user_time (user_id, order_time);其次把SQL改写一下,把对orders表的时间过滤从WHERE挪到ON里,保持LEFT JOIN语义的同时缩小关联数据量:
SELECT u.name, o.order_id, o.amount, p.product_name FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.order_time >= '2023-01-01' LEFT JOIN order_items i ON o.order_id = i.order_id LEFT JOIN products p ON i.product_id = p.id WHERE u.created_at >= '2023-01-01' ORDER BY o.order_time DESC LIMIT 100;再次EXPLAIN,type列变成了ref或者eq_ref,key列能看到idx_user_time,rows估算从十几万降到了几百几千,Using join buffer消失了。实际查询时间从25秒降到了0.3秒左右。这一轮排查下来,核心教训就一句话:复合查询慢,九成出在关联字段没索引,或者过滤时机不对。先看EXPLAIN,再对症下药,比瞎改SQL高效得多。
5.4 相关子查询与非相关子查询的性能差别
排查过程中还遇到过一个性能更隐蔽的子查询写法。看这个需求:"找出所有在线用户中,最近30天有下单的用户"。新手喜欢这么写:
SELECT id, name FROM users WHERE status = 'online' AND id IN ( SELECT user_id FROM orders WHERE create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) );这是非相关子查询,orders表内部先过滤出最近30天的user_id集合,然后外层IN匹配,子查询本身执行一次,性能主要取决于orders表是否有create_time索引。可如果换成相关子查询写法:
SELECT id, name FROM users u WHERE status = 'online' AND EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY) );这个SQL每扫描一行users,就会用该行的id去orders表匹配。如果orders表user_id没有索引,每个用户都要全表扫一次订单,几条记录还好,50万在线用户就是50万次全表扫描,妥妥原地爆炸。所以判断一个子查询是否危险,先问一句:**它是不是相关子查询?关联列有没有索引?**这两点想清楚了,性能就不会失控。
5.5 OR条件引发的索引失效与去重问题
最后给一个OR条件带来的坑,这也是复合查询里非常容易被忽视的地方。MySQL的索引优化器对OR的优化远不如AND,因为AND条件下,多个索引可以合并;而OR条件下,优化器往往只能用一个索引,或者干脆放弃索引走全表扫描。
比如这个SQL:
SELECT * FROM orders WHERE user_id = 1001 OR status = 'paid';user_id有索引,status也有索引,但如果两个条件用OR连接,优化器可能会选择全表扫描,因为要同时满足"user_id=1001"和"status='paid'"这两拨数据,走两个索引再合并的成本可能高于全表。这种情况下,改写为UNION:
SELECT * FROM orders WHERE user_id = 1001 UNION SELECT * FROM orders WHERE status = 'paid';两条独立查询各自走索引,再合并结果。而且前面说过,UNION自带去重,如果业务上本来就需要"满足任一条件的数据合集",这个改写一箭双雕。不过记得,如果两条结果没有重复行且数据量很大,用UNION ALL不去重反而更快。这个细节在实际优化里很实用。
6. 复合查询落地的几个实战习惯
写SQL和写代码一样,方法对、习惯好,才能少踩坑。我总结几个自己在日常工作中踩过坑之后沉淀下来的习惯,分享给大家。
**第一,先翻译需求,再动手写SQL。**拿到任何查询需求,我习惯用三步翻译法:要返回哪些字段?这些字段分别在哪些表里?每条过滤条件的判断依据是什么,需不需要先算一步?比如"查最近30天下单超过5次的用户",翻译出来是:返回字段是用户姓名和次数;用户姓名在users表,次数来自orders表的聚合;过滤条件是下单时间在30天内,而且聚合之后次数>5。翻译完,SQL结构基本就出来了:users表JOIN子查询(先按user_id分组计数),过滤时间后HAVING次数条件。先想清楚,再动键盘,少走一半弯路。
**第二,每条复合查询都要跑一遍EXPLAIN。**这不是可选项,是必选项。EXPLAIN会告诉你驱动表是谁、有没有用到索引、扫描了多少行、有没有临时表和文件排序。我看到很多人写完SQL一跑,结果对了就交付。但"结果对"和"性能可接受"完全是两码事。养成习惯,每次写完复合查询,EXPLAIN扫一眼,注意type、key、rows、Extra这四列,我建议一开始就强迫自己逐列读一遍,读不懂就回头翻执行计划的文档,读懂了,你对SQL的理解会上一个台阶。
**第三,少用SELECT *,尽量只取需要的列。**复合查询的中间结果集本来就被JOIN放大了,再全字段拉出来,内存、IO、网络全遭罪。我在Review里经常看到SELECT *配合多表JOIN,一张500万行的表被复制好几次,纯粹是浪费。只写需要的列,不但让意图清晰,也让MySQL能走覆盖索引优化,少回表。索引优化这个点,我最近在做表结构Review时验证过:合理的联合索引(覆盖常用查询列)能直接把查询从几十毫秒压到几毫秒,前提是SQL的SELECT列表别乱写。
**第四,子查询最多嵌套三层。**别为了炫技写四五层嵌套子查询,可读性剧降、排查困难、性能也不可控。遇到深层嵌套,多数情况都能用JOIN或者派生表展开。SQL不是越复杂越厉害,而是越高效越清晰越厉害。我见过一个同事把"用户-订单-明细-商品"四层JOIN加三层子查询写成一坨,最后EXPLAIN发现某层全表扫描,改了半小时才定位。与其这样,不如一开始就分层写,每一层用派生表或CTE(MySQL 8.0的WITH子句)拆开,既好理解又好优化。
最后再分享一个小技巧:**给表起别名,给列起有意义的名字。**别小看这个,复合查询里表多了,a、b、c这种别名会让你在排查时疯掉。我习惯用单词缩写,比如users用u,orders用o,order_items用oi,一眼就能看出是哪张表;SELECT的列尽量用AS改成业务语义,比如SUM(oi.quantity) AS total_sales,导出报表后下游接手的同学一看就懂。这些习惯如果你一开始就养成,写复合查询这件事会轻松一半,排查问题时省下的时间更是无法估量。