三种数据模型:层次(树)、网状(图)、关系(二维表)。现在活下来的只有关系模型。 关系模型把一切装进二维表,代价是「一对多要拆表、多对多要中间表」。 三范式治的是同一种病:一句话存了很多遍。1NF 治复合列,2NF 治部分依赖,3NF 治传递依赖。
教材 1.3 有五个小节:数据模型的概念、常见的数据模型、关系数据库的规范化、 关系数据库的设计原则、实体与关系。前两个是历史,后三个是干活要用的。 这一节是全书唯一讲「表该怎么设计」的地方。后面第 5 章讲建表语法、第 14 章讲完整性约束, 但「为什么这么拆」的答案在这里。 演示里把一张八行的大表拆成三张,然后演一遍「换班主任」:大表要改六行、 改漏一行就出现两个班主任;三表版改一行,而且不可能不一致。
一、三种模型,为什么只剩一种
| 模型 | 结构 | 典型产品 | 为什么不流行了 |
|---|---|---|---|
| 层次模型 | 一棵树,每个节点一个父 | IMS | 只能表达一对多,多对多要冗余 |
| 网状模型 | 一张图,节点可以有多个父 | DBTG | 结构复杂,改一次要改全图 |
| 关系模型 | 二维表,靠外键相连 | SQL Server 等 | 现在是主流 |
前两行的共同毛病是「结构写在程序里」。 层次模型里,你要从一个部门找它下面的员工,得知道「从哪个指针开始往下走」; 一旦部门层级变了,遍历代码就得改。 关系模型的好处是「结构写在数据里」。 表与表怎么连,不写在代码里,写在 SQL 的 JOIN 条件里——所以加一张表、改一个关系,不用改程序结构。 这也是为什么关系模型赢了:它把「数据结构」和「程序结构」解耦了。 教材那句「关系模型建立在严格的数学概念基础上」,说的是同一件事——有集合论撑着,所以能证明、能优化。
二、关系模型的五个词
| 术语 | 日常说法 | 例子 |
|---|---|---|
| 关系 | 表 | tb_student |
| 元组 | 行/记录 | ('20230101', '张伟', ...) |
| 属性 | 列/字段 | sname |
| 域 | 列的取值范围 | sex 的域是 {男, 女} |
| 码 | 能唯一确定一行的属性组 | sno |
第四行「域」是最容易被忽略的。它说的是「这一列允许出现哪些值」——sex 只允许男和女,
score 只允许 0 到 100。
现实里很多表根本没定义域,所以会插进 sex = '未知'、score = 999 这种数据。
把域写进建表语句,就是第 14.4 节讲的「域完整性」。
第五行「码」有两个层次:候选码是「所有能唯一确定一行的属性组」,主码是从中挑一个。
学号能唯一确定一个学生,身份证号也能——两个都是候选码,你挑学号当主码。
没被挑中的那个要加唯一约束,否则会插进两个身份证号相同的学生。
三、三范式,一级治一种病
先看那张没规范化的表:八行,每行是「某学生某门课的成绩」,旁边还带着班级、班主任、办公室。 「计科2301 的班主任是王老师」这句话,在这八行里重复了六遍。
| 范式 | 要求 | 这张表违反在哪 | 怎么治 |
|---|---|---|---|
| 1NF | 每列不可再分 | 如果写成「高等数学:88」挤一列就违反了 | 一列只放一个值 |
| 2NF | 非主码列依赖整个主码 | 主码是(sno, subject),sname 只依赖 sno | 拆出学生表 |
| 3NF | 非主码列之间不能互相依赖 | class → teacher → office | 拆出班级表 |
第二行讲的「部分依赖」,是联合主码才有的毛病。
(sno, subject) 一起才能确定一行成绩,但 sname 只要 sno 就够了——它只依赖主码的一半,
这就是部分依赖。
治疗办法是把「只依赖一半的那些列」搬到另一张表去,用 sno 做主码。
第三行讲的「传递依赖」更隐蔽:class 决定了 teacher,teacher 又决定了 office。
所以 office 是通过 class 间接依赖主码的——链条中间只要有一环会变,整条就要重写。
班主任换办公室,不改班级、不改学生,却要改所有学生的行。这就是病。
三段拆完是三张表:班级表 3 行、学生表 6 行、成绩表 8 行,合计 17 行。行数变多了(8 → 17),
但「一句话存几遍」从 6 遍降到 1 遍。这就是规范化的取舍:用空间和 JOIN 换一致性。
四、范式不是越高越好
演示最后一段:查「张伟的班级和班主任」,三表版要 JOIN 两次。大表版一条 SELECT 就出来了, 一个 JOIN 都不用。
| 写法 | 一次查询要 JOIN 几次 | 改一个事实要改几行 |
|---|---|---|
| 一张大表 | 0 次 | 6 行,改漏就矛盾 |
| 三张小表 | 2 次 | 1 行,不可能矛盾 |
所以第三范式不是教条。真实系统里会故意留冗余,最有名的一种叫「历史快照」。 订单表里存一份「下单时的商品名和单价」——这不是冗余错误,是故意的:商品改名涨价以后, 历史订单必须还能显示当时的名字和价格。 判断标准很实用:如果这份冗余需要「跟着主表同步更新」,那它是病; 如果它本来就该冻结在那一刻,那它是有意为之。 同理,报表库、数据仓库会故意反范式(宽表、星型模型),因为那里追求的是查询快,不是更新一致。 「范式」是给业务库用的,不是给所有库用的。
五、动手 Demo:三范式:一张大表怎么拆成三张
这一段造一张八行的「什么都往里塞」的表,先看「一句话重复六遍」长什么样,然后按 1NF、2NF、 3NF 逐级拆成三张,最后演一遍「计科2301 换班主任」——大表版改漏一行会怎样,三表版改一行又怎样。
import sqlite3
c = sqlite3.connect(u":memory:", isolation_level=None)
c.executescript(u"""
CREATE TABLE tb_raw (
sno TEXT, sname TEXT, class TEXT, teacher TEXT, office TEXT,
subject TEXT, score REAL);
""")
c.executemany(u"INSERT INTO tb_raw VALUES (?,?,?,?,?,?,?)", [
(u"20230101", u"张伟", u"计科2301", u"王老师", u"A301", u"高等数学", 88.0),
(u"20230101", u"张伟", u"计科2301", u"王老师", u"A301", u"大学英语", 76.5),
(u"20230102", u"王芳", u"计科2301", u"王老师", u"A301", u"高等数学", 92.0),
(u"20230102", u"王芳", u"计科2301", u"王老师", u"A301", u"大学英语", 85.0),
(u"20230103", u"李娜", u"计科2301", u"王老师", u"A301", u"高等数学", 61.0),
(u"20230201", u"刘强", u"计科2302", u"李老师", u"B204", u"高等数学", 55.5),
(u"20230202", u"陈静", u"计科2302", u"李老师", u"B204", u"大学英语", 91.0),
(u"20230301", u"杨洋", u"软工2301", u"赵老师", u"C102", u"高等数学", 79.0),
])
# 2NF + 3NF:拆成班级、学生、成绩三张
c.executescript(u"""
CREATE TABLE tb_class2 (cname TEXT PRIMARY KEY, teacher TEXT, office TEXT);
CREATE TABLE tb_stu2 (sno TEXT PRIMARY KEY, sname TEXT, cname TEXT);
CREATE TABLE tb_score2 (sno TEXT, subject TEXT, score REAL,
PRIMARY KEY (sno, subject));
""")
# 换班主任:大表改漏两行
c.execute(u"UPDATE tb_raw SET teacher = '孙老师' WHERE class = '计科2301' "
u"AND sno IN ('20230101','20230102')")
for r in c.execute(u"SELECT class, teacher, COUNT(*) FROM tb_raw "
u"WHERE class = '计科2301' GROUP BY class, teacher"):
print(u"%s -> %s(%d 行)" % r)
# 三表版只改一行
c.execute(u"UPDATE tb_class2 SET teacher = '孙老师' WHERE cname = '计科2301'")
实跑输出:
--------------------------------------------------------------------
三范式:一张大表怎么拆成三张
--------------------------------------------------------------------
① 先造一张「什么都往里塞」的表
--------------------------------------------------------------------
1> SELECT * FROM tb_raw
sno sname class teacher office subject score
-----------------------------------------------------------
20230101 张伟 计科2301 王老师 A301 高等数学 88
20230101 张伟 计科2301 王老师 A301 大学英语 76.5
20230102 王芳 计科2301 王老师 A301 高等数学 92
20230102 王芳 计科2301 王老师 A301 大学英语 85
20230103 李娜 计科2301 王老师 A301 高等数学 61
20230201 刘强 计科2302 李老师 B204 高等数学 55.5
20230202 陈静 计科2302 李老师 B204 大学英语 91
20230301 杨洋 软工2301 赵老师 C102 高等数学 79
(8 行受影响)
8 行里,「计科2301 的班主任是王老师」这句话重复了 6 遍。
这就是三范式想治的病:一句话存了很多遍。
② 三个范式各治一种病
--------------------------------------------------------------------
| 范式 | 一句话 | 这张表违反在哪 |
|---|---|---|
| 1NF | 每列不可再分 | 「科目:成绩」如果挤在一列就违反了 |
| 2NF | 非主键列要依赖整个主键 | 主键是(sno, subject),sname/class 只依赖 sno |
| 3NF | 非主键列不能互相依赖 | class → teacher → office,传递依赖 |
③ 拆完是三张表
--------------------------------------------------------------------
| 结构 | 行数 | 一句话存几遍 |
|---|---:|---|
| 一张大表 | 8 | 「王老师管计科2301」存 6 遍 |
| 三张小表 | 17 | 存 1 遍 |
④ 拆开之后,更新才不会打架
--------------------------------------------------------------------
场景:计科2301 换班主任,王老师换成孙老师。
大表要改 6 行;改漏一行,同一个班就有两个班主任。
计科2301 → 孙老师(4 行)
计科2301 → 王老师(1 行)
漏改了 1 行,这个班现在有两个班主任。
1> UPDATE tb_class2 SET teacher = '孙老师' WHERE cname = '计科2301'
三表版只需要改 1 行,而且改完不可能不一致——因为只有一处。
⑤ 范式不是越高越好
--------------------------------------------------------------------
查「张伟的班级和班主任」,三表版要 JOIN 两次:
1> SELECT s.sname, s.cname, k.teacher
FROM tb_stu2 s JOIN tb_class2 k ON k.cname = s.cname
WHERE s.sname = '张伟'
sname cname teacher
------------------------
张伟 计科2301 孙老师
(1 行受影响)
一次查询多一次 JOIN。所以真实系统会故意留一点冗余(比如订单表里存一份
下单时的商品名),为的是「历史快照」——这不算违反范式,是有意的反范式。
**三范式的目标不是「拆得越细越好」,是「同一句话只存一处」。**
**判断标准很实用:如果改一个事实要改好几行、还得记着改哪几行,那这张表就该拆。**
八行的表看一遍就知道病在哪:计科2301、王老师、A301 这三个值在六行里重复。
它们表达的是同一句话:「计科2301 的班主任是王老师,办公室 A301」。
第四段是这一节的核心证据:改漏两行之后,查出来是「孙老师 4 行、王老师 1 行」。
同一个班出现两个班主任——数据本身自相矛盾,而且没有任何一处会报错。
这就是「更新异常」:不是写错了 SQL,是表结构让「改一半」变成了可能。
三表版改一行,而且改完不可能不一致——因为「谁是班主任」这句话全库只有一处。
注意这不是「三表版写得更好」,是「表结构让它没法写错」。
行数上是有代价的:8 行拆成 17 行,查询要多两次 JOIN。
规范化从来不是免费的,它拿「查询的复杂度」换「更新的正确性」。
30 秒实验
- 在
tb_raw里插一行('20230101', '张伟', '计科2301', '孙老师', 'A301', '数据结构', 80), 注意班主任写的是孙老师。 现在这个班又变成「两个班主任」,而且是一行一行插出来的——说明这种病随时会自己长回来。 - 给
tb_score2插两行完全一样的('20230101', '高等数学', 88),看会怎样。 组合主键会拦住第二行——这正是 1NF 之外的一个额外好处:主键顺便保证了唯一。 - 把
office从tb_class2里删掉,改成放在tb_stu2里,再想想为什么这样又违反了 3NF。 同一个班的办公室对每个学生都一样,放学生表里就是重新制造了一遍冗余。
常见误区
- 「三范式就是要把表拆得越细越好」。反了。 范式的目标是「一句话只存一处」,不是「表越多越好」。 拆过头会变成「查一个报表 JOIN 八张表」,那是另一种病。
- 「冗余一律要消除」。历史快照、报表宽表、缓存字段都是有意冗余。 判断标准是:这份冗余需不需要跟着主表同步更新。需要同步的是病,冻结在那一刻的是设计。
- 「主键一定要用自增 id」。不一定。
tb_score2用(sno, subject)组合主键, 顺便把「同一人同一门课只能有一条成绩」这条业务规则也写进去了。 用自增 id 还得另加唯一约束。
自测三问(点开对答案)
1. 2NF 和 3NF 的差别,一句话说清? —— **2NF 治「只依赖主码的一部分」,3NF 治「依赖别的非主码列」。**都只在有联合主码时才谈 2NF; 3NF 则在任何表上都可能违反。 判断方法:看这个非主码列,是「跟着主码走」还是「跟着另一个非主码列走」。 2. 为什么说「改漏一行」比「写错一条 SQL」更危险? —— **写错 SQL 会报错或者查出明显不对的结果;改漏一行不报错,只是数据自己矛盾了。** 矛盾会一直躺在库里,直到某天有人算总数发现对不上。 **这也是为什么要在表结构上堵住它,而不是在流程上要求「记得改全」。** 3. 什么时候可以故意不遵守三范式? —— **当这份冗余是「历史快照」的时候**(订单里的商品名和单价), **或者当这张表只读、只用来查报表的时候**(宽表、星型模型)。 判断标准:这份冗余需不需要跟着主表同步更新——需要同步的是病,不需要的是设计。下一篇进 1.4 常见关系数据库。那一节把 Access、SQL Server、Oracle、MySQL 四个产品摆在一起, 看「同一个需求为什么会有四种写法」,也把本课的落地边界说清。
- ☐ (本篇完,下一篇 1.4 常见关系数据库)