从重复数据理解第二范式、第三范式和 BC 范式
第一范式还能看见:一个格子里塞了好几个值。到了第二、第三范式,表面上每个格子都规规矩矩,问题却藏在「谁决定谁」里面。
我以前记范式,总能背出「完全依赖」「传递依赖」,一到真实表就不知道该拆哪里。后来换了一个问题:这条事实描述的是谁?如果它变了,我需要改几行? 顺着这个问题,几个范式反而容易区分。
本文默认读者已经理解第一范式,只补一个约定:下面的「A 决定 B」,指在业务规则下,A 的值相同,B 的值就必须相同,记作 A → B。它不是从当前几行样本里猜出来的规律。
从一张选课表开始
假设一名学生可以选多门课,一门课也有多名学生;每名学生在每门课只有一条成绩记录。
| 学号 | 课程号 | 学生姓名 | 课程名 | 成绩 |
|---|---|---|---|---|
| 01 | C1 | 小李 | 数据库 | 90 |
| 01 | C2 | 小李 | 网络 | 85 |
| 02 | C1 | 小王 | 数据库 | 88 |
每个单元格都只有一个值,第一范式看起来没有问题。但假设「数据库」改名为「数据库系统」,两条选课记录都得改;只改一条,同一个课程号就有两个课程名。
这叫更新异常。类似地,课程尚无人选时,若只用这张表,就没地方单独登记课程,叫插入异常;删掉某门课最后一条选课记录时,课程信息也跟着消失,叫删除异常。范式要解决的,正是这些由重复存储引起的问题。
第二范式:一半的键,别决定整块信息
选课记录的候选键是 (学号, 课程号):只给学号,无法确定是哪门课;只给课程号,也无法确定是哪位学生。两者一起才能确定一行。
可是表里有三种依赖:
学号 → 学生姓名
课程号 → 课程名
(学号, 课程号) → 成绩
学生姓名只依赖复合键的一部分,课程名也是。把它们留在选课表,就不得不随每条选课记录重复。第二范式要求:在满足第一范式的前提下,每个非主属性都要完整依赖每个候选键,不能只依赖其中一部分。 这里的「主属性」指属于某个候选键的属性。
拆开后,事实各归其位:
学生(学号, 学生姓名)
课程(课程号, 课程名)
选课(学号, 课程号, 成绩)
学生改名只改学生表一行,课程改名只改课程表一行。成绩仍在选课表,因为一位学生在不同课上的成绩,必须由学号和课程号共同确定。
这也解释了第二范式何时值得特别留意:表有复合候选键时,逐个检查非主属性是否只依赖其中一部分。如果所有候选键都只有一列,就不会出现这种「部分依赖」。不过,单列键的表仍可能违反第三范式。
第三范式:别把别人的属性挂在自己身上
假设学生表又加上专业信息:
| 学号 | 学生姓名 | 专业号 | 专业名 |
|---|---|---|---|
| 01 | 小李 | M1 | 计算机 |
| 02 | 小王 | M1 | 计算机 |
键只有学号,因此没有上一节那种部分依赖。但专业改名时,仍要修改所有属于该专业的学生。业务规则其实是:
学号 → 专业号 → 专业名
专业名描述的是专业,而学生表借着专业号间接存了它。第三范式要求,在满足第二范式的基础上,非主属性不能通过另一个非主属性依赖候选键。于是拆为:
学生(学号, 学生姓名, 专业号)
专业(专业号, 专业名)
这条口语化规则适合眼前这种只有一个简单候选键的例子。更准确的判断方式是:对每条非平凡函数依赖 X → A,要么 X 是超键,要么 A 是主属性。超键就是足以唯一确定一行的属性组合;候选键则是其中不能再删减属性的最小组合。这里 专业号 → 专业名 两边都不满足,所以违反第三范式。
BC 范式:决定别人的,也得能决定整行
第三范式留下一个不太直观的例外。设想排课规则是:一位教师只教一门课程;一名学生在每门课程只跟一位教师。于是有:
| 学生 | 课程 | 教师 |
|---|---|---|
| 小李 | 数据库 | 张老师 |
| 小王 | 数据库 | 张老师 |
| 小李 | 网络 | 王老师 |
这里有两条业务依赖:
(学生, 课程) → 教师
教师 → 课程
(学生, 课程) 能确定一行;由于教师能确定课程,(学生, 教师) 也能确定一行。它们都是候选键,所以「学生」「课程」「教师」三个属性都是主属性。
注意第二条:教师 → 课程。教师不能确定学生,因此不是超键;但「课程」是主属性,所以这张表满足第三范式。问题仍在:张老师所教课程随着学生重复,改课时还得改多行。
BC 范式(BCNF)把第三范式的例外收紧为一句话:每条非平凡函数依赖 X → Y,决定方 X 都必须是超键。 教师 → 课程 不满足,因而要继续拆:
教师授课(教师, 课程)
学生跟课(学生, 教师)
这样「张老师教哪门课」只存一次。这里有个不能略过的代价:拆完后,原来的「一名学生在每门课只跟一位教师」无法仅靠这两张表各自的键直接保证。检查时可能需要连接两表,或者增加额外约束与业务校验。BC 范式也不是无条件地拆得越细越好。
设计时怎么用
我会先写清楚业务规则,再问三件事:
- 一行由什么唯一确定? 列出所有候选键,而不是只看数据库里声明的主键。
- 每个字段到底由什么决定? 看它是否只依赖复合键的一部分,或依赖另一个非主属性。
- 同一事实修改时要改几行? 如果答案是「很多行」,再检查该不该拆表,以及拆后哪些约束会更难保证。
订单明细、选课记录这类复合键表,尤其容易遇到第二范式问题;学生与专业、员工与部门这类主数据关系,容易出现第三范式问题;多个候选键交叠、还有额外业务规则时,才更可能需要检查 BC 范式。大多数日常业务表先做到清楚的第三范式,通常已经能消除主要的更新异常。
拆表的成本也很实在:查询可能需要更多连接,约束可能跨表,写入流程也要安排好。若某个查询确实需要冗余字段,可以有意识地保留,但应明确它的权威来源、同步方式,以及不一致时怎样修复。范式给的是判断依据,不是禁止冗余的命令。
回头看,第二范式在问「是不是只靠半个键就知道了」,第三范式在问「是不是绕过别人知道了」,BC 范式则追问「任何能决定别人的东西,能不能决定整行」。名字可以暂时忘,先找到那条被重复写了许多遍的事实,拆表理由自然就清楚了。
参考资料
- Microsoft Learn:Description of the database normalization basics:关于冗余数据、依赖关系及选课表逐步拆分的入门说明。
版权声明: 本文首发于 指尖魔法屋-从重复数据理解第二范式、第三范式和 BC 范式(https://blog.thinkmoon.cn/post/1043-notes-database-normalization-2nf-3nf-bcnf/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。