关系代数:读懂SQL优化与数据库查询的底层逻辑

📅 发布时间:2026/10/2 14:38:47
关系代数:读懂SQL优化与数据库查询的底层逻辑
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里的子查询、CTECommon 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之前执行就是这个原理。再说一个容易踩坑的点多条件的先后顺序。从逻辑结果上看σ_a1 ∧ b2(R)和先σ_b2(σ_a1(R))结果是一样的因为选择条件满足交换律。但从执行效率看如果b2能过滤掉90%的数据而a1只能过滤10%那应该先执行σ_b2。我在调优一条三百多万行的查询时就是把两个条件换了个顺序查询时间从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 ALLUNION 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并去重UNIONR ∪ ALL S并不去重UNION ALLR − S差EXCEPT或MINUSR ∩ S交INTERSECTR × S笛卡尔积CROSS JOINR ⋈ S自然连接NATURAL JOIN慎用R ⋈条件S条件连接/等值连接INNER JOIN ... ON 条件R ⟕ S左外连接LEFT JOINR ⟖ S右外连接RIGHT JOINR ⟗ S全外连接FULL OUTER JOINR ÷ 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 还是 ORUNION去重开销大理论上如果你的两张子查询结果在语义上不可能重复用UNION ALL代替UNION可以显著提速。但前提是你必须保证“不可能重复”。用关系代数来理解R ∪ S和R ∪ ALL S的区别就是是否去掉了重复。如果R和S的交集为空两个结果相同如果不是UNION ALL会多出行业务上可能出错。还有一种常见写法是“用OR等价替代UNION”比如WHERE city北京 OR city上海等价于“北京的记录并上上海的记录”。优化器有时会把OR改写成UNION有时不会。在数据分布不均的情况下OR可能导致索引选择失败。这种细节很难三言两语说清但从关系代数的层面思考“我的条件是并集还是交集”能帮助你做出更合理的判断。7. 实操训练用关系代数重写一条真实业务SQL7.1 需求说明与初始SQL这里我用一个真实业务场景假设有学生表students学号sno、姓名sname、班级class、课程表courses课程号cno、课程名cname、选课表sc学号sno、课程号cno、成绩grade。需求是找出“在1班且至少选修了‘数据库’和‘操作系统’两门课程”的学生名单。通常新手会写这样的SQLSELECT 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的逻辑是错误的——它会把只选了“数据库”没选“操作系统”的学生也算进去。用关系代数来写应该是(σ_class1班(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性能问题困扰不要急着找更多“高级技巧”先回去把这张关系代数的网织好很多问题自然就解开了。