从UNF到3NF:数据库规范化拆表与函数依赖实战
“正则化(Normalization)”这个说法在数据库语境里其实有个更常见的译名:规范化。我第一次看到这个标题时愣了一下,因为“正则化”在数值计算、模型训练那个圈子里指的是完全不同的另一件事,中文译名撞车之后,在搜索引擎上搜“正则化”,前几页全是过拟合、惩罚项、L1/L2 那些内容,想找 UNF、1NF、2NF、3NF 的人很容易搜着搜着就跑偏。所以先把话说死:这里讲的是关系型数据库里一张表该怎么拆、拆到什么程度该停手、拆的依据是什么。
具体到这篇要覆盖的东西,是从UNF(未规范化)一路推到第三范式 3NF这条主线:每一级的判定标准是什么、背后靠什么理论支撑(核心是函数依赖)、一张真实的“大宽表”怎么一步步拆成几张干净的小表、拆完怎么用 SQL 验证没拆错、以及拆过头之后会遇到什么麻烦。它的价值不在于背定义,而在于你接手一张字段几十个、更新一条数据要改五六行的表时,能一眼看出病根在哪。适合三类人看:正在学数据库基础、被范式定义绕晕的同学;写过不少表但从没系统想过“为什么这么拆”的开发者;以及要接手别人遗留表结构、需要判断该不该动刀的人。
1. 正则化到底在治什么病
1.1 先看一张“什么都往里塞”的表
我见过的第一张典型的病表,是在一个早期的订单管理项目里。它长这样:一行记录里同时塞着订单号、下单日期、客户编号、客户姓名、客户电话、客户收货地址、商品编号、商品名称、商品单价、购买数量、订单总金额。当时设计这张表的人想法很朴素——查询方便,一次查询全拿到,不用连表。
问题在于,一个订单买了三样东西怎么办?他选择了最省事的做法:在“商品编号”这一格里写“P01,P02,P03”,在“商品名称”里写“苹果,香蕉,橙子”,单价写“5.00,3.00,4.00”。这一格里的逗号分隔串,就是后面所有麻烦的起点。
这张表带来的第一个直接后果是:你没法用 SQL 干净地查“买了苹果的所有订单”。只能写WHERE 商品名称 LIKE '%苹果%',而这个条件会误伤“苹果醋”“苹果手机壳”这类名字里带“苹果”的商品。第二个后果是:想统计某个商品卖了多少件,得先把那串数字拆开,而 SQL 本身并不擅长拆字符串。
这就是 UNF 最典型的形态:单元格里装着多个值、表里存在重复组、没有明确的主键。它甚至还不能算一张合格的“关系表”,只是看起来像表格的一段文本。
1.2 三类异常:拆表的真正动机
教科书上讲范式,往往先甩定义。我带人的时候习惯反过来,先讲三类操作异常,因为这三样东西是你在实际写业务代码时真真切切会撞上的,而范式只是消除它们的理论工具。
插入异常:假设新签约了一个客户,但还没下单。这时候你想把客户信息录进系统,发现录不进去——因为这张表的主键是(订单号, 商品编号),没有订单就没有主键,没有主键这一行就不合法。客户信息被“绑架”在了订单上。
更新异常:同一个客户下了 20 个订单,客户电话变了,你得改 20 行。改的时候手一抖漏了一行,系统里就出现了同一个客户两个电话。这不是操作员不小心的问题,是表结构在逼人犯错。更麻烦的是并发场景,两个人同时改,改出来的结果可能互相覆盖。
删除异常:最后一个订单被删掉,这个客户的所有信息跟着一起消失了。客户资料本来应该独立存在,现在成了订单的附属品。
这三类异常的共同根源只有一个:同一份事实被存了多份,或者一份事实被存错了位置。正则化的全部工作,就是把“一份事实”收敛到“一个地方”,让每次修改只需要动一处。
1.3 范式的本质是给函数依赖上约束
把上面那些现象抽象一层,就落到函数依赖这个概念上。说人话:如果知道了 A 的值,就唯一确定了 B 的值,就称 B 函数依赖于 A,记作 A → B。在订单表里,客户编号 → 客户姓名、客户编号 → 客户电话,这两条依赖是成立的;反过来,客户姓名 → 客户编号不成立,因为可能重名。
而 1NF、2NF、3NF 的递进关系,本质上是给这些依赖不断加约束:1NF 管的是“一个格子里只能有一个值”;2NF 管的是“非主属性不能只依赖复合主键的一半”;3NF 管的是“非主属性不能依赖另一个非主属性”。每加一层约束,表就少一种冗余,异常就少一类。
我个人的理解是,范式不是让你追求某种数学上的完美,而是给“这份数据该放哪张表”提供一个可判定的判据。没有判据的时候,拆表全靠感觉,团队里三个人能拆出三种结果;有了判据,讨论就能落到“这条依赖成立不成立”上。
2. 拆表之前必须搞清楚的键与依赖
2.1 超键、候选键、主键、非主属性
这几个词在教材里往往一口气全丢出来,容易看晕。我用一个班里的学生表来类比:字段有学号、身份证号、姓名、班级、班主任。
超键是“能唯一确定一行”的任意字段组合。学号可以,身份证号可以,(学号, 姓名)这种带冗余的组合也可以。超键是个很宽松的概念,包含了很多没必要的组合。
候选键是超键里“最小”的那些,也就是去掉任何一个字段就不再唯一的组合。学号是最小的,身份证号也是最小的,(学号, 姓名)不是,因为去掉姓名还是唯一的。所以这张表有两个候选键。
主键是从候选键里人为挑一个出来当“代表”的。挑学号当主键,身份证号就成了备用键,通常加个唯一约束保证它也不重复。
非主属性就是不包含在任何候选键里的字段。这里姓名、班级、班主任都是非主属性。注意“班主任”虽然是老师,但在学生表里它是非主属性,因为它不出现在任何候选键中。
这个概念特别重要,因为 2NF 和 3NF 讨论的全是“非主属性”对候选键的依赖关系。搞不清谁是主属性谁是非主属性,后面所有判定都会跑偏。
2.2 三种函数依赖:完全、部分、传递
完全函数依赖:X 是复合键,Y 依赖于整个 X,缺了任何一部分都不行。订单明细里,(订单号, 商品编号)→ 购买数量,这个数量必须同时知道订单和商品才能确定,所以是完全依赖。
部分函数依赖:Y 只依赖复合键的一部分。(订单号, 商品编号)→ 客户编号,实际上只要知道订单号就够了,跟商品编号没关系。客户编号部分依赖于主键。这就是 2NF 要消灭的东西。
传递函数依赖:X → Y 且 Y → Z,同时 Y 不能反推出 X,那 Z 就传递依赖于 X。员工表里,员工编号 → 部门编号,部门编号 → 部门名称,所以员工编号 → 部门名称是传递依赖。这是 3NF 要消灭的东西。
用生活化的话说:部分依赖是“这条信息其实只跟主键的一半有关”,传递依赖是“这条信息其实是别人的信息,被顺手抄过来了”。两句话记牢,判定 2NF 和 3NF 的时候就不会卡住。
2.3 一个可以直接套用的判定顺序
我在实际做表结构评审时,会用下面这个固定顺序走一遍,基本不会漏:
| 步骤 | 检查内容 | 不满足时的病名 |
|---|---|---|
| 1 | 每个格子是否只有一个值 | UNF |
| 2 | 是否存在重复的列组(如电话1、电话2) | UNF |
| 3 | 是否有明确的主键 | UNF |
| 4 | 每个非主属性是否完全依赖整个候选键 | 违反 2NF |
| 5 | 每个非主属性是否不依赖其他非主属性 | 违反 3NF |
这个顺序不能乱。因为 2NF 的前提是“已经满足 1NF”,3NF 的前提是“已经满足 2NF”。我见过有人在 UNF 的表上直接讨论 3NF,讨论半天发现连主键都没定下来,白费功夫。
3. UNF 到 1NF:把单元格里的东西摊开
3.1 UNF 的四种典型长相
第一种:多值塞一格。就是开头说的“P01,P02,P03”。同源变体还有用分号、斜杠、竖线分隔的,或者用 JSON 数组塞进一个文本字段的。
第二种:重复列组。设计者为了绕开“一格多值”,横向扩展出电话1、电话2、电话3三列。表面上解决了问题,实际上制造了新问题:想查“电话是 138xxx 的客户”,得写三个 OR;想支持第四个电话,就得改表结构;而且大部分客户只有一个电话,剩下两列常年是空值。
第三种:复合字段。地址一列里塞着“省 市 区 街道 门牌”,或者姓名里塞着“张先生”。这在导出报表时很方便,但想按城市统计就抓瞎了。
第四种:没有主键或者主键不唯一。一张表里存在完全重复的两行,删其中一行得靠ROWID之类的物理标识,这已经失去关系模型的意义了。
3.2 1NF 的判定与拆法
1NF 的要求可以压缩成两条硬性判据:每个属性值都是原子的(不可再分),且不存在重复的属性组。加上一条隐含要求:表必须有主键。
拆法的核心思路是“把多值属性变成多行,而不是多列”。上面那个订单表的 1NF 化过程是这样的:
原来一行:
订单号=O001, 商品编号=P01,P02,P03, 数量=2,3,1拆成三行:
O001, P01, 2 O001, P02, 3 O001, P03, 1这样一来,主键变成(订单号, 商品编号),每一行的数量都是唯一的、可计算的。想统计 P01 卖了多少,一句SUM(数量) WHERE 商品编号='P01'就完事。
对于重复列组的处理,通常有两种走向:如果业务上“电话”确实可能有多个,就竖向拆表——建一张客户电话表,主键是(客户编号, 电话类型),一行一个电话;如果业务上其实最多就一个,那就别留三列,只保留一列加唯一约束。
提示:1NF 化最容易被忽略的是“派生列”和“代理键”的取舍。拆出来的明细行往往需要一个新的自增 ID 作为物理主键,而业务主键(订单号, 商品编号)加唯一约束。这两者的分工在后面对账、分页场景里差别很大,值得提前定下来。
3.3 原子性是有业务边界的
这里要泼一盆冷水:“原子”不是绝对的,取决于业务粒度。地址拆成省市区是原子的吗?如果业务只做整单发货,地址就是一整块文本,拆不拆无所谓;如果业务要按城市做配送分区定价,那地址就必须拆成独立字段甚至独立的地区表。
我的经验判断标准是:这个字段会不会被单独查询、单独统计、单独修改。会的,就该拆;不会的,留着当整体反而省事。我见过有人把“备注”字段按逗号拆成多行存储,结果每次读取都要拼回去,性能掉了不说,代码还难写——这就是把原子性用错了地方。
另外提醒一句,把 JSON 塞进一个文本列,严格来说违反了 1NF的精神,因为那个格子里装的是结构化数据。但现代关系型数据库对 JSON 类型的查询支持越来越好,在“配置项”“扩展属性”这类场景下,用它换取的灵活性往往大于范式带来的收益。这是取舍,不是错误,但要清楚自己在做什么。
4. 1NF 到 2NF:复合主键下的部分依赖
4.1 2NF 的判据
2NF 的定义一句话:在满足 1NF 的前提下,所有非主属性都完全函数依赖于候选键,不存在部分依赖。
请注意“候选键”三个字。很多人背成“主键”,在只有单一主键的表上没什么差别,但在有多个候选键的表上,判定会出错。
拆解的方法很机械:看主键是不是复合的,如果是,逐个检查每个非主属性,问一句“这个字段是不是只跟主键的一部分有关”。是的话,把它和它依赖的那部分主键一起搬出去,成立一张新表。
4.2 单主键表的陷阱式误判
这是初学者最容易踩的坑。很多教材的例子、很多人的第一反应是:“我的表主键就一个自增 ID,那肯定满足 2NF 了吧?”
答案是:从定义上确实满足,但这句话毫无意义。
因为 2NF 管的是“部分依赖”,而部分依赖的前提是复合键。单列主键的表在数学上自动满足 2NF,但这并不代表这张表没问题——它的冗余问题往往藏在 3NF 那一层,属于传递依赖。我见过有人用“我的表是 2NF 的”来给自己辩护,实际上他的表里有部门编号 → 部门名称这样的传递依赖,压根没到 3NF。
所以正确的姿势是:2NF 只对复合候选键的表有实质检查意义。如果你的表主键是单一自增 ID,快速确认一下有没有其他复合唯一约束(比如订单明细的(订单号, 商品编号)),没有的话就可以直接跳到 3NF 的检查。
4.3 拆解演示与判定流程
拿开头那张订单表,做完 1NF 之后是这样:
订单明细(订单号, 商品编号, 下单日期, 客户编号, 客户姓名, 客户电话, 商品名称, 商品单价, 数量)主键是(订单号, 商品编号)。逐个非主属性问一遍:
下单日期:只跟订单号有关,跟商品编号无关。部分依赖。客户编号、客户姓名、客户电话:同样只跟订单号有关。部分依赖。商品名称、商品单价:只跟商品编号有关。部分依赖。数量:必须同时知道订单号和商品编号。完全依赖,留下。
拆解结果是三张表加一张明细表:
-- 订单主表 CREATE TABLE orders ( order_no VARCHAR(20) PRIMARY KEY, order_date DATE NOT NULL, customer_no VARCHAR(20) NOT NULL ); -- 客户表 CREATE TABLE customers ( customer_no VARCHAR(20) PRIMARY KEY, customer_name VARCHAR(50) NOT NULL, phone VARCHAR(20) ); -- 商品表 CREATE TABLE products ( product_no VARCHAR(20) PRIMARY KEY, product_name VARCHAR(80) NOT NULL, unit_price DECIMAL(10,2) NOT NULL ); -- 订单明细表 CREATE TABLE order_items ( order_no VARCHAR(20) NOT NULL, product_no VARCHAR(20) NOT NULL, quantity INT NOT NULL, PRIMARY KEY (order_no, product_no) );拆完之后,前面说的三类异常基本消失了:新客户可以直接录进customers,不用等下单;客户改电话只改一行;删订单不会带走客户资料。
但还没完。orders表里留着customer_no,这个字段是不是有问题?往下看。
4.4 拆表时的两个实操注意点
第一,外键约束要不要加。教科书上拆完表一定画连线,实际项目里很多团队为了性能或者历史原因不加外键。我的建议是:核心业务表尽量加,尤其是金额、库存相关的,外键能挡掉一大批脏数据;日志、统计类的宽表可以不加,因为它们的写入量大、来源不可控。加不加是取舍,但至少要有应用层的一致性校验兜底。
第二,拆分时的历史数据迁移。把一张宽表拆成四张,最大工作量不在建表,在数据回填。我的做法是先建新表,用INSERT INTO ... SELECT DISTINCT把主数据抽出来,再用关联更新把明细表补全。
-- 先把客户主数据抽出来(DISTINCT 很重要,否则主键冲突) INSERT INTO customers (customer_no, customer_name, phone) SELECT DISTINCT customer_no, customer_name, phone FROM legacy_order_wide; -- 再回填订单主表的客户编号(如果原来就有就不用) -- 最后迁移明细,注意重复行 INSERT INTO order_items (order_no, product_no, quantity) SELECT order_no, product_no, quantity FROM legacy_order_wide WHERE quantity > 0;SELECT DISTINCT这一步如果原表里同一个客户存在两种电话,会抽出两行,主键冲突直接报错。这其实是好事——它在迁移阶段就把脏数据暴露出来了,比上线后才发现强。遇到这种情况,先跑一个查询把冲突的客户列出来,人工确认哪个是对的,再决定留哪条。
5. 2NF 到 3NF:传递依赖藏得最深
5.1 传递依赖长什么样
3NF 的定义:在满足 2NF 的前提下,所有非主属性都不传递依赖于候选键。
用人话说:表里的某个字段,它其实描述的是另一个非主属性,而不是直接描述主键。这种依赖往往表现为“主键 → 属性 A → 属性 B”的链条。
教科书上最经典的例子是员工表:
员工(员工编号, 姓名, 部门编号, 部门名称, 部门负责人)主键是员工编号。员工编号 → 部门编号,而部门编号 → 部门名称、部门编号 → 部门负责人。所以部门名称和部门负责人都是传递依赖于员工编号的。它们描述的是“部门”这个实体,不是“员工”。
注意部门编号本身不违反 3NF,因为它是用来关联的外键,属于正常设计。真正违规的是部门名称和部门负责人——把别人的属性抄到了自己表里。
5.2 拆解演示
判定的方法很简单:逐个非主属性,问“它是不是由另一个非主属性决定的”。是的话,链条上后面那个就搬走。
-- 违反 3NF 的写法 CREATE TABLE employees_bad ( emp_no VARCHAR(20) PRIMARY KEY, emp_name VARCHAR(50), dept_no VARCHAR(20), dept_name VARCHAR(50), -- 传递依赖 dept_leader VARCHAR(50) -- 传递依赖 ); -- 拆成两张 CREATE TABLE departments ( dept_no VARCHAR(20) PRIMARY KEY, dept_name VARCHAR(50) NOT NULL, dept_leader VARCHAR(50) ); CREATE TABLE employees ( emp_no VARCHAR(20) PRIMARY KEY, emp_name VARCHAR(50) NOT NULL, dept_no VARCHAR(20) NOT NULL );拆完之后,部门改名只改一行,一个部门下 500 个员工不受影响。这就是 3NF 带来的最直接的收益:修改点从 N 个收敛到 1 个。
再回头看第 4 节拆出来的orders表,里面只有customer_no没有customer_name,已经满足 3NF 了。如果当时偷懒把customer_name和customer_city塞回订单表,那就立刻违反 3NF。
5.3 派生列算不算违反 3NF
这是个高频疑问。订单明细里存一个总金额 = 数量 × 单价,它依赖谁?依赖(订单号, 商品编号)这个复合主键,而且是完全依赖。链条上没有经过其他非主属性,所以严格来说它不违反 3NF。
但从“消除冗余”的初衷看,它确实是冗余的——因为可以算出来。我的实践原则是:
| 场景 | 处理方式 |
|---|---|
| 金额、库存等强一致要求 | 不存计算列,查询时算;或存了用生成列/触发器保证 |
| 报表、大屏、历史快照 | 存下来,图查询快,接受冗余 |
| 单价这类“快照价格” | 必须存,因为商品价格会变,订单里要留成交价 |
第三种情况特别值得说。很多人会想“单价从商品表查不就行了”,但商品表的单价是当前价,订单里的单价是成交价,两者语义完全不同。如果把单价从订单明细里删掉,商品调价之后所有历史订单的金额都会跟着变,财务对账直接崩。所以这一列不是冗余,它是一份独立的、必须固化的业务事实。
注意:判断一列该不该冗余,问的不是“能不能算出来”,而是“算出来的结果和当初发生的事实是否一致”。会变的东西,就得存快照。
6. 完整实操:一份可照着做的范式化改造流程
6.1 五步法
上面三节讲的是单点判定,实际动手的时候,我一般走这五步:
第一步,收集所有字段名,写在一张纸上。这一步别用工具,就列出原始字段清单。很多时候问题在列清单的过程中就暴露了——“哦,原来我有两个字段都叫‘状态’”。
第二步,确定候选键。找出所有能唯一标识一行的最小字段组合。如果找不出来,说明表缺了标识,需要引入代理键。
第三步,标注函数依赖。对每个非主属性,画出它依赖谁。这一步是关键,靠的是对业务的理解,不是靠工具。比如“折扣金额”依赖谁?得看业务规则,通常依赖订单号加商品编号。
第四步,按范式逐层拆分。先查 1NF(格子是否原子),再查 2NF(部分依赖),再查 3NF(传递依赖)。每拆一次,重新检查一遍,因为拆完之后主键可能变了。
第五步,写验证 SQL。拆完不算完,得证明拆完之后的表能还原出原来的信息。这是最容易被跳过的一步,也是最容易发现问题的一步。
6.2 用 SQL 验证拆分是否正确
验证的核心思路是“拆之前和拆之后做同样的统计,结果必须一致”。具体做法:把拆分后的表 JOIN 回去,和原宽表对比。
-- 验证1:总金额是否一致 SELECT (SELECT SUM(total_amount) FROM legacy_order_wide) AS before, (SELECT SUM(i.quantity * p.unit_price) FROM order_items i JOIN products p ON i.product_no = p.product_no) AS after; -- 验证2:每个客户的订单数是否一致 SELECT (SELECT COUNT(DISTINCT order_no) FROM legacy_order_wide) AS before_cnt, (SELECT COUNT(*) FROM orders) AS after_cnt; -- 验证3:有没有孤儿明细(明细指向了不存在的订单或商品) SELECT COUNT(*) FROM order_items i LEFT JOIN orders o ON i.order_no = o.order_no LEFT JOIN products p ON i.product_no = p.product_no WHERE o.order_no IS NULL OR p.product_no IS NULL;第三句特别有用。拆表之后最常见的脏数据就是孤儿行——明细里引用了不存在的商品编号。这类数据在应用层可能因为顺序问题偶发产生,跑一次孤儿检查就能揪出来。
再补一个查重复的检查,防止 1NF 化的时候漏了行:
-- 明细表按(订单号, 商品编号)应该唯一,不唯一说明有重复行 SELECT order_no, product_no, COUNT(*) AS cnt FROM order_items GROUP BY order_no, product_no HAVING COUNT(*) > 1;6.3 拆到什么程度就该停手
3NF 之后还有 BCNF、4NF、5NF,但绝大多数业务系统做到 3NF 就足够了。原因很现实:范式的每一级都在增加表的数量,而表越多,查询时需要的 JOIN 就越多。
我做过一个极端案例:为了追求高范式,一张报表需求被拆成了 11 张表,查询语句要连 10 次表,跑一个月的汇总数据要 40 多秒。后来把其中三张高频关联的表做了反规范化,冗余了两个字段,同样的查询降到 3 秒以内。
所以我的取舍原则是:
- 写多读少的业务表,严格做到 3NF。因为是写入保障一致性,收益最大。
- 读多写少的报表层,允许反规范化。可以建宽表、建物化视图、冗余统计字段。
- 核心交易表,宁愿多连表也别冗余。金额、库存这类字段一旦不一致,排查成本极高。
我之前查过一版资料,里面顺带提到的 BCNF,其实是在 3NF 基础上再收紧一层:连主属性都不能对候选键有部分或传递依赖。这个在只有单一候选键的表上,3NF 和 BCNF 是等价的,所以普通业务表基本不用单独操心。什么时候会走到 BCNF?一个表有多个重叠候选键、并且这些键之间有交叉依赖的时候。这种情况在真实业务里不算多,遇到再单独处理就行。
7. 常见问题与排查技巧实录
7.1 高频疑问速查表
| 现象 | 大概率的病根 | 排查动作 |
|---|---|---|
| 一条数据改完,别的行跟着变了 | 同一事实存了多份,违反 3NF | 找所有“名字型”字段,看它是否依赖另一个非主键字段 |
| 想按某个维度统计,只能写 LIKE | 单元格多值,违反 1NF | 检查有没有逗号、竖线分隔的字符串字段 |
| 想支持“第二个电话”得改表结构 | 重复列组,违反 1NF | 找 phone1/phone2 这类带序号的列名 |
| 新数据录不进去,因为主键没法填 | 依赖关系方向错了,属于插入异常 | 检查是不是把主数据挂在了流水表上 |
| 明细表和主表数量对不上 | 拆分不彻底,部分依赖没清干净 | 对比明细去重后的主键数量 |
| JOIN 之后行数暴涨 | 拆分时主键没定对,产生了笛卡尔积 | 检查每张表的主键和唯一约束 |
7.2 几个真踩过的坑
坑一:把“编号”当成主键就万事大吉。有个项目所有表都用自增 ID 做主键,看着很规范。结果订单明细表里没有对(订单号, 商品编号)加唯一约束,同一个订单同一个商品能插进去两条。表面上“满足 3NF”,实际上业务规则没约束住。范式管的是冗余,唯一约束管的是业务规则,两件事不能互相替代。
坑二:拆表之后忘了加索引。把宽表拆成四张之后,JOIN 的字段没建索引,原本一条 SQL 能干完的事变成四次全表扫描。拆表本身不会变慢,变慢的是没有为新的访问路径准备索引。我现在的习惯是:新建的关联字段一律先加索引,跑一段时间看执行计划再决定要不要精简。
坑三:把“状态”字段拆过头。有张表里有个订单状态,值是“待支付/已支付/已发货/已完成”。有人主张拆一张状态字典表,用外键关联。从纯范式角度,状态确实不依赖其他非主属性,它直接依赖主键,本身不违反 3NF。拆成字典表的唯一好处是便于改中文名,代价是每次查询都要 JOIN。我的判断是:状态值稳定、数量少、不需要动态增删的,就别拆。
坑四:1NF 化之后行数爆炸。有个统计表原本一行代表“一个用户一天”,用的是多个列存不同指标。改成 1NF 之后变成“一行一个指标”,行数翻了几十倍,查询反而变慢。这种属于把“横向的指标”误当成“多值属性”了。指标列如果数量固定、语义不同、业务上总是同时查询,它们不是重复组,是正常的多列设计。
提示:判断“多列算不算重复组”,看的是这几列语义是否相同、是否会一起增长。电话1/电话2/电话3 语义相同,是可增长的,算重复组;浏览量/点赞量/收藏量 语义不同,是固定的,不算。
7.3 一个可以常备的自检脚本
接手一张陌生表时,我会跑几组查询快速摸底。第一组查主键和约束:
-- 查表的约束信息(不同数据库的系统表名不同,这里以标准信息模式为例) SELECT tc.constraint_type, kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name WHERE tc.table_name = 'your_table_name';第二组查疑似多值字段——扫一遍文本列,看有没有大量的分隔符:
-- 找出含有逗号的字段值,这类字段十有八九违反 1NF SELECT order_no, product_no FROM legacy_order_wide WHERE product_no LIKE '%,%' OR product_name LIKE '%,%';第三组查疑似传递依赖——找那些“名字型”字段,看它们的取值重复率是不是异常高:
-- 如果 dept_name 的重复率超过 80%,说明它是被抄来抄去的 SELECT COUNT(*) AS total, COUNT(DISTINCT dept_name) AS uniq, ROUND(COUNT(DISTINCT dept_name) * 100.0 / COUNT(*), 2) AS pct FROM employees_bad;这三组跑完,基本能判断一张表该不该动、从哪一层动。我实际用下来,重复率这个指标比翻文档快得多,尤其适合接手没有设计文档的老系统。
最后分享一个我自己一直在用的习惯:每次建表之前,先在注释里写三行——这张表描述的唯一实体是什么、主键为什么是这几个字段、哪些字段是从别的表引用的。写完这三行,如果发现“描述的唯一实体”填不出来,或者主键里混着两个不同实体的标识,这张表八成要拆。这比事后回头改表成本低太多了。