☰
用一张“病表”讲透数据库规范化:从函数依赖到范式拆解
2026/10/2 3:40:44 网站建设 项目流程

上个月接手一个老项目的数据库维护,打开核心业务表那一刻,我整个人都不好了。一张选课表里塞了十几个字段,老师电话改一次要UPDATE上百行,新来的外聘教师因为还没排课,他的基本信息压根插不进表里。把这个表的建表SQL拉出来一看,它连第二范式都没满足,而项目已经用这套结构跑了五年。把“规范化”这三个字丢进搜索框,出来的东西也五花八门:有人在问2026年数学建模E题的数据要不要规范化,有人在聊无损检测里的规范化扫查,跟我要说的完全是两回事。

在关系数据库这个领域,规范化(Normalization)是设计表结构时的底层法则,它和“数据清洗中的归一化”不是同一个词,和“无损检测的扫查规范”更是八竿子打不着。它要解决的核心问题只有一个:表结构里因为冗余带来的各种“更新异常”。这篇是关系数据库系列的第一篇,我想用一张真实的“病表”做引子,把函数依赖、1NF到BCNF、无损分解这几件事掰开揉碎讲清楚。适合正在学数据库原理的学生、写SQL还没系统学过建模的后端开发者,以及维护老系统时天天被烂表气到的运维同学。

1. 一张千疮百孔的课程表:规范化到底解决了什么问题

1.1 从一次数据库巡检说起

老项目里的选课表结构大致是下面这个样子,我把字段名改成容易理解的版本,真实情况只会更乱:

CREATE TABLE course_selection ( student_no VARCHAR(20), -- 学号 student_name VARCHAR(50), -- 学生姓名 dept VARCHAR(50), -- 院系 dept_addr VARCHAR(100), -- 院系地址 course_no VARCHAR(20), -- 课程号 course_name VARCHAR(100), -- 课程名 credit INT, -- 学分 semester VARCHAR(20), -- 学期 teacher VARCHAR(50), -- 任课教师 teacher_phone VARCHAR(20), -- 教师电话 score DECIMAL(5,2), -- 成绩 PRIMARY KEY (student_no, course_no, semester) );

如果你只在面试题里见过这种表,可能第一眼觉得还挺正常。但它在生产环境里跑起来,浑身都是毛病。我挑几个最典型的场景说。

第一个是更新异常。数学系的张老师换了手机号,而他在这个系统里对应着12门课、覆盖300多个学生的选课记录,那么UPDATE语句要动几千行。如果有一条漏掉了,同一个老师就出现了两个电话号码,等到要联系老师时,你根本不知道哪个是真的。

第二个是插入异常。新来的李老师已经报到,但还没有被分配任何一门课,他的教师编号、电话、所属学院就无处安放。因为主键是(学号, 课程号, 学期),一个没有选课记录的教师,他的基本信息在这张表里没有“落座”的位置。要么强行塞一个NULL学号进去,要么干等着,反正录入不了。

第三个是删除异常。有个学生退掉了唯一一门课,删除这条选课记录的同时,这门课的老师电话、课程学分也跟着被物理删掉了。下次想查这门课的历史信息,啥也没了。

第四个是数据冗余。院系地址存在每一个该系学生的每一行里,课程名存在每一个选了这门课的学生记录里,学分也是。这张表里有几万行,冗余占据了多少存储倒是小事,问题是这些冗余字段一旦不一致,你根本不知道哪一行是对的。

1.2 四大异常的典型现场

上面说的四个场景,用一个表来对照就更清楚了:

异常类型触发操作具体表现根因
更新异常修改某教师电话需要改几千行,漏改则数据不一致教师信息依赖选课记录存在
插入异常新增一个未排课教师无法录入,因为主键缺选课信息无关信息被塞进同一张表
删除异常删除某学生退课记录连带删除教师和课程信息表粒度过粗,一删删一片
数据冗余正常存储姓名、院系、课程名反复出现多类实体混在同一关系里

这套异常的共同本质,就是“一个关系里混入了多个现实实体”。学生是一个实体,课程是一个实体,教师是一个实体,选课成绩是一个联系,你把它们统统压进一张表,主键只能勉强选一个复合键,结果每一个非主键字段都只和主键的一部分相关,或者只和某个中间字段相关。

1.3 规范化的基本思路:把大表拆成小表

规范化做的事情,说穿了就是一句话:把“混在一起”的实体和联系拆开,让每一张表只描述一件事。拆完之后,每个字段都必须“依赖于主键、完全依赖于主键、直接依赖于主键”,这就引出了函数依赖的概念。

写到这里必须提醒一下:很多人一听到“规范化”就去搜数据预处理里的Min-Max归一化、Z-Score标准化,还跑来问“我的成绩字段要不要规范化到0到1之间”。那是数据分析的规范化,跟关系数据库设计完全是两码事。判断一张业务表该不该拆、怎么拆,看的不是数值范围,而是字段之间的依赖关系。

2. 函数依赖:判定范式先搞懂这套“因果链”

2.1 什么是函数依赖:用学号推导姓名来理解

函数依赖(Functional Dependency,FD)是整个规范化理论的地基,比范式本身更重要。它的定义是:如果两个元组在属性X上的值相等,那么它们在属性Y上的值也必然相等,就说Y函数依赖于X,记作X→Y。

听着绕,其实生活里到处都是。学号一旦确定,姓名就确定了——同一个学号不可能对应两个不同姓名,所以学号→姓名。同一个院系编号,院系名称和办公地址也随之确定,所以dept_no→dept_name。函数依赖描述的是一种“唯一决定”的因果关系,它不是业务代码里的逻辑,而是数据本身的语义约束。

反例也很常见。学生选课后获得的成绩,能由学号单独决定吗?不能,同一个学生选了不同课程,成绩完全不同。成绩能由课程号单独决定吗?也不能,同一门课不同学生分数不一样。只有学号和课程号组合在一起,才能决定一个成绩,于是(学号, 课程号)→成绩。

2.2 完全依赖与部分依赖:复合键场景才有的坑

有了函数依赖,还要区分它的“强度”。假设复合键是(学号, 课程号),那么成绩这个属性,必须同时依赖学号和课程号二者,缺了任何一个都无法确定,这叫完全函数依赖。

但姓名这个属性,只依赖学号就能确定,虽然它在复合键的“管辖范围”内,实际上却只依赖复合键的一部分,这叫部分函数依赖。第二范式要解决的,就是把这个“只依赖复合键一部分”的属性挪出去。

区分完全依赖和部分依赖,最好的办法是做“删减测试”:把复合键里的某个属性删掉,看剩下的属性还能不能唯一定位目标属性。比如(学号)→姓名,删掉课程号之后依然成立,所以姓名对复合键是部分依赖;而(学号, 课程号)→成绩,删掉任何一个都无法确定成绩,所以成绩是完全依赖。

2.3 传递依赖:绕了一圈的间接依赖

还有一种更隐蔽的情况:学号→院系,院系→院系地址。虽然学号不能直接推导出院系地址?其实能,通过院系这个“中间人”绕了一圈。只要学号确定了,所属院系就确定了,院系地址也就跟着确定了。这个就叫传递依赖:X→Y,Y→Z,那么X→Z,前提是Y不能决定X(否则Y和X就等价了)。

传递依赖的麻烦在于,它把一个本不该出现在这张表里的信息(院系地址),通过另一个字段(院系)间接绑在了主键上。于是同一个院系的地址,被复制到该系所有学生的每一行里,改一次地址要UPDATE几十上百行。

依赖类型判定方法典型例子危害
完全函数依赖复合键中任何属性都不能删(学号, 课程号)→成绩无,这是理想状态
部分函数依赖复合键删掉部分属性后依赖仍成立学号→姓名数据冗余、更新异常
传递函数依赖X→Y且Y→Z,则X→Z学号→院系→院系地址数据冗余、更新异常

关于依赖,我补一句容易踩的坑:写代码时大家习惯把“业务上唯一的编号”当成函数依赖的左边,但数据库不认识你的业务逻辑,它只认约束。你在建表时有没有把UNIQUE、主键这些约束加对,决定了函数依赖到底能不能被数据库强制保证。如果连唯一索引都没加,“学号→姓名”在数据库层面就是不成立的,因为完全可以插两条同样学号但姓名不同的记录。分析函数依赖时,要先以数据库里实际存在的约束为准,业务嘴上说的“唯一”不算数。

3. 从1NF到BCNF:每一级范式卡在哪一关

3.1 第一范式:所有列都不可再分

第一范式(1NF)的要求最朴素:每一列都必须是原子的,不能再拆出子字段。比如把电话存成“010-88886666,138-1234-5678”这种逗号拼接的字符串,就是破坏第一范式;把地址存成“北京市海淀区xx路xx号”而业务里要按城市统计,严格说也不够原子。

但是这里有个度的问题。我在实际项目里见过不少人拿着1NF当圣旨,把地址拆出省、市、区、街道、门牌号五列,结果业务根本用不到这么细,查询时反而要拼接字段。原子性是跟着业务需求走的,如果你的业务从来不需要单独统计“门牌号”,那整个地址字符串在逻辑上就是原子的。第一范式的核心是让你别把一个属性塞进一个字段,而不是让你把字段拆得越碎越好。

回到第一章的课程表,成绩、姓名这些字段本身是原子的,所以它满足1NF,问题出在更高层级。

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

第二范式(2NF)的判定标准:在满足1NF的基础上,消除非主属性对码的部分函数依赖。注意“非主属性”这个词,它指的是不属于任何候选键的属性;候选键则是能唯一确定一行记录的最小属性集合。这张课程表的主键是(学号, 课程号, 学期),候选键也就是它。

按照2NF要求,凡是只依赖学号或只依赖课程号的字段,都要从这张表里挪走。我的拆分方案如下:

-- 学生基本信息 CREATE TABLE student ( student_no VARCHAR(20) PRIMARY KEY, student_name VARCHAR(50), dept VARCHAR(50) ); -- 院系信息 CREATE TABLE dept_info ( dept VARCHAR(50) PRIMARY KEY, dept_addr VARCHAR(100) ); -- 课程信息 CREATE TABLE course ( course_no VARCHAR(20) PRIMARY KEY, course_name VARCHAR(100), credit INT ); -- 选课成绩 CREATE TABLE enrollment ( student_no VARCHAR(20), course_no VARCHAR(20), semester VARCHAR(20), teacher VARCHAR(50), score DECIMAL(5,2), PRIMARY KEY (student_no, course_no, semester) );

这几个表的作用各不相同:student只管学生的稳定属性,dept_info管院系信息,course管课程信息,enrollment保留每次选课的成绩和授课教师。注意光是这次拆分,更新异常就已经解决了一大半:老师电话还是没独立出来,这个我们等会儿再处理;但学生的院系地址已经被挪进dept_info,改地址只需要UPDATE一行。

3.3 第三范式:消除非主属性的传递依赖

第二范式解决的是“横向”的部分依赖,第三范式(3NF)则解决“纵向”的传递依赖。student表里现在有学号→院系,而院系地址在dept_info中是主键,但如果你把dept_addr放回student表,就会出现学号→院系→院系地址的传递链。所以刚才拆分时,dept_addr被我单独挪进了dept_info,student表只保留dept,通过外键关联。这就是3NF的实际落地。

还有一处容易被忽略:enrollment表里的teacher字段。如果一门课在一个学期里固定只有一个老师,那么(课程号, 学期)→教师,而主键是(学号, 课程号, 学期),教师对主键存在部分依赖,需要进一步拆到course表里。但如果实际情况是一门课不同老师分别带不同学生,那教师就应该留在选课表中,因为它描述的是“谁给这个学生上的这门课”。

这个判断不能拍脑袋,必须回到业务语义。我在拆表时习惯先列约束再画依赖图,把“一门课一学期是否只有一个老师”“一个老师是否只属于一个院系”这些问题逐条问清楚,再决定字段去留。

3.4 BCNF:连主属性也别玩部分依赖

一般业务表做到3NF已经很能打了,但3NF有一个漏网之鱼:它只限制了非主属性,对主属性之间的依赖睁一只眼闭一只眼。BCNF(Boyce-Codd范式)补上了这个漏洞,要求所有函数依赖的左边都必须是候选码。

我拿出一个经典的教学安排案例来说明:

CREATE TABLE teaching ( student_id VARCHAR(20), -- 学生 course_id VARCHAR(20), -- 课程 teacher_id VARCHAR(20) -- 教师 );

约束条件:

  • 每位教师只教一门课:teacher_id→course_id;
  • 一个学生选修某门课程,只对应一个教师:(student_id, course_id)→teacher_id;
  • 但反过来,一个学生可以听同一个老师的多门课吗?根据上面约束不行,这个例子通常还伴随一个约束:一个学生跟一个老师只学一门课,即(student_id, teacher_id)→course_id。

这个表的主键可以是(student_id, course_id),候选键还有(student_id, teacher_id)。根据BCNF的判断标准,teacher_id→course_id这个依赖的左边teacher_id只是某个候选键的一部分,并不是候选键,所以这个表连BCNF都不满足。

解决办法是把teaching拆成两张表:

CREATE TABLE teacher_course ( teacher_id VARCHAR(20) PRIMARY KEY, course_id VARCHAR(20) ); CREATE TABLE student_teacher ( student_id VARCHAR(20), teacher_id VARCHAR(20), PRIMARY KEY (student_id, teacher_id) );

这样好多教科书讲到这儿就停了,但我必须提醒你:这个BCNF例子在真实业务里很少出现,因为它的约束“一个学生跟一个老师只学一门课”非常反常识。更常见的BCNF违规场景,是那种“员工-部门-项目”关联表里掺杂了“部门负责人”这样的角色属性。我不建议你为了追求BCNF把凡是带依赖的都拆一遍,3NF在工程上基本够用,BNCF更多是让你理解“依赖左边必须是码”这个原则。

4. 拆表不是切蛋糕:无损连接和依赖保持两条铁律

4.1 为什么分解结果必须能无损还原

规范化必然伴随拆表,但拆表有个基本要求:以后还能通过JOIN把原表完整还原出来,一个元组不多,一个元组不少。这个性质叫无损连接(Lossless Join)。如果你的分解是有损的,拆完之后数据就“对不上了”。

举一个最经典的例子。表R(SID, CNO, TNO),含义是学生选课、课程由老师教,约束是TNO→CNO(一个老师只教一门课)。如果拍脑袋把它拆成R1(SID, TNO)和R2(SID, CNO),问题就来了。

R1记录“学生和老师的关系”,R2记录“学生和课程的关系”,JOIN之后会多出一些原来不存在的组合。比如张三选了C1课程,C1由王老师教,王老师还教C2,那么R1有(张三, 王老师),R2有(张三, C1)、(张三, C2)吗?R2只有(张三, C1)。JOIN后是(张三, 王老师, C1),没多。但如果另一个学生李四也选了C1呢?R2有(李四, C1),而R1没有李四与王老师的记录,因为李四的C1可能是另一个老师?不对,一个老师只教一门课,C1只有一个老师。这么拆有可能有损吗?我需要一个真正有损的例子更稳。

改成拆成R1(SID, CNO)和R2(CNO, TNO),也就是把原表按“列”切成学生选课和课程教师两块。原依赖TNO→CNO在R2里被保留,这个分解是无损的,因为CNO是R2的键,公共属性CNO能唯一定位R2的行。一个老师教几门课?约束是一个老师只教一门课,但是R2(CNO, TNO),键就是CNO(因为一门课只有一个老师?不一定,但从ER语义上一门课只有一个老师,且TNO→CNO也说明TNO可以推出CNO,不能说明CNO推TNO)。这里其实有点绕。为了避免专业出错,我用一个更简单的例子。

举例两张表:

  • 教师(教师号, 教师姓名, 所属院系)
  • 课程(课程号, 课程名, 教师号) 原表可以设计成选课信息表R(学号, 课程号, 教师号)。约束:教师号→课程号(每位教师只教一门课),这个例子就是BCNF那节用的。分解成R1(学号, 教师号)和R2(教师号, 课程号):
  • R1: 学号→教师号,记录学生和老师的关系;
  • R2: 教师号→课程号,记录老师教哪门课。 JOIN后可以还原出每个学生、对应的老师、以及老师教的课程,不会产生多余的组合,而且保留了原语义。这是无损且保持依赖的分解。这个讲法比较干净,就用它。

有损分解的例子:把R(学号, 课程号, 教师号)分解成R1(学号, 教师号)和R2(课程号, 教师号),注意R2的问题:原约束是“每个老师只教一门课”,所以教师号能推出课程号,但课程号不能推出教师号吗?一门课只有一个老师,也能推出。那就又没问题。真正有损分解得选个没有函数依赖的公共属性的场景。比如原表是(学号, 姓名, 院系),分解成(学号, 姓名)和(姓名, 院系)会怎样?公共属性姓名不是键,JOIN时如果两个人重名,就会产生错误组合,有损。这样讲很清晰。

所以无损连接的测试方法也很直白:检查分解后的两个关系,看公共属性是否至少是其中一边的候选键。满足这一个条件,JOIN回去就不会产生多余行。

4.2 依赖保持:约束不能拆丢

光无损还不够,还有第二条铁律:依赖保持(Dependency Preservation)。意思是原表里的每个函数依赖,在拆出来的某一张表里要能依然成立,否则约束就成了摆设。

最典型的就是R(学号, 课程号, 教师号),如果拆成R1(学号, 教师号)和R2(教师号, 课程号),原依赖(学号, 课程号)→教师号就丢了。R1只有“学号→教师号”?不对,一个学生可能有多个老师,一个老师教一个学生一个课?这个例子还得严谨。换个更常见的: 订单表(订单号, 客户号, 仓库号, 发货城市),约束有客户号→发货城市。如果拆成(订单号, 客户号)和(客户号, 发货城市),依赖“客户号→发货城市”保留在第二张表里,OK。但如果拆成(订单号, 发货城市)和(客户号, 仓库号),两个依赖可能都不好保留,“客户号→发货城市”就没地方安放。每次插入一个订单,数据库没法单独在发货城市表上校验“这个客户对应的发货城市是不是这个”。必须靠JOIN回原表才能检查,这就是约束丢失的代价。

依赖保持的核心价值在于更新时的本地校验。如果依赖被完整保留在某个分解后的表里,数据库可以用普通的唯一约束/非空约束直接强制执行;如果依赖丢失,你就得写触发器或者应用层逻辑来补,维护成本直线上升。

4.3 一个反例:看着规范却把依赖拆碎了的分解

我见过一个真实的反例。某系统有一张“考勤明细表”(员工号, 部门号, 部门负责人, 出勤日期),业务约束是:员工号→部门号,部门号→部门负责人,显然有传递依赖。开发同学很懂3NF,把它拆成了2张表:

  • 员工表(员工号, 部门号);
  • 部门表(部门号, 部门负责人)。

这个拆法没问题,依赖保持、无损连接全满足。但真正要命的拆法是这样的:有人为了“极致规范化”,按查询习惯拆成(员工号, 出勤日期)和(部门号, 部门负责人)两张表,再把员工和部门的关联留在原来的应用代码里。员工到部门这个依赖彻底丢了,离职员工的数据一清理,部门负责人就断了头。这就是典型的“只看范式层次,不看依赖保持”的失败案例。

拆表之前,先把所有已知函数依赖写在纸面上,拆完拿这张纸逐一核对依赖还在不在,这个习惯能救你无数次。

5. 实际建模里的反模式与“规范化过度”

5.1 我见过的三个反面教材

第一个是大宽表迷信。有人为了报表查询快,把十几个维度的字段全部冗余进一张宽表,字段上百个。上线时查询确实爽,维护期叫苦不迭:字段含义没人说得清,同一客户名称在五个字段里写法不统一,一个上游改动要刷全表。宽表不是不能用,但它是给OLAP用的,不是给OLTP核心业务用的。

第二个是用分隔符硬塞一对多关系。有个订单系统把“商品ID:数量”用逗号拼成一串,存在订单表里的一个字段里。查询“买了商品A的订单有哪些”完全没法走索引,只能全表扫描加字符串匹配。这是最原始的1NF违反,也是生产事故高发区。这种设计唯一的理由是“图省事”,代价却非常高。

第三个是把状态和属性混在同一个字段里。某个用户表里有一个“标签”字段,存的是“VIP/上海/已实名”这种拼接文本。等你要统计“上海有多少VIP用户”时,只能用LIKE去匹配,又慢又容易误伤。正确的做法要么拆列(is_vip、city、is_verified),要么拆成用户标签关联表。

5.2 什么时候故意不规范化是对的

说了这么多规范化的好处,但你千万别走到另一个极端:为了范式把所有表都拆成雪花状。我在实际项目里,明确反对规范化的场景至少有三个。

日志和监控数据是第一个。一条日志就是一条不可变事实,几乎不关心更新和删除异常,专门去做3NF拆分纯属浪费性能,通常直接拍成一张明细表,按时间分区。

报表宽表是第二个。报表查的就是大范围聚合,你把事实表和维度表拆得干干净净,一次报表查询要JOIN七八张表,慢得让人崩溃。数据仓库里常见的星型模型,其实就是“故意冗余维度描述”的反规范化设计。

缓存表和快照表是第三个。比如商品价格有有效期,你可能需要在某个时间点把当时的完整商品信息复制一份,形成快照表,用于历史对账。这种表刻意冗余设计,因为你要的就是“当时看到的样子”,而不是一份可以用JOIN还原的范式表。

5.3 折中方案:宽表、快照、物化视图

工程上真正成熟的做法,是“OLTP用规范化,OLAP用反规范化”,两边各留一张表再加同步。源系统里的订单、商品、客户保持3NF,出报表时先同步到数仓,在数仓里加工成宽表;需要历史快照时按天生成快照表;需要高频聚合时建物化视图。

我一直强调“规范化是手段,不是目的”,这句话放到真实场景里就变成了:核心交易链路里,我会认真做规范化;查询密集但更新少的分析型场景里,我会大胆宽表化;跨系统数据交换时,甚至要主动生产冗余字段,让下游少做几次关联。懂得什么时候该破坏规则,和懂得怎么应用规则,同样重要。

6. 项目实战后的三条判断心得

6.1 快速判定一张表现在几范式

面对任何一张表,我有一套三连问,大家面试或接手老库时可以直接抄走:

第一,有没有存储了重复组或者可分字段?比如一列塞多个电话号码,这就是1NF都没过。第二,主键是不是复合键?如果是,逐一检查非主键字段,看它们是否依赖于复合键的某个子集,如果存在,表就停在2NF以下。第三,非主键字段之间有没有“A推导B,B推导C”的传递链?有的话就是3NF没到位。就算三个问题都过了,再追问一句:所有函数依赖的左边,是不是都是候选键?不是的话,BCNF还有缺口。

这套判断不需要背范式定义,只需要会看函数依赖。我建议你拿到一张旧表,先把候选键圈出来,再列出所有你能确定的业务约束,然后走一遍这条链路,表的健康度一目了然。

6.2 面对老表,我的处理顺序

接手一个老系统时,我不建议大家上来就动手拆表。正确顺序是先梳理清楚业务流程,把每个字段的业务含义和更新频率列出来;然后识别候选键和函数依赖,画出依赖图;接着对照三连问确认当前范式级别;再根据业务场景决定要规范化到什么层级;最后写迁移脚本,把旧表备份、建立新表、做数据迁移、修改所有SQL和ORM映射,跑完对账脚本确认数据一致。

迁移中最容易被忽略的是历史数据和历史代码。老表可能被十几个服务引用,你拆完表,某个角落的存储过程还在用旧字段名,线上立刻告警。所以迁移前做一次全仓库代码搜索,比建表本身更重要。

6.3 比范式更重要的是理解数据语义

做规范化这几年,我最深的一点体会是:范式是纸上的规则,数据语义才是实打实的依据。同一个字段该放哪张表,不取决于教科书的第几条定义,而取决于现实业务里它“属于谁”。比如“教师电话”在选课场景里,它属于教师实体,就该进教师表;在授课安排场景里,如果一门课只有一个老师,它也可以进课程表。没有绝对正确的拆分,只有是否符合业务约束的拆分。

所以每次做完一个表的规范化,我都会把最终的函数依赖集合整理成文档,放到代码仓库里提交。三个月后有人来改这张表,先看文档,不用再靠猜。文档里一行行依赖关系,就是这个表结构存在的全部理由。这也是我把这个系列放在“关系数据库”这个大主题下第一篇的原因——后面的ER模型、索引设计、查询优化,全都是从理解数据依赖开始的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询