🛢️ 第1章 数据库基础
📄 一句话概括

数据模型是三件东西:数据长什么样、能怎么动、得守什么规矩。 少了第三件,前两件都白搭。 三范式不是三条教条,是三步「把重复的搬出去」。 每搬一次,表多一张、改一处省一堆。

📝 本章和前面几章的关系

上一篇讲「为什么要有数据库」,这一篇讲「数据在库里长什么样」。 它是第 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 秒实验

  1. 把 Demo 里的 KEY 改成只有 {"订单号"}(假设一张订单只买一种商品),再跑一遍, 看「部分依赖」那组还剩什么。 答案是只剩「订单号 → 会员号」,商品那组消失了——因为商品号不再是主键的一部分, 商品名 → 商品号 变成了传递依赖。这就是「单列主键天然满足 2NF」这句话的来历。
  2. 给 FD 加一列 "折扣": {"会员号"},再跑,看它落在哪一组。它和「城市」一样,属于传递依赖。 但注意:如果折扣是「这次订单谈下来的」,那它应该依赖主键而不是会员号——依赖关系写错, 拆出来的表就是错的。
  3. 试着给商品表加一列「累计销量」,然后问自己:它是冗余还是快照?是冗余——它该跟着订单变。 所以它需要一张定时任务或触发器去校正,否则迟早对不上。

常见误区

  1. 「三范式必须严格遵守。」 不是。范式解决的是「写入时的一致性」,读多写少的表故意冗余是常规操作。 关键是冗余之后你得知道怎么同步,或者明确「它就是快照,不用同步」。
  2. 「多对多关系在学生表里加一列课程名就行。」 不行,这违反第一范式(一个格子塞多个值),而且没法回答「这门课有多少人选」。 多对多必须用中间表。
  3. 「主键就是第一个字段。」 主键是「能唯一确定一行的那一列或几列」,位置无关。组合主键(订单号 + 行号)也是主键, 而且它才让 2NF 有了实际意义。
自测三问(点开对答案) 1. 数据模型的三要素是什么?为什么约束最容易被漏掉? —— **数据结构、数据操作、完整性约束。** **因为前两件「建表 + 增删改查」马上能看到效果,约束要等到脏数据进来才显形。** **而约束恰恰是数据库和 Excel 的分界线。** 2. 一对多和多对多,落成表分别怎么存? —— **一对多:在「多」的那边放「一」的主键**(员工表存部门号)。 **多对多:必须多一张中间表**,存两边的键,外加只属于「这一次关联」的属性(比如成绩)。 3. 订单明细里存一份下单时的单价,算冗余吗? —— **不算,算快照。** **判断标准:它不该跟着商品表的现价变。** **涨价之后历史订单金额必须保持不变,所以这份值必须冻住。**

下一篇讲「三层看不看得见」:外模式、模式、内模式, 以及那两级映射换来的两种独立性——为什么你改表结构时,别人的查询不用跟着改。

相关笔记