MySQL四大JOIN与笛卡尔积:原理、执行与避坑指南

发布时间:2026/9/18 4:14:18
MySQL四大JOIN与笛卡尔积:原理、执行与避坑指南 做后端或者数据相关的工作早晚都会撞上这样一种情况一条SQL跑出来数据莫名多了一倍或者几个关键字段突然变成NULL排查半天最后发现是join写错了。数据库里的join、笛卡尔乘积这两个概念几乎是从SQL入门到线上性能优化都绕不开的硬骨头。以MySQL为例很多人能把inner join、left join、right join、cross join四个名字背得滚瓜烂熟却说不清它们结果集的差别到底是什么、执行过程里发生了什么、什么时候会触发笛卡尔积把几万行悄悄膨胀成几亿行。我自己在早期做报表统计的时候就吃过这个亏两张几十万行的表关联因为漏了一个关联条件查询跑了十几分钟还差点把数据库连接池拖垮。这篇文章就围绕这个主题把四大join和笛卡尔乘积从结果集长什么样到底层怎么执行再到线上怎么排查完整讲一遍。内容适合刚学SQL的在校学生也适合工作几年但没系统梳理过join原理的后端和数据分析同学。我会用一张用户表和一张城市表贯穿全程所有SQL都可以直接复制到本地MySQL里跑看到结果再回来看解释理解会快很多。1. 先把join这件事讲清楚它到底在解决什么问题数据库设计里有个基本规范叫范式目的之一就是避免数据冗余把用户信息和城市信息拆到两张表里存用户的表里只留一个城市ID当作指向。这种做法干净但带来一个新问题我想查每个用户住在哪个城市数据分散在两张表里单表查询拿不全。join就是用来把这种拆分存储的关联数据重新拼回一张结果集的工具。理解这一点很关键join不是语法糖它是关系型数据库最核心的能力之一正是因为有了join我们才敢放心地按范式拆表。1.1 笛卡尔积是join的地基不是异常很多人第一次听到笛卡尔乘积是在数学课上觉得它抽象但在数据库里它非常具体。假设A表有3行B表有4行对这两张表做不带任何条件的连接结果就是3乘4等于12行A表的每一行都会和B表的每一行配一次。这就是笛卡尔积也叫交叉连接。它是join所有形态的数学基础inner join、left join本质上都是先做出笛卡尔积再按条件把不符合的行筛掉的思维模型。这里要纠正一个常见误解笛卡尔积本身不是错误错误是本不该产生笛卡尔积的连接产生了笛卡尔积。当你确实需要两张表所有组合的时候它就是正确结果当你在写两个表关联却忘了写on条件或者条件写得让优化器没法用上导致结果集行数爆炸那才是事故。我在生产环境见过最典型的案例就是多表join时中间某两个表漏了关联条件四张表联查直接把返回行数从几千顶到了上千万。1.2 四大join的直观区别抛开执行细节先用一句话区分这四种连接的结果集形态这是后面所有内容的锚点连接类型中文名结果集特点INNER JOIN内连接只保留左右两边都能匹配上的行LEFT JOIN左外连接左表所有行都保留右表匹配不上则填NULLRIGHT JOIN右外连接右表所有行都保留左表匹配不上则填NULLCROSS JOIN交叉连接不做匹配直接输出笛卡尔积记这张表有个小技巧外连接的关键字指的是哪一端的表要全保。left join保左表right join保右表另一端的缺失部分用NULL补位。而inner join两头都不保只认匹配。cross join则完全不看条件是纯粹的排列组合。把这个锚点记牢后面看执行计划和排查问题时心里就有底了。2. 环境准备从装MySQL到造出一份能暴露问题的测试数据讲理论不如直接上手。为了避免纸上谈兵我们需要一个能跑的MySQL环境再准备一份刻意埋了坑的数据。下面的步骤我自己反复用过尤其是数据构造那部分埋进去的那几行异常数据正是后面讲NULL匹配、讲行数膨胀时真正起作用的东西。2.1 MySQL的安装与初始化要点MySQL现在主流的安装方式有两种一种是官网下载安装包或压缩包手动配置另一种是用包管理器安装比如在Linux上用apt或者yum在macOS上用Homebrew。手动安装的好处是版本和路径完全可控适合学习和测试环境。安装时几个容易踩的点我列一下第一Windows下安装向导会让你选端口默认3306别随手改改了后面连接容易忘第二字符集一定要确认是utf8mb4而不是旧版的utf8否则存emoji这类四字节字符会报错第三root密码设置完要记牢忘记重置很麻烦。安装完之后用命令行客户端或者MySQL Workbench连上先执行SELECT VERSION();确认版本。我建议直接用8.0以上的版本因为8.0.18之后引入了Hash Join而且窗口函数、CTE这些特性在处理复杂关联时特别有用下面讲执行算法的时候也会用到。建库之前先确认字符集SHOW VARIABLES LIKE character_set_server; SHOW VARIABLES LIKE collation_server;如果看到的是utf8mb4和对应的utf8mb4_general_ci或utf8mb4_0900_ai_ci就没问题。接着建一个专门的测试库避免和别的数据混在一起CREATE DATABASE join_demo DEFAULT CHARACTER SET utf8mb4; USE join_demo;2.2 表结构设计与测试数据构造我们要造的场景很简单一批用户每个用户归属一个城市。城市信息单独存一张表用户表里存的是城市ID。这个结构完美贴合按范式拆表再用join拼回的典型模式。建表语句如下CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(32) NOT NULL, city_id INT ) ENGINEInnoDB; CREATE TABLE cities ( id INT PRIMARY KEY AUTO_INCREMENT, city_name VARCHAR(32) NOT NULL ) ENGINEInnoDB;注意users表的city_id允许为NULL而且我没有给它加外键约束。这一点是故意的真实业务里经常出现脏数据用户填了个不存在的城市ID或者新用户还没选城市。这种对不上的数据恰恰是外连接存在的意义。接下来插数据我特意安排了三种特殊行INSERT INTO cities (id, city_name) VALUES (1, 北京), (2, 上海), (3, 广州), (4, 成都); -- 成都暂时没有用户 INSERT INTO users (id, user_name, city_id) VALUES (1, 张三, 1), (2, 李四, 2), (3, 王五, NULL), -- 没填城市 (4, 赵六, 1), (5, 钱七, 99); -- 城市ID不存在现在users有5行cities有4行。成都这座城没有任何用户王五的city_id是NULL钱七指向了一个根本不存在的99号城市。这三条数据是后面所有演示的关键你看结果的时候重点关注它们。造数据这件事我想多说一句很多人测试join时随便插几行干净数据结果什么问题都暴露不出来。结构化的、带边界情况的测试数据集价值远超随便写几十行。3. 四种join逐个实测SQL怎么写、结果怎么变数据齐了现在一个一个跑。我建议你别只看我写的结果自己开个客户端跟着敲一遍尤其是观察NULL和对不上的行在每种连接里的表现这个对比过程比任何讲解都管用。3.1 INNER JOIN只留两边都对得上的内连接是最常用的也是最严格的。写法上inner关键字可以省略JOIN默认就是inner joinSELECT u.id, u.user_name, c.city_name FROM users u JOIN cities c ON u.city_id c.id;跑出来的结果只有4行张三-北京、李四-上海、赵六-北京加上……等一下仔细看实际上是三行用户不对重新数张三(1→北京)、李四(2→上海)、赵六(1→北京)钱七的99匹配不上王五的NULL匹配不上所以是3行。这里我第一次跑也愣了一下因为直觉会以为5个用户怎么也得出来4行。原因就是inner join只认匹配任何一边对不上的行全部被筛掉。这个特性在业务里非常有用。比如你要做销售业绩报表只关心既有订单又有有效用户的记录inner join天然帮你把脏数据过滤掉了。但它的副作用是如果数据质量差你可能在不知不觉中丢掉了本该统计的行。这也是为什么做数据核对时一定要用count分别查单表和join后的行数差额去哪了要说得清楚。3.2 LEFT JOIN左表全保右表能对几个对几个左连接的规则是左表一行都不能少。把上面的JOIN换成LEFT JOINSELECT u.id, u.user_name, c.city_name FROM users u LEFT JOIN cities c ON u.city_id c.id;结果变成5行所有用户都在。不同点在于王五那行city_name是NULL他本来city_id就是NULL匹配不上任何城市钱七那行city_name也是NULL99号城市不存在。这就是左连接的核心价值——保留主体数据的完整性。实际做用户画像、做留存分析经常用左连接因为你要保证主表比如用户表每一行都出现在结果里哪怕是没行为的用户。有个细节值得单独强调左连接里如果左表某行在右表匹配到多行结果会变成多行行数会膨胀。比如一个用户有多个订单你用users左连orders这个用户就会出现多次。很多人第一次遇到join之后用户数变多了就是这个原因下一章讲排查会专门处理它。3.3 RIGHT JOIN换角度看就是左连接右连接的规则反过来右表一行不能少。写法SELECT u.id, u.user_name, c.city_name FROM users u RIGHT JOIN cities c ON u.city_id c.id;结果会是5行——成都出现了因为它保右表成都必须出现只是左边匹配不上user字段填NULL。这里有个工程上的小建议right join和left join在能力上是等价的A RIGHT JOIN B完全等价于B LEFT JOIN A。所以在团队里很多规范会要求统一用left join理由是人的阅读习惯从左到右主表放左边、保主表用left join逻辑更顺也不容易看反。我参与过的项目基本都规定禁止使用right join就是这个道理。3.4 CROSS JOIN与笛卡尔积的正面和反面交叉连接不写on条件直接把两张表所有行两两组合SELECT u.user_name, c.city_name FROM users u CROSS JOIN cities c;5乘4等于20行每个用户都和每个城市组合了一次。这就是最纯粹的笛卡尔积。你可能会问这玩意儿有什么用其实它有用武之地。比如做排班表、做每个商品每个门店的库存初始化、做日期维度补全每个日期配每个产品这些场景本质上就需要全组合。数据库里还有一种隐式写法FROM a, b用逗号分隔且不写where条件效果和cross join一样SELECT u.user_name, c.city_name FROM users u, cities c;反面是什么是你在写多表关联时以为自己写了条件实际没写全。比如三张表join只写了A和B的关联条件漏了B和C的关联优化器找不到约束就会退化成部分笛卡尔积。行数直接乘起来几千行变几百万行。我给你一个快速判断的方法join之后的实际行数如果远超你的业务预期先别急着查索引第一件事是检查on条件是不是漏了或者写错了关联字段。这个排查顺序能帮你省下大量时间。4. 执行视角join到底是怎么被数据库跑出来的写到这你可能已经会用四种join了但会用和理解之间还差一层——执行过程。同一句SQL数据库内部可能用完全不同的算法去跑性能差出几十倍都正常。搞懂这层你才能在慢查询面前有底气而不是只会说加个索引试试。4.1 嵌套循环连接最朴素的算法MySQL最经典的join算法是Nested Loop Join简称NLJ。它的思路特别直白从驱动表一般就是外层那张表取一行然后拿着这行去被驱动表里挨个找匹配的行找到就组合输出然后驱动表取下一行重复。用生活类比就是拿着名单挨个去另一个表格里翻。这种算法在小数据量时表现很好因为只要被驱动表的关联字段有索引每次查找都是索引查找效率接近O(1)。问题出在被驱动表没有索引的时候每取一行驱动表数据都要全表扫描一遍被驱动表。驱动表1万行、被驱动表10万行那就要扫10万乘1万次这个量级直接让查询卡死。所以NLJ的命门就是被驱动表的关联字段必须走索引。你去看执行计划如果被驱动表那一步出现type是ALL全表扫描基本就是问题所在。4.2 Block Nested Loop与Hash Join大数据量下的进化当被驱动表没索引时MySQL在较老版本里会退而用Block Nested Loop Join思路是把驱动表的数据先读进一块内存缓冲区然后扫描被驱动表把缓冲区里的每一行都拿来比对一遍减少重复读取次数。但它的复杂度依然是乘积级别的表大了照样扛不住。于是从MySQL 8.0.18开始官方引入了Hash Join把较小的那张表的数据读进内存按关联键建一张哈希表然后扫描另一张表用查哈希的方式找匹配。哈希查找理论上接近常数时间这让大数据量、无索引的等值join性能大幅提升。这对我们的实践意味着什么第一你的MySQL版本如果还是5.7很多大表无索引join的优化手段就受限能升级尽量升级到8.0第二Hash Join只适用于等值连接就是on里用的那种如果是范围条件还是得靠NLJ加索引第三别因为有了Hash Join就懒得建索引索引在过滤数据和走排序上依然不可替代。4.3 用EXPLAIN把执行过程看清楚光说不练没用给你一段可以反复用的方法——用EXPLAIN看执行计划EXPLAIN SELECT u.id, u.user_name, c.city_name FROM users u LEFT JOIN cities c ON u.city_id c.id WHERE c.city_name 北京;看结果时重点关注这几列type反映访问方式system、const、eq_ref、ref是比较好的ALL和index说明扫描面很大key显示实际用到的索引如果显示NULL说明没走索引rows是预估扫描行数数值越大越要警惕Extra里如果出现Using join buffer (hash join)说明用上了哈希连接。我养成的一个习惯是任何join性能不对先EXPLAIN看type和rows比盲目加索引高效得多。这里再补一句EXPLAIN的rows是优化器基于统计信息估的不一定准想看真实数字可以用EXPLAIN ANALYZE它会实际执行并给出真实行数。5. 踩坑实录join最常见的六类问题和排查套路前面讲了原理和实现这一段是我最想分享的部分因为下面这些问题几乎每一个人写join时都会撞上而且它们的表现往往很隐蔽不会报错只是悄悄给你错了的数据。5.1 ON和WHERE放错位置结果天差地别这是最高频、也最容易被忽略的坑。在外连接里ON后面的条件和WHERE后面的条件语义完全不同。放在ON里它决定右表怎么匹配放在WHERE里它是在join结果出来之后再过滤。举个实际例子-- 写法一条件在ON SELECT u.user_name, c.city_name FROM users u LEFT JOIN cities c ON u.city_id c.id AND c.city_name 北京; -- 写法二条件在WHERE SELECT u.user_name, c.city_name FROM users u LEFT JOIN cities c ON u.city_id c.id WHERE c.city_name 北京;写法一的结果里本地用户仍然全在只是只有北京的匹配上了其他人的城市是NULL总共5行。写法二的结果只剩1行——因为WHERE把城市为NULL的行全过滤掉了实际上等于把左连接退化成了内连接。这个差别在统计类SQL里是致命的你想要所有用户的活跃情况结果写成了WHERE过滤把没行为的用户全丢了。我的经验是凡是用外连接做统计先想清楚这个条件是用来匹配还是用来过滤匹配就放ON过滤就放WHERE两者不能混。5.2 一对多关联导致行数翻倍第二个大坑是行数膨胀。只要关联字段在右表不是唯一键就会一对多。比如用户和订单一个用户多个订单用users左连orders结果里这个用户会出现多次。如果你还顺手做了个count来计算用户数就得到的是订单数。正确姿势是count(distinct u.id)或者先把订单聚合再关联。判断有没有膨胀很简单join前后各count一次主体表的唯一键数字对不上就说明膨胀了。5.3 NULL参与比较为什么它总是不匹配还有一个反直觉的点NULL和任何值用比较结果既不是true也不是false而是unknown所以永远不会被匹配上。这就是为什么城市ID是NULL的王五在inner join里直接消失。想做NULL判断必须用IS NULL或IS NOT NULL。更细一点如果关联字段两边都是NULL用也匹配不上需要用这个安全等于运算符也叫空值安全比较它能正确处理两边都是NULL的情况。这个符号平时不常用但在数据清洗场景里偶尔能救命。5.4 常见问题速查表把上面这些坑整理成一张表出问题时对着查现象大概率原因快速验证方法join后行数暴增漏写on条件或产生了笛卡尔积EXPLAIN看type是否为ALL、rows是否异常大join后行数翻倍一对多关联count(distinct 主体唯一键)对比外连接结果只剩匹配行过滤条件写在了WHERE里把条件移到ON后重跑对比关联字段死活匹配不上存在NULL值或类型不一致用IS NULL检查、确认两边字段类型join查询特别慢被驱动表关联字段没索引在关联字段上加索引后EXPLAIN对比right join被人看不懂阅读方向反了改写成left join逻辑等价5.5 一个真实的排查小故事我印象最深的一次线上问题是一张报表数字对不上。业务方说某天的活跃用户数怎么突然少了一半。我拿到SQL一看是个三表join用left join串起来然后在WHERE里写了个settle_status 1的过滤条件。结果就是所有没结算记录的用户全被WHERE干掉了而这些恰恰是当天新来的、还没产生行为的用户。把那个条件从WHERE挪回到ON里数字立刻恢复正常。这件事让我彻底记住了ON管匹配、WHERE管过滤这条铁律也让我后来写每一条外连接都会多看一眼条件落在哪里。排查这类问题靠的不是高深技巧而是对语义的清晰认知。6. 几条能直接落地的实操建议最后集中说几个我日常写join时坚持的习惯都是吃过亏换来的。第一条写多表join时先写on再写select让关联条件最先被大脑处理能有效降低漏写概率第二条所有外连接优先用left join把主表放左边团队协同时大家的阅读方向一致减少沟通成本第三条任何涉及join的统计SQL写完后先跑一遍count对比单表数量行数对不上的话多出来的少掉的分别属于哪类数据必须在心里有数再交付。关于索引这里补充一个实用判断等值join时索引应该建在被驱动表的关联字段上而不是驱动表。哪张是驱动表由优化器决定通常是结果集更小、过滤后行数更少的表你可以通过EXPLAIN看第一行来判断。另外关联字段的数据类型一定要一致比如一边int一边varchar可能导致隐式类型转换索引直接失效这个坑特别隐蔽查半天查不出来。还有一个容易被忽视的点是关联条件的字段选择。能用主键或唯一索引关联当然最好但如果必须用普通字段记得确认它的区分度区分度太低的字段比如性别、状态这种只有几个值的加索引效果很差这时候不如考虑调整查询逻辑或者加联合索引。我自己的经验是join性能问题八成出在没索引和条件写错这两件事上真正需要动到复杂优化的场景其实不多把基础打牢就能解决大部分问题。最后分享一个我平时验证join逻辑的小技巧拿一份数据量很小的、你自己清楚的测试集手工算出你期望的结果然后跑SQL对比。因为数据量小你一眼就能看出哪行多了、哪行少了、哪个NULL不该出现。这个习惯让我在写复杂SQL时少犯很多低级错误比事后在生产环境里对着几百万行数据猜问题原因高效得多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询