外键
外键(Foreign Key)是关系型数据库用来维护表之间关系的一种约束。它保证一张表中用于“引用”的值,必须能在另一张表中找到对应记录。
例如,一笔订单可以通过 user_id 指向下单用户。如果订单中的 user_id 是 100,数据库就应该确认用户表中确实存在编号为 100 的用户。外键的核心价值不是方便连接查询,而是阻止不合法的数据进入数据库。
从一个没有外键的例子开始
假设有用户表和订单表:
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name VARCHAR(50) NOT NULL
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
amount DECIMAL(10, 2) NOT NULL
);
此时,下面这条订单可以成功插入:
INSERT INTO orders (id, user_id, amount)
VALUES (101, 999, 88.00);
即使 users 中不存在 id = 999 的用户,数据库也不会阻止它。这样的订单称为孤立记录:它声称属于某个用户,但这个用户并不存在。
应用程序当然可以先查询用户是否存在,但仅靠应用检查并不牢靠。例如,并发请求可能在“检查完成”和“插入订单”之间删除用户,其他脚本也可能绕过应用直接写入数据。
创建外键
可以在创建订单表时声明外键:
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id)
REFERENCES users (id)
);
这段约束表达了三件事:
orders.user_id是引用方,通常称为外键列或子表列;users.id是被引用方,通常称为父表列;- 每个非空的
orders.user_id都必须对应一个已有的users.id。
现在,再插入 user_id = 999 的订单就会失败。正确顺序是先创建用户,再创建属于该用户的订单:
INSERT INTO users (id, name)
VALUES (1, 'Alice');
INSERT INTO orders (id, user_id, amount)
VALUES (101, 1, 88.00);
约束名称 fk_orders_user 不是必需的,但明确命名后,错误信息、数据库迁移和删除约束都会更容易理解。
外键约束了哪些操作
外键会同时关注关系的两端。
插入或修改子表
插入订单或修改订单的 user_id 时,新值必须能在用户表中找到:
UPDATE orders
SET user_id = 999
WHERE id = 101;
如果用户 999 不存在,这次修改会失败。
删除或修改父表
如果用户 1 仍然被订单引用,数据库也要决定能否删除该用户:
DELETE FROM users
WHERE id = 1;
默认情况下,多数数据库会拒绝这个操作。因为删除用户后,订单的 user_id = 1 就失去了引用目标。
具体如何处理,由外键的引用动作决定。
删除和更新时的引用动作
可以通过 ON DELETE 和 ON UPDATE 指定父表记录变化时如何处理子表。
RESTRICT 或 NO ACTION:拒绝破坏关系
FOREIGN KEY (user_id)
REFERENCES users (id)
ON DELETE RESTRICT
只要用户仍有订单,就不允许删除用户。这通常是最稳妥的选择,因为它要求业务先明确处理关联数据。
RESTRICT 和 NO ACTION 在许多常见操作中的表现相似,但在支持延迟约束检查的数据库中,两者的检查时机可能不同。具体语义应以所用数据库文档为准。
CASCADE:级联处理
FOREIGN KEY (user_id)
REFERENCES users (id)
ON DELETE CASCADE
ON UPDATE CASCADE
- 删除用户时,数据库会自动删除该用户的所有订单;
- 修改用户主键时,数据库会自动更新订单中的
user_id。
级联很方便,但必须符合业务含义。删除用户账户不一定等于删除历史订单;如果订单需要用于审计或财务记录,ON DELETE CASCADE 可能造成不可接受的数据丢失。
SET NULL:保留子表记录
user_id INTEGER,
FOREIGN KEY (user_id)
REFERENCES users (id)
ON DELETE SET NULL
删除用户后,订单仍然保留,但 user_id 被设为 NULL。这要求外键列允许 NULL,也要求业务能够解释“订单没有关联用户”的状态。
SET DEFAULT:使用默认值
部分数据库支持在父表记录被删除或更新时,将外键列设为默认值。默认值本身通常仍需满足外键约束,因此不能随意指定一个不存在的编号。
常见动作可以这样选择:
| 业务需求 | 常见选择 |
|---|---|
| 有子记录时禁止删除父记录 | RESTRICT 或 NO ACTION |
| 子记录完全依附父记录,没有独立价值 | CASCADE |
| 删除父记录后仍需保留子记录 | SET NULL |
| 有一个确实存在的默认归属 | SET DEFAULT(数据库支持时) |
外键列可以为 NULL 吗
可以,只要该列没有声明 NOT NULL。普通外键约束通常只检查非空值:
CREATE TABLE tickets (
id INTEGER PRIMARY KEY,
assignee_id INTEGER,
FOREIGN KEY (assignee_id) REFERENCES users (id)
);
这里的 assignee_id = NULL 可以表示工单尚未分配。它并不要求用户表中存在一个“空用户”。
因此,两个约束解决不同问题:
- 外键保证“填写了负责人时,这个人必须存在”;
NOT NULL保证“每张工单都必须填写负责人”。
是否允许为空应由业务规则决定,而不是为了绕过外键错误。
一个表可以有多个外键
订单可能同时引用用户、地址和优惠券:
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
shipping_address_id INTEGER NOT NULL,
coupon_id INTEGER,
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (shipping_address_id) REFERENCES addresses (id),
FOREIGN KEY (coupon_id) REFERENCES coupons (id)
);
每个外键独立维护一条关系。可选关系可以允许 NULL,必选关系通常同时使用 NOT NULL。
同一个表也可以引用自己。例如,组织成员可以记录直属上级:
CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name VARCHAR(50) NOT NULL,
manager_id INTEGER,
FOREIGN KEY (manager_id) REFERENCES employees (id)
);
这称为自引用外键。顶层负责人没有上级时,manager_id 可以是 NULL。
联合外键
当被引用记录由多个列共同标识时,需要使用联合外键。下面的课程在不同学期可能重复开设,因此由课程编号和学期共同标识:
CREATE TABLE course_offerings (
course_code VARCHAR(20),
semester VARCHAR(20),
teacher VARCHAR(50) NOT NULL,
PRIMARY KEY (course_code, semester)
);
CREATE TABLE enrollments (
student_id INTEGER,
course_code VARCHAR(20),
semester VARCHAR(20),
FOREIGN KEY (course_code, semester)
REFERENCES course_offerings (course_code, semester)
);
列的数量、顺序和兼容类型都要对应。被引用列必须满足数据库对候选键的要求,通常是主键或唯一约束。
外键和 JOIN 不是一回事
外键负责保证数据关系有效,JOIN 负责在查询时组合数据。即使没有外键,也可以写连接查询:
SELECT u.name, o.amount
FROM users AS u
JOIN orders AS o ON o.user_id = u.id;
反过来,声明外键也不会自动把两张表连接起来。查询需要哪些数据,仍然要明确写出 JOIN。
可以简单记为:
外键:写入时守住关系
JOIN:读取时使用关系
外键与索引
被引用列通常已经是主键或唯一键,因此一般有索引。子表的外键列是否自动创建索引,则取决于数据库产品,不能一概而论。
在订单量很大时,通常应考虑为 orders.user_id 建立索引:
CREATE INDEX idx_orders_user_id
ON orders (user_id);
它可以帮助两类操作:
- 按用户查询订单;
- 删除或更新用户时,快速检查哪些订单正在引用该用户。
索引也会占用空间并增加写入成本。是否创建、使用单列还是联合索引,应结合实际查询和执行计划判断。
添加外键时为什么会失败
给已有表添加外键前,数据库通常会检查历史数据。如果已经存在孤立记录,约束就无法建立:
SELECT o.id, o.user_id
FROM orders AS o
LEFT JOIN users AS u ON u.id = o.user_id
WHERE o.user_id IS NOT NULL
AND u.id IS NULL;
应先确认这些记录的业务含义,再补齐父记录、修正外键值、设为 NULL 或删除错误数据。不要为了让迁移通过而随意制造占位记录。
数据库对新增约束、延迟验证和在线变更的支持不同。大表上的生产迁移还要评估锁表时间和写入影响。
常见误区
只在应用代码中维护关系
应用校验可以提供友好的错误提示,但它无法覆盖所有写入入口和并发情况。对于必须始终成立的关系,应优先让数据库约束成为最后一道防线。
所有关系都使用 CASCADE
级联删除可能沿关系链删除大量数据。只有当子记录确实依附父记录、父记录删除后子记录也毫无意义时,才适合使用 ON DELETE CASCADE。
用一个外键列指向多种表
例如用 target_type 和 target_id 表示评论目标,可能让 target_id 一会儿指向文章,一会儿指向视频。普通外键无法根据另一列动态切换目标表,因此数据库不能完整保证引用有效。可以改用不同关联表、明确的多个可空外键,或重新设计公共父实体。
认为有外键就不需要业务校验
外键只能保证记录存在,不能保证用户有权限操作该记录,也不能保证订单状态允许修改。权限、状态流转等规则仍需由其他约束或应用逻辑处理。
小结
- 外键保证子表中的引用值能够在父表中找到对应记录。
NOT NULL决定关系是否必填,外键决定填写后的值是否有效。RESTRICT、CASCADE、SET NULL等动作必须符合真实业务生命周期。- 外键负责完整性,
JOIN负责查询,两者用途不同。 - 子表外键列通常值得评估索引,但是否自动创建以及是否需要创建取决于数据库和查询场景。