前面五篇把单表查询的底子打得差不多了WHERE过滤、ORDER BY排序、LIMIT分页、聚合函数、GROUP BY分组这些单独拎出来都不至于发怵。但真正到了业务里你会发现大多数报表需求不是一张表能搞定的。用户信息放一张表订单流水放另一张表你总得把两边的数据“拼”起来再查——这就是本系列第六篇要说的多表连接查询JOIN以及和它形影不离的子查询。不是我不舍得在前面讲而是单表查询都没练熟就上JOIN很容易被“连接结果为什么多出来几行”“为什么一加WHERE就少数据”这类问题绕晕。这篇先把需求场景理清楚再讲JOIN的三种类型接着给一套可以直接抄的实操SQL最后把子查询和常见翻车点一并收拾了。适合已经有单表查询基础、正准备迈过“多表”这道坎的朋友。1. 从三个真实需求倒推为什么单表查询满足不了业务1.1 需求一订单列表要带用户昵称假设你在做一个交易系统用户表users只管用户基本资料订单表orders只管订单金额、状态和时间。两张表之间靠user_id互相引用这是再常见不过的设计。现在运营提了一个需求把最近一个月的订单列表导出来要包含用户昵称。你打开orders表一看里面只有user_id没有昵称再去users表里一条条查昵称订单多的时候能查到天亮。这里的本质问题是信息分散在不同表里单表查询没法把“跨表的完整信息”一次取出来。你当然可以在应用层写循环去补全用户昵称但那会产生N条查询既慢又啰嗦。SQL里解决这个问题的标准动作就是 JOIN把两张表通过共同字段拼成一张宽表再查。1.2 需求二统计每个用户到底下过几单再看第二个需求统计每个用户的下单次数一个用户一行没下过单的用户也要显示出来次数标成0。如果只有orders表你可以用GROUP BY user_id数出每个有订单的用户各下了几单。但users表里那些从来没有下过单的用户压根不会出现在orders表里GROUP BY数不到他们。换句话说以订单表为主表做统计天然丢掉了“无订单用户”。这个需求背后的关键点是你要搞清楚哪张表是“主表”、哪张表是“附表”。主表的数据要全部保留附表只负责补充信息附表没有匹配到主表的行就补NULL。这种“保留主表全部行”的连接就是后文要讲的 LEFT JOIN。它跟 INNER JOIN 的区别直接决定了统计口径准不准。1.3 连接键多表查询的关键就在于它两张表能被拼在一起靠的是连接键也就是 ON 条件里写的那一对字段。最常见的连接键是“一张表的主键 另一张表的外键”比如users.id orders.user_id。连接键的选择会影响结果行数。如果users.id是主键、值唯一那么LEFT JOIN orders之后订单多的人就会重复出现多次一笔订单对应一行。这个“行数膨胀”不是错误而是连接查询的固有行为后面会专门说它在什么情况下会变成事故。另外还要啰嗦一句既然连接要基于连接键做匹配那连接键上的索引就非常重要。没有索引时MySQL只能对驱动表的每一行去被驱动表里做全表扫描数据量一大就是灾难。这个放到最后一节结合执行计划细聊。2. JOIN三兄弟怎么选INNER、LEFT、RIGHT的适用边界2.1 三种连接的执行逻辑一句话版本MySQL里日常用的连接类型主要有三种执行逻辑可以用一句话概括INNER JOIN只保留两表都匹配得上的行匹配不上的两边都丢LEFT JOIN左表所有行都保留右表匹配不上就补NULLRIGHT JOIN右表所有行都保留左表匹配不上就补NULL。实际开发中 RIGHT JOIN 用得极少因为把表顺序调换一下RIGHT JOIN 就能改写成 LEFT JOIN而 LEFT JOIN 的阅读习惯更符合“从左往右看主表”的直觉。你只要把 LEFT JOIN 吃透RIGHT JOIN 就当作对称写法偶尔看一眼。三者的关系可以用集合来理解如果左表集合是A右表集合是BINNER JOIN 取的是 A∩BLEFT JOIN 取的是整个A多出来的右表部分能补多少补多少。很多面试题喜欢画三个圆来考其实落到SQL里判断标准就一句话哪边不能丢就用从那边出发的连接。2.2 ON和WHERE的过滤差异这是新手最容易翻车的地方有个细节很多人踩坑踩到怀疑人生同样的条件写在 ON 后面和写在 WHERE 后面结果可能完全不一样尤其是在 LEFT JOIN 里。先看一组对比同样查“用户已完成订单”-- 写法A条件写在ON后面 SELECT u.id, u.name, o.id AS order_id FROM users u LEFT JOIN orders o ON u.id o.user_id AND o.status 已完成; -- 写法B条件写在WHERE后面 SELECT u.id, u.name, o.id AS order_id FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.status 已完成;写法A会返回所有用户。没有已完成订单的用户订单字段为 NULL用户本身保留。写法B返回的却只有“有已完成订单”的用户凡是订单字段为 NULL 的行全被 WHERE 过滤掉了。名义上你写的是 LEFT JOIN实际结果等价于 INNER JOIN。原因要追溯到SQL的逻辑执行顺序ON 是在连接阶段生效的WHERE 是在连接结果生成之后才过滤的。LEFT JOIN 先按 ON 条件把左表行都保留生成中间结果然后 WHERE 对这个中间结果动手右表字段是 NULL 的行自然逃不过过滤。所以在实际项目里我的习惯是查询出来要“保留主表全部行”时条件尽量写在 ON 里如果业务上就是要做内连接的效果那就明明白白写 INNER JOIN别用 LEFT JOIN 的壳配 WHERE 的刀既误导同事也容易改坏。2.3 CROSS JOIN与老式逗号连接什么时候会踩雷除了上面三种还有 CROSS JOIN也就是交叉连接它把两表的每一行都互相配对一次不做任何匹配条件。结果行数是两表行数的乘积。SELECT u.name, o.amount FROM users u CROSS JOIN orders o;这种写法极少单独使用但有个隐藏雷区老式逗号连接FROM users u, orders o等价于 CROSS JOIN。如果你在后面 WHERE 里写了u.id o.user_id那它就成了带过滤条件的交叉连接结果等于 INNER JOIN能跑通。可要是哪天 WHERE 写漏了条件就是一个灾难性的笛卡尔积——比如用户表8行、订单表10行查出来80行。更麻烦的是逗号连接里混杂了“过滤”和“连接”两种语义可读性也差。所以我个人的红线是新代码一律用明确的 JOIN 关键字 ON 条件禁用逗号连接。正则表达式搜到老代码里有FROM a, b WHERE的我会顺手改成标准写法。3. 实操演示建两张表、造一批数、把JOIN跑一遍3.1 准备数据用户表和订单表理论再多也不如亲手敲一遍。我准备了两张模拟表users有8个用户orders有10笔订单刻意造了几类边界数据有用户下了多笔订单、有用户没下过单、还有一笔订单的user_id是 NULL。CREATE TABLE users ( id INT PRIMARY KEY, name VARCHAR(20), city VARCHAR(20), age INT ); INSERT INTO users VALUES (1, 小A, 东区, 28), (2, 小B, 西区, 32), (3, 小C, 南区, 25), (4, 小D, 北区, 41), (5, 小E, 东区, 36), (6, 小F, 西区, 29), (7, 小G, 南区, 33), (8, 小H, 北区, 27);CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, amount DECIMAL(10,2), status VARCHAR(10), order_time DATETIME ); INSERT INTO orders VALUES (101, 1, 199.00, 已完成, 2024-11-01 10:23:00), (102, 2, 599.00, 已完成, 2024-11-02 14:11:00), (103, 1, 89.00, 已取消, 2024-11-03 09:00:00), (104, 3, 1280.00, 已完成, 2024-11-05 16:40:00), (105, 5, 320.00, 已完成, 2024-11-06 12:02:00), (106, NULL, 450.00, 已完成, 2024-11-07 08:30:00), (107, 6, 88.00, 已完成, 2024-11-08 21:15:00), (108, 2, 210.00, 已取消, 2024-11-09 11:11:00), (109, 4, 760.00, 已完成, 2024-11-10 10:00:00), (110, 8, 150.00, 已完成, 2024-11-11 20:20:00);这套数据里用户7号“小G”没有任何订单订单106没有关联用户。这两个边界行就是检验你写的是真 LEFT JOIN 还是假 LEFT JOIN 的最佳试金石。3.2 第一轮INNER JOIN 和内连接等价的 WHERE 写法先来内连接查“每个订单对应的用户姓名”SELECT u.id, u.name, o.id AS order_id, o.amount, o.status FROM users u INNER JOIN orders o ON u.id o.user_id WHERE o.status 已完成;执行结果大致是这样idnameorder_idamountstatus1小A101199.00已完成2小B102599.00已完成3小C1041280.00已完成5小E105320.00已完成6小F10788.00已完成4小D109760.00已完成8小H110150.00已完成注意几个细节订单108和103因为状态是“已取消”被过滤掉了订单106因为user_id是NULL匹配不到任何用户也被丢掉了用户7号“小G”没有订单同样不出现。这就是 INNER JOIN 的行为——两边都必须匹配上任何一边缺失都上不了结果表。这个查询用老式写法也能跑通FROM users u, orders o WHERE u.id o.user_id AND o.status 已完成结果完全一致但我仍然建议用标准的 INNER JOIN语义清晰以后加条件也不会把连接和过滤混在一起。3.3 第二轮LEFT JOIN 看 NULLRIGHT JOIN 看缺失接着看 LEFT JOIN这次不限制订单状态把用户表和订单表全量连起来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 ORDER BY u.id;结果里用户7号“小G”会有一行order_id是NULLamount也是NULL。这就是 LEFT JOIN 的标志性输出左表没有匹配项就用NULL占位。再看订单106它的user_id是NULL在 LEFT JOIN 里因为左表是users106找不到与之相等的users.id于是直接被忽略。如果你想让“孤儿订单”也出现在结果里就得改用 RIGHT JOIN把orders放到右侧保留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;这时订单106会出现在结果里user_id和name都是NULL。实际业务里这种“孤儿数据”往往代表脏数据或者被删掉的关联记录查出NULL往往比查出正常值更有排查价值。3.4 第三轮LEFT JOIN GROUP BY 统计下单数回到开头那个“统计每个用户下过几单”的需求正确写法是这样SELECT u.id, u.name, COUNT(o.id) AS order_cnt FROM users u LEFT JOIN orders o ON u.id o.user_id GROUP BY u.id, u.name ORDER BY u.id;输出结果里7号“小G”的order_cnt是0而不会是1。这里有个很容易写错的地方如果统计时用了COUNT(*)那order_cnt会把“一行NULL占位”也算进去小G就变成1单了这是经典的统计口径错误。区分 COUNT(*) 和 COUNT(列名) 非常重要。COUNT(*)数是行数哪怕这一行全是NULLCOUNT(o.id)只数“非NULL值”LEFT JOIN 右表匹配不上的 NULL 不会被计入。统计关联数据量时我一律用COUNT(右表主键)因为这个写法天然规避了NULL带来的虚增。4. 子查询实战嵌套在WHERE和FROM里的两类核心用法4.1 WHERE子句子查询先算出边界再过滤主表多表查询还有一个常用补充技能是子查询也就是把一个 SELECT 的结果当作另一个 SELECT 的输入。子查询最常见的位置是 WHERE 子句里用来提供一个“过滤边界值”。比如查“订单金额高于平均金额的订单”SELECT id, user_id, amount, status FROM orders WHERE amount (SELECT AVG(amount) FROM orders);执行过程分两步先算出全表平均金额再在主查询的 WHERE 里跟每行 orders 比较。这个场景里子查询返回的是一个标量值直接可以和、、配合。子查询还能返回一个列表最常见的是配合 INSELECT id, user_id, amount, status FROM orders WHERE user_id IN (SELECT id FROM users WHERE city 东区);它先查出“东区”用户的所有 id得到一个结果集再让 orders 表按user_id是否在里面过滤。这里必须提醒IN 后面的子查询只能返回一列多列会直接报错“Operand should contain 1 column(s)”。如果不小心写了SELECT id, name FROM users WHERE ...MySQL会拒绝执行这在第5章会再提。4.2 FROM子句子查询把聚合结果当成临时表来JOIN子查询第二种常见落法是放在 FROM 子句里充当一张“临时表”然后跟其他表正常 JOIN。这种写法的典型场景是“先聚合再关联主表”。比如统计每个用户的已完成订单总金额同时要带出用户名SELECT u.name, t.total_amount, t.order_cnt FROM users u LEFT JOIN ( SELECT user_id, SUM(amount) AS total_amount, COUNT(*) AS order_cnt FROM orders WHERE status 已完成 GROUP BY user_id ) t ON u.id t.user_id ORDER BY t.total_amount DESC;逻辑拆开看更清晰内层子查询先按用户分组算出每个用户的已完成订单总额和笔数外层再用 LEFT JOIN 把用户名拼进来。因为子查询已经把多个订单聚合成了每个用户一行外层 JOIN 不会产生重复行这个技巧能避免很多“先JOIN再GROUP BY”造成的字段错乱。写这种查询有几个注意点。第一FROM 子句里的派生表必须起别名上面写的t必不可少。第二派生表里已经做了 WHERE 过滤外层就不要再用右表字段去过滤否则又掉进 LEFT JOIN 变 INNER JOIN 的坑。第三如果不需要保留无订单用户可以把 LEFT JOIN 换成 INNER JOIN结果更干净。4.3 相关子查询与EXISTS查“什么都没干”的用户还有一种子查询跟前面不太一样它引用了外层查询的列叫做“相关子查询”最常见的搭档是 EXISTS。查“下过大于500元订单的用户”SELECT u.id, u.name FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id u.id AND o.amount 500 );这个查询有点像嵌套循环外层拿到用户1就去内层找有没有属于他的、金额大于500的订单找到一条就返回 TRUE这条用户留下来。EXISTS 只看“有没有”不关心查出了几行所以子查询 SELECT 后面写 1 或者写o.id都一样纯粹是占位符习惯。反过来查“从没下过单的用户”用 NOT EXISTSSELECT u.id, u.name FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id u.id );这套写法比NOT IN更稳因为它处理NULL的方式更友好。如果orders.user_id里有NULLNOT IN子查询可能因为“跟NULL比较结果不确定”直接返回空结果而NOT EXISTS用逐行匹配判断不会踩这个雷。数据里若存在外键为NULL的脏数据选 EXISTS 方向基本不会出问题。5. 多表查询翻车现场高频报错与排查习惯实录5.1 高频报错对照表连接查询和子查询写多了报错信息就那么几个套路。我整理了一张对照表基本覆盖了日常能遇到的大头问题报错信息翻车原因解决方式Column id in field list is ambiguous两表都有id字段SELECT里没写表别名所有字段写成u.id/o.id的完整形式Every derived table must have its own aliasFROM 子查询没写别名给子查询加别名比如AS tOperand should contain 1 column(s)IN 后面的子查询返回了多个列只保留一个过滤列Subquery returns more than 1 row用匹配子查询但子查询返回多行改成IN或加LIMIT 1This is incompatible with sql_modeonly_full_group_bySELECT里写了非聚合字段却没放进 GROUP BY把该字段加入 GROUP BY或改成聚合函数包一层Unknown column o.user_id in where clauseLEFT JOIN 的驱动表顺序写反或表别名引用错确认表别名与 JOIN 里的位置一致这几个报错里“ambiguous”和“only_full_group_by”出现的频率最高。前者是偷懒不写别名导致的后者则是MySQL 5.7之后默认开启了严格模式老写法在低版本能混过去在高版本直接硬报错。5.2 排查习惯一先把 SELECT 换成 COUNT(*)连接查询结果对不对先别急着看明细我习惯把 SELECT 字段替换成COUNT(*)看总行数。比如写完一个 LEFT JOIN预估某个用户有2笔订单连接后总行数应该是各用户订单数之和再加无订单用户的NULL行。如果COUNT(*)和预估对不上八成是连接条件写错导致笛卡尔积或者多表连接时把两个一对多关系叠在一起放大了行数。这个习惯成本极低但能过滤掉大量低级错误。查数没问题再回头把 SELECT 字段换回来去核对字段内容。5.3 排查习惯二单拿 NULL 行出来看LEFT JOIN 最迷人的地方就是它会产出 NULL。反过来NULL 行也最适合用来验证连接逻辑对不对。比如查“没有订单的用户”可以这样SELECT u.id, u.name, o.id AS order_id FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.id IS NULL;这条 SQL 的 WHERE 条件利用了 LEFT JOIN 的补NULL特性专门捞出右表没有匹配的行。执行计划里它是靠“先连接再过滤”实现的不是说它性能一定差而是这种写法能帮你快速验证无订单用户到底有哪些、数量对不对。如果这个结果跟业务预期对不上就要回头查 orders 表里有没有脏数据了。5.4 排查习惯三EXPLAIN 看连接键有没有走索引查询结果对不代表性能没问题。线上慢查询大多是连接键没索引导致的。这时候执行计划比猜靠谱EXPLAIN SELECT u.name, o.amount FROM users u LEFT JOIN orders o ON u.id o.user_id;注意看type列如果是ALL说明被驱动表在做全表扫描这种JOIN基本等着超时如果是ref或eq_ref说明连接键上有索引能快速定位。没有索引就补一个ALTER TABLE orders ADD INDEX idx_user_id (user_id);至于驱动顺序MySQL优化器一般会拿小表当驱动表但前提是ON条件里的关联字段统计信息准确。实际开发里我很少手工指定STRAIGHT_JOIN先把两边连接键的索引补齐再配合 EXPLAIN 观察绝大多数场景都能跑得不错。6. 绕不开的性能细节连接键的索引与驱动顺序6.1 索引对JOIN的影响EXPLAIN看type上面提了 EXPLAIN 看type这里展开讲一下为什么连接键索引这么关键。JOIN 的底层逻辑本质是嵌套循环对于驱动表的每一行去被驱动表里找匹配行。如果被驱动表的连接键有索引这一次查找走的是 B 树搜索速度很快如果没有索引MySQL 只能对被驱动表做全表扫描驱动表有1万行被驱动表有100万行最坏就是1万次全表扫描数据量一大直接卡死。所以在设计索引时外键字段几乎是无脑要建索引的。像orders.user_id这种引用users.id的列默认不会自动建索引必须手动补上。案例里users.id是主键自然有索引所以前面的JOIN演示数据量小看不出差别真实生产环境里少一个索引就是慢查询与秒回的差距。6.2 小表驱动大表还有两个细节习惯驱动顺序也是老话题。JOIN 的规则简单说就是“小表驱动大表”先遍历行数少的表再拿着每一行的连接值去大表里匹配总匹配次数 小表行数 × 大表单次查找成本。刚才显式建索引是对连接键的基本尊重另外两个习惯也很重要一是 JOIN 查询里 SELECT 尽量只取需要的列别随手SELECT *尤其别把大字段比如很长的备注拖进中间结果二是子查询做派生表时要让子查询先做过滤减少外层连接的数据量。这些细节单看都不大叠在一起就是线上 SQL 稳定性的分水岭。我在实际项目里见过最简单的优化案例一个两表 LEFT JOIN 查询原本跑3秒给被驱动表的连接键加上索引之后直接掉到0.02秒连业务代码都没改。最后分享一个我自己的排查习惯。写连接查询先跑COUNT(*)核对行数再单独查一遍 NULL 行最后用 EXPLAIN 确认连接键走了索引——三步走完正确性和性能都有底。尤其是 LEFT JOIN写完一定检查 ON 条件里有没有混入过滤条件、WHERE 里有没有碰右表字段这两个位置放错轻则结果少数据重则报表口径错一整天。这套动作做顺手了多表查询基本不会给你惹大麻烦。