“第二章,关系数据库”——教材里这往往是一道分水岭,前面刚介绍完数据库系统长什么样,这一章突然就开始讲关系、元组、属性、关系代数这些偏数学的词。很多初学者就是在这里被劝退的,觉得学数据库就是背概念,离真实业务很远。但我做了这么多年开发,回头再看,真正影响系统稳定性的问题,最后都能追到这一章:主键选得对不对、外键约束建没建、表拆得合不合理、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行。再加上连接条件,比如学生编号相等,就把无关配对去掉,只留下真正有关系的行。
我们日常写的关联查询,完全可以翻译成关系代数表达式。看这段SQL:
SELECT 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纸、一块白板都可以,至少标清楚:有哪些实体,每个实体的主键是什么,实体之间是什么关系,是一对多、多对一还是多对多。多对多的关系在数据库里永远要拆成中间表,这个能不能马上识别出来,直接影响以后查询和统计的复杂度。
这些动作看起来“不写代码不算进度”,但恰恰是最省进度的。关系数据库设计得好的项目,后续维护成本会低到你几乎感觉不到它的存在;设计得不好的项目,每一个新需求都在和烂数据搏斗。把“第二章”的价值想明白,项目的路才能走得稳。