尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

候选码、主码、外码:关系数据库设计基石与实战解析

候选码、主码、外码:关系数据库设计基石与实战解析 做数据库这行久了我有个特别明显的感受很多人写SQL一时半会没问题但一聊到候选码、主码、外码这几个基础概念就开始犯迷糊。主码和主键是不是一回事候选码又是干嘛的外码是不是就是外键更别提去理解为什么删除父表记录会报错为什么外码列能为NULL而主码列不能。这几个概念表面看是教科书里的定义实际上是整个关系数据库设计的地基。地基没打牢后面建表、设计关联关系、排查数据问题每一步都会踩坑。这篇文章我打算完全抛开教材式的定义轰炸站在真实表设计的角度把候选码、主码、外码彻底讲透。你会知道它们分别解决什么问题、在什么场景下怎么选、实际建表时有哪些坑以及我这些年做项目积累下来的命名和排障经验。无论你是准备数据库面试的在校生还是正在设计表结构但对外码关系拿捏不准的后端开发这篇都值得看完。1. 码到底在解决什么问题1.1 没有码的表就是一堆失控的乱账我先抛出一个场景。假设你要维护一张员工表字段有姓名、部门、入职日期、工资。看起来很正常但仔细一想就会发现问题如果公司有两个同名同姓的张伟你要修改其中一个人的工资怎么精确修改如果姓名完全一样条件一写两个人的数据可能同时被改。再往后想一步。你要在其他地方引用某个员工的数据比如考勤记录表里存了张伟这个名字那到底引用的是哪个张伟数据一多彻底分不清谁是谁。这个时候整张表就是一堆失控的乱账所有操作都建立在不靠谱的基础上。数据库里的码就是解决这个问题的制度设计。它负责从表的属性中挑出能够稳定区分每一行记录的属性组合相当于给每行记录发了一个身份证号。这个身份证号可能是单列也可能是多列组合但无论如何它承担着整张表的身份识别职责。生活中的类比很好理解。我们每个人有姓名但姓名无法唯一区分人所以有身份证号。每辆车有品牌、颜色、型号但凭这些没法判断具体是哪一辆车所以有车牌号。数据库里的码本质上就是这套身份标识制度的数字化版本。1.2 三个码的分工其实一句话就能说清很多人把候选码、主码、外码放在一起总觉得它们是一个东西的三种叫法。其实它们解决的问题完全不同。我用一张表格先把关系铺开。码的类型一句话定位和业务的关系超码能唯一确定一行记录的属性组合可以有冗余概念基础不直接使用候选码超码中去掉冗余后的最小版本是备选身份证通常是多个可选方案主码从候选码中最终选定的那一个真正承担身份标识的字段外码本表用来引用其他表主码的属性表与表之间建立关系的连线从集合角度理解候选码是超码的子集主码是候选码之一。外码和其他三个码不在同一层次它描述的是表间引用关系中的列。我举个实际的注册场景。你注册一个系统能唯一标识你身份的信息可能有三种用户名、邮箱、手机号。这三个都能唯一区分不同的人所以它们都是候选码。后来系统拍板统一用手机号作为登录凭证和关联数据的依据手机号就成了主码。另一张订单表里记录了每笔订单对应的用户手机号用来表示这单是谁买的那订单表里的这个手机号就是外码。这个例子已经基本覆盖三个概念的分工了。下面我逐个深入展开。2. 候选码从属性堆里挑出够格的备选2.1 候选码的两个硬性条件候选码的定义我教新手时总会让他们记住两条第一能够唯一识别一行记录第二去掉任何一个属性后就不再具备唯一识别能力。第二条就是所谓的最小性这是候选码和超码之间最本质的区别。来看一张选课记录表字段包括学号、姓名、课程号、成绩。如果只是要求行不重复超码可以有很多组合。比如学号课程号可以更大的学号姓名课程号也可以学号姓名课程号成绩也可以。这些组合只要保证任意两行不会完全相同都算超码。但候选码只能是那个最小的组合也就是学号课程号。为什么这么说因为去掉学号光靠课程号没法区分同一个课程下不同的学生去掉课程号一个学号对应多门课程也没法唯一锁定。只有学号和课程号这两个属性共同组合才恰好撑起唯一性满足最小性。这个最小性在考试题里经常出现比如判断下列组合中哪些是候选码。但工程里它更重要。因为如果你把一个包含大量冗余属性的组合当成唯一标识后面所有引用这张表的字段都会跟着膨胀查询条件、索引、外码全部变得笨重改一处动全身。2.2 手算候选码函数依赖与闭包的方法教材里求候选码有一套标准方法核心是函数依赖推导。很多同学把这部分当成数学题背考完就忘。我倒觉得它其实是一个把表的数据逻辑摸透的过程。通过分析属性之间的函数依赖你能知道哪些属性可以推出其他属性哪些属性是真正的地基。函数依赖用箭头表示比如A→B的意思就是给定A的值一定能确定唯一的B值。求候选码的完整步骤我拆成三步来讲。第一步把所有的函数依赖列清楚。假设有关系R(A, B, C, D)函数依赖为A→BB→CD→B。这里箭头左边决定右边意味着只要知道左边属性的值右边的属性值就被唯一锁定。第二步计算每个属性或属性组合的闭包。闭包的意思是从这个属性出发顺着所有的函数依赖最终能推出哪些属性。比如A的闭包是{A, B, C}。为什么因为A推出BB又推出C所以A、B、C全被覆盖。D的闭包是{D, B, C}因为D推出BB推出C。这里注意A和D之间谁也不能推出谁因此单独一个A或单独一个D都无法覆盖全部属性。第三步找到闭包能覆盖全部属性的组合然后检查最小性。如果某个组合的闭包覆盖了R全部属性A, B, C, D说明这个组合能推出整行数据至少是超码。再试一下去掉任一属性后闭包是否仍然覆盖全部。如果不再覆盖那这个组合就是候选码。举一道经典题关系R(A, B, C, D, E)函数依赖为A→BAB→CD→E。求候选码。先算A的闭包。A能推出B得到{A, B}结合AB能推出C所以A的闭包变为{A, B, C}。再看D的闭包D能推出E得到{D, E}。接下来试组合AD从A出发能推到B和C从D出发能推到E所以AD的闭包就是{A, B, C, D, E}刚好覆盖全部属性。检查最小性去掉A只剩DD的闭包是{D, E}覆盖不全去掉D只剩AA的闭包是{A, B, C}覆盖也不全。因此AD就是关系R唯一的候选码。这个过程看起来很理论但它真的能提升你对表结构的直觉。设计表的时候如果你先把哪些字段决定哪些字段列出来基本上就能判断出这个唯一标识稳不稳定。2.3 业务中选候选码的实操经验理论归理论实际业务里候选码往往有多个取舍才是关键。我的经验主要归纳为三条。第一条优先选几乎不会变的属性。用户的昵称会改手机号可能换但身份证号几乎终身不变。某个属性如果经常变它就不适合作为稳定标识今天用它关联的数据明天换了个值前面的记录就全对不上了。第二条别把长组合当候选码。比如用姓名加生日加城市来标识用户短时间看似乎够区分数据量一上去就会频繁撞车。而且组合越长所有引用这张表的表都需要多开几个字段查询和索引的复杂度直线上升。第三条当多个候选码都能满足条件时选那个业务上最自然、最常用的。用户表里邮箱和手机号都能唯一标识用户但如果你的系统主要靠手机号登录那手机号作为候选码的优先级就更高因为业务链路里到处会碰到它自然一点会更省事。这里我要特意提醒一句候选码是最小但不代表它是最优。候选码是在概念层面的最优集合真正落地时还需要从里面筛一个出来筛选的结果就是主码。3. 主码唯一性规则的最终裁决3.1 主码的约束与代价主码是从候选码里选出来的那一个。它一旦确定数据库层面会同时附加两个硬性约束非空和唯一。也就是说主码列不允许出现NULL也不允许出现重复值。这样表的每一行才真正有了可识别的身份。主码在物理层面还会触发索引的创建。拿MySQL的InnoDB引擎来说主键索引就是聚簇索引数据行会按照主码的顺序进行存储排列。PostgreSQL里主键约束也会自动创建一个唯一索引。这意味着主码不只是考试里的概念它直接决定了存储布局、查询效率、锁竞争甚至写入性能。主码的代价主要体现在变更上。列类型定错了要改就得重建表业务规则变了想换主码所有关联它的外码约束都要跟着动。这也是我反复强调主码设计要一次到位的原因。频繁更换主码几乎等于把整张表的关系网络推倒重来。3.2 自然键与代理键到底选哪个这是我在设计表时被问到最多的一部分主码到底用业务字段还是加一个跟业务无关的ID字段。自然键直接拿业务属性做主码比如用户表用身份证号车辆表用车牌号。优点是语义明确天然唯一不需要额外造字段。缺点是业务属性可能变化语义也可能复用比如一个车牌号被注销后分配给另一辆车如果之前有历史数据引用它就出大问题了。而且身份证号、车牌号这类长字符串在索引和关联时性能也不如整数。代理键则是专门加一个字段用于标识常见的有自增整数、UUID、雪花ID。它和业务彻底解耦不随业务属性变化稳定又简洁。缺点就是它本身没有业务含义看起来就是一串数字或字符串需要关联别的字段才知道这条记录是谁。我把两者做一个直接对比。维度自然键代理键语义清晰度高一眼能看出业务低只是一串标识稳定性取决于业务属性本身极高不随业务变化索引效率取决于类型的长度和格式整数型最优跨表引用成本字段越长外码占空间越大外码简洁统一典型使用场景车牌、身份证这类一对一业务标识用户ID、订单ID等内部关联我个人的施工标准是主码默认用代理键尤其当业务字段本身存在修改可能时。自然键我也不会浪费会设置成唯一索引让它起到候选码的作用。最典型的案例就是订单表订单号对外公开、需要被频繁引用可以设置唯一约束但主码还是用自增ID或雪花ID。这样内部关联稳定订单号以后调整格式也不影响外键关系。3.3 复合主码能不用就别用复合主码指主码由多个列共同组成比如选课表用学号课程号作为主码。教材上拿它举例很正常但从工程角度我越来越倾向于能不用就不用。原因有三个。第一复合主码一旦作为外码被引用另一张表需要同时带上一组字段才能引用它外码数量成倍增加。第二复合主码生成的索引通常更占空间查询时如果只带部分字段过滤索引效率往往不如单列主键。第三业务上很难保证组合永远稳定。比如选课表以后要支持同一学生同一门课程的多次重修记录那学号加课程号就不再唯一了。所以我现在的习惯是关联表也单独加一个自增ID当主码然后把原来的组合字段设置为唯一约束。这样既能保证业务上不重复又能保留单列主码的简洁和高效。这是我在实际项目里用了很多次的做法也是踩过复合主码几次坑之后总结出来的。4. 外码让数据表产生关系的关键4.1 外码的本质是引用关系外码和前三个码最大的不同是它不负责本表唯一性而是用来指向另一张表的主码。你可以把它理解为别人家的身份证在这张表里的登记信息。举一个最直接的例子。订单表里有user_id它指向用户表的id。这个user_id就是订单表的外码。如果数据库层面声明了外码约束系统强制要求user_id的值必须存在于用户表的id中否则插入或更新就会被拒绝。这个约束机制在理论术语里叫参照完整性。它是关系数据库维护数据可信度的底层防线。如果没有外码约束应用层写错了user_id系统也能插入一条不存在的用户下的订单。等后面要关联查询时就会出现一堆张冠李戴的脏数据。有了外码约束数据库在写入环节就把这扇门关上了。外码还有一个容易被忽略的特点列允许为NULL。因为有些关系是可选的。比如订单表里的优惠券ID用户下单时用了优惠券但用完就删了这时可以把订单里的优惠券ID置为NULL而订单本身还保留着。这是完全合理的业务状态。4.2 外码的级联操作到底怎么选声明外码时可以同时定义父表数据变动后的行为。最常见的几个选项是RESTRICT、CASCADE和SET NULL。RESTRICT是最安全的默认选项。当父表记录被删除时如果子表还有引用记录删除操作直接被拒绝并抛错。这能防止订单还在用户却被删了之类的逻辑灾难。CASCADE的含义是父表记录删除时所有引用它的子表记录也会一并删除。典型场景是订单明细订单删除了订单里的明细行自然失去意义。但CASCADE杀伤力很大一不小心就引发连锁删除。所以我会严格控制它的使用范围只在父子数据生命周期完全一致时才启用。SET NULL则是删除父表记录后把子表的外码置为NULL但子表的记录本身保留下来。适合优惠券没了但订单还要留着的场景。SET NULL有个前提条件就是外码列本身要允许NULL这一点经常有人踩坑。这里还想补充一句数据库之间的细微差别MySQL里的RESTRICT和NO ACTION行为基本等同删除时先检查子表引用有引用就失败PostgreSQL里NO ACTION是事务结束前统一检查RESTRICT是立即检查。大多数场景下差别不大但跨库迁移时容易遇到出乎意料的差异文档里最好提前写清楚。4.3 一个完整例子跑通外码设计我用一个最经典的用户-订单-订单明细模型把外码设计完整演示一遍。CREATE TABLE users ( id BIGINT PRIMARY KEY, nickname VARCHAR(50) NOT NULL ); CREATE TABLE orders ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, coupon_id BIGINT NULL, CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE TABLE order_items ( id BIGINT PRIMARY KEY, order_id BIGINT NOT NULL, product_name VARCHAR(100) NOT NULL, quantity INT NOT NULL, CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE );这里我做了几个关键决定。users表用id做主码BIGINT类型这是代理键的典型写法。orders表的user_id引用users.id约束名是fk_orders_user采用默认的RESTRICT行为。这样只要用户还存在有效订单用户就不能被直接删除避免了业务上人没了但订单还在的状态。order_items表单独有id主码同时用order_id引用orders.id并设置ON DELETE CASCADE。这样订单删除后明细行自动清理完全符合业务预期。实际项目里我还会在外码列上手动加索引。很多新手容易忽略这一点数据库不会自动为外码列创建索引。尤其是MySQL如果不手动建索引后续关联查询走外码进行JOIN和过滤时性能会明显下降。这个细节等数据量起来之后就非常致命了。5. 常见问题与排查技巧实录5.1 高频问题速查表我这几年整理了一些日常工作中反复出现的问题直接做了一张速查表。问题现象根本原因处理思路主码列不能插入NULL主码约束强制非空建表时必须写NOT NULL外码列可以为NULL吗可以外码只约束已填入的引用值按业务需要决定是否允许NULL删除父表记录报外键错误子表存在引用记录RESTRICT生效先删子表引用或改级联/置空更新主码值时子表不联动默认外码不会跟随主码更新声明ON UPDATE CASCADE或者避免修改主码外码列多个NULL算重复吗不算多数数据库允许同一外码列有多个NULL需要唯一时单独加唯一约束这些问题的共同根源说到底就是对主码管本表唯一性外码管表间引用合法性掌握得不够透彻。一旦抓住这个本质排查的时候就不容易跑偏。5.2 一次真实排障删不掉的用户背后的数据链说一个我实际踩过的坑。之前在一套电商系统里测试环境有个异常用户要删除结果执行删除时报外键约束错误。我当时第一反应就是肯定有订单先把订单删掉。结果删完订单还是报错。继续往下查才发现这个用户不仅下过订单还发过商品评价评价表也引用了用户ID评价又关联了商品商品关联了库存记录库存记录又被出入库日志引用。一条删除操作前后牵动了五张表数据像是被连环锁住了。最终靠写递归查询把所有引用这个用户ID的表找出来一层一层清理才算删干净。这次教训让我养成了一个习惯任何涉及删除核心业务表的操作上线前必须梳理清楚这张表的所有外码引用关系最好单独写一份删除影响分析文档列出会波及到哪些表和哪些字段。数据库能帮我们挡住错误但它不会替你判断业务上哪些删除是允许的这个判断必须设计师自己来做。5.3 码的命名与维护规范命名这件事看起来小实际影响非常大。我给自己定的规范有三条几乎每个项目都直接套用。第一主码统一叫id类型用BIGINT。除非有特殊的分布式ID需求否则全库保持统一。第二外码字段统一用引用表名_id的格式比如user_id、order_id。这样一眼就能看出它引用的是哪张表。第三约束名统一以fk_开头后面跟随表名加字段名比如fk_orders_user。这样出问题时单看报错里的约束名就能快速定位是哪张表的哪个外码出了问题。主码类型的统一同样重要。用户表用BIGINT订单表用INT另一张表用VARCHAR(32)互相引用的时候类型不一致会导致外码声明失败。这种问题虽然报错信息明确但如果表已经上线改起来就要涉及索引重建和约束调整代价相当大。另外提醒一句如果使用UUID字符串当主码一定要提前定准长度和字符集。MySQL里utf8mb4字符集的VARCHAR(32)和VARCHAR(36)在存储和索引层面都有实际差别。表结构一旦多了这些细节不一致就会成为隐患。我在实际项目里还常用一个检查手段定期查询信息模式中的外码元数据把全库外码关系导出来核对一遍看看有没有引用失效、命名不规范、类型不匹配的情况。这种巡检意识比等出了问题再头疼要高效得多。结尾做数据库的时间越长我越觉得这些码不是考试卷上背完就扔的定义。候选码考验的是你对数据本质的理解主码考验的是你对工程稳定性的判断外码考验的是你对表间关系的全局视野。把这三个概念真正想明白建表、写查询、做数据治理都会顺畅很多。如果你刚开始接触这部分内容我的建议是别急着背定义先拿手头的项目表做一次码的体检把每张表的主码、外码、候选码逐张标出来看清它们之间的引用关系模拟一下删除一条核心数据会走哪些链路。这个过程下来比翻十遍教材都管用。把码这层基础打牢后面那些复杂的查询优化、数据建模才谈得上真正理解和灵活运用。
返回列表