ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

数据库三范式3NF详解:从函数依赖到反范式设计实战

数据库三范式3NF详解:从函数依赖到反范式设计实战 先问个问题你写SQL的时候有没有碰到过一张表里塞了几十个字段查起来倒是方便可一更新就到处报错如果你做过数据库课程设计或者面试被问过“三范式是什么”那今天这篇对你肯定有用。数据库三范式3NF指什么说白了就是一套“表该怎么设计才不容易出乱子”的经验规则。它不涉及具体数据库产品不管是MySQL、Oracle还是达梦规则都一样。这篇我会从第一范式讲到第三范式重点把3NF讲透再用订单表的例子一步步拆给你看最后聊点面试题和反范式的实战取舍。1. 先搞清楚三范式解决的是数据库设计的什么问题1.1 一个最常见的“坏表”现场我见过很多刚入门的同学设计表习惯把所有信息塞进一张大表。比如做一个“学生选课系统”直接建一张表选课记录表: 学号, 姓名, 院系, 课程号, 课程名, 学分, 成绩表面上看这张表一条记录就能查出一位学生某门课的所有信息多方便。可一旦真要上线问题就来了张三选了3门课他的姓名、院系就被复制了3份。哪天他转系了得把这张表里所有张三的记录全改一遍漏改一条数据就对不上了。想新增一门还没人选的新课程但课程号是主键的一部分主键不能为空这门课压根插不进去。某门课因为选课人数太少被删掉不仅选课记录没了课程名、学分这些信息也跟着一起消失。这类问题在数据库原理里有个正经名称更新异常、插入异常和删除异常。三范式就是用来解决这三类异常的一整套判断标准。1.2 范式到底是什么范式的英文是Normal Form缩写为NF。第一范式叫1NF第二范式叫2NF第三范式叫3NF。注意这里的“第几”不是随便排的而是一个递进关系满足了第二范式一定已经满足第一范式满足了第三范式一定已经满足前两个范式。所以3NF指的是在满足2NF的基础上还要满足“非主属性不能传递依赖于主键”。这句话光看定义还是挺绕的我需要先讲清楚1NF和2NF3NF才有落地的基础。顺便说一句很多面试者上来就背“第三范式就是消除传递依赖”这个说法方向对但不够严谨。第三范式的前提是“先满足第二范式”如果不提这个前提等于只背了半句话。2. 第一范式与第二范式先把地基打牢2.1 1NF字段不可再分其实是“最小粒度”问题第一范式的定义很好记每一列都必须是最小的、不可再拆分的原子值。所谓“原子”意思是这个字段存的东西不能再拆出更小的业务单元了。举个反例有人为了省事在订单表里设计了一个“收货地址”字段存成“广东省深圳市南山区科技园路1号”。从数据库字段的角度看它确实是一个字符串技术上没问题。但业务上如果以后想按省份统计订单量、按城市做物流分析这个字段就非常难用。更典型的问题是手机号、座机并存时有人存成“138000012340755-66668888”。这种“一个字段塞多个值”的做法直接违背1NF因为后续想筛选“手机号以138开头”SQL会写得非常别扭。1NF的价值容易被低估它是所有范式的基础也是最容易被认真对待的。实务里我的建议是原子性不是绝对的要按业务需要来定。地址拆不拆取决于你的系统到底要不要按省、市、区去做统计分析。如果只是打个物流单那整条地址字符串也够用。2.2 2NF非主属性必须完全依赖主键第二范式在1NF基础上加了一条约束非主属性必须完全依赖主键不能只依赖主键的一部分。这里要引入一个核心概念函数依赖。用大白话说就是“知道了A就能唯一确定B”。比如知道了学号就能确定姓名。在数据库原理里这种关系写作学号→姓名。函数依赖分为“完全依赖”和“部分依赖”。只有当主键是多个字段组成的联合主键时才可能出现部分依赖。比如选课记录表的主键是“学号课程号”而“姓名”只依赖学号不依赖课程号这就是部分依赖也就是2NF要消除的东西。2.3 从1NF到2NF学生选课表的拆解示例还是用前面的“选课记录表”来演示。这张表的主键定为“学号课程号”里面所有字段都满足1NF但不符合2NF。怎么拆先拆出学生相关信息学号、姓名、院系主键是学号。再拆出课程相关信息课程号、课程名、学分主键是课程号。最后剩下来的选课关系学号、课程号、成绩主键是学号课程号。拆完之后学生转系只需要更新“学生表”中的一条记录不会再牵扯选课数据。新增课程也能独立插入删除选课记录也不会误删课程信息。这一步做完插入异常、更新异常、删除异常已经解决了一大半。原表字段依赖情况归属表学号主键一部分学生表主键学号姓名部分依赖依赖学号学生表院系部分依赖依赖学号学生表课程号主键一部分课程表主键课程号课程名部分依赖依赖课程号课程表学分部分依赖依赖课程号课程表成绩完全依赖学号课程号选课表这个拆分过程就是2NF的实操。很多开发者也把这个过程叫“把表的职责拆清楚”同一张表里不要混着多种业务实体。3. 3NF核心消除传递依赖3.1 定义拆解直接依赖 vs 间接依赖第三范式的标准定义是满足2NF且不存在非主属性对主键的传递依赖。什么是传递依赖用符号表达就是主键 A非主属性 B非主属性 C存在 A→B→C。也就是说C不直接依赖主键A而是先通过B再由B确定C。这个中间人B的存在就是麻烦的来源。这里要特别注意B和C都必须是“非主属性”即它们都不是主键的一部分。如果B是主键的一部分哪怕存在 A→B→C 这种链路也不是范式意义上的传递依赖那是另外的问题。为什么传递依赖要消除还是回到更新异常的老话题。如果C是由B决定的而B又由主键A决定那么同一张表里同一个B会对应多条记录。一旦B→C的对应关系变了你就得把表里所有B相同的记录统统改一遍和前面学生改院系的问题一模一样。3.2 一个典型的3NF违规场景来看一个特别经典的例子员工信息表。假设有这样一张表员工表: 员工编号, 员工姓名, 部门编号, 部门名称, 部门办公电话主键是“员工编号”。所有字段都依赖于主键吗看一遍员工姓名依赖员工编号没问题。部门编号员工入职或调动时部门编号是跟着员工走的可以认为它依赖员工编号。部门名称它其实是由部门编号决定的部门编号→部门名称。部门办公电话同样由部门编号决定。所以在这里真正的依赖链是员工编号→部门编号→部门名称/部门办公电话。“部门编号”就是中间人B而“部门名称”“部门办公电话”就是C。这张表符合2NF因为主键是单列不存在部分依赖。但它不符合3NF。带来的问题很具体某部门有20名员工部门名称和办公电话会被存储20遍。一旦部门更名或电话变更要么写一个UPDATE语句把20条记录全部更新稍有不慎就会漏掉几条造成同部门数据不一致。正确的做法是把部门相关信息抽出去单独建一张部门表员工表: 员工编号, 员工姓名, 部门编号 部门表: 部门编号, 部门名称, 部门办公电话这样部门改名只需要改部门表的一行。员工表里存一个外键“部门编号”需要查部门名称时通过JOIN关联即可。3NF的本质就是让每一个非主属性都“只依赖于主键”中间不要隔一层别的属性。3.3 判断传递依赖的三个步骤面试和考试里经常给一张现成的表让你判断它是否符合3NF。我总结了一个固定步骤照着做基本不会出错。第一步确定主键。这需要结合业务找到能唯一标识一行记录的字段或字段组合。主键找错后面全盘皆输。第二步找出所有非主属性逐个判断依赖关系。从主键出发看成不能直接推出这个属性。第三步检查属性之间是否存在“抱团”关系。典型特征是表里某些非主属性之间本来就有稳定的对应关系比如“部门编号→部门名称”“省份编号→省份名称”“商品分类编号→分类名称”。一旦发现这种关系就可以判定存在传递依赖。提示判断时可以把表里的字段按“业务实体”分组。员工编号、员工姓名属于“员工”实体部门编号、部门名称属于“部门”实体。如果一张表里出现了两个及以上业务实体且能独立建表就要警惕它可能不符合3NF了。4. 实操演练把一张订单表“重构”成3NF4.1 原始表结构理论知识说得再多不如上手拆一遍。我拿电商系统里最常见的订单表来做完整演练。假设最初的表长这样订单表: 订单号, 下单时间, 客户编号, 客户姓名, 客户电话, 客户地址, 商品编号, 商品名称, 商品单价, 购买数量看一眼就知道这张表把“订单”“客户”“商品”三个业务实体全揉在一起了。主键怎么定一个订单可以买多件商品所以“订单号”不能单独作为主键比较合理的主键是“订单号商品编号”。先检查1NF所有字段都是单个值满足。再检查2NF主键是联合主键这时候要小心了。“下单时间”“客户编号”“客户姓名”这些字段其实只依赖订单号不依赖商品编号。明明是同一个订单却因为商品编号不同被重复存储成多行。这就是部分依赖不满足2NF。4.2 第一轮拆分解决部分依赖既然不满足2NF先拆掉部分依赖。按依赖关系分成三块。订单信息订单号、下单时间、客户编号。这一块描述“这张订单本身”。客户信息客户编号、客户姓名、客户电话、客户地址。这一块描述“下单的人是谁”。商品信息商品编号、商品名称、商品单价。这一块描述“商品本身是什么”。再加上订单与商品的关系订单号、商品编号、购买数量。这里的“购买数量”不能放到商品表里因为同一件商品在不同订单里的购买数量是不一样的也不能放到订单表里因为一个订单可能包含多种商品。它属于“这个订单买了这件商品”的关系只能放在关联表里。拆完以后四张表是订单表: 订单号, 下单时间, 客户编号 客户表: 客户编号, 客户姓名, 客户电话, 客户地址 商品表: 商品编号, 商品名称, 商品单价 订单明细表: 订单号, 商品编号, 购买数量4.3 第二轮拆分解决传递依赖现在检查一下四张表的3NF情况。订单表里客户编号是外键通过“客户编号”可以查客户姓名没有把客户姓名冗余在订单表里没问题。商品表和订单明细表也没毛病。问题可能出在客户表。假设客户表里还放了“客户城市”和“客户省份”两个字段而“客户城市”本身决定了“客户省份”那么“客户编号→客户城市→客户省份”就是一条传递依赖。尽管“客户省份”确实可以通过客户编号推出来但它得先经过“客户城市”这就不满足3NF。更规范的做法是拆出一张城市表客户表里只保留“城市编号”。做完第二轮拆分最终结构就是订单表: 订单号, 下单时间, 客户编号 客户表: 客户编号, 客户姓名, 客户电话, 城市编号 城市表: 城市编号, 城市名称, 所在省份 商品表: 商品编号, 商品名称, 商品单价 订单明细表: 订单号, 商品编号, 购买数量4.4 设计结果对比同样的业务需求拆分前和拆分后的差别非常大。对比项拆分前拆分后客户改地址需要更新该客户所有订单记录只更新客户表一行商品涨价需要逐条更新历史订单里的商品单价只更新商品表一行删除某笔订单可能连客户、商品信息一起删掉只删除订单对应记录客户和商品不受影响查询客户订单单表查询方便需要JOIN三张表SQL稍微复杂数据一致性风险高低这个对比也解释了为什么面试官总爱问范式他们要考察的不只是记忆而是你有没有能力在设计阶段预判潜在的数据维护问题。3NF做得好的表后续做需求变更的时候会省很多力气。5. 3NF不是银弹什么时候该反范式5.1 范式规范与查询性能的矛盾严格遵守3NF能最大程度保证数据一致性和减少冗余但它不是免费的。3NF要求把数据拆到不同的表里。查询的时候如果需要同时展示订单号、客户姓名、商品名称就得分表查询再JOIN回来。在数据量小的时候这个JOIN根本感觉不到。可一旦订单表有几百万行客户表也有几十万行JOIN的成本就会明显上升。索引设计得不好一次列表页查询可能慢到无法接受。所以业界的普遍做法不是无脑追求3NF而是“逻辑设计遵循3NF物理设计按需反范式”。也就是说概念和逻辑层面按范式来做但到了实际建表和查询优化阶段允许有意识地冗余一部分字段换取更快的查询速度。5.2 适合保留冗余的几种典型场景先说说订单系统里最常见的冗余商品快照。规范设计里订单明细表不应该冗余商品名称、商品单价因为那是商品表的事。但实际电商系统几乎都会在订单明细表里保存一份“下单时的商品名称”和“下单时的商品价格”。原因是商品可能改价、改名、下架甚至被删除。如果订单明细只存一个商品编号等过两年查历史订单商品表里那件商品可能早没了或者价格已经变了。这种冗余不是设计失误而是业务需要它保留了“历史事实”。再比如统计报表。报表场景几乎都是宽表一张原子宽表里直接放好维度字段和指标字段查询的时候基本不需要JOIN。尤其是用数据分析平台跑大屏如果每次都现场关联几十张表延迟会让人抓狂。实务里会用定时任务在凌晨把规范化数据“拍扁”成宽表供白天的报表查询使用。还有一个场景是计数器字段。“文章表”里存一个“评论数”每次有人发评论时加1而不是每次展示文章详情时去COUNT评论表。这类冗余可以大幅度减少高频接口的查询压力代价是写操作时要额外维护这个字段得靠事务或者消息队列保证同步。5.3 用业务规则约束替代一部分范式约束如果你在设计中决定保留冗余字段就要想好怎么保证“冗余得可靠”。3NF是在表结构层面强制一致性而反范式后一致性的责任就从数据库转移到了应用层。我的经验是冗余字段要遵循几个原则冗余只发生在“当前值”明确且变更频率低的字段上比如商品名称。冗余字段必须由明确的写入方维护不要出现两套代码各自更新同一批字段的情况。关键的冗余字段更新要放在同一个事务里避免长时间的不一致窗口。给冗余字段加上“含义说明”的注释尤其是表字段名看不出来源的那种不然三个月后接手的人很难理解。说到底范式是兵器反范式是战术。没有绝对的好坏只有是否适合当前系统的“一致性要求”和“查询压力”。6. 高频面试题与常见误区6.1 面试必问3NF和BCNF的区别很多岗位的面试题会把3NF和BCNF放在一起考。BCNF叫“巴斯-科德范式”它是在3NF基础上进一步升级在3NF的基础上消除“主属性对候选键的部分依赖和传递依赖”。听起来很难懂说直白一点3NF只约束“非主属性”不能传递依赖但BCNF要求所有属性都不能部分依赖或传递依赖包括主属性在内。所以BCNF比3NF更严格。举个例子有一张表字段是“学生、课程、老师”。规则是一个老师只教一门课但一门课可能由多个老师教。主键是“学生课程”。这里“课程→老师”是一条非主属性之间的依赖不违反3NF。但在BCNF里由于“老师”这个属性理论上也由“课程”决定并且“课程”不是超键这条依赖就会被判为违规需要继续拆表。面试里能讲清楚这一层基本就算过关了。6.2 四个容易踩坑的误区我经常在论坛和教学中看到一些对3NF的误解列出来帮你排雷。误区一以为主键是单字段就一定符合2NF。这是错的。单字段主键确实不会有“部分依赖”因为压根没有“主键的一部分”这个概念。但要满足2NF还要看所有非主属性是否都完全依赖这个主键如果存在主键→A属性→B属性虽然2NF没问题但3NF可能会出问题。误区二一提到冗余就认为必须拆表。其实“合理冗余”在高并发系统里非常常见。判断冗余是否合理要看能不能保证一致、是否带来明显的性能收益而不是只看理论规范。误区三把“字段不可再分”理解成“字段值不能包含空格或逗号”。1NF强调的是业务逻辑上的原子性不是字符串层面的形式要求。一个地址字段存“北京市朝阳区”不能简单说它违反1NF要看业务是否需要对省、市、区单独检索。误区四以为拆得越细越好。表拆得太多虽有好的结构但业务查询和事务维护成本也会上升。正常的做法是拆到3NF左右再根据业务做适度冗余和汇总而不是见到两个字段有依赖关系就立刻拆表。6.3 判断题小测验给你三道判断题心里先给答案再看后面对照。第一题表“学生学号姓名系名系主任”主键学号符合3NF吗不符合。“系名→系主任”是一条传递依赖虽然学号能确定系主任但它得先经过系名。第二题表“图书图书编号书名出版社编号出版社名称”主键图书编号符合3NF吗不符合。“出版社编号→出版社名称”是典型传递依赖需要把出版社拆成独立表。第三题表“订单订单号下单时间客户编号商品编号数量”主键订单号商品编号这个结构符合2NF吗符合。因为所有非主属性下单时间、客户编号、数量都完全依赖于联合主键不存在部分依赖。至于3NF还得看客户编号、下单时间之间有没有奇怪的依赖关系单从这几个字段看一般是符合3NF的。这些题做多了你对范式的理解就会从“背定义”变成“看依赖”面试的时候也能应对得更加灵活。7. 最后我的设计习惯范式理论学了这么多年我自己形成了几条比较实用的习惯也许对你有参考价值。工具箱里第一步永远不是画ER图而是先把业务里的“实体”列出来用户、商品、订单、分类、库存、优惠券。做完这一步再开始判断字段归属每张表只管一类实体主键定清楚再逐张表过一遍3NF检查。这个过程听起来慢但后期省下的是大把踩坑时间。遇到复杂老系统改造我也不会要求一步到位。先把最核心的订单、用户、商品拆到3NF外围报表库可以先维持宽表后续通过定时同步逐步收敛。数据模型的重构和系统重构一样最怕想一口吃成胖子。最后再分享一个心态上的经验3NF是一个很好的设计出发点但它不是终点。很多维护过“千万级数据”的工程师都明白实际生产环境有时候要故意保留部分冗余有时候要先把数据反范式化再提供给报表。理解范式是为了让你在需要打破范式的时候清楚地知道自己在打破什么、要付出什么代价。掌握这个度比单单记住“3NF指什么”要重要得多。
返回列表