软件分析与设计笔记 19:规范化与逻辑数据模型
一次改地址引发的数据不一致,讲清规范化的真正动机;UNF 到 3NF 每一步在解决什么问题,派生字段为什么不能存,优化为什么和规范化方向相反,以及逻辑数据模型在 3NF 之上补了什么。
本文是 《软件分析与设计笔记》 系列的第 19 篇,共 28 篇
这是我在学习软件分析与设计时整理的系列笔记。概念部分是我按自己的理解写的,示例与图示都是自己编、自己画的。理解有偏差的地方欢迎指出。
贯穿案例:片区家电维修派单系统。18 篇画了 ERD、写了数据字典,这一篇把表调到 3NF,再补成逻辑数据模型。
0 一次改地址引发的麻烦
客户张先生搬了家。客服在系统里改了客户档案上的地址。
三天后发现:他上个月的两张工单,工程师上门时还是按旧地址跑的。因为工单表里也存了一份客户地址 —— 当时为了「派单时看一眼方便」,一份地址被存了两处,改的时候只改了一处。
后果比听起来严重:
- 工程师白跑一趟(这就是 06 篇那个 11% 的缺料率之外,另一种「白跑」);
- 客户投诉;
- 更麻烦的是没人知道还有多少条旧地址散落在别的表里。
这不是粗心,而是数据冗余(data redundancy) 必然会带来的更新异常(update anomaly):同一份事实存在多处,就一定会有忘记同步的那一天。
规范化(normalization)要解决的就是这件事。
1 三种异常
冗余带来的问题一共三类:
| 异常 | 表现 | 本例的具体情况 |
|---|---|---|
| 更新异常(update anomaly) | 改一处漏一处,数据自相矛盾 | 客户搬家后,工单上的旧地址没改 |
| 插入异常(insert anomaly) | 想录入某条信息,却因为没有别的主数据而录不进去 | 新配件要录入库存,但还没被任何工单用过,就无处可录 |
| 删除异常(delete anomaly) | 删掉一条记录,顺带把别的信息也弄丢了 | 删掉唯一一张用过配件 A231 的工单,A231 的库存信息也没了 |
第二行是插入异常的一个变体,值得细看:如果「配件信息」和「工单消耗」存在同一张表里,那么一个刚采购回来、还没被用过的配件就没有地方录入 —— 因为它没有对应的工单。
「想存 A,但必须先有 B」这种别扭,就是插入异常的信号。
第三行则说明了删除异常的代价:一张工单被删,带的不是「一条记录」,而是「某个配件的全部存在证明」。
2 UNF 到 3NF:每一步拿走一种重复
规范化的做法是对表逐条套用规则,每套用一次就上一个范式。
本例的起点是这样一张宽表(UNF):
| 工单号 | 客户名 | 客户地址 | 工程师名 | 配件(多行) | 合计金额 |
|---|---|---|---|---|---|
| WO-001 | 张先生 | 海淀 xx 小区 | 老陈 | A231 × 2;B110 × 1 | 560 |
| WO-002 | 张先生 | 海淀 xx 小区 | 阿明 | C305 × 1 | 180 |
这张表把五类信息挤在了一起:工单、客户、工程师、配件明细、金额。下面四步逐步拆开它。
2.1 未规范化形式(unnormalized form, UNF)
原始的宽表:一张表里既有工单信息,又有客户信息,还有多行配件明细挤在同一格里。
2.2 第一范式(first normal form, 1NF)
消除多值属性(multivalue attribute) 与重复组(repeating group):
- 每个单元格只放一个值(原子值,atomic value);
- 把重复出现的配件行拆成独立记录,而不是塞在一格里。
拆完是这样(主键变成「工单号 + 配件号」):
| 工单号 | 配件号 | 数量 | 客户名 | 客户地址 | 工程师名 |
|---|---|---|---|---|---|
| WO-001 | A231 | 2 | 张先生 | 海淀 xx 小区 | 老陈 |
| WO-001 | B110 | 1 | 张先生 | 海淀 xx 小区 | 老陈 |
| WO-002 | C305 | 1 | 张先生 | 海淀 xx 小区 | 阿明 |
1NF 之后,重复的问题只是被摊开了,还没有解决 —— 注意客户名和地址现在重复了三次。
2.3 第二范式(second normal form, 2NF)
消除部分依赖(partial dependency):
- 前提是这个表有复合主键;
- 规则是:非主键字段必须依赖整个主键,不能只依赖其中一部分。
上面那张表的主键是「工单号 + 配件号」。逐字段检查它依赖谁:
| 字段 | 依赖 | 判断 |
|---|---|---|
| 数量 | 工单号 + 配件号 | ✅ 完整依赖,留下 |
| 客户名 / 客户地址 / 工程师名 | 只依赖工单号 | ❌ 部分依赖,要搬走 |
搬走之后分成两张表:
work_order_part(work_order_no, part_no, qty) -- 主键:工单号 + 配件号
work_order(work_order_no, customer_name, customer_address, engineer_name)
注意这一步的判据非常机械:看每个字段能不能只靠主键的一半确定。能,就是部分依赖。
2.4 第三范式(third normal form, 3NF)
消除传递依赖(transitive dependency):
- 规则是:非主键字段不能依赖另一个非主键字段;
- 典型例子:工单表里有「客户号」和「客户地址」。地址依赖的是客户号(一个非主键字段),而不是工单号 —— 所以地址该搬到客户表。
本例里 work_order 表还有两个传递依赖:
| 字段 | 依赖链 | 处理 |
|---|---|---|
customer_address | 工单号 → 客户名 → 客户地址 | 搬到 customer 表 |
engineer_name | 工单号 → 工程师编号 → 工程师名 | 搬到 engineer 表 |
拆完得到四张表:
customer(customer_id, name, phone, address)
engineer(engineer_id, name, phone, skills)
work_order(work_order_no, customer_id, engineer_id, status, created_at)
work_order_part(work_order_no, part_no, qty)
到这一步,「客户搬家」只需要改 customer 表的一行。更新异常的根源被消除了。
2.5 函数依赖:三条规则的统一说法
函数依赖(functional dependency)是这三条规则背后的统一说法:如果知道 A 就能唯一确定 B,就写成 A → B。规范化要做的,是让所有非主键字段都直接依赖于主键。
三个范式其实是同一句话的三次检查:
- 1NF —— 消除「一个格子里有多个值」这种非函数依赖;
- 2NF —— 消除
配件号 → 数量(只依赖主键一半)这类依赖; - 3NF —— 消除
客户号 → 地址(非主键依赖非主键)这类依赖。
2.6 一个能背下来的检查法
规范化到最后,可以用一句话自检:每个非主键字段,都必须满足「只依赖主键、依赖整个主键、且只依赖主键」。
这三句正好对应三个范式,而且判断起来很快 —— 对着每张表的每个字段问一遍即可。
3 派生字段(derived attribute)不存
工单明细里有「单价」和「数量」,那么「小计」要不要存?「合计金额」呢?
都不要。 因为小计可以由另外两个字段算出来,合计又由所有小计加起来 —— 这类字段叫派生字段(derived attribute)。存它的代价是:每次单价或数量变化,你都必须记得同步更新它;漏一次,数据永久不一致。
回到 UNF 那张宽表里的「合计金额」:它其实有三个来源可能变(单价改、数量改、明细增删)。每多一个来源,就多一条漏更新的路径。
正确做法是在需要的地方算(查询时计算、或由视图/应用层计算)。如果确实因为性能原因要存,那要配套约定清楚「谁负责更新、什么时候更新」。
这条规则和笔记 09 讲业务实体时说的「能算出来的不要存」是同一条,也和 18 篇数据字典里「缺料率不建字段」是同一个道理。
4 优化:把表合回去的两种情形
规范化不是终点。有些场景下,把表适度合回去反而更好 —— 这叫优化(optimization),它和规范化是方向相反的两种权衡。
常见的两种情形:
- 频繁关联查询的小表。 两张表总是一起查、且都很少变动,合并可以减少连接;
- 只在一处使用的数据。 某个字段只在同一个业务流程里被读写,且没有独立生命周期。
本例里有一个合适的候选:work_order 与它的 work_order_part 不该合并(因为有独立生命周期),但 stock_item 与 part(配件主数据)可以考虑合并 —— 如果仓管系统和派单系统是同一个,配件名称、规格这些字段就不必单独一张表。
但 customer.address 绝对不能合回去。这正是本篇开头那个事故。
关键是把话说清楚:这不是「没做规范化」,而是权衡后退一步。判断依据仍然是那条:这份数据会不会因为存在两处而不一致?如果合起来不会引入冗余,那合起来就是合理的。
换句话说:可以为了性能容忍冗余,但必须知道自己在容忍什么。本例的「容忍清单」应该写在设计文档里,注明「stock_item.part_name 冗余自 part,由采购入库时同步」。
5 逻辑数据模型(LDM)
5.1 它和 ERD 的关系
完成规范化之后,把所有表(这里叫 3NF 表)之间的关系补出来,就得到逻辑数据模型(logical data model, LDM)。
它和 ERD 的关系可以这样理解:
| ERD | 逻辑数据模型(LDM) | |
|---|---|---|
| 关注 | 业务概念与关系 | 具体的表、字段、键与关系 |
| 粒度 | 概念级 | 接近实现级,但仍不绑定具体产品 |
| 是否含类型长度 | 通常不含 | 含 |
| 是否含外键 | 不含 | 含 |
拿 18 篇那份数据字典填进去,LDM 就是这个样子:
customer(customer_id PK, name, phone, address)
engineer(engineer_id PK, name, phone, skills)
district(district_id PK, name, manager_id)
engineer_district(engineer_id PK, district_id PK) ← 多对多拆出的关联表
work_order(order_no PK, customer_id FK, engineer_id FK,
district_id FK, appliance_type, fault_desc, status, created_at, closed_at)
stock_item(part_no PK, name, on_hand_qty, safe_qty, district_id FK)
part_usage(work_order_id PK FK, part_no PK FK, qty)
注意第五行 engineer_district —— 它就是 18 篇里那个「一个工程师能属多个片区吗」问出来的结果。这一个问题让表从 6 张变成 7 张。
LDM 本身已经是一个关系模型(relational model):表、字段、键、关系都明确了。再往后才是物理设计 —— 选具体的关系型数据库(relational database)、定索引、考虑分区。LDM 阶段还在讲「逻辑」,还没到「用哪个产品、建什么索引」。
5.2 参照完整性
LDM 里有个概念要提前留意 —— 参照完整性(referential integrity):外键指向的主键必须存在。它保证了「配件消耗的父工单一定在」。
本例里参照完整性有个值得单独说的细节:part_usage.part_no 指向 stock_item.part_no,但配件可能被停用。如果直接拒绝删除停用配件,历史工单就会出问题。
处理办法不是关掉参照完整性,而是加一个「停用」状态(stock_item.is_active),让配件永远存在、只是不能被新申请引用。这又是一个「技术约束要靠业务方案解决」的例子。
5.3 规范化做完,还剩什么没解决
值得停下来想一下:3NF 解决了冗余,但它不解决性能。本例里 06 篇那张交易量表说过,「客户查进度」一天 3600 次,而这个查询要 join 三张表(工单 + 客户 + 工程师)。
规范化做得越彻底,join 越多。这正是 §4 那个「方向相反的权衡」存在的原因 —— 也是为什么物理设计阶段还要做索引、物化视图、读库分离。
一句话:3NF 是正确性的保证,不是性能的保证。两者要分开考虑。
6 常见误区
- 以为规范化就是「表越多越好」。 规范化的目标是消除冗余,不是增加表数。过度拆表会带来大量连接;
- 把派生字段当成普通字段存。 见上面第 3 节;
- 跳过 1NF 直接讨论 3NF。 重复组没拆掉的话,后面几步没有意义;
- 把 LDM 当成建表脚本。 LDM 还是逻辑模型,字段名可以更贴近业务。
再加一条本例里特别容易犯的:
- 以为 3NF 之后就没事了。 3NF 只管冗余,不管性能、不管并发、不管参照完整性的边界情况(见 §5.2)。
本节自测
- 「想录入一个新配件,却因为没有工单用过而录不进去」属于哪一类异常?
- 1NF、2NF、3NF 分别消除的是什么?各自的前提条件是什么?
- 工单明细表里的「小计」为什么不该存储?如果非要存,需要补什么约定?
- 什么情况下把两张表合回去是合理的?
- LDM 和 ERD 的差别在哪?LDM 之后还差哪一步?
- 本例里
work_order表的customer_address属于哪种依赖?为什么该搬到 customer 表? - 用了 3NF 之后,为什么查询反而可能变慢?
- 配件被停用时,参照完整性怎么处理才不影响历史工单?
参考答案
- 插入异常(insert anomaly)。特征是「想存 A,但必须先有 B」—— 新配件没被任何工单用过,就没有地方录入(§1)。
- 1NF 消除多值属性与重复组(前提:一格一值);2NF 消除部分依赖(前提:表有复合主键);3NF 消除传递依赖(规则:非主键字段不能依赖另一个非主键字段)(§2.2–2.4)。
- 因为它是派生字段(单价 × 数量),存了就要在每次单价或数量变化时同步更新,漏一次就永久不一致。非要存必须配套约定「谁负责更新、什么时候更新」(§3)。
- ①频繁关联查询的小表(总是一起查、很少变动);②只在一处使用、没有独立生命周期的数据。判据:合起来会不会引入冗余(§4)。
- ERD 是概念级(业务概念与关系,不含类型长度与外键);LDM 接近实现级(含字段、键、外键、类型长度),但仍不绑定具体产品。LDM 之后还差物理设计(选数据库、定索引、分区)(§5.1)。
- 传递依赖:
工单号 → 客户号 → 客户地址。地址依赖的是非主键字段「客户号」,所以该搬到 customer 表,否则客户搬家时工单上的地址不会跟着改(§2.4)。 - 因为规范化做得越彻底,join 越多。本例「查进度」要 join 三张表,而它一天被查 3600 次。3NF 保证正确性,不保证性能(§5.3)。
- 不要关掉参照完整性,而是给配件加「停用」状态(
is_active)—— 配件永远存在、只是不能被新申请引用。技术约束要靠业务方案解决(§5.2)。
术语表
| 术语 | 中文 | 一句话解释 |
|---|---|---|
| normalization | 规范化 | 按规则调整表结构以消除冗余 |
| data redundancy | 数据冗余 | 同一份事实被存储在多个地方 |
| update anomaly | 更新异常 | 改一处漏一处导致数据互相矛盾 |
| insert anomaly | 插入异常 | 想录入某条信息却因缺少别的主数据而无法录入 |
| delete anomaly | 删除异常 | 删一条记录顺带丢失了别的信息 |
| multivalue attribute | 多值属性 | 一个单元格里塞了多个值 |
| repeating group | 重复组 | 同一组字段在同一记录里重复出现 |
| unnormalized form(UNF) | 未规范化形式 | 尚未套用任何规范化规则的原始表 |
| first normal form(1NF) | 第一范式 | 每格只放一个原子值,消除重复组 |
| second normal form(2NF) | 第二范式 | 非主键字段依赖整个主键,消除部分依赖 |
| third normal form(3NF) | 第三范式 | 非主键字段不依赖其他非主键字段 |
| functional dependency | 函数依赖 | 知道 A 就能唯一确定 B 的关系 |
| partial dependency | 部分依赖 | 只依赖复合主键中的一部分 |
| transitive dependency | 传递依赖 | 依赖了另一个非主键字段 |
| atomic value | 原子值 | 不可再分的最小数据单位 |
| derived attribute | 派生字段 | 可以由其他字段计算出来的字段 |
| optimization | 优化 | 出于性能考虑对规范化结果做的调整 |
| logical data model(LDM) | 逻辑数据模型 | 3NF 之上补齐字段、键与关系的模型 |
| relational model | 关系模型 | 以表与关系组织数据的模型 |
| referential integrity | 参照完整性 | 外键指向的主键必须存在 |