主键与外键(表关系设计)
本教程共 50 篇 · 第 36 篇 · 更新于 2026-07-31 · 约 12 分钟阅读
36. 主键与外键(表关系设计)
本节目标:理解主键(PRIMARY KEY)和外键(FOREIGN KEY)的作用,会建单列与复合主键,会建外键并选对 ON DELETE / ON UPDATE 级联动作,能据此给两张表建模一对一、一对多、多对多关系。
关系型数据库「关系」二字,主要靠两种约束落地:主键和外键。它们保证每行能被唯一找到,表与表之间能对得上号。建表时把这两样想清楚,数据的「骨架」就稳了。
主键 PRIMARY KEY:行的身份证
主键是一列(或几列的组合),用来唯一标识表里的一行。它有三条铁律:
- 值不能重复;
- 值不能是
NULL; - 一张表最多只能有一个主键。
技术上,主键 = NOT NULL + UNIQUE 的组合。建表时加主键,PostgreSQL 会自动建一个唯一的 B-tree 索引来保障唯一性,查询按主键找行也特别快。
单列主键,最常见:
CREATE TABLE users (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(50) NOT NULL,
email VARCHAR(100)
);
这里 id 就是主键。用 GENERATED ALWAYS AS IDENTITY(PostgreSQL 18 推荐写法)让它自增,不用手动填。插入时如果不指定 id,数据库自动分配一个不重复的值。
复合主键(多列联合唯一):
CREATE TABLE order_items (
order_id INT,
item_no INT,
product VARCHAR(100) NOT NULL,
PRIMARY KEY (order_id, item_no)
);
单独看 order_id 或 item_no 都可能有重复,但「同一订单里的行号」这个组合是唯一的。这种情况用复合主键最自然。注意复合主键里每一列都隐含 NOT NULL。
Note历史代码里常见
SERIAL做自增,比如id SERIAL PRIMARY KEY。在 PostgreSQL 18 里更推荐GENERATED AS IDENTITY,它对自增行为的控制更严格、更标准(比如禁止手动插入冲突值,除非用OVERRIDING SYSTEM VALUE)。SERIAL现在主要作为兼容老写法存在。
外键 FOREIGN KEY:表与表的纽带
外键(foreign key)是子表(child table,也叫引用表)里的一列(或几列),它指向父表(parent table,也叫被引用表)的主键或唯一约束。它的作用是维护「引用完整性(referential integrity)」——子表里的外键值,必须在父表里找得到。
拿 users 和 orders 举例:orders.user_id 引用 users.id。
CREATE TABLE users (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(50) NOT NULL
);
CREATE TABLE orders (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id INT,
amount NUMERIC(10,2) NOT NULL,
CONSTRAINT fk_user
FOREIGN KEY (user_id)
REFERENCES users(id)
);
CONSTRAINT fk_user 给外键起了个名字(不写也行,系统会自动命名)。REFERENCES users(id) 说明 user_id 指向 users 表的 id。
试着插入一个不存在的用户订单:INSERT INTO orders(user_id, amount) VALUES (999, 10); ——会直接报错,因为 999 在 users 里没有,引用完整性被拦住了。这就是外键的价值:它替你挡住「孤儿数据」。
Tip外键引用的列,父表上必须有主键或唯一约束,否则建不了外键。外键列的类型也要和父表被引用列一致(如都是
INT)。很多初学者漏了父表的约束,导致外键建失败。
ON DELETE / ON UPDATE:父表变动时怎么办
父表那一行如果被改或被删,子表里引用它的数据怎么处理?这由外键的 ON DELETE / ON UPDATE 动作决定。主键很少改,所以 ON UPDATE 用得少,重点看 ON DELETE。常见动作有五种:
| 动作 | 含义 |
|---|---|
NO ACTION(默认) | 父表行被引用时,拒绝删除/更新 |
RESTRICT | 和 NO ACTION 基本一样,拒绝操作 |
CASCADE | 父表删/改,子表引用行跟着删/改 |
SET NULL | 父表删/改,子表外键列被置为 NULL |
SET DEFAULT | 父表删/改,子表外键列被置为默认值 |
最常用的是 CASCADE(级联删除)。比如「删用户时,把他所有订单一起删掉」:
CREATE TABLE orders (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id INT,
amount NUMERIC(10,2) NOT NULL,
CONSTRAINT fk_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE
);
这样 DELETE FROM users WHERE id = 1; 时,orders 里 user_id = 1 的订单会被自动清掉。
如果用默认的 NO ACTION,删除有订单的用户会直接报错——这能防止误删、保护数据,但也意味着你得先处理掉子表数据才能删父表。
Warning
ON DELETE CASCADE很方便,但也很「狠」:一删父表,子表数据全没。生产环境用之前想清楚,是否真要级联。我一般对「强归属」关系(订单属于用户)才用 CASCADE,对「弱关联」更愿意用SET NULL或默认的NO ACTION。SET NULL要求外键列本身允许为空,否则建约束会失败。
给已有表加 / 删外键
建表时没加,也能事后补:
-- 加外键
ALTER TABLE orders
ADD CONSTRAINT fk_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-- 删外键(按约束名)
ALTER TABLE orders
DROP CONSTRAINT fk_user;
Note加外键时,PostgreSQL 会立刻校验现有数据是否已经满足引用完整性。如果
orders里已经存在指向不存在用户的user_id,加约束会失败。得先把脏数据清理掉,或把这些外键改成合法值。
用它们建模表关系
靠主键+外键,可以表达三种常见关系:
- 一对多(1:N):
users1 — Norders,在orders放user_id外键。这是最常见的关系。 - 多对多(M:N):需要一张中间表(junction table),存两边的主键作为复合外键。比如「学生选课」:
students和courses之间加enrollments(student_id, course_id),两个外键分别指向两边,常把这两列做成复合主键。 - 一对一(1:1):子表主键同时是外键,指向父表主键。比如
users和user_profiles,user_profiles.user_id既是主键又引用users.id。这种常用于把「不常用的大字段」拆出去。
-- 多对多示例:学生选课中间表
CREATE TABLE students (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(50) NOT NULL
);
CREATE TABLE courses (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title VARCHAR(100) NOT NULL
);
CREATE TABLE enrollments (
student_id INT REFERENCES students(id) ON DELETE CASCADE,
course_id INT REFERENCES courses(id) ON DELETE CASCADE,
PRIMARY KEY (student_id, course_id)
);
查看已有约束
想知道一张表上有哪些主键/外键,用 psql 的 \d:
postgres=# \d orders
输出里会列出 Foreign-key constraints 及对应的 ON DELETE 动作。想看全库的约束关系,也可以查系统视图 information_schema.table_constraints。
常见错误
- 外键列类型与被引用列不一致,建约束报错。
- 父表被引用列没有主键/唯一约束。
- 在已有脏数据上加外键失败,忘了先清洗。
- 滥用
ON DELETE CASCADE,误删父表时连带删掉大量子表数据。
外键列记得建索引
外键约束本身不会自动在被引用列以外建索引。子表的外键列(如 orders.user_id)上最好手动建索引:
CREATE INDEX idx_orders_user ON orders(user_id);
好处有二:一是按用户查订单时更快;二是删除父表用户(尤其是 ON DELETE CASCADE)时,数据库要扫子表找关联行,有索引就不会全表扫。我一般建外键后会顺手补这个索引。
Note父表被引用列(如
users.id)本身就是主键,已经有唯一索引,不用再建。需要补的是子表这一侧的外键列索引。
小结
主键是「行身份证」(唯一、非空、每表一个),单列或复合皆可;外键是「表间纽带」,指向父表主键来保证引用完整,ON DELETE 决定父表被删时子表怎么动,常用 CASCADE、SET NULL、默认 NO ACTION。把一对多、多对多、一对一用主键外键表达清楚,关系型数据库的「关系」就立住了。