ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

关系代数、元组演算与域演算:数据库查询的三种底层逻辑

关系代数、元组演算与域演算:数据库查询的三种底层逻辑 很多人聊数据库能聊上半天索引、事务、分库分表可一提关系运算就露馅。前阵子帮朋友做校招面试复盘前面几个技术问题答得都不错问到“关系代数和SQL到底什么关系”他憋了半天只回了一句“好像差不多”。这种反应其实挺普遍关系运算在数据库原理课里往往是“背完就忘”的一章但它恰恰是关系型数据库最底层的理论地基——我们每天写的SQL、数据库里的查询优化器甚至不少BI工具的拖拽式查询底层逻辑都跑不脱这套规则。这篇文章打算把关系代数、元组关系演算、域关系演算这三套体系完整串一遍顺带把每条运算背后的设计逻辑和实际应用讲透。不管你是正在准备数据库面试的开发者还是被课程设计折磨的学生或者单纯想补一补数据库理论的老码农都能在这里面找到直接能用的东西。1. 关系运算到底在解决什么问题1.1 关系模型的“操作引擎”讲到关系运算先要明确“关系”是什么。这里的关系不是人和人之间的关系而是关系模型里的二维表。关系模型在1970年由E.F.Codd提出包含三个核心部分数据结构、关系操作、完整性约束。数据结构就是一张张二维表完整性约束是主键、外键这些规则而关系操作就是关系运算本身。可以把这个模型想象成一个大型仓库货架上的货按二维表摆放关系运算就是仓库里的一套分拣指令——找出符合条件的一批货选择、只要其中某几列投影、把两批货拼在一起连接、把不需要的剔除差。这套指令一旦定义好不管仓库里装的是学生数据、订单数据还是日志数据操作规则完全一致。这正是关系运算能成为通用理论的根本原因它不关心业务内容只关心表与表之间的变换逻辑。1.2 为什么会有三套“方言”同一个查询需求在教材里会出现三种表达方式很多人第一次学就懵了——不是学一套就行了吗为什么要搞三套其实这三套东西的定位完全不一样。关系代数是过程式的。它像一份操作清单先做什么、后做什么写得很明确。比如“先筛选出年龄大于20的学生再投影出姓名”这就是一条代数表达式。数据库的查询优化器、执行计划天然就是在“过程式”思路上构建的。元组关系演算是描述式的。它不关心怎么找只描述“我要的东西长什么样”。比如“我要所有满足‘是学生且年龄小于20’这一条件的元组”。它用谓词逻辑来刻画结果的集合写出来更像数学公式。域关系演算更彻底。它把关注点从“整行”降到“某个属性列取值”描述的是“每一列的值之间应该满足什么关系”。它的代表语言是QBE那种在表格里直接填查询条件的交互方式今天很多可视化查询工具里还能看到影子。三套体系表达力等价但各有侧重。把它们放在一起学不是为了增加负担而是让你理解同一件事可以从“操作步骤”“结果描述”“列值约束”三个角度去看。这三重视角恰好对应了数据库从理论到实现的几个层次。1.3 本文会用到的教学示例数据后续所有例子我统一用三张经典教学表学生表S、课程表C、选课表SC。SnoSnameSdeptSage1张明CS192王芳MA203李雷CS214韩梅CS20课程表C包含课程号、课程名、先修课编号、学分CnoCnameCpnoCcreditC1数据库空4C2数据结构C14C3操作系统C24选课表SC记录每个学生选了什么课、考了多少分SnoCnoGrade1C1881C2911C3792C1853C1624C1704C266注意一下数据特点学生1张明选了全部三门课学生2王芳只选C1学生3李雷只选C1学生4韩梅选C1和C2。这个分布不是随便设计的后面讲除法、全称量词、差集时这些差异正好能覆盖所有边界情况。2. 关系代数最接近SQL执行计划的那套语言2.1 集合运算并、差、交、广义笛卡尔积关系代数的运算分两大类。一类是集合运算包括并、差、交、广义笛卡尔积直接继承自数学里的集合论另一类是专门的关系运算选择、投影、连接、除法这才是关系模型独有的内容。先看集合运算。并∪、差-、交∩有个硬性前提参与运算的两个关系必须类型相容也就是属性个数相同对应属性的数据类型一致。比如“查询CS系和MA系所有学生的并集”写成σ_SdeptCS(S) ∪ σ_SdeptMA(S)如果两边的表结构不同操作根本没有意义。交运算其实可以由差运算推导出来R ∩ S R - (R - S)。很多教材仍然把它单列是因为它足够常用用推导写法反而绕。广义笛卡尔积R × S则是把两个关系的元组两两拼接结果属性数是两表列数之和元组数是两表行数之积。以SC × C为例4行乘以3行得到12行每一行都是“一个选课记录拼上一门课程”里面包含大量“学生某门课没选但记录里却出现了这门课”的无意义组合。笛卡尔积看起来没什么用但它是一切连接运算的基础。SQL里的JOIN优化器在执行时本质上就是从笛卡尔积出发再用连接条件过滤掉不匹配的行。不理解这一层看执行计划时就容易断片。2.2 选择与投影行筛选与列裁剪选择运算σ按行过滤符号写作σ_条件(关系)。它不会改变关系模式输出的列数和原来一样只是行数变少。比如“查询年龄大于20的CS系学生”σ_Sage20 ∧ SdeptCS(S)。条件里的比较运算可以用、≠、、、≥、≤多个条件之间用∧、∨、¬组合。投影运算π按列裁剪符号写作π_属性列表(关系)。比如“查询所有学生的姓名和系别”π_Sname,Sdept(S)。选择与投影有个关键区别投影会产生去重效果。因为关系是集合不允许重复元组所以投影后如果两行在保留列上完全相同结果里只会保留一行。实操中最容易踩坑的是选择和投影的顺序。先选择后投影通常更稳因为投影一旦提前裁掉后面条件要用的列条件就没办法计算了。比如“查询年龄大于20的学生姓名”写成π_Sname(σ_Sage20(S))没有任何问题但如果脑子一热先写π_Sname(S)再想用Sage20条件Sage已经被裁掉了。SQL里的优化器会自动做“选择下推”和“投影裁剪”但自己写代数表达式时顺序直接决定正确性。2.3 连接家族条件连接、等值连接与自然连接连接运算用来把两张表的信息按某种条件拼在一起。最一般的形式是θ连接R ⋈_AθB S其中θ是某个比较运算符意思是从R×S里挑出满足A θ B的元组。它完全可以用“笛卡尔积选择”表示σ_AθB(R×S)。等值连接是θ连接的特例条件是A B。但有个细节很坑等值连接的比较属性在结果里会出现两次。比如S ⋈_S.SnoSC.Sno SC结果里会有两列Sno值相同但分别来自S和SC。SQL里写inner join ... on S.Sno SC.Sno结果同样是两列都在唯一的区别是很多客户端工具给其中一列改了别名。自然连接是在等值连接基础上把公共属性合并成一列。S⋈SC的结果里Sno只出现一次列数是S的4列加SC的3列再减去1个公共列等于6列。自然连接还有一个暗含条件它比较的是同名属性。如果两张表里没有同名列自然连接就退化成了笛卡尔积。考试时这里特别容易被设陷阱。再说一个和连接紧密相关的概念悬浮元组。自然连接只保留匹配成功的行没匹配上的会被丢弃。比如要查“每门课程的选课人数没人选的课也得列出来”自然连接就会把没人选的那门课丢掉。于是就有了外连接左外连接保留左边表的悬浮元组右外连接反之全外连接两边都保留。外连接表达的是“补全”的意图关系代数里没把它列成基本运算但现实SQL里每天都在用。2.4 除法几乎所有教材都会讲但很多人没吃透的运算除法在教材里的地位很特殊平时用得少考试和面试却非常爱考。它解决的问题非常明确找出那些“跟某个集合里的每个元素都满足关系”的对象典型场景就是“查询选修了全部课程的学生”。先看一个缩小版例子查询“至少选修了C1和C2两门课的学生”。把SC投影到(Sno, Cno)作为被除数A把课程表中C1和C2这两行投影到(Cno)作为除数BA ÷ B 的结果就是满足条件的学号。数一下数据学生1有C1、C2、C3学生2只有C1学生3只有C1学生4有C1、C2。同时满足C1和C2的只有学生1和学生4所以结果是{1, 4}。那除法到底是怎样算出来的理解它分三步。第一步把A中所有出现过的被除属性值这里是Sno都找出来作为候选商候选商是{1, 2, 3, 4}。第二步对每个候选商检查它在除数集合里缺了哪些项。具体做法是候选商集合 × 除数集合得到所有可能的“学号-课程”组合然后从这个组合里减掉实际存在的选课记录A剩下的行就是每个学生缺的课。第三步从候选商里剔除掉那些有缺课记录的学号剩下的就是商。用代数表达式写就是π_Sno(A) - π_Sno(π_Sno(A) × B - A)。这个式子虽然长但揭示了一个重要事实除法不是独立的基本运算它可以用差、笛卡尔积、投影这些基本运算构造出来。这正是关系代数教材在谈“基本运算”时只提五种的深层原因。2.5 五种基本运算为什么够用把前面所有运算看一遍会发现一个关键结论交可以用差表示连接可以用笛卡尔积加选择、投影表示除法上面也推导了。关系代数里那一长串运算符号真正不可替代的只有五个σ、π、∪、-、×。这个结论不是巧合而是Codd提出关系模型时的核心设计用极简的五个算子就能表达所有关系查询需求。它的意义在于数据库产品只需要实现五个底层算子并在它们之上做等价变换就够了。学习时掌握五个基本运算就像掌握了化学里的元素周期表后面所有复杂操作都只是组合拳。3. 元组关系演算用“谓词公式”描述想要什么3.1 三个原子与四类连接词如果说关系代数像一份“做菜步骤单”元组关系演算就更像“你描述一道菜的特点让厨师自由发挥”。它由Codd在1972年提出核心思想是用一阶谓词逻辑来描述“什么样的元组是我想要的”。在元组关系演算里变量叫元组变量代表关系里的一整行。公式由三种原子公式组合而成R(t)元组t属于关系R。t[i] θ c元组t的第i个分量与常量c满足比较关系θ。t[i] θ u[j]元组t的第i个分量与元组u的第j个分量满足比较关系θ。原子公式之间用逻辑连接词∧、∨、¬组合前面还可以加量词∃存在和∀所有。最后把整个公式放进集合描述符里{t | P(t)}就表示“满足P(t)的所有元组”。比如“查询CS系且年龄小于20的学生”写成{t | S(t) ∧ t[3]CS ∧ t[4]20}。t[3]是系别属性t[4]是年龄属性这里的列序号需要按S表的属性顺序来数。3.2 用存在量词实现“连接”元组演算里没有“连接”这个操作符那两张表的信息怎么拼起来答案是借助存在量词。比如“查询选修了C1课程的学生的姓名”{t | ∃u (S(t) ∧ SC(u) ∧ u[1]t[1] ∧ u[2]C1)}读起来是我要找这样的元组tt是学生表里的一行同时存在另一个元组u在选课表里u的学号等于t的学号且u的课程号是C1。t最终保留的是S表的结构所以拿到的是满足条件的学生行再投影一下就能得到姓名。这个写法的精髓在于连接关系不是写“把两表拼在一起”这个动作而是通过量词声明“我需要另一张表里有一行与当前行关联”。这也是为什么说元组演算是描述式的它描述的是最终结果应该满足的约束而不是执行步骤。一个常见的初学者错误是试图写成S(t) ∧ SC(t)。这看起来挺合理但逻辑上要求“同一个元组t同时属于S表又属于SC表”。如果两表结构不同这个条件几乎永远为假。字符串的连接是拼接逻辑上的“关联”必须借助另一个元组变量。3.3 用全称量词表达“所有”再上一个难度。“查询选修了全部课程的学生姓名”。这个查询用关系代数要写成除法在元组演算里可以用全称量词直接表达但这也是最容易写错的地方。正确写法是{t | S(t) ∧ ∀u (C(u) → ∃v (SC(v) ∧ v[1]t[1] ∧ v[2]u[1]))}翻译成人话t是学生对任意元组u只要u是课程就存在一个选课记录vv的学号等于t的学号且v的课程号等于u的课程号。意思就是“每一门课这个学生都有一笔选课记录”。很多人一看到“所有”就条件反射写出∀u(C(u) ∧ ...)。这在逻辑上是错的因为它要求“论域里的一切对象都是课程并且满足后面条件”。当论域里除了课程之外还有学生、还有其他任何对象时这个公式恒为假结果永远是空集。全称量词后面通常接箭头→蕴含存在量词后面通常接∧合取这两个是最固定的模式考试前最好默写三遍。3.4 ALPHA语言的GET与量词元组关系演算并不只是纸上的数学符号它有过真正的程序设计语言实现也就是ALPHA。ALPHA是Codd在提出关系模型时设计的语言语法大致长这样GET W (Student.Sname): Student.SdeptCS意思是从Student关系里取出系别为CS的学生的Sname放到工作空间W。RANGE语句用来给关系定义元组变量GET负责把满足条件的元组取出来交给宿主语言。今天已经很难在实际系统里见到ALPHA了SQL把它替代得干干净净。但从历史脉络看SQL的声明式风格、SELECT语法、WHERE里的比较逻辑很多都是从ALPHA和元组关系演算这条线演化过来的。理解了元组演算再回头看SQL里EXISTS、NOT EXISTS、子查询这些内容你会觉得它们本质上就是在这个逻辑框架上包了一层业务外壳。4. 域关系演算从“列”的角度出发的另一种思路4.1 域变量的核心区别元组关系演算里变量代表一整行域关系演算换了个视角变量代表某一列的具体属性值所以叫域变量。表达式的基本形式是{x1, x2, ..., xk | P(x1, x2, ..., xk)}意思是我要的每一行结果由这k个域变量组成它们必须满足公式P。写域演算公式时关系谓词后面跟的是一个一个具体的值和变量像把一个二维表的单元格逐个点名。比如“查询CS系年龄小于20的学生的姓名”。沿用S表的属性顺序Sno, Sname, Sdept, Sage可以写成{ | ∃sno ∃sdept ∃age (S(sno, name, sdept, age) ∧ sdeptCS ∧ age20)}这里name是要输出的域变量sno、sdept、age是受存在量词约束的辅助变量。它与元组演算最大的区别在于用S(...)时括号里不是整个元组t而是把每一列的值拆开放进去列与列之间的关系完全靠变量的名字和位置来维持。初学者很容易在这种公式里把属性顺序搞反尤其当关系列数较多时。我的习惯是先把“关系名对应的属性顺序”固定下来再写公式必要时把S表展开成“第一个参数是Sno、第二个是Sname”再对照填变量。先定结构、再填变量能少踩很多坑。4.2 QBE那个把查询做成表格的图形化语言域关系演算最有名的实践是QBE全称Query By Example由IBM实验室的Moshé Zloof提出。它的交互方式在当时非常超前用户直接在屏幕上把查询条件填进一张表的对应列里由系统把表格翻译成域演算公式再执行。拿上面那个例子来说在QBE里你会调出学生表然后在Sdept列下面输入CS在Sage列下面输入20在Sname列下面输入P.。P.代表“打印这一列”也就是结果要输出Sname。整个查询在视觉上就是一张只填了几个格子的表SnoSnameSdeptSageP.CS20后台执行的逻辑正是域关系演算。QBE后来没有成为主流但它的思想深深影响了Access的查询设计视图、很多BI工具的筛选面板。今天你用可视化工具在列名下面填一个筛选值本质上还是在做域演算。4.3 域演算的局限与学习价值从符号表达力上看域关系演算和元组关系演算是等价的任何元组演算表达式都能改写成域演算表达式反过来也行。但实际使用中域演算写起来更啰嗦因为要处理大量细粒度的变量绑定再加上主要实现载体QBE已经不是主流所以它在数据库原理课里经常被一笔带过。那为什么还要学它我的体会是它能帮你建立“列优先”的思维。写SQL时大家习惯先写SELECT列再写WHERE行再写FROM表——这其实已经在做列和行的交叉思考了。域演算把这种交叉提炼成了一种严格的数学形式。一旦习惯了从“列值之间的约束”来理解查询再看复杂报表需求时会更快判断过滤条件该挂在哪一层。另外面试中能讲出“QBE是域关系演算的图形化实现”而不是简单说“那个老古董”会明显跟其他候选人拉开差距。这类细节知识不常用但偶尔会成为面试官记住你的理由。5. 三套体系等价吗理论、安全性与实际影响5.1 等价性Codd完备性现在到了最关键的问题这三套体系表达力到底是不是一样的答案是一致的在安全表达式的前提下关系代数、元组关系演算、域关系演算三者是等价的。这个结论通常被称为关系运算的完备性Codd在1972年的论文里给出了形式化证明。理解这个结论有个直观方法关系代数五种基本操作能表达任何查询而每一种基本操作在规则演算里都能找到对应的表示反过来任何规则演算表达式也能翻译成一棵代数操作树。既然代数能表达演算、演算也能表达代数二者自然等价。打个比方从城市A到城市B关系代数是“你亲自开车左转右转按导航走”元组演算是“你告诉司机目的地地址司机自己规划路线”域演算是“你只报出几个关键地标让他自己去找”。出发点不同、表述不同但最终到达的地方必须一样。这个等价性对数据库实现有决定性影响SQL是声明式的数据库却可以把它翻译成过程式的代数计划来执行——正因二者等价这种翻译才是可行的。5.2 安全表达式无限关系怎么避免前面反复强调“安全表达式”这个前提它到底是什么先看问题如果规则演算不加限制有些表达式会得出无限关系。比如{t | ¬S(t)}意思是“找出所有不属于S表的元组”。如果属性值域是整数这类无限集合这种元组无穷无尽数据库根本没法存储、没法返回。安全表达式的定义通俗说就是结果中的每个分量值都必须来自表达式里某个关系中出现过的值。这个限制保证了结果一定来自“已知值域”不会是无限集合。SQL不必担心这个问题因为它的语法结构天然安全——FROM子句已经把能引用的范围圈死了WHERE再怎么写也不可能得到无限结果。但理论层面不一样如果不定义安全性公式会失去可计算性保证等价性证明也无从谈起。研究生入学考试经常拿这个点出题所以我特意拎出来讲。5.3 查询优化器到底干了什么作为一个常跟数据库底层打交道的人我可以负责任地说关系代数不是学完就扔的教科书符号它在真实数据库里无处不在。一条SQL从发出去到出结果大致经历四步解析、逻辑优化、物理优化、执行。解析阶段把SQL文本变成语法树逻辑优化阶段最重要的工作之一就是把语法树转换成逻辑查询计划——这个计划正是一棵由σ、π、⋈、∪等代数算子组成的表达式树物理优化阶段再决定每个算子具体怎么实现比如连接用Nested Loop Join还是Hash Join要不要走索引。换句话说你看一份执行计划时看到的其实是优化器经过等价变换后选出来的一棵关系代数树。比如“先过滤再连接”和“先连接再过滤”在逻辑上结果一样但代价天差地别优化器会把选择尽量下推到靠近表的位置——这就是教科书里“等价变换规则”在工业系统里的直接应用。能看懂执行计划里的连接顺序、过滤下推就相当于在用关系代数的语言跟数据库对话。5.4 面试、考试和课程设计里怎么用理论学完终究要落到应用场景。我按三种典型场景分别说一下。面试场景关系运算相关的高频问题基本集中在关系代数和SQL的关系自然连接和等值连接的区别怎样用关系代数表达“选修了全部课程的学生”“没选任何课程的学生”除法的计算步骤。回答时有个通用模板先讲定义再给一张具体表的例子最后落到“这在SQL里对应什么写法”。能讲出执行计划里对应哪个算子是明显的加分项。考试场景做关系运算题可以遵循一个三步法确定这个问题涉及哪几张表确定行级过滤条件确定列级投影和必要的连接、除法。顺序上严格遵守“先选择、后投影、最后再看是否需要连接或差集”能有效减少漏条件、写重条件的问题。碰到“全部/所有”这类字眼优先想到除法或全称量词。课程设计场景如果想真正吃透这些运算我建议自己写一个简化版的关系运算引擎。用Python写一个小demo核心就是把每张表想成“字典组成的列表”选择、投影、连接各用一两个函数实现。示例代码如下def select(relation, predicate): return [row for row in relation if predicate(row)] def project(relation, cols): seen set() result [] for row in relation: key tuple(row[c] for c in cols) if key not in seen: seen.add(key) result.append({c: row[c] for c in cols}) return result def natural_join(left, right): common set(left[0].keys()) set(right[0].keys()) result [] for lrow in left: for rrow in right: if all(lrow[k] rrow[k] for k in common): merged {**lrow, **rrow} result.append(merged) return result表加载成列表之后用这三个函数就能组合出很多查询。想验证除法就按2.4里的三步构造法用基本函数组合一遍跑出来跟手算结果对比。这个练习做完比单纯背定义管用得多。6. 常见问题与避坑指南6.1 自然连接的结果列数怎么数这是考试里失分率很高的问题。自然连接会合并同名属性所以结果列数等于R列数加S列数再减同名属性数。前面例子中S有4列、SC有3列、同名Sno只有一个S⋈SC结果就是6列。但等值连接不同它不合并重复属性。如果写S ⋈_S.SnoSC.Sno SC结果会有7列两个Sno同时存在。SQL里inner join ... on S.Sno SC.Sno也正是这个效果。想得到“自然连接”的去重效果可以理解为等值连接之后再做一次投影只保留其中一个公共列或者直接用using(Sno)让MySQL自动合并。另外要小心同名属性不一定是主键。两张表里可能有多个同名列自然连接会要求所有同名列都相等才匹配。如果两张表有很多同名但含义不同的列比如都有“备注”字段自然连接会得到一个比预期小得多的结果。这种坑在真实数据模型里尤其隐蔽建模时给列名加前缀是常见规避手段。6.2 除法、全称量词与“不存在”型查询“查询没选任何课程的学生”和“查询选修了全部课程的学生”是两回事但经常被搞混。前者用差集π_Sno(S) - π_Sno(SC)表示“学生表里有、但选课表里没有的学号”后者才用除法或全称量词。还有一个容易被忽视的边界除数为空集时商等于被除数里的所有候选值。因为“满足空集合中的每个条件”这个逻辑命题是平凡成立的这个结论在数学上正确但实际业务里基本遇不到。考试如果出判断题这个点很容易挖坑。再补充一个“不存在”型查询的常见套路。比如“查询没有选修C1课程的学生”最稳妥的写法是差集π_Sno(S) - π_Sno(σ_CnoC1(SC))——先算出选过C1的人再整体减掉。而不是直接找“选课表里没有C1记录的人”后者会把只选了其他课、确实有选课记录的学生也算进去逻辑上完全错误。这类题目做多了会发现一个规律几乎所有“不存在”都能转化成“全集减去满足条件的集合”这恰恰是差运算在理论上的威力。6.3 学了就忘怎么办三个实操建议关系运算这章有个特点上课你以为听懂了过两周再看全是陌生符号。这不奇怪它本质是一套符号系统不经常使用一定会生疏。根据我的个人经验三个办法最管用。第一把同一道题用三种语言各写一遍。随便找一本数据库习题集挑十道经典查询分别用关系代数、元组关系演算、SQL写出来。对照着看会发现它们只是同一逻辑的三副面孔记忆会深很多。第二打开数据库的执行计划看算子。在MySQL里explain一条带join和where的SQL把执行计划里的连接顺序、过滤顺序翻译回σ、π、⋈符号。做几次之后抽象符号就有了真实手感面试时被问到“执行计划”也能多聊几句。第三自己搭一个小型实验环境把除法和自然连接的实际结果跑一遍亲手验证一次比背十遍公式都有效。真到哪天你能看着一条SQL心里大致猜出优化器会先连接哪两张表、在哪里做过滤这一章就算真正吃透了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进