这周的数据库作业正好做到第九章“关系查询处理和查询优化”,课后习题刷完之后,我对数据库的认知就像被人狠狠推了一把——前面几章讲表结构、讲SQL怎么写,到了这一章终于把镜头转向数据库内部:一条SQL发过来,它到底经历了什么?凭什么有的查询一眼出结果,有的查询能把数据库拖垮?我整理了这一章的完整学习笔记和作业复盘,包含查询处理的四个阶段、代数优化与物理优化的原理、以及几类典型课后题的逐步拆解。如果你正在学数据库原理,或者想搞明白执行计划、索引选择背后的逻辑,这篇内容应该能帮你省不少力气。
1. 章节核心内容拆解:从“select怎么执行”到“为什么这么执行”
1.1 这章在整门数据库课程里的坐标
数据库教材看到第九章,很多人的第一感觉是“画风变了”。前面几章的内容再难,本质都是在描述静态的东西:关系模型长什么样、SQL怎么表达需求、关系代数如何操作集合。但第九章突然开始讲动态过程,讲的是一条查询从输入到输出的完整生命周期。这个转折特别重要,因为后面要学的事务管理、故障恢复、并发控制,全都建立在一个前提之上:数据到底是怎么被读出来、算出来、写回去的。没有这一章打底,后面所有关于性能和安全的知识都是浮沙筑塔。
我自己的体会是,这章的学习强度不亚于前面任何一章。知识密度大,课后习题的题型也多,而且每一道题都在逼你“亲自动手”。动手画查询树、动手做等价变换、动手估算代价,这些操作只看书是学不会的。我花了整整两个晚上才把作业啃完,但啃完之后再看那些曾经觉得神秘的慢查询问题,思路一下子清晰了。这章就像一个分水岭,跨过去之后,你不再只是一个“写SQL的人”,而是开始变成“理解数据库的人”。
1.2 关系查询处理的四个阶段
查询处理这条流水线,我用四个阶段来记,分别是查询分析、查询检查、查询优化、查询执行。这四个阶段层层递进,每一阶段的输出都是下一阶段的输入。
查询分析阶段,数据库拿到SQL字符串后先做词法分析和语法分析。词法分析把SQL拆成一个一个单词和符号,语法分析则检查这些单词组合起来是否符合SQL规范,最终生成一棵语法树。这个阶段完全不碰数据,纯粹是“读懂这句话”。如果有同学做过程序语言编译原理的作业,会发现这里的思路和编译器的词法语法分析几乎同源。
查询检查阶段要干的事情更多。数据库要检查SQL里引用的表、列是否存在,属性类型是否匹配,还要做完整性检查,确认不会破坏已有的约束。与此同时,权限检查也在这里完成,毕竟不是谁都能查所有表的。如果SQL里用了视图,查询检查还要做视图消解,把视图定义展开成对基本表的查询。做完这些,数据库才敢说自己“理解了”这条查询的含义。
查询优化阶段是整章的核心。它又细分成代数优化和物理优化。代数优化在关系代数表达式的层面做等价变换,目标是让中间结果尽可能小;物理优化则在具体执行层面做决策,比如走全表扫描还是走索引、用哪种连接算法、先连接哪两张表,必要的时候还要用统计信息和代价公式估算每种方案的I/O成本和CPU成本。这一阶段产出的是一份执行计划。
查询执行阶段就是按照执行计划真正干活。执行器调用存储引擎的接口,一条条扫描索引记录、读取数据页、计算表达式、连接结果集,最后把结果返回给用户。这里还有解释执行和编译执行的区别,但课后题一般只要求理解到这个层面。我建议把这四个阶段当成一条流水线记,不要分开背,因为后面所有应用题,本质上都是让你还原这条流水线上某个中间产物。
1.3 为什么要做查询优化:一个数量级差异的例子
学这一章之前,如果有人跟我说“一条SQL没优化会慢一万倍”,我可能会觉得夸张。但是看完第一章的例题,我自己算过一笔账之后,彻底服了。
假设学生表有1万行,选课表有10万行,要查“选了某门课的学生姓名”。最笨的执行方法是先做两表笛卡尔积,得到10万乘以1万等于10亿行的中间结果,然后再按照连接条件和课程名过滤。10亿行是什么概念?就算每行只占80字节,也是接近800GB的临时数据,普通教学环境根本跑不动。但优化器如果把“课程名等于某值”这个选择提前,先到选课表里筛出符合条件的记录,假设只有500行,再和学生表做连接,中间结果立刻从10亿行缩到500行,后面每一步操作的代价都降了好几个数量级。
这个例子就是课后习题第一类题型的原型。后来我看到习题里那些“画出优化前后的查询树”的题目,心里想的其实都是这个例子:选择下推、连接顺序调整、中间结果变小,所有优化技巧最终都在为一个目标服务——别让数据库处理无关的数据。
2. 查询优化的两大手段:代数优化与物理优化
2.1 代数优化:几条必须掌握的等价变换规则
代数优化的理论依据是关系代数的等价变换规则。两个关系代数表达式如果对任意合法的数据库实例都产生完全相同的结果集合,那它们就被称为等价的。既然结果一样,优化器自然可以替换成计算量更小的那个。
这一章需要重点掌握的规则,我整理了一下:连接和笛卡尔积的交换律与结合律,它们的存在让连接顺序可以任意调整;选择的串接定律,意思是连续选择可以合并,复合选择条件也可以拆分;投影的串接定律,代表中间过程的投影越多、越早,数据量越小;选择对笛卡尔积的分配律,这是“选择下推”最直接的理论依据;投影对笛卡尔积的分配律,对应“投影下推”;还有选择对并、交、差的分配律,处理集合运算时会用到。
这些规则看着不难,但用的时候有一个核心原则:选择要尽量先做,投影要尽量早做。我自己的理解是,把关系想象成一杯浑浊的水,选择和投影就是过滤器,过滤器装在管道越靠前的位置,后面管道里的杂质就越少,最终处理起来越省力。
做题时必须注意,选择下推不是无条件成立的。如果选择条件里同时涉及两个不同关系的属性,比如一条等值连接条件“学生.学号 = 选课.学号”,那它就不能被下推到任何一侧的叶子节点。这种条件本质上描述了表与表之间的关联,只能在连接操作发生时执行。把“能下推的条件”和“连接条件”分开识别,是这类题目不丢分的关键。
2.2 物理优化:三种典型策略
代数优化把逻辑层面的表达式理顺之后,真正的挑战才刚开始。同一个连接操作,可以用嵌套循环连接、排序合并连接、索引连接,分别对应完全不同的磁盘I/O成本和内存开销。物理优化要做的,就是在这些候选方案里挑一个最合适的。
一般来说有三种策略。第一种是启发式规则,这是基于经验的快速判断,比如优先选择小表作为驱动表、连接列和过滤列上有索引时优先走索引、输入数据如果已经有序就优先考虑排序合并连接。第二种是基于代价估算,数据库在数据字典里保存统计信息,比如表的行数、列的不同值个数、数据分布直方图,然后用一个代价模型估算每个执行计划需要多少次I/O、多少CPU时间。第三种是两者结合,先用启发式规则找出几个看起来有希望的候选计划,再对这些候选做代价估算,最后选择代价最小的。这也是主流数据库优化器实际采用的做法。
在课本习题里,I/O代价被反复强调,因为磁盘操作比内存操作慢几个数量级。代价公式基本可以分解成I/O代价加CPU代价,而I/O代价的绝对大头是读取数据块的次数。如果一条过滤条件能通过索引把扫描范围从全表缩小到几十个块,I/O次数就能从几千降到几十,这个差距直接决定了查询是秒回还是等到超时。
2.3 一个完整流程:从SQL到优化后的查询树
学完两个优化手段,我习惯把完整流程走一遍来加深印象。比如有一条查询:从学生关系、选课关系、课程关系三个表里找出选了某门指定课程的学生姓名。
第一步,按照SQL的语法结构画出初始语法树。目标列放在最上面的投影节点,where条件整体放在一个选择节点,from里的三张表先做笛卡尔积。第二步,把语法树翻译成关系代数表达式,本质上就是“先笛卡尔积,再选择,最后投影”。第三步,利用等价变换规则优化。先拆解选择条件,把只依赖单个表的子条件一个个下推到对应的叶子节点;再下推投影,只保留最终输出需要的列和连接操作必需的列;最后调整连接顺序,让行数少的表先参与运算。第四步,画出优化后的查询树,和初始语法树对比一下,你会发现中间结果的数据量很可能缩小了一两个数量级。
这个流程是整个章节的通用模板。我从作业里总结出来的经验是:不管题目怎么出,只要按照“语法树→关系代数表达式→等价变换→优化查询树”这个链条走,思路就不会乱。
3. 课后习题实操:三种典型题目逐步拆解
3.1 题型一:从SQL到语法树到查询树
课后习题最常见的第一类题,是给一条SQL让你画出语法树,然后写出初始的关系代数表达式,最后画出优化后的查询树。看起来简单,实际上最考验对复合条件拆解的熟练度。
我拿作业里一道典型题来说,条件是“专业为某个指定专业且成绩大于某个分数”。初始阶段,我会先画一棵特别“机械”的树:select里的目标列放最上面的投影,where里的所有条件整体放进一个选择节点,from里出现的所有关系先做笛卡尔积。这一步不需要技巧,唯一的难点是别漏条件、别写错列。
优化阶段才是真正考验。我把复合选择条件拆开:专业条件只涉及学生表,直接下推到学生关系那一侧;成绩条件只涉及选课表,下推到选课关系那一侧。拆完之后,还要注意投影下推要保留哪些列。这里特别容易踩坑——如果只想着“最终输出只要姓名”,就把学号、课程号这些连接需要的列也砍掉了,到后面连接条件就没法计算了。我后来养成一个习惯:下推投影时,保留列 = 最终输出列 + 连接条件涉及列 + 后续选择条件涉及列。多留几列不会错,少留一列必然错。
3.2 题型二:关系代数等价变换运算
第二类题直接给你一个关系代数表达式,要求化简成优化后的形式。这种题有点像做代数恒等变形,但底层的依据是集合论,而不是普通的数学运算。
我记得有一道题给的是“对(学生连接选课)整体做‘专业等于某值且成绩大于多少’的选择,最后投影学号和姓名”。因为没有下推优化时,选择发生在连接之后,导致连接前两张表都保持全量数据。优化思路是把“专业等于某值”下推到学生表,把“成绩大于多少”下推到选课表,先各自瘦身,再做连接,最后投影。
不少同学在这里会犯一个我一开始也犯过的错误:想当然地把选择往叶子方向一路压下去,完全不管条件里涉及几个关系。实际上判断标准很清晰——只要条件引用的属性全部属于某一个子表达式包含的表,这个条件就能安全下推到那棵子树;一旦条件横跨两个表,它就必须在连接运算的上一层执行。把这句判断标准写在草稿纸上,这道类型的题基本就不会再踩坑了。
3.3 题型三:基于代价估算的物理优化选择
第三类题会进阶一些,题目一般给出两张表的行数、每块能放多少条记录、是否有序、有没有索引,再要求比较不同连接算法下的I/O代价。这种题的重点不是死背公式,而是理解两个关键点。
第一点是嵌套循环连接的行为特征。外层表每读一块,内层表可能要完整扫一遍;如果内层有索引,则可以通过索引快速定位匹配行,避免全表重复扫描。第二点是排序合并连接的成本特征。它的主要代价来自排序阶段,如果输入数据本来就在连接键上有序,开销会比嵌套循环小很多,但若是乱序数据,排序本身可能吃掉大部分预算。
我做过一道很有感觉的题:外层表约200个块,内层表约500个块,内层无索引。最坏情况下嵌套循环要做200乘500等于10万次块级I/O。如果对两表先排序再合并,算上排序开销,总I/O可能降到几千次。如果内层在连接键上有索引,每次匹配只访问少量索引块和数据块,总I/O可能只有几百次。这笔账算完之后,再回头看执行计划里优化器的选择,就完全能理解它为什么那么看重索引和连接顺序了。
4. 常见问题与排查技巧实录
4.1 学习这个章节的三个误区
这三个误区是我自己在刷题过程中踩过、也看周围同学反复踩的,值得单独拿出来说说。
第一个误区是把关系代数和SQL当成两个互不相干的知识点来学。其实它们是一体两面:SQL是用户看到的外衣,关系代数是数据库实际操作的骨架。我建议学习时每写一条SQL,都强迫自己把它翻译成关系代数表达式,再用优化规则化简一遍。同一条查询用两种语言各看一遍,理解会深很多。
第二个误区是背了一堆等价变换规则,却不理解为什么可以交换顺序。规则看着多,但只要记住“关系本质上就是元组的集合”这句话,大部分规则都可以自己推导。选择下推为什么正确?因为先筛选后组合和先组合后筛选,最终得到的元组集合是一样的,差的只是中间过程的存储开销。理解了这一层,规则就不再是需要死记的条文。
第三个误区是只盯着代数优化,完全不管物理优化。课后题如果做到“优化查询树画完”就收手,会漏掉这章一半的内容。真正数据库里的优化器,还要考虑索引扫描、连接算法、内存缓存、数据分布,这些都属于物理优化的范畴。代数优化只是把选择空间缩小了,物理优化才真正拍板执行方式。
4.2 做题易错点速查
我把刷题过程里见过的经典错误整理成了一张速查表,做题卡住时对照着看一眼,往往一下子就能发现问题。
| 易错点 | 错误示范 | 正确做法 |
|---|---|---|
| 复合条件拆错 | 把多条件作为一个整体强行下推 | 先拆成只依赖单个关系的子条件,再分别下推 |
| 投影下推丢列 | 下推投影时把连接属性也砍掉 | 至少保留输出列、连接列、后续条件列 |
| 笛卡尔积与连接混淆 | 优化前把笛卡尔积直接写成连接 | 先显式写出笛卡尔积,再按连接条件处理 |
| 视图不消解 | 直接对视图名做优化 | 先把视图定义展开成具体查询再执行优化 |
| 忽略结果集大小 | 只想着规则正确,不关注中间结果 | 每步都估算结果集大小,判断优化效果是否真实 |
这里面最要命的是最后一条。优化的本质是让中间结果不断缩小,如果做了一堆等价变换,中间结果反而变大,那说明变换方向错了。我每次做完优化,都会在查询树每一步旁边标注估算行数,用来验证优化效果。这个习惯在考试和面试里都很加分,因为能说出每步中间结果多大,比单纯背概念更能体现理解深度。
4.3 从习题到实践:用执行计划验证优化效果
如果只是做完课后题就结束,我觉得挺可惜的。几乎所有数据库系统都提供了查看执行计划的功能,一条查询前面加上特定的关键字,数据库就会把它要执行的算子顺序、扫描方式、连接方式全部列出来。
我第一次看到真实执行计划的时候特别有感触。教科书里画查询树用的是树状图,执行计划则用一张算子列表呈现,但背后的东西完全一致。你会看到过滤条件被下推到索引扫描,会看到驱动表被换成小表,会看到嵌套循环连接被改成哈希连接或排序合并连接——这些都是第九章的代数优化和物理优化在真实系统里的落地。
看执行计划时最有价值的观察点有三个:一是过滤条件有没有在扫描阶段就执行,如果条件出现在连接之后,说明选择没有下推到位;二是连接顺序是否合理,驱动表是否足够小;三是是否使用了合适的索引,以及估算行数和实际行数是否偏离过大。这三个问题对照课后习题里的优化原则,其实就是同一条方法论。
4.4 连接顺序与中间结果优化经验
我还想分享一个从实践中总结的排查思路。遇到慢查询,我的第一反应不是加索引,而是先检查where条件能不能提前过滤。能提前过滤的条件,尽量让它发生在连接之前,因为过滤是让数据量变小的最直接手段。第二步看连接顺序,尽量让筛选后行数更少的表作为驱动表,这跟教材里“小表驱动大表”的启发式规则完全一致。第三步才看连接列和过滤列有没有可用的索引,没有的话再评估要不要建索引。
有一次我处理一个大数据量的查询,明明有很强的过滤条件,但执行计划显示过滤发生在连接之后,导致中间结果撑大了内存消耗。把SQL结构调整一下,把过滤提前到单表内部执行,查询时间立刻降了一个数量级。这个经历让我对教材里“选择尽量先做”这句话格外信服。有些经验你以为要到工作里才能积累,实际上第九章的每一道习题,早就把答案告诉你了。
5. 从课后习题到工程实践:一点个人体会
5.1 为什么这章值得多花时间
如果只让我推荐数据库课程里值得多花时间的一章,我会选第九章。前面章节决定你会不会用数据库,这一章决定你理不理解数据库。有了查询处理这套框架之后,任何一条慢SQL都可以拆成四个阶段去定位:是语法问题、语义问题、代数层的浪费,还是物理层的低效。一次排查往往就是先看执行计划,再看估算行数和实际行数的偏差,最后回到代数层检查有没有选择可以下推、投影可以精简。这套方法论,完全是习题训练出来的思维习惯。
所以我建议把这章的课后题当成核心练习,而不是可做可不做的附加题。尤其是那些让你亲手画查询树、亲手做代数变换的题目,一定要动手写,不要光在脑子里想。写出来的过程才会暴露概念上的模糊点。
5.2 做题时记录中间结果大小的小技巧
一个小技巧想分享给正在刷题的人:做题时,在查询树每个节点旁边写上估算的输入行数和输出行数。初始语法树阶段,笛卡尔积那一步可能写的是“一万行乘十万行”,优化之后的连接那一步就变成了“五百行连一千行”。一张图下来,优化效果一目了然。这个习惯不仅能帮你验证自己的优化是否正确,还能在考试答题时展示出真正理解,而不是停留在背规则表面。
做作业时我经常碰到这种情况:规则都用对了,但画出来的优化树中间结果还是很大,仔细一查往往是某个选择条件漏拆了。写下中间结果大小之后,这种问题立刻暴露,因为数字不骗人。
5.3 后续还可以往哪个方向延伸
如果对这块内容还想深入,我觉得有三个方向值得关注。一是统计信息与代价估算,可以了解真实系统如何用采样、直方图、选择率估计来支撑成本模型,很多慢查询问题都出在统计信息不准上。二是连接算法的内部实现,尤其是当内存缓冲不足以容纳全部输入时,排序合并和哈希连接如何分阶段处理数据。三是查询优化在新场景里的变体,包括并行查询优化、分布式数据库里的查询计划生成,以及数据仓库环境下更复杂的星型模型连接。每个方向都值得再花一整章去学,但它们的起点,都是第九章这套关系查询处理和查询优化的基础框架。
最后说一句个人体会。做这章作业的时候,我一开始也觉得关系代数的等价变换有点绕,无非是几个规则来回倒腾。但坚持把所有题目亲手画出优化前后的查询树、一步不落地估算中间结果大小之后,再看数据库的执行计划,就再也不觉得它玄了。这章的知识特别像一副眼镜,戴上之前你以为自己看懂了SQL,戴上之后才发现自己之前只是看到了数据库的表面的那层皮。希望这份作业复盘能让你刷题的时候少走一点弯路,也真的把“查询优化”这四个字,从书上的名词变成自己能用的技能。