从范式到 E-R 图:数据库设计
数据库设计是后端开发的基本功,也是面试与实战中反复被拷问的核心能力。很多人建表时凭感觉"拍脑袋",结果数据冗余、更新异常、插入删除处处踩坑。其实这一切的根源,是没有真正理解范式 、实体与类的映射 、以及 E-R 图这套方法论。本文结合一份数据库设计课件,把每一条要点掰开揉碎讲清楚,并融入我自己的理解,希望能帮你建立起一套完整的数据库设计思维框架。
引言:为什么数据库设计值得如此郑重对待
如果把写代码比作盖楼,那么数据库设计就是打地基 。地基打得歪,楼盖得越高塌得越惨。我们经常会听到这样的抱怨:"这张表怎么存了这么多重复数据?""改一个字段为什么要把一万行全部更新?""一门新课还没人考试,为什么就存不进数据库?"------这些问题的本质,都是数据库设计不合理。
合理的关系型数据库设计,需要遵循一组被反复验证过的规则,这就是范式(Normal Form) 。范式不是玄学,它是对"数据依赖关系"的严格约束,目的是消除冗余、避免操作异常。而范式解决的是"一张表内部怎么设计",要处理"多张表之间怎么协作",还得借助另一套工具------实体关系图(E-R 图)。
本文的脉络是总---分---总:先从全局理解数据库设计要解决什么问题,再逐层拆解范式、设计过程、类与实体与表的映射、E-R 图、三类实体关系,最后用实战建表把理论落地,并在结尾把所有线索收束成一套可复用的设计心法。
第一部分:范式------衡量表设计好坏的标尺
什么是范式
数据库的范式是一组规则。在设计关系数据库时,遵从不同的规范要求,可以设计出合理的关系型数据库,这些不同的规范要求就被称为不同的范式。
关系数据库一共有六种范式,层层递进:
| 范式 | 名称 | 核心约束(一句话) |
|---|---|---|
| 1NF | 第一范式 | 每一列都是不可再分的原子项 |
| 2NF | 第二范式 | 在 1NF 基础上,消除对主键的部分函数依赖 |
| 3NF | 第三范式 | 在 2NF 基础上,消除对主键的传递依赖 |
| BCNF | 巴斯-科德范式 | 在 3NF 基础上,每个决定因素都包含候选键 |
| 4NF | 第四范式 | 在 BCNF 基础上,消除非平凡的多值依赖 |
| 5NF | 第五范式(完美范式) | 消除连接依赖 |
范式逐级加强,越高的范式,数据冗余越小 。但这里有一个非常关键的工程判断:范式越高,虽然对数据关系的约束性越强,却也意味着表被拆得越碎,查询时往往需要更多、更复杂的表连接(JOIN) ,导致数据库 IO 更繁忙、性能下降。因此在实际应用中,数据库设计通常只需满足第三范式(3NF)即可,再高的范式属于"理论完备但工程上少见"的范畴,了解概念即可,不必刻意追求。
我的理解:范式本质上是在做一场"冗余与复杂度的权衡"。低范式把太多信息塞进一张表,省了连接却制造冗余;高范式把信息拆到极致,消了冗余却制造连接成本。3NF 是工程实践打磨出来的"甜点区"------既消灭了绝大多数异常,又不会让表结构碎到难以维护。
下面重点讲透 1NF、2NF、3NF 这三个最常用的范式。
第一范式(1NF):列的原子性
定义
- 数据库表的每一列都是不可分割的原子数据项,而不能是集合、数组、对象等非原子数据。
- 在关系型数据库设计中,满足第一范式是对关系模式的基本要求。不满足第一范式的数据库,甚至不能被称为关系数据库。
第一范式是"门槛级"的范式,它是后一切范式的地基。
示例:学生表(含学校信息)
假设要设计一张学生表,需要记录学生信息和学校信息。
反例(不满足 1NF) :把"学校"作为一个字段,里面存了学校名称、地址、电话等一坨信息。学校本质上是一个对象,可以继续被拆分,所以这样的列是非原子的,不满足第一范式。
正例(满足 1NF):把学校信息平铺到多个列里(如学校名称、学校地址各自成列),每一列都不能再进行拆分,此时就满足了第一范式。
值得注意的是:在标准的关系型数据库中,每一列都用基本数据类型表示,天然满足第一范式。换句话说,只要你在用真正的关系型数据库,1NF 几乎是"送分"的。但在 MySQL 这类支持 JSON 列的数据库里,如果偷懒把一堆信息塞进一个 JSON 字段,本质上又破坏了原子性,这是现代开发中容易被忽略的隐患。
第二范式(2NF):消除部分函数依赖
定义
在满足第一范式的基础上,不存在非关键字段对任意候选键的部分函数依赖 。第二范式的问题主要存在于定义了复合主键的表中。
这里要先厘清一个概念:
- 候选键:可以唯一标识一行数据的列或列的组合,可以从候选键中选一个或几个当作表的主键。
- 函数依赖:若由 X 能唯一确定 Y,就称 Y 函数依赖于 X。
- 部分函数依赖:对于复合主键 (X, Y),如果某个非主属性只依赖于 X(或只依赖于 Y),而不是完整地依赖于整个主键,就称为部分函数依赖。
判断口诀 :当一张表的主键只有一列(单列主键)时,不存在"依赖主键的一部分"的可能,因此天然满足第二范式。第二范式的坑,几乎只在复合主键场景下才会出现。
示例:学生选课成绩
需求:学生可以选修课程,课程有对应的学分,学生考试后每门课程会产生相应的成绩。
反例:用一张大表记录所有信息,字段形如:学号 + 课程名(复合主键)+ 学生姓名 + 年龄 + 性别 + 学分 + 成绩。
逐条分析依赖关系:
- 学生的姓名、年龄、性别只由学号决定,与课程名无关------它们部分依赖于主键。
- 课程的学分只由课程名决定,与学生无关------它也部分依赖于主键。
- 只有"成绩"才是真正依赖于"学号 + 课程名"这个完整主键的。
既然姓名、年龄、性别、学分这些列只与复合主键的一部分有关,就说明存在部分函数依赖,不满足第二范式。
不满足第二范式带来的四大问题:
- 数据冗余:学生的姓名、年龄、性别和课程的学分在每一行记录中重复出现,造成大量数据冗余。
- 更新异常:如果要调整 MySQL 课程的学分,就需要更新表中所有关于 MySQL 的记录。一旦执行中断,部分成功部分失败,就会出现"同一门课在不同记录里学分不一致"的脏数据。
- 插入异常:在这种设计下,成绩与"学生 + 课程"绑定,只有学生选修了某门课并考出成绩,才能生成一条记录。当一门新课还没有任何学生参加考试取得成绩时,这门新课根本存不进数据库------因为成绩为空时记录没有意义。
- 删除异常:把毕业学生的考试数据全部删除时,课程和学分信息也会连带被删掉,可能导致某段时间里数据库里完全缺失某门课程及其学分的信息。
正例 :把这张大表拆成三张表------学生表、课程表、成绩表 。学生表只存学生信息(主键学号),课程表只存课程信息(主键课程,含学分),成绩表通过学号和课程号建立关联。拆分后每张表都用单列主键,天然满足第二范式,冗余与异常一并消除。
第三范式(3NF):消除传递依赖
定义
在满足第二范式的基础上,不存在非关键字段对任一候选键的传递依赖。
- 传递依赖:若 A → B 且 B → C,则 C 传递依赖于 A。也就是某个非主属性不是直接依赖主键,而是"绕了一层"------通过另一个非主属性间接依赖主键。
示例:学生表中记录所属学院
要求学生表记录学生所属的学院,在满足第二范式的基础上对学生表做修改。
反例(不满足 3NF):在学生表里既放学生信息(学号、姓名、年龄、性别),又放学院信息(学院名、学院电话、学院地址),主键为学生 Id。分析依赖链:
- 学号、姓名、年龄、性别由主键 Id 直接决定------这是直接的强相关。
- 学院电话、学院地址由学院名决定,而学院名又由学生 Id 决定。
- 于是一条传递链出现了:学生 Id → 学院名 → 学院电话/学院地址。
一个表里出现了"两组强相关关系",而且它们之间存在传递现象,这正是传递依赖,因此不满足第三范式。它的危害和第二范式的冗余如出一辙:同一学院的所有学生,其学院电话、地址会被重复存储无数次;改一次学院电话就要刷一遍全表。
正例(满足 3NF) :把学院信息拆分出来,单独建一张学院表 (主键学院 Id,含学院名、电话、地址),学生表里只保留一个指向学院的外键。这样学生表所有列都直接依赖主键,传递依赖被消除,两张表都满足第三范式。
第二部分:数据库的设计过程
理解了范式(怎么把单表设计好),再看宏观的"一套数据库从业务到落地"的完整流程。课件给出了清晰的三步走:
- 从现实业务中抽象得到概念类
概念类是从现实世界中抽象出来的,在需求分析阶段就需要确定下来。这一步是从"业务语言"翻译到"模型语言"的关键一跃。 - 确定实体与实体之间的关系,并画出 E-R 图
E-R 图是一种用于描述数据模型的概念图,主要服务于数据库设计阶段,目的是方便项目参与人员(产品、开发、测试)之间的理解与沟通。 - 根据 E-R 图完成 SQL 语句的编写并创建数据库
把概念模型翻译成具体的建表语句,最终落地为可运行的数据库。
这条流水线体现了一个核心思想:先建模、再沟通、最后落地。直接跳到第 3 步写 SQL 的人,往往会在改需求时陷入灾难;而认真走完前两步的人,能把业务变化对数据库的冲击降到最低。
第三部分:类、实体、表------三者的对应关系
这是连接"面向对象的世界"与"关系型数据库的世界"的桥梁,也是初学者最容易混淆的地方。课件用一组漂亮的映射把它们串起来:
| 面向对象(概念模型) | 实体模型 | 关系型数据库 |
|---|---|---|
| 类(Class) | 实体(Entity) | 表(Table) |
| 类中的属性 | 实体的属性 | 表中的列 |
| 对象(实例) | 实体的一个取值 | 表中的一行记录 |
我的理解:这套映射的本质,是把"对象的世界"平移到"表的世界"。面向对象强调封装、继承、多态,而关系型数据库只有"表、行、列"。理解了三者的对应,你就能在做 ORM(对象关系映射)时心里有数:为什么一个 Java 对象往往对应表里的一行,为什么类的字段会变成表的列。把这一层关系理顺,后面所有的关系映射都会变得顺理成章。
第四部分:E-R 图------描述数据模型的通用语言
什么是 E-R 图
实体-关系图(Entity-Relationship Diagram)简称 E-R 图,也称作实体联系模型、实体关系模型,是一种用于描述数据模型的概念图,主要用于数据库设计阶段。它是设计者与所有人沟通的"通用语言"------不需要懂 SQL,也能看懂整个系统的数据结构。
E-R 图的基本组成(三种成分)
E-R 图由三种基本成分构成,记住它们的图形表示是看懂/画好 E-R 图的前提:
- 实体 :即数据对象,用矩形框表示,比如用户、学生、班级等。
- 属性 :实体的特性,用椭圆形或圆角矩形表示,如学生的姓名、年龄等。
- 关系 :实体之间的联系,用菱形框表示,并标明关系的类型,用直线将相关实体与关系连接起来。
关系的类型
实体之间的联系有且只有三种基本类型:一对一、一对多、多对多。把它们吃透,几乎就能建模现实世界里所有数据关系。
一对一关系(1 : 1)
- 用户实体包含的属性:用户昵称、真实姓名、手机号、邮箱地址、性别、学校。
- 账户实体包含的属性:登录用户名、密码。
- 一个用户对应一个账户,一个账户也只属于一个用户------二者是一对一关系。
为什么要把用户和账户拆成两张表?因为密码、登录用户名属于安全敏感信息,且访问频率与用户基础信息不同。拆开后,把不常变动的基础信息和敏感凭证分离,是常见的设计优化。
一对多关系(1 : N)
- 学生实体包含的属性:真实姓名、学号、年龄、性别、入学时间。
- 班级实体包含的属性:班级名、学生人数。
- 一个班级中有多个学生,所以班级与学生是一对多 关系;反过来说,学生与班级是多对一关系。
注意:1 : N 和 N : 1 只是观察方向不同,描述的是同一种关系。在建模时我们习惯站在"一"的一方称其为"一对多"。
多对多关系(M : N)
- 学生实体包含的属性:真实姓名、学号、年龄、性别、入学时间。
- 课程实体包含的属性:课程名。
- 一个学生可以选修多门课程,一门课程也可以被多名学生选修------所以学生与课程之间是多对多关系。
关键技巧 :对于多对多关系,需要使用中间表来记录。比如"一个学生参加了某一门课程的考试得到了相应的成绩",这个"成绩"本身就依附于"学生 × 课程"的组合,天然适合作为一张独立的中间表(成绩表),同时外键指向学生和课程。
我的理解:为什么 1 : 1 和 1 : N 不需要中间表,而 M : N 一定要?因为在关系型数据库里,一张表的某一列只能存一个值。让一个学生存多个课程号是做不到的,所以必须另起一张表,把"多对多"拆成"两个一对多"。中间表(成绩表)就是这种拆分的载体------它同时是 M : N 的解法,也是把"成绩"这种关系属性安放的天然位置。这个"用中间表把多对多拆成两个一对多"的技巧,是数据库建模中出现频率最高的招式之一。
第五部分:实战------把 E-R 图翻译成建表语句
理论落地的关键时刻来了。下面根据三类 E-R 关系,给出完整的建表 SQL。课件中的 SQL 已修正了原文的若干笔误,并补上了外键约束,让模型真正"立得住"。
一对一:用户与账户
实体间一对一关系,只需要在其中一个实体中添加对另一个实体的关联字段即可。
方案一:在"用户"表中放账户外键
sql
-- 用户表
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20) NOT NULL,
nickname VARCHAR(20),
phone_num VARCHAR(11),
email VARCHAR(50),
gender TINYINT(1),
account_id BIGINT -- 关联账户
);
-- 账户表
DROP TABLE IF EXISTS account;
CREATE TABLE account (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(20) NOT NULL,
password VARCHAR(32) NOT NULL
);
方案二:在"账户"表中放用户外键
sql
-- 用户表
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20) NOT NULL,
nickname VARCHAR(20),
phone_num VARCHAR(11),
email VARCHAR(50),
gender TINYINT(1)
);
-- 账户表
DROP TABLE IF EXISTS account;
CREATE TABLE account (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(20) NOT NULL,
password VARCHAR(32) NOT NULL,
users_id BIGINT -- 关联用户
);
取舍建议:外键放哪一边都行,但应放在"访问关系更自然 "的一方。本例中一个账户必然归属一个用户,把
users_id放在 account 表里更符合直觉。
一对多:学生与班级
分别在"多"的一方(学生表)添加一列,与"一"的一方(班级表)建立关联。
sql
-- 班级表
DROP TABLE IF EXISTS class;
CREATE TABLE class (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20)
);
-- 学生表
DROP TABLE IF EXISTS student;
CREATE TABLE student (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20) NOT NULL,
sno VARCHAR(10) NOT NULL,
age INT DEFAULT 18,
gender TINYINT(1),
enroll_date DATE,
class_id BIGINT -- 关联班级
);
规律:一对多,外键永远放在"多"的一方。 因为一个班级对应多个学生,只能让每个学生记住自己属于哪个班级(一行一个 class_id),而无法让班级记住所有学生。
多对多:学生、课程与成绩
学生可以选修多门课程,每门课程考试后会产生一个成绩。两个表之间没有办法直接建立关系,所以要用一张记录成绩的中间表。
sql
-- 学生表
DROP TABLE IF EXISTS student;
CREATE TABLE student (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20) NOT NULL,
sno VARCHAR(10) NOT NULL,
age INT DEFAULT 18,
gender TINYINT(1),
enroll_date DATE,
class_id BIGINT,
FOREIGN KEY (class_id) REFERENCES class(id)
);
-- 课程表
DROP TABLE IF EXISTS course;
CREATE TABLE course (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20)
);
-- 分数表(中间表)
DROP TABLE IF EXISTS score;
CREATE TABLE score (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
score FLOAT,
student_id BIGINT,
course_id BIGINT,
FOREIGN KEY (student_id) REFERENCES student(id),
FOREIGN KEY (course_id) REFERENCES course(id)
);
观察:成绩表同时持有两个外键,分别指向学生和课程。这正是"用中间表把多对多拆成两个一对多"的代码体现------成绩表对学生是"一对多",对课程也是"一对多"。
总结:一套可复用的数据库设计心法
回到开头的总---分---总主线。我们逐层拆解了范式、设计过程、三层映射、E-R 图与三类关系,最后用 SQL 把它们串成闭环。如果要把整篇文章浓缩成一套可带走的"设计心法",我会归纳为这样几条:
- 用范式把单表管好:1NF 保证列的原子性,2NF 消除部分函数依赖(重点盯住复合主键),3NF 消除传递依赖。工程上做到 3NF 即可,不必盲目追求高范式。
- 用"先建模、再沟通、后落地"的流程控制风险:从业务抽象概念类 → 画 E-R 图 → 翻译成 SQL。不要跳过建模直接写建表语句。
- 用映射打通两个世界:类 ↔ 实体 ↔ 表,属性 ↔ 列,对象 ↔ 行。这是面向对象与关系型数据库之间的翻译词典。
- 用 E-R 图统一沟通语言:矩形是实体、椭圆是属性、菱形是关系。
- 用三条铁律处理关系:一对一任选一方加外键;一对多外键放"多"的一方;多对多必须用中间表,把它拆成两个一对多。
数据库设计的精妙之处,在于它从来不是"背规则",而是"在冗余、复杂度与性能之间反复权衡"。范式的每一步拆分,都是在用"多一张表、多一次连接"换取"少一份冗余、少一类异常"。当你真正理解了每一个设计决策背后的"为什么",建表就不再是凭感觉拍脑袋,而是一种胸有成竹的工程判断。
这,也正是从"会写 SQL"迈向"会设计数据库"的关键一步。