数据库三大范式是什么?追问 1:范式设计是为了解决什么问题? 追问 2:范式设计有什么缺点?
可以把数据库范式理解成:整理宿舍的规则。
刚搬进宿舍时,衣服、书、零食、充电器全堆在床上,拿东西很方便,但时间一长就会出现三个问题:
- 同样的东西到处重复;
- 改一个地方,其他地方忘了改;
- 扔掉一件东西时,不小心把有用信息也一起扔了。
数据库范式做的事情,就是把这张“万能大表”拆成几张职责明确的小表。
我们用大学生最熟悉的学生选课系统来解释。
一、先看一张设计很差的表
假设学校把所有信息都塞进一张表:
| 学号 | 学生姓名 | 手机号 | 课程编号 | 课程名称 | 教师姓名 | 教师电话 | 成绩 |
|---|---|---|---|---|---|---|---|
| 1001 | 张三 | 1380001 | C01 | 数据库 | 王老师 | 1391001 | 85 |
| 1001 | 张三 | 1380001 | C02 | Java | 李老师 | 1391002 | 90 |
| 1002 | 李四 | 1380002 | C01 | 数据库 | 王老师 | 1391001 | 78 |
| 1003 | 王五 | 1380003 | C01 | 数据库 | 王老师 | 1391001 | 92 |
乍一看挺方便:查学生、课程、老师、成绩,都在一张表里。
但问题已经出现了。
张三的信息重复了两次:
张三、1380001
数据库课程的信息重复了三次:
C01、数据库、王老师、1391001
学生越多、选课越多,重复数据就越严重。
范式就是一步一步拆掉这些不合理的重复。
二、第一范式:每个格子只放一个值
第一范式,简称 1NF。
核心要求是:
表中的每一列都应该是不可再拆分的单一值,不能在一个单元格中存放多个同类数据。
一个违反第一范式的例子
假设学生表这样设计:
| 学号 | 姓名 | 手机号 | 所选课程 |
|---|---|---|---|
| 1001 | 张三 | 1380001 | 数据库、Java、操作系统 |
| 1002 | 李四 | 1380002 | 数据库、计算机网络 |
问题在于:
所选课程 = 数据库、Java、操作系统
一个格子里放了三个课程。
这会导致查询非常麻烦。
例如,想查询“所有选择数据库课程的学生”,可能只能写模糊匹配:
SELECT *
FROM student
WHERE courses LIKE '%数据库%';
这样做不仅效率低,还可能误匹配。
更麻烦的是,如果课程名称中本身包含逗号,或者使用了不同分隔符,处理起来就更混乱。
符合第一范式的设计
把一名学生的每门课程拆成独立的一行:
| 学号 | 姓名 | 手机号 | 课程 |
|---|---|---|---|
| 1001 | 张三 | 1380001 | 数据库 |
| 1001 | 张三 | 1380001 | Java |
| 1001 | 张三 | 1380001 | 操作系统 |
| 1002 | 李四 | 1380002 | 数据库 |
| 1002 | 李四 | 1380002 | 计算机网络 |
现在每个格子只有一个值。
查询数据库课程的学生就很简单:
SELECT *
FROM student_course
WHERE course_name = '数据库';
现实生活类比
假设食堂点餐单上有一个“菜品”栏。
不规范的写法是:
宫保鸡丁、米饭、可乐
全部挤在同一个格子里。
规范的写法是每种菜品单独记录:
订单 001:宫保鸡丁
订单 001:米饭
订单 001:可乐
这样才能方便地统计:
- 宫保鸡丁卖了多少份;
- 哪些订单买了可乐;
- 米饭今天的销售额是多少。
第一范式解决什么问题
主要解决的是:
- 一个字段保存多个值;
- 数据难以查询;
- 数据难以排序和统计;
- 数据格式不统一。
不过,仅仅满足第一范式还远远不够。
因为刚才拆成多行后,张三的姓名和手机号还是重复了很多次。
这就需要第二范式。
三、第二范式:非主键字段必须依赖整个主键
第二范式,简称 2NF。
它必须先满足第一范式,然后要求:
表中的非主键字段,必须完全依赖整个主键,不能只依赖联合主键的一部分。
这句话听起来有点绕,我们慢慢拆开。
什么是联合主键
在选课表中,单独使用学号不能唯一确定一条记录。
因为一个学生可以选择多门课程:
1001:数据库
1001:Java
单独使用课程编号也不能唯一确定一条记录。
因为一门课程可以被多个学生选择:
1001:数据库
1002:数据库
所以需要把:
学号 + 课程编号
放在一起,才能唯一确定一条选课记录。
这个组合就是联合主键。
例如:
1001 + C01
代表张三选择了数据库课程。
现在来看这张表:
| 学号 | 课程编号 | 学生姓名 | 手机号 | 课程名称 | 教师姓名 | 成绩 |
|---|---|---|---|---|---|---|
| 1001 | C01 | 张三 | 1380001 | 数据库 | 王老师 | 85 |
| 1001 | C02 | 张三 | 1380001 | Java | 李老师 | 90 |
| 1002 | C01 | 李四 | 1380002 | 数据库 | 王老师 | 78 |
它的联合主键是:
学号 + 课程编号
接下来分析每个字段依赖谁。
学生姓名依赖谁?
只要知道学号,就能知道学生姓名:
学号 1001 → 张三
不需要知道课程编号。
所以:
学生姓名只依赖学号
手机号依赖谁?
也是只依赖学号:
学号 1001 → 1380001
课程名称依赖谁?
只要知道课程编号,就能知道课程名称:
课程编号 C01 → 数据库
不需要知道学号。
教师姓名依赖谁?
在这个简化例子中,假设一门课程由一位老师负责,那么教师姓名只依赖课程编号。
成绩依赖谁?
成绩必须同时知道学生和课程。
张三的数据库成绩是 85,张三的 Java 成绩是 90;李四的数据库成绩是 78。
所以:
学号 + 课程编号 → 成绩
成绩依赖完整的联合主键。
问题在哪里
联合主键是:
学号 + 课程编号
但:
学生姓名、手机号只依赖学号
课程名称、教师姓名只依赖课程编号
它们都只依赖联合主键的一部分。
这叫做:
部分依赖。
违反第二范式。
四、如何改造成第二范式
把不同职责的数据拆开。
学生表
student
| 学号 | 学生姓名 | 手机号 |
|---|---|---|
| 1001 | 张三 | 1380001 |
| 1002 | 李四 | 1380002 |
主键是学号。
学生姓名和手机号都依赖学号。
课程表
course
| 课程编号 | 课程名称 | 教师姓名 |
|---|---|---|
| C01 | 数据库 | 王老师 |
| C02 | Java | 李老师 |
主键是课程编号。
课程名称和教师姓名都依赖课程编号。
选课表
enrollment
| 学号 | 课程编号 | 成绩 |
|---|---|---|
| 1001 | C01 | 85 |
| 1001 | C02 | 90 |
| 1002 | C01 | 78 |
联合主键是:
学号 + 课程编号
成绩依赖这两个字段共同组成的主键。
现在每张表都只负责一件事:
- 学生表负责学生信息;
- 课程表负责课程信息;
- 选课表负责学生和课程之间的关系以及成绩。
现实生活类比
想象学校以前使用一本巨大的登记册。
每当张三选择一门课,就要重新抄一遍:
张三、手机号、家庭地址、专业、课程、教师、成绩
张三选十门课,个人信息就抄十遍。
第二范式相当于告诉教务处:
学生的个人档案只保存一份。选课登记表只写学号,不要每次都重新抄学生的全部资料。
就像坐飞机时,航空公司不会在每张行李标签上重新打印你的完整身份证档案,只写一个能关联到你的编号。
五、第三范式:非主键字段之间不能互相决定
第三范式,简称 3NF。
它必须先满足第二范式,然后要求:
非主键字段不能依赖另一个非主键字段,也就是不能存在传递依赖。
我们继续看课程表:
| 课程编号 | 课程名称 | 教师编号 | 教师姓名 | 教师电话 |
|---|---|---|---|---|
| C01 | 数据库 | T01 | 王老师 | 1391001 |
| C02 | Java | T02 | 李老师 | 1391002 |
| C03 | 操作系统 | T01 | 王老师 | 1391001 |
主键是课程编号。
从课程编号可以找到教师编号:
课程编号 → 教师编号
从教师编号又可以找到教师姓名和电话:
教师编号 → 教师姓名、教师电话
所以形成了这样一条依赖链:
课程编号
→ 教师编号
→ 教师姓名、教师电话
课程编号并不是直接决定教师电话,而是通过教师编号间接决定。
这就叫:
传递依赖。
为什么这是问题
王老师同时教授数据库和操作系统。
于是王老师的信息需要重复保存两次:
T01、王老师、1391001
假设王老师换了电话号码。
管理员必须修改两行:
数据库课程中的电话号码
操作系统课程中的电话号码
如果只修改了一行,就出现了:
同一个王老师,有两个电话号码
数据库自己都不知道哪个是真的。
六、如何改造成第三范式
把教师信息单独拆成教师表。
课程表
| 课程编号 | 课程名称 | 教师编号 |
|---|---|---|
| C01 | 数据库 | T01 |
| C02 | Java | T02 |
| C03 | 操作系统 | T01 |
教师表
| 教师编号 | 教师姓名 | 教师电话 |
|---|---|---|
| T01 | 王老师 | 1391001 |
| T02 | 李老师 | 1391002 |
现在:
- 课程表只保存课程信息;
- 教师表只保存教师信息;
- 课程表通过教师编号关联教师表。
王老师换电话时,只需要修改一处:
UPDATE teacher
SET phone = '1399999'
WHERE teacher_id = 'T01';
所有由王老师教授的课程,都能查询到新的号码。
现实生活类比
假设每个班级的课表上都写着老师的姓名、电话号码、家庭地址和办公室号码。
一位老师教五个班,就要在五张课表中重复写五遍。
老师换办公室时,需要修改五张课表。少改一张,就有人跑错办公室。
第三范式的做法是:
课表只写教师编号,老师的详细资料放在教师通讯录里。
这样教师资料只保存一份。
七、把三大范式放在一起理解
可以用三句话记忆:
第一范式
一个格子只放一个值。
解决“一个字段塞了多个数据”的问题。
例如不能写:
课程:数据库、Java、操作系统
应该拆成多条记录。
第二范式
每个非主键字段都要依赖整个主键。
重点处理联合主键中的部分依赖。
例如选课表的主键是:
学号 + 课程编号
成绩依赖两者,但学生姓名只依赖学号,所以学生姓名应该放进学生表。
第三范式
非主键字段不能依赖另一个非主键字段。
例如:
课程编号 → 教师编号 → 教师电话
教师电话应该放进教师表,而不是放在课程表里。
八、范式设计到底是为了解决什么问题
范式设计最重要的目标,是:
减少数据冗余,避免数据不一致,并解决数据增删改时出现的异常。
这些异常通常分成三类。
1. 更新异常
原表中,王老师的信息重复出现很多次。
| 课程 | 教师 | 电话 |
|---|---|---|
| 数据库 | 王老师 | 1391001 |
| 操作系统 | 王老师 | 1391001 |
| 数据结构 | 王老师 | 1391001 |
王老师换电话时,需要修改三行。
如果漏改了一行:
| 课程 | 教师 | 电话 |
|---|---|---|
| 数据库 | 王老师 | 1399999 |
| 操作系统 | 王老师 | 1399999 |
| 数据结构 | 王老师 | 1391001 |
同一位老师出现两个电话。
这就是更新异常。
规范化以后,教师信息只保存一份,修改一次即可。
2. 插入异常
假设学校新招聘了赵老师,但他暂时还没有安排课程。
如果教师信息只能存放在课程表中:
| 课程编号 | 课程名称 | 教师姓名 | 教师电话 |
|---|
那么没有课程编号,就无法录入赵老师。
这很奇怪:
明明学校已经有这位老师,却因为他还没有教课,所以数据库不允许保存他。
这就是插入异常。
拆出教师表以后,即使赵老师没有课程,也可以先录入:
| 教师编号 | 教师姓名 | 教师电话 |
|---|---|---|
| T03 | 赵老师 | 1391003 |
3. 删除异常
假设李老师目前只教授 Java。
课程表中只有这一条记录:
| 课程编号 | 课程名称 | 教师姓名 | 教师电话 |
|---|---|---|---|
| C02 | Java | 李老师 | 1391002 |
现在学校取消 Java 课程。
删除这条课程记录时,李老师的信息也跟着全部消失了。
但现实中:
课程取消了,不代表老师离职了。
这就是删除异常。
拆分教师表以后,删除课程只会删除课程信息,不会删除教师档案。
九、范式设计的缺点是什么
范式不是越高越好。
它解决了重复和一致性问题,但代价是:
表变多了,查询变复杂了,关联成本也提高了。
缺点一:查询需要更多 JOIN
没有拆表时,查询学生成绩可能很简单:
SELECT *
FROM student_course_info;
规范化后,需要连接多个表:
SELECT
s.student_name,
c.course_name,
t.teacher_name,
e.score
FROM enrollment e
JOIN student s
ON e.student_id = s.student_id
JOIN course c
ON e.course_id = c.course_id
JOIN teacher t
ON c.teacher_id = t.teacher_id;
这条 SQL 同时连接了:
- 选课表;
- 学生表;
- 课程表;
- 教师表。
对于初学者来说,理解和维护难度会提高。
缺点二:复杂查询可能变慢
每次查询都要进行多表关联。
如果表中有几千万条数据,而索引设计又不好,多个 JOIN 可能消耗较多资源。
例如电商首页要展示:
- 商品名称;
- 品牌名称;
- 店铺名称;
- 当前价格;
- 销量;
- 商品分类;
- 活动信息。
如果所有信息都严格拆成很多张表,每次打开页面都进行十几张表的关联,性能可能不理想。
所以实际项目中,可能会适当保存一些重复字段。
例如订单明细中,通常会保存购买当时的商品名称和价格:
| 订单号 | 商品编号 | 商品名称快照 | 成交价格 |
|---|---|---|---|
| O001 | P001 | 黑色双肩包 | 199 |
即使商品表中的商品后来改名或者涨价,历史订单仍然应该显示购买当时的信息。
这种冗余不是设计失误,而是有业务目的的反范式设计。
缺点三:表太多,开发和维护更复杂
假设把每个概念都拆得特别细:
学生表
学生姓名表
学生电话表
电话号码类型表
学生地址表
省份表
城市表
区县表
街道表
只是查询一名学生的信息,可能就要关联七八张表。
理论上很规范,实际开发人员看了可能会头疼。
这叫:
过度规范化。
范式应该帮助业务,而不是让数据库变成迷宫。
缺点四:统计分析不方便
业务系统通常喜欢规范化,因为需要频繁增删改。
但数据报表和数据仓库往往更喜欢宽表。
比如校长想看一张报表:
| 学生 | 专业 | 课程 | 教师 | 成绩 | 学期 |
|---|
如果数据分散在很多表中,每次统计都需要复杂关联。
因此数据仓库经常会使用星型模型,允许一定的数据冗余,从而提高统计查询效率。
十、现实项目中应该怎么选择
可以把数据库设计分成两个目标。
业务系统:更重视一致性
例如:
- 银行账户;
- 用户信息;
- 商品库存;
- 教务系统;
- 医院挂号系统。
这些系统经常修改数据,非常害怕出现“不一致”。
通常会尽量遵循第三范式。
查询和报表系统:更重视读取速度
例如:
- 销售大屏;
- 经营分析报表;
- 推荐系统特征表;
- 数据仓库宽表。
这些系统主要用于查询,更新相对较少。
可以适当反范式化,用一定的数据冗余换取更快的查询速度。
所以真实工程中不是简单地说:
范式越高越好
而是:
在数据一致性、开发复杂度和查询性能之间做平衡。
十一、面试回答版本
面试时可以这样回答:
第一范式要求字段具有原子性,也就是一个字段只保存一个值,不能保存多个同类数据。
第二范式在第一范式的基础上,要求非主键字段完全依赖整个主键,不能只依赖联合主键的一部分,主要用于消除部分依赖。
第三范式在第二范式的基础上,要求非主键字段不能依赖其他非主键字段,主要用于消除传递依赖。范式设计的目的,是减少数据冗余,避免数据不一致,并解决更新异常、插入异常和删除异常。
它的缺点是会把数据拆分到更多表中,使 SQL 中的
JOIN增多,查询和开发复杂度提高,有时也会影响查询性能。因此实际项目中通常以第三范式为基础,再根据性能和业务需求适当进行反范式设计。
最后记住这个顺口溜:
一范式:一个格子一个值。
二范式:依赖主键要完整。
三范式:非主键之间别传递。
浙公网安备 33010602011771号