数据库基础体系 · 第 2/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

关系模型与规范化:键、函数依赖、范式和反规范化边界

所属系列:数据库基础体系
所属模块:一、数据库共同原理
标签:数据库、SQL、数据建模、后端
版本与范围:本文的 SQL 示例以 PostgreSQL 18 和 MySQL 8.4、InnoDB 为主要语境;关系模型、函数依赖和范式属于与具体产品无关的关系数据库理论。涉及 NULL、外键检查、唯一约束和事务行为时,会明确区分理论语义与产品实现。

关系模型解决的不是“如何把对象映射成表”这么简单的问题,而是要回答:

  • 一行数据究竟表示什么;
  • 哪些列能够唯一确定一行;
  • 一个事实应该存储一次还是多次;
  • 某列发生变化时,需要修改多少处;
  • 拆分表以后,原来的事实是否仍然能够无损恢复;
  • 约束能否由数据库直接验证;
  • 为了查询性能而复制数据时,一致性责任由谁承担。

规范化的核心,就是利用函数依赖识别数据中的事实结构,并通过分解关系消除不必要的重复。反规范化则是在明确承担一致性成本的前提下,有意保留某些重复。


一、从“表”回到关系模型

1. 关系、元组、属性和域

在关系模型中,一个关系可以理解为一个有限集合:

r(R)r(R)

其中:

  • RR关系模式
  • rr 是满足该模式的某个关系实例;
  • 关系实例中的每个元素称为元组
  • 关系模式中的每个命名字段称为属性
  • 每个属性的取值范围称为

例如:

Student(student_id, student_name, department_id)

这是关系模式。它描述了一个关系的结构,但不是当前存储的数据。

某一时刻的数据可能是:

student_id student_name department_id
1001 Alice CS
1002 Bob EE

这组行是关系实例。

在 SQL 中,通常把:

  • 关系模式对应为表结构;
  • 元组对应为行;
  • 属性对应为列;
  • 域对应为数据类型以及更严格的取值约束。

但 SQL 表与数学意义上的关系并不完全相同:

  1. 数学关系是集合,不允许重复元组;SQL 查询结果默认可能包含重复行,需要 DISTINCT 才去重。
  2. 数学模型通常不把 NULL 当作普通域值;SQL 使用 NULL 表示未知、缺失或不适用,并由三值逻辑参与比较。
  3. SQL 的约束语义和索引实现由具体数据库产品决定。

因此,规范化分析时应先使用关系模型的精确定义,再映射到 SQL 的约束和查询语义。

2. 行粒度:一行到底代表什么

行粒度是关系设计中最容易被忽略、却最重要的前置问题。

例如,下面这个表看似是在保存订单:

OrderReport(
    order_id,
    order_created_at,
    customer_id,
    customer_name,
    product_id,
    product_name,
    quantity,
    unit_price
)

但一行究竟表示:

  • 一个订单;
  • 一个订单中的一个商品;
  • 某个订单商品在报表中的一次展示;
  • 订单与商品的组合?

如果一个订单可以包含多个商品,那么合理的粒度应是:

一行表示一个订单中的一个订单明细。

这意味着:

  • order_id 不能单独唯一标识一行;
  • (order_id, product_id) 可能是候选键;
  • 订单级属性,如 order_created_atcustomer_id,会在多个订单明细行中重复;
  • 明细级属性,如 quantityunit_price,则随商品明细变化。

如果不先确定粒度,后续的键、函数依赖和范式判断都会失去意义。


二、键:从“能定位一行”到“候选键”

设关系模式为:

R(A1,A2,,An)R(A_1,A_2,\ldots,A_n)

1. 超键

属性集合 KRK \subseteq R 是一个超键,当且仅当:

KRK \to R

也就是,KK 的取值能够函数式地确定关系中的全部属性。

“函数式确定”意味着:在合法关系实例中,不允许存在两行在 KK 上相同、但其他属性不同。

例如:

Employee(employee_id, email, name, department_id)

如果业务约束保证:

  • employee_id 唯一;
  • email 唯一;

那么:

  • {employee_id} 是超键;
  • {email} 是超键;
  • {employee_id, email} 也是超键。

最后一个集合虽然能唯一定位行,但包含了不必要的属性。

2. 候选键

候选键是极小超键:

KRK \to R

并且对于任意真子集 KKK' \subset K,都有:

K↛RK' \not\to R

因此,候选键必须同时满足:

  1. 唯一性:能够确定整行;
  2. 最小性:去掉任意一个属性后就不再具备唯一确定能力。

一个关系可以有多个候选键。

例如:

User(user_id, email, username)

如果 user_idemailusername 都分别唯一,那么三者都是候选键。

3. 主键

主键是从候选键中选出的一个,用于作为表的主要行标识。

关系理论并不要求必须选出“主键”;主键是数据库设计和 SQL 约束中的工程选择。

在 PostgreSQL 中,主键要求列值唯一且非 NULL,并自动建立相应的唯一 B-tree 索引;一个表最多有一个主键,但可以有多个唯一约束。(postgresql.org)

例如:

CREATE TABLE app_user (
    user_id BIGINT PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    username TEXT NOT NULL UNIQUE,
    display_name TEXT NOT NULL
);

这里:

  • user_id 是主键;
  • emailusername 是候选键的实现;
  • display_name 不是键的一部分,因为它不能保证唯一。

主键不一定是自然业务字段,也可以是代理键,例如自增整数或 UUID。但代理键只解决“如何稳定引用一行”,不能自动替代业务唯一性约束。

4. 外键

设子关系有属性集合 FF,父关系有候选键 KK。如果子关系中的 FF 引用父关系中的 KK,则 FF外键

外键表达的是引用完整性:

tchild,t[F] 为 NULL,或存在 pparent, p[K]=t[F]\forall t \in child,\quad t[F] \text{ 为 NULL,或存在 } p \in parent,\ p[K]=t[F]

例如:

CREATE TABLE customer (
    customer_id BIGINT PRIMARY KEY,
    name TEXT NOT NULL
);

CREATE TABLE orders (
    order_id BIGINT PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    created_at TIMESTAMP NOT NULL,
    CONSTRAINT fk_orders_customer
        FOREIGN KEY (customer_id)
        REFERENCES customer(customer_id)
);

orders.customer_id 的值必须引用 customer.customer_id 中已经存在的值,除非外键列允许 NULL

外键不等于自动建立业务语义。例如:

  • 外键保证客户存在;
  • 它不保证客户处于“允许下单”状态;
  • 它不保证订单总金额等于订单明细金额之和;
  • 它不保证删除客户时应该级联删除订单、禁止删除,还是转移归档。

删除和更新动作必须根据业务语义显式选择,例如 RESTRICTCASCADESET NULL 等。PostgreSQL 支持多种引用动作和延迟约束检查;MySQL 8.4 的 InnoDB 支持常见引用动作,但不支持延迟约束检查,NO ACTION 按 InnoDB 语义视为 RESTRICT。(postgresql.org)


三、用属性闭包求候选键

1. 属性闭包的定义

给定属性集合 XX 和函数依赖集合 FFXXFF 下的闭包记为:

XF+X_F^+

它表示:从 XX 出发,依据 FF 能够推出的全部属性。

若:

XF+=RX_F^+ = R

XX 是超键。

如果同时没有任何真子集能够闭包到 RR,则 XX 是候选键。

2. 闭包算法

计算 X+X^+ 的基本过程:

  1. 初始令 X+=XX^+ = X
  2. 如果函数依赖 YZY \to Z 满足 YX+Y \subseteq X^+,就把 ZZ 加入 X+X^+
  3. 重复执行,直到闭包不再变化;
  4. 若闭包包含关系的全部属性,说明 XX 是超键;
  5. 再逐个删除 XX 中的属性,检查是否仍能覆盖全部关系属性,以判断最小性。

3. 完整算例

设:

R(A,B,C,D,E)R(A,B,C,D,E)

函数依赖为:

F={AB, BC, CDE, EA}F=\{A\to B,\ B\to C,\ CD\to E,\ E\to A\}

计算 A+A^+

初始:

A+={A}A^+=\{A\}

根据 ABA\to B

A+={A,B}A^+=\{A,B\}

根据 BCB\to C

A+={A,B,C}A^+=\{A,B,C\}

此时:

  • CD -> E 还缺少 D
  • E -> A 已经没有新增属性。

所以:

A+={A,B,C}A^+=\{A,B,C\}

不能覆盖 RR,因此 A 不是超键。

计算 (CD)+(CD)^+

初始:

(CD)+={C,D}(CD)^+=\{C,D\}

根据 CDECD\to E

(CD)+={C,D,E}(CD)^+=\{C,D,E\}

根据 EAE\to A

(CD)+={A,C,D,E}(CD)^+=\{A,C,D,E\}

根据 ABA\to B

(CD)+={A,B,C,D,E}(CD)^+=\{A,B,C,D,E\}

根据 BCB\to C 不再新增属性。

因此:

(CD)+=R(CD)^+=R

所以 CD 是超键。

检查最小性:

  • C+={C}C^+=\{C\},不能覆盖全部属性;
  • D+={D}D^+=\{D\},不能覆盖全部属性。

因此 CD 是候选键。

计算 (DE)+(DE)^+

初始:

(DE)+={D,E}(DE)^+=\{D,E\}

根据 EAE\to A

(DE)+={A,D,E}(DE)^+=\{A,D,E\}

根据 ABA\to B

(DE)+={A,B,D,E}(DE)^+=\{A,B,D,E\}

根据 BCB\to C

(DE)+={A,B,C,D,E}(DE)^+=\{A,B,C,D,E\}

所以 DE 是超键。

检查最小性:

  • D+D^+ 不覆盖全部属性;
  • E+={E,A,B,C}E^+=\{E,A,B,C\},缺少 D

因此 DE 也是候选键。

因为 D 从未出现在任何函数依赖的右侧,所以任何候选键都必须包含 D。再分别检查其余属性:

  • ADA -> B -> C,随后 CD -> E,因此闭包为全部属性;
  • BDB -> C,随后 CD -> E -> A,因此闭包为全部属性;
  • CDDE 的闭包已在上面逐步算出;
  • D 单独不能推出其他属性,而 ABCE 单独都缺少 D,所以上述四组都满足最小性。

因此全部候选键为:

{AD, BD, CD, DE}\{AD,\ BD,\ CD,\ DE\}

若选择 CD 作为主键,其他候选键仍然应该通过 UNIQUE 等约束表达,否则数据库只知道主键唯一,不知道其余组合也必须唯一。


四、函数依赖与 Armstrong 公理

1. 函数依赖

在关系 RR 中,函数依赖:

XYX \to Y

表示:如果两个元组在 XX 上相等,那么它们在 YY 上也必须相等。

形式化地说:

t1,t2r,t1[X]=t2[X]t1[Y]=t2[Y]\forall t_1,t_2 \in r,\quad t_1[X]=t_2[X]\Rightarrow t_1[Y]=t_2[Y]

例如:

student_id -> student_name

表示同一个学生编号只能对应一个学生姓名。

函数依赖描述的是业务规则或关系约束,而不是当前样本数据中“碰巧观察到”的关系。

如果当前数据中每个订单恰好只有一个商品,那么不能因此推出:

order_id -> product_id

真正的依赖必须来自业务语义,而不是某次查询结果。

2. 平凡函数依赖

如果:

YXY \subseteq X

则:

XYX \to Y

称为平凡函数依赖

例如:

(A,B)A(A,B)\to A

一定成立,因为知道 (A, B) 自然知道 A

平凡依赖通常不提供新的建模信息,但在形式化推导中很重要。

3. 完全依赖与部分依赖

如果:

XYX\to Y

且不存在 XX 的真子集 XX',使得:

XYX'\to Y

则称 YY 完全函数依赖于 XX

如果存在真子集 XXX'\subset X,满足:

XYX'\to Y

则称 YY 部分依赖于 XX

例如:

Enrollment(student_id, course_id, student_name, grade)

假设:

(student_id,course_id)grade(student\_id,course\_id)\to grade

以及:

student_idstudent_namestudent\_id\to student\_name

那么 student_name 部分依赖于复合候选键 (student_id, course_id),因为它实际上只依赖 student_id

4. 传递依赖

如果:

XY,YZX\to Y,\quad Y\to Z

YY 不是候选键,也不是 ZZ 的候选键的一部分,通常称 ZZXX 存在传递依赖。

例如:

Employee(employee_id, department_id, department_name)

函数依赖为:

employee_iddepartment_idemployee\_id\to department\_id

department_iddepartment_namedepartment\_id\to department\_name

因此:

employee_iddepartment_nameemployee\_id\to department\_name

但部门名称的事实属于部门,而不是员工。把它放在员工表中会造成重复。

5. Armstrong 公理

Armstrong 公理是一组用于推导函数依赖的完备推理规则。

自反律

如果:

YXY\subseteq X

则:

XYX\to Y

例如:

(A,B)A(A,B)\to A

增广律

如果:

XYX\to Y

则对于任意属性集合 ZZ

XZYZXZ\to YZ

例如,由:

ABA\to B

可以推出:

ACBCAC\to BC

传递律

如果:

XY,YZX\to Y,\quad Y\to Z

则:

XZX\to Z

例如:

student_iddepartment_idstudent\_id\to department\_id

和:

department_iddepartment_namedepartment\_id\to department\_name

可以推出:

student_iddepartment_namestudent\_id\to department\_name

在此基础上还可以得到常用推理规则。

合并律

如果:

XY,XZX\to Y,\quad X\to Z

则:

XYZX\to YZ

分解律

如果:

XYZX\to YZ

则:

XY,XZX\to Y,\quad X\to Z

伪传递律

如果:

XY,WYZX\to Y,\quad WY\to Z

则:

WXZWX\to Z

例如:

AB,BCDA\to B,\quad BC\to D

可以推出:

ACDAC\to D

Armstrong 公理的作用,是在不枚举所有关系实例的情况下,对函数依赖进行逻辑推导。


五、属性闭包与最小覆盖

1. 最小覆盖的目标

函数依赖集合可能包含:

  • 右侧多个属性;
  • 左侧多余属性;
  • 完全冗余的依赖。

最小覆盖是与原函数依赖集合逻辑等价、但形式更简洁的一组依赖。通常要求:

  1. 每条依赖右侧只有一个属性;
  2. 左侧没有多余属性;
  3. 没有多余的函数依赖。

2. 逐步计算最小覆盖

设:

F={ABC, BC, ABD, DE}F=\{A\to BC,\ B\to C,\ AB\to D,\ D\to E\}

第一步:拆分右侧

将:

ABCA\to BC

拆成:

AB,ACA\to B,\quad A\to C

得到:

F1={AB, AC, BC, ABD, DE}F_1=\{A\to B,\ A\to C,\ B\to C,\ AB\to D,\ D\to E\}

第二步:删除右侧冗余依赖

检查 A -> C 是否可以由其他依赖推出。

在不使用 A -> C 的情况下:

A+={A}A^+=\{A\}

使用 A -> B

A+={A,B}A^+=\{A,B\}

使用 B -> C

A+={A,B,C}A^+=\{A,B,C\}

因此 A -> C 是冗余的,可以删除:

F2={AB, BC, ABD, DE}F_2=\{A\to B,\ B\to C,\ AB\to D,\ D\to E\}

第三步:检查左侧多余属性

检查 AB -> D 中的 A 是否多余。

若删除 A,需要检查:

B+B^+

B 出发只能得到:

B+={B,C}B^+=\{B,C\}

得不到 D,所以 A 不能删除。

检查 B 是否多余。

若删除 B,计算:

A+A^+

已有:

ABA\to B

因此得到 B,再使用 AB -> D 得到 D。也就是说:

ADA\to D

在原依赖集合下成立,因此 AB -> D 中的 B 是多余的,可以改写为:

ADA\to D

得到:

F3={AB, BC, AD, DE}F_3=\{A\to B,\ B\to C,\ A\to D,\ D\to E\}

第四步:检查整条依赖是否冗余

逐条尝试删除:

  • 删除 A -> B 后,其他依赖不能推出 B
  • 删除 B -> C 后,其他依赖不能推出 C
  • 删除 A -> D 后,其他依赖不能推出 D
  • 删除 D -> E 后,其他依赖不能推出 E

因此最小覆盖为:

Fmin={AB, BC, AD, DE}F_{\min}=\{A\to B,\ B\to C,\ A\to D,\ D\to E\}

注意:最小覆盖不一定唯一。不同的等价推导顺序可能得到形式不同但逻辑等价的结果。


六、第一范式:原子值和明确的列语义

1. 1NF 的基本含义

传统关系模型中的第一范式要求:

  1. 每个属性的值来自一个明确的域;
  2. 每个单元格只有一个值;
  3. 不在一个字段中嵌套一组同类值;
  4. 不通过重复列表达同一组结构。

反例:

Customer(
    customer_id,
    name,
    phone_numbers
)

如果 phone_numbers 中存储:

"13800000001,13900000002"

那么一个单元格中包含多个电话号码,数据库无法直接对单个电话号码进行约束、连接和索引。

更合理的结构是:

Customer(customer_id, name)

CustomerPhone(
    customer_id,
    phone_number,
    phone_type
)

其中:

  • Customer 一行表示一个客户;
  • CustomerPhone 一行表示客户的一个电话号码;
  • (customer_id, phone_number) 或单独的 phone_id 可以作为标识。

需要区分的是:JSON、数组、空间类型等复杂类型是否违反 1NF,取决于采用的理论模型和业务边界。若数据库把 JSON 视为一个原子域值,形式上可以存储;但如果应用需要对其中的子元素进行独立约束、关联和更新,那么把多个事实塞入 JSON 往往只是把关系设计问题转移到了应用层。


七、第二范式:消除对复合键的部分依赖

1. 形式化条件

关系满足 2NF,当且仅当:

  1. 它满足 1NF;
  2. 每个非主属性都完全函数依赖于每个候选键。

这里的“主属性”是指属于某个候选键的属性;“非主属性”是不属于任何候选键的属性。

2NF 主要处理复合候选键导致的部分依赖。

2. 反例

考虑:

Enrollment(
    student_id,
    course_id,
    student_name,
    course_name,
    grade
)

假设:

(student_id,course_id)grade(student\_id,course\_id)\to grade

student_idstudent_namestudent\_id\to student\_name

course_idcourse_namecourse\_id\to course\_name

候选键是:

(student_id,course_id)(student\_id,course\_id)

但:

  • student_name 只依赖 student_id
  • course_name 只依赖 course_id
  • 它们都不是对整个复合键的完全依赖。

因此该关系不满足 2NF。

3. 分解

拆成:

Student(
    student_id PRIMARY KEY,
    student_name
)

Course(
    course_id PRIMARY KEY,
    course_name
)

Enrollment(
    student_id,
    course_id,
    grade,
    PRIMARY KEY (student_id, course_id)
)

分解后:

  • 学生事实只保存一次;
  • 课程事实只保存一次;
  • 选课事实保存成绩;
  • 复合键只用于标识“某学生选择某课程”。

如果使用代理主键 enrollment_id,仍然不能忽略 (student_id, course_id) 的业务唯一性。否则同一个学生可能被重复登记同一门课程。


八、第三范式:消除非键属性之间的传递依赖

1. 形式化条件

关系 RR 满足 3NF,当且仅当对于每个非平凡函数依赖:

XAX\to A

至少满足以下一个条件:

  1. XX 是超键;
  2. AA 是主属性,即属于某个候选键。

3NF 比“所有非主属性都直接依赖主键”的口语描述更精确,因为它允许右侧属性是主属性。

2. 反例:员工与部门

Employee(
    employee_id,
    employee_name,
    department_id,
    department_name
)

依赖:

employee_idemployee_name,department_idemployee\_id\to employee\_name,department\_id

department_iddepartment_namedepartment\_id\to department\_name

候选键是 employee_id

department_id -> department_name 的左侧不是超键,右侧 department_name 也不是主属性,因此违反 3NF。

分解为:

Employee(
    employee_id PRIMARY KEY,
    employee_name,
    department_id
)

Department(
    department_id PRIMARY KEY,
    department_name
)

3. 插入、更新和删除异常

未规范化设计的风险通常表现为三类异常。

更新异常

一个部门名称重复出现在 1000 个员工行中。部门改名时,如果只修改了 999 行,数据库中就出现同一部门编号对应两个部门名称。

插入异常

如果部门信息只能通过员工表保存,那么在还没有员工之前,无法单独添加一个部门。

删除异常

如果删除某部门的最后一名员工,同时也删除了该部门的唯一记录,那么部门事实会丢失。

规范化的目的不是让表“看起来整齐”,而是让不同事实分别拥有自己的存储位置和生命周期。


九、BCNF:比 3NF 更严格的决定因素条件

1. 形式化条件

关系 RR 满足 BCNF,当且仅当对每个非平凡函数依赖:

XYX\to Y

都有:

XX

是超键。

BCNF 不再允许“右侧是主属性”作为例外。因此:

3NF⇏BCNF3NF \not\Rightarrow BCNF

但:

BCNF3NFBCNF \Rightarrow 3NF

2. 3NF 但不满足 BCNF 的反例

考虑关系:

Teaching(student, course, instructor)

业务规则:

  1. 一个学生选一门课程时,授课教师确定:

(student,course)instructor(student,course)\to instructor

  1. 每位教师只教授一门课程:

instructorcourseinstructor\to course

候选键有:

  • (student, course)
  • (student, instructor)

因为 instructor -> course,但 instructor 不是超键,所以该关系违反 BCNF。

然而它满足 3NF:

  • (student, course) -> instructor 的左侧是候选键;
  • instructor -> course 的右侧 course 是主属性,因为 course 属于候选键 (student, course)

3. BCNF 分解

根据违反 BCNF 的依赖:

instructorcourseinstructor\to course

分解为:

InstructorCourse(instructor, course)

StudentInstructor(student, instructor)

无损连接证明

两个分解关系的交集是:

{instructor}\{instructor\}

InstructorCourse 中:

instructorcourseinstructor\to course

因此交集属性能够函数决定其中一个分解关系的全部属性,分解无损。

连接结果:

SELECT si.student, ic.course
FROM StudentInstructor AS si
JOIN InstructorCourse AS ic
  ON ic.instructor = si.instructor;

可以恢复原关系中的课程信息。

依赖保持问题

原依赖:

(student,course)instructor(student,course)\to instructor

在分解后的两个表中无法由单个表的局部约束直接表达。要验证它,通常需要连接两个表:

同一个学生和课程组合不能对应多个教师。

这意味着该 BCNF 分解虽然无损,但不一定依赖保持。

这正是 3NF 与 BCNF 的经典取舍:

  • BCNF 更强,能进一步消除某些冗余;
  • 3NF 通常更容易构造依赖保持的分解;
  • 生产系统中,能否由数据库约束直接维护依赖,往往比“形式上达到更高范式”更重要。

十、无损连接与依赖保持

1. 无损连接

将关系 RR 分解为 R1R_1R2R_2 后,若对所有满足原函数依赖的合法关系实例 rr,都有:

r=πR1(r)πR2(r)r=\pi_{R_1}(r)\Join\pi_{R_2}(r)

则称该分解具有无损连接性

直觉是:

分解后再连接,不能凭空产生原来没有的元组,也不能丢失原来的元组。

反例:

R(A, B, C)

当前数据:

A B C
1 x p
2 x q

分解为:

R1(A, B)
R2(B, C)

投影后:

R1

A B
1 x
2 x

R2

B C
x p
x q

重新连接得到:

A B C
1 x p
1 x q
2 x p
2 x q

其中两行是原数据中不存在的“伪元组”。这就是有损分解。

2. 二元分解的常用判定

RR 分解为 R1R_1R2R_2 时,令:

X=R1R2X=R_1\cap R_2

如果:

XR1X\to R_1

或:

XR2X\to R_2

F+F^+ 中成立,则该二元分解无损。

其中 F+F^+ 表示由 FF 能推导出的全部函数依赖。

3. 依赖保持

分解 RRR1,,RnR_1,\ldots,R_n 后,如果原函数依赖可以仅通过各个分解关系上的依赖进行验证,而无需连接关系,则称分解依赖保持

形式上,设 FiF_iFFRiR_i 上的投影。如果:

(F1F2Fn)+(F_1\cup F_2\cup\cdots\cup F_n)^+

能够覆盖原依赖 FF,则分解依赖保持。

依赖保持的工程价值在于:

  • 可以在单表上建立 PRIMARY KEYUNIQUECHECK 等约束;
  • 插入或更新时不必先连接多个表验证;
  • 约束失败更容易定位;
  • 在高并发下通常更容易获得明确的数据库级保证。

但不是所有业务依赖都能自然地表达为单表约束。跨行、跨表约束可能需要:

  • 唯一索引;
  • 排他约束;
  • CHECK
  • 触发器;
  • 存储过程;
  • 应用事务;
  • 专门的约束表。

不能因为“逻辑上存在函数依赖”,就误以为数据库一定能用一个普通外键自动表达它。


十一、3NF 综合分解算例

考虑关系:

OrderLine(
    order_id,
    product_id,
    customer_id,
    customer_name,
    product_name,
    quantity,
    unit_price
)

定义行粒度:

一行表示一个订单中的一个商品明细。

假设函数依赖为:

(order_id,product_id)customer_id,quantity,unit_price(order\_id,product\_id) \to customer\_id,quantity,unit\_price

customer_idcustomer_namecustomer\_id\to customer\_name

product_idproduct_nameproduct\_id\to product\_name

候选键为:

(order_id,product_id)(order\_id,product\_id)

其中:

  • customer_name 通过 customer_id 传递依赖;
  • product_name 只依赖 product_id,属于部分依赖;
  • 关系同时违反 2NF 和 3NF。

分解为:

CREATE TABLE customer (
    customer_id BIGINT PRIMARY KEY,
    customer_name TEXT NOT NULL
);

CREATE TABLE product (
    product_id BIGINT PRIMARY KEY,
    product_name TEXT NOT NULL
);

CREATE TABLE orders (
    order_id BIGINT PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    CONSTRAINT fk_orders_customer
        FOREIGN KEY (customer_id)
        REFERENCES customer(customer_id)
);

CREATE TABLE order_line (
    order_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    quantity INTEGER NOT NULL CHECK (quantity > 0),
    unit_price NUMERIC(12, 2) NOT NULL CHECK (unit_price >= 0),
    PRIMARY KEY (order_id, product_id),
    CONSTRAINT fk_line_order
        FOREIGN KEY (order_id)
        REFERENCES orders(order_id),
    CONSTRAINT fk_line_product
        FOREIGN KEY (product_id)
        REFERENCES product(product_id)
);

这组表的粒度分别是:

  • customer:一个客户;
  • product:一个商品;
  • orders:一个订单;
  • order_line:一个订单中的一个商品。

查询报表时再连接:

SELECT
    o.order_id,
    c.customer_name,
    p.product_name,
    ol.quantity,
    ol.unit_price
FROM orders AS o
JOIN customer AS c
  ON c.customer_id = o.customer_id
JOIN order_line AS ol
  ON ol.order_id = o.order_id
JOIN product AS p
  ON p.product_id = ol.product_id;

这里的连接不是数据重复,而是查询时重新组合不同事实。


十二、多值依赖与第四范式

1. 多值依赖

函数依赖表达:

XYX\to Y

即给定 XX 后,YY 只有一个确定值。

多值依赖表达的是:

XYX\twoheadrightarrow Y

即给定 XX 后,YY 的一组值与其他属性的取值集合相互独立。

考虑:

StudentProfile(student, hobby, language)

假设:

  • 一个学生可以有多个爱好;
  • 一个学生可以掌握多种语言;
  • 爱好集合与语言集合相互独立。

某个学生有两个爱好、两种语言时,表中往往需要存储四行笛卡尔组合:

student hobby language
Alice Reading English
Alice Reading Chinese
Alice Cycling English
Alice Cycling Chinese

这不是普通函数依赖问题,而是两个独立多值事实被放在同一关系中。

2. 4NF 条件

关系满足 4NF,当且仅当对于每个非平凡多值依赖:

XYX\twoheadrightarrow Y

XX 都是超键。

上面的 student ->> hobby 中,student 不是超键,因此违反 4NF。

分解为:

StudentHobby(student, hobby)

StudentLanguage(student, language)

这样每组独立多值事实只保存一次。

3. 4NF 的适用边界

4NF 不是“看到多行就拆表”。

只有当两个属性集合确实是独立的多值事实时,4NF 分解才成立。

例如:

OrderLine(order_id, product_id, discount)

discount 可能依赖订单、商品、促销规则等复杂条件。不能仅因为一个订单有多个商品,就把所有属性机械拆成多个表。

判断是否存在多值依赖,必须回到业务事实:

  • 一个学生的语言与爱好是否独立?
  • 一个产品的颜色和尺寸是否可以任意组合?
  • 一个订单的优惠是否依赖具体商品,而不是独立于商品?

如果组合并非笛卡尔独立,强行按 4NF 拆分会丢失真实约束。


十三、规范化不能替代历史快照设计

规范化描述的是当前事实之间的依赖,但业务系统经常还要保存历史发生时的事实

例如订单明细中有:

product_id
unit_price

商品表中也有当前价格:

product.current_price

这并不意味着 order_line.unit_price 是错误的重复字段。

原因是它们代表不同事实:

  • product.current_price:商品当前销售价格;
  • order_line.unit_price:订单成交时的价格。

它们的生命周期不同:

  • 商品价格可以今天改为 99;
  • 昨天已经支付的订单不应因此变成 99;
  • 订单明细必须保留当时实际成交价格。

因此,历史快照字段应明确命名和语义,例如:

order_line.unit_price_at_purchase

或者通过价格版本表表达:

ProductPrice(
    product_id,
    valid_from,
    valid_to,
    price
)

历史数据的“重复”只有在它表达不同时间点、不同上下文或不同业务事件时才是合理的。关键不是“相同值是否出现多次”,而是“这些值是否代表同一个事实”。


十四、反规范化:不是少建几张表,而是转移一致性责任

1. 什么是反规范化

反规范化是在规范化结构基础上,主动保留或引入冗余,例如:

  • 在订单表保存 total_amount
  • 在文章表保存 comment_count
  • 在订单明细保存成交时价格;
  • 在宽表中复制用户名称;
  • 创建汇总表或物化视图;
  • 将多个查询需要的字段预先展开。

反规范化常见动机包括:

  • 减少高频连接;
  • 降低报表查询成本;
  • 固定历史快照;
  • 适配读多写少的访问模式;
  • 将跨服务查询结果物化为本地读模型。

它不等于“数据库设计差”,但每一个冗余字段都必须有明确的一致性策略。

2. 一致性边界

假设订单表增加:

orders.total_amount

同时订单明细还能计算:

SUM(quantity * unit_price)

现在出现两个事实来源:

  1. orders.total_amount
  2. 明细行聚合结果。

必须明确:

  • 哪个是权威值;
  • 哪个是缓存或快照;
  • 何时更新;
  • 更新失败如何恢复;
  • 是否允许短暂不一致;
  • 查询应该读取哪个值。

如果要求强一致,可以在同一事务中完成订单明细写入和总额更新:

BEGIN;

INSERT INTO orders(order_id, customer_id)
VALUES (10001, 20001);

INSERT INTO order_line(order_id, product_id, quantity, unit_price)
VALUES
    (10001, 30001, 2, 19.90),
    (10001, 30002, 1, 29.00);

UPDATE orders
SET total_amount = (
    SELECT COALESCE(SUM(quantity * unit_price), 0)
    FROM order_line
    WHERE order_id = 10001
)
WHERE order_id = 10001;

COMMIT;

但这段 SQL 仍需考虑并发:

  • 是否允许其他事务同时插入该订单的明细;
  • 总额更新是否覆盖其他事务的结果;
  • 读取总额时要求什么隔离级别;
  • 事务失败时是否完整回滚。

PostgreSQL 和 MySQL/InnoDB 都提供事务和隔离级别,但默认隔离级别不同:PostgreSQL 默认是 READ COMMITTED,MySQL 8.4 的 InnoDB 默认是 REPEATABLE READ。因此,涉及派生冗余字段的并发正确性时,不能只看 SQL 语句本身,还要明确事务边界和隔离语义。(dev.mysql.com)

如果允许最终一致,则可以:

  1. 先写入事实表;
  2. 发布可靠事件;
  3. 异步更新汇总表;
  4. 定期通过全量聚合校验和修复。

这时查询结果可能暂时落后,但必须把“允许落后多久、谁负责修复、如何发现不一致”定义清楚。

3. 反规范化的失败表现

常见失败不是查询慢,而是数据出现多个互相矛盾的答案:

  • 列表页显示 comment_count = 10,详情页实际只有 9 条评论;
  • 订单总额与支付金额不一致;
  • 用户改名后,新页面显示新名字,历史报表显示随机旧名字;
  • 删除明细后汇总字段没有减少;
  • 重放消息导致计数重复增加;
  • 多个写入入口采用不同规则更新同一个冗余字段。

诊断时,应先建立“权威事实”:

SELECT
    order_id,
    SUM(quantity * unit_price) AS calculated_total
FROM order_line
GROUP BY order_id;

再与冗余值比较:

SELECT
    o.order_id,
    o.total_amount,
    x.calculated_total
FROM orders AS o
JOIN (
    SELECT
        order_id,
        SUM(quantity * unit_price) AS calculated_total
    FROM order_line
    GROUP BY order_id
) AS x
  ON x.order_id = o.order_id
WHERE o.total_amount <> x.calculated_total;

如果 NULL 可能存在,还需要明确 NULL 是缺失、未知还是“没有明细”,不能直接用普通 <> 判断所有异常。


十五、SQL 中的 NULL、唯一性和理论边界

关系理论中的函数依赖通常假设每个属性都有明确值。SQL 的 NULL 会带来额外语义:

SELECT NULL = NULL;

结果不是 TRUE,而是 UNKNOWN

因此,不能把 NULL 当成一个普通的字符串或特殊编号。

唯一约束也需要注意产品差异。

PostgreSQL 默认把唯一约束中的两个 NULL 视为不相等,因此允许多个 NULL;也支持 NULLS NOT DISTINCT,把多个 NULL 视为相同。(postgresql.org)

例如:

CREATE TABLE user_email (
    email TEXT UNIQUE
);

在 PostgreSQL 默认语义下,可以存在多行 email IS NULL

如果业务要求最多一个缺失邮箱,需要显式设计约束,而不能只凭 UNIQUE 的直觉判断。

外键中的 NULL 也有类似边界。PostgreSQL 默认的 MATCH SIMPLE 允许复合外键的部分列为 NULL,只要参与匹配的列中有 NULL,就不要求匹配父表;MATCH FULL 则要求要么全部为空,要么全部匹配。(postgresql.org)

例如:

FOREIGN KEY (tenant_id, external_user_id)
REFERENCES external_user(tenant_id, external_user_id)
MATCH FULL

这表达的是一个整体可选的复合引用,而不是两个相互独立的可选字段。

在 MySQL 8.4 中,CHECK 表达式对 NULL 的处理是:表达式为 TRUEUNKNOWN 时允许该行,只有为 FALSE 时违反约束。(dev.mysql.com)

所以:

CHECK (quantity > 0)

如果 quantity 允许 NULL,那么 quantity = NULL 不会因为该 CHECK 自动失败。若业务要求必须有数量,应同时写:

quantity INTEGER NOT NULL CHECK (quantity > 0)

这也是为什么“函数依赖成立”与“SQL 约束真的阻止非法数据”之间不能直接画等号。必须同时检查:

  • 是否允许 NULL
  • 唯一约束如何处理 NULL
  • 外键是否真正由当前存储引擎执行;
  • 约束是否立即检查;
  • 多列约束是否整体生效;
  • 事务失败时是否完整回滚。

例如 MySQL 中,外键主要由 InnoDB 和 NDB 支持;对其他存储引擎,相关语法可能被解析但不实际执行。(dev.mysql.com)


十六、从函数依赖到数据库约束

函数依赖是理论描述,SQL 约束是实现手段。两者可以对应,但不能完全等同。

业务规则 常见 SQL 表达
标识符唯一且非空 PRIMARY KEY
备用候选键唯一 UNIQUE + NOT NULL
引用的父对象必须存在 FOREIGN KEY
单行内数值满足条件 CHECK
字段必须存在 NOT NULL
跨行唯一 唯一索引或排他约束
跨表聚合约束 触发器、事务、应用逻辑或汇总校验
历史有效区间不重叠 排他约束、专门的时间区间设计或事务控制

例如:

CREATE TABLE subscription (
    subscription_id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    plan_code TEXT NOT NULL,
    valid_from TIMESTAMP NOT NULL,
    valid_to TIMESTAMP,
    CHECK (valid_to IS NULL OR valid_to > valid_from)
);

CHECK 可以表达单行内的区间关系,但不能单独表达:

同一个用户的有效订阅区间不能重叠。

这种规则涉及多行,需要更强的约束机制或事务设计。PostgreSQL 提供排他约束等机制,但具体方案仍取决于数据类型、索引方法和业务语义;不能把普通 UNIQUE 当作区间不重叠约束。


十七、规范化设计的实际判断顺序

面对一个新表,可以按以下顺序推导,而不是先凭经验决定“拆几张表”。

第一步:确定行粒度

用一句完整的话描述:

一行表示什么业务事实?

如果这句话无法说清楚,暂时不要讨论范式。

第二步:列出候选键和业务唯一性

不要只写一个技术主键,还要识别:

  • 哪些属性组合业务上唯一;
  • 哪些字段只是显示信息;
  • 哪些字段需要允许重复;
  • 哪些业务唯一性需要 UNIQUE 表达。

第三步:列出函数依赖

区分:

  • 主键决定哪些属性;
  • 非键属性是否决定其他属性;
  • 复合键上的属性是否发生部分依赖;
  • 是否存在历史快照导致的不同时间语义。

第四步:计算闭包验证候选键

不要凭“看起来像唯一”判断键。使用属性闭包确认:

K+=RK^+=R

然后验证最小性。

第五步:构造最小覆盖

将依赖拆成单属性右侧,删除左侧多余属性和冗余依赖,为后续分解提供清晰输入。

第六步:检查范式

依次检查:

  1. 是否满足 1NF;
  2. 是否存在对复合键的部分依赖;
  3. 是否存在非超键决定因素;
  4. 是否存在非平凡多值依赖。

第七步:验证分解

至少检查:

  • 无损连接;
  • 依赖保持;
  • 候选键是否仍然可表达;
  • 外键和唯一约束是否能落地;
  • NULL 语义是否改变了原业务规则。

第八步:最后才讨论反规范化

如果保留冗余,需要写清楚:

  • 权威事实在哪里;
  • 冗余值何时生成;
  • 同步是否与事实写入处于同一事务;
  • 异步失败如何重试;
  • 如何定期校验;
  • 读请求允许什么程度的不一致。

结语

关系模型中的“表”不是字段的随意集合,而是具有明确粒度、属性域和约束的事实集合。

  • 回答一行如何被唯一识别;
  • 函数依赖回答哪些属性决定哪些事实;
  • 属性闭包用于验证超键和候选键;
  • 最小覆盖把依赖集合化简为可推导、可操作的形式;
  • 1NF处理原子值和行结构;
  • 2NF消除对复合键的部分依赖;
  • 3NF消除非键属性之间的传递依赖;
  • BCNF要求每个非平凡决定因素都是超键;
  • 4NF处理独立多值事实;
  • 无损连接保证拆分后能够正确恢复;
  • 依赖保持决定约束能否在分解后的表上直接验证;
  • 反规范化则是在明确的一致性边界内,用冗余换取查询、快照或系统集成能力。

真正可靠的 Schema 设计,不是追求某个范式名称,而是让每个事实都有清晰的归属,让每个约束都有可执行的实现,让每次冗余都有明确的权威来源和恢复路径。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。