很多年前第一次面试,面试官问“候选码和主码有什么区别”,我张口就来:候选码是能唯一标识一条记录的最小属性集合,主码是从候选码里挑出来的一个。他接着问“那员工表里手机号和身份证号都是候选码,为什么主码还要单独搞一个自增 id”,我当场卡住。这个问题表面是概念辨析,实际上牵扯到业务唯一性、索引设计、外码引用、甚至隐私合规。后来在真实项目里做用户中心和订单系统,才知道这三个概念没吃透,设计的表几乎注定返工。这篇文章就从这几个问题切入,把候选码、主码、外码掰开揉碎讲清楚,配上 SQL 实例和排障经验,适合正在学数据库的朋友、准备面试的同学,以及被外键约束坑过无数次的后端开发。
1. 先搞清楚:数据库里的“码”解决的是什么问题
1.1 没有码,表就是一团乱麻
先看一张最朴素的员工表,只有姓名、部门、性别三个字段。张三入职两次,或者李四从研发部调到财务部,你想更新某条记录,用姓名定位会怎样?重名的人会一起被改。更麻烦的是,如果允许插入两条完全一样的记录,这张表在逻辑上就不是“集合”了,因为集合不允许重复元素,关系模型也默认表里不该有重复行。缺了唯一标识,删除会删错,更新会更新多行,关联查询更无从谈起。码的作用,就是给每一行一个确定的“身份”。
这里的“码”就是教材里常说的 Key,开发同学更习惯叫“键”。候选码(Candidate Key)、主码(Primary Key)、外码(Foreign Key)是一套完整的身份标识体系:候选码负责“哪些属性组合可以唯一确定一行”,主码负责“我们最终选哪个作为正式身份”,外码负责“一张表怎么引用另一张表的身份”。三个概念相互独立又紧密相关,很多开发对它们的理解只停留在 SQL 语法层面,一旦面对真实建模场景就容易翻车。
1.2 函数依赖是码的理论起点
要理解码,绕不开函数依赖。函数依赖说的是:属性集合 X 的值一旦确定,属性集合 Y 的值也跟着确定,记作 X→Y。比如学号确定后,姓名、年级、系别都能确定,学号→姓名就成立。当 X 能推出关系里的所有属性时,X 就是超码;如果 X 是最小的超码——去掉任何一个属性都不能推出全集——那 X 就是候选码。
这里有个常见误区:很多人以为“能唯一确定一行”就够了,但候选码还要求“最小性”。比如在学号唯一的表里,(学号, 姓名)也能唯一确定一行,但它不是候选码,因为去掉姓名后,学号依然可以推出其他所有属性。候选码必须是“不能再删掉任何属性的超码”,这一个“最小性”条件,是后续所有键选型判断的起点。凡是跳过函数依赖直接背定义的人,遇到复合键和复杂依赖关系基本都会迷路。
1.3 一句话区分候选码、主码、外码
如果只能记住三句话,那就背这三句:
- 候选码:能唯一确定一行记录的最小属性集合,一个表可以有多个候选码。
- 主码:从候选码里挑出来一个当“正式身份”的码,一个表只能有一个主码。
- 外码:子表里用来引用父表主码(或唯一键)的列组合,负责把两张表的关系钉死。
这三个概念很容易混淆,因为它们都在说“键”,但作用维度完全不同。候选码和主码是在“一张表内部”讨论唯一性,外码是“跨表”讨论引用关系。用一个不太严谨但很好记的类比:候选码是“待选身份证号池”,主码是“发出去的那张身份证”,外码是“你填在表格上的别人家身份证号”。
| 对比维度 | 候选码 | 主码 | 外码 |
|---|---|---|---|
| 所属范围 | 单表内部 | 单表内部 | 表与表之间 |
| 数量 | 可以有多个 | 只能有一个 | 可以有多个 |
| 是否允许 NULL | 不允许 | 不允许 | 通常允许 |
| 核心作用 | 标识唯一性的所有候选方案 | 最终被选中的唯一标识 | 维护参照完整性 |
| SQL 体现 | UNIQUE 约束或主键候选 | PRIMARY KEY | FOREIGN KEY |
2. 候选码:唯一且最小,还常常不止一个
2.1 手工判定候选码的完整步骤
遇到复杂表结构时,不能靠肉眼猜,要按属性闭包的思路一步步算。我给一个可以直接抄的步骤:
- 列出关系 R 上的全部函数依赖 F。
- 把所有属性分成几类:只出现在函数依赖左侧的、只出现在右侧的、左右都出现的、左右都没出现的。
- 只出现在左侧的属性,一定属于每个候选码;只出现在右侧的属性,一定不属于任何候选码。
- 先拿“只在左侧出现的属性集合”算闭包,看能不能推出全部属性。如果能,并且去掉其中任何属性都不行,那它就是一个候选码。
- 如果推不出全集,就把左右都出现过的属性或左右都没出现的属性逐个加进来试,对每组组合算闭包,然后检查最小性。
举个例子。关系 R(A, B, C, D),函数依赖 F = {A→B, B→C, D→B}。属性分类:A、D 只出现在左侧,C 只出现在右侧,B 左右都出现。候选码必须包含 A 和 D,不能包含 C。先试 (AD)+:A→B 推出 B,B→C 推出 C,D→B 推出 B,最终 A、D、B、C 全齐,说明 AD 是超码。再检查最小性:单独的 (A)+ = {A, B, C},缺 D;单独的 (D)+ = {D, B, C},缺 A。所以去掉 AD 中任何一个属性都不能推出所有属性,候选码就是 AD。
再举个例子,学生表(学号 S#,身份证号 IDCard,姓名 Name,系别 Dept),函数依赖 S#→Name/Dept,IDCard→Name/Dept。左右分类后能很快看出,候选码有两个:S# 单独一个,IDCard 单独一个。这就是“一个表可以有多个候选码”的典型场景,后续选主码时才需要做取舍。
2.2 候选码与超码、备用码的边界
很多人分不清超码和候选码。超码是“能唯一确定记录”的属性集合,候选码是“最小超码”。{(学号, 姓名)} 是超码,但去掉姓名后“学号”依然是超码,所以它不是候选码。{(学号)} 才是候选码。理论上,候选码是最小约束,超码可以任意加冗余属性。
再说“备用码”(Alternate Key)。一个表可能有好几个候选码,被选为主码的那个是主码,剩下没被选中的候选码,统称备用码。在 SQL 里,备用码通常用 UNIQUE 约束来实现。要注意的是,备用码不是“不重要”,它一样承担着业务唯一性校验的职责。比如用户表里主码是自增 id,身份证号和手机号都是候选码,虽然没被选为主码,但必须加 UNIQUE 约束,否则就会出现两个用户共用一张身份证的脏数据。
2.3 业务上怎么找候选码
理论讲完了,落地时第一件事就是找候选码。这里有几个实操原则:
第一,候选码必须来自真实的业务唯一性。身份证号、手机号、邮箱、车牌号、订单号、合同编号这类业务标识,天然就是候选码。第二,允许为 NULL 的列永远不能做候选码,因为 NULL 的语义是“未知”,未知值之间的唯一性判断没有意义。第三,要警惕“伪唯一”。比如一个表里存了历史订单,业务上说“同一个用户一天只能下一单”,听起来 (user_id, order_date) 是候选码,但如果系统允许补单、售后单、异常单,这个组合往往不可靠。
第四,多列组合的候选码要特别小心。订单明细表里的 (order_id, item_id) 就是一个典型的复合候选码,单靠 order_id 推不出明细的唯一性,单靠 item_id 更不行,两个列合起来才行。这种复合候选码在实际设计里非常常见,也是最容易在后续外码引用时出问题的点。
3. 主码:从候选码里选一个“当家的”
3.1 选主码的四个原则
候选码可能有多个,但主码只能有一个。到底选谁?我的经验是看四个维度:
- 最小性:能单列就不要用多列,单列主码在索引存储、外码引用、SQL 写法上都更简单。
- 稳定性:主码一旦确定,理论上终身不变。用户名会改,身份证号几乎不变,自增 id 从生成那天就定死,所以自增 id 天然占优。
- 简洁性:主码会被二级索引反复存储,列越长,索引越大,查询越慢。VARCHAR(64) 的随机字符串主键性能和维护成本都不如 BIGINT。
- 非敏感:主码经常出现在 URL、日志、外码引用里。拿手机号当主码,等于把隐私字段散落在各个角落,不管从隐私合规还是防爬角度都是灾难。
拿用户表举例:候选码有 user_id(自增代理键)、id_card(身份证号)、mobile(手机号)。按这四个原则,主码选 user_id 是压倒性优势。那身份证号和手机号怎么办?加 UNIQUE 约束,让它们继续承担“业务唯一性校验”的职责,但不再作为被引用的身份标识。
3.2 用 SQL 定义主码与备用码
定义主码最直接的方式是建表时加 PRIMARY KEY。以 MySQL 为例:
CREATE TABLE app_user ( user_id BIGINT AUTO_INCREMENT PRIMARY KEY, id_card VARCHAR(18) NOT NULL UNIQUE, mobile VARCHAR(20) NOT NULL UNIQUE, nickname VARCHAR(50) NOT NULL, created_at DATETIME NOT NULL ) ENGINE=InnoDB;这里 user_id 是主码,id_card 和 mobile 是备用码。PostgreSQL 推荐用GENERATED ALWAYS AS IDENTITY,Oracle 12c 之前用 SEQUENCE + TRIGGER,12c 之后也支持 IDENTITY。生产环境多数情况不建议继续用AUTO_INCREMENT+ 手动序列这种容易埋坑的写法,能用数据库原生 IDENTITY 就用原生。
复合主码的 SQL 写法略有不同。订单明细表的主码通常由 (order_id, item_id) 共同组成:
CREATE TABLE order_item ( order_id BIGINT NOT NULL, item_id INT NOT NULL, quantity INT NOT NULL, PRIMARY KEY (order_id, item_id) );这里 order_id 和 item_id 都不能为 NULL,否则违反主码约束。复合主码在逻辑上没问题,但带来的问题是:所有希望引用这条明细的外码都要同时携带两个列,查询和连接会变啰嗦。所以实际项目中,也有团队会给明细表加一个无业务含义的自增 id 当主码,然后给 (order_id, item_id) 加 UNIQUE 约束,让这个组合退化为候选码。两种设计都能跑,关键在于你要想清楚哪个维度更重要。
3.3 主码选不好,索引和存储都会遭殃
以 MySQL InnoDB 为例,这是最容易踩坑的地方。InnoDB 是聚簇索引表,整张表的数据行物理存储在主键索引(聚簇索引)的 B+ 树叶子节点上。主码选得好不好,直接决定数据写入和查询的性能。
主码是自增序列时,新插入的行基本是追加到索引末尾,页分裂少,写入效率高。主码是随机 UUID 时,插入位置随机,B+ 树频繁分裂,数据和索引文件碎片化严重,写入放大明显。更麻烦的是,InnoDB 的二级索引叶子节点不存整行的物理地址,而是存主键值。主码是 UUID 这种 36 个字符的字符串,意味着每个二级索引都会凭空变大很多,磁盘和内存成本全跟着涨。
如果建表时没有显式定义主键,InnoDB 会按顺序找第一个非空的 UNIQUE 列做聚簇索引;实在找不到,就生成一个隐藏的 6 字节 rowid。这种“没主键也能跑”的表,一旦数据量大起来,性能、日志、同步工具都会变得不可控。所以我写建表语句的第一条纪律就是:每个表先检查有没有主码,没有就先补上,再谈索引优化。
3.4 自然键和代理键之争
开发圈里一直在吵“主码到底用自然键还是代理键”。自然键就是有业务含义的键,比如身份证号、手机号、国家代码;代理键就是纯粹为标识而生的值,比如自增 id、UUID、雪花 id。
自然键的优点是业务上可读、外部系统容易理解,比如国家代码表用 ISO 标准代码做主码,就非常合理。缺点是业务一旦变化就牵一发动全身:手机号可能注销换绑,身份证号涉及隐私,字符串本身又长又占空间。代理键的优点是稳定、简短、生成可控、不暴露隐私,缺点是没有业务含义,业务唯一性必须额外建唯一约束兜住。
我的默认建议是:内部业务表优先代理键,外部标准化字典表优先自然键。用户表、订单表、评论表这类会被大量引用、且业务标识可能发生变化的表,用代理键当主码;国家代码、币种、省份、枚举词典这类稳定且业务含义明确的表,用自然键当主码更顺手。下面的对照表可以帮助快速决策:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 自增主键 | 写入紧凑、索引小、实现简单 | 分布式不好生成、可被遍历枚举 | 单库单表、内部系统 |
| UUID 主键 | 全局唯一、客户端可生成 | 写入随机、索引膨胀 | 分布式环境但别直接当聚集主键 |
| 雪花 id 类 | 全局唯一且大致有序 | 需要生成组件、时钟回拨处理 | 分布式高并发写 |
| 自然键 | 业务可读、无需额外列 | 变更风险高、可能过长、有隐私 | 稳定字典表、外部强约定的标识 |
4. 外码:数据库级的关系锁
4.1 外码到底“锁”住了什么
外码解决的问题是参照完整性:子表中的外码值,要么等于父表中某个主码/唯一键的值,要么是 NULL,不允许指向一个不存在的父行。这条约束如果只靠应用层代码保证,很容易出漏洞。比如用户下单后立刻注销账号,如果业务代码先删用户再插入订单,一个并发问题就可能产生僵尸数据。
外码的意义在于:把“子表必须引用存在的父表记录”这条规则下沉到数据库引擎层面。有了外码约束,任何破坏参照完整性的 INSERT、UPDATE、DELETE 都会被数据库拒绝执行,错误在源头就被拦住,而不是等脏数据进表之后靠对账任务清洗。
4.2 外码 SQL 与级联行为的选择
创建外码的标准 SQL 长这样:
CREATE TABLE `order` ( order_id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES app_user(user_id) ON UPDATE CASCADE ON DELETE RESTRICT ) ENGINE=InnoDB;外码的核心是 REFERENCES 子句,后面的 ON DELETE 和 ON UPDATE 定义了父表数据变化时子表怎么处理。四种行为看起来简单,选错才是大坑:
- RESTRICT / NO ACTION:默认行为,父表有子记录引用时,禁止删除或更新。MySQL 中两者行为几乎一致。
- CASCADE:父表删除,子表相关行跟着删;父表主键更新,子表外码跟着改。
- SET NULL:父表删除后,子表外码置为 NULL。前提是外码列没有 NOT NULL 约束。
- SET DEFAULT:少数数据库支持,父表删除后子表外码改成默认值。
生产上,业务流水类表强烈不建议用 ON DELETE CASCADE。一个用户删除账号,系统把他名下所有历史订单全删了,财务、审计、报表全都会崩。更稳妥的做法是逻辑删除:user 表加一个 deleted 字段,用户“删除”后只是标记失效,订单数据永不移除。如果确实想保留子表行但不再指向父表,就设计外码列允许 NULL,使用 ON DELETE SET NULL。
4.3 外码的 NULL 问题与复合外码
很多新手看到外码,下意识认为外码列必须 NOT NULL,其实不是。外码列的 NULL 含义是“这条记录没有引用任何父行”。比如订单表的 coupon_id 外码,不是所有订单都用了优惠券,没有用券的订单,coupon_id 存 NULL 是合法且合理的。这里的重点是 NULL 和“引用了一个不存在的值”是两回事:NULL 是允许的,引用不存在的父键则会被外码约束直接拒绝。
复合外码的规则也容易被忽略:外码引用的列组合,必须在父表上有唯一索引(主码或 UNIQUE 约束),而且子表的列数量、顺序要和父表被引用的列完全一致。举个例子,如果订单明细表想用 (order_id, product_id) 去引用商品表,商品表就必须有 (order_id, product_id) 这样的复合唯一键,否则外码根本建不出来。实际项目里,这种跨表复合外码会让数据写入和查询变得非常别扭,能用单列尽量用单列。
4.4 外码对性能的真实影响
说完正确性,说性能。外码不是免费午餐,它带来的代价经常被低估。
插入或更新子表时,数据库要检查父表引用是否存在,这个过程会给父表行加共享锁,高并发写入时,锁等待和锁冲突就可能变成瓶颈。删除或更新父表时,数据库也要扫子表,检查是否还有记录引用这一行。如果子表外码列没建索引,这个检查就是全表扫描,数据量一大,删除父表一行能拖垮整个库。
所以有一条铁律:外码列必须建索引。不光是性能问题,InnoDB 在检查外码时,如果子表没有索引,会锁住扫描范围,造成比预期大得多的锁范围,并发和死锁概率直线上升。我之前在一个订单系统里排查过一次“删除用户超时”,最后发现就是子表订单表没给 user_id 建索引,删除一个用户触发了全表扫描加锁,直接把连接池打满。
5. 一单到底:订单系统里三个码怎么落地
5.1 从需求到建表
理论说再多,不如一套完整的建表场景。现在模拟一个小型电商订单系统的表设计,用户表、商品表、订单表、订单明细表,把它们之间候选码、主码、外码一次理清。
用户表已经建过,主码是 user_id,备用码是 id_card 和 mobile。商品表类似,主码用自增 product_id,product_code 业务编码加 UNIQUE。核心是订单表和明细表:
CREATE TABLE `order` ( order_id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES app_user(user_id) ON UPDATE CASCADE ON DELETE RESTRICT ) ENGINE=InnoDB; CREATE TABLE order_item ( order_id BIGINT NOT NULL, item_id INT NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL, unit_price DECIMAL(12,2) NOT NULL, PRIMARY KEY (order_id, item_id), CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES `order`(order_id) ON DELETE CASCADE, CONSTRAINT fk_item_product FOREIGN KEY (product_id) REFERENCES product(product_id) ON DELETE RESTRICT ) ENGINE=InnoDB;这里的候选码分布:订单表的候选码是 order_id 和 order_no,主码选了 order_id,order_no 用 UNIQUE 兜住“一个业务单号不能重复”。订单明细表的候选码是 (order_id, item_id),主码直接就是它;如果想把明细表外码引用变得更灵活,可以给明细表加一个自增 id 当主码,再把 (order_id, item_id) 设为 UNIQUE。两种都合规,只是权衡点不同。
5.2 现场演练:哪些组合是候选码
很多初级开发在设计订单表时,会提出“用 user_id + created_at 当唯一键行不行”。这个想法听起来好像能唯一标识一笔订单,但实际上完全不可靠:同一用户在同一秒下了两笔单怎么办?同一秒内一单一退、业务上又允许重复提交怎么办?用户 id 和订单创建时间组合,既无法从业务上证明唯一,也无法在数据库层写成候选码,因为数据库只能保证这两个字段的组合不重复,保证不了“业务上一秒只能下一单”。
正确的候选码判断逻辑是:先看业务规则里哪些字段或字段组合具备“唯一确定一条订单”的潜力,再验证它是不是最小超码。order_no 是唯一的业务单号,满足;order_id 是自增代理键,满足;(user_id, created_at) 不具备业务唯一性,不满足。所以最后能进入主码候选池的,只有 order_id 和 order_no 两个。这就是候选人筛选,宁可少,不能滥。
5.3 每次评审都该过的检查清单
我自己在设计评审时,会对着一个检查清单逐条过每张表,强烈建议你也存一份:
- 每个表都有主码吗?没有就是设计缺陷。
- 主码是否满足稳定、简短、尽量单列?不满足就考虑换代理键。
- 业务上的其他候选码,是否都建了 UNIQUE 约束?否则业务唯一性没有数据库保证。
- 每个外码列都建索引了吗?没建,先补再提性能优化。
- 外码的 ON DELETE / ON UPDATE 行为是否符合业务语义?误用 CASCADE 删数据,是线上事故级别的问题。
- 有没有循环外码?A 表引用 B 表、B 表又引用 A 表,是设计坏味道,要尽早拆。
- 有没有一张表被多个表引用,却没有稳定主键?这是外码设计失败的前兆。
这套清单在需求评审阶段花五分钟过一遍,能省掉后面数不清的脏数据和返工。
6. 面试高频题和上线后排障实录
6.1 五道经典辨析题
把面试和实战里最常被问到的辨析题整理成一个表,方便复习:
| 问题 | 简明答案 |
|---|---|
| 候选码和主码什么区别? | 候选码是可能被选为唯一标识的最小属性集合,可以多个;主码是从候选码中选出的那个正式标识,只能一个。 |
| 外码一定引用主码吗? | 不一定,外码可以引用父表的任意唯一键,但被引用的列必须有唯一索引。 |
| 一张表最多有几个主码? | 一个。一张表只能有一个主码,但可以有多个候选码和多个 UNIQUE 约束。 |
| 主码列可以为空吗? | 不可以。主码的每个列都隐式带有 NOT NULL 约束。 |
| 外码列可以为空吗? | 可以。NULL 表示没有引用父表,是合法状态,但造成“孤儿引用”的是非 NULL 且不存在的值。 |
这些问题看着基础,但面试官要听的不是定义背诵,而是你能不能结合实际例子解释。比如回答候选码和主码区别时,能顺带说一个“用户表有 id_card 和 mobile 两个候选码、主码选 id”的例子,说服力瞬间不一样。
6.2 四个真实踩坑现场
第一个坑:删除父表行报Cannot delete or update a parent row: a foreign key constraint fails。排查思路是先找哪些子表引用了这张父表,把引用数据查出来,要么先删/改子表数据,要么改用逻辑删除,不要在不确定业务语义时暴力关闭外码检查。SET FOREIGN_KEY_CHECKS=0这种命令适合数据迁移的特定场景,生产环境执行要极度谨慎。
第二个坑:自增主键用完。曾经见过一张表用 INT 自增主键,到 21 亿上限后插入直接报主键重复。解决方式是 ALTER TABLE 把列类型改成 BIGINT,但大表改列类型会重建表,耗时很长。更早的防范是在新表上直接用 BIGINT,或者申请更大的自增范围。这个坑提醒我:主码选型必须考虑未来十年数据量,INT 在互联网场景下已经越来越不够用了。
第三个坑:外码列没建索引导致删除超时。前面讲过,父表删除时要扫子表,子表没有索引就是全表扫描。有一次业务反馈“删除一个用户卡了 5 分钟”,查慢日志发现子订单表 user_id 没索引,加了索引后删除从分钟级降到毫秒级。凡是外码列,建索引,这句话值得写进团队规范。
第四个坑:UNIQUE 约束在 NULL 上失灵。MySQL 里 UNIQUE 约束允许多个 NULL 并存,用户表 mobile 加了 UNIQUE,但没加 NOT NULL,结果系统里出现了好几条 mobile 为 NULL 的“无手机号用户”。真正要做的是把 mobile 设为 NOT NULL,没有手机号时给空字符串占位,或者根据数据库能力用部分索引。这个坑的本质是:候选码的语义不允许 NULL,实现时就要一并处理 NOT NULL。
6.3 关于码的实用经验
数据库设计里很多问题,追根溯源都能落到码的选择上。我在实际项目里的默认组合是“代理主键 + 业务唯一约束 + 外码建索引”。代理主键保证引用稳定,唯一约束兜住业务唯一性,外码索引保证关联查询和父表操作性能。这套组合不一定适合所有业务,字典表用自然键、时序表不要主键的场景也有,但它确实帮我躲过了大多数返工和线上事故。
如果只留一句话,我会说:先想清楚哪几个属性组合是候选码,再从候选码里挑一个当主码,最后用外码把关系说清楚。这三个问题想透了,表结构设计就成功了大半。