SQL 主键和外键,这两样东西是数据库表设计的“地基”约束,也是最容易在面试和实际开发中翻车的知识点。很多人建表时不加主键,或者外键怎么都插不进去数据,根本原因不是语法不会写,而是没理解约束到底在管什么。这次我们就把主键和外键约束拆开讲透,包括创建语法、复合主键、外键的级联策略、建表实战示例,以及最常见的约束冲突报错排查思路。
本文会按照“先看概念和规格,再看创建语法,然后跑一个完整的学生-课程-选课建表示例,最后讲更新删除约束、性能影响和错误排查”的顺序展开。如果你是后端开发、数据分析师,或者正在准备数据库面试,这篇文章可以直接收藏。
1. 核心能力速览
先给一张速览表,把主键约束、外键约束和常见约束类型一次看清楚。
| 约束类型 | 作用 | 特点 | 使用场景 |
|---|---|---|---|
| 主键约束(PRIMARY KEY) | 唯一标识表中每一行数据 | 非空且唯一,一张表只有一个主键;可以作用于单列,也可以作用于多列组合 | 每张业务表必须有,如用户表的 user_id |
| 外键约束(FOREIGN KEY) | 建立表与表之间的关系,维护引用完整性 | 引用其他表的某列(通常是主键),外键列的值必须存在于被引用表中,或者为 NULL | 订单表引用用户表、选课表引用课程表 |
| 唯一约束(UNIQUE) | 保证一列或一组列的值不重复 | 允许 NULL,但 NULL 可以重复(不同数据库实现有差异) | 邮箱、手机号等业务唯一字段 |
| 非空约束(NOT NULL) | 保证列的值不能为空 | 用于必填字段 | 用户名、订单金额 |
| 检查约束(CHECK) | 对列的值做条件校验 | 不同数据库支持程度差异较大 | 年龄大于 0、状态值只能为 1 或 2 |
| 默认约束(DEFAULT) | 为列设置默认值 | 不填时自动使用默认值 | 创建时间默认当前时间 |
从这张表能看出来,主键和外键并不是“写不写都行”的装饰品。主键管的是“这张表的每一行是否可以被唯一确定”,外键管的是“表之间的关联引用是否合法”。两者配合,才能让数据库在写入阶段拦住脏数据,而不是靠应用程序去判断。
真正理解约束,需要先明确一个事实:约束的本质是数据库层面的数据完整性校验规则。你可以在应用层写 if 判断,但应用层判断只能拦自己程序的输入,挡不住其他客户端、手工 SQL、脚本批量写入的异常数据。只有把约束建在表结构上,数据库引擎才会在每次 INSERT、UPDATE 时自动做校检。
下面进入具体语法和用法。
2. 主键约束详解
2.1 主键的定义与特性
主键约束用于唯一标识表中每一行。一个规范的主键必须满足两个基本要求:非空、唯一。非空意味着这一列不能写入 NULL,唯一意味着表中不能出现两行相同的主键值。
这里有一个重要细节:主键和唯一索引不完全是一回事。唯一索引允许一个 NULL 值(MySQL 中允许多个,但语义上不推荐依赖),主键完全不允许 NULL。而且主键通常会自动创建一个聚簇索引,数据的物理存储顺序会跟随主键值组织,这对查询性能影响很大。
主键的经典使用场景包括:
- 用户表的 user_id。
- 商品表的 product_id。
- 订单表的 order_id。
- 日志表的自增 id。
在很多实际系统中,主键都被设计成整数自增 ID。但是要注意,自增整数主键不一定适合所有场景,比如分布式系统里自增主键会带来 ID 生成瓶颈,这时候就需要雪花 ID、UUID 或者分布式 ID 方案。
2.2 创建主键的三种方式
第一种是建表时在列级定义主键,语法最简洁:
CREATE TABLE student ( student_id INT PRIMARY KEY, student_name VARCHAR(50), gender CHAR(1), class_id INT );第二种是建表时在表级定义主键,适合需要指定约束名称的场景:
CREATE TABLE student ( student_id INT, student_name VARCHAR(50), gender CHAR(1), class_id INT, CONSTRAINT pk_student PRIMARY KEY (student_id) );第三种是建表后通过 ALTER TABLE 添加主键:
ALTER TABLE student ADD PRIMARY KEY (student_id);这三种方式在 MySQL、PostgreSQL、SQL Server 等主流数据库中都可以使用。区别在于约束名称:第一种和第三种如果没有显式指定约束名,数据库会自动生成一个,后续要删除主键时可能需要先查出约束名。
2.3 复合主键
复合主键是指用两列或多列组合成主键,只要组合值不重复即可。最常见的例子是选课表。
选课表中一个学生可以选多门课,一门课可以被多个学生选,单独一列 student_id 无法唯一标识一行,单独一列 course_id 也无法唯一标识一行,但 student_id + course_id 的组合可以唯一确定“某个学生选某门课”这条记录。
CREATE TABLE student_course ( student_id INT, course_id INT, score DECIMAL(5,2), PRIMARY KEY (student_id, course_id) );这里要注意,复合主键要求的是“组合值唯一”,而不是“每一列单独唯一”。同一行里 student_id 重复是允许的,cource_id 重复也是允许的,只要 (student_id, course_id) 这个组合不重复就行。
复合主键实际使用中需要权衡:组合列越多,唯一性判断越复杂,索引占用空间也就越大。如果业务允许,更多时候会添加一个无业务含义的自增 id 作为主键,再对业务列组合加唯一约束。这两种方案没有绝对优劣,取决于查询模式和数据规模。
2.4 删除主键约束
删除主键的 SQL 在不同数据库里有细微区别。MySQL 的写法是:
ALTER TABLE student DROP PRIMARY KEY;PostgreSQL 的写法需要显式指定约束名:
ALTER TABLE student DROP CONSTRAINT pk_student;SQL Server 同样用 DROP CONSTRAINT:
ALTER TABLE student DROP CONSTRAINT pk_student;所以建议在创建主键时显式指定约束名,否则后期删约束要先去系统表查名字,比较麻烦。
3. 外键约束详解
3.1 什么是外键
外键约束用于建立两张表之间的引用关系。简单说,外键列中的值必须存在于被引用表的主键列(或唯一键列)中,或者为 NULL。
以一个经典例子说明:课程表 course 和老师表 teacher 之间是关联关系,每门课程有一个 teacher_id,这个 teacher_id 引用 teacher 表的老师主键。如果某门课程的 teacher_id 写着 99,但 teacher 表中根本没有 id 为 99 的老师,这条课程数据就是无效的。
外键约束就是用来拦截这种无效引用的。
CREATE TABLE teacher ( teacher_id INT PRIMARY KEY, teacher_name VARCHAR(50) ); CREATE TABLE course ( course_id INT PRIMARY KEY, course_name VARCHAR(100), teacher_id INT, CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id) );很多初学者搞不明白“外键列”和“被引用列”的关系。为了说清楚,这里拆成三个关键点:
第一,外键约束是定义在“子表”上的,字段写在子表中,REFERENCES 指向“父表”的列。课程表是子表,老师表是父表,课程表里的 teacher_id 字段引用老师表里的 teacher_id 字段。
第二,外键列的值在插入时必须能在父表被引用列中找到。比如课程表插入 teacher_id = 10 的课程,teacher 表中就必须存在 teacher_id = 10 的老师。如果不存在,插入会失败并报外键约束错误。
第三,外键列允许为 NULL,NULL 表示“尚未分配”或“无引用”,不会被外键检查拦截。这一点在业务设计中常常被利用,比如未分配老师的课程可以先把 teacher_id 留空,等后续再分配。
3.2 外键约束的引用策略
外键约束不只是插入时的校验工具,它还决定了父表数据被删除或更新时,子表的数据应该怎么处理。这是很多 SQL 教程容易忽略的重点。
常见的外键引用策略有四种:
| 策略 | 行为 | 使用场景 |
|---|---|---|
| RESTRICT | 如果子表存在引用数据,父表的删除或更新会被拒绝 | 防止误删有关联的数据 |
| NO ACTION | 与 RESTRICT 基本一致,但检查时机略有差异(部分数据库实现相同) | 同样用于拒绝操作 |
| CASCADE | 父表删除时自动删除子表对应数据;父表更新主键时自动更新子表外键 | 父子生命周期一致,如删除订单后同步删除订单明细 |
| SET NULL | 父表删除或更新时,子表外键自动置为 NULL | 保留历史记录的操作日志、不要求强关联的场景 |
下面是一个 CASCADE 级联删除的建表示例:
CREATE TABLE orders ( order_id INT PRIMARY KEY, customer_id INT, order_date DATE ); CREATE TABLE order_items ( order_item_id INT PRIMARY KEY, order_id INT, product_name VARCHAR(100), quantity INT, CONSTRAINT fk_order_items_orders FOREIGN KEY (order_id) REFERENCES orders(order_id) ON DELETE CASCADE );这段 SQL 的含义是:删除订单时,关联的订单明细记录也会自动删除,避免残留孤儿数据。这在订单系统里非常实用。
如果希望删除老师后,课程表中的 teacher_id 自动变为 NULL,可以使用 ON DELETE SET NULL:
CREATE TABLE course ( course_id INT PRIMARY KEY, course_name VARCHAR(100), teacher_id INT, CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id) ON DELETE SET NULL );选择哪种策略需要谨慎。CASCADE 确实方便,但也有风险:父表一次 DELETE 可能级联删除成百上千条子表数据,如果业务没有预期这个行为,可能造成不可恢复的数据丢失。稳妥的做法是,对核心业务表先使用 RESTRICT 防止误删,确认级联范围合理后再调整策略。
3.3 外键约束的创建方式
外键同样可以在建表时和建表后添加。上面已经展示了建表时添加外键的方式。建表后添加外键的语法如下:
ALTER TABLE course ADD CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id);要删除外键约束,MySQL 的写法是:
ALTER TABLE course DROP FOREIGN KEY fk_course_teacher;PostgreSQL 和 SQL Server 的写法是:
ALTER TABLE course DROP CONSTRAINT fk_course_teacher;4. 建表实战:学生-课程-选课完整示例
从概念到实际应用之间,还差一个完整建模过程。下面用一个经典的教学案例把主键、复合主键、外键串起来。
业务需求如下:
- 学生表 student,记录学生信息。
- 课程表 course,记录课程和授课老师。
- 老师表 teacher,记录老师信息。
- 选课表 student_course,记录学生选了哪门课程、考了多少分。
4.1 创建老师表
CREATE TABLE teacher ( teacher_id INT PRIMARY KEY, teacher_name VARCHAR(50) NOT NULL, title VARCHAR(20) );这里 teacher_id 作为主键。主键列不需要再额外加 NOT NULL,因为主键本身已经隐式包含非空约束。
4.2 创建课程表
CREATE TABLE course ( course_id INT PRIMARY KEY, course_name VARCHAR(100) NOT NULL, teacher_id INT, CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id) ON DELETE SET NULL );课程表通过 teacher_id 引用老师表,删除老师后课程保留但 teacher_id 置空。这是考虑到课程数据有历史价值,不应该因为老师离职就一起删掉。
4.3 创建学生表
CREATE TABLE student ( student_id INT PRIMARY KEY, student_name VARCHAR(50) NOT NULL, gender CHAR(1) DEFAULT '1', enroll_date DATE );4.4 创建选课表
CREATE TABLE student_course ( student_id INT, course_id INT, score DECIMAL(5,2), PRIMARY KEY (student_id, course_id), CONSTRAINT fk_sc_student FOREIGN KEY (student_id) REFERENCES student(student_id) ON DELETE CASCADE, CONSTRAINT fk_sc_course FOREIGN KEY (course_id) REFERENCES course(course_id) ON DELETE CASCADE );选课表是典型的中间关系表。主键用 student_id 和 course_id 组成复合主键,保证同一个学生不能重复选同一门课。两个外键分别指向学生表和课程表,学生退学或课程下线时,选课记录应该同步清理,所以使用 ON DELETE CASCADE。
4.5 插入数据验证约束
建表后先插入老师和学生数据:
INSERT INTO teacher (teacher_id, teacher_name) VALUES (1, '张老师'); INSERT INTO teacher (teacher_id, teacher_name) VALUES (2, '李老师'); INSERT INTO student (student_id, student_name) VALUES (101, '小明'); INSERT INTO student (student_id, student_name) VALUES (102, '小红');正常插入课程数据:
INSERT INTO course (course_id, course_name, teacher_id) VALUES (1001, '数据库原理', 1);这里 teacher_id=1 在 teacher 表中存在,所以插入成功。
接下来试一条外键非法插入:
INSERT INTO course (course_id, course_name, teacher_id) VALUES (1002, '编译原理', 99);teacher 表中没有 teacher_id=99 的记录,数据库会报外键约束错误,这条插入不会生效。这是约束起作用的最直接演示。
再插入选课记录:
INSERT INTO student_course (student_id, course_id, score) VALUES (101, 1001, 92.5); INSERT INTO student_course (student_id, course_id, score) VALUES (101, 1001, 88.0);第二条插入会失败,因为 (101, 1001) 这个主键组合已经存在。复合主键在这里拦住了重复选课。
4.6 级联删除效果验证
删除学生 101:
DELETE FROM student WHERE student_id = 101;由于选课表的外键使用了 ON DELETE CASCADE,student_course 表中所有 student_id=101 的记录都会自动被删除,不会残留“学生在校但选课记录丢失人”的脏数据。
如果当初在设计时把外键策略设为 RESTRICT,这条删除会被直接拒绝,提示存在关联记录。两种策略没有谁更好,关键看业务预期。
5. 修改和删除约束的 SQL 操作
在实际项目里,表结构不会是永远不变的。业务调整时,可能需要删除旧的外键、添加新约束。这里把常用的 ALTER TABLE 约束操作整理出来。
添加主键:
ALTER TABLE student ADD PRIMARY KEY (student_id);删除主键:
ALTER TABLE student DROP PRIMARY KEY;添加外键:
ALTER TABLE course ADD CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id);删除外键:
ALTER TABLE course DROP FOREIGN KEY fk_course_teacher;添加唯一约束:
ALTER TABLE student ADD CONSTRAINT uk_student_email UNIQUE (email);添加检查约束,MySQL 8.0.16 之后的版本支持:
ALTER TABLE student ADD CONSTRAINT chk_student_gender CHECK (gender IN ('0', '1'));修改约束前,最需要确认的一件事是:目标表里是否已经存在违反约束的数据。比如给一列加上唯一约束,而表中已经有重复值,ALTER TABLE 命令会直接报错。正确的操作顺序是:先查出重复数据并清洗,再添加约束。
SELECT student_id, email, COUNT(*) FROM student GROUP BY email HAVING COUNT(*) > 1;这条查询能快速定位重复的邮箱,清洗后再执行唯一约束的添加。
6. 约束对索引和查询性能的影响
主键和外键约束不只是数据完整性的保障,它们对查询性能也有直接影响。
先说主键。大多数数据库中,主键会自动创建索引,MySQL InnoDB 引擎中主键索引就是聚簇索引,数据行按照主键值的顺序物理存储。这意味着按主键查询时,数据库可以直接定位到数据页,查询速度非常快。反过来,如果表没有主键,InnoDB 会选择一个唯一索引代替,如果没有唯一索引,则生成隐藏主键。隐藏主键对开发不可见,还会额外占用存储空间,所以业务表都应该显式设计主键。
再说外键。外键字段上的索引容易被忽略。当外键约束创建后,MySQL 会自动为外键列创建索引,但其他数据库不一定都会自动创建。如果外键列经常作为查询条件或 JOIN 的连接字段,没有索引会导致全表扫描,数据量大时性能明显下降。
一个常见的性能优化建议是:在被频繁引用和 JOIN 的列上主动创建索引。比如选课表的 student_id 和 course_id 虽然是复合主键的前缀列,已经可以走索引,但在更复杂的场景中要检查 EXPLAIN 的执行计划。
EXPLAIN SELECT * FROM student_course sc JOIN student s ON sc.student_id = s.student_id WHERE sc.course_id = 1001;如果执行计划中出现全表扫描,就需要考虑为 course_id 单独建索引,因为复合主键 (student_id, course_id) 中 course_id 不是最左前缀,无法直接利用该索引加速单独按 course_id 的查询。
7. 常见问题与排查方法
约束相关的报错是数据库使用中最常见的一类问题。下面给出一张排查表,覆盖主键外键的典型报错场景。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 主键重复,插入失败 | 表中已存在相同主键值 | 查询表中已有数据 | 改用自增主键或更换主键值 |
| 主键为 NULL,插入失败 | 插入语句没有给主键列赋值 | 检查 INSERT 语句 | 给主键列赋值,或使用自增主键 |
| 外键插入时报引用错误 | 外键值在被引用表中不存在 | 查询父表是否有对应主键值 | 先插入父表数据,再插入子表数据 |
| 删除父表数据被拒绝 | 子表存在引用数据,外键策略为 RESTRICT | 查询子表关联记录 | 先删除子表数据,或改用 CASCADE |
| 级联删除后数据意外消失 | 外键策略设置成了 CASCADE | 查看建表语句中的 ON DELETE 策略 | 修复策略,或临时关闭外键检查后恢复数据 |
| ALTER 添加主键失败 | 目标列存在重复值或 NULL | 用 GROUP BY 查重复值 | 清洗数据后重新添加约束 |
| 添加唯一约束失败 | 目标列存在重复值 | 按列分组统计 | 去重后再添加约束 |
| 外键列没有索引导致 JOIN 慢 | 外键列缺少索引 | 使用 EXPLAIN 查看执行计划 | 为外键列创建索引 |
| 删除约束名找不到 | 建表时未指定约束名称 | 查看表中约束信息 | 查询系统表获取约束名后删除 |
有一个常见操作可以留作备用:在数据导入阶段,如果表中已有数据不完整,需要先导数据后建约束,可以使用数据库提供的延时约束或临时关闭外键检查功能。MySQL 中可以执行:
SET FOREIGN_KEY_CHECKS = 0;批量导入完成后再开启:
SET FOREIGN_KEY_CHECKS = 1;要注意,这只是一个临时措施。生产环境的数据导入应该走完整的校验流程,关闭外键检查虽然提供了便利,但也意味着数据完整性的校检被延后到导入结束之后,需要自己补充验证步骤,否则脏数据会悄悄进入表里。
8. 最佳实践与使用建议
结合项目中的实际经验,给出下面几条针对主键和外键约束的使用建议。
第一,每张表都要有主键。即使是日志表、临时表、中间表,也应该有一个能唯一标识行的列或组合列。没有主键的表在数据复制、更新、删除、去重场景中非常痛苦,而且会让数据库的存储和查询烦琐很多。
第二,主键设计优先考虑稳定且无业务含义的列。自增整数和 UUID 都是常见方案。自增整数占用空间小、索引效率高,适合单库单应用场景;UUID 适合分布式环境,但索引性能略差。不要在业务主键上叠加太多语义,比如不要用身份证号、手机号做物理主键,这类信息一旦业务规则变化,改主键的成本极高。
第三,外键约束要按业务选择策略。强关联数据用 CASCADE 可以省掉大量手动处理;弱关联或历史数据保留场景用 SET NULL;默认情况建议用 RESTRICT 或 NO ACTION 保守处理,防止误删。
第四,不要过度使用复合主键。复合主键能解决唯一性问题,但会让外键引用变得复杂。如果业务表列数很多,可以考虑添加一个单列自增主键,再对业务列组合添加唯一约束。这样既保证了唯一性,也简化了其他表对这张表的引用。
第五,约束设计要在建表阶段完成,不要等数据量大了再补。数据量小的时候,补一个主键或外键可能只要几秒钟。数据量到了千万级别,添加索引或约束可能锁表很久,直接影响业务。
第六,涉及测试环境或人工操作时,要养成先备份再改表结构的习惯。删除主键、修改外键策略、批量清理数据,这些操作都有一定的风险。测试库可以随意玩,生产环境必须确认影响面。
9. 总结与下一步
主键约束和外键约束是数据库表设计的核心内容,也是检验一个人 SQL 基本功是否扎实的常见考点。读完这篇内容,建议先做两件事:第一,打开你常用的数据库客户端,建一张学生表和课程表,按文中示例把主键、复合主键、外键、CASCADE 策略都跑一遍;第二,检查你当前负责的业务表,看看哪些表缺主键,哪些外键列没有索引,哪些外键策略可能与业务预期不一致。
最容易踩的坑是三个:一是觉得主键可有可无,先建表后续再说,结果数据量上来后补主键变成大工程;二是外键策略不假思索全部用 CASCADE,误删数据时才发现级联范围远超预期;三是只建外键不加索引,主表 JOIN 子表时性能断崖式下降。
接下来可以继续学习的方向包括:SQL 中的唯一约束和检查约束、数据库事务与并发控制、以及基于外键关系设计复杂业务模型。主键和外键只是数据完整性的第一层保障,学会结合索引和事务去思考数据,才能把表结构设计得更加稳定。建议收藏备用,建表时回来对照一遍。