数据库范式
问题
什么是数据库范式?第一、第二、第三范式分别是什么?什么时候需要反范式?
答案
范式(Normal Form) 是一组用来规范表结构设计的规则,目的是减少数据冗余和避免数据异常。范式是从低到高逐级递进的关系:1NF → 2NF → 3NF → BCNF。
你可以把范式想象成"表结构的整理术"——就像整理房间一样,目标是让每样东西都有确定的位置,不重复、不混乱。
为什么需要范式?
假设我们有一个"不规范"的学生选课表:
| 学号 | 姓名 | 系名 | 系办公室 | 课程号 | 课程名 | 成绩 |
|---|---|---|---|---|---|---|
| 001 | 张三 | 计算机 | A301 | C01 | 数据库 | 85 |
| 001 | 张三 | 计算机 | A301 | C02 | 操作系统 | 90 |
| 002 | 李四 | 数学 | B205 | C01 | 数据库 | 78 |
这个表有什么问题?
- 数据冗余:张三的姓名、系名、系办公室被存储了 2 次(选了 2 门课)
- 插入异常:新建一个系但还没有学生时,无法插入系信息(主键不完整)
- 更新异常:计算机系搬到 A302 时,需要修改所有计算机系学生的记录
- 删除异常:如果李四退选了所有课程,删除后连李四的信息也丢了
范式就是用来解决这些问题的。
第一范式(1NF):列不可再分
每一列都是不可再分的原子值。
简单来说:一个格子里只放一个值,不能放多个值。
违反示例:
| 学号 | 姓名 | 联系方式 |
|---|---|---|
| 001 | 张三 | 手机:138xxx, 邮箱:zhang@xx |
联系方式里包含了多个值,违反 1NF。
修正:
| 学号 | 姓名 | 手机 | 邮箱 |
|---|---|---|---|
| 001 | 张三 | 138xxx | zhang@xx |
或者拆分为多条记录:
| 学号 | 姓名 | 联系类型 | 联系值 |
|---|---|---|---|
| 001 | 张三 | 手机 | 138xxx |
| 001 | 张三 | 邮箱 | zhang@xx |
在现代关系型数据库中,只要你正常建表,基本都自动满足 1NF。关系型数据库本身就不允许在一个字段里存储多个值(JSON 类型除外,但 JSON 内部不受范式约束)。
第二范式(2NF):消除部分依赖
在满足 1NF 的基础上,所有非主属性必须完全依赖于主键,不能只依赖主键的一部分。
"部分依赖"只在复合主键时才会出现。如果你的主键是单列的(如自增 ID),自动满足 2NF。
违反示例:
假设选课表的主键是 (学号, 课程号):
| 学号 (PK) | 课程号 (PK) | 姓名 | 课程名 | 成绩 |
|---|---|---|---|---|
| 001 | C01 | 张三 | 数据库 | 85 |
| 001 | C02 | 张三 | 操作系统 | 90 |
| 002 | C01 | 李四 | 数据库 | 78 |
问题分析:
成绩→ 完全依赖于(学号, 课程号)✅姓名→ 只依赖于学号,不需要课程号❌ 部分依赖课程名→ 只依赖于课程号,不需要学号❌ 部分依赖
修正:将部分依赖的属性拆分到独立的表中。
-- 学生表
CREATE TABLE students (
student_id INT PRIMARY KEY,
name VARCHAR(50)
);
-- 课程表
CREATE TABLE courses (
course_id INT PRIMARY KEY,
course_name VARCHAR(100)
);
-- 选课表(只保留完全依赖于复合主键的属性)
CREATE TABLE enrollments (
student_id INT,
course_id INT,
grade DECIMAL(4,1),
PRIMARY KEY (student_id, course_id)
);
第三范式(3NF):消除传递依赖
在满足 2NF 的基础上,非主属性不能依赖于其他非主属性(即不能有传递依赖)。
违反示例:
| 学号 (PK) | 姓名 | 系名 | 系办公室 |
|---|---|---|---|
| 001 | 张三 | 计算机 | A301 |
| 002 | 李四 | 数学 | B205 |
| 003 | 王五 | 计算机 | A301 |
依赖关系分析:
学号→姓名✅ 直接依赖学号→系名✅ 直接依赖学号→系办公室❌ 传递依赖(学号 → 系名 → 系办公室)
系办公室 实际上是由 系名 决定的,不是由 学号 直接决定的。
修正:
-- 学生表(去掉系办公室)
CREATE TABLE students (
student_id INT PRIMARY KEY,
name VARCHAR(50),
dept_name VARCHAR(50) -- 外键关联到系表
);
-- 系表(独立存储系的信息)
CREATE TABLE departments (
dept_name VARCHAR(50) PRIMARY KEY,
office VARCHAR(50)
);
这样,系办公室只存储一次,修改时也只需要修改一处。
BCNF(Boyce-Codd 范式)
在满足 3NF 的基础上,所有的决定因素都必须是候选键。
BCNF 是 3NF 的加强版,处理的是 3NF 无法解决的特殊情况——当主属性依赖于非主属性时。
违反示例:
假设一个仓库管理系统中,一个仓库只能有一个管理员,一个管理员只管一个仓库:
| 仓库号 (PK) | 物品号 (PK) | 管理员 | 数量 |
|---|---|---|---|
| W01 | P01 | 张三 | 100 |
| W01 | P02 | 张三 | 200 |
| W02 | P01 | 李四 | 150 |
- 主键:
(仓库号, 物品号) 管理员→仓库号(管理员可以确定仓库,但管理员不是候选键的一部分)
这违反了 BCNF,因为 管理员 不是候选键,但它能决定 仓库号。
大多数情况下满足 3NF 就足够了。BCNF 在面试中偶尔会问,但实际开发中很少特意去满足。
范式总结对比
| 范式 | 核心要求 | 消除的问题 | 助记 |
|---|---|---|---|
| 1NF | 列不可再分 | 非原子值 | 每个格子只有一个值 |
| 2NF | 消除部分依赖 | 非主属性依赖复合主键的一部分 | 非主属性完全依赖主键 |
| 3NF | 消除传递依赖 | 非主属性之间的间接依赖 | 非主属性不依赖其他非主属性 |
| BCNF | 所有决定因素都是候选键 | 主属性对非主属性的依赖 | 3NF 的加强版 |
反范式设计
在实际开发中,并不是范式越高越好。反范式(Denormalization) 是有意违反范式规则,通过适度冗余来换取查询性能。
什么时候用反范式?
| 场景 | 说明 |
|---|---|
| 高频查询 | 如果每次查询都需要 JOIN 多张表,可以把常用字段冗余到一张表中 |
| 报表统计 | 数据仓库通常使用宽表(一张大表包含所有维度),方便快速聚合 |
| 读多写少 | 冗余带来插入/更新开销,适合读多写少的场景 |
反范式示例
-- 范式设计:查询订单详情需要 JOIN 三张表
SELECT o.id, u.name, p.product_name, o.amount
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN products p ON o.product_id = p.id;
-- 反范式设计:把用户名和商品名冗余到订单表中
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT,
user_name VARCHAR(50), -- 冗余:用户名
product_id INT,
product_name VARCHAR(100), -- 冗余:商品名
amount DECIMAL(10,2)
);
-- 查询变简单了,不需要 JOIN
SELECT id, user_name, product_name, amount FROM orders;
冗余数据需要在写入时同步更新。例如用户改了名字,需要同步更新所有订单中的 user_name。如果同步不及时,就会出现数据不一致的问题。
范式 vs 反范式
| 对比 | 高范式(3NF) | 反范式 |
|---|---|---|
| 数据冗余 | 少 | 多 |
| 写入性能 | 好 | 差(需要同步冗余) |
| 查询性能 | 可能需要多表 JOIN | 单表查询,性能好 |
| 一致性 | 好,单点修改 | 差,需要同步 |
| 适用场景 | OLTP(在线事务处理) | OLAP(在线分析处理)/ 读多写少 |
常见面试问题
Q1: 用自己的话解释第一、第二、第三范式
答案:
- 1NF:表里每个格子只存一个值,不能塞多个值进去。比如"联系方式"不能同时存手机号和邮箱
- 2NF:非主键列必须完全依赖于整个主键,不能只依赖主键的一部分。这个问题主要出现在复合主键的表上
- 3NF:非主键列之间不能有间接依赖。比如
学号→系名→系办公室,系办公室应该放到系表中去
一句话总结:1NF 管列,2NF 管部分依赖,3NF 管传递依赖。
Q2: 实际工作中一定要满足第三范式吗?
答案:
不一定。实际工作中通常在 2NF ~ 3NF 之间取平衡:
- 核心业务表(用户、订单、支付):尽量满足 3NF,保证数据一致性
- 查询热点表:可以适度反范式,冗余常用字段,避免多表 JOIN
- 数据仓库/报表:通常使用宽表(完全反范式),所有维度摊平到一张表
关键原则:在数据一致性和查询性能之间找到业务需要的平衡点。
Q3: 什么是函数依赖?
答案:
函数依赖 是指属性之间的一种约束关系:如果知道了 A 的值,就能唯一确定 B 的值,记作 A → B。
学号 → 姓名:知道学号就能确定姓名 ✅姓名 → 学号:但知道姓名不一定能确定学号(可能同名) ❌
范式理论就是建立在函数依赖的基础上的:
- 2NF 消除的是部分函数依赖
- 3NF 消除的是传递函数依赖
Q4: 数据仓库为什么通常不遵循范式?
答案:
数据仓库主要用于分析查询(OLAP),特点是:
- 读多写少:数据通过 ETL 批量写入,很少更新
- 复杂聚合查询:频繁使用 GROUP BY、SUM、COUNT 等
- 多维度分析:需要从不同角度看同一份数据
如果严格遵循范式,每次查询都需要 JOIN 大量表,在亿级数据量下性能极差。因此数据仓库采用维度建模(星型模型/雪花模型) 和宽表,用空间换时间。
Q5: 如何判断一张表是否满足 3NF?
答案:
按以下步骤检查:
- 确认 1NF:每个字段是否只存一个原子值?有没有 JSON/数组/逗号分隔的值?
- 确认 2NF:如果主键是复合主键,非主键列是否完全依赖于整个主键?
- 确认 3NF:有没有传递依赖?即
A → B → C,C 是否应该拆到以 B 为主键的新表中?
-- 快速判断口诀:
-- 非主属性对主键:
-- 部分依赖 → 违反 2NF
-- 传递依赖 → 违反 3NF
-- 直接依赖 → 满足 3NF ✅
Q6: 反范式设计有哪些常见手段?
答案:
| 手段 | 说明 | 适用场景 |
|---|---|---|
| 冗余字段 | 在订单表中存储用户名,避免每次 JOIN 用户表 | 高频查询 |
| 预计算字段 | 在商品表中存储评分均值和评论数 | 统计型查询 |
| 宽表 | 将多张表的字段合并到一张大表 | 数据仓库、BI 报表 |
| 缓存表 | 单独建一张汇总表,定期刷新 | 复杂统计结果 |
| 历史快照 | 在订单项中存储下单时的商品名和价格 | 历史数据不变性 |
-- 示例:订单表冗余用户名(反范式)
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT,
user_name VARCHAR(50), -- 冗余字段,避免 JOIN users 表
total_amount DECIMAL(10,2),
-- 还可以冗余更多高频查询的字段
user_phone VARCHAR(20),
user_address TEXT
);
-- 代价:用户修改名字后,订单表中的名字不会自动更新
-- 解决:要么接受历史数据不变(通常是需要的),要么用触发器同步更新
Q7: 什么是候选键和超键?
答案:
- 超键(Super Key):能唯一标识记录的属性集合(可能包含冗余属性)
- 候选键(Candidate Key):最小的超键(去掉任何一个属性就不能唯一标识了)
- 主键(Primary Key):从候选键中选出的一个
例子:学生表 (学号, 身份证号, 姓名, 年龄)
超键:{学号}, {身份证号}, {学号,姓名}, {学号,身份证号,姓名,年龄} ...
候选键:{学号}, {身份证号}(都是最小能唯一标识的)
主键:选择 {学号}(从候选键中指定一个)
相关链接
- 数据库范式 - 维基百科
- MySQL 表设计最佳实践- ER 模型与数据建模 - 范式设计的前提是正确的 ER 建模
- 关系模型 - 范式理论的数学基础
- 约束与完整性 - 约束帮助保证范式的数据完整性