ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL连接查询全解:LEFT JOIN的ON与WHERE陷阱、性能优化与实战避坑

MySQL连接查询全解:LEFT JOIN的ON与WHERE陷阱、性能优化与实战避坑 上周帮团队排查一个SQL统计问题同事写了一条LEFT JOIN外连接的一种本意是把所有用户都列出来哪怕没有订单也要保留结果报表里没下单的用户全都不见了。原因很简单他在WHERE里加了过滤条件把外连接活活变成了内连接的效果。类似的坑我在面试别人、带新人、看线上慢查询的时候见得太多了。MySQL 的表连接无非两类内连接INNER JOIN和外连接LEFT JOIN / RIGHT JOIN语法上不难但真正到业务里ON和WHERE的区别、NULL 的语义、一对多导致的行膨胀随便一个都能让结果错得离谱。这篇文章把我实际用到的写法、踩过的坑、排查思路一次性讲清楚测试数据可以直接复制跑。1. 为什么单表查询不够用连接查询解决的根本问题1.1 数据拆分一张表装不下真实业务很多人刚学SQL时习惯把所有信息塞进一张表。用一个电商订单场景举例你想记录谁买了什么、花了多少钱、用户住在哪如果全部放一张表张三下了两单他的姓名、城市就要跟着订单记录存两次。地址改了要改两行改漏了数据就矛盾。这就是典型的冗余和更新异常。正规做法是拆成两张表用户表只存用户信息订单表只存订单信息订单表里通过user_id指向用户表的主键。这是数据库范式的核心思想每张表只负责一类实体表与表之间用外键字段建立逻辑关系。但拆完之后问题来了业务查询经常需要订单 用户姓名这种合并视图。你不可能每次都在应用层先查订单再循环查用户那就得靠SQL里的连接JOIN在数据库层面把多张表拼起来。所以连接查询不是一个附加功能而是拆表设计的必然配套。1.2 连接运算的本质笛卡尔积加匹配条件那连接底层到底做了什么一句话先做笛卡尔积再用连接条件过滤。笛卡尔积就是两张表所有行两两配对。users 表 4 行、orders 表 4 行无条件下配对出来就是 16 行。这 16 行里只有users.id orders.user_id的那些组合才是有业务意义的其余全是噪音。连接查询要做的就是通过ON后面的匹配条件从笛卡尔积中挑出有效组合。实际执行时MySQL优化器根本不会真的把16行全构建出来而是会用索引直接定位匹配行。但理解笛卡尔积 过滤这个语义模型非常重要因为它能解释很多诡异现象比如为什么 JOIN 后行数变多了因为一对多匹配时左表一行会被复制成多行。1.3 内连接与外连接的核心语义对比抛开语法先建立整体认知。内连接和外连接对没有匹配上的行的处理策略完全不同连接类型语义左表无匹配时右表无匹配时INNER JOIN两边都匹配才出现丢弃丢弃LEFT JOIN保留左表全部保留右列补NULL不会发生RIGHT JOIN保留右表全部不会发生保留左列补NULLFULL JOIN两边都全部保留保留右列补NULL保留左列补NULL内连接是求交集外连接是保留主表全集非主表求交集以外的部分用 NULL 填充。MySQL 原生不支持 FULL JOIN后面会讲等价写法。为了演示我先建两张测试表后面所有SQL都可以直接跑CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, city VARCHAR(50) DEFAULT , created_at DATETIME ); CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME ); INSERT INTO users (id, name, city) VALUES (1, 张三, 北京), (2, 李四, 上海), (3, 王五, 广州), (4, 赵六, 深圳); INSERT INTO orders (id, user_id, amount, status) VALUES (1, 1, 299.00, 1), (2, 1, 59.90, 0), (3, 2, 150.00, 1), (4, 4, 800.00, 0);注意数据设计张三有2个订单李四、赵六各有1个王五没有订单。这个结构能把连接查询的各种细节都暴露出来。2. INNER JOIN内连接正确写法与运行逻辑2.1 显式JOIN和隐式JOIN推荐前者内连接有两种写法结果完全一样-- 显式连接 SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id o.user_id; -- 隐式连接老式写法 SELECT u.name, o.amount FROM users u, orders o WHERE u.id o.user_id;执行结果都是三条记录张三299、张三59.9、李四150、赵六800一共4行。等等这里张三有两条订单所以是4行结果对应4个订单记录王五没有订单所以不出现。我推荐一律用显式INNER JOIN写法。原因很实际隐式写法如果哪天忘了写WHERE条件直接变成笛卡尔积几万行表和几万行表碰一下就是上亿行线上环境很容易直接把数据库拖垮。显式写法的ON是强制结构不写SQL直接报错天然挡掉一类低级事故。2.2 ON还是WHERE内连接里它们等价在内连接里条件写ON和写WHERE的结果是一样的SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id o.user_id WHERE o.amount 100; SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id o.user_id AND o.amount 100;两条SQL都会返回张三299、李四150、赵六800三行。原因是MySQL优化器会把WHERE条件下推到连接阶段提前过滤内连接没有保留未匹配行的语义所以条件放哪都一样。但请记住这个结论仅限内连接。一旦换成外连接ON和WHERE就是天壤之别这个第3章重点讲。2.3 自连接员工和经理在一张表里内连接一个容易忽略的应用是自连接也就是一张表和自己做JOIN。典型场景是员工表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 employee_name, m.name AS manager_name FROM emp e INNER JOIN emp m ON e.manager_id m.id;这里给emp起了两个别名e和m左表当员工右表当经理表。结果出来张伟的经理是刘总、李静的经理是刘总、王强的经理是张伟。刘总没有经理内连接不会出现这正好呼应了内连接只保留匹配成功的行。如果想把刘总也列出来用LEFT JOIN就行。2.4 非等值连接ON不止等号很多人以为ON只能写等值条件。其实连接条件可以是任意布尔表达式大于、小于、区间都行。生活中最常见的例子是订单金额匹配优惠档位CREATE TABLE grade ( id INT PRIMARY KEY, grade_name VARCHAR(50), min_amt DECIMAL(10,2), max_amt DECIMAL(10,2) ); INSERT INTO grade VALUES (1, 普通会员, 0, 100), (2, 白银会员, 100.01, 500), (3, 黄金会员, 500.01, 99999); SELECT o.id, o.amount, g.grade_name FROM orders o INNER JOIN grade g ON o.amount BETWEEN g.min_amt AND g.max_amt;这种非等值连接也是内连接只是匹配规则不是ID相等而是金额落在区间里。理解这一点ON的灵活性就打开了。3. 外连接LEFT JOIN和RIGHT JOIN的核心与致命细节3.1 LEFT JOIN到底是怎么执行出来的LEFT JOIN的完整语义是左表的每一行都要出现在结果里。右表能匹配上就拼接右表字段右表匹配不上右表全部字段填 NULL左表照样保留。看执行结果最直观SELECT u.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;结果如下idnameorder_idamount1张三1299.001张三259.902李四3150.003王五NULLNULL4赵六4800.00王五没有任何订单但他的信息仍然保留订单字段是 NULL。这就是外连接和内连接最本质的区别。3.2 条件放ON和放WHERE结果是两个世界这是外连接里翻车率最高的一点。还是用 orders 表需求变成查所有用户以及他们金额大于100的订单。写法A过滤条件放ONSELECT u.id, u.name, o.id AS order_id, o.amount FROM users u LEFT JOIN orders o ON u.id o.user_id AND o.amount 100 ORDER BY u.id;写法B过滤条件放WHERESELECT u.id, u.name, o.id AS order_id, o.amount FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.amount 100 ORDER BY u.id;两种写法的结果差异巨大。写法A返回5行张三有两行299.00 和 NULL、李四150、王五NULL、赵六800。张三金额59.9的订单虽然不满足条件但左表保留语义生效拼接了NULL。王五依然存在。写法B返回3行张三299、李四150、赵六800。王五消失了。为什么因为WHERE o.amount 100是在连接完成之后过滤整个结果集王五那行的 amount 是 NULLNULL 100的结果是 NULL不是 TRUE于是被过滤掉了。用白话说ON里的条件决定怎么匹配WHERE里的条件决定哪些行最终能活下来。LEFT JOIN 的保底语义在WHERE阶段就失效了。上面写法B的结果本质上和 INNER JOIN 加 WHERE 没区别。所以写外连接时先想清楚想过滤右表数据但左表所有主行必须都在 → 条件放ON想对最终结果做全局裁剪 → 条件放WHERE3.3 RIGHT JOIN孤儿订单的展示RIGHT JOIN和LEFT JOIN完全对称只是主表换到了右边。实际业务里大家习惯把主表放左边所以RIGHT JOIN用得少。但有一种场景它很顺手查所有订单即使订单对应的用户已经不存在脏数据孤儿订单。我插入一条不存在的用户订单INSERT INTO orders (id, user_id, amount, status) VALUES (5, 99, 66.00, 1); SELECT o.id AS order_id, o.amount, u.name FROM users u RIGHT JOIN orders o ON u.id o.user_id ORDER BY o.id;结果里订单5会出现但u.name是 NULL。这种以右表为主表的查询用 RIGHT JOIN 最直观。如果你实在不习惯 RIGHT JOIN把表的书写顺序调换、LEFT JOIN 也能实现同样效果完全等价。3.4 多表连续LEFT JOIN的链式保留问题三张表以上连接时LEFT JOIN 的坑会叠加。典型业务用户 → 订单 → 订单明细。写出如下SQLSELECT u.name, o.id AS order_id, i.product_name FROM users u LEFT JOIN orders o ON u.id o.user_id LEFT JOIN order_items i ON o.id i.order_id;这里每个 LEFT JOIN 都是一层保留左数据集的操作第二层 LEFT JOIN 以用户订单为左数据集订单匹配不到明细时明细列填 NULL但订单行照样保留。一个很容易犯的错是第三个连接条件写错表。比如有人写成i.user_id u.id那明细表会跨越订单表去匹配用户整个数据逻辑就乱了。多表连接时每一个 ON 条件都应该基于相邻的、刚刚连接进来的表不要跨表连接。另一个连锁问题是多表 LEFT JOIN 后后面的表字段大量为 NULL统计时如果用了COUNT(*)这些 NULL 行也都会被算进去结果和业务理解完全对不上。这个问题在第六章展开。4. MySQL没有FULL JOIN全外连接怎么用UNION拼出来4.1 什么业务需要全外连接全外连接FULL JOIN的语义是左表和右表的所有行都保留匹配不上的左右两侧分别补 NULL。什么时候会用到典型的对账场景。比如左边是白名单用户表右边是实际访问记录表。你要清楚知道三件事正常访问的用户交集、白名单里没来的只在左、来了但不在白名单的只在右。这种两边都要齐全的需求就是 FULL JOIN 的用武之地。可惜 MySQL 一直没提供原生的FULL OUTER JOIN语法。虽然8.x版本加了不少新特性但这个空缺始终没补上。实际项目里一般用UNION组合左连接和右连接实现。4.2 左连接加右连接再UNION用我们前面的 users 和 orders包含孤儿订单5写这样一条SQLSELECT 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;执行逻辑拆开看第一个查询输出所有用户 匹配到的订单包含王五订单列NULL第二个查询输出所有订单 匹配到的用户包含孤儿订单用户列NULL。两个结果集用UNION合并时交集部分内连接的行会重复出现一次UNION 自动去重最后得到完整全集张三的2个订单李四的1个订单王五用户侧保留订单NULL赵六的1个订单孤儿订单5订单侧保留用户NULL这就是 FULL JOIN 的效果。4.3 大表场景下更省的实现思路用UNION实现的问题在于它会把两边查询的完整结果集都算出来再去做去重排序大表场景内存压力和临时表开销都不小。另一种思路是只取两边各自独有的部分再用开销更低的UNION ALL拼接。MySQL里判断只属于一边的经典手法是IS NULLSELECT u.id AS user_id, o.id AS order_id FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.id IS NULL UNION ALL SELECT u.id AS user_id, o.id AS order_id FROM users u RIGHT JOIN orders o ON u.id o.user_id WHERE u.id IS NULL;第一个查询找出没有订单的用户王五第二个查询找出用户不存在的订单孤儿订单两边都没有交集直接UNION ALL不会重复还省掉了去重开销。数据量大时这个写法明显更稳。5. 连接查询的性能优化执行计划、驱动表与索引5.1 用EXPLAIN看懂连接执行顺序连接查询一旦慢第一步永远是看执行计划。对刚才的内连接跑一次EXPLAINEXPLAIN SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id o.user_id;输出大致是这样不同版本字段略有差异----------------------------------------------------------------- | id | select_type | table | type | key | ref | rows | Extra | ----------------------------------------------------------------- | 1 | SIMPLE | o | ALL | NULL | NULL | 5 | NULL | | 1 | SIMPLE | u | eq_ref | PRIMARY | o.user_id | 1 | NULL | -----------------------------------------------------------------重点看几个信息table连接中涉及的表从上到下通常是执行顺序。type访问类型。ALL是全表扫描eq_ref表示按主键或唯一索引精确匹配一行ref表示按普通索引匹配多行。从快到慢大致是const eq_ref ref range index ALL。key实际用到的索引。rows预估扫描行数。这个例子中orders 表先用全表扫描5行然后用o.user_id作为钥匙去 users 表按主键精确查找eq_ref。执行计划里第二个出现的表就是被驱动表必须走索引。5.2 驱动表与被驱动表谁去驱动谁必须索引MySQL处理连接的方式可以理解成两层嵌套循环外层循环遍历驱动表每取到一行就去内层表查匹配。内层表就是被驱动表。这个模型下性能关键点非常明确驱动表本身扫描多少行决定了外层循环次数。被驱动表必须能通过索引快速定位否则每来一行就全表扫一遍。优化器一般会选择小表驱动大表也就是预估行数少的作为驱动表。但在外连接里LEFT JOIN的左表天然是主表优化器通常不能随便调整它的驱动位置。所以如果你的 LEFT JOIN 左表特别大、右表连接列又没有索引这个查询就会非常慢。实践中最重要的操作就是给被驱动表的连接列建索引。拿示例来说如果想以 users 驱动、orders 被驱动那orders.user_id就要建索引ALTER TABLE orders ADD INDEX idx_user_id (user_id);建完索引后再看执行计划orders表的访问类型会从ALL变成ref性能提升立竿见影。5.3 STRAIGHT_JOIN想自己指定顺序时的办法优化器大多数时候是聪明的但偶尔也会犯浑特别是在统计信息不准或者过滤条件很复杂时选错驱动表。如果你想强制指定驱动顺序MySQL 提供了STRAIGHT_JOINSELECT u.name, o.amount FROM users u STRAIGHT_JOIN orders o ON u.id o.user_id;这个写法会强制 users 作为驱动表按书写顺序执行连接。但我建议不到万不得已别乱用它会让优化器放弃自己计算连接顺序一旦以后表的数据分布变化强制顺序可能变成负优化。只有在 EXPLAIN 看到优化器明显选错驱动表、并且你确认自己更了解数据分布时再考虑它。5.4 连接列上的隐式转换与字符集问题还有两个索引失效的常见原因我必须单独提。第一个是隐式类型转换。连接列一边是VARCHAR一边是INT或者一边是字符串存了数字MySQL 会把字符串转成数字比较导致索引失效。比如连接条件写成u.id o.user_id_str如果两边类型不一致执行计划里 type 会退化成ALL。写表结构时连接列尽量保持同类型没有例外。第二个是字符集和排序规则不一致。一张表是utf8mb4_general_ci另一张是utf8mb4_unicode_ci相等判断时无法直接使用索引。排查方法还是看执行计划发现 key 是 NULL 但连接列明明有索引优先查这两项。解决办法是统一表字符集ALTER TABLE orders CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;6. 连接查询实战经验行膨胀、COUNT陷阱与NULL处理6.1 一对多连接带来的行数膨胀外连接最常见的意外是行数膨胀。users 对 orders 是一对多连接后张三因为有两个订单他的用户信息会被复制成两行。结果集的行数不是用户数而是用户和订单匹配关系数 未匹配用户数。很多人拿这个结果集去统计用户总量SELECT COUNT(*) FROM users u LEFT JOIN orders o ON u.id o.user_id;结果是多少5。但 users 表只有4个用户。要数用户必须用SELECT COUNT(DISTINCT u.id) FROM users u LEFT JOIN orders o ON u.id o.user_id;回到4。在写连接查询前先想清楚结果集的粒度是什么如果粒度是订单那用户信息重复是正常的如果粒度是用户就要警惕因为一对多产生的重复行。6.2 COUNT(*)和COUNT(列)的差异统计每个用户的订单数时这个差异最明显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;结果张三2、李四1、王五0、赵六1。这里COUNT(o.id)只统计非 NULL 的订单ID王五没有订单所以是0。如果手滑写成COUNT(*)王五那行会统计到1因为COUNT(*)把整行都数进去了包括那行右表全是 NULL 的行。这个坑在统计报表里特别常见。记住一条规则多表统计时想数哪张表的量就COUNT那张表的主键字段别用COUNT(*)。6.3 求差集LEFT JOIN加IS NULL的经典写法找没有订单的用户是外连接非常经典的应用。写法是 LEFT JOIN 后判断右表主键为 NULLSELECT u.id, u.name FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.id IS NULL;结果只有王五。这里WHERE里的IS NULL判断和前面过滤条件区分不矛盾它不是过滤右表字段值而是利用未匹配时右表全为 NULL的特性筛选出左表独有的记录。判断字段建议用右表的主键o.id因为主键本来就不允许为 NULL用它是绝对安全的标准。6.4 写连接查询前先想清楚四件事最后分享我写连接查询前会快速过一遍的检查清单也当帮你做自查结果集的粒度是什么是主表的行还是明细表的行。哪种连接类型符合粒度需求要全集就一定用外连接不要幻想 WHERE 能救你。过滤条件该放哪右表过滤放 ON全局过滤放 WHERE千万别混。统计字段选对了吗数哪张表就 COUNT 哪张表的主键警惕 NULL 和重复行。连接查询的坑绝大多数都能靠这四个问题提前挡掉。我在实际写SQL前习惯先在草稿上画一画两个集合的关系求交集、左边全保留、两边全保留画清楚了再动手基本不会出方向性错误。这招对刚入门的朋友尤其有用。
返回列表