简介:《数据库原理及技术》(钱学忠)答案面向学习数据库课程的高校学生、备考者及需要查漏补缺的从业人员,围绕教材习题与核心知识提供参考解答,帮助对照课堂内容理解数据库设计、SQL语言、关系数据库理论、数据库管理系统与数据库管理等主题。资源以rar压缩包分发,约16.69MB;上游未提供文件总数及具体文件类型明细,实际内容以下载后的目录为准。已有693人学习或下载,说明其在课程复习与习题核对场景中具有一定参考价值。读者可借助答案梳理需求分析、E-R建模、关系规范化、查询与更新语句、事务与权限管理等知识点的解题思路,并用于课后练习核对、考前查漏补缺和实际问题分析,逐步提升数据库设计与维护能力。
1. 搜「数据库原理及技术 答案」的人,多半卡在同一步
搜「数据库原理及技术 答案」的人大概分三类:赶作业的、考前刷题的、以及把关系模型认真学了一遍却总在 π、σ、⋈ 上卡住的人。前两类要的是结果,第三类要的是能验证结果的过程。我按第三条路写:不给一份抄完就忘的答案,而是搭一套本地能跑起来的环境,让每道题的答案都能被执行、被断言、被推翻。
数据库原理及技术的习题有个特点——纸面答案几乎都能用 SQL 验一遍,而验不过的答案十有八九是理解偏了,不是答案错了。选课库上的除法题、函数依赖的候选码、隔离级别的现象复现,都属于「跑一次胜过看十遍」的类型。适合已经过了一遍教材、答案在手却对不上号的人;也适合带课的人,把习题集变成可回归的测试集。前提只有一条:愿意装一个数据库,愿意写几行 SQL。
2. 四张表搭起最小实验台:建表脚本、造数边界与 EXPLAIN 读法
2.1 为什么用嵌入式库打底、服务端库复核
嵌入式库(SQLite 这类)打底的理由很实在:单文件、零配置,:memory:建库跑完即销毁,几十道题连着跑不用维护任何服务。代价是它的类型系统过于宽松——声明成INTEGER的列照样能塞进字符串,外键约束默认不生效。这两个特性恰好会把「无损连接」「参照完整性」这类题的答案带偏,所以它只适合日常刷题。
复核必须换服务端库。隔离级别实验、锁等待、执行计划的行数估算,只有真实的 MVCC 实现才能跑出可信结果。我一般用 PostgreSQL 当基准,原因是它的默认隔离级别是 READ COMMITTED,行为与标准描述差异小,教材上怎么写,跑出来基本对得上。两套环境共用一个初始化脚本,差异靠方言适配层吸收,这一点在后面的范式分解自检里会体现得很明显。
选型上还有一个考虑:这两套库的 SQL 方言都不算重,EXCEPT、NATURAL JOIN、窗口函数都能用,习题里出现的大部分语法都能直接落地。遇到只有服务端库支持的写法(比如FILTER子句、NULLS FIRST),我在嵌入式库里换等价写法,不为了跑通去改写题意。
2.2 建表与造数:一份能直接执行的初始化脚本
题库围绕四张表:教师、学生、课程、选课。这四张表能覆盖选择、投影、连接、除法、聚合、外键、NULL 七类考点,够用,也不至于让造数变成负担。
-- init.sql:选课库,覆盖 σ/π/⋈/除法/聚合/NULL 六类常考题 PRAGMA foreign_keys = ON; -- 嵌入式库默认关闭外键,不写这句参照完整性实验白做 DROP TABLE IF EXISTS sc; DROP TABLE IF EXISTS course; DROP TABLE IF EXISTS student; DROP TABLE IF EXISTS teacher; CREATE TABLE teacher ( tno TEXT PRIMARY KEY, tname TEXT NOT NULL, title TEXT ); CREATE TABLE student ( sno TEXT PRIMARY KEY, sname TEXT NOT NULL, sdept TEXT, sage INTEGER CHECK (sage BETWEEN 15 AND 60) -- 范围约束,用来验 CHECK 是否生效 ); CREATE TABLE course ( cno TEXT PRIMARY KEY, cname TEXT NOT NULL, ccredit REAL, tno TEXT REFERENCES teacher(tno) ); CREATE TABLE sc ( sno TEXT REFERENCES student(sno), cno TEXT REFERENCES course(cno), grade REAL, -- 允许 NULL:代表选了课但没出分 PRIMARY KEY (sno, cno) ); INSERT INTO teacher VALUES ('T01','教师A','教授'), ('T02','教师B','副教授'), ('T03','教师C','讲师'); INSERT INTO student VALUES ('S01','A同学','计算机',20), ('S02','B同学','计算机',19), ('S03','C同学','数学',21), ('S04','D同学','数学',20), ('S05','E同学','物理',22); INSERT INTO course VALUES ('C01','数据库',3,'T01'), ('C02','操作系统',4,'T01'), ('C03','数据结构',4,'T02'), ('C04','高等数学',5,'T03'); INSERT INTO sc VALUES ('S01','C01',92), ('S01','C02',85), ('S01','C03',78), ('S02','C01',88), ('S02','C02',90), ('S03','C01',76), ('S03','C03',82), ('S03','C04',91), ('S04','C01',NULL), -- 造一个 NULL 分数,专治聚合题 ('S05','C04',60);这段脚本的关键在最后几行造数,不是随手填的。S04选了C01但分数为NULL,这一行会让COUNT(*)和COUNT(grade)分出高下,也会让NOT IN子查询集体翻车,属于刻意埋的边界。T01名下只有C01、C02两门课,S01、S02各选了这两门,正好构成除法题的期望答案两行;S04只选了其中一门,用来验证除法模板不会把「选了一半」的人误算进来。
外键那行PRAGMA foreign_keys = ON常被忽略。关着外键时,往sc里插一个不存在的学号不会报错,题目问「插入操作是否违反参照完整性」,你跑出来的结论就是错的——这种错最难查,因为语句本身不报任何异常。
2.3 EXPLAIN 不是黑匣子:三行输出看懂答案对不对
有一类习题问「该为哪列建索引」「这个查询会不会走索引」,纸面上只能凭最左前缀原则猜。执行计划一出,猜的部分就没了。
-- 嵌入式库:看访问路径是 SCAN 还是 SEARCH EXPLAIN QUERY PLAN SELECT s.sname FROM student s JOIN sc ON s.sno = sc.sno WHERE sc.cno = 'C01';输出里出现SCAN sc就说明走了全表扫;出现SEARCH sc USING INDEX ...才叫命中索引。这张表的索引是主键(sno, cno)的复合索引,查询条件只给了cno单列,按最左前缀原则没法用,所以计划必然是扫描。这本身就是一道题的答案——要让它走索引,得单独为cno建一个索引,或者调整复合索引的列序。纸面上背「最左前缀」,不如在这里跑一次记得牢。
-- 服务端库:估算行数与实际行数的偏差,比是否走索引更有信息量 EXPLAIN (ANALYZE, BUFFERS) SELECT s.sname FROM student s JOIN sc ON s.sno = sc.sno WHERE sc.cno = 'C01';ANALYZE会真的执行语句并把实际行数打出来,BUFFERS顺带给出缓存命中块数。看两个数:优化器估算的行数和实际返回的行数差了几个数量级,说明统计信息过期,这时候任何「该建什么索引」的结论都不作数。参数上,COSTS默认开启不用管;数据量小的时候计划可能失真,想看稳定结果就先跑一遍ANALYZE更新统计——这不影响答案本身,但影响你对答案的信任度。
3. 关系代数与 SQL 互译:把 π、σ、⋈ 写成能跑的语句
3.1 六个基本运算到 SQL 的映射与两个易错点
关系代数和 SQL 看着一一对应,实际上语义有两处系统性偏差:关系是集合,SQL 的表是多重集;关系的比较只有真假,SQL 多了个 UNKNOWN。这两处偏差贡献了习题答案对不上的大半。
| 关系代数 | 语义 | SQL 写法 | 偏差点 |
|---|---|---|---|
| σ 选择 | 行过滤 | WHERE | 条件为 UNKNOWN 的行被丢弃,不是 FALSE 才算丢 |
| π 投影 | 列裁剪 | SELECT | 关系代数默认去重,SQL 不去重,要手写DISTINCT |
| × 笛卡尔积 | 全组合 | CROSS JOIN | 漏写连接条件就等于笛卡尔积,行数是乘积 |
| ∪ 并 | 集合并 | UNION | UNION去重、UNION ALL不去重,选错就多行 |
| − 差 | 集合差 | EXCEPT | 两侧列数与类型必须兼容,NULL 被视为相等 |
| ρ 改名 | 重命名 | AS | 自连接必须改名,否则列名歧义 |
照着这张表把教材上的表达式逐条翻译,八九不离十。真正要动手验证的是投影去重和差运算这两类。
-- π_sdept (σ_sdept='计算机' (student)) -- 教材答案只有 1 行,因为关系是集合;不加 DISTINCT 会返回 2 行 SELECT DISTINCT sdept FROM student WHERE sdept = '计算机'; -- 差运算:没选过任何课的学生 = student − π_sno(sc) SELECT sno FROM student EXCEPT SELECT sno FROM sc;第一段不加DISTINCT会返回两条「计算机」,这就是典型的「答案差几行」问题,跟理解没关系,纯粹是集合语义和多重集语义的区别。养成习惯:凡是投影之后可能出现重复值的题,一律先加DISTINCT跑一遍,再决定要不要去掉。
第二段的EXCEPT要注意列兼容。原始表student有五列,sc只有三列,直接EXCEPT会报列数不匹配,所以必须先把左操作数投影成sno单列。教材上写student − π_sno(sc)时,减号左边隐含了投影,翻译成 SQL 时这层投影不能省。这个细节在批改时错得非常多。
3.2 除法为什么最容易翻车:NOT EXISTS 双取反模板
除法是关系代数里唯一没有直接 SQL 对应物的运算,也是答案最容易写错的地方。题目典型问法是「查询选修了教师A全部课程的学生」,正确写法是双取反:不存在一门教师A的课,是该生没选的。
-- 除法模板:外层 NOT EXISTS 遍历课程,内层 NOT EXISTS 检查该生是否选了 SELECT s.sno, s.sname FROM student s WHERE NOT EXISTS ( SELECT 1 FROM course c WHERE c.tno = 'T01' AND NOT EXISTS ( SELECT 1 FROM sc WHERE sc.sno = s.sno AND sc.cno = c.cno ) );执行逻辑是逐层否定的:内层子查询问「这个学生选没选这门课」,NOT EXISTS把它翻成「没选」;外层再问「存不存在这样一门课」,再翻一次,合起来就是「不存在他没选的课」,也就是全选了。这种写法的好处是对sc里的重复行完全免疫——存在性判断只关心有没有,不关心有几条。
参数上唯一要盯的是外层WHERE c.tno = 'T01'这个筛选条件,它定义了「全部课程」的范围。写错成WHERE 1=1就变成了「选修了全校所有课程的学生」,答案直接归零。改题目范围时只需要改这一处。
第二种写法用分组计数,可读性更好,但有个前提:
-- 等价写法:按学生分组,选课数等于该教师课程总数 SELECT sc.sno FROM sc JOIN course c ON sc.cno = c.cno WHERE c.tno = 'T01' GROUP BY sc.sno HAVING COUNT(DISTINCT sc.cno) = ( SELECT COUNT(*) FROM course WHERE tno = 'T01' );COUNT(DISTINCT sc.cno)里的DISTINCT不能省,否则sc表里同一学生对同一课程出现重复行时计数会偏大,把没全选的人算成全选。子查询的COUNT(*)则要求course表里cno唯一——主键保证了这一点,但如果题目改成按课程名去重,这里也得跟着改。两种写法我都会跑一遍,结果一致才敢在答案上落笔。
3.3 NULL 与聚合:答案差一行往往差在这
聚合题的标准坑是COUNT(*)和COUNT(列)混用。
-- 三个数一起看,才知道 NULL 在哪一步被跳过 SELECT COUNT(*) AS 选课记录数, COUNT(grade) AS 有分记录数, AVG(grade) AS 平均分 FROM sc;COUNT(*)数行,COUNT(grade)跳过NULL,AVG(grade)的分母同样跳过NULL。造数里S04那条NULL记录让前两个数差 1,平均分的分母也不是 10 而是 9。教材答案如果按 10 算平均分,你的结果对不上不是算错了,是教材没考虑NULL。
更隐蔽的是NOT IN遇上NULL:
-- 想查分数不等于 60 的选课记录,结果是 0 行 SELECT sno FROM sc WHERE grade NOT IN (SELECT grade FROM sc WHERE cno = 'C04'); -- 换成 NOT EXISTS 才正确 SELECT sno FROM sc a WHERE NOT EXISTS ( SELECT 1 FROM sc b WHERE b.cno = 'C04' AND b.grade = a.grade );第一条语句的子查询返回(60, NULL)(S04那条是NULL),grade NOT IN (60, NULL)展开成grade <> 60 AND grade <> NULL,后半句恒为 UNKNOWN,整个条件永远不成立,一行都返回不了。这种「查询静默返回空集」的坑没有报错、没有警告,靠肉眼检查几乎发现不了,只能靠换成NOT EXISTS或者给子查询补WHERE grade IS NOT NULL来规避。血泪经验:只要NOT IN的子查询来自可能含NULL的列,一律换成NOT EXISTS。
4. 范式判定不靠眼力:属性闭包、表格法与分解落地后的三项自检
4.1 候选码求解:属性闭包的手算表与脚本校验
范式题的第一步永远是求候选码,而候选码靠眼看极易漏。标准做法是算属性闭包。取一个干净的例子:
U = {A, B, C, D, E} F = {AB→C, C→D, D→E}求(AB)+,一轮轮把右部并进来,直到不再变化:
| 轮次 | 闭包 | 用到的依赖 |
|---|---|---|
| 初始 | {A, B} | — |
| 1 | {A, B, C} | AB→C |
| 2 | {A, B, C, D} | C→D |
| 3 | {A, B, C, D, E} | D→E |
| 收敛 | 等于 U,AB 是候选码 | — |
手算三行就完了,但题一多就容易漏轮次。写成脚本一劳永逸:
def closure(attrs, fds): """求属性闭包。attrs: 初始属性集合;fds: [(左部集合, 右部集合)]""" result = set(attrs) changed = True while changed: # 迭代到不动点,最多 |U| 轮 changed = False for lhs, rhs in fds: if lhs <= result and not rhs <= result: # 左部已具备、右部还没并入 result |= rhs changed = True return result F = [({'A', 'B'}, {'C'}), ({'C'}, {'D'}), ({'D'}, {'E'})] print(sorted(closure({'A', 'B'}, F))) # ['A','B','C','D','E'] print(sorted(closure({'A'}, F))) # ['A'],说明 A 单独不是候选码函数的核心是不动点迭代:每一轮把能推出的属性并进结果集,直到某一轮没有任何新增为止。属性集只增不减,所以循环一定收敛,最坏复杂度是O(|F| × |U|)。参数上,lhs <= result用的是集合的包含判断,要求左部所有属性都在当前闭包里,缺一个都不触发。
求全部候选码就在外层穷举属性子集,按基数从小到大试,第一个闭包等于 U 的组合就是极小候选码:
from itertools import combinations U = ['A', 'B', 'C', 'D', 'E'] found = [] for r in range(1, len(U) + 1): for cand in combinations(U, r): s = set(cand) if any(set(f) <= s for f in found): # 跳过已找到候选码的超集 continue if closure(s, F) == set(U): found.append(cand) print(found) # [('A','B')],唯一候选码any(... <= s for f in found)这一行是剪枝,不加的话所有候选码的超集都会被重复输出。属性数超过 8 个时穷举会变慢,但习题规模下完全够用。
拿到候选码 AB 之后判断范式:AB→C是完全函数依赖(A、B 单独都推不出 C),满足 2NF;C→D、D→E让非主属性 D、E 对码形成传递依赖,所以只到 2NF。这个结论手工推也行,但用闭包脚本验一遍,能避免「A 单独是不是也能推 C」这类看走眼。
4.2 无损连接判定的表格法,一步都别省
上面的模式分解成ρ = {R1(A,B,C), R2(C,D), R3(D,E)},判断是否无损连接,标准做法是构造判定表再按函数依赖改写。
初始表按行填关系模式、按列填属性:某属性属于该关系就填a加列号,不属于就填b加行列号。
| A | B | C | D | E | |
|---|---|---|---|---|---|
| R1 | a1 | a2 | a3 | b14 | b15 |
| R2 | b21 | b22 | a3 | a4 | b25 |
| R3 | b31 | b32 | b33 | a4 | a5 |
然后逐条扫函数依赖。AB→C:找 A、B 两列都是a的行,没有(R1 的 A、B 是 a,但那是同一行,需要两列同行为 a 才算),跳过。实际判定规则是「左部各列取值相同的行,右部列改成一致」,这里 A、B 没有两行取值相同,跳过。C→D:R1 和 R2 的 C 列都是a3,把 R2 的 D 列改成和 R1 一致(b14);再按约定优先取 a 值,R3 的 D 是 a4,所以 R1、R2 的 D 都改成 a4。D→E:三行 D 都是 a4,把 E 列统一成 a5。
改写后 R1 行变成a1 a2 a3 a4 a5,全 a,判定为无损连接。表格法一步都不能跳,尤其「优先取 a 值」这一步,很多人把 b 值直接覆盖上去,导致最后凑不出全 a,误判成有损。
4.3 分解结果落地成 SQL 时的三个自检
纸面判定无损只证明了理论性质,落到真实数据上还要跑一遍。三个自检动作:
第一,重连行数。无损的定义是r = πR1(r) ⋈ πR2(r) ⋈ πR3(r),连接后的行数必须等于原表行数,多一行少一行都说明分解有问题。
-- 无损连接自检:三段分解自然连接后的行数必须等于原表行数 SELECT (SELECT COUNT(*) FROM r_orig) AS 原表行数, (SELECT COUNT(*) FROM (SELECT a, b, c FROM r_orig) x NATURAL JOIN (SELECT c, d FROM r_orig) y NATURAL JOIN (SELECT d, e FROM r_orig) z) AS 重连行数;NATURAL JOIN按同名列自动连接,所以分解时公共属性必须保留原列名,改名成c_id就再也连不上了。这是把理论分解落地成 DDL 时最常见的失误,改名一时爽,重连两行泪。
第二,剩余依赖。分解出来的每张子表要重新算一次候选码和范式等级,确认没有残留的部分依赖或传递依赖。工具就是 4.1 的闭包函数,输入换成子表的属性集和投影下来的函数依赖。
第三,保持依赖。对每条原始 FD,检查它的左部和右部是否都落在某一张子表的属性集里。AB→C在 R1 内可验证,C→D在 R2 内可验证,D→E在 R3 内可验证,三条都保住了;只要有一条跨表,就得在答案里注明「无损但不保持依赖」,这个结论在评分里往往是区分点。
5. 答案对不上时先别改答案:五类高频踩坑的排查路径
5.1 结果集类:三条看起来像理解错误的踩坑
现象一:投影题你的答案是 3 行,教材是 1 行。原因几乎都是 SQL 的多重集语义——关系代数里投影自动去重,SQL 的SELECT不去重。解决:先加DISTINCT跑一次,行数对上就确认是这个原因;如果加了DISTINCT还是多,再去看造数里是不是多插了行。不要一上来就怀疑自己理解错了关系代数,先怀疑语义差异。
现象二:NOT IN写出来的查询永远返回 0 行,换个数据集又正常。原因是子查询结果里混进了NULL,三值逻辑让整个条件恒为 UNKNOWN。解决:改成NOT EXISTS相关子查询,或者在子查询里补WHERE 列 IS NOT NULL。排查手法很简单——把NOT IN拆成IN跑一遍,IN能返回行而NOT IN返回空,基本可以确诊。
现象三:从表格软件导入的学号,WHERE sno = 'S01'查不到数据。现象看着像字符集问题,原因多半是导入时带了尾随空格或不可见字符,等值比较自然失败。解决:用TRIM(sno)包一层先验证,确诊后回到导入环节清洗,别在查询里长期留着函数包裹——它会让索引失效,顺带把执行计划那道题也做错。
5.2 事务与并发类:隔离级别不是背出来的
现象四:想复现不可重复读,两个会话怎么跑都复现不出来。原因是自动提交开着,两条语句各自是一个独立事务,压根没有「同一事务内读两次」这回事,隔离级别自然不起作用。解决:两个会话都显式开事务,读完第一次之后不要提交,在中间插入另一个会话的写操作。
-- 会话 A BEGIN ISOLATION LEVEL READ COMMITTED; SELECT COUNT(*) FROM sc WHERE cno = 'C01'; -- 记为 n1 -- 此处停住,切到会话 B -- 会话 B BEGIN; INSERT INTO sc(sno, cno, grade) VALUES ('S02', 'C04', 77); COMMIT; -- 回到会话 A,同一条语句再查 SELECT COUNT(*) FROM sc WHERE cno = 'C01'; -- 与 n1 比较,判断是否出现幻读 COMMIT;参数上要盯的是BEGIN ISOLATION LEVEL那一行,级别不同结论完全不同,别在会话级别上设一次就以为全库生效。另一个容易忽略的点:服务端库的隔离级别作用域是事务,会话级的SET只是给后续事务设默认值,改完要新开事务才生效。
现象五:按教材表格背的隔离级别行为,在自己机器上对不上。原因在表格本身——标准只规定了「不允许什么」,没规定「必须怎么实现」,各家实现差异很大:
| 隔离级别 | 脏读 | 不可重复读 | 幻读(标准) | 常见实现差异 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 部分实现直接等同 RC |
| READ COMMITTED | 否 | 可能 | 可能 | 语句级快照,同语句内一致 |
| REPEATABLE READ | 否 | 否 | 标准允许 | 快照读往往已消除幻读 |
| SERIALIZABLE | 否 | 否 | 否 | 靠锁或冲突检测 |
解决:答题时以「标准描述」为准,做实验时以「你手上这套库的实际行为」为准,两套结论分开记,别互相覆盖。我吃过一次亏——按标准答了「RR 允许幻读」,实验却跑不出来,纠结了半天才发现是快照读把幻读吃掉了,不是自己写错了。这种题的正确答法是写「标准允许,本实现下观察到 X」,比单纯背表格站得住。
6. 把六十道习题变成回归测试:pytest 断言集的写法与两个取舍
刷到后半程会发现一个规律:错过的题会反复错,而且每次错的原因都不一样。我的做法是把每道题写成一条断言,跑一次全绿,说明这一章真的过关了。
# test_answers.py —— 每道题一条断言,答案改动会立刻变红 import sqlite3 import pytest @pytest.fixture(scope="module") def db(): conn = sqlite3.connect(":memory:") # 内存库,跑完即弃 with open("init.sql", encoding="utf-8") as f: conn.executescript(f.read()) yield conn conn.close() def rows(db, sql): return sorted(db.execute(sql).fetchall()) # 排序消除行序不确定性 def test_div_all_t01_courses(db): sql = """SELECT s.sno FROM student s WHERE NOT EXISTS ( SELECT 1 FROM course c WHERE c.tno = 'T01' AND NOT EXISTS (SELECT 1 FROM sc WHERE sc.sno = s.sno AND sc.cno = c.cno))""" assert rows(db, sql) == [("S01",), ("S02",)] def test_not_selected_any_course(db): sql = "SELECT sno FROM student EXCEPT SELECT sno FROM sc" assert rows(db, sql) == [] # 造数里人人有课,空集也是答案scope="module"让建库只发生一次,六十道题跑下来不到一秒;sorted()是必需的,SQL 不保证返回顺序,不排序会出现「内容对了但断言失败」的假红。第二个用例特意断言空集——空集是最容易被写错的答案,因为写错了它也是空的,看不出区别。
两个取舍值得说清楚:一是断言里直接写死期望值,等于把答案固化进代码,题目范围一改就要改断言,但它换来的是可重复验证;二是不做跨库断言,同一份期望值分别对嵌入式库和服务端库跑,差异靠单独的适配脚本标记,别指望一套 SQL 通吃两种方言。
我现在的习惯是:每做错一道题,先把它写成一条会红的断言,再去翻答案。半年下来这套test_answers.py比任何一份答案文档都可靠——答案会骗人,断言不会。希望帮到你。
本文还有配套的精品资源,点击获取