说实话,刚开始学《数据库系统原理》的时候,"关系数据库设计"这一章我是当八股文背的:第一范式要原子性,第二范式消除部分依赖,第三范式消除传递依赖,口诀倒背如流,可一回到真实业务做表结构,整个人就懵了——我连一张表该不该拆、能拆到第几范式,都拿不准。
这篇内容是我重新整理这章笔记的产物。除了把范式定义和函数依赖的推断过程摊开讲清楚,还会用一张"订单宽表"从头到尾拆一遍,最后再聊聊工程里为什么经常刻意"违背"第三范式。正在备考、准备面试,或者工作中需要设计数据表的同学,都可以把这套思路当成一份对照用的底稿。
1. 关系模型先把这几个概念钉死:元组、属性、键与主属性
1.1 关系不是"普通的表":四条性质决定设计边界
关系数据库里的"关系",听着玄乎,其实就是一张二维表。但它不是Excel里随手拉的那种表,它有四条硬约束,理解了这四条,很多设计问题都能从源头规避。
第一,每一列的名字在表内唯一,而且某一列的所有取值必须来自同一个"域"。域可以理解为取值范围,比如"年龄"列只能放整数,"性别"列只能放固定枚举值。第二,行与行之间不允许重复,整个关系本质上是集合,集合的元素天然不能重复。第三,列的顺序、行的顺序都不影响关系的内容。第四是最容易被忽略的:每个分量必须是不可再分的原子值,这就是第一范式(1NF)的雏形。
举个例子,一张学生表如果有一列叫"联系方式",单元格里填的是"010-12345678, 13800138000",这种"一个格子塞两个信息"的写法从关系模型角度看就是非法的。初学的时候觉得"无非是数据丑一点",但实际做项目时会发现,这种非原子列根本无法被SQL高效检索和聚合,最后只能靠字符串函数硬解,性能极差。
1.2 候选键、主键与外键:主属性判断别被"主键"带偏
键是关系模型的钢筋。先把几个概念对齐一下,后面判定BCNF时全靠它们:
| 术语 | 含义 | 例子 |
|---|---|---|
| 超键 | 能唯一标识元组的属性或属性组合,允许包含多余属性 | (学号,姓名)能唯一标识学生 |
| 候选键 | 最小的超键,去掉任一属性都无法唯一标识 | 学号 |
| 主键 | 从候选键里挑一个,作为正式标识 | 学号 |
| 外键 | 某关系中引用另一个关系主键的属性 | 选课表中的学号 |
| 主属性 | 属于任意候选键的属性,不是只属于主键 | 若(学号,课程号)是候选键,则学号、课程号都是主属性 |
| 非主属性 | 不属于任何候选键的属性 | 选课表中的成绩 |
这里最容易踩的坑是"主属性"这个词。很多人想当然地认为主属性就是主键里的属性,其实定义是"出现在任意候选键中的属性"。一个关系可以有好几个候选键,比如"身份证号"和"学号"都是候选键,选哪个当主键是设计者决定的,但无论选哪个,两个属性都属于主属性。这个区别在判断3NF和BCNF时会产生完全不同的结论,后面第三部分会专门用例子说明。
2. 函数依赖才是范式的底层密码:从"多值事实"到"事实错位"
2.1 函数依赖描述的是一条业务规则
函数依赖是关系数据库设计里最核心的分析工具,形式化定义是:设关系R上有属性集X和Y,如果对于任意两个元组,它们在X上的取值相同,那么它们在Y上的取值也必定相同,就称X函数确定Y,记作X→Y。
翻译成人话:X的值一旦定下来,Y的值就不可能有两个。身份证号→姓名,因为一个身份证号只能对应一个人;订单号→下单日期,因为一个订单只有一个下单时间。反过来,"姓名→身份证号"通常不成立,因为有重名。
这里我想强调一个很多教科书没点破的东西:函数依赖不是从某个具体表"推断"出来的,它是业务需求给定的规则。你设计"学生表"时,"学号→姓名"这条依赖天然存在,是因为业务就是这样运行的。所以做设计的第一步永远不是画表,而是找业务人员一条一条确认依赖关系。依赖收集错了,后面范式级别即使算到BCNF也是空中楼阁。
2.2 完全函数依赖与部分函数依赖:复合主键下的分水岭
在复合候选键(多个属性组合)的场景下,函数依赖会分裂成两种:完全函数依赖和部分函数依赖。
如果X→Y,且X的任何一个真子集都不能决定Y,那么Y完全函数依赖于X。比如选课表(学号,课程号,成绩),(学号,课程号)→成绩,且单独的学号或课程号都无法确定成绩,所以"成绩"完全依赖于"(学号,课程号)"。
反过来,如果在X里存在一个真子集X',使得X'→Y成立,那就是部分函数依赖。还是这张选课表,如果它长这样:SC(学号,课程号,成绩,姓名,系别),候选键是(学号,课程号)。那么"姓名"只依赖学号,而学号是候选键的真子集,"姓名"就对候选键形成了部分依赖。
部分依赖是第二范式要消灭的元凶。它造成的直接后果就是冗余和更新异常:同一个学生选了10门课,他的姓名就要在表里存10遍;改一次姓名,得更新这10行,漏掉任何一行数据就变得不一致。
2.3 传递函数依赖:间接依赖是第三范式的打击目标
传递依赖是指X→Y、Y→Z,而Z不是Y的子集,同时Y不能反过来决定X,这时X→Z就是传递依赖。
用选课表拆出来的学生表举例:S(学号,姓名,系编号),加上业务规则"学号→系编号,系编号→系主任",那么学号→系主任这条依赖就是通过"系编号"间接建立的"传递依赖"。
注意,判断传递依赖有个很关键的前提:Y必须不能决定X。如果Y→X也成立,那X和Y就是互相唯一决定的关系,两个属性实际上是"等价标识符"(比如学号和身份证号),这条依赖就不再是"传递"概念下的异常依赖。
2.4 异常的本质:一张表承载了多个"独立事实"
我在整理笔记时最大的感悟是:关系数据库设计中的各种"异常"——数据冗余、更新异常、插入异常、删除异常——本质只有一个:把多个本应独立的"事实"硬塞进了同一张表。你的表中既存"学生选课"的事实,又存"学生基本信息"的事实,还存"系别负责人"的事实,当这些事实的更新节奏不一致时,异常就出现了。
所以规范化设计不是"炫技",而是一场"事实拆分":把一张混装表,拆成每张只负责一个事实的表。理解了这个,再看范式定义,就不再是死记硬背,而是一套清晰的设计思路。
3. 范式逐级拆解:1NF到BCNF各自的判定与分级案例
3.1 1NF:原子性真的那么简单吗
第一范式的判定标准最简单:所有分量都必须是不可再分的原子值。符合1NF的关系也基本符合关系模型的定义,所以现实中绝大多数表天生就是1NF。
但真去审视项目,违反1NF的情况比想象中多。除了"联系方式"塞多值,"标签"列用逗号拼接一堆值,订单表中把"收货地址"写成一整段"省市区街道"字符串——这些在"数据库理论"上不能算规范的原子值。不是说业务上不能这样存,而是你要清楚地知道:一旦列里塞了结构化文本,SQL里那些优雅的等值查询、范围查询、聚合分析就全不适用了,只能靠应用层拆解。
3.2 2NF:消除非主属性对候选键的部分依赖
2NF的前提是满足1NF,再进一步要求:每个非主属性都完全函数依赖于候选键。
回到选课表SC(学号,课程号,成绩,姓名,系别),候选键是(学号,课程号)。它为什么不是2NF?因为"姓名""系别"这类非主属性只依赖候选键的真子集"学号",形成部分依赖。后果是:学生选3门课,姓名、系别就重复存3遍。
2NF的修法是把部分依赖的属性连同它依赖的那个属性拆出去,形成新实体:
- 学生表(学号,姓名,系别)
- 选课表(学号,课程号,成绩)
拆完之后,选课表里"成绩"对(学号,课程号)是完全依赖;学生表里"学号→姓名、系别"是单属性候选键,不存在真子集,自然没有部分依赖。
这里有个实用建议:只要你发现一个关系用了复合候选键,就第一时间检查所有非主属性是不是只依赖其中一部分。如果存在,立即拆。这是2NF最大的价值,也是实际表设计中最常见的整改动作。
3.3 3NF:切断非主属性之间的传递链
3NF在2NF基础上再进一步:非主属性不得传递依赖于候选键。它针对的是这样一条链条:候选键→某个非主属性A→另一个非主属性B。
用刚才拆出来的学生表S(学号,姓名,系编号,系主任)来看。业务事实是:学号→系编号,系编号→系主任,于是学号→系主任变成一条传递依赖。这张表里,同一个系只要有100个学生,"系主任"就会重复100次,而且如果这个系暂时没有学生,系主任信息根本插不进去——这就是插入异常。
处理方式依然是拆表:
- 学生表(学号,姓名,系编号),保留外键
- 系别表(系编号,系主任)
这里一般会连系名一起放进去。拆完以后,"学号→系主任"的关系通过外键关联实现,不再以冗余方式存于单表。
3.4 BCNF:决定因素必须是候选键
BCNF常被称为"修正的第三范式",它比3NF更严格。定义是:对于关系中的每一个非平凡函数依赖X→Y,X都必须是一个超键。通俗讲:任何一列被另一列决定时,决定者必须具备"独一无二标识整行"的能力。
区别在哪?3NF只管"非主属性"对候选键的依赖,BCNF要把"主属性"也纳入检查。看一个经典例子:
Teaching(教师,课程,班级),业务规则如下:每位教师只教一门课;每门课在一个班只由一位教师授课。可以推出函数依赖:教师→课程;(课程,班级)→教师。
这个关系的候选键有两个:(教师,班级)和(课程,班级),所有属性都属于候选键,都是主属性。既然没有非主属性,那它天然满足3NF——非主属性的部分依赖和传递依赖都不存在。可它满足BCNF吗?检查"教师→课程",左边"教师"不是超键,因为教师单独无法唯一确定"哪个班"这个信息,所以违反BCNF。
它有什么问题?如果张老师带3个班,表里就要出现3行"张老师,数据库原理",哪天张老师改教算法,这3行全得改。把它分解成:
- T1(教师,课程)
- T2(课程,班级)
就能把重复彻底消除。这个例子特别适合帮助理解3NF与BCNF的边界:3NF没抓住"主属性之间也存在错误依赖"这种情况。
3.5 用一个表格记住四个范式
| 范式 | 额外要求 | 消除的问题 | 检查对象 |
|---|---|---|---|
| 1NF | 属性不可再分 | 非原子数据 | 分量 |
| 2NF | 非主属性完全依赖候选键 | 部分依赖、冗余 | 非主属性 vs 候选键真子集 |
| 3NF | 非主属性不传递依赖候选键 | 传递依赖、插入/删除异常 | 非主属性之间的依赖链 |
| BCNF | 每个决定因素都是超键 | 主属性间的错误依赖 | 所有函数依赖左侧 |
从包含关系看,满足BCNF一定满足3NF,满足3NF一定满足2NF,满足2NF一定满足1NF。反过来不成立:3NF关系不一定是BCNF。
4. 规范化实操:一张订单宽表是怎么一步步拆成3NF的
4.1 原始宽表与函数依赖收集
下面用一个非常贴近真实业务的例子,把规范化全过程走一遍。假设某订单管理系统的原始表长这样:
订单明细表(订单号,客户编号,客户姓名,客户城市,商品编号,商品名称,单价,数量,订单日期)
一行表示"某一个订单里的某一个商品"。这张表看起来信息齐全,但仔细分析函数依赖,问题非常多:
- 订单号 → 客户编号、客户姓名、客户城市、订单日期
- 客户编号 → 客户姓名、客户城市
- 商品编号 → 商品名称、单价
- (订单号,商品编号) → 数量
下一步不是急着拆,而是先确认候选键。候选键是(订单号,商品编号)——单独一个订单号无法确定一行(因为一个订单有多个商品),单独一个商品编号也不行,只有把两者组合起来才能唯一定位"某订单中的某商品"。
4.2 从1NF到2NF:把"部分依赖"一个一个摘出来
这张表已经满足1NF。接着看是否存在非主属性对候选键(订单号,商品编号)的部分依赖——有,而且不少。
客户姓名、客户城市只依赖客户编号,客户编号是候选键的真子集,所以它们是部分依赖。商品名称、单价只依赖商品编号,商品编号同样是候选键的真子集,也是部分依赖。订单日期只依赖订单号,订单号还是候选键的真子集,所以在"候选键=订单号+商品编号"的前提下,订单日期也属于部分依赖。这一点特别容易漏,很多同学以为"部分依赖"只发生在与主键无关的散列属性上,其实它指的就是"候选键的一部分就能决定的属性"。
把所有这些部分依赖拆分出去,得到四张表:
- 客户表(客户编号,客户姓名,客户城市)
- 商品表(商品编号,商品名称,单价)
- 订单表(订单号,客户编号,订单日期)
- 订单明细表(订单号,商品编号,数量)
拆完之后,订单明细表只保留订单和商品的关系以及关系属性"数量"。客户改了电话和姓名,只需要更新客户表一行;商品改名,只需要更新商品表一行,历史订单引用的外键不变。
4.3 再走一轮:消除传递依赖,到达3NF
四张表现在各自是否满足3NF?逐一检查:
- 客户表:客户编号→客户姓名、客户城市,单属性候选键,没有部分依赖;两个非主属性都直接依赖客户编号,没有中间依赖链,满足3NF。
- 商品表:商品编号→商品名称、单价,同理满足3NF。
- 订单表:订单号→客户编号、订单日期。注意,客户编号在这里是外键,不是非主属性的"中间人",订单号并不通过客户编号去决定什么别的非主属性,所以无传递依赖,满足3NF。
- 订单明细表:候选键(订单号,商品编号),非主属性只有数量,完全依赖,没有传递链,满足3NF。
到这里,这套模型已经是3NF了。继续做BCNF检查:每个函数依赖的左侧都是对应表的候选键,所以它同时符合BCNF。整个拆分流程结束。
4.4 验证一下:拆分是否真的"无损"
拆分完之后有个必做动作:验证能不能通过自然连接(JOIN)无损还原回原始表。以上述方案为例,只要订单明细表的外键引用关系正确,执行"订单表 JOIN 订单明细表 JOIN 客户表 JOIN 商品表",就能把每一条"某订单的某个商品"连同客户、商品信息拼回原样,而且不会产生多余的笛卡尔积行,也不会丢失任何一行。
实际操作中,判断无损连接除了靠业务直觉,也有形式化算法,但大多数设计场景用"外键链路完整、业务主键唯一"就足以保证。真正要小心的是拆表时把"关系属性"和"实体属性"混在一起,比如把"数量"放在订单表而不是订单明细表,那样一JOIN数量就被放大,数据就错了。
对比一下冗余水平:原始宽表里一个订单有3个商品,客户姓名、客户城市、订单日期各重复3遍,商品名称和单价也各重复3遍。拆分后,订单日期只在订单表存1次,客户姓名只在客户表存1次,商品名称只在商品表存1次。数据行的总体积明显下降,更新异常也随之消失。
5. 工程里的"反范式":为什么规范过头反而碍事
5.1 范式化不是免费的:JOIN成本和写路径变复杂
上面讲了范式化如何消灭冗余和异常,但工程世界里没有银弹。范式化也有价格:数据被拆到多张表后,一次业务查询就可能要关联4到5张表,SQL复杂度上升,数据库执行计划的成本也随之升高;写入时为了维护外键约束,还要跨表检查和更新。
举一个典型场景:后台管理系统要展示"订单列表",每行包含客户姓名、商品名称、数量、日期。按3NF设计,这个列表要JOIN四张表才能出结果。如果订单量有百万级,这种高频查询每多一个JOIN,都是实实在在的延迟。理论上的"完美设计"无法直接满足性能指标,这就必须考虑反范式。
5.2 该保留冗余的两个典型场景
第一种是"读多写少"的报表/数仓场景。数据仓库里最常用的星型模型,本质就是围绕事实表把维度冗余进去。事实表里直接存着客户名称、商品名称,查询时几乎不需要JOIN,就是为了在超大表上换取稳定的查询性能。因为数据仓库里的维度信息更新频率极低,一天甚至一周才同步一次,冗余带来的不一致风险很小。
第二种是"历史快照"场景。订单表里冗余一份下单时的商品名称和单价,即使后来商品改名、改价,历史订单依然显示下单那一刻的快照值。这不属于常规意义上的"更新异常"——恰恰相反,这是业务要求:历史事实必须定格。否则,商品改名后,所有历史订单显示的商品名都会跟着变,那才是真正的数据错误。
这两种场景的共同点是:冗余字段都有明确来源,而且更新频率可控。反范式的重点是"有意识地冗余",跟"因为偷懒所以没有拆表"是两回事。
5.3 做反范式设计前,先回答这三个问题
我在实际项目里总结了一套判断标准,任何一个冗余字段在落地前,都应该过一遍以下三问:
- 冗余字段的来源表是什么?从哪条函数依赖推导而来?如果答不上来,说明这个字段说不清业务归属,迟早变成脏数据。
- 冗余数据的更新频率和延迟容忍度是多少?比如"客户最近一次登录时间"冗余在用户主表,允许5分钟延迟,那可以放心做;如果"账户余额"这种要求强一致就不该冗余。
- 读频率是否显著高于写频率?反范式的收益本质是"用冗余换速度",只有读非常频繁,这笔生意才划算。
只要这三个问题都能明确回答,反范式不是坏味道,而是有效的工程决策。
6. 整理这套关系数据库设计笔记时,我反复确认的几个判断捷径与易错点
6.1 一套可复用的范式判定流程
下面这个流程,是我自己做任何新表设计时都会走的步骤,也推荐你直接拿去用:
- 写全函数依赖集合:把业务规则逐一翻译成"X→Y"的形式,这一步别省。
- 求候选键:可以用闭包计算,也可以靠业务直觉先猜,再用函数依赖验证。"哪几个列组合起来能唯一定位一行"往往就是候选键。
- 判2NF:检查每一个非主属性是否对候选键存在部分依赖,有就拆。
- 判3NF:检查非主属性之间是否有传递依赖链,有就拆。
- 判BCNF:把每个函数依赖左侧都拉出来,看它是否都是超键,不是则继续拆。
一句话总结这个流程:找码,查依赖,拆出部分和传递,最后看看决定因素是不是码。
6.2 初学者最容易栽的三个判断点
第一个是"主属性"的理解。3NF只看非主属性,BCNF检查全部函数依赖。很多人判断BCNF时把主属性排除在外,结果像Teaching那个例子一样判断错误;反过来,把主属性当成"只能在主键里出现",也可能漏判候选键。
第二个是传递依赖的排除条件。Y→X的等价依赖不算传递异常。比如"学号→身份证号,身份证号→学号"同时成立,这两列是互相唯一决定的等价键,用任何一个都能定位到人,此时身份证号→姓名不算传递依赖。这种表不需要硬拆成两张,把其中一个列设为唯一约束即可。
第三个是复合键的部分依赖范围。只要某个非主属性被"候选键的真子集"决定,哪怕它直接依赖业务主字段(比如订单日期依赖订单号),也依然构成部分依赖。这是我从订单表例子里得到的最大教训,也是最容易让你在考试和面试中翻车的地方。
6.3 把范式翻译成工程语言
整理完这章笔记,我还有一个比较大的体会:范式的价值不在"达到第几级",而在"每一张表是否只表达一个清晰的事实"。只要你把事实定义清楚,范式级别是自然推导出来的;反过来,如果业务规则混乱,硬套范式也只会拆出一些莫名其妙、无法JOIN回去的怪表。
所以在设计新表之前,我建议你先把业务术语表梳理清楚,明确每个实体、每个属性、每条关系,把函数依赖当作一种"数据字典"。这个动作越扎实,后面无论走规范化流程,还是选反范式方案,都会顺畅很多。
实际做项目的时候,如果遇到一张又臭又长、更新还很费劲的表,不要再试图在上层打补丁,回到关系数据库设计的基本功里,重新收集依赖、重新定义实体,往往比改报错要省时间得多。