资讯动态

数据库三范式实战:函数依赖、表拆分与反范式设计

发布时间:2026/10/29 21:09:57 来源:尧图企业网站定制
“数据库三范式”这五个字做后端的人基本都背过标准答案第一范式列不可再分第二范式非主键列完全依赖主键第三范式非主键列不能依赖其他非主键列。但背下来和用起来是两件事。我见过太多表结构写的人能流利背出三条定义画出来的表却依然把用户昵称、部门名称、商品标题一层层冗余地塞进从表里等到要改一个部门名字才发现有七张表都要跟着动。也见过另一个极端有人把范式当戒律恨不得每张表只有三列最后查一条列表要 join 七张表性能掉得没法看。这篇东西我想把三范式从头捋一遍重点不在定义而在于为什么这条规则会存在、怎么在真实表结构上判断到底违不违反、发现违反了以后怎么拆、以及什么情况下应该故意不拆。适合刚接手表结构设计的新人也适合工作几年但一直是照着模板画表、没深想过推演过程的人。看完你应该能做到拿到一张堆满字段的宽表三分钟内指出它违反了哪一条范式、会引发什么具体问题、怎么改。1. 从一张十三列的订单宽表说起范式真正想治的病1.1 同一个事实被存了三遍才是问题的根前阵子帮人看一个后台系统的表结构订单表里同时存了user_id、user_name、user_phone、product_id、product_name、product_price、category_name、merchant_name这么一堆字段。写的人理由很直接列表页要展示这些信息存一份省得 join。这张表的毛病不是“不合规范”这么抽象而是有非常具体的后果。假设某个用户改了昵称他历史上一百条订单里的user_name全都变成了旧名字除非你写脚本批量更新假设某个商品调价了历史订单里的product_price也跟着乱你根本分不清哪个价格是成交价、哪个是当前售价假设某个商户更名你要扫全表更新。更麻烦的是更新过程中只要有一条记录没更新成功数据库里就同时存在两个版本的“事实”此后所有基于这张表的统计都不再可信。这就是范式想治的病同一个事实在数据库里只应该有一个权威存放点其他地方要么不存要么明确标注为“快照”并且永不回改。冗余本身不是罪无法自洽的冗余才是罪。搞清楚这一点后面三条规则就都好理解了——1NF 管的是“一个格子里别塞多个事实”2NF 和 3NF 管的是“一个事实别被多张表或多个字段重复承载”。1.2 三条范式其实是一条链从“格子”到“表内”再到“表间”很多人把三范式当成三个并列的知识点背实际它是一条递进的推导链每一层都在解决上一层解决不了的问题。层级管的对象要消灭的依赖违反后的典型症状1NF单个字段列字段内包含多个值无法索引、无法精确查询、更新时要做字符串切割2NF表内非主键列与主键的关系部分依赖只依赖复合主键的一部分同一条业务数据随主键组合重复出现3NF表内非主键列之间的关系传递依赖A 决定 BB 决定 C改一个属性要动多行且容易改漏BCNF所有函数依赖的决定因素决定因素不是候选键罕见但确实存在多出现在多对多关联表这张表建议存下来。判断一张表违反哪一条就是从上往下逐层过一遍字段里有没有复合值有就是 1NF 问题。主键是不是复合的、非主键列能不能被主键的一部分唯一确定能就是 2NF 问题。非主键列之间能不能互相推导能就是 3NF 问题。有个前提概念得先钉死函数依赖。说 A 决定 B写作 A → B意思是只要 A 的值定了B 的值就唯一确定了。比如学号 → 姓名一个学号只对应一个姓名。反过来姓名 → 学号通常不成立因为可能重名。判断范式本质上就是在找表里所有的函数依赖然后看这些依赖的形态合不合规。这一步不需要数学功底需要的是对业务的准确理解——你得知道在你的业务里一个订单号是不是只对应一个下单人一个商品是不是只属于一个类目。业务规则搞错了依赖关系就判错了后面全是白搭。1.3 先明确一件事范式和性能不是对立的和“偷懒”才是经常听到一种说法三范式适合教学真实项目要反范式。这话对一半。真实项目确实经常不满足 3NF但绝大多数情况不是设计者深思熟虑后的取舍而是图省事——列表页要展示啥就直接往主表加一列加着加着表就废了。我的经验是先把表设计到 3NF再针对具体查询压力做定向的反范式并且一定要给反范式字段写下注释和同步方案。这个顺序不能反。因为从规范结构出发做反范式的加法你清楚自己在加什么、谁来维护它从来没规范过就直接堆字段等到出问题的时候你连“哪一份数据才是对的”都说不清。2. 第一范式原子性这条线比你以为的更容易踩2.1 什么样的字段算“不原子”得看你怎么查它1NF 的经典定义是“每个字段都是不可再分的原子值”。这个“原子”是相对的判断标准不在哲学层面而在你的查询需求。举个具体例子。地址字段存成北京市海淀区中关村大街1号一整个字符串算不算违反 1NF如果业务只需要展示完整地址、偶尔做模糊搜索那它是原子的没问题。但如果业务要按城市统计订单量、要按区县做配送范围匹配那这个字段就不原子了——它里面同时装了省、市、区、详细地址四个事实你却没法单独取用其中一个。我见过的典型 1NF 违规有这么几类联系方式字段里存13800138000 / zhangsanexample.com一个格子里塞了两种联系方式标签字段存数码,手机,苹果用逗号拼接权限字段存read,write,delete商品规格存颜色:红;尺寸:XL一个“备注”字段被当成杂物间塞进了客服工单号、优惠券码、渠道来源这些结构一旦落地麻烦是连锁的。以那个逗号分隔的标签字段为例想查“所有打了手机标签的商品”只能用LIKE %手机%这个写法用不上索引数据量上万就开始明显变慢更糟的是它会把“手机壳”这种带“手机”二字的标签也匹配进来。想删掉某个标签得用字符串替换函数稍有不慎就把“手机配件”改成了“配件”。想统计每个标签下的商品数得先做字符串切割再分组SQL 写得又长又慢。2.2 拆字段的三种手法按需求强度选发现字段不原子改造方式按查询需求的强度分三档。第一档拆成多个固定列。适用于属性数量固定、且每一项都会被单独查询的情况。比如把联系方式拆成mobile和email两列把地址拆成province、city、district、detail四列。这个改造成本最低查询也最快-- 改造前 SELECT * FROM orders WHERE address LIKE %海淀区%; -- 改造后 SELECT * FROM orders WHERE province 北京市 AND district 海淀区;改造后第二句能走索引而且不会误伤。要注意的是拆完之后detail里不要再塞省市区否则冗余又回来了。第二档拆成关联表。适用于属性数量不固定、或者需要单独管理的情况。标签、权限、多规格都属于这一类。原来一个逗号字段变成一张product_tag(product_id, tag_id)的中间表标签本身存在tag表里。这样“查某标签下所有商品”变成一句简单的 join标签改名也只改一处。第三档结构化类型字段。关系型数据库现在普遍支持 JSON 类型把不参与检索的扩展属性丢进 JSON 列是一种务实的折中。但前提要说死放进 JSON 的字段你不能指望高效地按它筛选。虽然有些数据库支持 JSON 内部字段建索引但查询写法受限、优化器支持有限我一般只在“这个属性只用于详情页展示、永远不做筛选条件”时才这么干比如商品的图文详情参数、用户的个性化配置。2.3 原子性的边界拆到哪一层才算够这一节是我最想强调的因为新手最容易在这里走偏。1NF 不是让你把地址拆到门牌号、把姓名拆成姓和名。判断标准只有一条这个字段会不会被用作独立的查询条件或统计维度。如果运营后台有“按城市筛选订单”的功能城市就得独立成列如果从来没有“按姓氏搜索用户”的需求姓名就别拆如果地址只用于发货打印那整串存着完全合理如果商品规格要支持按颜色筛选颜色必须独立但“材质说明”这种纯展示项放 JSON 就行一句话总结原子性服务于查询不服务于洁癖。过度拆分带来的后果一点不比不拆轻——字段翻了三倍每条 SQL 都要拼五个条件业务改一个需求就要动表结构这同样是技术债。3. 第二范式复合主键才是部分依赖的温床3.1 部分依赖的判定先看主键是不是复合的2NF 要求“非主键列必须完全依赖于整个主键而不能只依赖主键的一部分”。这句话有个隐含前提只有复合主键的表才可能违反 2NF。如果一张表的主键是单一列比如自增 id那么主键没有真子集“部分依赖”在数学上就不存在这张表天然满足 2NF。这个推论非常实用。你去看一张表违不违反 2NF第一个动作就是看主键单列主键直接跳过复合主键进入逐列检查。检查方法是对每个非主键列问一句“我只知道主键里的一部分能不能唯一确定这个列的值”能就是部分依赖。3.2 一张选课表的拆解全过程经典例子最好用。假设有一张选课成绩表CREATE TABLE score ( student_id INT, course_id INT, score DECIMAL(5,2), student_name VARCHAR(32), student_dept VARCHAR(64), course_name VARCHAR(64), credit INT, teacher_name VARCHAR(32), PRIMARY KEY (student_id, course_id) );主键是(student_id, course_id)因为一个学生对一门课只有一条成绩。现在逐列问只知道student_id主键的一部分能不能确定student_name能。所以student_name部分依赖于主键违反 2NF。同理student_dept只依赖student_idcourse_name、credit、teacher_name只依赖course_id全都是部分依赖。这张表会出什么问题很具体同一个学生选了五门课他的姓名和院系就被存了五遍。某天他转专业了你要更新五行漏一行就出现两个院系。同一门课的学分被存了几百遍教务改一次学分要扫全表。更隐蔽的问题是一门新开的课如果还没有学生选它根本无法插入这张表——因为主键里的student_id没有值。这叫插入异常是冗余设计最典型的副作用之一。拆法是按依赖关系分组谁依赖谁就跟谁走-- 学生表主键 student_id所有只依赖学号的列搬过来 CREATE TABLE student ( student_id INT PRIMARY KEY, student_name VARCHAR(32), student_dept VARCHAR(64) ); -- 课程表主键 course_id所有只依赖课程号的列搬过来 CREATE TABLE course ( course_id INT PRIMARY KEY, course_name VARCHAR(64), credit INT, teacher_name VARCHAR(32) ); -- 成绩表只留完全依赖 (student_id, course_id) 的列 CREATE TABLE score ( student_id INT, course_id INT, score DECIMAL(5,2), PRIMARY KEY (student_id, course_id) );拆完再验证一遍score表里只剩score一个非主键列它必须同时知道学生和课程才能确定是完全依赖2NF 通过。而且新课可以先插入course表等有人选了再往score表写插入异常消失了。顺便说一句course表里的teacher_name其实还埋着 3NF 的问题——多个课程由同一个老师教老师名字就重复了。这个留到下一节处理。3.3 复合主键的替代方案代理主键 唯一索引实际项目里我越来越少用复合主键了更常见的做法是给关联表加一个自增或雪花 id 做主键然后把业务上的唯一组合建成唯一索引CREATE TABLE score ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2), UNIQUE KEY uk_student_course (student_id, course_id) );这么做有几个实在的好处一是数据结构统一ORM 处理单主键省事二是后续如果业务变成“一个学生可以重修同一门课”只需要把唯一索引改成包含学期字段主键不用动三是某些数据库的聚簇索引对复合主键的存储和二级索引体积不太友好。代价也要清楚加了代理主键之后student_id course_id的唯一性从“主键约束”降级成了“唯一索引约束”功能上等价但语义上弱了一点。另外多了一列存储和索引都会略微变大。这是典型的工程取舍没有标准答案团队内部统一就行——同一个项目里别一半表用复合主键一半用代理主键那才是真的乱。4. 第三范式传递依赖怎么识别怎么拆干净4.1 传递依赖的样子A 决定 BB 又决定 C3NF 要求非主键列之间不能互相推导。用依赖链描述就是主键 → 非主键列 A → 非主键列 B那么 B 传递依赖于主键违反 3NF。还是拿上面拆完的表继续。假设员工表长这样CREATE TABLE employee ( emp_id INT PRIMARY KEY, emp_name VARCHAR(32), dept_id INT, dept_name VARCHAR(64), dept_leader VARCHAR(32) );主键是emp_id。dept_name依赖emp_id吗表面上看依赖——员工定了部门就定了。但中间隔了一层emp_id → dept_id → dept_name。所以dept_name是传递依赖于主键的违反 3NF。dept_leader同理。识别技巧盯着非主键列问“这一列的值变了是因为主键变了还是因为另一个非主键列变了”如果答案是后者就是传递依赖。拆法很直接把被传递依赖的那一层独立成表CREATE TABLE department ( dept_id INT PRIMARY KEY, dept_name VARCHAR(64), dept_leader VARCHAR(32) ); CREATE TABLE employee ( emp_id INT PRIMARY KEY, emp_name VARCHAR(32), dept_id INT, KEY idx_dept (dept_id) );现在部门改名只改department一行所有员工自动生效。部门还没招人时也能先建部门记录插入异常再次消失。4.2 一个更隐蔽的例子省市区与邮编传递依赖有时候藏在看起来很自然的地方。比如CREATE TABLE address ( addr_id INT PRIMARY KEY, user_id INT, province VARCHAR(32), city VARCHAR(32), district VARCHAR(32), zip_code VARCHAR(8) );这里district → city → province本身就是一条链区县定了城市就定了城市定了省份就定了。严格按 3NF省市区应该单独建一张行政区划表地址表里只存district_id。那要不要拆我的判断依据是变化频率和一致性要求。行政区划调整极少发生而且地址数据本身往往是一次写入、很少修改的快照冗余的风险很低反过来拆了之后每次查询地址都要多 join 一次列表页成本明显上升。这种场景我倾向于不拆但会在字段注释里写清楚“省市区为冗余快照来源为行政区划表不随区划调整回改历史数据”。对比之下前面那个部门名字就一定要拆——部门改名、部门合并、部门撤销在真实业务里发生频率很高而且一旦不一致会直接影响报表和权限。4.3 3NF 之后还剩什么问题多值依赖与 BCNF3NF 不是终点。有一种情况 3NF 检查会漏掉表里有多个候选键且它们之间存在交叉依赖。教科书上的经典例子是“学生选修某位老师的某门课”且约定一位老师只教一门课CREATE TABLE teaching ( student_id INT, course VARCHAR(64), teacher VARCHAR(32) );候选键有两个(student_id, course)和(student_id, teacher)。因为老师定了课程就定了所以teacher → course。选(student_id, course)作主键时teacher完全依赖主键不违反 2NFteacher和course之间虽然能互相推导但两者都是主键或候选键的一部分3NF 的检查条件放过了它。后果是某个老师改教另一门课时要把这张表里所有该老师的记录都更新漏一条就出现矛盾数据。同时还存在删除异常删掉某个学生选课记录可能顺带把“某老师教某课程”这个事实也删没了。解决办法是拆成两张表teacher_course(teacher, course)表达“谁教什么”student_teacher(student_id, teacher)表达“谁选了谁”。这就是 BCNF 的思路——要求所有函数依赖的决定因素都是候选键。实际项目里 BCNF 违规很少见遇到基本都出现在多对多的关联表上而且往往是因为业务规则没想清楚。我一般不在设计阶段死磕 BCNF而是在发现“这张关联表里居然有业务属性”的时候警觉一下——关联表里一旦出现描述性字段就该怀疑它是不是承载了多余的事实。5. 把三范式变成设计流程里的固定动作5.1 从业务描述到表结构的推导路径范式不是事后检查用的最好是设计时就用它推导。我自己的固定流程是五步。第一步列实体。把业务描述里所有“名词”圈出来圈完去掉纯属性、去掉同义词。比如做一个内容社区名词有用户、文章、分类、标签、评论、点赞。这些是实体每个实体对应一张主表。第二步给每个实体列属性。这一步先不管范式把所有想得到的属性全写上宁可多不可少。用户昵称、头像、手机号、注册时间、会员等级、会员折扣。文章标题、正文、作者、分类、发布时间、阅读数。第三步标主键。每个实体找能唯一标识一行的那一组字段。没有自然键就用代理主键。第四步逐表套范式重点看两类字段。一类是“看起来不像本实体的属性”比如用户表里的“会员折扣”——折扣其实由会员等级决定是用户 → 会员等级 → 折扣传递依赖应该拆到等级配置表。另一类是“可能出现重复值的字段”比如文章表里的“作者昵称”作者昵称由作者 id 决定属于冗余除非有意做快照。第五步反向检查异常场景。问自己三个问题新增一条记录时是不是有字段无法填值插入异常删除一条记录时是不是会连带丢失其他事实删除异常修改一个业务属性时是不是要更新多行更新异常三个问题任何一个答“是”说明还藏着依赖问题。5.2 一张可以直接照着用的自查表检查项违规表现处理方向字段是否含多个值逗号、竖线、分号拼接的字符串拆列或建关联表字段是否被用于模糊匹配大量LIKE %x%查询说明该字段该拆主键是否为复合复合主键下存在只依赖单列的描述字段按依赖拆表是否存在“名字”类冗余从表里存了主表的名称字段删除或明确标注为快照是否存在配置类冗余单价、折扣、等级名称写死在业务表抽出配置表修改某属性是否要动多行批量更新脚本频繁出现定位传递依赖是否存在无法插入的场景新数据因缺少主键部分而插不进检查 2NF这张表我一般放在设计评审的模板里每张新表过一遍大部分问题在评审阶段就能拦住比上线之后改表结构便宜太多。改表结构最麻烦的不是 DDL 本身而是历史数据的清洗和回归验证——一个几十万行的表加字段容易把拆出去的字段重新对齐业务含义往往要花好几天。6. 反范式什么时候该故意不拆6.1 四类值得反范式的场景说完该拆的说说该留的。反范式不是不讲规范是在算过账之后主动做的选择。我总结下来常见四类。第一类是历史快照。订单里的商品名称、下单时的价格、收货地址这些必须冗余而且必须永不回改。注意这不是“冗余”是“快照”——它记录的是下单那一刻的事实跟商品现在叫什么、卖多少钱没有关系。这类字段我会在命名上加后缀比如product_name_snapshot、unit_price_at_order让人一眼看出它不跟随主表变化。把快照和引用混在一起是导致订单金额算错的常见原因。第二类是列表页的高频展示字段。文章列表要显示作者昵称如果每次都 join 用户表列表页的查询会重不少。这种场景我通常会在文章表冗余一个author_name同时明确它的定位是“展示用缓存”提供单独的重命名同步逻辑用户改昵称时发一条消息异步更新他名下的文章记录。要不要这么做取决于 QPS 和用户改名频率如果一天没几个人改名、列表页也就几百 QPS我宁可不冗余join 一下更省心。第三类是统计计数。点赞数、评论数、阅读数这类字段如果每次都COUNT(*)数据库扛不住。做法是在主表上维护计数列写入时增减再配一个定时对账任务比如每天凌晨按真实数据重算一次纠正偏差。对账这步不能省因为并发场景下增减操作可能因为重试导致计数漂移。第四类是报表与数仓宽表。面向分析的宽表本来就该是反范式的把所有维度拍平在一张表里查询最快。这类表通常由离线任务从规范的业务表里生成本身就是“派生数据”不用担心一致性重跑一次就恢复了。6.2 反范式字段的一致性怎么保反范式最大的风险不是设计是维护。我踩过最疼的一次坑是一个冗余字段靠代码里三处不同的地方更新某次新增了一个入口忘了同步两个月后数据对不上清洗花了整整两天。后面我定了几条规矩一直沿用冗余字段必须有注释写清楚三件事来源表是哪个、由谁负责同步、是否允许与源数据不一致同步逻辑只允许有一个入口要么统一走服务层的一个方法要么统一走数据库触发器后者我不推荐但小团队图省事也见过必须有不一致检测手段最简单的是写一条对账 SQL定期跑一遍发现差异就报警-- 检查文章表冗余的作者昵称是否与用户表一致 SELECT a.id, a.author_name, u.nickname FROM article a JOIN user u ON a.author_id u.id WHERE a.author_name u.nickname LIMIT 100;这条 SQL 成本不高但能救命。我的习惯是把它放进日常巡检任务里一周跑一次有差异先看数量级——如果只是几条多半是异步延迟如果是几千条说明同步链路某个环节断了得立刻查。7. 我在实际项目里踩过的几个坑7.1 把范式当教条拆出了七表 join刚工作时参与一个活动配置系统当时对范式特别虔诚把活动的基础信息、参与条件、奖品、库存、限购规则、展示样式全部拆成独立表每个表只有四五列看起来非常“规范”。结果运营后台的活动列表页要展示活动名称、奖品名称、剩余库存、参与人数一个查询 join 了六张表数据量上来之后慢查询日志里全是它。更麻烦的是新增一个活动要按顺序写六张表中间任何一步失败就要考虑事务回滚代码复杂度直线上升。后来重新审视发现有些拆分是没必要的奖品名称和库存其实是强绑定关系一个奖品记录对应一份库存拆成两张表除了让字段少一点没有带来任何一致性的收益反而增加了 join。合并之后 join 少了两层接口耗时降了一个数量级。这个教训我记到现在拆表的目的是一致性不是列数好看。如果两组字段永远一一对应、且生命周期完全同步它们就该在同一张表里。7.2 主键选型的连锁反应另一个坑跟主键有关。有个表当初用手机号做主键理由是“手机号天然唯一”。前两年没问题后来业务要求支持一个手机号绑定多个账号家庭共享场景整个系统跟着改所有引用这张表的外键、所有缓存的 key、所有接口参数里的标识全要换。主键这个东西一旦定下来改动的成本是全局的。所以我现在选主键的原则很明确优先代理主键自增 id 或分布式 id业务唯一性用唯一索引保证绝不用可能变化的业务字段做主键手机号、邮箱、身份证号、用户名都属于会变或会重复的代理主键的类型要一次性定够INT换成BIGINT在大表上是一次痛苦的操作一开始就用BIGINT能省掉未来的麻烦顺带提一下复合主键在关联表里的适用性前面 3.3 节说过了取舍的核心是团队一致性别在不同表之间反复横跳。7.3 用字符串拼接存多值的两个真实代价最后说一个看起来最无害的违规——用逗号拼标签。这个模式在小系统里极其常见因为开发快。但它有两个代价经常被低估。第一个是删除和修改的原子性。你要从数码,手机,苹果,配件里删掉“手机”用REPLACE会误伤用字符串函数拼接要写一长串表达式而且并发下两个人同时改后写的会覆盖先写的。第二个是统计几乎做不了。想算“每个标签下有多少商品”关系表一句GROUP BY就完事逗号串得先把所有行读出来在应用层切分几万行还能忍几十万行直接把内存打满。我现在的判断很简单这个字段如果只写不查、只在详情页整体展示拼字符串可以接受只要有任何筛选、统计、单值修改的需求一律建关联表。而现实中几乎不存在“永远不查”的字段所以绝大多数情况都应该老老实实建关联表。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑