做数据库这行久了,我有个特别明显的感受:很多人写SQL一时半会没问题,但一聊到候选码、主码、外码这几个基础概念,就开始犯迷糊。主码和主键是不是一回事?候选码又是干嘛的?外码是不是就是外键?更别提去理解"为什么删除父表记录会报错""为什么外码列能为NULL而主码列不能"。这几个概念,表面看是教科书里的定义,实际上是整个关系数据库设计的地基。地基没打牢,后面建表、设计关联关系、排查数据问题,每一步都会踩坑。
这篇文章,我打算完全抛开教材式的定义轰炸,站在真实表设计的角度,把候选码、主码、外码彻底讲透。你会知道它们分别解决什么问题、在什么场景下怎么选、实际建表时有哪些坑,以及我这些年做项目积累下来的命名和排障经验。无论你是准备数据库面试的在校生,还是正在设计表结构但对外码关系拿捏不准的后端开发,这篇都值得看完。
1. 码到底在解决什么问题
1.1 没有码的表,就是一堆失控的乱账
我先抛出一个场景。假设你要维护一张员工表,字段有姓名、部门、入职日期、工资。看起来很正常,但仔细一想就会发现问题:如果公司有两个同名同姓的"张伟",你要修改其中一个人的工资,怎么精确修改?如果姓名完全一样,条件一写,两个人的数据可能同时被改。
再往后想一步。你要在其他地方引用某个员工的数据,比如考勤记录表里存了"张伟"这个名字,那到底引用的是哪个张伟?数据一多,彻底分不清谁是谁。这个时候,整张表就是一堆失控的乱账,所有操作都建立在不靠谱的基础上。
数据库里的"码"就是解决这个问题的制度设计。它负责从表的属性中挑出能够稳定区分每一行记录的属性组合,相当于给每行记录发了一个"身份证号"。这个身份证号可能是单列,也可能是多列组合,但无论如何,它承担着整张表的身份识别职责。
生活中的类比很好理解。我们每个人有姓名,但姓名无法唯一区分人,所以有身份证号。每辆车有品牌、颜色、型号,但凭这些没法判断具体是哪一辆车,所以有车牌号。数据库里的码,本质上就是这套"身份标识制度"的数字化版本。
1.2 三个码的分工,其实一句话就能说清
很多人把候选码、主码、外码放在一起,总觉得它们是一个东西的三种叫法。其实它们解决的问题完全不同。我用一张表格先把关系铺开。
| 码的类型 | 一句话定位 | 和业务的关系 |
|---|---|---|
| 超码 | 能唯一确定一行记录的属性组合,可以有冗余 | 概念基础,不直接使用 |
| 候选码 | 超码中去掉冗余后的最小版本,是"备选身份证" | 通常是多个可选方案 |
| 主码 | 从候选码中最终选定的那一个 | 真正承担身份标识的字段 |
| 外码 | 本表用来引用其他表主码的属性 | 表与表之间建立关系的连线 |
从集合角度理解,候选码是超码的子集,主码是候选码之一。外码和其他三个码不在同一层次,它描述的是表间引用关系中的列。
我举个实际的注册场景。你注册一个系统,能唯一标识你身份的信息可能有三种:用户名、邮箱、手机号。这三个都能唯一区分不同的人,所以它们都是候选码。后来系统拍板,统一用手机号作为登录凭证和关联数据的依据,手机号就成了主码。另一张订单表里,记录了每笔订单对应的用户手机号,用来表示"这单是谁买的",那订单表里的这个手机号,就是外码。
这个例子已经基本覆盖三个概念的分工了。下面我逐个深入展开。
2. 候选码:从属性堆里挑出"够格的备选"
2.1 候选码的两个硬性条件
候选码的定义,我教新手时总会让他们记住两条:第一,能够唯一识别一行记录;第二,去掉任何一个属性后,就不再具备唯一识别能力。第二条就是所谓的"最小性",这是候选码和超码之间最本质的区别。
来看一张选课记录表,字段包括学号、姓名、课程号、成绩。如果只是要求"行不重复",超码可以有很多组合。比如(学号,课程号)可以,更大的(学号,姓名,课程号)也可以,(学号,姓名,课程号,成绩)也可以。这些组合只要保证任意两行不会完全相同,都算超码。
但候选码只能是那个"最小"的组合,也就是(学号,课程号)。为什么这么说?因为去掉学号,光靠课程号没法区分同一个课程下不同的学生;去掉课程号,一个学号对应多门课程,也没法唯一锁定。只有学号和课程号这两个属性共同组合,才恰好撑起唯一性,满足最小性。
这个"最小性"在考试题里经常出现,比如"判断下列组合中哪些是候选码"。但工程里它更重要。因为如果你把一个包含大量冗余属性的组合当成唯一标识,后面所有引用这张表的字段都会跟着膨胀,查询条件、索引、外码全部变得笨重,改一处动全身。
2.2 手算候选码:函数依赖与闭包的方法
教材里求候选码有一套标准方法,核心是函数依赖推导。很多同学把这部分当成数学题背,考完就忘。我倒觉得它其实是一个把表的数据逻辑摸透的过程。通过分析属性之间的函数依赖,你能知道哪些属性可以推出其他属性,哪些属性是真正的地基。
函数依赖用箭头表示,比如A→B的意思就是,给定A的值,一定能确定唯一的B值。求候选码的完整步骤,我拆成三步来讲。
第一步,把所有的函数依赖列清楚。假设有关系R(A, B, C, D),函数依赖为:A→B,B→C,D→B。这里箭头左边决定右边,意味着只要知道左边属性的值,右边的属性值就被唯一锁定。
第二步,计算每个属性或属性组合的闭包。闭包的意思,是从这个属性出发,顺着所有的函数依赖,最终能推出哪些属性。比如A的闭包是{A, B, C}。为什么?因为A推出B,B又推出C,所以A、B、C全被覆盖。D的闭包是{D, B, C},因为D推出B,B推出C。这里注意,A和D之间谁也不能推出谁,因此单独一个A或单独一个D,都无法覆盖全部属性。
第三步,找到闭包能覆盖全部属性的组合,然后检查最小性。如果某个组合的闭包覆盖了R全部属性(A, B, C, D),说明这个组合能推出整行数据,至少是超码。再试一下去掉任一属性后,闭包是否仍然覆盖全部。如果不再覆盖,那这个组合就是候选码。
举一道经典题:关系R(A, B, C, D, E),函数依赖为A→B,AB→C,D→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只剩D,D的闭包是{D, E},覆盖不全;去掉D只剩A,A的闭包是{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),在存储和索引层面都有实际差别。表结构一旦多了,这些细节不一致就会成为隐患。
我在实际项目里还常用一个检查手段:定期查询信息模式中的外码元数据,把全库外码关系导出来核对一遍,看看有没有引用失效、命名不规范、类型不匹配的情况。这种"巡检"意识,比等出了问题再头疼要高效得多。
结尾
做数据库的时间越长,我越觉得这些"码"不是考试卷上背完就扔的定义。候选码考验的是你对数据本质的理解,主码考验的是你对工程稳定性的判断,外码考验的是你对表间关系的全局视野。把这三个概念真正想明白,建表、写查询、做数据治理都会顺畅很多。
如果你刚开始接触这部分内容,我的建议是别急着背定义,先拿手头的项目表做一次"码的体检":把每张表的主码、外码、候选码逐张标出来,看清它们之间的引用关系,模拟一下删除一条核心数据会走哪些链路。这个过程下来,比翻十遍教材都管用。把"码"这层基础打牢,后面那些复杂的查询优化、数据建模,才谈得上真正理解和灵活运用。