
1. 先弄清楚这三种连接到底在解决什么问题很多人写多表查询写到一半突然卡住不是因为语法不会而是因为脑子里没有一张行是怎么被拼出来的地图。mysql 自然连接、内连接、外连接的区别这个问题表面看是三个关键字的用法对比实际上考的是你对结果集行数来源的理解。我见过太多人把LEFT JOIN写成内连接的语义查出来的数据少了几条排查一下午最后发现是WHERE条件放错了位置。这类问题不需要背语法需要的是想明白每一步到底在做什么。这篇文章面向的是已经会写基础SELECT、但面对多表关联时还不够笃定的开发者也适合那些能跑就行但一直没搞懂外连接为什么有时会返回空行的朋友。我会用一份可控的测试数据把内连接、自然连接、外连接放在同一张桌面上逐行对比讲清楚它们各自的行是怎么产生的、ON 和 WHERE 为什么在外连接里效果完全不同、NATURAL JOIN为什么在生产代码里几乎见不到。读完你应该能做到看到一个关联需求立刻判断该用哪种连接并且预判出结果会有多少行。需要提前说明一句本文的结论基于 MySQL 8.0 的默认行为部分写法在 5.7 上语法一致但优化器表现略有差异涉及执行计划的段落我会单独标注。所有测试数据均为虚构表结构是我按常见业务抽象出来的简化版本你可以直接复制到本地库复现。2. 所有连接的共同起点笛卡尔积2.1 连接的物理本质是什么在讲三种连接之前必须先压住一个底层概念连接的第一步永远是笛卡尔积。以两张表 Am 行和 Bn 行为例数据库会先构造出 m × n 行的全部组合然后再用条件去筛。这个描述在逻辑层面是准确的物理层面优化器当然不会真的把所有组合都物化出来但理解逻辑顺序对排查问题极其关键。为什么这句话重要因为当你看到一条LEFT JOIN查询返回了意料之外的行数绝大多数情况不是连接类型写错了而是连接条件没能唯一确定匹配关系。比如订单表里一个订单有 5 条明细你用订单去左连明细结果必然膨胀 5 倍这不是左连接有 bug而是笛卡尔积在起作用只不过被ON条件裁剪成了 5 行而不是全部组合。我用一个生活化的类比你有两摞扑克牌左边一摞是用户右边一摞是订单。所谓连接就是先把左边的每张牌和右边的每张牌都配一次对这就是笛卡尔积然后按规则把不符合的配对扔掉。内连接是把配对成功的留下其他全扔左外连接是左边的牌一张都不能丢右边配不上的留空白。你看区别只在于配对失败时怎么处理而不在于配对的过程。2.2 一句话区分三种连接的处理策略抛开语法细节三者的差异可以压缩成下面这张表连接类型关键字未匹配行的处理结果集行数下界内连接INNER JOIN/JOIN/,直接丢弃可以为 0自然连接NATURAL JOIN丢弃但连接条件由系统自动推断可以为 0左外连接LEFT [OUTER] JOIN保留左表全部行右表补 NULL≥ 左表行数右外连接RIGHT [OUTER] JOIN保留右表全部行左表补 NULL≥ 右表行数这张表里最值得盯住的是最后一列。很多人对左连接一定比内连接返回更多行有直觉但说不清为什么原因就在这左连接的下界是左表行数而内连接的下界是 0。当你的业务逻辑是每个用户都要显示没下单的显示 0 单那答案只能是左连接内连接在语义上就直接排除了没有订单的用户。自然连接比较特殊它属于内连接的一个语法变体行数规则跟内连接完全一致差别只在于连接条件不是你写的是系统猜的。这个猜字就是后面所有麻烦的源头。3. 内连接最常用但坑都在条件的位置上3.1 INNER JOIN 的三种等价写法内连接是日常写得最多的一种MySQL 支持三种写法语义等价但可读性差异很大-- 写法一显式 INNER JOIN推荐 SELECT u.name, o.order_no FROM users u INNER JOIN orders o ON u.id o.user_id; -- 写法二省略 INNER直接 JOIN等价推荐 SELECT u.name, o.order_no FROM users u JOIN orders o ON u.id o.user_id; -- 写法三逗号 WHERE隐式内连接不推荐 SELECT u.name, o.order_no FROM users u, orders o WHERE u.id o.user_id;三种写法在 MySQL 里会被优化成同一个执行计划性能上没有区别。但我个人坚决只用前两种原因很实际第三种写法把连接条件和过滤条件混在同一个WHERE里一旦查询变复杂、加上三五张表你就再也分不清哪个条件是用来连表的、哪个条件是用来筛数据的。等到某天需要把内连接改成左连接你会发现无从下手因为改FROM子句之后原来的WHERE条件会自动把外连接退化成内连接结果跟你预期完全不一样。注意隐式内连接在 SQL 标准里已经被标为不推荐MySQL 8.0 的官方文档也明确建议使用显式JOIN语法。别图省那几个字符。3.2 ON 和 WHERE 在内连接里为什么没区别这是很多人第一个困惑点都说ON和WHERE在外连接里影响巨大那在内连接里呢答案是在内连接里两者效果完全相同。原因还是回到笛卡尔积内连接的处理顺序是先做笛卡尔积 → 用 ON 筛一遍 → 再用 WHERE 筛一遍但因为两个筛选都是不满足就丢弃整行所以把条件写在哪儿最终结果都一致。优化器甚至会直接把ON条件合并进WHERE一起处理。我用一组等价查询验证一下-- 查询 A条件写在 ON SELECT u.name, o.order_no FROM users u JOIN orders o ON u.id o.user_id AND o.status 1; -- 查询 B条件写在 WHERE SELECT u.name, o.order_no FROM users u JOIN orders o ON u.id o.user_id WHERE o.status 1;在 MySQL 8.0 上这两条语句的EXPLAIN输出是逐字段一致的扫描行数、使用的索引、连接算法都相同。所以你要是只写内连接这个区别可以暂时不管。但我要强调一个习惯问题即使内连接里两者等价也应该把连表关系放ON、业务过滤放WHERE。这不是性能要求是可维护性要求。一旦哪天需求变成未支付订单也要显示用户信息你需要把JOIN改成LEFT JOIN此时如果你的过滤条件在ON里改起来就是改一个关键字如果混在WHERE里你得先拆条件再改很容易拆错。3.3 多表内连接时条件放错的连锁反应三张表以上的内连接是最容易写乱的地方。举个例子users用户、orders订单、order_items订单明细要查下过已支付订单的用户及其商品SELECT u.name, o.order_no, i.product_name FROM users u JOIN orders o ON o.user_id u.id JOIN order_items i ON i.order_id o.id WHERE o.status 1;这条写法的执行顺序是users与orders按user_id关联结果再与order_items按order_id关联最后用status过滤。逻辑上没问题但如果数据量大status这个条件能不能提前下推到第一步会显著影响中间结果集的规模。MySQL 优化器通常会自动做这个下推但当你发现查询慢得离谱时手动调整写法比如用子查询先把已支付订单筛出来再连往往比等待优化器更可靠。我在实际项目里踩过一次坑一张 200 万行的订单表关联明细表WHERE o.status 1因为status字段选择性太差90% 都是已支付优化器估计错误选了错误的驱动表查询跑了 40 多秒。后来改成先子查询过滤再连接降到 1 秒以内。这件事让我养成了一个习惯看到内连接慢先用EXPLAIN看驱动表是谁再看过滤条件有没有生效在驱动表上。4. 自然连接看上去省事实际埋着三颗雷4.1 NATURAL JOIN 系统到底帮你做了什么自然连接的语法极简连ON都不用写SELECT u.name, o.order_no FROM users u NATURAL JOIN orders o;它的规则是自动找出两张表中列名相同的所有列用这些列做等值连接并且结果集中这些同名列只保留一份。注意是所有列名相同的列不是所有语义相关的列。这一点是理解所有问题的钥匙。假设users表结构是(id, name, created_at, updated_at)orders表结构是(id, order_no, user_id, created_at, updated_at)。你写NATURAL JOIN系统会自动用id、created_at、updated_at三列做连接条件等价于ON u.id o.id AND u.created_at o.created_at AND u.updated_at o.updated_at看出问题了吗id本来是用户主键orders.id是订单主键这两个字段只是名字碰巧相同语义上毫无关系。用它们做等值连接结果几乎必然是空集。这就是第一颗雷同名列的语义错配。4.2 三颗雷名字撞车、审计字段、隐式转换我把它拆开讲清楚这三条是我在代码评审里反复强调的第一颗雷主键撞名。如前所述绝大多数业务表都有id主键NATURAL JOIN会把它们当成连接条件直接导致结果错误。这个问题在测试数据少的时候可能表现为返回空看起来像 bug排查方向很容易跑偏到是不是没数据。第二颗雷审计字段。created_at、updated_at、deleted_at、tenant_id这些字段在规范化的表设计里几乎处处都有。用NATURAL JOIN时它们全会变成连接条件的一部分。即使数据恰好能对上比如同一秒创建的记录也会让查询计划变得极其诡异。更麻烦的是tenant_id这种字段虽然语义上确实应该一致但把它放进连接条件会让索引选择变得不可控。第三颗雷隐式类型转换导致索引失效。如果两张表的同名列类型不完全一样比如一张是INT一张是BIGINT或者一张有符号一张无符号MySQL 会在比较时做隐式转换。一旦发生隐式转换该列上的索引就用不上了查询会退化成全表扫描。这个问题最阴险因为语法上完全合法结果也看起来对只是慢。我在一个报表查询里遇到过NATURAL JOIN写得好好的数据也正确就是从 0.1 秒变成 8 秒查了半天才发现是同名列的类型差异。-- 快速检查两张表同名列的类型是否一致 SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA your_db AND TABLE_NAME IN (users, orders) ORDER BY COLUMN_NAME, TABLE_NAME;执行这条查询把结果按COLUMN_NAME分组看凡是同一个列名出现两行且COLUMN_TYPE不一致的都是潜在的风险点。4.3 USING 子句我给自然连接的替代方案如果确实想享受不用写等值条件的便利我会推荐USING而不是NATURAL JOINSELECT name, order_no FROM users JOIN orders USING (user_id);USING的好处非常明确连接列由你显式指定不存在自动推断的风险结果集中该列只保留一份省去了u.user_id/o.user_id的歧义问题注意SELECT *时这一列不会重复出现语法检查严格写错列名会直接报错而不是像NATURAL JOIN那样悄悄地用另一套条件跑出结果USING的局限也存在它只支持等值连接不适用于不等值关联比如找出金额在某个区间内的订单。所以我给的建议是等值连接且连接列名完全相同时可以用USING其他一律写完整的ON。至于NATURAL JOIN我的态度很明确——读别人的代码要能看懂自己写就别用了。提示NATURAL JOIN与NATURAL LEFT JOIN、NATURAL RIGHT JOIN是一套语法。后者虽然保留了左表或右表的全部行但连接条件依然是自动推断的雷一个不少。别以为加上LEFT就安全了。5. 外连接真正的难点在 ON 和 WHERE 的分工5.1 LEFT JOIN 的执行模型用一张图说清楚左连接的处理可以拆成三步我按顺序写取左表的每一行这是驱动表一行都不能少去右表找满足ON条件的行找到了就拼接输出找不到就用 NULL 填充右表的所有列输出一行右连接正好反过来以右表为驱动表。理解这三步之后很多诡异的结果都能解释。比如SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON o.user_id u.id AND o.status 1;这条查询返回的是所有用户以及他们已支付的订单号。没下过单的用户会出现在结果里订单号是 NULL下过单但全是未支付的用户也会出现订单号同样是 NULL。这正好符合每个用户都要显示的业务需求。对比一下等价改法SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON o.user_id u.id WHERE o.status 1;这条查询的语义完全变了。WHERE是在连接完成之后执行的此时没匹配上的行里o.status是 NULLNULL 1的结果是UNKNOWN不是TRUE所以整行被过滤掉。左连接被WHERE条件强行退化成内连接那些没有已支付订单的用户全部消失。这是外连接里最高频的 bug没有之一。5.2 把过滤条件嵌进连接子查询的写法上面那个需求除了把条件放ON里还有一种写法在复杂场景下更清晰SELECT u.name, t.order_no FROM users u LEFT JOIN ( SELECT user_id, order_no FROM orders WHERE status 1 ) t ON t.user_id u.id;这种写法的优势在于当过滤逻辑很复杂涉及多个条件、子查询、聚合时ON后面的表达式会变得又长又难读把它塞进一个派生表里主查询的结构就干净了。代价是 MySQL 需要先物化这个派生表在 8.0 之前这可能带来额外的开销8.0 之后优化器能更好地做条件下推实际影响通常可以接受。我在做报表类查询时更倾向第二种因为报表的过滤条件往往一改再改藏在ON里的条件改起来容易顾此失彼。而在 OLTP 的简单查询里直接写ON更省事。5.3 MySQL 没有 FULL OUTER JOIN三种替代写法实测MySQL 至今不支持FULL OUTER JOIN这是很多从其他数据库迁移过来的人会踩的坑。如果确实需要两边的行都要保留有三种替代方案方案一UNION 拼接左右连接SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id o.user_id UNION SELECT u.name, o.order_no FROM users u RIGHT JOIN orders o ON u.id o.user_id;注意这里我用了UNION而不是UNION ALL。因为两侧查询的匹配成功部分是完全重复的必须去重。如果业务上可以接受重复或者你确信不存在重复比如其中一侧不可能有匹配用UNION ALL性能会好一些。方案二UNION ALL 配合反连接SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id o.user_id UNION ALL SELECT NULL, o.order_no FROM orders o WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.id o.user_id);这种写法的思路是左连接负责左表全部 匹配部分反连接NOT EXISTS负责右表中没被匹配上的部分两部分拼起来正好是全外连接且没有去重开销。在数据量大的时候这个版本通常比UNION快。方案三业务层拆分查询。说实话我在实际项目里从没真正需要过全外连接。绝大多数业务需求都能拆成两个独立的查询在应用层合并可读性和可控性反而更好。全外连接更多是 SQL 教学里的概念题真到生产环境先问一句这个需求是不是可以换个角度表达往往能省掉很多麻烦。5.4 右连接为什么用得少RIGHT JOIN在语义上跟LEFT JOIN完全对称没有任何功能上的独有性。实际代码里少见的原因很朴素人的阅读习惯是从左到右左表是主体右表是附属用LEFT JOIN表达主体全部保留最自然。-- 这两条完全等价 SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id o.user_id; SELECT u.name, o.order_no FROM orders o RIGHT JOIN users u ON u.id o.user_id;我的建议是统一用LEFT JOIN。如果遇到需要右连接的场景把两张表的位置调换一下再写左连接可读性会更好。混用左右连接是代码审查里我最容易看花眼的地方之一尤其是在多表连接的中间层一个RIGHT JOIN夹在几个LEFT JOIN之间维护者的心智负担会陡增。6. 建表实测同一份数据跑四种连接6.1 测试数据准备光说理论不够直观我准备了一份最小可复现的数据。表结构做了简化但保留了线上环境常见的几个特征主键列名相同、审计字段相同、存在 NULL 值、存在无法匹配的行。CREATE TABLE users ( id INT PRIMARY KEY, name VARCHAR(20), created_at DATETIME ); CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, order_no VARCHAR(20), created_at DATETIME ); INSERT INTO users VALUES (1, A同学, 2024-01-01 10:00:00), (2, B同学, 2024-01-02 10:00:00), (3, C同学, 2024-01-03 10:00:00), (4, D同学, 2024-01-04 10:00:00); INSERT INTO orders VALUES (101, 1, SO001, 2024-02-01 10:00:00), (102, 1, SO002, 2024-02-02 10:00:00), (103, 2, SO003, 2024-02-03 10:00:00), (104, NULL, SO004, 2024-02-04 10:00:00), (105, 9, SO005, 2024-02-05 10:00:00);这份数据的设计意图很明确A同学有 2 个订单用于观察一对多导致的行膨胀B同学有 1 个订单正常的匹配情况C同学、D同学没有订单用于观察外连接补 NULLSO004的user_id是 NULL用于观察 NULL 作为连接键的表现SO005的user_id是 9在users表里不存在用于观察右表孤儿行两张表的id和created_at列名相同但语义无关正好用来演示NATURAL JOIN的翻车6.2 四组查询的结果逐行对比查询一内连接SELECT u.id AS uid, u.name, o.id AS oid, o.order_no FROM users u JOIN orders o ON u.id o.user_id;结果 3 行A同学对应SO001、SO002B同学对应SO003。C同学、D同学因为没有匹配被丢弃SO004user_id 为 NULL和SO005user_id 为 9因为左表找不到对应也被丢弃。查询二自然连接SELECT u.id, u.name, o.order_no FROM users u NATURAL JOIN orders o;结果0 行。原因很直接系统自动用了id和created_at两列做连接条件。users.id是 1~4orders.id是 101~105光主键这一条就不可能匹配就算主键碰巧有交集created_at的值也完全不同。这就是我前面说的名字撞车最典型的翻车现场。如果你把这条语句换成显式写法就能看出它到底在做什么EXPLAIN FORMATTREE SELECT u.id, u.name, o.order_no FROM users u NATURAL JOIN orders o;EXPLAIN的输出里会明明白白列出实际使用的连接列。当你不确定NATURAL JOIN用了哪些列时这是最靠谱的确认方式比翻文档快得多。查询三左外连接SELECT u.id AS uid, u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id o.user_id;结果 5 行A同学两行SO001、SO002B同学一行SO003C同学一行order_no为 NULLD同学一行order_no为 NULL。注意行数不是 4 而 是 5因为A同学有两个订单一对多的关系导致左表的这一行被展开成了两行。左连接返回左表行数这句话只在右表至多匹配一行时成立一旦右表有多条匹配行数就会膨胀。这个细节经常被忽略。查询四右外连接SELECT u.id AS uid, u.name, o.order_no FROM users u RIGHT JOIN orders o ON u.id o.user_id;结果 4 行A同学两行、B同学一行、SO004一行name为 NULL因为user_id是 NULLNULL 不参与等值匹配、SO005一行name为 NULL因为user_id为 9 找不到用户。这里有个非常值得注意的点SO004的user_id是 NULL它没有被匹配到但在右连接里它被保留了下来。这说明 NULL 不是匹配失败后被丢弃而是作为连接键时永远不匹配但作为驱动表的行时依然被保留。这两件事经常被混为一谈。6.3 结果对照表一眼看清差异数据行内连接自然连接左连接右连接A同学 SO001保留无保留保留A同学 SO002保留无保留保留B同学 SO003保留无保留保留C同学 无订单丢弃无保留补 NULL丢弃D同学 无订单丢弃无保留补 NULL丢弃SO004user_id 为 NULL丢弃无丢弃保留补 NULLSO005user_id 为 9丢弃无丢弃保留补 NULL结果行数3054这张表建议你保存下来。下次写关联查询之前先问自己一句我要的结果里哪些行是必须出现的如果是左表的全部行左连接如果是右表的全部行右连接如果两边只需要交集内连接。自然连接从这张表的结果就能看出来它在真实数据上大概率不是你想要的东西。7. 常见问题与排查实录7.1 连接之后行数暴涨先别急着怀疑连接类型这是最常见的求助场景明明两边都是 1000 行连接完变成 50 万行。原因几乎总是用于连接的键在某一侧不唯一。排查套路我固定用这三步-- 第一步确认连接键在两侧的唯一性 SELECT COUNT(*) AS total, COUNT(DISTINCT user_id) AS uniq FROM orders; -- 第二步找出重复的键和重复次数 SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id HAVING cnt 1 ORDER BY cnt DESC LIMIT 10; -- 第三步确认业务上是否允许重复比如订单明细天然一对多如果第一步跑出来total远大于uniq说明右表在连接键上是一对多行膨胀是预期行为不是 bug。如果业务上确实希望每个用户只出一行那要么先用聚合把右表收敛到唯一要么改用子查询取最新一条或金额最大的一条。硬连是解决不了的。注意COUNT(DISTINCT col)会忽略 NULL。如果你的连接键允许为 NULL这个统计结果会偏小需要额外处理。7.2 左连接查出来的数据变少了症状明明写的是LEFT JOIN但结果里没有那些右边没数据的行。原因基本锁定在WHERE条件上。前面讲过WHERE o.xxx ?会把 NULL 行过滤掉。排查方法很直接把WHERE里所有涉及右表的条件逐个挪到ON里试看结果行数怎么变。-- 退化写法WHERE 里有右表条件 SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.status 1; -- 这一行是罪魁祸首 -- 修正写法条件挪进 ON SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id o.user_id AND o.status 1;还有一个隐蔽的变体条件不是写在WHERE里而是写在HAVING里效果一样也会把 NULL 行干掉。看到LEFT JOIN配HAVING就要提高警惕。7.3 NULL 相关的三个高频误区误区一NULL NULL为真。在 SQL 里任何值与NULL做比较结果都是UNKNOWN三值逻辑不是TRUE也不是FALSE。所以ON a.col b.col里如果两侧都是 NULL匹配不成立。这就是为什么SO004那条记录无论左连接还是内连接都匹配不上任何人。要判断 NULL只能用IS NULL/IS NOT NULL或者用NULL 安全等值-- 这两条等价 SELECT * FROM orders WHERE user_id IS NULL; SELECT * FROM orders WHERE user_id NULL; -- NULL 安全等值可以用来匹配两侧都为 NULL 的情况 SELECT * FROM users u JOIN orders o ON u.id o.user_id;是个冷门但好用的运算符。用它做连接条件两侧都是 NULL 时会被判定为匹配。实际业务里这个需求不多但知道它存在能避免在排查时走弯路。误区二COUNT(*)和COUNT(列名)结果不同。左连接之后统计数量COUNT(*)会把补 NULL 的行也算进去COUNT(o.id)则只统计真正匹配上的行。SELECT u.name, COUNT(*) AS cnt_all, COUNT(o.id) AS cnt_matched FROM users u LEFT JOIN orders o ON u.id o.user_id GROUP BY u.id, u.name;对C同学来说cnt_all是 1cnt_matched是 0。如果你的业务是统计每个用户的订单数必须用COUNT(o.id)用COUNT(*)会把没下单的用户算成 1 单这个 bug 特别隐蔽因为它在大部分用户都有订单的数据上看起来完全正常。误区三SUM遇到 NULL 的处理。SUM会忽略 NULL这和COUNT的行为一致所以SUM(o.amount)在左连接里不会因为补 NULL 而报错结果是NULL而不是 0。如果要显示 0得用COALESCE(SUM(o.amount), 0)。这个小细节在报表里天天遇到。7.4 常见问题速查表现象最可能的原因快速验证自然连接返回 0 行同名列语义不匹配如两侧idEXPLAIN FORMATTREE看实际连接列左连接结果少了行WHERE里有右表条件把条件挪进ON再看行数连接后行数翻倍右表在连接键上不唯一COUNT(*)对比COUNT(DISTINCT 键)查询突然变慢同名列类型不一致索引失效information_schema.COLUMNS查COLUMN_TYPECOUNT结果偏大用COUNT(*)统计左连接改成COUNT(右表主键)右表 NULL 行没出现连接键为 NULL等值比较不成立用或先过滤 NULL全外连接语法报错MySQL 不支持FULL OUTER JOIN改用UNION或反连接拼接8. 选型建议与几个性能层面的实操心得8.1 什么场景用哪种连接我给一张决策表写查询时我的判断顺序是这样的基本能覆盖 90% 的情况业务需求推荐写法理由只要两表都有的数据INNER JOIN语义最直接优化器选择最多左表全部保留右表可有可无LEFT JOIN 条件放ON语义明确不易误退化右表全部保留调换表序改LEFT JOIN可读性优于RIGHT JOIN找出左表有但右表没有LEFT JOIN ... WHERE 右表主键 IS NULL反连接标准写法连接列名同名且等值JOIN ... USING (col)省去列名歧义安全需要两边全部保留UNION或UNION ALL 反连接MySQL 无全外连接任意场景避免NATURAL JOIN连接条件不可控关于反连接那条展开说一句。LEFT JOIN ... WHERE b.id IS NULL和NOT EXISTS在 MySQL 8.0 里通常会被优化成相同的执行计划性能相当。但前者有个陷阱如果b.id本身允许为 NULLIS NULL会把匹配上了但值为 NULL的行也混进来。所以判断右表没有匹配行时一定要用右表的主键或非空列做IS NULL判断别用可能为空的业务列。8.2 索引该怎么建连接列上必须有索引外连接的性能很大程度取决于被驱动表的连接列上有没有索引。以users LEFT JOIN orders ON users.id orders.user_id为例users是驱动表orders是被驱动表那么orders.user_id上必须有索引否则每一行左表数据都要去右表做一次全表扫描。-- 被驱动表的连接列建索引 ALTER TABLE orders ADD INDEX idx_user_id (user_id); -- 如果连接条件里还带业务过滤考虑联合索引 ALTER TABLE orders ADD INDEX idx_user_status (user_id, status);联合索引的字段顺序很讲究连接列在前过滤列在后。因为 MySQL 使用索引时遵循最左前缀原则(user_id, status)可以同时服务于按 user_id 连接和连接后按 status 过滤两个动作。反过来(status, user_id)就只能用上status连接时还是要回表效率差很多。我在一个批量导出功能里做过对比测试同样是 10 万用户关联 50 万订单orders.user_id无索引时耗时 12 秒以上加了索引后降到 0.3 秒。这个差距说明讨论左连接快还是内连接快意义不大索引才是决定性的。内连接因为可以在优化器阶段做更多变换比如把过滤条件下推、调整驱动表顺序在缺索引的情况下往往比外连接表现好一些但这不应该成为选择连接类型的理由。8.3 用 EXPLAIN 验证别靠猜最后分享一个我的固定习惯任何涉及两张以上表的查询上线前必须跑一遍EXPLAIN。不是走形式是看三个关键列type如果是ALL说明在做全表扫描考虑加索引rows预估扫描行数如果远大于实际需要的行数说明条件没生效Extra出现Using join buffer通常意味着被驱动表没走索引出现Using temporary; Using filesort说明有额外的排序开销MySQL 8.0 的EXPLAIN FORMATTREE更直观它会用树形结构展示实际的连接顺序和每个节点的代价比传统表格版更容易看出谁驱动谁。对于NATURAL JOIN这种连接条件不透明的写法这个命令几乎是唯一的排查手段。EXPLAIN FORMATTREE SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.status 1;跑完你会清楚看到status这个条件是在连接前还是连接后生效的。如果发现它没有下推到orders表的扫描阶段那就是我前面说的退化成内连接或者索引没用上需要回头调写法。我个人在实际操作中的体会是这三种连接的区别本质上是**匹配失败的行怎么办**这一个问题的三种答案。内连接说丢弃自然连接说丢弃但条件我来定外连接说保留并补 NULL。把这个核心想透剩下的语法、索引、优化都是在这个基础上的延伸。真正容易出错的从来不是记不住关键字而是没想清楚我到底要哪些行出现在结果里。写查询之前先用一句话把需求说清楚——我要所有用户包括没下单的——连接类型自然就选出来了比对着文档一个个试要快得多。