
上周帮同事排查一个数据汇总脚本部门负责人名单明明齐全可最终报表里硬是少了三名员工。翻到 SQL 里看到 JOIN 的时候我就明白了——默认是内连接匹配不上的行会被静默扔掉。这在某些场景下确实是需要的但做报表、做对账时往往恰恰是这些匹配不上的行才是要重点关注的。这个问题如果只靠背 SQL 语法很难想清楚把它还原成关系代数里的运算语义一眼就能看穿。这篇文章我想聊透的是关系代数里的两个核心操作投影运算和外连接运算。投影负责管列外连接负责把丢失的行找回来两者在实际查询中几乎天天遇到。我会用一张员工表和一张部门表把每个运算亲手推一遍再重点对比自然连接和外连接的差异。如果你是数据库课还没学透的学生、被 JOIN 搞晕的开发或是刚转行做数据分析的朋友这篇文章的推演过程应该能帮你把概念落到实处。1. 为什么查数据会把行查丢从一张员工表开始1.1 一个每天都在发生的查询需求先看两张极简的表。员工表 employeeEmpIDNameDeptIDE1张伟D1E2李娜D2E3王强NULLE4赵敏D3部门表 departmentDeptIDDeptNameManagerD1研发一部刘洋D2市场二部陈静D3技术部孙越D4财务部周琳注意这张表里的两个钉子户员工王强的 DeptID 是 NULL说明他暂时没有被分配到任何部门部门表里的财务部 D4 没有对应员工属于空挂部门。现在需求来了列出每位员工的工号、姓名和所在部门名称。你很可能不假思索写一个JOIN ... ON。但先别急用自然连接或者普通内连接做一次你会发现结果变成EmpIDNameDeptIDDeptNameManagerE1张伟D1研发一部刘洋E2李娜D2市场二部陈静E4赵敏D3技术部孙越王强消失了财务部也消失了。这就是查丢行的根源内连接只保留能配对成功的元组。王强因为没有部门编号没有任何部门能和他配对财务部因为没有员工也没有任何员工能配给它。它们成了所谓悬挂元组被自然连接静默删除。报表里少了三个人这种问题在真实的数仓任务里非常隐蔽因为数据量大时你不会一行行去数。1.2 关系代数把查询变成可推导的运算关系代数这门功课很多同学觉得抽象、没用其实它最大的价值是把查询这种口语化的需求变成一组可以推导、可以等价变换、可以检查的数学表达式。它最核心的思想是表就是关系关系是无序的元组集合查询就是各种集合运算的组合。教科书上通常讲五个基本运算选择 σ、投影 π、并 ∪、差 −、笛卡尔积 ×。其余的常见操作——交、自然连接、外连接——都是从这五个基本运算组合出来的。这个可推导性非常重要。比如下面两节要讲的投影定义就是 σ 的列方向的兄弟操作而外连接则可以分解成内连接 ∪ 补 NULL 的悬挂元组。学会了用关系代数的眼光看查询你就不会再把 SQL 当成一个黑盒写错了也能一步步推回去找原因。这也是为什么我总是建议新手哪怕是写一句很简单的 SELECT也先在心里过一遍它的关系代数形状。2. 投影运算管理列的运算比想象中多一点讲究2.1 投影的数学定义与执行逻辑投影用符号π读作派表示定义是这样对关系 Rπ_{A1,A2,...,Ak}(R)表示从 R 中取出指定的 k 个属性列删掉其他列并且去掉重复的行形成一个新的关系。听起来很简单就是选列 去重。比如π_{EmpID, Name}(employee)的结果就是前面员工表只保留前两列EmpIDNameE1张伟E2李娜E3王强E4赵敏但如果执行π_{DeptID}(employee)理论上结果是这样的DeptIDD1D2NULLD3这里有个关键点需要掰开揉碎地讲关系代数把关系当成集合集合里不允许出现两个一模一样的元素所以投影一定会去重。哪怕 employee 表里有两百个员工都在 D1投影之后 D1 也只会出现一次。这一点看起来是理论洁癖实则是理解后续一切差异的根基。2.2 集合语义下的去重行为理论与实现的差异学习关系代数时最容易产生的困惑就是为什么课本说投影会减少行数我写 SELECT 却没减少原因在于SQL 的表不是严格意义上的集合而是多重集合bag/multiset它允许重复行。所以SELECT DeptID FROM employee会返回和员工数一样多的行其中 D1 出现了许多次而SELECT DISTINCT DeptID FROM employee才真正对应关系代数里的投影语义把重复值合并成一个。我用个生活化类比关系代数里的投影像点名签到表每个部门只签到一次SQL 默认的投影像流水账每名员工来一次就记一行。两种语义都有道理但你心里必须清楚自己在用哪种否则做数据统计时会出大问题——比如COUNT(DeptID)和COUNT(DISTINCT DeptID)结果差好几倍。这个差异还牵出一个实践教训理论上投影一定去重物理上它需要 sort 或 hash 才能去掉重复代价不低。所以优化器在处理SELECT DISTINCT时通常会比普通SELECT慢就是因为多了这一步去重操作。2.3 投影在真实查询里的三种用法虽然定义简单投影在实战里的地位非常高主要体现在三处。第一是列裁剪。分析任务里一张事实表可能有上百列但你只需要其中五列。先投影掉无关列能显著减少磁盘 IO 和网络传输。ETL 里常见的SELECT id, name, amount FROM ...就是在做投影。第二是列的重命名和派生。严格的关系代数投影只取原始属性但扩展投影允许在投影列表里写表达式SQL 里对应的是SELECT dept_id AS 部门编号, amount * 1.1 AS 含税金额 FROM orders;这在关系代数里可以写作π_{dept_id→部门编号, amount*1.1→含税金额}(orders)符号上虽然没那么统一语义上是完全一样的。第三是去重统计。想知道公司员工覆盖了哪些部门编号最简单的方式就是SELECT DISTINCT DeptID FROM employee。对应关系代数的思路就是先投影部门编号再去重最后数一数结果里有多少个元组。这类基数估算是数据仓库里做维度梳理的常用操作。2.4 投影的注意事项空值、重复列与顺序投影也有一些容易踩的边角细节。NULL 的去重行为投影时多个 NULL 会被当作同一个值只保留一个SQL 里的DISTINCT同样把 NULL 视为一个分组。但要注意COUNT(DISTINCT 列)在绝大多数数据库里会忽略 NULL——这两个行为不一致统计口径上要特别小心。投影属性的顺序关系是集合行和列本来都无序但 SQL 查询结果的列顺序由 SELECT 列表顺序决定。这是实现层面的便利不影响语义。重复列名自然连接会把同名列合并但等值连接可能产生两个名字相同的列。投影时如果只写列名数据库会报歧义错误必须用表名前缀限定。这个在写多表查询时特别常见。投影本身不复杂但它决定了你最终看到哪些列。理解了它再去看连接运算视角就会完全不一样。3. 连接运算的完整谱系乘、筛选到合并的演进3.1 为什么要连接拆分与重组数据库设计讲究规范化会把信息拆到不同表里通过主键外键关联起来。员工只有部门编号这样一个短代码而部门的详细信息在 department 表里。查询时要拼表把两张表按关联键缝起来。这个缝的过程在关系代数里就是连接系列运算。连接的底层逻辑是先做笛卡尔积再按条件过滤。笛卡尔积R × S会把 R 的每一行和 S 的每一行都拼一遍结果的行数是|R| × |S|列数是|R| |S|。这当然会产生大量无意义组合所以必须加条件筛掉。θ 连接的定义就是σ_θ(R × S)其中 θ 是任意比较条件。等值连接是条件形如R.A S.B的特例也是业务里最常用的。3.2 自然连接同名列自动匹配简洁但有代价自然连接用符号⋈表示。它的规则很省事自动找出两个关系中所有同名的属性在这些属性上做等值匹配并且结果里把重复的同名列合并为一列。用 employee ⋈ department 推一遍匹配条件是两者共有的 DeptID匹配成功三行结果列变成 EmpID, Name, DeptID, DeptName, Manager。注意 DeptID 只出现一次没有冗余。我在实际教学里发现自然连接最大的问题是看上去方便实则危险。一个危险是如果两张表根本没有任何同名列自然连接会退化成笛卡尔积数据瞬间膨胀而且还不报错。另一个更隐蔽如果两个表碰巧都有 DesignTime、UpdateTime 这类同名字段自然连接会把它们也自动纳入等值匹配明明你只想要按 DeptID 连接结果却成了DeptID 相等且更新时间也相等才能匹配上符合条件的数据一下子少了大半。这种 bug 在表结构稍微复杂一点的项目里排查起来非常费劲。所以生产环境里我基本不推荐NATURAL JOIN。3.3 相等连接条件由你控制和自然连接相对的是显式条件的等值连接符号上常写作R ⋈_{R.A S.B} S。它和自然连接的核心区别有两点连接条件由人显式指定不靠自动猜测可读性和可维护性都好很多结果不合并同名列因此 R.A 和 S.B 两列都会保留下来当它们同名时需要加表前缀区分。SQL 里的写法就是SELECT * FROM employee e JOIN department d ON e.DeptID d.DeptID;这里结果里会出现两列 DeptID一个来自 e一个来自 d。SQL 标准里的USING (DeptID)则处于两者之间连接条件指定但结果把 USING 的列合并为一列。这三种形式各有适用场景但论通用性和安全性显式 ON 是团队协作的首选。需要强调一点无论自然连接还是等值连接都属于内连接语义不匹配的行都会被丢掉。这个丢掉是内连接的本质行为不是 bug。问题是很多业务需求恰恰不能接受丢行于是外连接登场了。3.4 外连接的动机丢失的行去哪里找回回到开头的报表需求。如果老板说我要所有员工的名单没分配到部门的也显示出来部门列留空内连接就交不了差。再比如对账场景查哪些订单付了款但没发货你要找的恰恰是匹配不上的数据。内连接把匹配失败的行删掉等于把你要找的答案亲手扔了。外连接的动机非常朴素保留匹配失败的行并在缺失的一侧用 NULL 占位。它没有发明新数据只是把查不到这个事实显式地表示出来。从理论上讲外连接可以用内连接、并、笛卡尔积和投影组合出来不增加关系代数的表达能力但它是极好的语法糖让保留全部行这么重要的语义能被人一眼看懂也让数据库优化器有更多机会做优化。这里你只需要记住内连接处理匹配得上的世界外连接处理匹配不上也是信息的世界。4. 外连接三种形态的语义拆解与数据推演4.1 左外连接保留左侧全部行外连接有三种左外、右外、全外。先说左外符号是⟕。R ⟕ S的语义是把 R 的全部行都保留下来。R 中凡是能在 S 里找到匹配的行照常拼接 S 的属性如果找不到匹配S 的所有属性在结果里填 NULL。推演一下 employee ⟕ departmentEmpIDNameDeptIDDeptNameManagerE1张伟D1研发一部刘洋E2李娜D2市场二部陈静E3王强NULLNULLNULLE4赵敏D3技术部孙越注意财务部 D4 没有出现因为它在右侧左外连接不负责保留右侧的悬挂元组。王强则回来了Department 侧全部是 NULL。这正是刚才全员名单需求想要的形状。对应的 SQL 是SELECT e.EmpID, e.Name, e.DeptID, d.DeptName, d.Manager FROM employee e LEFT JOIN department d ON e.DeptID d.DeptID;一个对初学者非常重要的结论LEFT JOIN 的结果行数不会少于左表行数。严格说是左表中至少出现一次的行加上因一对多匹配产生的额外行数之和。这一点后面还会再讲。4.2 右外连接保留右侧全部行右外连接符号是⟖语义上就是把左外的方向反过来R ⟖ S保留 S 的全部行R 侧匹配不上的填 NULL。推演 employee ⟖ departmentEmpIDNameDeptIDDeptNameManagerE1张伟D1研发一部刘洋E2李娜D2市场二部陈静E4赵敏D3技术部孙越NULLNULLNULL财务部周琳这次王强丢了因为他在左侧右外不保留他而财务部 D4 回来了员工侧属性填 NULL。有一个等价关系值得记R ⟖ S 等价于 S ⟕ R。所以右外连接写起来并没有任何额外的表达能力。实战里很多团队会统一约定只在需要保留的行所在的表上写 LEFT JOIN把表顺序调换来减少左右混淆这是有一定道理的——嵌套多层 JOIN 时右外连接的方向非常容易搞错。4.3 全外连接两侧悬挂元组并存全外连接符号是⟗R ⟗ S同时保留 R 和 S 的全部行任何一侧匹配不上都补 NULL。推演结果EmpIDNameDeptIDDeptNameManagerE1张伟D1研发一部刘洋E2李娜D2市场二部陈静E3王强NULLNULLNULLE4赵敏D3技术部孙越NULLNULLNULL财务部周琳全外连接是最大程度保留信息的连接方式。在数据对账、双表差异分析这类场景里非常有用它能让左右两侧的悬挂元组同时现身配合 WHERE 条件过滤出只出现在左边或只出现在右边的数据就能精确找出两张表之间的差异。SQL 层面PostgreSQL、SQL Server、Oracle 都支持FULL OUTER JOIN但 MySQL 一直到 8.x 都还没实现完整的全外连接语法。实操中需要模拟常见做法是左外和右外做 UNIONSELECT e.EmpID, e.Name, d.DeptName FROM employee e LEFT JOIN department d ON e.DeptID d.DeptID UNION SELECT e.EmpID, e.Name, d.DeptName FROM employee e RIGHT JOIN department d ON e.DeptID d.DeptID;UNION 会自动去重而全外连接在理论上不会因为左右匹配组合相同而重复保留同一行所以这个模拟在大多数场景下语义是对的。4.4 悬挂元组与 NULL 填充规则讲到这里该给钉子户们一个正式名分了。在连接中无法在对方关系里找到匹配元的元组叫悬挂元组。内连接悬挂元组直接删除左外连接左侧悬挂元组保留右侧列填 NULL右外连接右侧悬挂元组保留左侧列填 NULL全外连接两侧悬挂元组都保留各自对侧填 NULL。理解谁补 NULL有两个小技巧。第一NULL 永远出现在没匹配上的那一侧所对应的列上第二判断一个外连接结果里的某行是不是悬挂元组就看它在非主表一侧的列是否全为 NULL——这也是很多排错工程师用WHERE d.DeptID IS NULL来抓左表没有匹配的原因。这个模式在对账脚本里出现频率极高值得你记下来。5. 自然连接 vs 外连接同一份数据上的逐行对照5.1 逐行对照推演现在把四种连接放在同一张表里对比这样语义差异会非常直观。状态自然连接左外连接右外连接全外连接E1 匹配 D1保留保留保留保留E2 匹配 D2保留保留保留保留E4 匹配 D3保留保留保留保留E3 无部门丢弃保留右列 NULL丢弃保留右列 NULLD4 无员工丢弃丢弃保留左列 NULL保留左列 NULL逐行看过去规律就出来了匹配成功的三行在四种连接里一模一样差异只发生在悬挂元组的去留和 NULL 填充上。所以当你看到左外连接结果比内连接多几行那不是数据错了而是悬挂元组被保留了。为了加深印象再想一种特殊情况如果 employee 表里再来一个 E5DeptID 也是 D1那么连接后 E1-D1 和 E5-D1 会各拼一次研发一部产生两行。这说明连接的匹配是一对多展开的外连接并不保证多出来的行都是悬挂元组也可能是一对多匹配造成的膨胀。区分这两种多行是排查连接结果行数异常的基本功。5.2 语义、列集合与适用场景差异表用表格把关键特性放在一起对比方便记忆和查阅对比维度自然连接左外连接全外连接连接条件自动用全部同名属性通常显式指定ON/USING通常显式指定是否合并同名列合并只保留一份不合并NATURAL 变体除外不合并悬挂元组全部丢弃左侧保留右侧填 NULL两侧保留补 NULL结果行数等于匹配组合数匹配组合数 左侧悬挂数匹配组合数 两侧悬挂数NULL 来源无右侧列上补的空值两侧列上补的空值典型场景快速看关联、课堂演示主表全量展示、补全信息对账、差异分析、全量合并自然连接和外连接并不是二选一的对立关系它们分属于两个维度一个是怎么确定连接条件、怎么合并列另一个是匹配失败时怎么处理。用 SQL 的语言说NATURAL LEFT JOIN就是把两个维度组合起来了。理解这种正交关系比死记各种 JOIN 类型高效得多。5.3 常见误区外连接不会多出匹配行写 JOIN 时有几个高频误区必须单独拿出来讲。第一个误区是左外连接行数一定等于左表行数。不对。如果左表一行在右表匹配到多行结果会产生多行。比如一个部门有十名员工左外连接后这个部门会对应十行。所以行数不小于左表行数只在右表关联键无重复的前提下成立。做数据校验时先确认关联键是否唯一再去断言行数范围。第二个误区是外连接比内连接多出来的行就是重复行。其实多出来的是悬挂元组它们携带的信息恰恰是内连接漏掉的。对账需求里这些行就是差异本身。第三个误区是既然全外连接信息最全那就一直用它。且不说很多数据库不支持全外连接会把 NULL 广泛引入结果后续每个字段都要处理空值团队协作时很容易埋雷。信息全不等于好用按需选择才是工程态度。6. 实战组合投影 连接在查询中的协作与翻译6.1 一个带筛选和列裁剪的业务查询把投影和外连接组合起来看一个接近真实的查询。需求分两步列出所有有部门的员工姓名和部门名称改成列出所有员工姓名和部门名称没有部门的显示未分配。第一步用自然连接加投影关系代数表达式是π_{Name, DeptName}(employee ⋈ department)它先做自然连接只保留名字和部门名两列。结果张伟/研发一部李娜/市场二部赵敏/技术部。第二步必须换成左外连接π_{Name, DeptName}(employee ⟕ department)结果里多出王强/NULL这一行。到了展示层再把 NULL 替换成未分配。关系代数负责语义SQL 负责具体实现替换动作落在COALESCE上SELECT e.Name AS 员工姓名, COALESCE(d.DeptName, 未分配) AS 部门名称 FROM employee e LEFT JOIN department d ON e.DeptID d.DeptID;这里有个值得体会的分工外连接负责把缺少的信息保留并标成 NULLCOALESCE 负责把 NULL 转成业务友好文案。两者经常成对出现帮你把缺失变成可展示的信息。6.2 先连接再投影 vs 先投影再连接关系代数表达式只是语义层的描述不等于数据库真正执行时的步骤。优化器会把表达式重排比如把投影尽可能地往下推。但如果你要手写优化 SQL就得搞清楚一个关键问题投影能不能先于连接执行答案是可以但必须保留连接所需的属性。拿上面的例子来说π_{Name, DeptName}(employee ⟕ department)如果我想先投影再连接employee 侧至少要保留 Name 和 DeptIDdepartment 侧至少要保留 DeptName 和 DeptIDπ_{Name, DeptName}(π_{Name, DeptID}(employee) ⟕ π_{DeptID, DeptName}(department))注意外层投影不能把 DeptID 裁掉否则连接就无法进行。这是一个新手极容易犯的错误——想优化就 SELECT 少点列结果把关联键 SELECT 丢了。一般规律是这样的把投影下推到连接之下时每张表需要保留的属性集 最终需要的列∪连接条件涉及到的列。多保留连接键最后再投影掉是既正确又高效的标准姿势。不过现代数据库优化器普遍能自动做这种下推手写时不必过度优化关键是别写错。6.3 与 SQL 的对应SELECT、JOIN 与 COALESCE把几个关系代数的概念和 SQL 语法一一对应起来可以建立一张翻译对照表关系代数SQL 对应说明π 投影SELECT 列表 DISTINCT普通 SELECT 是多集语义DISTINCT 才是严格投影σ 选择WHERE / HAVING作用于行的过滤⋈ 自然连接NATURAL JOIN自动匹配同名属性生产环境慎用等值/θ 连接JOIN ... ON 条件显式条件最推荐⟕ 左外连接LEFT JOIN保留左表全部行⟖ 右外连接RIGHT JOIN保留右表全部行⟗ 全外连接FULL OUTER JOIN部分数据库不支持翻译时最容易出问题的是把投影等同于 SELECT 的默认行为。比如你想把员工表按部门去重写成SELECT DeptID FROM employee结果重复部门一大堆因为你写的是多集投影必须显式加DISTINCT。关系代数里一句话说清的事SQL 里要分清楚SELECT和SELECT DISTINCT这是我在面试里反复看到的基础盲点。6.4 一个小经验显式条件与可读性最后分享一个我自己的排查习惯。遇到多表连接结果行数对不上时我会先把查询还原成关系代数表达式然后问自己三件事用的是内连接还是外连接悬挂元组应该保留在哪一侧投影掉列之前连接键还在不在这三问能定位掉绝大部分 JOIN 问题。代码规范上我也强烈建议团队里禁用NATURAL JOIN要求连接条件必须显式写在 ON 后面。理由前面已经说过表结构一旦加了同名字段自然连接的语义会被悄悄改变而这种改变往往要到数据异常之后才会被发现。显式条件虽然多打几个字但它让为什么匹配这件事永久留存在代码里排错时节省的时间远超多敲字符的成本。把投影和外连接放在同一个工具箱里看你会发现它们解决的是两个不同维度的问题投影决定输出哪些列外连接决定保留哪些行。实际操作中我习惯先确定行方向的语义——用不用外连接、保哪一侧——再去定列方向的裁剪也就是投影。顺序反了连接键没了后面的所有操作都要推倒重来。数据查询这件事多数坑不是语法问题而是语义没想清楚关系代数帮我省掉的那些排错时间大概就是这门课最实在的回报。