“关系代数”这四个字,大概是数据库原理课睡眠率最高的部分。希腊字母、集合符号、抽象运算定义,当年背完就忘,工作后更觉得“直接写SQL就好了”。但我在线上排查过一个慢查询,优化器把三层子查询拆成了笛卡尔积连接,那一刻才彻底明白:数据库引擎内部跑的就是关系代数。这篇文章就聚焦关系代数里的两个关键运算——投影运算和外连接运算。
投影解决“取哪些列、怎么去掉重复”的问题,外连接解决“连接时是否保留未匹配行”的问题。我会用自然连接做参照,配合一个完整的订单业务案例,把两者的语义、用途、关键特性和SQL落地方式挨个讲透。适合谁看?写过SQL但没系统学过关系代数的开发者;经常和报表、数据看板打交道、对JOIN结果心存疑虑的分析师;准备技术面试、想弄清“关系代数到底怎么映射SQL”的候选人。
1. 关系代数为什么值得重新学一遍
1.1 关系代数是SQL的“中间表示”
一条SQL语句并不是数据库直接照着执行的。优化的第一步是把SQL解析成语法树,再转成关系代数表达式,接着在表达式上做等价改写:把WHERE条件下推到表扫描之前,把SELECT的投影列裁剪掉不需要的字段,把多表连接重排顺序以减少中间结果集。这些动作全部发生在关系代数层。等操作顺序确定之后,才决定用哪种物理算法去执行,进入执行计划阶段。
你可以把关系代数想象成“查询配方”,SQL是配方的口语表达,执行计划是厨房里的实际操作。配方里写着先投影还是先选择、左连接还是内连接、连接条件落在哪个属性上,这些都直接决定了最终端上桌的菜长什么样。所以我遇到复杂SQL时,习惯先在草稿纸上写出等价的关系代数表达式,再判断问题出在哪一步,而不是盯着几十行的SQL干瞪眼。
1.2 投影和外连接为什么最值得单独研究
关系代数基本运算符里,选择和投影最常用,连接最昂贵,但为什么单独把投影和外连接拎出来讲?因为这两个运算在集合语义上都容易让人摔跤。投影会改变关系的列结构和去重行为,外连接则改变了连接结果对未匹配行的“包含关系”,两者叠加后,结果集的行数和NULL分布都会出现反直觉变化。
举一个我见过的真实场景:用户表有100万行,订单表有50万行。用户表左外连接订单表,结果行数绝对不是100万,而是100万加上“一对多”扩展出来的所有订单行。如果这时候再做投影去重、聚合统计,很容易出现用户数重复统计、金额翻倍之类的事故。理解这两个运算,等于给这类报表问题上了保险。
2. 投影运算的语义与SQL落地
2.1 投影的本质是垂直切表
投影运算是从关系里选取若干列,生成一个新关系,记作π_列名列表(表)。比如有一张员工表:
| EmployeeID | Name | DeptID | Salary |
|---|---|---|---|
| 1 | 张伟 | D1 | 10000 |
| 2 | 李娜 | D2 | 12000 |
执行π_Name, DeptID(员工表),会得到:
| Name | DeptID |
|---|---|
| 张伟 | D1 |
| 李娜 | D2 |
它在逻辑上相当于“垂直切表”:只保留下指定列,其他列全部丢掉。投影不改变行的总数,它改变的是关系的“宽度”。从数据库执行来看,投影直接影响扫描阶段要读取的字段。如果你只需要两列,而这两列恰好都在某个二级索引里,数据库可以只扫索引不回表,这就是后面会提到的“覆盖索引”。
2.2 集合语义:为什么投影自动去重,而SELECT不去重
关系代数研究的是“关系”,关系在集合论中是一个集合,集合不允许重复元素。所以π_A(B)的结果里,相同行只出现一次。这个性质在理论上很干净,但在SQL里却出现了偏差:SQL的SELECT默认返回的是多重集合,允许重复行。也就是说,SELECT Name, DeptID FROM 员工表并不保证去重,只有加DISTINCT才和关系代数投影完全等价。这是初学者最容易踩的坑:以为SELECT就是投影,实际它更像是“扩展投影加重复保留”。
举一个实际例子。订单表里想查“有哪些用户下过单”,关系代数写π_UserID(Orders),每个用户只出现一次。翻译成SQL如果直接写SELECT UserID FROM Orders,下过10单的用户会出现10次,后续聚合全不对。正确对应是SELECT DISTINCT UserID FROM Orders。反过来,如果你要的是“每个订单和它的用户ID”明细,那绝不能加DISTINCT,否则多个相同用户ID的订单会被合并,订单维度就丢了。
2.3 三种投影写法的对应关系
在实际SQL里,投影有三种常见映射形态,弄清楚就不会乱了。
| 关系代数写法 | SQL写法 | 是否去重 |
|---|---|---|
| π_A,B(R) | SELECT DISTINCT A, B FROM R | 是 |
| 扩展投影 π_{F1,F2}(R) | SELECT expr1 AS F1, expr2 AS F2 FROM R | 否 |
| π_A,B(σ_条件(R)) | SELECT DISTINCT A, B FROM R WHERE 条件 | 是 |
为什么SQL要引入“扩展投影”?因为业务中经常需要计算列,比如年薪等于月薪乘12、从时间字段里提取年份、拼接姓名等,这些都不是单纯的列裁剪,而是“生成新列”。关系代数的原始投影只有列名,不支持表达式,后来实际应用中才扩展出了表达式投影能力。理解这个演变,你就能明白为什么优化器有时无法把SELECT *优化成只取需要的列:表达式、函数和不确定行数都会增加改写的难度。
2.4 投影在实践中要避开的三个坑
第一个坑:DISTINCT用错对象。SELECT DISTINCT UserID, OrderID是对“UserID和OrderID的整体组合”去重,不是对 UserID 单独去重。如果你只想看 UserID 的所有可能取值,要写SELECT DISTINCT UserID FROM Orders,而不是把 OrderID 也带上去重。
第二个坑:忽略去重代价。做DISTINCT需要排序或哈希去重,对百万级、亿级表来说成本很高。很多场景业务上根本不需要去重,加上DISTINCT只是心里舒服,查询却慢了几十倍。我见过有人对“两表连接后天然不会重复”的结果强行加DISTINCT,纯属浪费。先想清楚业务语义,再决定要不要去重。
第三个坑:投影列过宽。SELECT *会把所有列都捞出来,哪怕外层只用一个字段。这既浪费I/O和网络带宽,又压缩了覆盖索引的发挥空间。在复杂查询里,尽量在源头子查询就把列裁剪好,给优化器“投影下推”留空间。
3. 外连接究竟在干什么
3.1 连接运算的基本模型:笛卡尔积加选择
连接可以这样理解:先把左边每行和右边每行做笛卡尔积,然后用连接条件判断哪些组合成立,即R ⋈_C S = σ_C(R × S)。如果两张表分别有m行和n行,笛卡尔积会产生m×n行,连接条件筛掉不满足的,剩下的就是结果。
数据库真实执行时当然不会物化完整笛卡尔积,它会在扫描过程中利用索引、哈希结构直接跳过大量不可能匹配的组合。但逻辑模型仍然很有价值:连接条件决定哪些行配对成功,从而决定结果行数。如果你写ON a.user_id = b.user_id,配对粒度就是用户维度;如果漏写条件,就是全笛卡尔积,结果行数爆炸。很多慢SQL的根源,其实就是这种非预期笛卡尔积。
3.2 自然连接:简洁背后的“隐式条件”
自然连接是等值连接的一种简化写法:自动把两个关系中同名的所有列做等值比较,结果中同名列只保留一份。比如Users(CityID)和Cities(CityID)自然连接,就等于按CityID等值连接,输出列里只出现一个CityID,再附带其他列。
自然连接看起来很简洁,前提是两张表设计规范、命名统一、重名字段的语义确实是该连接的键。问题在于现实世界里的表是多个版本叠加出来的:用户表有status,订单表也有status,如果都用NATURAL JOIN,数据库会把status也拿去自动匹配,等于额外加了一条user.status = order.status条件,结果行数会少很多甚至为空。这种错误出现概率不低,而且特别难排查,因为SQL看起来“很干净”。所以我在生产环境几乎不用NATURAL JOIN,看到同名列就手动写在ON后面。连接是业务意图,应该显式表达,而不是让数据库猜。
3.3 三种外连接:保留未匹配行的语义扩展
标准内连接只保留匹配成功的行,两边没配上的行会被丢弃。但很多业务需求是“没配上也要保留”:订单和退款,有的订单没有退款记录;用户和订单,有的用户从没下过单;城市和订单,有的城市没有任何交易。这时候就需要外连接。
- 左外连接:保留左侧所有行,右侧没有匹配就补
NULL。 - 右外连接:保留右侧所有行,左侧没有匹配就补
NULL。 - 全外连接:保留两侧所有行,各自没有匹配的就补
NULL。
最典型的例子:订单表Orders和退款表Refunds。要查“所有订单是否有退款”,用Orders LEFT JOIN Refunds,没有退款的订单,退款字段就是NULL。再进一步,如果想筛出“没有退款记录的订单”,直接在外连接结果上加WHERE Refunds.OrderID IS NULL即可。这个写法初看有点反直觉,但非常常用,属于外连接的招牌用法。
4. 自然连接与外连接的核心差异
4.1 一张表看清差异
把自然连接和代表外连接的左外连接放在同一张表里对比,语义差异一望便知:
| 对比维度 | 自然连接 | 左外连接(外连接代表) |
|---|---|---|
| 匹配依据 | 自动匹配所有同名同值列 | 必须在 ON/USING 中显式指定 |
| 结果行数 | 仅保留匹配成功的行 | 保留左表所有行,未匹配补 NULL |
| 同名列处理 | 自动合并为一列 | 通过表名或别名区分显示 |
| 语义定位 | 内连接语义 | 保留未匹配行的扩展语义 |
| 业务风险 | 同名不同义时静默出错 | 条件可控,问题容易定位 |
| SQL写法 | NATURAL JOIN / USING | LEFT/RIGHT/FULL OUTER JOIN |
从行数角度理解:自然连接的结果行数不会超过左表和右表中“能匹配上”的行的组合数;左外连接的结果行数至少包含左表的全部行,再加上一对多匹配产生的扩展行。外连接通常比内连接多出那些“未匹配行”,这一点直接决定了报表聚合时要不要用DISTINCT。
4.2 生产库我为什么不推荐自然连接
自然连接并非一无是处。在列名体系极其规范、同名列语义完全一致的数据仓库中,它可以少写不少连接条件。但业务系统数据库往往做不到。
原因是业务表结构天然会演进:用户表加了source,订单表也加了source,一个表示注册渠道,一个表示下单渠道。如果哪天有人图省事把JOIN改成NATURAL JOIN,优化器就会自动把source也变成等值条件,用户来自微信、订单来自抖音的行全部配不上,结果表行数骤减。线上排查这类问题非常痛苦,因为语句不长、不报错,只有最后数据对不上时才暴露。
我自己的原则是“三个显式”:连接条件显式写、连接类型显式写、过滤字段显式写。宁可多敲几个字符,也不要让系统去猜连接意图。
4.3 外连接:报表与对账场景中的刚需
外连接在两种业务场景中不可替代。
第一类是“补全维度”。要统计每个城市的用户数和订单金额,城市是维度表,用户和订单是事实表。内连接会把没有用户的空城市直接丢掉,而报表要求所有城市都出现,哪怕数字是0。这时只能以城市为左表,逐级左外连接用户、订单,再聚合。这就是典型的“外连接做维度补全”。
第二类是“寻找缺失”。比如找出从未下过单的用户、找出没有退款的订单、找出有入账但没有出账的对账单。推荐写法有两种:一是外连接加IS NULL过滤,二是NOT EXISTS子查询。在关系代数层面,前者正是外连接引入NULL后产生的新能力,语义非常清晰。
5. 实战:订单场景中投影与外连接的组合运用
5.1 表结构与业务前提
造三张能说明问题又不啰嗦的表:
Users(UserID, UserName, CityID):用户表,一个用户属于一个城市。Cities(CityID, CityName):城市表,城市维度。Orders(OrderID, UserID, Amount, Status):订单表,Status只有SUCCESS和CANCELED。
业务特点:一个城市可以有很多用户,一个用户可以下很多订单,所以从城市到订单是典型的一对多关系。这个结构覆盖了投影、内连接、外连接、聚合所有关键点,做演示很合适。
5.2 需求1:查询每个用户的用户名和所在城市名称
关系代数写法很简洁:π_UserName, CityName(Users ⋈ Cities)。这里用自然连接,因为Users和Cities共有CityID列,语义就是要按城市ID匹配。翻译成SQL:
SELECT u.UserName, c.CityName FROM Users u JOIN Cities c ON u.CityID = c.CityID;注意,我没有写成NATURAL JOIN。虽然当前表结构只有CityID一个同名列,用NATURAL JOIN没问题,但换个系统就不一定。用ON显式声明后,读代码的人一眼知道是按城市ID连接,不会被未来的同名字段坑到。
5.3 需求2:查询所有用户以及他们的订单信息
如果只写内连接Users JOIN Orders ON ...,没下过单的用户会整个消失。业务要求“所有用户”,哪怕没有订单也要出现,所以用左外连接:
SELECT u.UserID, u.UserName, o.OrderID, o.Amount FROM Users u LEFT JOIN Orders o ON u.UserID = o.UserID;关系代数表达式可以写作π_UserID, UserName, OrderID, Amount(Users ⟕ Orders),其中⟕表示左外连接。特别注意,结果集中的OrderID和Amount允许为NULL。展示时可以用COALESCE(Amount, 0)做页面展示,但生产统计里不要随便把 NULL 变成 0,因为 NULL 和 0 的业务含义不同:NULL 表示“没有订单”,0 可能被误读为“金额为零的订单”。
结果行数规则也值得记一下:有多少个用户至少就有多少行;每个用户有n张订单,就会额外贡献n-1行。
5.4 需求3:统计每个城市的用户数和成功订单金额
这是把外连接和聚合结合起来的经典报表需求,SQL如下:
SELECT c.CityName, COUNT(DISTINCT u.UserID) AS user_cnt, COALESCE(SUM(o.Amount), 0) AS success_amount FROM Cities c LEFT JOIN Users u ON u.CityID = c.CityID LEFT JOIN Orders o ON u.UserID = o.UserID AND o.Status = 'SUCCESS' GROUP BY c.CityName;这里有三个关键点。
第一,城市表必须是左表,否则没有用户的空城市不会出现在结果里。
第二,第一个LEFT JOIN之后,一个城市会展开成多行用户;第二个LEFT JOIN之后,一个用户又会展开成多行订单。如果直接COUNT(u.UserID),同一个用户有多张订单时会被重复计数,所以必须用COUNT(DISTINCT u.UserID),或者先做用户维度预聚合再连接。这是我反复见过的报表错误,十次里有八次数据对不上都是这个原因。
第三,o.Status = 'SUCCESS'放在ON条件里,而不是WHERE里。如果放进WHERE,那么没有成功订单的用户,其o.Status是 NULL,NULL = 'SUCCESS'为 UNKNOWN,整行会被过滤,左外连接“保留左侧全量”的语义就被破坏了。
5.5 关系代数表达式的完整推导
为了更贴近关系代数,我把上面的SQL一步步拆开。
第一步,城市和用户左外连接,得到“城市-用户”明细:T1 = Cities ⟕ Users ON Cities.CityID = Users.CityID。
第二步,先对订单做选择,只留成功订单,再左外连接到T1:T2 = T1 ⟕ (σ_Status='SUCCESS'(Orders)) ON T1.UserID = Orders.UserID。先选择再连接,可以保证被过滤的订单不会把左侧的行整个滤掉。
第三步,分组聚合。关系代数中常用G表示分组聚合,把分组列和聚合函数写在下标里:T3 = G_CityName, COUNT(DISTINCT UserID), SUM(Amount)(T2)。
最后投影出展示列:Result = π_CityName, user_cnt, success_amount(T3)。
可以看到,SQL里每个FROM顺序、每个JOIN条件、每个聚合列,都能对应到关系代数的一步。遇到复杂SQL,先画这样的推导,再回去看执行计划,很多“为什么多一行、少一行”的问题就清楚了。
6. 执行计划视角:数据库怎么执行投影和外连接
6.1 投影下推与索引覆盖
查询优化器有个经典优化叫“投影下推”:把SELECT需要的列信息尽可能下推到扫描阶段,让表扫描只读取必要字段,减少行宽和中间结果集。如果你在子查询里写SELECT *,外层再裁列,优化器往往没法跨层裁剪,数据被迫先全量带出来再丢弃。
充分利用这一点的实践是设计“覆盖索引”。比如经常查UserID和Amount,可以建(UserID, Amount)索引,当查询SELECT UserID, Amount FROM Orders WHERE UserID = ?时,数据库直接在索引里拿到两列,不需要回表。理解投影,你就理解“查询需要哪些列”怎样直接影响索引设计。
6.2 三种连接算法怎么选
数据库执行连接常见有三种算法。
嵌套循环连接适合小表驱动大表、且连接列有索引的情况,每次拿驱动表一行去被驱动表里找匹配。哈希连接适合两个大表做等值连接,先在内存里建哈希表再探测。合并连接适合两边数据已按连接列排好序的情况,比如连接列上有索引,扫描时像拉链一样顺序匹配。
这些算法复杂度各不相同,但共同点是:连接列上有索引,或者两边数据量小,才能跑得快。所以连接条件越明确、可用索引越好,数据库越容易选出合适的算法。外连接通常会限定驱动表顺序,如果被驱动表上缺索引,容易变成逐行扫描,性能会明显劣化。
6.3 外连接在优化器眼中的“限制”
外连接和普通连接在优化上有一个重要区别:外连接左右顺序的语义是固定的,优化器不能随意交换左右表来降低中间结果集大小。比如A LEFT JOIN B,A必须保留全部行,不能临时改成B RIGHT JOIN A去优化扫描顺序,这会让执行计划的可选空间变小。
所以遇到大表外连接性能差时,我会先检查两件事:一是被驱动表的连接列有没有索引;二是能不能通过预聚合缩小左侧表的数据量。很多情况下,把左侧的过滤条件先执行、再外连接,执行计划会好看很多。
7. 常见问题与排查实录
7.1 LEFT JOIN 被 WHERE 悄悄变成 INNER JOIN
这是最常被问到的外连接问题。假设SQL写成了:
SELECT u.UserName, o.Amount FROM Users u LEFT JOIN Orders o ON u.UserID = o.UserID WHERE o.Amount > 100;表面看是左外连接,但WHERE o.Amount > 100会把o.Amount为NULL的行过滤掉,而没有订单的用户正好o.Amount是NULL,于是他们消失了,左外连接实际变成了内连接。如果确实只想过滤订单金额大于100的订单,同时保留所有用户,请把金额条件放到ON里:
LEFT JOIN Orders o ON u.UserID = o.UserID AND o.Amount > 100;排查这类问题时,先看 WHERE 里有没有引用右表字段,有就得警惕。
7.2 自然连接匹配到“同名不同义”的字段
我遇到过一次事故:用户表和订单表都新增了一个region字段,一个是用户注册地区,一个是订单归属仓。同事为了省事用了NATURAL JOIN,结果region被自动拿来等值连接,原本能配上的订单大量被过滤掉。排查了两天,最后看执行计划时才发现连接条件比预期多了一条。
教训已经写在前面:连接条件必须显式,生产库禁用NATURAL JOIN。这个例子不算极端,任何两张业务表在演进中都可能撞出同名但不同义的字段。
7.3 SELECT DISTINCT 让结果神秘变少
某次报表需求是查“每天每个商品成交了多少订单”,同事写的是:
SELECT DISTINCT order_date, product_id, COUNT(*) AS cnt FROM orders GROUP BY order_date, product_id;这里的DISTINCT是多余的,它作用在整个分组结果上,不会让计数变大或变小,但会让数据库多一次去重。真正常见的“变少”是另一个版本:把DISTINCT加在明细结果上,但由于分组列和明细列混用,合并掉了本不该合并的行。统计行数时一定要先确认去重对象是什么,DISTINCT作用于整个行组合,不是某一列。
7.4 多表外连接导致 COUNT 翻倍
订单明细和退款明细是多对多关联的典型。如果你把订单表 LEFT JOIN 退款表,再按用户做 COUNT,退款多几条,订单行就被复制几遍,用户订单数就虚高了。
解决办法有两个:先在退款表里按订单聚合,再连接;或者计算时使用COUNT(DISTINCT OrderID)去重统计。我更推荐前者,因为明细聚合后再连接,不会污染订单层其他指标,比如金额求和。
我在实际踩坑后形成的习惯是:写任何 JOIN,先问自己三个问题——连接条件的业务含义是什么?这个连接会保留哪些行?聚合时会不会被明细行放大?如果答不上来,就先写关系代数表达式,再转成 SQL。这套习惯帮我在无数报表和查询里避免了很多莫名其妙的数据错误。关系代数不是考场里的符号游戏,它是理解数据库行为、写出可控 SQL 的最底层语言。投影和外连接尤其如此,搞清楚了这两个运算,你再看 JOIN、看 DISTINCT、看聚合,都会有庖丁解牛的感觉。