数据库基础体系 · 第 2/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
关系模型与规范化:键、函数依赖、范式和反规范化边界
所属系列:数据库基础体系
所属模块:一、数据库共同原理
标签:数据库、SQL、数据建模、后端
版本与范围:本文的 SQL 示例以 PostgreSQL 18 和 MySQL 8.4、InnoDB 为主要语境;关系模型、函数依赖和范式属于与具体产品无关的关系数据库理论。涉及NULL、外键检查、唯一约束和事务行为时,会明确区分理论语义与产品实现。
关系模型解决的不是“如何把对象映射成表”这么简单的问题,而是要回答:
- 一行数据究竟表示什么;
- 哪些列能够唯一确定一行;
- 一个事实应该存储一次还是多次;
- 某列发生变化时,需要修改多少处;
- 拆分表以后,原来的事实是否仍然能够无损恢复;
- 约束能否由数据库直接验证;
- 为了查询性能而复制数据时,一致性责任由谁承担。
规范化的核心,就是利用键和函数依赖识别数据中的事实结构,并通过分解关系消除不必要的重复。反规范化则是在明确承担一致性成本的前提下,有意保留某些重复。
一、从“表”回到关系模型
1. 关系、元组、属性和域
在关系模型中,一个关系可以理解为一个有限集合:
其中:
- 是关系模式;
- 是满足该模式的某个关系实例;
- 关系实例中的每个元素称为元组;
- 关系模式中的每个命名字段称为属性;
- 每个属性的取值范围称为域。
例如:
Student(student_id, student_name, department_id)
这是关系模式。它描述了一个关系的结构,但不是当前存储的数据。
某一时刻的数据可能是:
| student_id | student_name | department_id |
|---|---|---|
| 1001 | Alice | CS |
| 1002 | Bob | EE |
这组行是关系实例。
在 SQL 中,通常把:
- 关系模式对应为表结构;
- 元组对应为行;
- 属性对应为列;
- 域对应为数据类型以及更严格的取值约束。
但 SQL 表与数学意义上的关系并不完全相同:
- 数学关系是集合,不允许重复元组;SQL 查询结果默认可能包含重复行,需要
DISTINCT才去重。 - 数学模型通常不把
NULL当作普通域值;SQL 使用NULL表示未知、缺失或不适用,并由三值逻辑参与比较。 - 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_at、customer_id,会在多个订单明细行中重复; - 明细级属性,如
quantity、unit_price,则随商品明细变化。
如果不先确定粒度,后续的键、函数依赖和范式判断都会失去意义。
二、键:从“能定位一行”到“候选键”
设关系模式为:
1. 超键
属性集合 是一个超键,当且仅当:
也就是, 的取值能够函数式地确定关系中的全部属性。
“函数式确定”意味着:在合法关系实例中,不允许存在两行在 上相同、但其他属性不同。
例如:
Employee(employee_id, email, name, department_id)
如果业务约束保证:
employee_id唯一;email唯一;
那么:
{employee_id}是超键;{email}是超键;{employee_id, email}也是超键。
最后一个集合虽然能唯一定位行,但包含了不必要的属性。
2. 候选键
候选键是极小超键:
并且对于任意真子集 ,都有:
因此,候选键必须同时满足:
- 唯一性:能够确定整行;
- 最小性:去掉任意一个属性后就不再具备唯一确定能力。
一个关系可以有多个候选键。
例如:
User(user_id, email, username)
如果 user_id、email、username 都分别唯一,那么三者都是候选键。
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是主键;email和username是候选键的实现;display_name不是键的一部分,因为它不能保证唯一。
主键不一定是自然业务字段,也可以是代理键,例如自增整数或 UUID。但代理键只解决“如何稳定引用一行”,不能自动替代业务唯一性约束。
4. 外键
设子关系有属性集合 ,父关系有候选键 。如果子关系中的 引用父关系中的 ,则 是外键。
外键表达的是引用完整性:
例如:
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。
外键不等于自动建立业务语义。例如:
- 外键保证客户存在;
- 它不保证客户处于“允许下单”状态;
- 它不保证订单总金额等于订单明细金额之和;
- 它不保证删除客户时应该级联删除订单、禁止删除,还是转移归档。
删除和更新动作必须根据业务语义显式选择,例如 RESTRICT、CASCADE、SET NULL 等。PostgreSQL 支持多种引用动作和延迟约束检查;MySQL 8.4 的 InnoDB 支持常见引用动作,但不支持延迟约束检查,NO ACTION 按 InnoDB 语义视为 RESTRICT。(postgresql.org)
三、用属性闭包求候选键
1. 属性闭包的定义
给定属性集合 和函数依赖集合 , 在 下的闭包记为:
它表示:从 出发,依据 能够推出的全部属性。
若:
则 是超键。
如果同时没有任何真子集能够闭包到 ,则 是候选键。
2. 闭包算法
计算 的基本过程:
- 初始令 ;
- 如果函数依赖 满足 ,就把 加入 ;
- 重复执行,直到闭包不再变化;
- 若闭包包含关系的全部属性,说明 是超键;
- 再逐个删除 中的属性,检查是否仍能覆盖全部关系属性,以判断最小性。
3. 完整算例
设:
函数依赖为:
计算
初始:
根据 :
根据 :
此时:
CD -> E还缺少D;E -> A已经没有新增属性。
所以:
不能覆盖 ,因此 A 不是超键。
计算
初始:
根据 :
根据 :
根据 :
根据 不再新增属性。
因此:
所以 CD 是超键。
检查最小性:
- ,不能覆盖全部属性;
- ,不能覆盖全部属性。
因此 CD 是候选键。
计算
初始:
根据 :
根据 :
根据 :
所以 DE 是超键。
检查最小性:
- 不覆盖全部属性;
- ,缺少
D。
因此 DE 也是候选键。
因为 D 从未出现在任何函数依赖的右侧,所以任何候选键都必须包含 D。再分别检查其余属性:
AD:A -> B -> C,随后CD -> E,因此闭包为全部属性;BD:B -> C,随后CD -> E -> A,因此闭包为全部属性;CD和DE的闭包已在上面逐步算出;D单独不能推出其他属性,而A、B、C、E单独都缺少D,所以上述四组都满足最小性。
因此全部候选键为:
若选择 CD 作为主键,其他候选键仍然应该通过 UNIQUE 等约束表达,否则数据库只知道主键唯一,不知道其余组合也必须唯一。
四、函数依赖与 Armstrong 公理
1. 函数依赖
在关系 中,函数依赖:
表示:如果两个元组在 上相等,那么它们在 上也必须相等。
形式化地说:
例如:
student_id -> student_name
表示同一个学生编号只能对应一个学生姓名。
函数依赖描述的是业务规则或关系约束,而不是当前样本数据中“碰巧观察到”的关系。
如果当前数据中每个订单恰好只有一个商品,那么不能因此推出:
order_id -> product_id
真正的依赖必须来自业务语义,而不是某次查询结果。
2. 平凡函数依赖
如果:
则:
称为平凡函数依赖。
例如:
一定成立,因为知道 (A, B) 自然知道 A。
平凡依赖通常不提供新的建模信息,但在形式化推导中很重要。
3. 完全依赖与部分依赖
如果:
且不存在 的真子集 ,使得:
则称 完全函数依赖于 。
如果存在真子集 ,满足:
则称 部分依赖于 。
例如:
Enrollment(student_id, course_id, student_name, grade)
假设:
以及:
那么 student_name 部分依赖于复合候选键 (student_id, course_id),因为它实际上只依赖 student_id。
4. 传递依赖
如果:
且 不是候选键,也不是 的候选键的一部分,通常称 对 存在传递依赖。
例如:
Employee(employee_id, department_id, department_name)
函数依赖为:
因此:
但部门名称的事实属于部门,而不是员工。把它放在员工表中会造成重复。
5. Armstrong 公理
Armstrong 公理是一组用于推导函数依赖的完备推理规则。
自反律
如果:
则:
例如:
增广律
如果:
则对于任意属性集合 :
例如,由:
可以推出:
传递律
如果:
则:
例如:
和:
可以推出:
在此基础上还可以得到常用推理规则。
合并律
如果:
则:
分解律
如果:
则:
伪传递律
如果:
则:
例如:
可以推出:
Armstrong 公理的作用,是在不枚举所有关系实例的情况下,对函数依赖进行逻辑推导。
五、属性闭包与最小覆盖
1. 最小覆盖的目标
函数依赖集合可能包含:
- 右侧多个属性;
- 左侧多余属性;
- 完全冗余的依赖。
最小覆盖是与原函数依赖集合逻辑等价、但形式更简洁的一组依赖。通常要求:
- 每条依赖右侧只有一个属性;
- 左侧没有多余属性;
- 没有多余的函数依赖。
2. 逐步计算最小覆盖
设:
第一步:拆分右侧
将:
拆成:
得到:
第二步:删除右侧冗余依赖
检查 A -> C 是否可以由其他依赖推出。
在不使用 A -> C 的情况下:
使用 A -> B:
使用 B -> C:
因此 A -> C 是冗余的,可以删除:
第三步:检查左侧多余属性
检查 AB -> D 中的 A 是否多余。
若删除 A,需要检查:
从 B 出发只能得到:
得不到 D,所以 A 不能删除。
检查 B 是否多余。
若删除 B,计算:
已有:
因此得到 B,再使用 AB -> D 得到 D。也就是说:
在原依赖集合下成立,因此 AB -> D 中的 B 是多余的,可以改写为:
得到:
第四步:检查整条依赖是否冗余
逐条尝试删除:
- 删除
A -> B后,其他依赖不能推出B; - 删除
B -> C后,其他依赖不能推出C; - 删除
A -> D后,其他依赖不能推出D; - 删除
D -> E后,其他依赖不能推出E。
因此最小覆盖为:
注意:最小覆盖不一定唯一。不同的等价推导顺序可能得到形式不同但逻辑等价的结果。
六、第一范式:原子值和明确的列语义
1. 1NF 的基本含义
传统关系模型中的第一范式要求:
- 每个属性的值来自一个明确的域;
- 每个单元格只有一个值;
- 不在一个字段中嵌套一组同类值;
- 不通过重复列表达同一组结构。
反例:
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,当且仅当:
- 它满足 1NF;
- 每个非主属性都完全函数依赖于每个候选键。
这里的“主属性”是指属于某个候选键的属性;“非主属性”是不属于任何候选键的属性。
2NF 主要处理复合候选键导致的部分依赖。
2. 反例
考虑:
Enrollment(
student_id,
course_id,
student_name,
course_name,
grade
)
假设:
候选键是:
但:
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. 形式化条件
关系 满足 3NF,当且仅当对于每个非平凡函数依赖:
至少满足以下一个条件:
- 是超键;
- 是主属性,即属于某个候选键。
3NF 比“所有非主属性都直接依赖主键”的口语描述更精确,因为它允许右侧属性是主属性。
2. 反例:员工与部门
Employee(
employee_id,
employee_name,
department_id,
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. 形式化条件
关系 满足 BCNF,当且仅当对每个非平凡函数依赖:
都有:
是超键。
BCNF 不再允许“右侧是主属性”作为例外。因此:
但:
2. 3NF 但不满足 BCNF 的反例
考虑关系:
Teaching(student, course, instructor)
业务规则:
- 一个学生选一门课程时,授课教师确定:
- 每位教师只教授一门课程:
候选键有:
(student, course);(student, instructor)。
因为 instructor -> course,但 instructor 不是超键,所以该关系违反 BCNF。
然而它满足 3NF:
(student, course) -> instructor的左侧是候选键;instructor -> course的右侧course是主属性,因为course属于候选键(student, course)。
3. BCNF 分解
根据违反 BCNF 的依赖:
分解为:
InstructorCourse(instructor, course)
StudentInstructor(student, instructor)
无损连接证明
两个分解关系的交集是:
在 InstructorCourse 中:
因此交集属性能够函数决定其中一个分解关系的全部属性,分解无损。
连接结果:
SELECT si.student, ic.course
FROM StudentInstructor AS si
JOIN InstructorCourse AS ic
ON ic.instructor = si.instructor;
可以恢复原关系中的课程信息。
依赖保持问题
原依赖:
在分解后的两个表中无法由单个表的局部约束直接表达。要验证它,通常需要连接两个表:
同一个学生和课程组合不能对应多个教师。
这意味着该 BCNF 分解虽然无损,但不一定依赖保持。
这正是 3NF 与 BCNF 的经典取舍:
- BCNF 更强,能进一步消除某些冗余;
- 3NF 通常更容易构造依赖保持的分解;
- 生产系统中,能否由数据库约束直接维护依赖,往往比“形式上达到更高范式”更重要。
十、无损连接与依赖保持
1. 无损连接
将关系 分解为 和 后,若对所有满足原函数依赖的合法关系实例 ,都有:
则称该分解具有无损连接性。
直觉是:
分解后再连接,不能凭空产生原来没有的元组,也不能丢失原来的元组。
反例:
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. 二元分解的常用判定
将 分解为 和 时,令:
如果:
或:
在 中成立,则该二元分解无损。
其中 表示由 能推导出的全部函数依赖。
3. 依赖保持
分解 为 后,如果原函数依赖可以仅通过各个分解关系上的依赖进行验证,而无需连接关系,则称分解依赖保持。
形式上,设 是 在 上的投影。如果:
能够覆盖原依赖 ,则分解依赖保持。
依赖保持的工程价值在于:
- 可以在单表上建立
PRIMARY KEY、UNIQUE、CHECK等约束; - 插入或更新时不必先连接多个表验证;
- 约束失败更容易定位;
- 在高并发下通常更容易获得明确的数据库级保证。
但不是所有业务依赖都能自然地表达为单表约束。跨行、跨表约束可能需要:
- 唯一索引;
- 排他约束;
CHECK;- 触发器;
- 存储过程;
- 应用事务;
- 专门的约束表。
不能因为“逻辑上存在函数依赖”,就误以为数据库一定能用一个普通外键自动表达它。
十一、3NF 综合分解算例
考虑关系:
OrderLine(
order_id,
product_id,
customer_id,
customer_name,
product_name,
quantity,
unit_price
)
定义行粒度:
一行表示一个订单中的一个商品明细。
假设函数依赖为:
候选键为:
其中:
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. 多值依赖
函数依赖表达:
即给定 后, 只有一个确定值。
多值依赖表达的是:
即给定 后, 的一组值与其他属性的取值集合相互独立。
考虑:
StudentProfile(student, hobby, language)
假设:
- 一个学生可以有多个爱好;
- 一个学生可以掌握多种语言;
- 爱好集合与语言集合相互独立。
某个学生有两个爱好、两种语言时,表中往往需要存储四行笛卡尔组合:
| student | hobby | language |
|---|---|---|
| Alice | Reading | English |
| Alice | Reading | Chinese |
| Alice | Cycling | English |
| Alice | Cycling | Chinese |
这不是普通函数依赖问题,而是两个独立多值事实被放在同一关系中。
2. 4NF 条件
关系满足 4NF,当且仅当对于每个非平凡多值依赖:
都是超键。
上面的 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)
现在出现两个事实来源:
orders.total_amount;- 明细行聚合结果。
必须明确:
- 哪个是权威值;
- 哪个是缓存或快照;
- 何时更新;
- 更新失败如何恢复;
- 是否允许短暂不一致;
- 查询应该读取哪个值。
如果要求强一致,可以在同一事务中完成订单明细写入和总额更新:
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)
如果允许最终一致,则可以:
- 先写入事实表;
- 发布可靠事件;
- 异步更新汇总表;
- 定期通过全量聚合校验和修复。
这时查询结果可能暂时落后,但必须把“允许落后多久、谁负责修复、如何发现不一致”定义清楚。
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 的处理是:表达式为 TRUE 或 UNKNOWN 时允许该行,只有为 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表达。
第三步:列出函数依赖
区分:
- 主键决定哪些属性;
- 非键属性是否决定其他属性;
- 复合键上的属性是否发生部分依赖;
- 是否存在历史快照导致的不同时间语义。
第四步:计算闭包验证候选键
不要凭“看起来像唯一”判断键。使用属性闭包确认:
然后验证最小性。
第五步:构造最小覆盖
将依赖拆成单属性右侧,删除左侧多余属性和冗余依赖,为后续分解提供清晰输入。
第六步:检查范式
依次检查:
- 是否满足 1NF;
- 是否存在对复合键的部分依赖;
- 是否存在非超键决定因素;
- 是否存在非平凡多值依赖。
第七步:验证分解
至少检查:
- 无损连接;
- 依赖保持;
- 候选键是否仍然可表达;
- 外键和唯一约束是否能落地;
NULL语义是否改变了原业务规则。
第八步:最后才讨论反规范化
如果保留冗余,需要写清楚:
- 权威事实在哪里;
- 冗余值何时生成;
- 同步是否与事实写入处于同一事务;
- 异步失败如何重试;
- 如何定期校验;
- 读请求允许什么程度的不一致。
结语
关系模型中的“表”不是字段的随意集合,而是具有明确粒度、属性域和约束的事实集合。
- 键回答一行如何被唯一识别;
- 函数依赖回答哪些属性决定哪些事实;
- 属性闭包用于验证超键和候选键;
- 最小覆盖把依赖集合化简为可推导、可操作的形式;
- 1NF处理原子值和行结构;
- 2NF消除对复合键的部分依赖;
- 3NF消除非键属性之间的传递依赖;
- BCNF要求每个非平凡决定因素都是超键;
- 4NF处理独立多值事实;
- 无损连接保证拆分后能够正确恢复;
- 依赖保持决定约束能否在分解后的表上直接验证;
- 反规范化则是在明确的一致性边界内,用冗余换取查询、快照或系统集成能力。
真正可靠的 Schema 设计,不是追求某个范式名称,而是让每个事实都有清晰的归属,让每个约束都有可执行的实现,让每次冗余都有明确的权威来源和恢复路径。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 下一篇:SQL 完整基础:查询逻辑顺序、连接、子查询、窗口函数与集合运算
- 延伸:数据库 Schema 设计:数据类型、主外键、约束、NULL 与演进
- 延伸:数据库事务完整指南:ACID、隔离级别、异常现象与正确边界
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论