去年帮一个学弟复习数据库期末考试,他抱着范式那章的习题册愁眉苦脸,说定义背得滚瓜烂熟,一拿到新表还是不知道从哪下手。我问他拿到题目第一步干什么,他说“看它属于第几范式”。问题就出在这——数据库范式例题的正确打开方式,不是先给表定级,而是先做“底层排查”。
这篇东西就想解决这一个问题:把范式判断从“背定义”变成“按流程执行”。我会用一组有代表性的数据库范式例题,把从1NF到BCNF的判断、分解、验证过程完整拆开,每一步用的什么逻辑、踩过哪些坑、考试怎么答才不丢分,统统讲清楚。备考期末、准备面试、或者自学数据库设计的朋友,都能直接照着这套流程上手。
1. 范式不是背出来的:先用一个真实反例理解“为什么拆表”
很多人学范式觉得抽象,是因为把范式当成了“数据库的伦理道德”,觉得它是强加的一套规矩。实际上范式完全是实用主义的产物——它解决的是数据冗余和更新异常这两个真实到不能再真实的存储问题。我们先不讨论理论,只看一张设计得很差劲的表。
1.1 一张“填鸭式”订单表,藏着三类隐患
假设你要做一个电商系统,为了省事,把订单全部信息塞进一张表:
订单号、客户名、客户电话、商品名、商品价格、数量
同一客户下了三个订单,买了三件不同商品,那么客户名和客户电话会在三行里重复存储。这时候问题就来了:
- 修改异常:这位客户换手机号了,你得把涉及他的所有行全部改一遍,漏改一行,数据就前后矛盾。这叫更新异常。
- 插入异常:一个新客户刚注册,还没下单,他的信息根本没法录入,因为订单号是主键,主键不能为空。这叫插入异常。
- 删除异常:某个客户只下过一单,后来订单被取消了,你删除这条订单记录,客户的基本信息也跟着一起没了。这叫删除异常。
范式理论就是针对这类问题给出的整改方案,每一个等级处理一类问题。
1.2 范式等级的本质:一张“问题排查清单”
从这里开始,请你把范式理解成一份“逐级排查清单”,而不是一堆孤立定义:
- 1NF:表中每个属性都不可再分,所有属性都是原子的。这是数据库表的最基本底线。
- 2NF:在1NF基础上,消除非主属性对候选码的部分函数依赖。
- 3NF:在2NF基础上,消除非主属性对候选码的传递函数依赖。
- BCNF:在3NF基础上,要求每一个函数依赖的左边都必须包含候选码(也就是左边必须是超码)。
注意这个层级关系:满足BCNF一定满足3NF,满足3NF一定满足2NF,依此类推。反过来不成立。很多同学把“3NF比2NF要求更严格”理解成“3NF和2NF是两个独立的东西”,这是考试丢分的第一步。
再打个比方。如果候选码是一把能开全屋锁的总钥匙,那么:
- 2NF查的是:有没有某把房间钥匙脱离了总钥匙,单独决定了一个非主属性?(部分依赖)
- 3NF查的是:有没有通过“总钥匙→中间属性→非主属性”这种间接链条来开门的情况?(传递依赖)
- BCNF查的是更狠的:就算你决定的是主属性,只要你的钥匙不是总钥匙,一律不许通过。
目标明确之后,范式判断就有章法了。核心方法论都在下一节。
2. 范式判断标准动作:候选码、主属性、函数依赖三步走
拿到一道范式题,不管题干多长、表多复杂,按三步走准没错。这三步是:写全函数依赖、求候选码、逐级排查。大部分学生做错题,不是概念不会,而是这三步里的基本功出了问题。
2.1 第一板斧:把函数依赖写全、写对
函数依赖用大白话说就是:知道了X的值,就能唯一确定Y的值,记作X→Y。就像身份证号能唯一确定姓名,这就是“身份证号→姓名”。如果你知道X但无法唯一确定Y,那X→Y就不成立。
写函数依赖集是整道题的基石,这里有两个容易犯的错误。
第一个:漏写依赖。比如题目里给了“每个学生属于一个系,每个系只有一个系主任”,有些同学只写“学号→系名”,忘了“系名→系主任”。这个依赖不写出来,后面的传递依赖判断就全瞎了。
第二个:写了多属性依赖但不拆。比如(学号,课程号)→成绩,这是对的,因为成绩要靠学号和课程号两个一起才能确定。但有些同学会把(学号,课程号)→姓名也写上,这就错了——姓名只要学号就能确定,不需要课程号。多属性依赖的前提是左边任何一个真子集都无法确定右边属性,否则就是冗余依赖,需要拆开或删掉。
判断函数依赖有个实用技巧:从业务语义出发,而不是从数据行出发。一份数据里碰巧没有重复,不代表依赖关系成立;反过来,一份数据里碰巧有重复,也可能是样本问题。要依据题目给定的语义约束来写。
2.2 第二板斧:用属性分类法求候选码
候选码求错,整个范式判断直接从根上崩掉。求候选码有标准方法,叫属性分类法,先给所有属性分四类:
- L类:只出现在函数依赖左边,从不出现在右边。这类属性必属于候选码。
- R类:只出现在函数依赖右边。这类属性必不属于候选码。
- LR类:既出现在左边,又出现在右边,待定。
- N类:不出现在任何函数依赖中。这类属性必属于候选码。
一个简单例子的完整计算过程:
有关系R(A,B,C,D),函数依赖集F={B→D, D→C},求候选码。
第一步,分类。属性A不出现在任何依赖里,属于N类,必在候选码中。属性B只出现在B→D左边,属于L类,必在候选码中。属性C只出现在D→C右边,属于R类,必不在候选码中。属性D既出现在B→D右边,又出现在D→C左边,属于LR类,待定。
第二步,拿着已知必选的属性求闭包。闭包就是从已知属性出发,沿着所有能推出的依赖不断扩展可达的属性集合。当前已知A和B必选,计算{A,B}的闭包:由B→D,D可加入;由D→C,C可加入。最终{A,B}的闭包等于全集{A,B,C,D},所以候选码就是AB。
这个例子一次就凑齐了,属于运气好。更多时候会遇到{A,B}的闭包凑不齐全集的情况,这时就要从LR类属性里逐个尝试加入。举一个稍复杂的例子:
关系R(A,B,C,D,E),函数依赖集F={A→BC, CD→E, B→D, E→A},求候选码。
分类一下。C只出现在CD→E左边,属于L类,必选。A、B、D、E四个属性都既出现在左部又出现在右部,属于LR类。N类没有。
先算{C}的闭包:只有C本身,不够。接下来依次尝试加入LR类属性。
加A,{A,C}的闭包:根据A→BC,B和C已可推出;由B→D,D可推出;由CD→E,E可推出。闭包达到全集,所以AC是候选码。
加E,{C,E}的闭包:由E→A,A可推出;由A→BC,B和C可推出;由B→D,D可推出。闭包也达到全集,所以CE是候选码。
加B,{B,C}的闭包:由B→D,D可推出,但A和E推不出来,不够。加D,{C,D}的闭包:由CD→E,E可推出,但A和B推不出来,不够。
于是得到候选码{AC, CE}。主属性是A、C、E,非主属性是B、D。
这个例子完整展示了LR类属性的试凑流程,是非常典型的训练题。
2.3 第三板斧:逐级排查部分依赖和传递依赖
候选码一出来,主属性、非主属性自然就清晰了。接下来按钉子户式排查:
第一步,判断1NF。看看有没有属性还能再拆分。比如“联系方式”里既装手机号又装邮箱,这就违反1NF。现在绝大多数表设计默认满足1NF,考试也基本不会卡在1NF上。
第二步,判断2NF。找出所有非主属性,看它们是否依赖于候选码的整体,是否依赖于候选码的某个真子集。如果存在“候选码的真子集→非主属性”的依赖,就存在部分依赖,不满足2NF。
第三步,判断3NF。在候选码是单属性的情况下(单个属性当然不会再有真子集),2NF自动满足。这时要检查传递依赖:是否存在非主属性Z,以及中间属性Y,满足“候选码→Y→Z”,而且Y不能决定候选码,Z是非主属性。一旦存在,就不满足3NF。
第四步,判断BCNF。看所有函数依赖的左边,是不是都包含候选码。只要有一个依赖的左边不是超码,就不满足BCNF。这一步不看右边是主属性还是非主属性,一视同仁。
四步检查完,答案自然出来。下面用三道例题把整条流程跑一遍,你会发现范式题根本不玄乎,就是“查字典”式操作。
3. 数据库范式例题精讲:从1NF到BCNF的完整判断流程
这一节我会带大家完整跑三道题,难度依次递增。每道题都按“先写依赖、再求候选码、逐级判断”的顺序走,重点展示中间的推导过程。
3.1 例题一:考试成绩表,连2NF都不满足
题目给出一张成绩表:
R(学号, 姓名, 系名, 课程号, 课程名, 成绩)
语义约束:一个学生属于一个系,一门课程只有一个课程名,一个学生选一门课得到一个成绩。
第一步,写函数依赖集:
- 学号→姓名
- 学号→系名
- 课程号→课程名
- (学号,课程号)→成绩
第二步,求候选码。先看属性分类。学号出现在左部也出现在右部(学号→姓名右边有学号吗?没有,学号→姓名右边是姓名,学号→系名右边是系名,课程号→课程名右边是课程名,(学号,课程号)→成绩右边是成绩,学号从不出现在任何右边),所以学号属于L类。课程号也是L类。姓名、系名、课程名、成绩都只出现在右边,属于R类。
L类属性学号和课程号组合求闭包,{学号,课程号}的闭包可以推出姓名、系名、课程名、成绩,正好凑齐全集。候选码是(学号,课程号)。主属性是学号、课程号,非主属性是姓名、系名、课程名、成绩。
第三步,逐级判断。1NF默认满足。检查2NF:学号是候选码(学号,课程号)的真子集,但学号→姓名成立,这意味着非主属性姓名依赖于候选码的一部分,存在部分函数依赖。同样,课程号→课程名也属于非主属性对候选码真子集的部分依赖。因此不满足2NF。
结论:R属于1NF。
这个例子是最常见的送分题,也是最早让学生意识到“候选码是两列”的启蒙题。只要候选码不止一个属性,就得立刻警觉部分依赖。
3.2 例题二:学生班级辅导员,卡在3NF门口的经典案例
题目给出一张学生信息表:
R(学号, 姓名, 班级, 辅导员)
语义约束:每个学生有唯一姓名,每个学生属于一个班级,每个班级配备一名辅导员。注意,一位辅导员可能带多个班级,但这里题目明确是“一个班级一名辅导员”,所以依赖是班级→辅导员,而不是辅导员→班级。
第一步,函数依赖集:
- 学号→姓名
- 学号→班级
- 班级→辅导员
第二步,求候选码。学号只出现在依赖左边,属于L类。姓名、班级、辅导员的情况:班级出现在学号→班级的右边,也出现在班级→辅导员的左边,属于LR类;姓名和辅导员只出现在右边,属于R类。
因为学号属于L类必选,先算{学号}的闭包:由学号→姓名,姓名加入;由学号→班级,班级加入;再由班级→辅导员,辅导员加入。闭包已是全集,候选码就是学号。
第三步,逐级判断。候选码是单个属性,不存在真子集,自然没有部分依赖,所以满足2NF。但检查3NF时发现:学号→班级,班级→辅导员,学号通过班级这个中间属性间接确定了辅导员,而且班级不能决定学号,辅导员是非主属性。典型的传递依赖,所以不满足3NF。
结论:R属于2NF。
把表实际填几行数据就能直观看到问题:同一个班的十个学生,十行里辅导员重复了十次。解决思路就是拆表:R1(学号,姓名,班级)和R2(班级,辅导员),分别存学生基本信息和班级辅导员映射。
3.3 例题三:城市街道邮编,3NF与BCNF的分水岭
这道题几乎是所有数据库教材里区分3NF和BCNF的必选案例。
R(城市, 街道, 邮编)
语义约束:一个城市、一条街道确定唯一邮编;同一个邮编必然对应同一个城市。注意这里没有“一个邮编唯一对应一条街道”,一个邮编通常包含多条街道。
第一步,函数依赖集:
- (城市,街道)→邮编
- 邮编→城市
第二步,求候选码。先分类。城市出现在(城市,街道)→邮编左边,也出现在邮编→城市右边,属于LR类。街道只出现在(城市,街道)→邮编左边,属于L类。邮编只出现在(城市,街道)→邮编右边?不对,邮编出现在(城市,街道)→邮编右边,也出现在邮编→城市左边,所以邮编属于LR类。街道是L类必选。
算{街道}的闭包,只有街道自己,不够。尝试加入城市,{街道,城市}的闭包:由(城市,街道)→邮编,邮编加入,闭包为全集。所以(城市,街道)是一个候选码。
再尝试加入邮编,{街道,邮编}的闭包:由邮编→城市,城市加入,由(城市,街道)→邮编,邮编已在。闭包为全集。所以(街道,邮编)是另一个候选码。
候选码有两个:(城市,街道)和(街道,邮编)。主属性是城市、街道、邮编——所有属性都是主属性。非主属性为空。
第三步,逐级判断。1NF满足。候选码没有真子集非主依赖,满足2NF。检查3NF时,3NF对依赖的要求是:每个依赖X→A,要么X包含候选码,要么A是主属性。看邮编→城市:邮编不包含候选码(邮编单独无法确定街道,所以邮编不是超码),但城市是主属性,所以这条依赖“右边是主属性”的豁免条款生效,满足3NF。
再看BCNF:BCNF对依赖的要求是每个依赖X→A中X必须是超码。邮编→城市中,邮编不是超码,违反BCNF。
结论:R满足3NF,但不满足BCNF。
这就是3NF和BCNF最核心的区别:3NF允许“依赖左边不含候选码”的情况存在,只要右边是主属性就行;BCNF直接一刀切,不允许任何依赖左边缺候选码。这道题把两者的边界切得清清楚楚。
4. 范式分解实操:3NF合成法与BCNF分解法
判断出范式等级只是第一步,考试和面试里真正拉分的题目是:如何把不达标的表分解成符合要求的多个表。分解算法有两个主流方向:BCNF分解和3NF合成,它们的思路截然不同,使用场景也不同。
4.1 BCNF分解的正确姿势:用依赖“撞开”超码检查
BCNF分解法也叫分解法,核心思路是:找到一条违反BCNF的依赖X→A,把表劈成两个模式,其中一个装X和A,另一个装X和除A之外的所有属性,然后递归处理。
用3.3节的城市街道邮编例子:
R(城市, 街道, 邮编),违反BCNF的依赖是邮编→城市。
按规则分解:
- R1(邮编, 城市)
- R2(邮编, 街道)
检查R1:函数依赖邮编→城市,邮编在R1里是候选码,满足BCNF。
检查R2:R2只有邮编和街道两个属性,候选码是(邮编,街道),没有任何非主属性,也不存在依赖左边不含候选码的情况,满足BCNF。
分解完成。验证无损连接性:R1和R2的交集是邮编,而邮编是R1的候选码——两模式交集能唯一标识R1中的一个元组,必然可以在连接时一一对应,不会产生多余的“幽灵行”,所以这是无损分解。
这里我要特别强调一点:BCNF分解一定是无损分解,但不一定保持函数依赖。也就是说,分解后的各模式上函数依赖的并集,可能无法推出原来的全部依赖。最典型的反例是R(A,B,C),函数依赖集F={AB→C, C→B}。R的候选码是AB和AC,C→B中C不含候选码,违反BCNF。分解成R1(A,C)和R2(B,C),R1和R2上都没有非平凡函数依赖,原来的AB→C丢失了。这种分解在数据一致性上是有代价的。所以生产环境里不一定非BCNF不可,3NF往往是更务实的终点。
4.2 3NF无损且保持依赖分解的五个步骤
3NF合成法是一套“保底算法”,保证分解结果既无损,又保持函数依赖。步骤如下:
- 求函数依赖集F的最小函数依赖集(右边都是单属性,左边没有多余属性,没有冗余依赖)。
- 把所有左边相同的函数依赖合并,每一组依赖的属性组成一个模式。
- 如果某个模式的属性集是另一个模式属性集的子集,去掉这个冗余模式。
- 检查候选码:如果候选码没有被任何已有模式包含,单独为候选码增加一个模式。
- 合并所有模式,输出结果。
用一个稍复杂的例题完整走一遍。
R(A,B,C,D,E),函数依赖集F={A→B, A→C, C→D}。求3NF无损且保持依赖分解。
第一步,检查最小依赖集。右边都是单属性,左边的A→B、A→C里没有冗余属性可以删,也没有依赖能被其他依赖推出。已经是极小化状态。
第二步,按左边相同合并。A→B和A→C合成一个模式(A,B,C),C→D单独成为一个模式(C,D)。
第三步,检查候选码。属性A是L类必选,E不在任何依赖中属于N类必选,所以候选码是AE。当前已有模式(A,B,C)和(C,D),属性E没有被任何模式包含,所以需要增加模式(A,E)。
第四步,检查冗余。现有三个模式:(A,B,C)、(C,D)、(A,E),互不包含,保留。
最终分解结果:R1(A,B,C)、R2(C,D)、R3(A,E)。
验证:R1上依赖A→B、A→C成立,候选码A,满足BCNF;R2上依赖C→D,C是候选码,满足BCNF;R3上单属性A和E,没有非平凡依赖,满足BCNF。整个分解无损且保持依赖。
这个例子里的关键教训是:如果漏算了N类属性E,会把候选码求错,分解结果也会跟着出错,所以第四步“候选码单独补模式”很容易被忽略却极其重要。
4.3 验证分解质量:无损连接性和函数依赖保持怎么查
两个分解模式的时候,无损连接性可以直接用“交集是否为其中一个模式的候选码”来判断。多于两个模式时,用表格法(chase算法)更稳妥。
表格法做法不复杂:构造一张行为分解模式、列为属性的表格,每个模式对应的属性列填记号,不属于的留空;然后逐条依赖,把左边相同属性值的行,右边属性值也统一成相同记号。如果最终某一行填满了所有属性,说明分解无损。
保持函数依赖的验证更简单:把分解后每个模式上的函数依赖投影出来,求并集,再判断原依赖集中的每条依赖能否由这个并集的闭包推出。能推出就保持,推不出就丢了。实际考试里,只要用4.2节的3NF合成法,结果天然满足保持依赖,一般不需要再额外验证。
5. 考试和面试中的高频易错点:这些坑我帮你踩过了
最后这部分是我自己教书和改作业时反复遇到的真实错误,也是你最容易丢分的地方。每一条都是血泪总结。
5.1 候选码求错,后面全白算
候选码是整道题的地基。我见过太多学生在一道看似复杂的题里直接“猜”候选码,比如看到A→B就默认A是候选码,完全忽略还有E这种孤立属性。前面已经示范过N类和L类属性的处理,这里再强调一遍:凡是L类和N类属性,都是候选码的“钉子户”,必须先拉进候选码再谈其他。如果L类和N类闭包不够,再逐层尝试LR类属性,不要跳步。
检查候选码求对没有,有个快速自测方法:所有候选码的属性个数应该相等(更准确地说,每个候选码的基数相同),且一个候选码不能是另一个候选码的真子集。
5.2 传递依赖的边界条件,最容易混淆
X→Y,Y→Z,凭这三条能不能判定X→Z是传递依赖?答案是:不一定。必须同时满足Y不能决定X。如果Y→X也成立,那么X和Y互相决定,它们本质上是等价键,X→Z就不算传递依赖。很多教材里的反例都藏这一手。
再补充一个:3NF的判断标准是“不存在非主属性对候选码的传递依赖”。如果依赖链的最终属性是主属性,那这条链不违反3NF,本文3.3节城市街道邮编案例就是活生生的例子。判断前先把主属性和非主属性分好,再对照定义。
5.3 标准化答题流程,这样写不丢分
阅卷和面试官最看重推导过程是否完整。即使你一眼就能看出答案是第几范式,也请按下面流程写:
- 明确写出函数依赖集F。
- 用属性分类法(写清楚L、R、LR、N类)求候选码,列出候选码和主属性集合、非主属性集合。
- 逐级判断,每一步写清楚:是否存在部分依赖?是否存在传递依赖?每条依赖左边是否为超码?给出对应结论。
- 如果题目要求分解,写明用了什么算法,分解结果是什么,再验证无损连接性和依赖保持性。
哪怕最终范式等级判断错了,只要推导过程逻辑严密,阅卷老师也会给步骤分——这是我自己参加统考阅卷时亲眼验证过的规则。
最后再分享一个小技巧:平时练习范式题,不要只做“判断范式等级”这种单一题目,一定要追着自己问“如果不满足,怎么拆”。把拆表思路想清楚,考试时即使题目换个问法也难不倒你。数据表重构这件事,熟练之后会在真正的项目设计里让你少走不少弯路。