资讯动态

关系代数:从慢查询到执行计划的SQL优化底层逻辑

发布时间:2026/10/18 16:59:11 来源:尧图企业网站定制
我大概是在接手一个慢查询排查任务的时候才真正意识到关系代数这四个字没那么书生气。当时线上有个报表SQL跑了十几秒我按惯性把子查询拆了又拆索引建了又建结果执行计划纹丝不动。后来一位常年跟执行计划打交道的同事丢了句话你先别改SQL把这条查询翻译成关系代数表达式看看。我老老实实画了半天才发现自己写SQL的顺序和数据库真正执行的顺序完全是两码事。关系代数说白了就是关系数据库的运算底层。它定义了一组针对关系也就是数据表的运算规则——选择、投影、连接、集合运算这些告诉你一张表或几张表经过哪些步骤才能变成你最终想要的结果。SQL只是这层理论的人话外壳数据库收到SQL之后第一件事就是把它解析成内部的关系代数表达式树再由优化器决定以什么顺序执行这些代数运算。所以你写的FROM顺序、WHERE位置都只是给优化器的候选建议最终怎么跑是代数说了算。这篇文章不打算写成考试复习提纲而是站在实际使用数据库的角度把关系代数的每个运算讲透它是什么、对应SQL的哪个部分、在什么场景下有意义、为什么优化器会做出那些让你看不懂的变换。适合刚接触数据库原理的初学者也适合写了很多年SQL但对执行计划依然一知半解的开发者。读完你至少能看懂执行计划里一半以上的操作符而且写SQL时会下意识地考虑哪些条件能先过滤掉。1. 关系代数到底是什么从一次慢查询排查说起1.1 那次让我翻车的报表SQL我记得很清楚那是一个四表关联的报表查询逻辑不复杂从订单、客户、商品、分类四张表里取某分类下近三十天的成交记录。数据量不算夸张单表也就几万行可这条SQL跑一次要十几秒。我把子查询改成JOIN、把WHERE条件里外腾挪、加了复合索引执行计划依旧雷打不动。最后同事建议我把这查询翻译成关系代数表达式我照做了写完才发现自己一直忽略了一件事——我写的SQL把过滤条件放在了多表连接之后而关系代数允许你先做选择、再做连接中间结果的大小完全是两个量级。后来我调整写法把能提前过滤的部分用子查询显式拆出来查询时间降到了两秒以内。这个故事想说明的是关系代数并不是只在教材里存在的理论它其实就是数据库执行查询的底层思路。你写SQL时数据库会先把它变成一棵关系代数表达式树再基于等价变换规则重新排列这棵树最后才决定用哪些物理操作去执行。如果你看不懂这层逻辑遇到执行计划异常就只能靠瞎试。1.2 关系与关系运算的基本认知先把概念对齐一下。关系代数的操作对象是关系你可以直接把它理解成一张表但要注意两个细节关系里的每一行称为一个元组每一列称为一个属性关系是一组元组的集合所以理论上不允许出现完全相同的两行。为什么强调这一点因为关系代数里所有运算都有一个共同特性叫封闭性——对关系做运算结果仍然是一个关系。这意味着运算可以不断嵌套、串联前一个运算的结果可以继续作为下一个运算的输入。SQL里那些子查询、嵌套表达式之所以能成立靠的正是这个性质。打个比方如果关系是数字那么关系代数就是加减乘除。小学学算术时我们只要记住四则运算规则就能把任意复杂的算式拆成简单步骤的组合。数据库也一样不管你的SQL写得绕了多少层最终都会被拆解成几个基本的关系运算按顺序执行。理解这一点再看那些动不动几十行的SQL心态会稳很多。1.3 关系代数到底解决什么问题关系代数解决的是查询逻辑的表达与化简问题。经典关系代数一共有八种基本运算选择、投影、并、差、笛卡尔积、更名、交、连接后来又加入了更实用的扩展运算比如分组聚合。理论上任何一个能用SQL写出来的查询都能翻译成一个由这些运算组成的关系代数表达式反过来任何一个关系代数表达式也都能找到对应的SQL写法。对开发者而言掌握这套东西最大的价值有三个。第一看懂执行计划。执行计划里的每个操作符几乎都能对应到某一种关系代数运算认识它们你才明白数据库在做什么。第二写出更容易被优化的SQL。当你清楚哪些代数变换能缩小中间结果就会自然地在写SQL时优先安排过滤和投影。第三排查疑难查询。很多看起来玄学的性能问题用代数表达式一拆问题就摆在明面上了。接下来我逐个拆解这些运算大部分内容都可以直接对应到日常SQL开发。2. 常用运算逐个拆解符号、语义与SQL对照这一节我把最常用的运算过一遍顺序不是教材顺序而是按实际写SQL时遇到它们的频率来排。每种运算我给三样东西数学记号、它到底做了什么动作、对应的SQL长什么样。为了下文统一先约定一个小例子Student(Sno, Sname, Sdept)表示学生表SC(Sno, Cno, Grade)表示选课表Course(Cno, Cname)表示课程表。2.1 选择σ与投影π最常用的过滤组合选择σ负责按条件挑行。σ_{Sdept计算机系}(Student) 读作对学生表做条件选择结果保留所有满足条件的元组列数不变。它对应SQL里的WHERE子句。条件可以由简单比较组成也可以用 ∧且、∨或、¬非组合相当于SQL里的AND、OR、NOT。投影π负责按属性挑列。π_{Sname, Sdept}(Student) 表示只保留姓名和系别两列对应SQL里的 SELECT Sname, Sdept。这里有个关键区别关系代数里的投影会自动去掉重复行因为结果必须是集合而SQL里的SELECT默认保留重复行只有写了DISTINCT才会去重。这一点初学者极易搞混后面我会专门再说。两者组合起来就是最常见的查询形态。比如列出计算机系学生的姓名先选行再选列π_{Sname}(σ_{Sdept计算机系}(Student))。请记住这个顺序感先缩行再缩列数据量越小后面的运算越轻松。这就是数据库优化里的基本盘很多调优思路都从这里长出来。2.2 并、交、差两张表之间的集合运算并∪对应SQL的UNION取两张表的全部元组并去重交∩对应INTERSECT取两边共有部分差-对应EXCEPT某些数据库写作MINUS取属于左边但不在右边的部分。它们有一个共同前提叫并相容性参与运算的两张表必须属性个数相同且对应位置上属性的取值域兼容。一句话两张表的形状要对得上否则并运算没有意义。举个例子新生名单A和学生名单B要找出在新生名单里但还没完成正式注册也就是不在学生名单的人就是 A - B。这三个运算在业务里常被用来做对账、同步校验、黑白名单比对这类取差异和找交集的工作。实际使用时要小心NULL值SQL里UNION去重时通常把两个NULL视为相等的而纯关系代数模型中一般假设没有NULL这两者之间存在细微的语义差异。数据质量不佳时结果很可能和直觉不一致。2.3 笛卡尔积×一切连接运算的地基笛卡尔积×做的事很简单把第一张表的每一行和第二张表的每一行都拼一遍。R有m行S有n行R×S就有m×n行属性个数是两者之和。如果你不加条件直接做笛卡尔积数据量会爆炸式增长这正是忘记写连接条件导致慢查询的根本原因。但在关系代数里笛卡尔积的地位极高因为连接运算的本质就是先做笛卡尔积再用选择条件过滤。SQL里的CROSS JOIN直接对应它而INNER JOIN写作代数表达式其实就是 σ_{连接条件}(R × S)。理解这层关系你就明白为什么连接条件要尽量精确、为什么连接顺序会影响性能——虽然数据库做了大量优化但笛卡尔积这个最大中间结果始终存在于逻辑层优化器的一切手段本质上都是在尽量别让它真的算出来。2.4 连接⋈等值连接、自然连接和θ连接连接⋈是个家族。最一般的形式叫θ连接允许用任意比较运算符、、等作为连接条件。当连接条件是等值关系时就是等值连接比如 σ_{Student.SnoSC.Sno}(Student × SC)把学生表和选课表按学号对上。自然连接是等值连接的特例它要求等值比较的是两张表中所有同名的属性并且结果里同名属性只保留一份。SQL里的NATURAL JOIN直接对应自然连接但生产环境我很少推荐用它因为它会自动匹配所有同名列一旦两张表里恰好还有别的同名列比如都存了更新时间结果就会和预期产生偏差。实际开发里用得最多的是显式等值连接也就是JOIN ... ON 连接条件。书写时建议把两件事写清楚先说两张表通过哪个键关联再放业务过滤条件。这既方便别人阅读也方便优化器识别。纯关系代数里连接是先积后选但现代数据库绝不会真的先算笛卡尔积而是用各种连接算法直接得到等值结果后面讲执行计划时会展开。2.5 除运算÷表达全部语义的狠角色除运算÷是八个基本运算里存在感最低、但表达能力最强的一个。R ÷ S的语义是找出R中那些与S中的每一行都关联过的行。经典例子是学生选课选课关系SC学号、课程号除以课程关系Course课程号得到的就是选修了全部课程的学生学号。写成表达式就是 π_{Sno,Cno}(SC) ÷ π_{Cno}(Course)。你会发现这个运算背后藏着一层对所有的X全称逻辑。SQL里表达全称语义通常要靠双重NOT EXISTS——不存在一门课他没选——绕来绕去很容易写错。而除运算一句话就说清楚了。所以当你遇到全部所有每一个这类需求时先想想除运算虽然最终写SQL大概率还是NOT EXISTS但思路不会跑偏。2.6 重命名ρ一个被严重低估的辅助运算重命名ρ的作用是给关系或属性换名字比如 ρ_{S1}(Student) 表示把Student这个关系改名为S1。它本身不改变数据却是很多复杂查询的前提。最典型的场景是自连接要把学生表和它自己连接、找出同系的同学两张同一个表的副本必须有不同的名字否则连属性都无法区分。另一个场景是笛卡尔积里同名列的消歧以及给长表达式起简短别名方便后续书写。SQL里对应的就是表别名和列别名。很多人觉得别名只是为了把SQL写短一点但从代数角度看别名的真正价值是让同一个关系可以在表达式里出现多次而不产生歧义。没有重命名自连接、多副本比较这一类查询根本没法表达。真到写复杂分析SQL的时候你会发现这步操作比想象中重要得多。3. 一条SQL的完整代数之旅从文本到执行计划3.1 动手翻译把SQL逐步转成代数表达式做一个练习。假设有这条SQLSELECT Sname FROM Student, SC WHERE Student.Sno SC.Sno AND Sdept 计算机系 AND Cno 1 AND Grade 80;把它机械地翻译成关系代数按FROM、WHERE、SELECT的顺序一步步套上去会得到下面这个朴素表达式π_{Sname}(σ_{Student.SnoSC.Sno ∧ Sdept计算机系 ∧ Cno1 ∧ Grade80}(Student × SC))这是完全没有经过优化的形态。它的计算过程是先把Student和SC做笛卡尔积得到所有可能的行组合再按四个条件过滤最后取Sname列。假设Student有1000行、SC有5万行第一步就会产生5000万行中间结果。如果真按这个顺序执行这条SQL跑一分钟都不奇怪。但数据库不会这么傻。优化器会拿着这个表达式用等价变换规则反复改写它尝试降低每一步的中间结果大小最后才会选择一个代价估算最低的方案去执行。这里最关键的一步变换就是下面要说的选择下推。3.2 选择下推最经典的代数优化对上面那个表达式优化器会做一次关键变换叫选择下推。依据是一条代数等价规则如果条件c只涉及表R的属性那么 σ_c(R × S) 等价于 σ_c(R) × S。意思就是过滤可以先做再做笛卡尔积。算一下效果。假设计算机系的学生只有100人先做 σ_{Sdept计算机系}(Student)得到100行再和SC做笛卡尔积是100×50000500万行接下来依次下推 σ_{Cno1} 和 σ_{Grade80}中间结果会进一步缩小。和最初的5000万行相比差距是数量级的。这就是为什么有时候把WHERE条件写早点就快了——不是SQL语法上的先后起作用而是你给了优化器更容易识别出下推机会的结构。多数情况下优化器会自动做选择下推但有几种结构会拦住它外连接要保留NULL扩展行、窗口函数、某些视图封装、OR条件跨表等。遇到这些情况你可以主动用子查询先把过滤做掉相当于替优化器把代数树摆好。这招在我的日常调优里很常用。3.3 一组常用的等价变换规则我把工作里最常用的代数等价规则整理成一张表。背熟它们读执行计划和调优SQL就有了抓手。变换规则形式化写法实际意义选择合并σ_{c1}(σ_{c2}(R)) σ_{c1∧c2}(R)WHERE多个条件会被合并再求值选择下推σ_c(R × S) σ_c(R) × Sc只涉及R先过滤再连接减少中间结果选择与投影交换π_A(σ_c(R)) σ_c(π_A(R))c只涉及A中属性投影越早做越好连接结合律(R ⋈ S) ⋈ T R ⋈ (S ⋈ T)连接顺序可换优化器会挑代价低的投影对并分配π_A(R ∪ S) π_A(R) ∪ π_A(S)集合运算前可以先缩列这些规则看起来像数学题本质却非常务实每一条都在回答同一个问题——哪些运算能提前做让中间结果变小。中间结果小IO和内存占用就低查询自然快。4. 从关系代数看查询优化器执行计划为什么不听话4.1 优化器处理一条SQL的完整链路一条SQL发到数据库之后大致要经过这几个阶段词法和语法分析生成语法树把语法树翻译成逻辑执行计划也就是关系代数表达式树此时还没有选择任何物理算法然后进行代数级重写利用上一节那些等价规则把表达式调整成更优的形状再做基于代价的优化对每种可能的物理实现估算成本挑一个最小方案最后得到物理执行计划交给执行引擎。所以FROM后面写的表顺序数据库会尊重吗这个问题的答案是基本不会。优化器会按代价模型决定先访问哪张表、用哪种连接算法。你写的表顺序只是它的一个候选起点最终计划很可能完全打乱你的书写顺序。这不是数据库不听话而是它在做自己的本职工作——代数级的重排。4.2 代数重写与物理算子之间的对应执行计划里常见的操作符几乎都能在关系代数里找到影子。全表扫描是实现 σ 的一种物理方式索引扫描也是 σ只不过用了索引加速查找嵌套循环连接、哈希连接、归并连接都是实现 ⋈ 的不同算法聚合算子对应扩展代数里的分组聚合运算排序算子则对应排序严格说经典关系代数没有顺序概念排序属于扩展功能。看执行计划时我习惯把每个操作符先翻译回代数符号然后问自己三个问题这一步的输入有几行输出有几行中间结果是不是被意外放大了如果某一步的实际行数和估算行数差出两三个数量级那里通常就是性能问题的根源——优化器多半选错了代数形状或者统计信息不准。4.3 一个执行计划的读法示范假设你在某个数据库的EXPLAIN输出里看到这样两行Nested Loop - Seq Scan on Student估算输出1000行 - Index Scan on SC对每一行Student执行一次等值查找翻译成代数语言就是先读取Student然后对Student的每一行用索引去SC里找学号相等的记录并连接。这等价于把连接条件 Student.SnoSC.Sno 实现成了嵌套循环并没有真的构建笛卡尔积。它的执行代价大约等于Student行数 × 每次索引查找的成本。如果Student只有1000行这个方案就很好如果Student有100万行优化器就更可能选哈希连接先建哈希表再探测。这就是为什么同一条JOIN数据分布变化后执行计划会突然改变——优化器只是在按代价模型选择代数等价的物理计划而已。理解了这一层你就不会再用我明明加了索引怎么不走这种问题折磨自己而是会去看当前这个代数结构下索引到底能不能被用上。5. 实战演练三个业务问题用关系代数建模5.1 选修了全部课程的学生除运算登场还是用刚才的表结构Student(Sno, Sname)、Course(Cno, Cname)、SC(Sno, Cno, Grade)。要找选了所有课程的学生关键思路是如果某学生选了全部课程那么他的选课记录集合必须包含课程表里所有的课程号。用除运算一句话就能解决把选课关系投影成(学号, 课程号)除以课程关系投影成(课程号)得到满足条件的学号再和Student连接取姓名。表达式π_{Sname}(Student ⋈ (π_{Sno,Cno}(SC) ÷ π_{Cno}(Course)))把它翻译成SQL最直观的写法是双重NOT EXISTSSELECT Sname FROM Student WHERE NOT EXISTS ( SELECT 1 FROM Course WHERE NOT EXISTS ( SELECT 1 FROM SC WHERE SC.Sno Student.Sno AND SC.Cno Course.Cno ) );外层NOT EXISTS对应不存在一门课里层NOT EXISTS对应这门课他没选。两重否定加起来就是全部课程都选了。初学者很容易在这里写成COUNT(*)比较拼字符串、拼临时表不仅费劲还容易算错。用除运算的视角一眼就能看穿这类需求的本质。5.2 成绩超过课程平均分的学生扩展代数补上聚合经典关系代数没有聚合函数但现实查询绕不开平均值总数。所以实际使用的数据库体系里会引入扩展代数新增分组运算G写法大致是 G_{分组属性, 聚合函数}(关系)。比如按课程号分组求平均成绩G_{Cno, AVG(Grade)}(SC)。要找成绩高于本门课平均分的学生可以分三步建模。第一步对SC按课程分组计算平均分得到一个临时关系TmpAvg(Cno, AvgGrade)。第二步把SC和TmpAvg按Cno连接每一行选课记录旁边挂上这门课的平均分。第三步用选择条件 Grade AvgGrade 过滤再连接Student和Course取姓名、课程名和成绩。翻译成SQL就是常见的相关子查询写法SELECT Sname, Cname, Grade FROM SC JOIN Student USING (Sno) JOIN Course USING (Cno) WHERE Grade ( SELECT AVG(Grade) FROM SC AS Tmp WHERE Tmp.Cno SC.Cno );很多人写不出这类查询是因为脑子里没有先分组、再连接回去比较这个代数步骤。一旦你养成先画数据流转的习惯相关子查询就没那么神秘了。有些数据库的优化器会把这种相关子查询改写成分组连接的计划本质上就是前面的三步。如果担心性能也可以手动写成聚合子查询再JOIN回来这在代数上等价执行计划往往更可控。5.3 差运算处理没有重命名解决自连接再看两个小案例正好把差运算和重命名用上。第一个没选过课程2的学生。逻辑上是全体学生的学号减去选了课程2的学生学号一个简洁的差运算π_{Sno}(Student) - π_{Sno}(σ_{Cno2}(SC))翻译成SQL一般写作NOT IN或NOT EXISTS。但注意如果子查询结果里可能含NULLNOT IN会出现未知逻辑导致整体结果为空所以稳妥起见用NOT EXISTS。这正是代数表达式里的隐式假设无NULL和实际数据之间的差异点踩过一次就长记性了。第二个找出和某同学在同一个系的同学。假设这位同学叫张三。需要把Student和自己连接两个副本必须用重命名区分π_{S2.Sname}(σ_{S1.SdeptS2.Sdept ∧ S1.Sname张三 ∧ S2.Sname≠张三}(ρ_{S1}(Student) × ρ_{S2}(Student)))SQL写出来就是标准的自连接。如果你不理解重命名看着一张表JOIN自己很容易发懵一旦清楚每个别名对应代数里的一个关系实例自连接的语义就清清楚楚了。6. 写在最后学习关系代数的常见坑与我的体会6.1 六个最容易掉进去的坑用一张表把常见问题汇总一下顺便回答很多初学者反复问的点。容易混淆的点关系代数的说法SQL里的实际情况投影是否去重π一定去重结果是集合SELECT默认不去重要DISTINCT自然连接与等值连接自然连接会合并同名列显式JOIN ON更可控集合运算前提需要并相容列数、域兼容UNION/INTERSECT/EXCEPT同样要求笛卡尔积无条件的行两两组合忘写ON就是CROSS JOIN巨慢除运算表达全部一个÷解决需要NOT EXISTS双重否定NULL值处理经典代数假设无NULL实际SQL的NULL会带来意外结果这里最值得多说一句的是NULL。关系代数是对理想干净数据建模的它假设每一格都有确定值行与行的比较结果只有真和假。真实数据库有NULL比较结果多了一个未知于是NOT IN遇到子查询结果里含NULL时整个查询可能返回空。我踩过这个坑之后凡是涉及没有语义一律默认先写NOT EXISTS除非明确知道目标列上存在NOT NULL约束。6.2 学完之后我写SQL的思路转变坦白说我早些年学关系代数纯粹是为了应付考试觉得那堆希腊字母毫无用处。直到开始啃执行计划才后悔当初没认真对待。现在我做SQL优化的标准动作是先在纸上把查询翻译成关系代数表达式标出每一步的输入输出行数找到可能的中间结果爆炸点再针对它调整写法。这个习惯帮我减少了很多瞎试。给正在学的人一个建议不用急着背所有希腊字母先记住选择、投影、连接、集合运算这四个家族然后拿真实业务查询反复做翻译练习——把SQL翻译成代数再把代数翻译回SQL。练上二三十条你对数据库执行逻辑的理解会比很多人死记硬背执行计划参数强得多。最后再分享一个小技巧。遇到复杂查询先别管SQL语法用一句话把需求说清楚然后问自己三个问题这里面有全部吗有没有吗有最大/平均吗这三个问题分别对应除运算、差运算、分组聚合。把答案写下来之后SQL的骨架其实已经出来了。这个习惯陪伴了我很多年每次都能让我在写第一行SQL之前先想清楚数据库到底该怎么把数据算出来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑