☰
关系代数:读懂SQL优化与数据库查询的底层逻辑
2026/10/2 14:38:41 网站建设 项目流程

1. 关系代数到底在解决什么问题

很多同学学数据库,学到SQL就以为万事大吉了,表建好了、查询写出来了,怎么优化性能、怎么判断一条SQL还有没有更好的写法,脑子里是一笔糊涂账。我刚开始带项目的时候也是这样,直到被一个慢查询折磨了一下午,才意识到自己欠缺的正是关系代数这套底层思维。

关系代数本质上是一套作用于关系(也就是表)上的运算体系。它不关心数据存在哪个文件里、索引长什么样,它只关心“怎么从一堆表中得到另一张表”。你可以把它理解为一种“表与表之间的搬运工”——给定一张或多张表作为输入,经过特定运算规则,吐出一张新的结果表。这套运算规则就是:选择、投影、并、差、交、笛卡尔积、连接、除法等。

为什么它重要?两条理由。

第一,它是SQL的“祖宗”。SQL在关系代数的骨架之上,加了排序、分组、聚合、去重等工程化的语法糖。如果你只会背SQL,很难看懂数据库优化器为什么把IN改写成EXISTS,为什么把子查询改成JOIN,为什么说COUNT(DISTINCT col)在某些场景下比COUNT(col)慢得多。而一旦你能把SQL翻译成关系代数表达式树,优化器的很多动作就变得透明了。

第二,它是写复杂查询的“草稿纸”。遇到一个业务需求,不要急着敲SQL,先在纸上把关系代数的运算顺序画出来。先选哪些行?再投影哪些列?哪两张表先连接?连接条件是什么?顺序不同,中间结果集大小可能差出几个数量级。关系代数给你提供的就是这种“先想清楚再动手”的思考框架。

这篇文章适合谁看?如果你是数据库初学者,正在被SELECT嵌套搞晕,关系代数能帮你把SQL拆解开;如果你是写了两三年SQL但没系统学过理论的工程师,它能帮你补齐这块短板,让你看执行计划时不再两眼一抹黑。

2. 看懂关系代数,先抓住几个底层约定

2.1 关系 = 表 = 集合,但“集合”不是简单的集合

数学上,关系代数建立在“关系”这个概念之上。一张表就是一个关系,每一行是一个元组,每一列是一个属性。我们平时用Excel表、用数据库表都习惯了“行有重复也不要紧”的思维,但在关系代数里有个严格的约束:关系是元组的集合,集合不允许重复元素。

这句话意味着什么?意味着在纯关系代数中,一张表里不可能存在两行完全相同的记录。这也是为什么SQL里会有DISTINCT关键字——它就是为了把SQL的结果从“多重集合”拉回到“集合”的语义上。我在实际排查数据问题时就遇到过多次:业务方说“这两张表join出来好像有重复数据”,结果一看,不是join写错了,而是源数据本身就存在完全重复的行,在SQL的默认语义下它们都被保留了下来。用关系代数的视角去看,这个问题就一目了然。

2.2 运算的封闭性:每次运算的结果仍然是一张表

关系代数有个很舒服的特性:封闭性。任何运算的输入是一张或多张表,输出还是一张表。这意味着你可以把上一次运算的结果当作下一次运算的输入,一层套一层,形成表达式树。比如σ(选择)之后再π(投影),再⋈(连接),整个链条是通畅的。

这个特性给了我们一种能力:把复杂查询拆成多个简单步骤。每个步骤只做一件事,做完得到一张中间表,下一步在这张中间表上继续操作。SQL里的子查询、CTE(Common Table Expression),本质上就是在利用这种“中间结果”的思想。你在写WITH tmp AS (...)的时候,其实就是在手动构造关系代数里的中间关系。

2.3 运算符的分类:基本运算与派生运算

关系代数中,最基础的五个运算是:选择(σ)、投影(π)、并(∪)、差(−)、笛卡尔积(×)。有了这五个,几乎所有其他运算都能推导出来。比如交(∩)可以用R ∩ S = R − (R − S)表达;连接(⋈)就是笛卡尔积加上选择条件;除法(÷)稍复杂一些,但也能用基本运算组合出来。

我之所以强调“派生”这个概念,是因为在实际学习时,很多人把自然连接、外连接等当成独立的新运算来记忆,越往后学越觉得知识点零碎。如果心里先有“基本运算”这根弦,再遇到新运算符,你就知道它不过是基础运算的组合包装罢了。理解到这个层面,后面看优化器的改写逻辑就会轻松很多。

3. 六个核心运算,逐个拆开讲透

3.1 选择运算 σ:按条件筛行

选择运算记作σ条件(R),它做的事情很简单:从关系R中选出满足条件的元组。条件里可以用比较运算符(=、≠、<、>、≤、≥)和逻辑运算符(∧且、∨或、¬非)。

这里特别要强调:选择筛的是行,不是列。比如我们要查成绩表中分数大于90的所有学生记录,写成关系代数是:

σ_分数 > 90(成绩表)

它不会改变表的结构,列数不变,只是行数可能变少。这个运算在SQL里对应的是WHERE子句——注意,不是SELECT,因为SQL的WHERE也是筛行。

从执行效率的角度,选择运算是“下推”操作的重要对象。什么叫下推?就是把这个条件尽量早地执行。如果先做笛卡尔积(或者是连接)再做选择,中间结果会非常大;如果先选择再乘积,数据量小很多。优化器总是希望选择运算能“靠近叶子节点”——也就是靠近最底层的表扫描阶段。你在看执行计划时,看到Filter或者WHERE条件被提前到Join之前执行,就是这个原理。

再说一个容易踩坑的点:多条件的先后顺序。从逻辑结果上看,σ_a=1 ∧ b=2(R)和先σ_b=2(σ_a=1(R))结果是一样的,因为选择条件满足交换律。但从执行效率看,如果b=2能过滤掉90%的数据,而a=1只能过滤10%,那应该先执行σ_b=2。我在调优一条三百多万行的查询时,就是把两个条件换了个顺序,查询时间从8秒降到了2秒多一点。SQL的执行计划和这个逻辑一脉相承,但SQL里你可能控制不了那么细,关系代数的思路能帮你想明白“为什么有些条件换个写法就快了”。

3.2 投影运算 π:按列取字段

投影记作π列名列表(R),作用是从关系中选出指定的列。比如:

π_学号, 姓名(学生表)

从表结构上说,投影会减少列的个数。和选择运算正好是一横一纵,选择管行,投影管列。

但这里有个隐藏细节:投影可能产生重复行。比如学生表中有班级和学生姓名两列,如果你只投影“班级”,一个班有40个学生,结果中这个班级就会出现40次。在集合语义下,重复元组必须被合并,所以在纯关系代数里,投影会自动去重。SQL中的SELECT DISTINCT 班级 FROM 学生表就是这个运算的对应物。而如果你用普通SELECT 班级 FROM 学生表,结果可能保留重复行,因为SQL默认是多重集合语义。

实操中,我建议你养成一个习惯:凡是写SQL时觉得DISTINCT可能影响性能,就用关系代数的投影语义想一想。如果业务上确实需要去重后再和其他表关联,那DISTINCT是必要的;如果只是查询展示,SQL允许重复,就没必要为了“看起来像关系代数”而强行去重。性能优化的前提是你清楚自己在算什么,而不是盲目套公式。

另外,投影会丢弃未被选中的列,这也意味着被丢弃的列上的索引可能无法用于后续运算。比如有张订单表在(用户ID, 下单时间)上有联合索引,如果你投影出用户ID,再按照下单时间排序,索引依然可能被用到;但如果投影时把用户ID丢弃了,后来又需要按用户ID分组,那索引大概率失效。这类问题在关系代数表达式里很容易看出来,因为中间结果的列集合是显式的。

3.3 并运算 ∪、差运算 −、交运算 ∩:集合三大件

这三个运算都要求两个关系具有相同的属性集合(属性数量相同,且对应属性域相同)。这叫做“并相容性”。如果两张表的列结构不一致,这三个运算根本没法定——这在关系代数中是一个硬性约束。

并运算R ∪ S:把两个关系的元组合在一起,去掉重复。SQL里用UNION(注意不是UNION ALL,UNION ALL是保留重复的)。差运算R − S:找出在R中但不在S中的元素。SQL里是EXCEPT(有的数据库叫MINUS)。交运算R ∩ S:找出同时存在于R和S中的元素。SQL里是INTERSECT。

别小看这三个简单运算,它们是很多集合类业务查询的基石。比如“找出既选修了数据库课程又选修了操作系统课程的学生”,就可以先把选课表按课程分别选择出来,再取两个结果的学生ID做交运算。再比如“找出所有用户中从未下过单的用户”,可以用全部用户ID差下过单的用户ID。

这里提示一下:实际操作中,UNION和INTERSECT的性能表现往往不如JOIN或者NOT EXISTS。原因在于,集合并交差操作需要对结果做去重或比较,代价不低。优化器虽然在有些场景下会做自动改写,但如果你能从关系代数表达式的角度判断出“这个集合运算其实可以转换成连接条件”,那你写出的SQL执行效率会有显著提升。

3.4 笛卡尔积 ×:一切连接的“原材料”

笛卡尔积记作R × S,把R中的每一行和S中的每一行拼在一起。如果R有m行、S有n行,结果就是m×n行,列数是两个表列数之和。这个运算在实际业务里几乎没有直接使用的场景——因为数据量会爆炸式增长。两张一万行的表做笛卡尔积,结果就是一个亿行,没有优化器会傻到真的把所有组合都算出来。

但笛卡尔积很重要,因为它是连接的底层机制。连接的本质就是:先做笛卡尔积,再按连接条件做选择。比如:

学生 ⋈ 选课 = σ_学生.学号 = 选课.学号(学生 × 选课)

优化器会想尽办法避免真正去物化笛卡尔积的结果,而是用索引、Hash Join、Nested Loop Join等算法在“算乘积的同时检查条件”。这就像做菜前先备好全部食材,但真正炒的时候是一边倒一边炒,而不是把所有食材堆成一锅再选。

从优化器的执行计划来看,如果某条SQL出现了笛卡尔积连接(Cross Join)且没有连接条件,那几乎是灾难性的。我在排查慢SQL时,第一眼就是看执行计划里有没有Nested Loop但没有Join Condition的节点。出现这种情况,多半是开发者写JOIN时漏了ON条件,或者条件里的关联字段在数据层面存在大量NULL值导致匹配不上。关系代数表达式能帮你直观地发现问题所在。

3.5 连接运算 ⋈:关系代数里的“重头戏”

连接算是关系代数中最常用也最复杂的运算。等值连接指连接条件为“属性值相等”的连接,比如学生.学号 = 选课.学号。自然连接是等值连接的一个特例——它自动把同名属性作为等值条件,并且结果中只保留一个同名列。比如学生表和学生详情表都有“学号”列,自然连接会以学号相等为条件连接,并只输出一个学号列。

我刚学的时候,最容易混淆的就是自然连接和等值连接。这里有一个记忆方法:自然连接不写连接条件,系统会自动在所有同名属性上做等值比较,并把重复的同名列去掉;等值连接需要你显式给出连接条件,且不会自动去重同名列。你可以把自然连接理解为“加了默认条件的等值连接再去重列”。但实际业务中我极少用自然连接——因为它的默认行为不透明,万一两张表里有多个同名列,比如都叫“创建时间”,自然连接就会悄悄把“创建时间”也加入连接条件,结果往往不是你想的那样。所以SQL的标准连接(INNER JOIN ... ON ...)才是更可控的写法。

连接又可以按保留行的情况分为内连接和外连接。内连接只保留满足连接条件的行;外连接则保留未匹配的行,并补NULL。左外连接保留左边关系的所有行、左连接在SQL里写LEFT JOIN;右外连接保留右边关系的所有行、写RIGHT JOIN;全外连接两边都保留、一般写FULL OUTER JOIN。

在实际项目中,外连接的数据补NULL行为经常造成意外。比如统计“每个学生的选课数量”,如果这个学生没有选课,用内连接会直接丢失这个学生,但用左外连接会保留学生,选课数量显示为0或NULL。关系代数表达式里有没有外连接、外连接在什么位置,直接影响统计口径。我踩过的一个坑是:在多个表连续LEFT JOIN时,由于中间表的过滤条件放在了WHERE里,导致左连接退化成内连接,数据凭空少了一批。用关系代数的视角检查,就能发现过滤位置出了问题。

3.6 除法运算 ÷:回答“包含全部”的经典问题

除法是所有关系代数运算里最不直观的一个。它的典型场景是:“找出选了所有课程的学生”。假设有选课表SC(学号, 课程号)和课程表C(课程号),那么SC ÷ C的结果就是那些“选了C中所有课程”的学生的学号。

形式上,R ÷ S要求S的属性集合是R的属性集合的子集。结果的属性是R有而S没有的那些。结果中的每一行,在R中和S的所有行都匹配过。这个概念我当初也是绕了好几圈才想通。直观理解:如果R是一张“学生选课记录表”,S是一张“必须课程表”,那么R ÷ S就是“全部满足必须课程的学生名单”。

SQL里没有直接写除法的语法,所以这类需求通常用NOT EXISTS双层嵌套、或者COUNT(DISTINCT ...)=总数来模拟。而优化器并不会自动把SQL翻译成除法运算,因此这类查询往往比较重。比如“找出所有部门都做过的项目”“找出购买过所有商品分类的用户”,用关系代数的除法来倒推SQL写法会更清晰:

SELECT 学号 FROM 选课表 sc WHERE NOT EXISTS ( SELECT 1 FROM 课程表 c WHERE NOT EXISTS ( SELECT 1 FROM 选课表 sc2 WHERE sc2.学号 = sc.学号 AND sc2.课程号 = c.课程号 ) ) GROUP BY 学号;

这段SQL其实就是两层NOT EXISTS实现除法。能用关系代数里的除法思维去理解它,就不会再觉得这个嵌套结构是“背下来的魔法”。

4. 从关系代数到SQL:一张对照表搞定翻译

4.1 常用运算符翻译对照

关系代数和SQL的对应关系,是学习过程中最有实用价值的桥梁。我把常用的对应关系整理如下,建议你存一份:

关系代数含义SQL对应
σ条件(R)按条件筛行WHERE 条件
π列(R)投影列(去重)SELECT DISTINCT 列
R ∪ S并(去重)UNION
R ∪ ALL S并(不去重)UNION ALL
R − S差EXCEPT(或MINUS)
R ∩ S交INTERSECT
R × S笛卡尔积CROSS JOIN
R ⋈ S自然连接NATURAL JOIN(慎用)
R ⋈条件S条件连接/等值连接INNER JOIN ... ON 条件
R ⟕ S左外连接LEFT JOIN
R ⟖ S右外连接RIGHT JOIN
R ⟗ S全外连接FULL OUTER JOIN
R ÷ S除法双层NOT EXISTS实现

这张表最大的价值在于帮你建立“语义等价”的概念。下次写SQL时,先想清楚自己用的是关系代数中的哪个运算,再去查SQL的语法,就不容易写出逻辑不对的查询。我自己带新人时,让他们默写这张表,效果好过让他们背一百道SQL例题。

4.2 分组聚合如何处理?关系代数的边界

严格来说,传统关系代数里没有“分组聚合”运算。它只处理集合级别的运算,不涉及“对每组分别计算”。SQL引入了GROUP BY和聚合函数(SUM、COUNT、AVG等),这是对关系代数的一种扩展。

为了处理这类需求,有些教材引入了分组运算符γ(gamma)。比如γ_班级, COUNT(*)(学生表)表示按班级分组并统计人数。这个扩展运算符的思路和SQL的GROUP BY完全一致:先把行分成若干组,然后对每组做聚合计算,结果每个组一行。

理解这一点对你写复杂报表很有帮助。你会经常遇到“先按A分组统计,再在结果上按B过滤”的需求,翻译成SQL就是HAVING子句。用关系代数的顺序来看:γ产生分组合计结果,然后σ在分组结果上筛条件。也就是说HAVING本质上是作用在分组之后的结果集上的选择操作。你可以把WHERE和HAVING的区别从关系代数层面理清:WHERE是分组前筛行,HAVING是分组后筛组。

很多慢查询的根源就是没搞清这个顺序——把本应放在HAVING里的条件错误地写进了WHERE,导致优化器无法利用索引,只能先全表扫描再分组。反过来,也有把WHERE能解决的过滤条件放进HAVING的情况,白白增加分组计算的开销。关系代数的表达式顺序能帮你快速检查SQL的逻辑正确性。

4.3 表达式的等价改写,是优化器的灵魂

关系代数表达式的一个重要性质是:同一个查询需求,可以写出多个等价的关系代数表达式。比如π_姓名(σ_成绩>90(选课⋈学生))和π_姓名((σ_成绩>90(选课))⋈学生),前一个先连接再筛选,后一个先筛选再连接。从结果看它们是等价的,但执行效率可能天差地远。

基于这种等价性,查询优化器的核心工作之一就是“把高代价的表达式改写成低代价的表达式”,常用手段包括:

  • 选择下推:把选择运算尽量移向表达式树的叶子节点,让数据量提前变小。
  • 投影下推:把投影运算尽量提前,减少后续运算需要处理的列数。
  • 连接顺序重排:在有多个表连接时,调整连接顺序,让小表先连,减少中间结果。
  • 笛卡尔积消除:把某些笛卡尔积和选择合并为连接,避免物化巨大中间结果。

如果你看过MySQL或者PostgreSQL的执行计划,你会发现优化器确实会做这些动作。但优化器不是万能的,它受限于统计信息、索引、成本模型。某些等价改写它做不了,比如复杂的子查询嵌套,或者它认为代价差不多就选了次优方案。这时人工干预就很重要。而人工干预的前提,就是你能用关系代数的等价改写来思考“更优的写法是什么”。

举个例子,我曾经优化过这样一个查询:用户表、订单表、订单明细表三表连接,但只取用户ID和订单总金额。原始SQL先JOIN出全量明细再GROUP BY用户ID,导致中间结果非常大。用关系代数思考,第一步该做的是“投影下推”:先在各表投影出需要的列,再连接。第二步是“聚合下推”:可以先在订单明细层级按订单ID分组聚合金额,再和订单表、用户表连接。改写后,查询时间从几十秒降到几百毫秒。这不是什么神奇技巧,就是关系代数表达式的基本应用。

5. 关系代数在真实系统中的应用场景

5.1 数据库执行计划:优化器在背后做什么

你在数据库里执行一条SQL,数据库并不会直接按你写的SQL一字不差地执行。优化器会先把SQL解析成逻辑计划,然后基于关系代数的等价规则做改写,再根据统计信息和索引情况生成物理计划——物理计划里才包含具体的连接算法、扫描路径、排序方式等。

这意味着,你写的SQL只是表达了一个“需求语义”,数据库最终执行的是优化器选择的一个“物理方案”。同一个需求,可能写出的SQL不同,但优化器有机会将它们改写成相同的执行方案;反之,同样的SQL在不同数据分布下也可能选择不同执行方案。

看执行计划时,怎么和关系代数对应起来?Seq Scan对应全表扫描,Index Scan对应利用索引读取记录,Filter对应选择运算,ProjectSet或结果列裁剪对应投影运算,Hash Join或Nested Loop Join对应连接运算,HashAggregate对应分组聚合。如果你能在一张执行计划里标出每个节点对应哪个关系代数运算,那你离真正的SQL调优高手就不远了。

5.2 数据库设计中的范式判断与数据校验

关系代数不仅能用在查询优化上,数据库建表时的范式判断也会用到它。比如第一范式强调属性原子性、第二范式强调非主属性完全依赖候选键、第三范式强调非主属性不传递依赖候选键。这些概念用关系代数的运算(特别是投影和连接)可以形式化验证:BOYCE-CODD范式(BCNF)的判定依赖函数依赖理论,而函数依赖中经常需要判断“闭包”,闭包计算其实也用到类似关系代数的集合操作思路。

实际项目中,我常用关系代数中的连接差异来校验数据质量。比如两张表用内连接和外连接结果行数不同,说明存在匹配不上的记录。把两表做全外连接,找出一边有值一边为NULL的空缺行,就能快速定位数据源的问题。这比写一堆临时SQL脚本要直观得多。

5.3 数据仓库与报表开发:用关系代数思维构建宽表

数据仓库里最常见的操作是“宽表构建”——从多张明细表、维度表通过ETL拼出一张可供报表直接查询的大宽表。这个过程中的每一步,本质都是在做关系运算。维度表与事实表的关联就是连接,字段裁剪就是投影,过滤脏数据就是选择。

我在做数仓时,经常和业务方确认报表指标。举一个例子:统计“华东区上个月的销售额”。这个指标涉及区域维度表、时间维度表、销售事实表。用关系代数表达:

π_总额(σ_区域='华东' ∧ 月份='2024-06'(销售事实表 ⋈ 区域表 ⋈ 时间表))

这个表达式看着简单,但改写成SQL时,你要决定先关联哪个表、过滤条件写在子查询还是JOIN ON里。从关系代数的角度,最优的顺序是先对区域表和时间表做选择,缩小维度表体积,再和事实表做连接。这跟你直接WHERE三个条件在最后过滤相比,中间结果集大小差距非常大。这也是很多报表在数据量小的时候一切正常、数据量上来就崩溃的根本原因。

6. 常见问题与排查技巧实录

6.1 连接结果莫名变多?检查笛卡尔积和连接条件

这是最高频的问题。我接到过不少排查请求,反馈是“两张表join,结果比预期多出很多行”。一问用的什么SQL,往往是FROM A, B这种老式写法,且WHERE里漏写了某张表的关联条件。用关系代数一画就知道:这变成了A × B,笛卡尔积的结果当然多。

排查技巧:第一步,先分别COUNT(*)两张表的行数。第二步,用EXPLAIN看执行计划是否出现Nested Loop但无法体现连接条件。第三步,检查两张表的关联字段是否有重复值。比如用户表和订单表通过用户ID关联,如果用户表中有重复的用户ID记录,订单就会翻倍。这类问题单靠SQL很难一眼看出,但一旦把关系代数的连接语义写在草稿上,原因立刻浮出水面。

6.2 选择运算写错了位置:WHERE 和 HAVING 的区别

很多人在一条SQL里有过滤条件又有分组聚合时,会纠结条件到底放WHERE还是HAVING。用关系代数顺序来看非常清楚:WHERE对应分组前的σ,对原始行做过滤;HAVING对应分组后的σ,对分组结果做过滤。如果你在WHERE里引用聚合函数别名,数据库会直接报错或者行为不可预料,因为分组还没发生。如果你在HAVING里放关联表的过滤条件,优化器往往做不到下推,性能大打折扣。

我给团队定的规矩是:凡是能用WHERE过滤的,绝不放HAVING。比如查“华东区域的订单总额”,区域条件应该放在WHERE或JOIN的ON里,在分组聚合前把数据量降到最小。而“订单总额超过10万的区域”这个条件只能放HAVING,因为它依赖聚合结果。

6.3 自然连接批量同名列带来的陷阱

前面提到自然连接会自动匹配所有同名列。实际开发中,很多表都会带created_at、updated_at这类通用字段。两张表一旦都有created_at,自然连接就会把created_at也作为连接条件,结果往往是空集或者意外丢失数据。我见过不止一个团队在初期图省事用NATURAL JOIN,最后数据对不上又花大量时间排查。在此提醒一句:生产环境SQL尽量显式书写连接条件,依赖隐含条件是给自己埋坑。

如果你要检查现有SQL里有没有这类隐患,可以把执行计划中Join节点涉及的条件全部列出,看看是否出现了你没预期到的列。这一步很繁琐,但关系代数的“自然连接=同名属性等值+去重”定义能帮你快速定位哪些列参与了连接。

6.4 除法需求忘掉NOT EXISTS细节

“找出选了所有课程的学生”这类需求,新手最容易写错的部分是内层关联。正确写法是:外层遍历每个学生,内层检查“是否存在一门课程这个学生没选”。翻译成代码时,很多人的内层条件写错了关联键,导致结果全空或全有。

我提供一个自查思路:先用关系代数写出除法表达式SC ÷ C,再去翻译成SQL。SQL里模拟除法的标准结构是双层NOT EXISTS:外层NOT EXISTS对应“不存在这样一门课”,内层NOT EXISTS对应“这个学生没选这门课”。每写一层就问自己:这一层在关系代数里对应的是哪个运算?答案对了,SQL一般就对了。

6.5 集合运算的性能优化:UNION 还是 OR?

UNION去重开销大,理论上如果你的两张子查询结果在语义上不可能重复,用UNION ALL代替UNION可以显著提速。但前提是你必须保证“不可能重复”。用关系代数来理解:R ∪ S和R ∪ ALL S的区别就是是否去掉了重复。如果R和S的交集为空,两个结果相同;如果不是,UNION ALL会多出行,业务上可能出错。

还有一种常见写法是“用OR等价替代UNION”,比如WHERE city='北京' OR city='上海'等价于“北京的记录并上上海的记录”。优化器有时会把OR改写成UNION,有时不会。在数据分布不均的情况下,OR可能导致索引选择失败。这种细节很难三言两语说清,但从关系代数的层面思考“我的条件是并集还是交集”,能帮助你做出更合理的判断。

7. 实操训练:用关系代数重写一条真实业务SQL

7.1 需求说明与初始SQL

这里我用一个真实业务场景:假设有学生表students(学号sno、姓名sname、班级class)、课程表courses(课程号cno、课程名cname)、选课表sc(学号sno、课程号cno、成绩grade)。需求是:找出“在1班且至少选修了‘数据库’和‘操作系统’两门课程”的学生名单。

通常新手会写这样的SQL:

SELECT DISTINCT s.sno, s.sname FROM students s JOIN sc ON s.sno = sc.sno JOIN courses c ON sc.cno = c.cno WHERE s.class = '1班' AND c.cname IN ('数据库', '操作系统');

这条SQL看着没毛病,但仔细分析:如果同一个学生同时选了这两门课,他会在连接结果中出现两行,虽然SELECT DISTINCT最终保证了结果正确,但中间过程多算了行;更重要的是,如果学生只选了其中一门,c.cname IN (...)也能把他筛出来,并不满足“至少选修两门”的要求。实际上这条SQL的逻辑是错误的——它会把只选了“数据库”没选“操作系统”的学生也算进去。

用关系代数来写应该是:

(σ_class='1班'(students)) ⋈ (π_sno(σ_cname='数据库'(courses ⋈ sc)) ∩ π_sno(σ_cname='操作系统'(courses ⋈ sc)))

先分别找出选了数据库的学生学号集合和选了操作系统的学生学号集合,取交集,再和学生表连接。

7.2 逐步改写与执行效率对比

第一步,提取“选了数据库的学生”:

SELECT DISTINCT sc.sno FROM sc JOIN courses c ON sc.cno = c.cno WHERE c.cname = '数据库';

第二步,提取“选了操作系统的学生”:

SELECT DISTINCT sc.sno FROM sc JOIN courses c ON sc.cno = c.cno WHERE c.cname = '操作系统';

第三步,取交集:

SELECT sno FROM 第一步 INTERSECT SELECT sno FROM 第二步;

第四步,和学生表连接并过滤班级。

改写完之后,你会发现这个方案的中间集合非常小:每门课的学生学号集合,再取交。而原始方案中,JOIN后的全量连接结果可能成百上千行,中间还生成了重复笛卡尔效应。在数据量级大的场景下,改写后的SQL性能能快出好几倍。

我也承认,不是所有业务场景都需要这么极致的SQL改写,但如果你能养成“先把关系代数表达式写出来”的习惯,你会发现很多隐藏的语义错误能被提前发现。比如上面这个例子,就是典型的“IN 被误用为集合包含”的陷阱,而关系代数的交运算直接暴露了这一点。

7.3 从练习中建立关系代数直觉

用关系代数做题,刚开始会觉得很啰嗦。一个简单的需求要写这么长一串表达式,远不如SQL一行来得爽快。但就像学数学要先学会列算式,建立关系代数直觉的价值,不在于每次都完整写出表达式,而在于你心里始终知道每一步在做什么运算。

我的建议是:平时刷SQL题的时候,先在草稿纸上写关系代数表达式,再翻译成SQL执行,最后对比执行计划。坚持二十道题左右,你对SQL的理解会发生质变——你开始能预测某些写法的性能,开始能理解索引为什么有效或者失效,开始能一眼看出子查询停在哪里。

8. 关系代数的扩展:与关系数据库理论的其他联系

8.1 元组关系演算和域关系演算

关系代数之外,关系数据库的理论体系还有关系演算。关系代数是“过程式”的——它明确告诉你每一步怎么做;关系演算是“声明式”的——它只告诉你想要什么结果,不规定操作步骤。SQL实际是关系代数和关系演算的混合体:SELECT ... FROM ... WHERE ...的结构更接近元组关系演算的写法,而JOIN、UNION等又保留着关系代数的味道。

对实践者来说,知道关系演算有什么意义?它解释了为什么SQL是“描述想要什么结果”的。比如写SELECT时你不需要指定数据库怎么扫描表、怎么连接、怎么排序,这些交给优化器。而优化器做的工作,恰恰是把声明式的需求转换成一系列关系代数运算组成的执行计划。理解这个底层关系,你在遇到SQL性能问题时就不会只想着“加索引”,而会从表达式改写、连接顺序、过滤下推等更本质的层面去寻找方案。

8.2 与函数依赖、范式的关系

函数依赖理论用于判断表的设计是否合理,而关系代数中的投影和连接又可用来检验分解后的关系能否无损还原。所谓无损连接分解,就是指把一张表分解成多张表之后,通过自然连接能够还原出原表,不产生额外行或丢失行。这个概念直接用关系代数的连接运算就能判断。

实际建表时,我强烈建议你在设计阶段就自问一句:这张表拆分成多张之后,能否通过主键外键关系无损连接还原?如果可以,那就是一个合理的规范化设计;如果不行,说明分解有损,会产生脏数据或重复数据。这个检查方法非常简单,但很多开发者建表时压根没想过,直到后续数据对不上才痛苦排查。关系代数在这里就是一把尺子。

8.3 在SQL标准与数据库演化中的位置

SQL标准这些年在持续演进,加入了窗口函数、LATERAL JOIN、递归CTE等更强大的功能。但无论语法怎么花样翻新,关系代数作为SQL语义基础的地位始终没变。窗口函数和GROUP BY一样都属于关系代数扩展的范畴,LATERAL JOIN是“相关子查询”的物化形式,递归CTE是“传递闭包”类运算的实现。理解关系代数能让你在学习新SQL特性时“一通百通”,因为你看到的是它底层的语义模式,而不是一个孤立的新语法。

9. 我对学好关系代数的几点实在建议

第一,练手时优先用“纸笔+脑图”。不要一上来就打开数据库跑数据。拿一个小型数据集(比如上面的学生选课例子),手写关系代数表达式,再手推结果。这个“慢思考”过程才是建立直觉的关键。跑SQL验证放在最后一步,用来确认自己手推的结果对不对。

第二,刻意用关系代数检查每条SQL。至少坚持一个月,每次写完SQL,在注释里——或者心里——标出它用了哪些关系运算。比如LEFT JOIN标注“左外连接”,WHERE标注“选择”,列列表标注“投影”。坚持一段时间后,你会发现写复杂SQL时根本不敢乱来了,因为每个运算组合在一起后,逻辑是否成立、是否存在多余中间结果,你心里都有数。

第三,学会用执行计划反推。看到一条慢SQL,第一件事是看执行计划,把每个算子翻译成关系代数运算:扫描是源关系、过滤是选择、投影节点是投影、连接是连接、聚合是分组扩展。当你能够把执行计划“翻译”回去时,你就知道优化器替你做了哪些改写,哪些地方它没做好,然后你就能有的放矢地调整SQL或增加索引。很多人问“执行计划怎么看”,其实这一步才是真正的核心。

第四,不要轻视集合运算的语义。数据库里的重复行问题、连接放大问题、去重开销问题,根源往往就在“集合语义 vs 多重集合语义”的差异上。我刚才讲的那张关系代数与SQL对照表,建议打印出来放工位上。它不复杂,但很救命——很多线上事故的根因,无非就是UNION该用没用、DISTINCT用了重复导致大数据量排序、JOIN条件缺失产生笛卡尔积。这一类问题用关系代数的思维盾牌去挡,几乎挡掉80%。

第五,多给自己设计“除法训练”。除法是关系代数中最反直觉的运算,也是最容易联系到实际业务需求的运算。凡是遇到“找出满足全部条件的对象”这类需求,都可以先试着写成除法表达式,再翻译成两层NOT EXISTS。这个过程练熟了,你的SQL嵌套能力会有一个质的提升——因为你能理解嵌套里每一层在做什么,而不是靠背模板硬凑。我见过的优秀数据工程师,几乎人人都有这种“语义翻译”的能力。

最后分享一个我自己的体会。我最早学关系代数那会儿,觉得它又干又抽象,不如直接写SQL来得痛快。直到工作中连续几次被慢查询教做人,才意识到“会写SQL”和“懂SQL”完全是两码事。关系代数不一定让你写出更炫酷的语法,但会让你在每一行SQL面前都清楚自己在算什么、数据库会怎么算、瓶颈可能在哪里。这种底层的掌控感,是刷再多语法技巧都换不来的。如果你正在被复杂查询或SQL性能问题困扰,不要急着找更多“高级技巧”,先回去把这张关系代数的网织好,很多问题自然就解开了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询