软件分析与设计笔记 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 × 1560
WO-002张先生海淀 xx 小区阿明C305 × 1180

这张表把五类信息挤在了一起:工单、客户、工程师、配件明细、金额。下面四步逐步拆开它。

2.1 未规范化形式(unnormalized form, UNF)

原始的宽表:一张表里既有工单信息,又有客户信息,还有多行配件明细挤在同一格里。

2.2 第一范式(first normal form, 1NF)

消除多值属性(multivalue attribute) 与重复组(repeating group):

  • 每个单元格只放一个值(原子值,atomic value);
  • 把重复出现的配件行拆成独立记录,而不是塞在一格里。

拆完是这样(主键变成「工单号 + 配件号」):

工单号配件号数量客户名客户地址工程师名
WO-001A2312张先生海淀 xx 小区老陈
WO-001B1101张先生海淀 xx 小区老陈
WO-002C3051张先生海淀 xx 小区阿明

1NF 之后,重复的问题只是被摊开了,还没有解决 —— 注意客户名和地址现在重复了三次。

2.3 第二范式(second normal form, 2NF)

消除部分依赖(partial dependency):

  • 前提是这个表有复合主键;
  • 规则是:非主键字段必须依赖整个主键,不能只依赖其中一部分。

上面那张表的主键是「工单号 + 配件号」。逐字段检查它依赖谁:

字段依赖判断
数量工单号 + 配件号✅ 完整依赖,留下
客户名 / 客户地址 / 工程师名只依赖工单号❌ 部分依赖,要搬走

搬走之后分成两张表:

text
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 表

拆完得到四张表:

text
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),它和规范化是方向相反的两种权衡。

常见的两种情形:

  1. 频繁关联查询的小表。 两张表总是一起查、且都很少变动,合并可以减少连接;
  2. 只在一处使用的数据。 某个字段只在同一个业务流程里被读写,且没有独立生命周期。

本例里有一个合适的候选: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 就是这个样子:

text
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)。

本节自测

  1. 「想录入一个新配件,却因为没有工单用过而录不进去」属于哪一类异常?
  2. 1NF、2NF、3NF 分别消除的是什么?各自的前提条件是什么?
  3. 工单明细表里的「小计」为什么不该存储?如果非要存,需要补什么约定?
  4. 什么情况下把两张表合回去是合理的?
  5. LDM 和 ERD 的差别在哪?LDM 之后还差哪一步?
  6. 本例里 work_order 表的 customer_address 属于哪种依赖?为什么该搬到 customer 表?
  7. 用了 3NF 之后,为什么查询反而可能变慢?
  8. 配件被停用时,参照完整性怎么处理才不影响历史工单?

参考答案

  1. 插入异常(insert anomaly)。特征是「想存 A,但必须先有 B」—— 新配件没被任何工单用过,就没有地方录入(§1)。
  2. 1NF 消除多值属性与重复组(前提:一格一值);2NF 消除部分依赖(前提:表有复合主键);3NF 消除传递依赖(规则:非主键字段不能依赖另一个非主键字段)(§2.2–2.4)。
  3. 因为它是派生字段(单价 × 数量),存了就要在每次单价或数量变化时同步更新,漏一次就永久不一致。非要存必须配套约定「谁负责更新、什么时候更新」(§3)。
  4. ①频繁关联查询的小表(总是一起查、很少变动);②只在一处使用、没有独立生命周期的数据。判据:合起来会不会引入冗余(§4)。
  5. ERD 是概念级(业务概念与关系,不含类型长度与外键);LDM 接近实现级(含字段、键、外键、类型长度),但仍不绑定具体产品。LDM 之后还差物理设计(选数据库、定索引、分区)(§5.1)。
  6. 传递依赖:工单号 → 客户号 → 客户地址。地址依赖的是非主键字段「客户号」,所以该搬到 customer 表,否则客户搬家时工单上的地址不会跟着改(§2.4)。
  7. 因为规范化做得越彻底,join 越多。本例「查进度」要 join 三张表,而它一天被查 3600 次。3NF 保证正确性,不保证性能(§5.3)。
  8. 不要关掉参照完整性,而是给配件加「停用」状态(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参照完整性外键指向的主键必须存在

© 2026 HuanYu Blog

记录技术,也记录思考