数据库表设计必知:函数依赖、候选码与范式分解从入门到实战

发布时间:2026/10/19 7:59:35
数据库表设计必知:函数依赖、候选码与范式分解从入门到实战 数据库这门课里“第6章关系数据理论”往往被不少同学当成“背范式定义的一章”可实际上它才是理解表结构设计质量的关键。前面几章讲建表、写SQL、做查询只要多练总能应付到了候选码、函数依赖、范式判定、模式分解这一块很多人开始一做一个错。我当年在这章也栽过跟头后来把整章重新串了一遍才明白那些符号和定义不是在为难人而是用来回答一个很实际的问题这张表到底该不该拆、该怎么拆。如果你也正在面对函数依赖、范式分解、属性闭包这些概念或者你已经工作了一段时间发现业务表冗余严重、出问题后不敢乱动那这篇内容应该能帮上忙。我会尽量把整章线索讲得接地气但该严谨的地方也不会含糊因为关系数据理论本身就是一个要求精确的体系。1. 这一章到底在解什么题烂表引发的四种异常1.1 从一张常见教务表说起先看一个很常见的场景。某教学管理系统要记录学生的选课、专业和院系信息设计者建了这么一张表学生信息表学号, 姓名, 专业, 系名, 系主任, 课程号, 成绩如果把所有数据塞进一张表很快会发现两个问题。第一个是“冗余”同一个学生的姓名、专业、系名、系主任会在每一门选课记录中重复出现。假设某学生选了10门课那么他的姓名、专业、系名等信息就要重复存10遍。第二个问题是“维护成本高”系主任换人了理论上要把所有相关行的系主任字段都改一遍只要漏掉一行就会导致同一系出现两个不同的系主任。这还只是表象。冗余的背后是一系列被称为“存储异常”的问题。教科书上一般总结成三类更新异常、插入异常、删除异常。它们其实是同一种病不同阶段的症状。更新异常上面已经提到了插入异常是指你想记录一个新院系或新专业但还没有学生选课由于学号和课程号是主键的一部分整条记录根本插不进去删除异常则相反某学生毕业被删掉后连带把他所在的专业信息和系主任信息也一起删没了。经历过这套问题的人再看级联删除、外键约束都会格外谨慎。1.2 范式不是越高越好的“等级压人”我当时一直有个困惑既然要防止冗余是不是范式级别越高越好后来才明白这一章的真正主线不是“往高处爬”而是“在满足业务约束的前提下让数据结构尽量干净”。关系数据理论用“范式”来描述表的好坏程度。从第一范式到第三范式再到BCNF、第四范式每一级都在消除一种特定的依赖问题。但这个过程中有一个关键平衡高范式往往意味着表拆得更细查询时需要的连接更多应用层写起来也更繁琐。后面我会专门讲这个度怎么把握现在先把目标定清楚先学会判断和拆表变成条件反射再谈取舍。1.3 先统一一组术语不然后面全乱套这一章涉及的术语很多但核心其实就那么几个我把它们和生活中的理解对应起来关系模式可以理解成“表结构”用 R(U) 表示U 是属性集合。属性就是字段、列。元组就是行记录。超码能唯一标识一条记录的属性组合比如学号, 姓名也能算超码但显然有冗余。候选码最精简的超码去掉任何一个属性都不再能唯一标识记录。主码从候选码里挑一个当主键的其他候选码叫“备用码”。主属性包含在任何一个候选码里的属性。非主属性不在任何候选码里的属性。很多人会把主码和候选码混为一谈这会导致后面判断部分函数依赖时出错。候选码往往不止一个判断范式时依据的是“所有候选码”而不是“我随便定的那个主键”。这一点很重要后面我会专门举反例。2. 函数依赖整章推理的发动机2.1 函数依赖的定义与直觉函数依赖是整个关系数据理论最底层的工具名字有点抽象但理解起来不难。如果在一个关系模式 R(U) 中属性集 X 的值一旦确定属性集 Y 的值也跟着确定就称 X 函数决定 Y记作 X→Y。它和数学里的函数概念一致给一个输入只能得到一个输出。放在表里就是同一张表里如果两行在 X 属性上的取值相同那么它们在 Y 属性上的取值也必然相同。生活中最典型的例子是学号→姓名。一个学号只会对应一个姓名所以学号决定姓名。但反过来姓名→学号不一定成立因为可能有重名。判断函数依赖时不能凭直觉“理应当”要从数据约束出发。我刚学的时候常把函数依赖当成业务逻辑比如觉得“性别→系名”这种句子看起来合理就不假思索地写上。标准做法是先问自己系统中是否存在两条记录它们在 X 上相同、Y 上不同如果有可能就不算函数依赖。2.2 Armstrong公理推导新依赖的合法工具有时候我拿到一个函数依赖集合 F想知道某个新依赖是否能由 F 推出。这时不能靠猜得用一套公理。最常见的三组基本公理是自反律如果 Y 是 X 的子集那么 X→Y。比如学号, 课程号→学号。增广律如果 X→Y那么 XZ→YZ。就好比学号→姓名成立那么学号, 课程号→姓名, 课程号也成立。传递律如果 X→YY→Z那么 X→Z。比如学号→系名系名→系主任推出学号→系主任。由这三条还能推出几个常用合并规则如果 X→Y 且 X→Z则 X→YZ反过来X→YZ 也能拆成 X→Y 和 X→Z。这些规则本身不难难的是做题时敢不敢用。看到一个 FD 链比如 A→BB→CC→D要能快速反应出 A→D 是一路传递过来的。2.3 属性闭包手算方法求属性闭包是这一章的“必考技能”几乎所有判断都依赖它。给定属性集 X 和函数依赖集合 FX 的闭包记作 X意思是“在 F 的推导下X 最终能决定的全部属性集合”。手算步骤不复杂但要小心迭代把 X 放入 X作为初始属性集。扫描每一条函数依赖 Y→Z如果 Y 的所有属性都已经出现在 X 中就把 Z 加入 X。重复扫描直到 X 不再变化。看个例子。关系模式 R(A,B,C,D)函数依赖集合 F {A→B, B→C, AC→D}求 A。初始 X {A}。扫描 FA→BA 已经在内加入 BX {A,B}B→C看到 B 已经在内加入 CX {A,B,C}AC→DA 和 C 都在内加入 DX {A,B,C,D}。到这里再扫一轮所有依赖的左侧都已经包含在闭包里不会再新增属性算法结束。A 就是 ABCD。注意一个关键细节扫描不是一轮就够因为第二轮可能因为新增属性而触发新的依赖。我就见过有人算完第一轮就宣布结果结果漏了属性。2.4 用闭包求候选码先锁定必须出现的属性求候选码是另一种重要题型。标准思路是先把所有属性分成两边只出现在函数依赖左侧、从不出现在右侧的属性 L以及出现在右侧的属性 R。候选码必须包含 L 中的所有属性因为 L 无法由别的属性推导出来。举个例子。R(A,B,C,D)F {A→B, C→D}属性 A 和 C 都出现在左侧且都没有出现在右侧所以候选码必须包含 A 和 C。求 AC 的闭包A→B 增加 BC→D 增加 D最终 AC ABCD正好是全属性所以 AC 就是唯一候选码。有时 L 的闭包覆盖不了全属性就要尝试往 L 里加入 R 中的某个属性再求闭包直到找出所有能覆盖全属性的最小组合。这个过程有点像搭积木先把必须放的积木放好再挑可选的补全最后检查有没有冗余属性。我还养成了一个习惯求完候选码后顺手统计一下主属性和非主属性。多数范式判断题都需要这两份清单。3. 从1NF到3NF逐级拆表的完整套路3.1 1NF先保证原子第一范式的定义最朴素每个字段必须是不可再分的原子值不能是集合、数组或复合结构。比如有人把“兴趣”字段写成“篮球、读书、音乐”这就不符合1NF。解决方法是拆成多行或者单独建一张兴趣表。我一直把1NF当成“建表的地基”如果连原子性都做不到后面所有范式讨论都没有意义。这里有个容易混淆的点现在不少数据库产品支持数组、JSON等复合类型哪种算1NF按关系模型的严格定义类似集合值的字段仍属于非原子域。工程上可以用但进范式体系讨论时不算符合1NF。3.2 2NF去掉非主属性对候选码的部分依赖第二范式建立在1NF基础上它要求每一个非主属性都完全函数依赖于候选码不能只依赖于候选码的一部分。继续用前面的教务表。候选码是学号, 课程号。成绩是完全依赖候选码的因为必须同时确定哪个学生、哪门课才有成绩。但姓名、专业、系名这些只依赖学号学号是候选码的真子集这就是“部分依赖”。所以这张表不满足2NF。要解决部分依赖做法很简单把部分依赖拆分出来。比如先把学号, 姓名, 专业拆成一张表把学号, 课程号, 成绩拆成另一张表。判断时最容易犯的错误是只看主键忽略其他候选码。如果一个表有复合候选码并且非主属性依赖其中某个候选码的局部那么即使它依赖主键的方式没问题依然不满足2NF。给人用起来的感觉很隐蔽考试也常在这里设坑。3.3 3NF再抠掉传递依赖第三范式在满足2NF的基础上进一步要求非主属性不能传递依赖于候选码。传递依赖的常见路径是候选码 X→属性YY→属性Z于是 Z 就通过 Y 传递依赖于 X。最典型的是学号→系名系名→系主任于是系主任传递依赖于学号。我一开始没转过弯心想只要不存在部分依赖就不就完了后来发现如果把名字和专业、系名放在同一张表虽然看起来“都是学号直接决定的”但系主任其实是经系名拐了个弯决定的。后果就是系主任字段重复出现换人的时候还是得改很多行。所以传递依赖和部分依赖一样是冗余之源。处理方式还是拆分把中间属性 Y 和最终属性 Z 单独成表。前面例子中把系名和系主任拆成新表原表只留学号、姓名、专业、系名即可。3.4 一个完整案例把教务表一路拆到3NF我们把前面的教务表完整处理一遍。原始关系模式R(学号, 姓名, 专业, 系名, 系主任, 课程号, 成绩)已知函数依赖集合 F学号 → 姓名, 专业专业 → 系名系名 → 系主任(学号, 课程号) → 成绩候选码明确是学号, 课程号。非主属性有姓名、专业、系名、系主任、成绩——因为成绩依赖完整候选码姓名和专业只依赖学号所以这张表不满足2NF。先把部分依赖拆掉R1(学号, 姓名, 专业, 系名, 系主任) R2(学号, 课程号, 成绩)R2 已经满足3NF因为只有一个函数依赖且左侧是候选码。R1 中还有传递依赖学号→专业→系名以及系名→系主任。继续拆R11(学号, 姓名, 专业) R12(专业, 系名) R13(系名, 系主任)最终得到四个关系模式它们都满足3NF。仔细看R12、R13 其实和成绩表没有直接关联目的是把“专业”和“系”这两类业务信息独立出来。这也说明拆分不是简单地把大表切成两半而是按函数依赖的边界来切。4. BCNF和4NF教材爱考、实战偶见的边界4.1 3NF与BCNF就差一层“主属性”BCNFBoyce-Codd范式判定条件比3NF更严格对于任何一个非平凡函数依赖 X→YX 必须包含某个候选码也就是 X 是超码。而3NF的放松条件是如果 X→Y 不是超码还允许 Y 是主属性Y 包含于某个候选码。BCNF 连这种豁免也不给所以常出现“满足3NF但不满足BCNF”的表。经典例子是R(学生, 课程, 教师)函数依赖(学生, 课程) → 教师教师 → 课程候选码有两个{学生, 课程} 和 {学生, 教师}。学生、课程、教师全是主属性因为每种属性都会出现在某个候选码里。3NF判定时对于教师→课程右侧课程是主属性所以允许但BCNF判定时教师不是超码因此不满足。这个例子我曾经反复看才理解为什么教材要把BCNF单独拎出来从定义上看3NF已经消除了非主属性带来的冗余但主属性之间的矛盾仍可能造成重复。比如某个教师只教一门课却在多个学生的记录里重复出现“教师-课程”这种组合。BCNF 的修改做法是对违反规则的 FD 拆分。可有趣的是这个例子一旦拆成教师, 课程和学生, 教师函数依赖 (学生, 课程) → 教师 就不好保持了。这个问题指向一个更深的主题范式不是越高越好还要考虑能不能保住约束。这一点我放在第5节讲。4.2 多值依赖与第四范式函数依赖之外还有一类依赖叫多值依赖我理解成“一个属性确定后另一组属性有一组对应的值且这些值相互独立”。举例某表记录“学号, 课程, 兴趣”。一个学生可以选修多门课同时可以有多个兴趣。课程集合和兴趣集合之间没有函数依赖比如课程并不会决定兴趣。这种情况存在多值依赖学号→→课程学号→→兴趣。如果不拆分表里出现的行数会是“课程数 × 兴趣数”。学生选修5门课、有4个兴趣就会产生20行而实际有效信息只是“他选了5门课”和“他有4个兴趣”。这显然又是冗余。第四范式要求在满足BCNF的基础上对于所有非平凡多值依赖 X→→YX 都必须包含候选码。处理方法是把这两组独立的值拆开一张存学号, 课程另一张存学号, 兴趣。这样每条记录都只表达一个事实。4.3 范式选择教科书、考试和工程是三个世界我把最常用的范式判断条件整理成一张表做题时可以对照着看。范式核心要求通俗说法典型场景1NF属性原子一张表就是一个平面表不能嵌套任何表结构设计2NF非主属性完全依赖候选码不能只依赖复合候选码的一部分复合主键的表3NF非主属性不能传递依赖不能出现 A→B→C 的传递链大多数业务表至少到这级BCNF每个非平凡FD的左侧都是超码连主属性之间的FD都不放过教师-课程这类主属性互推的表4NF每个非平凡MVD左侧都是超码把相互独立的集合属性拆开兴趣、技能、标签类多值场景我个人的经验是教科书喜欢考 BCNF因为它能把概念理解区分得非常清晰考试里3NF和BCNF的判断是高频题实际建表时通常做到3NF已经能应付绝大多数业务。如果业务上真的出现明显的BCNF问题比如“教师只能上一门课”这种约束与其强行拆到BCNF导致依赖失真我更倾向于保留3NF设计在应用层或者约束层处理约束。这些都是后话但一旦动手设计过系统你就知道这个权衡有多现实。5. 拆表之后如何验证无损连接与保持依赖5.1 无损连接拆完还能拼回原样规范化必然要拆表但拆表不是随便切一刀就行。前提之一是“无损连接”把分解后的多张表重新做自然连接得到的结果必须和原来那张表完全一致既不能丢数据也不能多出凭空冒出来的行。多数人第一次接触这个概念会觉得抽象。我常用的直觉是把“无损连接”理解成一个拼图约束——原表的信息是完整的源图拆表是把拼图块分开但每块之间必须有正确拼接口拼起来后不能少、不能多。如何验证标准方法是构造一个判断表行对应分解后的关系模式列对应属性。如果某属性属于某关系模式就在交叉位置放入该属性符号。然后不断利用原有函数依赖把表中符号统一。最后如果出现一行全是属性符号就说明分解是无损的。这个方法也叫 chase 过程考试中可以直接按照这个流程执行。如果不想背算法可以记住一些常见结论当分解后的两个关系模式交集包含其中一个关系模式的候选码时这一步连接就是无损的。多个关系分解时可以两两合并判断。比如 R(A,B,C) 拆成 AB 和 AC已知函数依赖 A→B交集是 A而 A 在 AB 中是候选码因此无损。5.2 依赖保持约束不能被拆丢另一个前提是“函数依赖保持”原函数依赖集合 F 里的每个依赖最好都能在某个分解后的关系模式里继续成立或者能通过分解后模式的依赖集合重新推导出来。为什么要关注这一点因为函数依赖本质上是一种数据完整性约束。如果拆表把 A→B 丢了那就无法通过数据库约束来保证 A 和 B 的一致性只能靠应用代码手工维护。时间一长只要某个接口忘记处理数据就乱了。举个例子。R(A,B,C)函数依赖为 A→B, B→C。如果拆成 AB 和 BC两个依赖分别保留在这两张表里依赖保持成立如果拆成 AC 和 AB则 B→C 丢失因为出现在 AC 表中的是 A 和 C无法直接表达 B。这种拆法考试时常常用来构造“有意丢失依赖”的选项。无损连接和依赖保持并不总是能同时满足。最典型的就是 4.1 里的学生, 课程, 教师例子拆到 BCNF 时保不住函数依赖。很多教材会强调3NF分解算法既能保证无损连接也能保证依赖保持BCNF分解算法只能保证无损连接依赖保持不一定。这个结论我面试时被问过不止一次。5.3 手边可用的两种分解思路工程上我不会每次都用 chase 表去验证但两类主流算法的思路值得掌握。第一类是 3NF 分解的合成算法。步骤可以概括为求函数依赖集合 F 的最小覆盖去重、去冗余依赖、去左侧多余属性。把所有依赖按左侧分组每组生成一个关系模式 XY。如果所有关系模式的属性并集不包含某个候选码额外补一张候选码表。合并相互包含的关系模式。我一般会把这个过程理解为“按依赖的左侧建表”。这种方式天然保证依赖保持再加一步候选码表就能保证无损。第二类是 BCNF 分解算法思路更暴力找到违反 BCNF 的依赖 X→Y将原表拆成 XY 和 (R - Y) ∪ X 两半然后递归检查每一半。优点是过程机械缺点是可能失去函数依赖。所以我通常用它来做验证而不是直接作为生产表的设计准则。5.4 结合第3节的教务表验证一下回到第3节最终拆出的四张表R11(学号, 姓名, 专业) R12(专业, 系名) R13(系名, 系主任) R2(学号, 课程号, 成绩)先看是否无损R11 和 R12 的共同属性是专业而 R12 中专业是候选码R11 与 R12 连接后再与 R13 连接共同属性是系名且 R13 中系名是候选码最后加入 R2共同属性是学号而 R11 中学号是候选码。每一步都是无损所以整个分解无损。再看依赖保持学号→姓名、学号→专业在 R11 中专业→系名在 R12 中系名→系主任在 R13 中(学号,课程号)→成绩在 R2 中。原依赖集合的每一条都能在某张表里找到依赖保持成立。这个例子其实非常有示范意义好的规范化分解每一步都能回答“我为什么这样拆”而不是凭感觉。6. 这一章做题和实战中最容易翻车的几个地方6.1 候选码没找全后面全盘皆输找候选码是这一章的起点。很多人把主键当作唯一候选码忽略了其他可能组合导致后面判断 2NF、3NF 全都出错。我印象最深的一道题是这样的R(A,B,C)函数依赖为 A→B, C→B。有人下意识选 A 当关键属性却忽略了 (A,C) 也能决定全部属性其实候选码应该是 AC。判断非主属性时单看 A 的闭包根本不够。应对办法很简单先把 L 属性找出来再逐个尝试加入 R 属性组合保证每个候选码都验证完。宁可多做几次闭包不要偷懒。6.2 闭包算法迭代不完整判错传递依赖闭包算法要认真迭代到“闭包不再变大”为止不能扫一遍就收工。因为新增属性可能会让下一条依赖的左侧得以满足这正是第2节强调的坑。我之前帮人看一份作业他算 A 时漏掉了经过中间属性才触发的依赖结果也没看出来表里有传递依赖最后把本应拆掉的表保留了下来。传递依赖经常藏在“A→B, B→C”这种链式关系中只有完整跑完闭包才会暴露。6.3 3NF和BCNF判定条件记反3NF 的完整判定条件是每个非平凡函数依赖 X→A满足 X 是超码或者 A 是主属性。BCNF 则要求 X 必须是超码没有“A是主属性”这个豁免项。这个区别非常小不看例子很容易混淆。我建议把4.1 的那个例子背下来所有属性都是主属性所以表满足3NF却能轻松找到一个违反BCNF的依赖。每次考试前我只看这一个例子就能把两者的差异照清楚。6.4 实战里别为“高阶范式”硬拆表最后说说真实的项目经验。很多人学完这一章一看到某张表有一点冗余就恨不得拆到 BCNF 甚至 4NF。但工程里面表查询要权衡拆得越细业务查询往往要关联越多而且有时为了满足极端范式反而不容易保住函数依赖需要应用层大量兜底。我自己的实践标准是先保证达到3NF再看是否存在明显的多余存储或更新异常如果某张表的查询量极大、拆分后需要反复连表那可能在性能上更吃亏。范式是设计质量的重要工具但不是唯一目标。真正重要的是你清楚自己做了什么选择以及为什么这样选。关系数据理论这一章如今回头看其实是一门“想清楚再建表”的课。如果你正在准备考试建议按“候选码 → 闭包 → 范式判断 → 分解验证”这条线复习多做几道完整流程的题如果你已经工作遇到历史遗留的烂表也可以拿这一章的思路重新梳理一遍很多“数据怎么又对不上了”的困惑根源往往都在函数依赖和范式的设计决定上。