☰
关系代数:SQL写不好与查询优化的根子
2026/10/16 4:17:35 网站建设 项目流程

1. 关系代数被低估了:SQL写不好,根子多半在这

我面试过不少写了三四年业务代码的开发者,增删改查溜得飞起,可一遇到这种需求就卡壳:"查出选了全部课程的学生""找出工资高于自己经理的员工""统计连续登录三天的用户"。这不是SQL语法不熟,是底层的关系代数思维没建立。你不会的那些复杂度,数据库优化器其实天天在帮你处理。

关系代数听起来像一门纯数学课,但只要你吃数据库这碗饭就绕不开。它是关系模型奠基人提出的一套对"关系"(也就是表)进行操作的代数系统,一共八大运算:选择、投影、并、差、笛卡尔积、交、连接、除。其中前五个是基本运算,后面三个可以由基本运算推导出来。SQL的每一个查询、优化器的每一步改写,归根到底都能落到关系代数表达式上。

那学它到底有什么用?我说三个最直接的。

第一,写复杂查询时你有推导能力。碰到"全部""至少""不存在"这类需求,脑子里能先映射成代数运算,而不是在SQL里瞎试。第二,看执行计划不再发怵。EXPLAIN出来的操作树就是一颗关系代数表达式树,认识节点就知道优化器在干什么。第三,面试。经典的关系代数题目几乎是所有数据库岗位的必考题,把σ、π、⋈、÷的语义讲清楚,比背一百条SQL技巧更有说服力。

这篇文章不会写成教科书。我会用一张经典的学生-课程库把所有运算讲透,再给你七个可以直接抄走、也可以拿来自测的经典查询案例,从关系代数表达式一路翻译到SQL。读完你最大的收获应该是:以后再遇到"全部""至少""不被包含"这类查询,第一反应不再是搜答案,而是自己推出来。

2. 先把话说清楚:关系、元组、属性和那五个基本操作

2.1 一张表在数学上到底是什么

关系代数操作的对象是"关系"。你可以把关系直接理解成一张二维表,但有四个词需要较真:

  • 元组(Tuple)就是一行,关系代数里通常用t表示。
  • 属性(Attribute)就是一列,比如学号、姓名、性别。
  • 度(Degree)是属性的个数,基数(Cardinality)是元组的个数。
  • 域(Domain)是某个属性取值的集合,比如"性别"的域就是{男, 女}。

一个关键点:关系是"集合"的抽象,理论上不允许有完全相同的两行;而SQL里的表是"多重集合"(bag),允许重复行。这就是为什么关系代数里投影π默认要去重,而SQL的SELECT默认不去重,需要你手动写DISTINCT。这个差异在后面很多坑里都会出现。

还有一个容易被忽略的性质:每个关系都有一个主键,它唯一标识一行。关系代数的运算结果也都是关系,所以运算可以嵌套,表达式可以写成树。这条性质是整个查询优化的根基——树上的每一层,都是一个独立的中间结果。

2.2 五个基本操作的含义

关系代数定义了五个基本操作,其他操作都能由它们组合出来:

运算符号作用类似SQL
选择σ按条件筛行WHERE
投影π按条件选列SELECT DISTINCT
并∪取两关系元组的并集UNION
差−取属于前一个但不属于后一个的元组EXCEPT / NOT IN
笛卡尔积×两关系所有元组的全组合CROSS JOIN

书上有时把改名运算ρ也列为基本操作,因为有些运算(比如自连接)没有它就没法表达,我们后面专门讲。

选择运算的写法是σ后面跟条件,比如σ_{Sdept='CS'}(Student)表示从Student表里筛出系别为"CS"的行。条件里可以用比较符=、≠、>、<、≥、≤,也可以用逻辑连接词∧(且)、∨(或)、¬(非)。

投影运算的写法是π后面跟属性列表,比如π_{Sno, Sname}(Student)只保留学号和姓名两列。一句话概括这两兄弟:选择是横向切一刀,决定哪些行留下;投影是纵向切一刀,决定哪些列留下。

很多人容易忽略它们的顺序。先选择再投影,还是先投影再选择?最终结果一样,但性能差很多。以Student表1万行、只有这4列来算,先投影不觉得有什么差别,但如果一张表有50列、每行好几KB,先投影就能省下大量IO。这个道理放到优化那一节细说。

2.3 为什么五个基本运算够用

你可能会想:连接、除法这么常用,居然不是基本运算?确实不是。连接可以定义为"先做笛卡尔积,再做选择":R ⋈_{条件} S = σ_{条件}(R × S)。除法也可以用差和笛卡尔积构造出来,第5章我会给完整推导。

"基本"的意思是:它们不能被其他运算组合出来,是所有表达式的最小公分母。优化器做等价变换时,就是在这些基本规则上做文章。你理解了这个层次,再看执行计划就会觉得异常清晰——不管多复杂的SQL,最后无非就是这几个操作的组合。

3. 集合四则运算:并、交、差、笛卡尔积在实战里的位置

3.1 并、交、差有个前提:两个关系要"兼容"

并、交、差不是随便两张表都能做。教科书上的要求是:两个关系的度数相同,且对应属性取自同一个域。通俗说就是"列数一样,每列的类型一致"。比如都是"学号、姓名"的两张表,就可以做并运算。

并运算∪的例子很常见:查出"计算机系的学生"并上"信息系的学生",写成σ_{Sdept='CS'}(Student) ∪ σ_{Sdept='IS'}(Student)。SQL里就是一个UNION。

交运算∩,比如查询"既选了课程1又选了课程2的学生"。这时候不能用一层的选择搞定,因为条件是跨行的:一个学生要同时出现在Cno='1'的行里和Cno='2'的行里。正确写法是两次选择之后再求交:

π_{Sno}(σ_{Cno='1'}(SC)) ∩ π_{Sno}(σ_{Cno='2'}(SC))

这个案例特别能说明关系代数"按集合思考"和SQL"按行思考"的区别。新手很容易写成σ_{Cno='1' ∧ Cno='2'}(SC)——这一看就是错的,因为单行里Cno不可能同时等于两个值。而上了关系代数,你自然会把"同时满足两个条件"拆成"两个集合的交"。

差运算−,比如查询"选了课程1但没选课程2的学生",写成:

π_{Sno}(σ_{Cno='1'}(SC)) − π_{Sno}(σ_{Cno='2'}(SC))

是不是比SQL里的NOT IN看着更干净?再比如"没有选任何课程的学生",整体差一下就出来了:

π_{Sno}(Student) − π_{Sno}(SC)

在SQL里你可能写NOT IN,或者LEFT JOIN ... WHERE IS NULL。两种写法都对,但关系代数让你一眼看清逻辑:全体学生,去掉选过课的,剩下的就是没选课的。

3.2 笛卡尔积:组合爆炸的源头

笛卡尔积×是把两个关系的所有元组两两组合。假设Student有10行,Course有5行,两者做笛卡尔积就是50行。如果Student有1万行,SC有100万行,笛卡尔积就是100亿行——这就是"组合爆炸"。

笛卡尔积本身几乎不会单独用,但它是连接的底层机制。连接运算可以定义为"先做笛卡尔积,再做选择"。很多数据库初学者写多表查询时,FROM后面接了三张表却没写JOIN条件,结果查出几千行重复数据,本质上就是不小心做了一次笛卡尔积。理解这个源头,你就能明白为什么每多连一张表,都要格外确认连接条件写全了。

我自己的经验是:一旦发现查询结果行数暴增,第一件事不是查业务逻辑,而是数一下JOIN条件——三张表至少有两条连接关系,少了任何一个,就等着看爆炸吧。

4. 选择、投影、连接:查询的三大主力

4.1 选择σ:行上的过滤

选择是最直观的操作。σ_{Sage < 20}(Student)查出年龄小于20的学生。条件可以很复杂,比如:

σ_{Sdept='CS' ∧ (Sage < 20 ∨ Ssex='男')}(Student)

意思是:计算机系中,年龄不到20岁或者性别为男的人。注意运算符优先级:∧比∨高。如果你本意是"计算机系的学生,且(年龄不到20或男生)",上面的写法没问题;但如果你忘了加括号,写成σ_{Sdept='CS' ∧ Sage < 20 ∨ Ssex='男'}(Student),意思就变成了"(计算机系且不到20岁)或男生",范围完全不一样。关系代数里多写括号是个好习惯,别嫌丑。

选择条件里还能处理"否定""空值"等场景,比如σ_{Grade IS NULL}(SC)虽然教科书默认没有NULL,但现实表里到处都是,后面避坑清单会专门讲。

4.2 投影π:列上的过滤

投影的经典坑是去重。π_{Sdept}(Student)返回学生表中所有出现过的系别,理论上相同系别只保留一个。SQL里SELECT Sdept FROM Student默认不去重,必须写SELECT DISTINCT Sdept才与关系代数一致。

另一个常见现象:投影之后,有些元组因为列变少了而变得相同,从而被去重。比如SC表投影成π_{Sno}(SC),每个学生的多条选课记录会合并成一条。这个细节在回答"哪些学生选过课"时特别有用——你根本不需要写GROUP BY,投影本身就完成了"去重"这件事。

4.3 连接⋈:把碎片拼回完整视图

连接按条件把两个关系的元组配对。条件可以是任意比较表达式,这时候叫θ连接(θ代表任意比较符)。条件恰为等号,叫等值连接。如果等值连接比较的是两个关系中共有的同名属性,并且结果里重复列只保留一份,就叫自然连接,记作R ⋈ S。

实践中还有三种外连接:左外连接保留左表所有元组,右外连接保留右表所有元组,全外连接两边都保留,没匹配上的属性填NULL。

为什么连接这么重要?因为关系数据库把一份业务数据拆成了多张表,查询几乎都要把碎片重新拼起来。比如"查询选了课程1的学生姓名",需要让SC和Student连接,再去筛选:

π_{Sname}(σ_{Cno='1'}(SC) ⋈ Student)

注意这里的顺序:我先对SC做选择,再和Student连接。如果你先连接再选择,结果一样,但中间结果大得多。这就是优化器一天到晚在干的事。

有一个经验之谈:能用等值连接完成的查询,别用笛卡尔积加WHERE。虽然逻辑等价,但等值连接让优化器有更多机会利用索引或哈希连接。写SQL的时候,多表关联坚持写JOIN ... ON,不写FROM A, B WHERE A.x = B.x,也是同样的道理。

5. 除运算:所有"全部"类查询的钥匙

5.1 除法到底在算什么

除运算(÷)是整个关系代数里公认最抽象的一个。先给定义:设R(X, Y),S(Y, Z),其中Y是两个关系共有的属性组,则R ÷ S的结果是一个只含X属性的关系,它包含那些对S中出现的每个Y值都在R中有对应元组的X值。

太拗口了,直接上例子。假设SC是选课关系,Sno是学号,Cno是课程号。再假设有一个只含课程号的表S,里面是全部课程。那么:

SC ÷ S

返回的就是"选了S里所有课程的那些学号"。除法做的事翻译成人话:检查每个学生,是否对S里的每一门课都有一条选课记录。

生活化类比:食堂老板手里有一份"必吃菜单"S,每个学生有一份"吃过的东西"清单(SC)。除法就是在所有学生里,找出把必吃菜单上的每一样都吃过的人。

5.2 除法为什么能这样算

理解除法最踏实的办法是看它的代数构造。除法可以写成:

R ÷ S = π_X(R) − π_X( (π_X(R) × S) − R )

一步步解释:π_X(R)是所有出现过的候选X值(比如所有选过课的学生学号)。π_X(R) × S是"每个候选学生 × S里的每门课"的全组合,相当于一张"理想选课表"——每个人每门课都选了的假想情况。减去真实选课表R,得到"哪些(学生,课程)组合缺失了"。再投影成学号,得到"至少缺一门课的学生"。最后用全体候选学号减去这些人,剩下的就是"一门都没缺,S里的课全选了"的学生。

这条式子虽然看着复杂,但它把除法拆成了差、笛卡尔积、投影的组合,也证明了除法不是独立的基本运算。你如果能在纸上推一遍这个式子,除法的直觉就再也忘不掉了。

5.3 除法对应的SQL怎么翻译

关系代数里一行写成SC ÷ S,SQL里就变成:

SELECT Sno FROM SC WHERE Cno IN (SELECT Cno FROM S) GROUP BY Sno HAVING COUNT(DISTINCT Cno) = (SELECT COUNT(*) FROM S);

核心思路:分组后,每个学生的去重课程数等于S里的课程总数,说明S里的每门课都选了。注意一定要用COUNT(DISTINCT Cno),因为同一个学生同一门课如果因为数据质量问题有重复记录,COUNT(*)会虚高。

再升华一下:如果把S替换成"某老师所授的课程",除法就能回答"选了某老师所有课的学生"。如果把S替换成"必修课程表",除法就能回答"修完全部必修课的学生"。凡是需求里有"全部""所有""每一个"的,优先考虑除法。

6. 改名运算和自连接:一张表和自己的对话

6.1 为什么要改名

自连接,就是把一张表和自己连接。问题是:关系代数里,同一个关系出现在表达式两边,怎么区分?答案是改名ρ。

比如查询"课程号及其先修课程的课程名"。Course表里有Cno(课程号)、Cname(课程名)、Cpno(先修课程号)。一门课的先修课程,在Course表里也是以一行存在着的。所以需要把Course表复制两份,一份看成"课程",一份看成"先修课程":

ρ_{C1}(Course) ⋈_{C1.Cpno = C2.Cno} ρ_{C2}(Course)

名字一改,同一个表就变成了两个不同的关系,连接条件也就写得清楚了。SQL里等价的写法是给表起别名,比如:

SELECT C1.Cname AS 课程名, C2.Cname AS 先修课程名 FROM Course C1 JOIN Course C2 ON C1.Cpno = C2.Cno;

6.2 自连接的经典案例:员工与经理

再举一个职场里很常见的场景:Employee表(Eno, Ename, Salary, Mgr),Mgr是经理的工号,经理本人也在Employee表里。要查"工资高于自己经理的员工姓名",关系代数是:

π_{E1.Ename}(σ_{E1.Salary > E2.Salary}(ρ_{E1}(Employee) ⋈_{E1.Mgr = E2.Eno} ρ_{E2}(Employee)))

翻译成SQL:

SELECT E1.Ename FROM Employee E1 JOIN Employee E2 ON E1.Mgr = E2.Eno WHERE E1.Salary > E2.Salary;

自连接最常踩的坑是忘记别名,或者别名没区分开。一旦两个Employee共用Eno、Salary这些属性名,条件里到底指哪一个就是一团浆糊。关系代数用ρ强制你起名字,其实是在帮你理清思路。

还有一个易错点:如果Employee里的Mgr允许为空(比如老板没有经理),自连接后老板那行会因为匹配不到而消失。要保留老板的话,得用左外连接。这提醒我们:写连接之前,先想清楚"没匹配上的行要不要保留",这个决定应该由业务需求来定,而不是由SQL写法顺手决定。

6.3 另一个常见自连接:找"同类对"

自连接还有一种典型场景:找出"同系别或同爱好"的所有两人组合。比如要查"同一个系的所有学生配对",关系代数是:

π_{A.Sname, B.Sname}(σ_{A.Sdept = B.Sdept ∧ A.Sno < B.Sno}(ρ_{A}(Student) × ρ_{B}(Student)))

这里用A.Sno < B.Sno而不是≠,是为了避免重复对:如果不加这个条件,(张三,李四)和(李四,张三)都会出现,而且自己和自己配对也会出现。这个技巧在处理所有"找配对"需求时都适用,面试里考"同部门员工对""共同好友对"都是同一套路。

7. 七个经典查询:从关系代数到SQL的完整翻译

这一节统一用一套库,你可以在任意支持SQL的数据库里复现:

  • Student(Sno, Sname, Ssex, Sage, Sdept):学号、姓名、性别、年龄、系别
  • Course(Cno, Cname, Cpno, Ccredit):课程号、课程名、先修课程号、学分
  • SC(Sno, Cno, Grade):学号、课程号、成绩
  • TC(Tno, Cno):教师号、课程号

所有关系代数表达式我都同时给出SQL,你可以对照着看。

7.1 热身题:单表筛选

第1题:查询计算机系(CS)全体学生的详细信息。

关系代数:σ_{Sdept='CS'}(Student)

SQL:SELECT * FROM Student WHERE Sdept = 'CS';

第2题:查询计算机系学生的姓名和年龄。

关系代数:π_{Sname, Sage}(σ_{Sdept='CS'}(Student))

SQL:SELECT Sname, Sage FROM Student WHERE Sdept = 'CS';

第3题:查询所有课程的课程名和学分。

关系代数:π_{Cname, Ccredit}(Course)

SQL:SELECT DISTINCT Cname, Ccredit FROM Course;(严格按关系代数语义要加DISTINCT;实际业务里如果课程名本身唯一,不加也行)

这三题是打底,感受一下选择和投影的组合顺序。

7.2 "至少"类:交运算

第4题:查询至少选了课程1和课程2两门课的学生的学号。

关系代数(交运算):π_{Sno}(σ_{Cno='1'}(SC)) ∩ π_{Sno}(σ_{Cno='2'}(SC))

SQL经典写法:

SELECT Sno FROM SC WHERE Cno = '1' INTERSECT SELECT Sno FROM SC WHERE Cno = '2';

不依赖INTERSECT的写法:

SELECT Sno FROM SC WHERE Cno = '1' AND Sno IN (SELECT Sno FROM SC WHERE Cno = '2');

第5题:查询选修了课程1但没选课程2的学生。

关系代数:π_{Sno}(σ_{Cno='1'}(SC)) − π_{Sno}(σ_{Cno='2'}(SC))

SQL用EXCEPT:

SELECT Sno FROM SC WHERE Cno = '1' EXCEPT SELECT Sno FROM SC WHERE Cno = '2';

7.3 "全部"类:除运算

第6题:查询选修了全部课程的学生的学号和姓名。

先算学号:

π_{Sno, Cno}(SC) ÷ π_{Cno}(Course)

然后和Student连接取姓名:

π_{Sno, Sname}((π_{Sno, Cno}(SC) ÷ π_{Cno}(Course)) ⋈ Student)

SQL写法:

SELECT Sno, Sname FROM Student WHERE Sno IN ( SELECT Sno FROM SC GROUP BY Sno HAVING COUNT(DISTINCT Cno) = (SELECT COUNT(*) FROM Course) );

第7题:查询选修了某位老师(比如教师号T01)所授全部课程的学生学号。这里把除数换成"该老师所授课程的课程号集合":

π_{Sno, Cno}(SC) ÷ π_{Cno}(σ_{Tno='T01'}(TC))

SQL:

SELECT Sno FROM SC WHERE Cno IN (SELECT Cno FROM TC WHERE Tno = 'T01') GROUP BY Sno HAVING COUNT(DISTINCT Cno) = (SELECT COUNT(*) FROM TC WHERE Tno = 'T01');

这套题最好自己动手在数据库里造一份小数据跑一遍:5个学生、4门课、十几个选课记录就够。手推一遍表达式,再用SQL验证一遍,比对结果一致,你才算真正掌握了。

8. 表达式优化直觉:操作顺序就是性能顺序

8.1 一个一眼能看出来的优化

看这个查询:"查询选修了课程1且成绩大于90的学生姓名"。

如果脑子里没有关系代数,很多人会这样写SQL:

SELECT Student.Sname FROM Student, SC WHERE Student.Sno = SC.Sno AND SC.Cno = '1' AND SC.Grade > 90;

数据库其实会帮你做等价变换,但关系代数的视角能让你自己先看出最优路径:先沿着条件把SC缩小,再做连接,最后投影。

π_{Sname}(σ_{Grade > 90 ∧ Cno='1'}(SC) ⋈ Student)

同样一个查询,一条路径是先做笛卡尔积再过滤,另一条是先把SC过滤成几百行再连接,代价相差几个数量级。假设Student 1万行、SC 500万行:先连接再过滤要处理500亿次比较,先过滤再连接可能只需要先排除掉绝大部分选课记录,再面对几千行数据。这个差距就是"秒回"和"查不出来"的区别。

8.2 几条启发式优化规则

教科书里的等价变换规则,落到实际就这几条最常用:

  • 选择下推(Selection Pushdown):尽量把σ往表达式树的叶子上移,先缩小数据量。
  • 投影尽早做(Projection Early):只保留后面用得到的列。列少了,每行占的空间小,IO和内存都省。
  • 连接重排(Join Reordering):小表先连接,减少中间结果。优化器会根据统计信息自动排,但你知道这个原理,写SQL时自然会把过滤条件写进子查询而不是写在最外层。
  • 减少不必要的笛卡尔积:一旦表达式里出现×,想想能不能换成⋈并补上条件。

有人可能会问:既然优化器会自动做,我为什么要学?答案有两个。第一,优化器不是万能的,统计信息不准、SQL写法太绕,它也可能给出糟糕的执行计划;你自己能看懂执行计划,才能发现问题。第二,写复杂查询时,如果你在脑子里就能把表达式化简,写出来的SQL天然就是最优的,根本不需要靠优化器兜底。

一个实操建议:每次写完一条慢查询,先看EXPLAIN。执行计划里的节点(Seq Scan、Hash Join、Nested Loop)对应的就是关系代数操作。把计划反推成表达式,你很快能看出是哪一步没下推、哪一步多做了投影,这是排查SQL性能最快的路径。

9. 实战中容易踩的坑,给你一份避坑清单

坑一:把关系的"集合语义"和SQL的"多重集合语义"混为一谈。关系代数里π自动去重,SQL里SELECT不去重。写"有哪些系别"这种查询时,忘加DISTINCT会得到一堆重复行;反之,如果业务上就要保留重复,你在关系代数层面反而要想清楚怎么表达。面试题里经常拿这点考基本功。

坑二:笛卡尔积爆炸。FROM里放了三张表,忘了写某个连接条件,结果行数莫名其妙翻了十几倍。遇到行数暴增的查询,第一反应就是去检查JOIN条件:连接键是不是写全了?有没有因为字段名重复而连接到了错误的列?

坑三:除运算的COUNT陷阱。用HAVING COUNT(DISTINCT Cno) = (SELECT COUNT(*) ...)翻译除法时,如果SC里存在同一个学生同一门课的多条记录,不加DISTINCT就会算错。数据清洗不到位的时候,这个坑特别隐蔽。

坑四:自连接别名混乱。两张同结构的表连接时,一定要给每张表起清楚的别名,并在SELECT、WHERE、ON里用别名限定所有属性。我见过有人自连接后没限定属性名,数据库直接报"ambiguous column",这就是关系代数里为什么要有ρ的原因。

坑五:NULL值的处理。等值连接碰到NULL时,NULL = NULL的结果是未知,行会被丢掉。如果你要保留没匹配上的行,就要用外连接;如果你要统计NULL,就要用IS NULL。关系代数教科书默认没有NULL,但现实表里到处都是NULL,所以做除法、做差的时候,要特别小心"没选课的学生"和"成绩为NULL的学生"是不是同一个逻辑。

再说一个经验:学关系代数最好的方式不是背定义,而是拿自己业务里的真实查询,手工翻译成代数表达式,再对照SQL结果验证。翻译得多了,你会慢慢发现以前的很多SQL其实是在"碰运气",而现在的你是真的知道每一步在干什么。

最后分享一个小技巧。我当年复习关系代数时,给自己列了一套"问题模板":遇到"全部"想÷,遇到"至少"想∩,遇到"不存在/没有"想−,遇到"和自己比"想ρ加⋈,遇到"多表拼数据"想⋈。这套模板不是拿来背的,是用来在第一时间锁定思考方向的。当你熟练之后,这些判断会成为直觉,那时候你再回头看那些曾经让你卡壳的查询,基本都是一眼出答案。

关系代数看起来离业务很远,但它其实一直在你的数据库里工作。多花一个小时把这些运算搞清楚,省下来的是无数个调试慢查询、纠结写法和翻文档的不眠夜。

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

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

立即咨询