MySQL联合查询(JOIN)完全指南:类型、性能优化与实战避坑 1. 为什么单表查询不够用联合查询的诞生背景先说个真实场景。我第一次接触MySQL的时候特别天真以为建一个表把所有信息塞进去就行。后来做了个小项目一个是用户表一个是订单表。用户有昵称、手机号、等级订单有订单号、金额、支付时间。那时候我脑子里想的是查订单的时候把用户昵称也一起显示出来怎么办直接复制一份昵称存进订单表那用户改昵称的时候订单表里的旧昵称怎么办总不能跑去UPDATE所有历史订单吧。这个问题在数据库圈子里叫“数据冗余带来的更新异常”。你存了两份昵称就相当于给自己挖了两个坑第一用户改名时你要记得同步第二哪天数据对不上账你要猜到底哪个才是“真实”的昵称。真正合理的做法是用户表只存用户信息订单表只存订单信息用户ID作为两个表之间的桥梁查询的时候再根据用户ID把两张表“拼”起来看。这个“拼”的动作就是联合查询官方叫JOIN。联合查询要解决的本质上是关系型数据库如何把分散在不同表里的信息按某种关系重新组合成一张完整的结果集。它不是什么高深莫测的技巧而是关系型数据库最核心的查询能力之一。你今天翻开任何一个业务系统的数据库用户、订单、商品、库存、日志、配置这些表之间几乎全靠JOIN串联。另外很多新手刚装好MySQL热搜里的安装教程确实占了一大片跟着教程建了两三张表然后就卡在“表建好了但不知道怎么把数据合在一起看”这一步。这篇文章就围绕联合查询从头到尾讲透JOIN有哪些类型各自解决什么场景ON和WHERE的关系是什么性能上要注意什么以及我在实际项目中踩过的那些坑。我建议你把这篇当作笔记来读里面有可以直接执行的SQL也有配合EXPLAIN的分析思路。看完之后最好自己也建两张表造个几百行数据亲手跑一遍印象会深很多。2. JOIN的五种基本形态与它们解决的实际问题联合查询在MySQL里核心就几种INNER JOIN内连接、LEFT JOIN左连接、RIGHT JOIN右连接、CROSS JOIN交叉连接以及基于JOIN思想延伸出来的自连接。FULL OUTER JOIN在MySQL里没有直接实现但可以用UNION把LEFT JOIN和RIGHT JOIN拼出来这个后面单独讲。先建两张示例表后面所有例子都基于它们CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, level TINYINT DEFAULT 1 ); CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, amount DECIMAL(10,2), created_at DATETIME );造点数据INSERT INTO users (name, level) VALUES (小明, 3), (小红, 5), (老王, 2), (赵姐, 4); INSERT INTO orders (user_id, amount, created_at) VALUES (1, 99.00, 2024-05-01 10:00:00), (1, 199.00, 2024-05-03 14:00:00), (2, 59.00, 2024-05-02 09:30:00), (2, 299.00, 2024-05-04 20:00:00), (5, 129.00, 2024-05-05 11:00:00);注意orders里那个user_id 5的订单users表里没有id5的用户——这是我故意造的用来演示各类JOIN的结果差异后面会反复提到它。2.1 内连接只要两边对得上的数据内连接是使用频率最高的一种JOIN写法上有“显式”和“隐式”两种结果完全一样-- 显式写法推荐 SELECT u.name, o.amount, o.created_at FROM users u INNER JOIN orders o ON u.id o.user_id; -- 隐式写法 SELECT u.name, o.amount, o.created_at FROM users u, orders o WHERE u.id o.user_id;执行结果nameamountcreated_at小明99.002024-05-01 10:00:00小明199.002024-05-03 14:00:00小红59.002024-05-02 09:30:00小红299.002024-05-04 20:00:00注意几条信息老王和赵姐没出现在结果里因为他们没下过单。JOIN的条件是u.id o.user_id没有匹配就淘汰。user_id5的那条订单也没出现因为users表里没有id5的人。同样是被条件筛掉了。小明有两条订单所以结果里小明占了两行。这是JOIN一个特别容易让新手懵的地方我查的是“用户”但结果里有重复的用户名。本质上JOIN是对行的组合一条用户记录匹配两条订单记录自然生成两行。内连接做的是求交集可以类比成相亲软件里的“互相喜欢”才展示联系方式你必须同时存在于两边的条件里才会出现在结果中。2.2 左连接以左表为准右边没有就补NULL左连接LEFT JOIN是我个人认为业务中使用率最高的JOIN。它保证左表的每一行都会出现在结果里右表有匹配就显示右表的数据没有匹配就显示NULL。带着前面造的脏数据跑一遍SELECT u.name, o.amount, o.created_at FROM users u LEFT JOIN orders o ON u.id o.user_id;结果nameamountcreated_at小明99.002024-05-01 10:00:00小明199.002024-05-03 14:00:00小红59.002024-05-02 09:30:00小红299.002024-05-04 20:00:00老王NULLNULL赵姐NULLNULL这里左表的四个人全在了老王和赵姐没有订单金额和下单时间就用NULL填充。这个特性特别适合做那种“查主表数据附带一些可能不存在的从表信息”的需求比如“列出所有用户以及他们最近一次购买的金额”用户即使从来没买过东西也要列出来。用INNER JOIN的话没买过东西的用户会直接消失这在很多业务场景里是不可接受的。2.3 右连接与左连接镜像对称但现实中少用右连接RIGHT JOIN和左连接是镜像关系以右表为基准左表没有匹配就补NULL。还拿上面数据举例SELECT u.name, o.amount FROM users u RIGHT JOIN orders o ON u.id o.user_id;结果nameamount小明99.00小明199.00小红59.00小红299.00NULL129.00注意users里的老王和赵姐没出现在结果中因为右表orders里没有他们的订单而orders里user_id5的订单出现了用户名为NULL。我建议你尽量统一用LEFT JOIN而不要LEFT一个、RIGHT一个地混着写。因为两者可以等价互换——把表顺序调换一下即可A LEFT JOIN B等价于B RIGHT JOIN A。统一成LEFT JOIN团队里其他人读SQL的时候心智负担会小很多。我自己早期写SQL经常左右不分后来养成习惯都是“主表放左边LEFT JOIN 附表”整个项目的SQL风格一下就统一了。2.4 交叉连接笛卡尔积的威力与风险交叉连接CROSS JOIN不指定匹配条件直接把左表每一行跟右表每一行组合。比如左表有4行右表有5行结果就是4×520行。SELECT u.name, o.amount FROM users u CROSS JOIN orders o;实际业务中几乎不会主动用CROSS JOIN但它在两种场景下值得知道生成笛卡尔积数据做测试用。比如你要造数据压测需要一张10万行的表可以通过几个小表CROSS JOIN之后INSERT进去一下就能膨胀出大量行。一些排列组合需求比如商品规格的SKU生成品牌表×型号表×颜色表CROSS JOIN三张表一次性生成所有组合比自己写循环嵌套省太多事。交叉连接最大的风险是误用你如果忘了写WHERE条件或者写了一个极宽的条件基本都有匹配结果集就会爆炸式增长。曾经有个实习生跑一个统计报表三张表CROSS JOIN每张表才几千行结果集直接变成了几亿行把测试库磁盘都塞满了。所以在生产环境里看到CROSS JOIN一定要多留个心眼先确认范围。2.5 自连接同一张表自己和自己JOIN自连接是JOIN一个比较巧妙的变体一张表和它自己进行连接。表面上看很奇怪但实际非常常见最典型的就是“员工表和上级”这种层级关系。假设我们把users表扩展一下加一个leader_id字段表示这个用户的直属上级是谁CREATE TABLE emp ( id INT PRIMARY KEY, name VARCHAR(50), manager_id INT ); INSERT INTO emp VALUES (1, 张三, NULL), (2, 李四, 1), (3, 王五, 1), (4, 赵六, 2);现在想查出“每个员工以及他的上级名字”SELECT e.name AS 员工, m.name AS 上级 FROM emp e LEFT JOIN emp m ON e.manager_id m.id;结果员工上级张三NULL李四张三王五张三赵六李四关键就是给同一张表起两个不同的别名e代表“员工表”m代表“上级表”通过manager_id关联。用LEFT JOIN保证没有上级的张三老板也能显示出来。自连接在组织结构、商品分类多级树、链路追踪这类场景里非常香。3. ON和WHERE的分工差异筛选时机的决定性区别很多MySQL新手在写LEFT JOIN的时候会踩一个隐蔽的坑筛选条件写在ON后面还是WHERE后面结果完全不一样。先看一个具体问题。我接到过一个需求统计“每个用户的订单总金额”但也只需要统计已支付订单假设orders表有status字段。我当时的第一版SQL是这样写的SELECT u.name, o.status, SUM(o.amount) AS total FROM users u LEFT JOIN orders o ON u.id o.user_id AND o.status paid GROUP BY u.id;乍一看没毛病ON后面加了o.status paid似乎既做了关联又做了过滤。然后另一个同事是把过滤条件放在WHERE里SELECT u.name, SUM(o.amount) AS total FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.status paid GROUP BY u.id;运行结果大相径庭。关键在于LEFT JOIN的语义保证左表所有行必须保留。ON里的条件在“决定右表是否匹配”这一步生效如果右表因为没有满足条件的行而匹配失败结果里右表字段一律是NULL但左表这一行依然保留。而WHERE是在JOIN完成之后对整个结果集做最终过滤这时候NULL会被o.status paid这个条件直接筛掉左表中没有已支付订单的用户就彻底消失了。拿我这个例子跑一遍第一种写法ON里带条件没买过东西的“老王”“赵姐”依然在结果里金额为0买了东西但只有未支付订单的用户也会以金额0出现。第二种写法WHERE里带条件没买过东西的用户、以及只买过未支付订单的用户全部从结果里消失。这两种结果背后的业务含义完全不同前者是“所有用户的已支付订单汇总没支付的算0”后者是“只统计至少有一笔已支付订单的用户”。如果你没有意识到这个差异把WHERE版本当成答案交给业务方那少统计的用户名单会让你在复盘会上非常难堪。怎么记住这个区别我自己总结的口诀是ON负责“定关系”WHERE负责“筛结果”。想在JOIN过程中控制“右边匹配的条件”就写ON。想在JOIN结果生成之后做条件过滤就写WHERE。对于INNER JOIN来说ON和WHERE没有区别因为INNER JOIN本来就要淘汰不匹配的行写在哪里最终结果都一样但建议还是把关联条件写ON里把过滤条件写WHERE里语义清晰便于维护。对于LEFT/RIGHT JOIN两者有巨大区别务必按语义放对位置。后续做性能优化和写复杂报表的时候这个区分会反复用到属于那种“一开始不重视、后面一定会回来补课”的基础知识。4. 驱动表、索引与EXPLAIN联合查询性能的三件套很多人把JOIN只当成“语法问题”觉得能查出正确结果就行。但一旦数据量上来SQL慢得像爬你才会意识到JOIN性能有多重要。这里我不打算列一堆高深理论只讲三个最核心的东西驱动表是谁、连接字段有没有索引、EXPLAIN里到底要看哪些列。先普及一个基础概念JOIN执行的时候MySQL会先选一张表作为“驱动表”逐行取出数据然后去另一张表里找匹配的行。驱动表的每一行都会触发一次对另一张表的查找。所以驱动表通常希望是结果集较小的一方而被驱动表上的连接字段最好有索引这样每次查找都是索引查询成本极低。我用EXPLAIN看一个真实例子EXPLAIN SELECT u.name, o.amount FROM users u LEFT JOIN orders o ON u.id o.user_id;执行计划输出里重点看这几列id执行计划的步骤编号。table当前步骤访问的表。type访问类型从好到差大致是system const eq_ref ref range index ALL。看到ALL就意味着全表扫描数据量大的时候很危险。key实际使用的索引名。rows预估扫描的行数。Extra额外的附加信息比如Using where、Using index、Using temporary、Using filesort等。上例中orders表如果没有在user_id上建立索引你会在EXPLAIN里看到对orders表的那一行是type: ALLkey: NULL说明每一次驱动表取一行都要全表扫描orders一次。users表4行的时候还好说但如果users表有1万行orders表有1万行那就意味着1万次全表扫描每次1万行——理论上最坏情况是1亿行的扫描量性能直接爆炸。解决办法很简单给连接的字段建索引。ALTER TABLE orders ADD INDEX idx_user_id (user_id);建完索引再EXPLAINorders那行会变成type: ref、key: idx_user_id扫描行数大幅下降性能天壤之别。还有一个经验值得一提连接字段的数据类型必须一致。很多坑就藏在数据类型不匹配上。比如orders.user_id是VARCHARusers.id是INTMySQL会做隐式转换索引可能直接失效EXPLAIN出来还是ALL。两个表连接前先检查字段类型和排序规则COLLATION是否一致这是DB设计阶段就该避免的问题但现实里我见过太多因为类型不一致导致JOIN慢的例子了。说到大表的JOIN还有一条扩展建议如果你JOIN之后还要做GROUP BY或者ORDER BY尤其要留意EXPLAIN里的Using temporary和Using filesort。这两个东西出现时代表MySQL需要在临时表里操作数据或者对结果排序数据量大时会非常吃内存和磁盘IO。优化思路通常是让GROUP BY / ORDER BY的字段尽可能来自同一个表且最好能走索引实在不行就先缩小结果集再排序别让排序发生在全量JOIN结果之上。最后说一句关于版本差异的体会。MySQL 8.0相比5.7优化器对JOIN的处理更加智能很多场景下会自动选择更优的执行计划还引入了hash join当连接字段没有索引时8.0会用hash join替代传统嵌套循环性能比5.7的全表扫描嵌套要好不少。但即便优化器再强也不意味着你可以在连接字段上不建索引。索引仍然是JOIN性能最根本的保障。你建好索引、控制好结果集大小再让优化器去发挥这才是稳妥的做法。5. 实战场景拆解一堆真实案例和前人踩过的坑前面把基本理论和性能基础讲完了这一节我按实际业务里最常见的几类查询需求把联合查询揉碎了过一遍。每个场景都给出了SQL和思路重要是这些坑我基本都真实踩过。5.1 一对多JOIN导致SUM翻倍最经典的统计陷阱这是让无数人头疼的“重复值”问题。比如统计每个用户的订单总金额SELECT u.id, u.name, SUM(o.amount) AS total FROM users u LEFT JOIN orders o ON u.id o.user_id GROUP BY u.id;小明有两笔订单金额分别是99和199总和应该是298。这个SQL跑出来没问题。但如果你想再加一个维度比如同时JOIN了“订单表”和“退款表”假设一群用户有退款记录退款表里每个用户也可能有多条记录。你把这个表也JOIN进来之后问题就来了订单和退款是多对多关系JOIN会把两边都展开金额就会莫名翻倍。举个具体数字小明有2笔订单总金额298同时有2笔退款总退款50。如果直接JOIN结果可能是4行数据2笔订单×2笔退款SUM(amount)会把订单金额重复算两遍变成596。这个“翻倍”效果极其隐蔽因为单看每一行都挺合理只有对总数的时候才会发现对不上。解决思路有几种先各自聚合再JOIN。分别算出每个用户的订单汇总和退款汇总再用JOIN把结果拼起来。这是最推荐的做法逻辑直观也不会产生中间笛卡尔积。SELECT u.id, u.name, o.total_amount, r.total_refund FROM users u LEFT JOIN ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) o ON u.id o.user_id LEFT JOIN ( SELECT user_id, SUM(amount) AS total_refund FROM refunds GROUP BY user_id ) r ON u.id r.user_id;**用COUNT(DISTINCT)**处理去重计数但SUM没法用DISTINCT去重所以对求和类需求第1种方法最稳。我在实际项目里因为这个翻倍问题专门做过一次数据修复当时还怀疑是订单数据写重了排查到最后发现是JOIN的锅。从此之后凡是涉及多表SUM我默认先用“子查询先聚合”的思路。5.2 三表甚至四表JOIN联表顺序与逻辑拆解真实业务中JOIN三张表很常见比如订单要关联用户、关联商品、关联门店SELECT o.order_no, u.name, p.product_name, s.store_name FROM orders o INNER JOIN users u ON o.user_id u.id INNER JOIN products p ON o.product_id p.id LEFT JOIN stores s ON o.store_id s.id;这种长JOIN看着唬人其实从左往右拆开看就是一步步在“补信息”先拿订单表通过用户ID补用户信息通过商品ID补商品信息再通过门店ID补门店信息。LEFT JOIN在这里保证门店字段允许为空——比如某些线上订单没有门店。写这种多表JOIN时我有个习惯把过滤条件尽量下沉到每个子查询里而不是全部JOIN完再WHERE。比如只需要查“2024年5月之后下单”的数据我会先把orders表过滤一次或者直接用索引条件再去做JOIN。这样联合查询的中间结果集会小很多性能更好。虽然MySQL的执行计划不一定是完全按SQL的书写顺序执行优化器也会自己调整但人为减少参与JOIN的数据量永远是有意义的。5.3 在JOIN结果里做分组统计关联字段要选对接第3节的那个例子需求变成“每个用户的订单数和总金额并且要带上用户等级”。SQL可以这样写SELECT u.id, u.name, u.level, COUNT(o.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, u.level;这里有两个细节容易被忽略COUNT要用COUNT(o.id)不要用COUNT(*)。COUNT(*)会把NULL也计算进去——如果一个用户没有订单LEFT JOIN的结果里orders表的字段全是NULL但COUNT(*)返回的是1会让“没买过东西的用户”变成“有1笔订单”。用COUNT(o.id)只统计非NULL的订单ID才是正确结果。这个坑我在面试题里看到过很多次也在实际报表里见过属于典型低级错误但极其隐蔽。GROUP BY要把u.name、u.level也带上。MySQL有一个众所周知的“ONLY_FULL_GROUP_BY”模式在8.0里默认开启。如果你的SELECT里有非聚合字段比如u.name但GROUP BY里没有对应字段SQL会直接报错。即便在老版本里能跑结果也充满不确定性——优化器选哪一行完全是随机的。所以测试查询前先看一眼sql_mode。SELECT sql_mode;看到有ONLY_FULL_GROUP_BY就老老实实把所有非聚合字段写进GROUP BY。5.4 用UNION拼出FULL OUTER JOIN效果MySQL没有原生的FULL OUTER JOIN但很多需求确实要“左边有但右边没有的 右边有但左边没有的 两边都有的”。最典型的场景是“找出所有用户和订单的完整对应关系”连孤儿订单没有对应用户的订单也要列出来。实现方式就是把LEFT JOIN和RIGHT JOIN的结果用UNION拼在一起SELECT u.id AS user_id, u.name, o.id AS order_id, o.amount FROM users u LEFT JOIN orders o ON u.id o.user_id UNION SELECT u.id AS user_id, u.name, o.id AS order_id, o.amount FROM users u RIGHT JOIN orders o ON u.id o.user_id;注意我用的UNION而不是UNION ALL。UNION会自动去重会把两个结果集里完全相同的行去掉。但在LEFT JOIN和RIGHT JOIN组合的场景里重复行通常不会出现因为两个结果集的NULL填充位置不同所以用UNION ALL也行性能还更好。不过为了保险起见我第一次跑这种SQL还是会加UNION去重确认结果无误后再看情况要不要改写成UNION ALL。还有一种做法是用LEFT JOIN ... WHERE ... IS NULL分别找出两边独有的记录再UNION ALL。这种写法语义更明确但对新手来说不如直接UNION两个JOIN直观。5.5 JOIN加子查询时别滥用后者的过滤条件还有一个常见做法需要提醒在LEFT JOIN的右表里套子查询时很多人会把外层条件误传入内层子查询然后发现数据少了。比如SELECT u.name, t.total FROM users u LEFT JOIN ( SELECT user_id, SUM(amount) AS total FROM orders WHERE created_at 2024-05-01 GROUP BY user_id ) t ON u.id t.user_id;这样写是安全的因为子查询里已经先过滤了时间范围。但如果你在子查询外再写WHERE t.total IS NOT NULL等于又把“没有订单的用户”过滤掉了——此时“没订单”和“订单不在时间范围内”的用户都被剔除了。你要想清楚业务到底要求哪种是“所有用户都要出现哪怕订单为0”还是“只要这段时间内有订单的用户”。把这个问题搞清楚SQL怎么写都清晰。5.6 EXISTS与JOIN的抉择谁都不总是对的很多教条会说“能用EXISTS就不要用IN能用JOIN就不要用子查询”实际情况没有这么绝对。MySQL优化器会做等价改写很多时候你写JOIN和写EXISTS最终执行计划是一样的。我有一次优化一个慢查询把子查询改成JOIN之后性能纹丝不动因为优化器早就把两者改写成同一种执行计划了。那你到底该怎么选我的建议是查出结果集的“数据行”用JOIN更直观尤其是还要返回关联表的字段时。只做“存在性判断”比如“找出有订单的用户”EXISTS在语义上更清晰而且避免了因为JOIN导致用户行重复的问题。-- 存在性判断推荐EXISTS SELECT u.* FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id u.id );所谓的“性能差异”更多来自你没建索引、没控制数据量而不是JOIN和EXISTS本身。选一个你能读懂、能维护的写法比追着性能微调更重要。别被网上那些“永远不要用IN”的说法带偏。6. 联合查询的调优实战思路从慢SQL到执行计划很多时候问题不是“查不出来”而是“查得慢”。这一节专门讲当我拿到一个慢的JOIN查询时完整的排查思路是什么。假设线上有一个SQL是订单列表页关联了用户、商品、门店三张表数据量300万订单量级页面要3秒才出来。我会按下面的顺序排查第一步看EXPLAIN。先跑EXPLAIN看每个表的访问类型。重点是找到type: ALL、key: NULL的表这些是性能短板。常见问题就是JOIN字段没建索引。第二步检查字段类型与排序规则。如果JOIN字段类型一个是INT一个是VARCHAR即使建了索引也可能失效。用SHOW CREATE TABLE看表结构确认两边的字段类型完全一致。第三步尝先把限制条件下沉到子查询。比如订单列表只需要最近30天的数据那就先把orders按created_at过滤成子查询结果再和users、products JOIN。这样参与JOIN的数据量能降低好几个数量级。第四步关注ORDER BY和LIMIT。JOIN之后ORDER BY是性能大头。如果SQL里有ORDER BY o.created_at DESC LIMIT 20而SQL需要先JOIN完全部数据再排序代价巨大。优化思路确认索引能否覆盖排序字段或者让驱动表先按排序字段取20条再去关联其他表——这类“延迟关联”技巧在大分页场景里特别管用。第五步看服务器状态变量。如果发现Created_tmp_disk_tables很高说明临时表落盘了多半是GROUP BY或去重逻辑造成的。可以考虑调整tmp_table_size、max_heap_table_size参数但治标不治本根本办法还是减少中间结果集的大小。第六步确认SQL模式与版本差异。8.0的hash join和优化器改写能力都很强但如果你项目还是5.7那JOIN的优化策略会保守不少。升级前先跑一遍关键SQL对比执行计划。还有一个实践中的体会别一上来就加索引。索引能解决的问题有限如果是因为JOIN产生的中间结果集太大加再多索引也救不回来。你首先要做的是减少参与JOIN的数据量把统计口径搞清楚再去谈索引优化。索引是最后一道防线不是第一招。7. 聚合查询与JOIN的深入联动别用一条SQL解决所有问题最后再深入聊聊JOIN和聚合查询的组合使用。这是联合查询里最容易写错、也最容易出现性能问题的地方。很多人写统计SQL喜欢一条到底从主表JOIN一堆表然后GROUP BY然后HAVING然后ORDER BY。短时间看是方便但一旦数据量大、表多这条SQL就变成黑盒子既不好调优也不好维护。我的习惯是分步聚合法先分别算出每个维度的统计结果最后再JOIN在一起。虽然看起来SQL变长了但每一步都是独立的、可控的小查询EXPLAIN和调优都容易很多。举一个综合案例统计每个用户的订单总金额、订单数、退款总金额并按金额倒序。第一步先算订单侧第二步再算退款侧第三步把两个结果LEFT JOIN起来再和用户表聚合。SELECT u.id, u.name, IFNULL(t1.order_count, 0) AS order_count, IFNULL(t1.total_amount, 0) AS total_amount, IFNULL(t2.refund_amount, 0) AS refund_amount FROM users u LEFT JOIN ( SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) t1 ON u.id t1.user_id LEFT JOIN ( SELECT user_id, SUM(amount) AS refund_amount FROM refunds GROUP BY user_id ) t2 ON u.id t2.user_id ORDER BY total_amount DESC;这种写法的好处是每个子查询各自聚焦一类数据中途不容易因为多对多JOIN产生翻倍。逻辑从上到下读一遍就能懂不像一条大JOIN需要人脑模拟执行过程。排查数据差异时能单独跑子查询验证快速定位是订单侧问题还是退款侧问题。我自己在实际工作中但凡查询超过两张表的聚合基本都采用这种“先聚合后JOIN”的思路。它牺牲了一点SQL的“紧凑感”换来了极高的可读性和可维护性在项目长期维护中非常值。顺带说一个细节ORDER BY total_amount DESC里面用的total_amount是SELECT里定义的别名在MySQL中你可以在ORDER BY里直接引用别名这在GROUP BY查询里是个常见且好用的写法。但要注意别名不能用在WHERE因为WHERE在SELECT之前执行用在HAVING和ORDER BY是没问题的。8. 写在最后几个我养成很久的JOIN习惯联合查询这块内容说多不多说少不少但真正能让SQL写得又快又稳的不是背语法而是养成几个好习惯。最后把这些习惯分享给你。习惯一写JOIN必成对检查ON条件。我见过太多人写LEFT JOIN时ON条件只写了关联键忘了把业务条件一并考虑进去结果多出一堆不该出现的行。每写完一条JOIN先想想“这两张表通过什么关系连接这个关系是否唯一如果一张表的一行对应另一表多行我是否真的需要这么多行”习惯二复杂查询先跑子查询验证。如果是三层以上的JOIN先分别跑各子查询确认数据正确后再拼起来。这样出问题的时候你能快速定位。一次拼好然后排查半天效率太低。习惯三SELECT字段显式写清楚。不要用SELECT *而是把需要的字段列出来。好处有两个一是结果集传输量小二是JOIN时如果有多表同名字段不会引起混乱。我发现很多新手因为SELECT *导致查出来的列根本不知道来自哪张表排查起来非常痛苦。习惯四给表起有意义的别名。单表可以用a、b这样的简短别名多表JOIN时最好用能辨识的缩写比如users表用uorders表用orefunds表用r。短别名在SQL里写起来清爽读起来也比全名更容易。再补充一个易忽略的点JOIN条件里的字段如果是字符串注意字符序collation一致。两个表字段都是VARCHAR(50)但一个的collation是utf8mb4_general_ci另一个是utf8mb4_unicode_ciJOIN时MySQL可能没法走索引甚至报“Illegal mix of collations”错误。建表时统一字符集和排序规则能省掉很多莫名其妙的问题。联合查询看起来是很基础的内容但越基础越容易出低级错误。今天这篇笔记覆盖了JOIN的类型、ON与WHERE的差异、性能分析和实战案例。我特意没写太多理论重点把那些我在真实项目中遇到过的场景和解决的思路呈现了出来。建议你拿起手边的数据库照着例子建两张带点脏数据的表亲手体验一遍各类JOIN的结果差异这样会比单纯看文章印象深得多。