跳到主要内容

外键

外键(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 负责查询,两者用途不同。
  • 子表外键列通常值得评估索引,但是否自动创建以及是否需要创建取决于数据库和查询场景。