数据模型是三件东西:数据长什么样、能怎么动、得守什么规矩。 少了第三件,前两件都白搭。 三范式不是三条教条,是三步「把重复的搬出去」。 每搬一次,表多一张、改一处省一堆。
上一篇讲「为什么要有数据库」,这一篇讲「数据在库里长什么样」。
它是第 6 章建表、第 13 章约束的地基。
建表时那一堆 PRIMARY KEY、FOREIGN KEY、CHECK,全部来自这一篇的三要素。
和第 9 章的关系:三范式决定了表怎么拆,表怎么拆决定了后面要 join 几张。
拆得好,查询短;拆得差,一条 SQL 六层嵌套。
一、三要素:结构、操作、约束
教材说数据模型由三部分组成,这三部分回答三个问题。
| 要素 | 回答的问题 | 关系模型里的样子 |
|---|---|---|
| 数据结构 | 数据长什么样 | 二维表:行是记录、列是字段 |
| 数据操作 | 能怎么动它 | 增删改查,也就是 CRUD |
| 完整性约束 | 得守什么规矩 | 主键、外键、唯一、非空、CHECK |
最容易漏的是第三件。
很多人以为「建张表、能增删改查」就算有数据模型了,其实只做了前两件。
约束才是数据库和 Excel 的分水岭。
Excel 允许你在年龄那一列填「待定」,数据库里一个 CHECK 就挡住了。
二、四种模型,为什么关系模型赢了
教材列了四种:层次、网状、关系、面向对象。前两种现在基本见不到了。
层次模型:数据像一棵树,一个父节点可以有多个子节点,但一个子节点只能有一个父节点。
遇到「一个学生选多门课、一门课有多个学生」就卡住了。
网状模型:把树放开,允许一个节点有多个父节点。
能表达多对多了,但结构复杂到只有专家画得出来——访问数据得顺着指针走,改结构等于重写程序。
关系模型:一切用二维表表达,表之间靠「值相等」关联,不靠指针。
它的杀手锏是「用户不用知道数据怎么存」。
你写 WHERE deptno = 'D01',至于它是走索引还是全表扫,不用管。
为什么赢:数学基础扎实(集合论)、结构简单(就是表)、
查询语言声明式(说「要什么」不说「怎么取」)。
前两条让实现变简单,第三条让用的人变多。
三、E-R 三要素:实体、属性、联系
画 E-R 图只用三个符号,但「联系」那一项有讲究。 实体:一个对象类别,比如「学生」「课程」。图上画矩形。 属性:实体的特征,比如学号、姓名。图上画椭圆。其中能唯一确定一个实体的那个属性,就是主键。 联系:实体之间怎么搭上,图上画菱形。分三种:
| 联系 | 例子 | 落成表怎么存 |
|---|---|---|
| 一对一 1:1 | 人 ↔ 身份证 | 任一张表里放对方主键,或合成一张 |
| 一对多 1:N | 部门 ↔ 员工 | 在「多」的那边放「一」的主键(员工表存部门号) |
| 多对多 M:N | 学生 ↔ 课程 | 必须多一张中间表,存两边的键 |
第三行是最容易做错的一处。
很多人想在学生表里加一列「所选课程」,塞成 语文,数学——那就退回 1.1 节讲的第一范式违规了。
多对多的正确做法只有一条:中间表。
选课表(学号、课程号、成绩)——成绩这个属性只属于「这一次选课」,不属于学生也不属于课程,
正好落在中间表上。
四、三范式:三次搬家
范式听着玄,其实就是三个问题。 ① 一个格子里是不是塞了多个值?——第一范式。要求每个格子只放一个不可再分的值。 ② 有没有哪一列只依赖主键的一部分?——第二范式。组合主键的表才有这个问题,单列主键天然满足。 ③ 有没有哪一列是通过别的列间接依赖主键的?——第三范式。也就是「非主键列依赖非主键列」。 下面这段代码把一张订单宽表的依赖关系摆出来,自动分出两组。 主键是「订单号 + 行号」,看每一列依赖谁。
五、反范式与快照:什么时候该故意冗余
范式的代价很实在:表变多、连接变多。
一份 50 行的宽表拆成四张,行数加起来 107 行——比原来多一倍。
所以实际项目里几乎都会反范式,但反范式必须是有意的。
两条常见理由:
① 读多写少。订单表一天写 1000 次、读 100 万次,把会员名冗余进订单,省掉一次 join,值。
代价是会员改名时要同步改历史订单——那就别同步,让它当快照。
② 统计字段。在商品表里存一个「累计销量」,每次下单加一,省掉每次 SUM 全表。
代价是它可能不准,得靠定时任务校正。
判断冗余还是快照,就看一个问题:这个值该不该跟着源数据变。
订单里的单价该冻结(快照),商品表里的现价该跟着改(冗余)。
这一句话能省掉后面很多扯皮。
六、动手 Demo:一张宽表拆成四张:每拆一步量一次账
Demo 从一张 50 行的订单宽表起步,它连 1NF 都不满足——「商品」那一列写成「机械键盘 x1」, 名字和数量挤在一个格子里。 然后拆三步:拆开格子(1NF)、搬走商品属性(2NF)、搬走会员属性(3NF)。 每拆一步都算:少存了多少字、改一处要动几行。
from collections import defaultdict
KEY = {"订单号", "行号"}
FD = {
"商品名": {"商品号"},
"单价": {"商品号"},
"会员号": {"订单号"},
"会员名": {"会员号"},
"城市": {"会员号"},
}
part = defaultdict(list) # 只依赖主键的一部分
trans = defaultdict(list) # 依赖一个非主键列
for col, deps in FD.items():
if col in KEY:
continue
if deps and not KEY <= deps:
part["、".join(sorted(deps))].append(col)
if [d for d in deps if d not in KEY]:
trans["、".join(sorted(deps))].append(col)
print("只看部分依赖(2NF):")
for k, v in part.items():
print(" %s -> %s" % (k, "、".join(v)))
print("只看传递依赖(3NF):")
for k, v in trans.items():
print(" %s -> %s" % (k, "、".join(v)))
实跑输出:
--------------------------------------------------------------------
第二课:三范式不是教条,是三步「把重复的搬出去」
--------------------------------------------------------------------
**范式听着玄,其实就是三个问题:**
**① 一个格子里是不是塞了多个值?**
**② 有没有哪一列只依赖主键的一部分?**
**③ 有没有哪一列是通过别的列间接依赖主键的?**
**三个问题分别对应第一、第二、第三范式。这一课把这三次搬家真做一遍,每搬一次量一次账。**
一、一张宽表起步:它连第一范式都不满足
--------------------------------------------------------------------
**50 条订单,每条一行,商品那一列写成「机械键盘 x1」这种「名字加数量」的合体。**
订单号 会员号 会员名 城市 商品 单价 类别
-----------------------------------------------------
O001 M2 钱二 武汉 无线鼠标 x1 129.00 C1
O002 M3 孙三 宜昌 移动硬盘 x1 459.00 C2
O003 M1 赵一 宜昌 27 寸显示器 x1 1599.00 C3
**问题就在「商品」那一列:一个格子里同时装了名字和数量,将来想按数量求和,得先把字符串劈开。**
**这就是违反第一范式(1NF)——要求每个格子只放一个不可再分的值。**
**拆法:把「名字 x 数量」拆成「商品名」和「数量」两列。**
**拆完 50 条订单还是 50 行(每条只买一种),主键是「订单号 + 行号」。**
二、第二范式:把只依赖「商品号」的列搬走
--------------------------------------------------------------------
**现在看这几列:商品名、单价、类别。它们只由商品号决定,跟是哪个订单没关系。**
**却跟着每一行订单重复存了一遍——这叫【部分依赖】,违反第二范式。**
**搬出去:商品名、单价进 `goods` 表,订单明细里只留商品号和数量。**
表 行数 存了什么
--------------------------------------------
order_detail 50 订单号、行号、商品号、数量
goods 4 商品号、商品名、单价、类别
**字符数从 1348 降到 506,少了 62.46%。**
**更关键的是:商品涨价只要改 `goods` 表里 1 行,不用去翻 50 条订单。**
三、第三范式:把通过会员号间接依赖的列搬走
--------------------------------------------------------------------
**明细表里还留着会员号、会员名、城市。**
**会员名和城市由会员号决定,而会员号不是主键。**
**这叫【传递依赖】,违反第三范式。**
**搬出去:会员名、城市进 `member` 表。**
**三次搬完之后一共四张表:**
表 行数 主键
-------------------------------
orders 50 订单号
order_detail 50 订单号 + 行号
goods 4 商品号
member 3 会员号
**一张 50 行的宽表变成四张表,行数加起来 107 行——比原来多。**
**这就是范式的代价:表多了、连接多了。它换到的是「改一处只改一处」。**
四、账要算清:范式换到了什么
--------------------------------------------------------------------
要改的事 宽表要改几行 四表要改几行
----------------------------------------------------------------
机械键盘涨价 20 元 12 行(每个买过它的订单都改) 1 行
赵一搬家到荆州 16 行 1 行
新增一个商品类别 改已有行或全填 NULL 插 1 行到类别表
查「赵一买过什么」 扫 50 行 连接 2 张表
**前两行是范式的好处,第四行是范式的代价。**
**写多读少的表该拆,读多写少的表可以留一点冗余。**
**这就是「反范式」——但它必须是【有意的】,不能是【没想到】。**
五、有一处冗余是必须留的:下单时的价格
--------------------------------------------------------------------
**注意:订单明细里通常还要存一份【下单时的单价】。**
**它看着像冗余——商品表里明明有单价,为什么还要存一遍?**
**因为商品表的单价是【现在的价】,订单里要的是【成交时的价】。**
**下个月涨价了,历史订单的金额不能跟着变。这不是冗余,是【快照】。**
**判断标准很简单:这个值是「跟着源数据变」还是「冻在那一刻」。跟着变就是冗余,冻住就是快照。**
正文那段代码的输出只有五行,但把整章的逻辑讲完了。 部分依赖有三组:商品号 → 商品名、单价;订单号 → 会员号;会员号 → 会员名、城市。 注意「会员名、城市」在两组里都出现了。 它们既通过会员号部分依赖于主键(2NF 问题), 又通过会员号这个非主键列传递依赖于主键(3NF 问题)。 所以拆商品表解决第一组,拆会员表解决后两组——两张表就够了。 Demo 里的账是这样的:宽表 50 行、字符数 1348; 拆完之后四张表一共 107 行、字符数 506,少了 62.46%。 少的那 62% 就是冗余。但注意:行数反而多了(50 → 107)。 这就是范式的真实代价——省的是「重复的字」,花的是「多出来的行和连接」。 最值得看的是那张「改几行」的表。 机械键盘涨价,宽表要改 12 行(每个买过它的订单都改),四表只改 1 行。 赵一搬家,宽表改 16 行,四表改 1 行。 这两个数就是范式换来的东西。 最后一段讲了一处「必须留的冗余」:订单明细里的下单单价。 它看着和商品表重复,但它冻在下单那一刻,涨价了也不能变。 这不是冗余,是快照。 判断标准就一句:跟着源数据变的是冗余,冻住的是快照。
30 秒实验
- 把 Demo 里的
KEY改成只有{"订单号"}(假设一张订单只买一种商品),再跑一遍, 看「部分依赖」那组还剩什么。 答案是只剩「订单号 → 会员号」,商品那组消失了——因为商品号不再是主键的一部分,商品名 → 商品号变成了传递依赖。这就是「单列主键天然满足 2NF」这句话的来历。 - 给
FD加一列"折扣": {"会员号"},再跑,看它落在哪一组。它和「城市」一样,属于传递依赖。 但注意:如果折扣是「这次订单谈下来的」,那它应该依赖主键而不是会员号——依赖关系写错, 拆出来的表就是错的。 - 试着给商品表加一列「累计销量」,然后问自己:它是冗余还是快照?是冗余——它该跟着订单变。 所以它需要一张定时任务或触发器去校正,否则迟早对不上。
常见误区
- 「三范式必须严格遵守。」 不是。范式解决的是「写入时的一致性」,读多写少的表故意冗余是常规操作。 关键是冗余之后你得知道怎么同步,或者明确「它就是快照,不用同步」。
- 「多对多关系在学生表里加一列课程名就行。」 不行,这违反第一范式(一个格子塞多个值),而且没法回答「这门课有多少人选」。 多对多必须用中间表。
- 「主键就是第一个字段。」 主键是「能唯一确定一行的那一列或几列」,位置无关。组合主键(订单号 + 行号)也是主键, 而且它才让 2NF 有了实际意义。
自测三问(点开对答案)
1. 数据模型的三要素是什么?为什么约束最容易被漏掉? —— **数据结构、数据操作、完整性约束。** **因为前两件「建表 + 增删改查」马上能看到效果,约束要等到脏数据进来才显形。** **而约束恰恰是数据库和 Excel 的分界线。** 2. 一对多和多对多,落成表分别怎么存? —— **一对多:在「多」的那边放「一」的主键**(员工表存部门号)。 **多对多:必须多一张中间表**,存两边的键,外加只属于「这一次关联」的属性(比如成绩)。 3. 订单明细里存一份下单时的单价,算冗余吗? —— **不算,算快照。** **判断标准:它不该跟着商品表的现价变。** **涨价之后历史订单金额必须保持不变,所以这份值必须冻住。**下一篇讲「三层看不看得见」:外模式、模式、内模式, 以及那两级映射换来的两种独立性——为什么你改表结构时,别人的查询不用跟着改。
- ☐ (本篇完,下一篇 1.3 数据库的体系结构)
相关笔记
- 数据库系统概述
- 数据库的体系结构
- 15 课 数据库基础:关系代数与范式的推导
- 🛢️ MySQL从入门到精通 · 索引