ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

数据库设计核心:三大范式、表关系与外键实战指南

数据库设计核心:三大范式、表关系与外键实战指南 1. 很多问题在写第一张表时就埋下了数据库设计到底在防什么我见过太多项目前期为了赶进度所有数据塞进两三张表里字段能省就省。等业务跑起来之后产品说订单要支持多个收货人商品要加一个多级分类开发当场愣住——因为当初那几张表的结构根本没法往上扩展改表等于重写整个模块。这种局面本质上就是数据库设计阶段没想清楚。数据库里讲的三大范式、表的关系、外键、ER图这些东西听着像学院派名词但它们解决的从来不是考试题而是三个最实际的问题数据会不会重复、改一处数据要不要改好几个地方、删数据的时候会不会删出问题。先看一个最典型的反面例子。假设我有一张订单表模拟一个电商系统的早期敷衍版CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(50), user_phone VARCHAR(20), province VARCHAR(20), city VARCHAR(20), address VARCHAR(100), product_names VARCHAR(500), product_prices VARCHAR(200), order_amount DECIMAL(10,2), created_at DATETIME );这张表第一眼看上去似乎没什么问题下单用户、收货地址、买的东西、订单金额信息都在。但实际跑起来就难受了。先说商品字段。product_names和product_prices用逗号拼接多个值这个设计让你没法对商品做任何统计。想查卖得最好的是哪个商品你得把每个订单的product_names拆开再聚合写出来的SQL又长又慢。这就是典型的违反第一范式字段不是原子的。再说冗余。user_name、user_phone这些用户信息直接放在订单表里一旦用户改了手机号所有历史订单要么跟着改要么就留着旧号码。跟着改意味着要update成百上千行不跟着改后续对账、风控拿到的用户信息就是错的。这是更新异常。同理如果某个用户暂时没有产生订单他的信息就永远插不进订单表这是插入异常。删除订单时把用户信息一并删掉这是删除异常。三大范式、外键、ER图这些概念说白了就是为了在结构层面提前把这些异常解决掉。下面我按实际设计顺序把这条链路完整走一遍。2. 三大范式逐层拆解从1NF到3NF卡住的分别是哪些问题三大范式是一层一层递进的关系。满足第二范式的前提是先满足第一范式满足第三范式的前提是先满足第二范式。但在实际工作里大家更常做的是直接按业务语义设计出满足3NF的结果很少有人真的会先写出违反1NF的表、再一步步拆。不过理解每一步在解决什么对判断要不要牺牲范式换性能很重要。2.1 第一范式字段必须保持原子性第一范式要求表的每个字段都不可再分也就是说一个字段不能存一个列表也不能存张三、李四、王五这种用分隔符拼起来的字符串。拿前面那张orders表来说product_names和product_prices就是典型的违反1NF。正确的做法是把商品信息从订单主表里拆出去单独建一张order_items明细表每个产品占一行CREATE TABLE order_items ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, product_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL DEFAULT 1, created_at DATETIME );这样每个订单对应多行明细字段值都是最小粒度单元。你统计销量、算SKU贡献、按商品维度做报表全都变成常规的聚合查询不用再写字符串拆分逻辑。这里有一个容易忽略的点原子性是相对业务需求而言的。比如address字段如果业务从不关心市区分别统计那么存一个完整地址字符串不算违反1NF但如果你需要按城市筛选订单那最好把省、市、区拆成独立字段。原子性不是教条是为查询服务的。2.2 第二范式非主键字段必须完全依赖主键第二范式专门针对联合主键的情况。它的要求是每一个非主键字段都必须完全依赖于整个主键而不能只依赖主键的一部分。先看一个违反2NF的经典场景。假设你有一张选课表主键是student_id和course_id的联合CREATE TABLE selection ( student_id INT, course_id INT, student_name VARCHAR(50), course_name VARCHAR(100), score DECIMAL(5,2), PRIMARY KEY (student_id, course_id) );score字段是完全依赖联合主键的——一个学生选一门课就对应一个成绩。但student_name只依赖student_idcourse_name只依赖course_id它们都只依赖主键的一部分。这就埋了隐患同一个学生选了10门课他的名字就得重复存10次。如果学生改名你必须同步更新这10行漏掉一行就是数据不一致。这就是部分依赖导致的更新异常。拆解方式还是拆分表学生信息放students表课程信息放courses表选课表里只留student_id、course_id和score三个字段。选课表作为关联表负责表达学生和课程之间的多对多关系后面讲表的关系时会再细说。2.3 第三范式消除非主键字段之间的传递依赖第三范式的要求是非主键字段之间不能存在依赖关系也就是说每个非主键字段都只能依赖主键不能依赖其他非主键字段。举个反例。订单表里如果同时存customer_id和customer_phone而customer_phone本质上由customer_id决定那么customer_phone就通过customer_id这个非主键字段间接依赖了主键。这就是传递依赖。带来的问题仍然是更新异常同一个客户下10单手机号存了10份改一次要改10行。万一漏改订单表里同一个客户就有两个手机号。正确的做法是把客户信息抽到独立的customers表订单表只保留customer_id这个外键字段。查询需要手机号时用join关联。2.4 范式与性能的平衡别为了范式而范式到这里很多人会陷入一个误区所有表都设计成3NF然后所有查询都用join。范式程度越高表拆分越细join链条越深查询性能往往越低。在互联网高并发场景下纯3NF设计通常是理想化但不可直接落地的。我在实际项目里常用的判断标准是三条数据一致性要求极高的核心字段比如账户余额、订单状态严格范式化消除冗余。读多写少、允许轻微冗余的字段比如商品名称、用户昵称可以在订单明细表里冗余一份快照避免下单后商品改名前订单显示也跟着变。纯统计报表类的数据直接单独建宽表每日跑任务写入完全不按范式来。所以不要用表满不满足3NF当唯一质量标准。更合理的目标是设计的时候先按范式把结构理清楚再针对具体查询场景做有意识的冗余。冗余可以但要清楚自己牺牲了什么以及怎么保证冗余字段的一致。3. 表的关系一对一、一对多、多对多建表时怎么选表之间的关系是ER图中最核心的内容也是面试必问。但面试题往往只问概念实战里真正要搞明白的是什么场景该用什么关系关系在MySQL里用什么字段表达。3.1 一对多关系最常用外键放在多的一方一对多是业务里出现频率最高的关系。一个部门有多个员工一个用户有多条订单一个订单有多条明细都是典型的一对多。建表规则非常固定在多的一方保存一的一方的主键作为外键。比如员工表里放department_id订单明细表里放order_id。CREATE TABLE department ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL ); CREATE TABLE employee ( id INT PRIMARY KEY AUTO_INCREMENT, department_id INT NOT NULL, name VARCHAR(50) NOT NULL, hire_date DATE, CONSTRAINT fk_emp_dept FOREIGN KEY (department_id) REFERENCES department(id) );记住一句话外键永远加在多的一方。你要是把employee_id放在department表里一个部门多个员工那部门表里就得用逗号拼接员工id瞬间又回到违反1NF的坑里。3.2 一对一关系主键共享或外键加唯一约束一对一相对少但典型场景很清晰。最常见的就是用户基础信息扩展表把不常用的大字段单独拆出去或者把敏感信息隔离放。具体实现有两种方式。第一种是主键共享两张表的主键完全一致子表不设自增主键而是直接引用主表主键作为自身主键CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password_hash VARCHAR(255) NOT NULL ); CREATE TABLE user_profile ( id INT PRIMARY KEY, nickname VARCHAR(50), avatar_url VARCHAR(255), bio TEXT, CONSTRAINT fk_profile_user FOREIGN KEY (id) REFERENCES user(id) );第二种是外键加唯一约束。子表有自己的自增主键同时把外键字段加上UNIQUE索引CREATE TABLE user_profile ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL UNIQUE, nickname VARCHAR(50), avatar_url VARCHAR(255), CONSTRAINT fk_profile_user FOREIGN KEY (user_id) REFERENCES user(id) );两种都可以唯一约束本质上是保证一个用户最多只能有一条profile记录。这两种写法选哪种如果业务上要求profile必须存在我倾向于用主键共享因为主键本身就有唯一性和非空的约束少建一个索引查询还能直接走主键。3.3 多对多关系必须通过中间表拆成两个一对多多对多关系在MySQL里不能直接表对表实现必须引入一张中间表。学生和课程的关系商品和标签的关系都是典型多对多。中间表的核心职责就是记录两边的对应关系。它通常包含两个外键分别指向两张表然后以这两个字段作为联合主键CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL ); CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL ); CREATE TABLE selection ( student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2), selected_at DATETIME, PRIMARY KEY (student_id, course_id), CONSTRAINT fk_sel_student FOREIGN KEY (student_id) REFERENCES student(id), CONSTRAINT fk_sel_course FOREIGN KEY (course_id) REFERENCES course(id) );中间表不是只能放两个外键。像成绩、选课时间这种关系自身携带的属性一定要放在中间表里。很多人设计多对多时只在中间表放两个外键等后面要记录成绩时只能改表结构这就是早期没想清楚关系属性该放哪。另外给一个经验中间表的联合主键我通常会用(student_id, course_id)业务查询里最常见的入口是某个学生选了哪些课让student_id走联合索引最左前缀。如果你的业务更频繁按课程查学生就把两个字段的顺序调整一下。联合索引的字段顺序要按查询频率来排这是建索引时的细节后面会再提。4. 外键的约束规则与生产环境的使用权衡外键是数据库设计里一个容易被误解的概念。很多教程把它当成多表关联的字段其实它有两个层面的意义一是逻辑层面的关联字段二是MySQL层面主动声明出来的约束关系。这两者可以一致也可以分离。4.1 外键约束的四种行为规则当你在MySQL里用CONSTRAINT FOREIGN KEY声明外键时你同时定义了当父表记录被更新或删除时子表数据如何处理。这是外键真正的价值所在。约束行为由ON UPDATE和ON DELETE两个子句控制可选值有RESTRICT、CASCADE、SET NULL、NO ACTION。行为父表删除/更新时子表表现适用场景RESTRICT/NO ACTION直接拒绝执行报错默认行为最严谨防误删CASCADE子表数据级联删除/更新订单与订单明细删除主单时明细一起删SET NULL子表外键字段置为NULL员工离职后部门记录保留员工部门置空我用一个具体例子说明。订单主表orders和被拆出来的order_items是典型的级联删除场景删除一条订单时它的所有明细都应该随之删除否则就会出现孤儿明细。这种情况下CREATE TABLE order_items ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE ON UPDATE CASCADE );而部门与员工的关系则通常用SET NULL。部门被删除后员工记录还需要保留但要把他归到无部门状态CREATE TABLE employee ( id INT PRIMARY KEY AUTO_INCREMENT, department_id INT NULL, name VARCHAR(50) NOT NULL, CONSTRAINT fk_emp_dept FOREIGN KEY (department_id) REFERENCES department(id) ON DELETE SET NULL ON UPDATE CASCADE );这里有个容易踩的坑如果你要用SET NULL子表的外键字段必须允许为NULL建表时不能加NOT NULL。我在项目里见过有人把外键字段设计成NOT NULL然后删父表记录时MySQL一直报错排查半天才发现是约束冲突。4.2 业务到底要不要用外键约束换个角度算账这是一个在开发者社区里争论过很多轮的话题。观点两极分化一方坚持数据库就该把所有关系管好外键必须加另一方说互联网公司生产环境极少用外键全靠应用层控制。我的态度比较折中核心交易链路尽量不用外键约束用应用层逻辑保证数据一致性要求极高、并发写入不高的后台管理系统可以用外键。为什么因为外键约束有三个成本每次插入子表数据时MySQL都要去父表校验对应主键是否存在这增加了一次索引查找的开销。在分库分表场景下外键无法跨库生效。拆库之后外键约束自然失效你还不如从一开始就别依赖它。线上做表结构调整时外键会让操作变得极其复杂。删除一张被引用的父表之前得先处理外键约束在变更窗口紧张的时候非常痛苦。实际替代方案也很成熟不建物理外键但在应用层完成校验和级联操作。比如删除订单时业务代码里先执行DELETE FROM order_items WHERE order_id ?再执行DELETE FROM orders WHERE id ?并把两个操作包在事务里。这样逻辑等价但把约束行为的控制权完全交给了开发人员。4.3 外键的索引问题和字符集一致性问题两个经常被忽略但很实用的小知识点。第一MySQL不会自动为子表的外键字段创建索引某些版本在特定条件下会但不要依赖。外键字段如果没建索引两张表join时MySQL就只能全表扫描子表性能会非常差。所以即使你决定不用物理外键约束这个外键字段也一定要手动加上索引。第二外键关联的两个字段类型必须完全一致。int和bigint不能关联varchar(50)和varchar(100)也不建议关联。最容易出问题的是字符集父表字段是utf8mb4子表字段是utf8建外键时MySQL会直接报错。所以在设计表结构的时候全库统一字符集配置很重要。5. 从ER图到CREATE TABLE建模思维怎么落地成真实表结构ER图实体关系图是这个链条里最偏设计的一环。它解决的是你在写CREATE TABLE之前怎么把业务需求翻译成结构和关系。很多人跳过画ER图直接建表等表建完发现关系不对再回头改成本高得多。5.1 ER图的组成元素与图形符号ER图本质上就四种元素实体、属性、关系、连线。实体Entity代表一类事物就是将来的一张表。在ER图里通常用矩形表示。属性Attribute实体携带的信息就是你表中的字段。通常用椭圆表示主键属性会加下划线。关系Relationship实体之间怎么关联。一对多还是一对多还是多对多。用菱形表示或者直接在线跟上标基数。连线Line连接实体和关系标注匹配数量。画法上常见两种流派。一种是Chen标记法实体矩形、属性椭圆、关系菱形图形元素多适合论文和教材。一种是鸦足标记法Crows Foot更像工程图纸用圆圈、叉号、分叉脚来表示0或多是目前工具里最常用的。MySQL Workbench、draw.io、PowerDesigner默认图形基本都是鸦足标记。符号含义———一条竖线表示恰好一个—-O圆圈表示零个—ᴗ—鸦足分叉表示多个—-Oᴗ—圆圈加分叉表示零个或多个5.2 怎么推导一张ER图画ER图最忌讳一上来就画表。正确的方式是从业务描述里先划出名词和动词。拿一个经典的学生选课业务举例。你从需求描述学生可以选修多门课程每门课程可以被多个学生选修选课后产生成绩里能提取出实体学生、课程实体属性学生的姓名、学号课程的课程名、学分关系选课多对多关系属性成绩然后据此画出学生(学号, 姓名)课程(课程号, 课程名, 学分)选课(学号, 课程号, 成绩)。三张表的雏形就出来了。这个推导过程用文字写可能有点抽象但实际在纸上或工具里画一遍整个关注点会完全不一样你会先想清楚学生和课程是什么关系而不是上来就纠结id字段用什么类型。5.3 从ER图映射到MySQL建表语句的转换规则ER图画完之后转成建表语句有一套固定的映射规则每个实体映射成一张表。实体的属性映射成表的字段。实体的主键映射成表的主键ER图里带下划线的属性就是主键。1对1关系外键放任意一侧或者共享主键。1对N关系外键放在N侧的表中。M对N关系生成一张中间表外键指向双方。在实际工具里MySQL Workbench可以直接把EER模型同步成数据库或生成SQL脚本。操作路径是File - New Model建好之后用Database - Forward Engineer就能把ER模型转成CREATE TABLE语句。反向操作也支持已经有了数据库可以通过Database - Reverse Engineer把现有库导出成ER图。5.4 用Workbench和Navicat画ER图的一些细节如果你用的是MySQL Workbench有几点值得注意。画实体关系模型时Workbench默认会给每个实体加一个名为id的自增主键字段很多新手没注意导致生成的建表语句里出现两个id字段。新建实体后先检查Columns区域把自动生成的id删掉或者确认是否需要保留。还有一列字段叫Physical Type下拉里能选主键、唯一索引、非空、外键等约束。设置外键时需要在关系连线那一侧确保两边的数据类型完全一致。Workbench在Forward Engineer前不会做完整校验不一致往往要等SQL扔到MySQL里才报错所以在模型阶段就要仔细核对。Navicat也支持建立物理外键在表设计器里切到外键页签选字段、选引用库表和引用字段然后设置ON DELETE和ON UPDATE行为。相比WorkbenchNavicat更直观但它更偏建表时顺手加外键没法和先完整建模再生成的正向流程比。画图我还有一个收藏的想法如果你想快速把现有库的SQL转成ER图用工具自动生成就行。反向工程把SQL文件导入Workbench几分钟就能出一张完整的关系图适合给文档、给同事讲表结构演变。6. 一段完整的实战收尾从需求到建表的操作清单总结我自己的习惯流程。拿到一个模块需求我会按下面这个顺序过一遍这套流程基本可以覆盖大多数后台业务的表结构设计。把需求里的核心名词提取出来确定有哪些实体。根据业务语义确定实体之间的关系是哪一种1对1、1对N还是M对N。为每个实体确定主键字段优先选择业务上不会变的自然主键没有合适自然主键就用自增id。根据关系的类型决定外键放在哪张表、要不要建中间表。用ER图把上述设计画出来给团队里其他人看一眼确认没有遗漏。按映射规则生成建表语句补全索引、约束、字符集。最后再对着查询场景看一遍哪些查询会频繁join需要为哪些字段建索引哪些冗余字段值得加这套流程走下来绝大多数表结构问题在设计阶段就暴露了而不是等上线之后用数据修复来填坑。我在实际项目里踩过最深的一个坑是最初设计订单表时只按第三范式把所有信息拆干净结果后端写查询时一个订单详情接口要join六张表。后来做了两处冗余一处在订单表里冗余了用户手机号快照一处在订单明细表里冗余了商品名称快照查询从六张join降到了两张。每次做冗余我都要在注释里写明这里是有意冗余同步时机是xxx避免后面的人误以为漏了范式。说到底三大范式、表关系、外键、ER图这些不是互相割裂的知识点它们是一条完整的设计链路先用ER图想清楚业务的结构再用范式规则去掉重复和隐患用表关系表达实体之间的关联最后用外键和索引保证数据的一致性和查询效率。把这套链路想明白了不管你用MySQL还是其他关系型数据库设计思路都是一样的。
返回列表