尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

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

关系数据库实战:主键、外键、关系代数与范式设计

关系数据库实战:主键、外键、关系代数与范式设计 “第二章关系数据库”——教材里这往往是一道分水岭前面刚介绍完数据库系统长什么样这一章突然就开始讲关系、元组、属性、关系代数这些偏数学的词。很多初学者就是在这里被劝退的觉得学数据库就是背概念离真实业务很远。但我做了这么多年开发回头再看真正影响系统稳定性的问题最后都能追到这一章主键选得对不对、外键约束建没建、表拆得合不合理、SQL为什么写出来这么慢。把这章吃透后面写SQL、做表结构设计、排查线上数据问题全部会变成有章法的事。这篇我打算不从“背定义”的角度展开而是结合实际开发中会遇到的问题把关系模型的来龙去脉、完整性约束的用意、关系代数跟SQL的关系、范式的拆分逻辑以及教材里很少写但实战经常翻车的地方一次性说透。1. 关系模型到底解决什么从“铺开一张表”到“定义数据真相”1.1 为什么“一张Excel表”解决不了系统问题很多人刚接触数据库时都会想数据存哪不是存我把所有字段放一张大表里不是更省事吗这种思路在Excel里确实没问题表格够大、列够多人工查阅方便。可一旦进入业务系统问题立刻暴露出来。举个例子。你维护一个客户订单表里面同时放客户姓名、联系方式、收货地址、购买商品、商品价格、订单状态。刚开始录入方便查询也方便但过了几个月客户修改了手机号你就得去找这个客户关联的所有订单挨个把旧手机号改掉。订单多的时候改漏一条数据就开始对不上了。再往后如果同一件商品的价格变了历史订单里那批记录要不要一起变如果一起变订单金额就失真如果不变你又怎么区分“下单时的价格”和“当前标价”这类问题本质上是数据被重复存储而系统无法识别哪些是同一事实的副本。关系模型核心要解决的就是这件事。它不允许你把所有事实塞进一张自包含的表里而是要求你把数据按“实体”和“实体之间的关系”拆开存放再通过“键”把它们关联起来。客户是客户订单是订单商品是商品各管各的。客户改手机号只改客户表那一行所有订单通过客户编号拿到新的手机号。这就是从“铺开一张表”到“定义数据真相”的转变。1.2 关系、元组、属性、域把日常数据“翻译”成严谨结构关系模型里的术语看似高冷其实对应的都是我们熟悉的东西。关系模型术语日常说法说明关系一张表强调表是数学意义上的集合不是一堆文件拼在一起的清单元组一行数据一个元组对应一个具体实体例如一个客户、一张订单属性一列属性要有名字也要有取值范围域取值范围例如年龄是0到150的整数状态是固定的几个枚举值为什么要把“表”叫作“关系”因为数学里的集合有去重、有约束、有运算规则这比“文件柜”的定义更严谨。一个关系必须有属性名同一列里的数据类型一致行与行之间可以任意交换顺序而不影响结果。这不只是定义严谨的强迫症而是后面关系代数、完整性约束能够成立的前提。我见过的实际问题里不少人建表时完全没有“域”的概念。比如状态字段一会儿存0和1一会儿存“Y”和“N”一会儿又变成中文。到了统计报表时代码里到处兼容恨不得写三层if。这就是把关系模型最基本的要求丢了——每一列都应该有明确的、稳定的取值范围。域定义清楚数据质量就从源头控制住了。2. 主键、外键和三类完整性约束撑住数据库的所有底线完整性约束是关系数据库的“安全护栏”。没有这些约束表结构再好看数据照样能在你不知道的时候烂掉。完整性约束分成三大类实体完整性、引用完整性和用户定义完整性。我一个一个说。2.1 实体完整性主键不是开发人员拍脑袋加的一个id实体完整性规定每张表必须有主键主键的值不能为空也不能重复。为什么这条这么重要因为如果没有一个明确标识行的键你就无法判断两条一模一样的记录到底是不是同一个东西。举个最直白的例子。用户在同一时间下了两笔内容完全相同的订单两行数据一模一样。如果没有订单编号你根本分不清哪一条对应哪一笔交易就像两张连号的钞票如果编号被抹掉你也没法证明它们各自属于哪个环节。主键就是这串编号。很多开发习惯是给每张表加一个自增id这没问题但要注意自增id只能保证唯一不能保证语义。真实业务里一个订单号、一个客户编号往往既是唯一标识又是业务沟通的语言。如果你只用自增id做唯一标识那起码还要给业务编号建立唯一索引防止重复。具体怎么选后面第5节我会专门讲。2.2 引用完整性为什么外键约束被删改时这么烦引用完整性通过外键实现。外键是一张表中指向另一张表主键的字段它要求外键的值要么为空要么必须在被引用表的主键里真实存在。这套规则保证了“你引用的一定是真的”不会出现订单挂在某个已删除客户名下的情况。实际开发里最痛苦的是删除和更新时的连锁反应。你删除客户A他有一堆历史订单怎么办关系数据库通常有几种策略不允许删、级联删除、设置空值。我用一个订单和客户的例子来说明操作策略结果删除客户RESTRICT如果该客户有订单删除操作直接被拒绝删除客户CASCADE该客户的所有订单一并删除删除客户SET NULL订单保留但订单上的客户编号被置空表示“该客户已不可追踪”很多团队为了图省事建表时不对外键删除全靠业务代码控制。这种设计的隐患在于数据库并不清楚引用关系只要有任何一个接口忘了做校验就会出现孤儿数据。你查历史订单时订单还在客户信息却再也匹配不上。我的做法是核心业务表一定要加外键约束但删除策略要谨慎。订单这种历史数据基本只允许SET NULL或RESTRICT临时业务表、流水日志表才考虑不加外键以提升写入性能。别一听到外键就拒绝那是把底线的决定权交给了概率。2.3 用户定义完整性那些个性化的业务规则关系数据库不只管“唯一”和“引用”还允许你定义自己的规则。比如库存不能为负、金额必须大于零、订单状态只能在几个固定枚举值里、手机号要符合11位数字等。这类约束叫用户定义完整性对应的SQL是CHECK约束。有一类观点认为这些校验放代码里做也一样。但代码校验有个明显短板只能覆盖“当前这条代码路径”。同一个数据库往往被多个系统读写后台管理、用户端、定时任务、数据修复脚本、手工SQL。你很难保证每条入口都写了同样的校验。数据库里的CHECK约束和唯一索引是在所有入口之上再加的一层保险属于“最后一道防线”。开发里常见的做法是建立唯一索引防重复建立CHECK约束防非法值这两件事能由数据库完成的就不要依赖程序员的记忆。3. 关系代数SQL背后那套集合运算才是真正值得学的底层逻辑3.1 并、差、交关系也是一种集合运算关系代数听起来很深但它本质上就是集合运算在数据库上的复刻。集合有并、差、交关系也有。并运算把两个结构相同的关系合并在一起自动去掉重复元组。对应SQL里的UNION。差运算从一个关系里去掉与另一个关系相同的元组。对应EXCEPT或MINUS。交运算找出两个关系里共有的元组。对应INTERSECT。这三个运算之所以重要是因为很多复杂查询本质上是在做集合比较。例如“找出今天登录过但昨天没登录过的用户”就可以用今天登录的用户集合减去昨天登录的用户集合。很多开发第一反应是写一堆嵌套子查询实际上用集合差更简单直观性能也更好。3.2 选择、投影横向过滤和纵向裁剪选择SELECTION是指从关系中筛选出满足条件的行对应SQL里的WHERE子句。投影PROJECTION是指从关系中挑出若干列对应SELECT后面能不能列出指定字段。两者一个是横向过滤一个是纵向裁剪是SQL查询最基本的两把剪刀。为什么很多人写SQL慢因为经常忽略“先过滤、后裁剪”的顺序。比如你要查一张一千万行的订单表里某几个客户的最近订单如果先做关联再做WHERE过滤中间产物会巨大无比。正确做法是先在订单表上按条件过滤把参与计算的数据集压到最小再去做关联。这个思路在数据量小的时候无所谓一旦数据量上来执行计划直接差一个数量级。3.3 连接和笛卡尔积join的根源很多初学者不理解JOIN为什么存在。看穿它只需要一句话JOIN的结果本质上来源于“笛卡尔积加上选择条件”。笛卡尔积就是让两张表的每一行都互相配对比如学生表有1000行课程表有50行两者直接相乘就是50000行。再加上连接条件比如学生编号相等就把无关配对去掉只留下真正有关系的行。我们日常写的关联查询完全可以翻译成关系代数表达式。看这段SQLSELECT s.student_id, s.student_name, c.course_name FROM student s JOIN course_selection sc ON s.student_id sc.student_id JOIN course c ON sc.course_id c.course_id WHERE c.course_name 数据库;它背后的逻辑是先把三个关系做笛卡尔积再用student_id相等、course_id相等两个条件做选择最后投影出三个列。数据库优化器不会真的用这个笨办法执行它会根据索引选择更好的路径。但理解这套关系代数之后你就知道“减少笛卡尔积的规模”“尽早过滤”“选择比投影先做”这些经验到底是从哪来的了。4. 规范化不是过度设计三范式拆表的动机与边界4.1 一张混乱的大表会带来哪三类异常假设你把所有选课信息塞进一张表学生编号、学生姓名、所在系、课程编号、课程名称、授课教师、教师办公室、成绩。看第一眼这个表信息齐全查什么都有。可一旦进入正式数据维护它就会产生三类异常。更新异常学生换了系。如果该学生选了10门课那这张表里有10行记录都存放了“所在系”你得在这10行里全部修改。万一只改了9行数据就自相矛盾了。插入异常一个新生刚报到还没有选任何课程他想把基本资料录入系统却发现主键是“学生编号课程编号”课程编号不能为空于是这个学生死活插不进去。删除异常某门课程只有一位学生选修你删掉这位学生的选课记录时授课教师和教师办公室信息也一起被删了哪怕你是想留下老师的信息。这三类异常是关系模型最想消灭的对象。它们共同点都是数据被不必要地重复存储或者某些信息错误地依赖了其他信息。4.2 从第一范式到第二范式消除局部依赖第一范式1NF要求每一列都必须是不可再分的原子值。比如不能在一列里存“语文,数学,英语”也不能用分隔符硬塞多个值。这个范式看起来最简单实际踩坑的人极多很多人图省事把标签、类型、多选值拼进一个字段里。第二范式2NF要求在1NF基础上消去非主属性对主键的“局部依赖”。还是选课表它的主键是“学生编号课程编号”联合主键但“学生姓名”“所在系”只依赖学生编号“课程名称”“授课教师”只依赖课程编号这两组属性都没有完整依赖整个主键这就是局部依赖。解决办法是拆表学生表存学生编号、姓名、系别课程表存课程编号、课程名称、授课教师选课表只存学生编号、课程编号、成绩。拆完以后学生的基本信息每名学生只存一行改系、改姓名都是一次UPDATE搞定不会再有更新异常。4.3 第三范式和BCNF消除传递依赖第三范式3NF要求消除非主属性对主键的传递依赖。传递依赖是什么意思看这个例子课程表里有课程编号、授课教师、教师办公室。教师办公室其实是通过“授课教师”才能确定的并不是直接由课程编号决定的这就形成了“课程编号→授课教师→教师办公室”的链条。一旦教师更换办公室信息就得跟着改万一多个老师共用课程还要改不止一处。BCNF是比3NF更严格的一版它不要求依赖的来源必须是候选键任何决定其他属性的属性自己也得是候选键。实际项目中BCNF常出现于多对多的组合关系或权限关系。比如一个教师可以讲授多门课程一门课程可以由多个教师共同负责同时你还想记录“哪位教师负责哪个教室的教学安排”BCNF的拆分就会更细致。不过这里要提醒一点规范化不是拆得越细越好。每拆一次写业务代码时就要多一次关联查询。当数据量巨大、读写比例失衡时适当保留一些冗余字段用反规范化换查询性能也是常见的工程取舍。关键是先保证3NF再根据实际瓶颈做局部让步。不要一上来就跳过范式设计等到线上数据自相矛盾了再来补救。5. 从教材到工程现场关系数据库设计里容易翻车的几个习惯5.1 主键设计不能用自然键硬扛教材里讲主键时会提到自然键和代理键但实战中的教训比定义丰富得多。自然键是业务本身存在的唯一标识比如身份证号、手机号、车牌号。听起来很自然直接用它们做主键似乎很合理但真实业务中手机号会过户身份证号存在特殊情况会重号车牌号会换车。一旦业务标识本身发生变化用它做主键会引发一连串的修改。我的建议是核心业务表尽量用与业务无关的代理键比如自增id、雪花算法生成的唯一编号。代理键不承载业务含义就是一个纯粹的定位符业务字段怎么变都不影响关联关系。至于自然键比如手机号、身份证号可以另建唯一索引来保证“不重复”但别让它成为主键。这个习惯能帮你避开很多“改了手机号导致历史订单无法关联”的灾难。5.2 约束写进数据库而不是依赖业务代码自觉我见过很多开发团队外键不建、唯一约束不建、CHECK约束不建所有校验都放在服务端代码里。每当写代码的人自觉性够高系统就稳定一旦人员变动、接口新增、数据处理脚本上线稍不留神脏数据就进来了。最典型的是重复数据问题。用户注册时代码里先查一次“手机号是否存在”不存在就插入。看起来没问题可在高并发下两个请求同时查到“不存在”后同时插入数据库层面没有唯一索引兜底就会产生两条一模一样的手机号记录。这种问题靠代码判断是防不住的唯一索引才能解决。所以我的原则很简单凡是数据库能保证的事就不要让代码去保证。唯一性用唯一索引引用关系用外键合法值用CHECK。业务逻辑该在代码里做还是在代码里做但数据库的底线必须清晰。5.3 先画关系模型再写代码是对“第二章”最好的尊重很多项目急性子产品刚提完需求就直接去建表字段跟着接口返回的报文走今天是这个字段明天补那个字段一张表越建越宽几百列的情况都有。后来想拆代价已经大得离谱。我自己的做法是动手编码之前先花哪怕半小时把核心实体画出来。一张A4纸、一块白板都可以至少标清楚有哪些实体每个实体的主键是什么实体之间是什么关系是一对多、多对一还是多对多。多对多的关系在数据库里永远要拆成中间表这个能不能马上识别出来直接影响以后查询和统计的复杂度。这些动作看起来“不写代码不算进度”但恰恰是最省进度的。关系数据库设计得好的项目后续维护成本会低到你几乎感觉不到它的存在设计得不好的项目每一个新需求都在和烂数据搏斗。把“第二章”的价值想明白项目的路才能走得稳。
返回列表