Aurora DSQL 补上外键约束:把引用完整性重新交给数据库

2026-09-28 33 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:8 分钟

AWS 为 Aurora DSQL 增加了外键约束支持,应用现在可以直接在数据库中维护表之间的引用完整性,并使用 CASCADE、SET NULL 等引用动作。这个变化看似只是补充了一项常见 SQL 能力,却解决了一个实际的采用障碍:团队不必再完全依赖应用代码来防止孤儿记录。

外键解决的不只是“少写几行校验代码”

没有数据库外键时,服务通常会在写入订单前查询客户是否存在:

  1. 查询父记录;
  2. 判断查询结果;
  3. 插入子记录;
  4. 删除父记录时,再由业务代码清理或更新子记录。

问题在于,这套逻辑很容易被绕过。数据修复脚本、批处理任务、新增的微服务,甚至一次手工 SQL 操作,都可能漏掉相同的检查。并发操作还会放大风险:应用刚确认父记录存在,另一个事务就可能删除它。

外键把不变量声明在数据模型中。例如,“每个订单必须属于一个真实客户”不再只是某个服务中的 if 判断,而是所有写入路径都必须遵守的数据库规则。

这并不意味着应用层校验失去价值。应用仍应返回友好的错误信息,但数据库约束可以成为最后一道防线:即使调用方有缺陷,也不能轻易写入不一致的数据。

CASCADE 与 SET NULL 表达两种不同的生命周期

引用动作应该反映业务实体之间的生命周期关系,而不是为了省事统一设置为 CASCADE。

  • ON DELETE CASCADE:父记录删除时,子记录也随之删除。适合生命周期完全从属于父对象的数据,例如测试环境中的客户与临时订单,或主体与其内部明细。
  • ON DELETE SET NULL:父记录删除时保留子记录,但清空引用。适合需要保留历史、审计或工单内容的场景。
  • 其他引用动作:可以阻止删除,或在特定时点检查约束。实际采用前应根据 Aurora DSQL 当前文档确认支持矩阵和行为细节。

需要特别谨慎的是级联删除。一条看似普通的父表删除,可能触发大量子表变更。数据库保证一致性,并不等于这种操作没有成本,也不等于业务上允许删除这些数据。

可以这样实践:建立两种外键关系

下面是一份可复制并按项目改造的 SQL。示例使用固定整数主键,避免依赖额外的 UUID 或自增函数;请连接到测试数据库后执行,不要直接用于生产环境。

CREATE TABLE app_customer (
    customer_id BIGINT PRIMARY KEY,
    display_name VARCHAR(200) NOT NULL
);

CREATE TABLE app_order (
    order_id BIGINT PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    amount_cents BIGINT NOT NULL,
    CONSTRAINT fk_order_customer
        FOREIGN KEY (customer_id)
        REFERENCES app_customer (customer_id)
        ON DELETE CASCADE
);

CREATE TABLE support_ticket (
    ticket_id BIGINT PRIMARY KEY,
    customer_id BIGINT,
    subject VARCHAR(500) NOT NULL,
    CONSTRAINT fk_ticket_customer
        FOREIGN KEY (customer_id)
        REFERENCES app_customer (customer_id)
        ON DELETE SET NULL
);

INSERT INTO app_customer (customer_id, display_name)
VALUES (1001, 'Example Customer');

INSERT INTO app_order (order_id, customer_id, amount_cents)
VALUES (5001, 1001, 2599);

INSERT INTO support_ticket (ticket_id, customer_id, subject)
VALUES (7001, 1001, 'Refund request');

DELETE FROM app_customer
WHERE customer_id = 1001;

-- 预期:订单随客户删除。
SELECT * FROM app_order WHERE order_id = 5001;

-- 预期:工单保留,但 customer_id 被设置为 NULL。
SELECT * FROM support_ticket WHERE ticket_id = 7001;

注意,使用 SET NULL 的外键列必须允许 NULL。如果将 support_ticket.customer_id 声明为 NOT NULL,删除父记录时就无法完成这个引用动作。

还可以增加一个负向测试,确认数据库确实拒绝孤儿记录:

INSERT INTO app_order (order_id, customer_id, amount_cents)
VALUES (5002, 9999, 1200);

由于客户 9999 不存在,这条写入应触发外键约束错误。应用可以捕获数据库错误,将其映射为稳定的业务响应,例如 HTTP 409 Conflict,而不是把底层错误文本直接暴露给客户端。

给已有系统加约束,难点往往在历史数据

新表可以从第一天就声明外键,旧表则可能已经存在孤儿记录。迁移前可以先运行类似查询:

SELECT o.order_id, o.customer_id
FROM app_order AS o
LEFT JOIN app_customer AS c
    ON c.customer_id = o.customer_id
WHERE c.customer_id IS NULL;

如果查询返回数据,需要先确定处理策略:删除无效子记录、补建父记录、将引用设为 NULL,还是转移到隔离表等待人工处理。不要在不了解数据规模和写入流量的情况下直接增加约束。

上线过程可以按以下顺序设计:

  1. 盘点所有写入方,包括脚本、数据管道和后台任务;
  2. 检测并修复现有孤儿数据;
  3. 在预发布环境验证删除、更新和事务回滚行为;
  4. 压测批量写入和级联删除;
  5. 更新应用的错误处理与监控;
  6. 分批引入约束,观察失败写入和延迟变化。

是否采用:看业务语义,而不是默认全加

Aurora DSQL 支持外键后,团队获得了一个更可靠的数据建模工具,但不应机械地为每个 ID 列建立外键。跨系统标识、事件快照和刻意保留的历史数据,未必适合强引用关系;生命周期明确、必须同步存在的实体则很适合数据库约束。

采用前至少确认三件事:删除语义是否清晰,历史数据是否干净,级联影响是否可控。满足这些条件后,外键能把分散在多个服务里的隐含规则,变成数据库统一执行、可检查的数据契约。


相关推荐