12.2 外键映射

把对象间关联映射为表中的外键引用,协调对象引用方向与关系约束。

学习目标

  • 能解释 orders.customer_id 怎样承接 Order.customer,并指出两边各自保存什么
  • 能修改一个加载与保存函数,处理关联对象、空关联和删除策略,而不把整份对象塞进订单行
  • 能回答:当关联可选、需要级联删除或出现循环加载时,为什么要重新划分拥有方

为什么 12.2 外键映射值得单独学习

想象订单编辑页显示“订单 42 属于 Alice”。页面里有两份信息:订单自己的金额与状态,以及 Alice 的姓名。用户只改了金额,系统保存时仍然要找回 Alice;如果把姓名复制进订单行,下一次 Alice 改名就会留下两份互相矛盾的资料。

这章解决的就是“一个对象指向另一个对象”如何落到两张表之间。没有一个稳定的连接点,读取会变成反复拼接数据,删除可能留下孤儿记录,保存也可能把关联对象误当成订单本身的一部分。

先暂时不要记名词:把订单看作一张卡片,把客户看作另一张卡片。订单卡片只需要写下“客户卡片的编号”,而不需要把客户的全部内容复印过来;编号失效时,系统应该在边界处拒绝这次操作。

先从两张清单建立直觉

仓库中每个箱子都有自己的标签,箱子上还可以写“货主标签号”。搬运箱子时,搬运单只记录这个号码,货主信息仍由另一张清单维护。这样,货主改地址不需要打开每个箱子重写;但货主标签被删除时,仓库必须先决定箱子该退回、改成无主,还是一起清理。

这就是本章的判断线:连接点要足够小,能够被约束和索引;拥有状态的一方要明确,才能知道谁负责写入、校验和处理失败。图示实验会把“读出编号”“保存编号”“处理空值与删除”拆开,让每一步都能回到这条判断线。

外键到底保存什么

一列编号,而不是一份嵌套对象

<Term term="外键">外键</Term>是子表中保存另一张表唯一编号的列。在订单例子里,orders.customer_id = 7 表示订单引用 customers.id = 7;它只保存坐标,不复制客户姓名、地址和偏好。对象侧仍然可以暴露 order.customer,但关系侧的真实连接点是一列数字。

这种拆分同时保留了两种好处:客户资料可以独立更新,订单表可以用索引快速筛选;代价是读取订单时可能要多一次查询,保存时还要把对象引用转换成编号。因此,映射器必须明确“什么时候解析引用,什么时候只保留编号”。

由数据库守住连接的有效性

<Term term="参照完整性">参照完整性</Term>是数据库对“子表编号必须能在父表找到”的约束。若 orders.customer_id 写入 99,而 customers.id = 99 不存在,写入就应该失败,而不是让应用稍后才发现一条无法展示的订单。

应用层仍需做更友好的错误翻译:先检查请求中的客户是否属于当前租户,再让数据库执行约束。两层职责不要混成一个隐式行为;应用层回答“这次业务操作是否允许”,数据库回答“这个编号在关系上是否存在”。

谁写入这一列

真正持有编号的那一侧称为 <Term term="拥有方">拥有方</Term>。对 Order.customer 来说,orders.customer_id 所在的订单行是拥有方:保存订单时由它把客户编号写入外键列。Customer.orders 可以是方便导航的反向集合,但它不应偷偷再维护一份不同的编号来源。

如果对象模型同时维护两个方向,更新 order.customer 后必须同步 customer.orders;否则内存图看起来正确,写回时却可能使用旧集合。更稳妥的做法是让一个方法完成两边更新,或明确反向集合只用于读取。

加载、保存与边界策略

加载:先读编号,再决定是否取对象

加载订单时,映射器先得到 customer_id。若调用方需要客户姓名,可以按这个编号取客户;若列表页只需要订单金额,则可以暂不加载客户。也就是说,外键列把“关联存在”与“关联对象的完整内容”分开,查询范围由用例决定。

保存:对象引用要收敛成一个编号

保存时只把 order.customer?.id 写进 orders.customer_id,不要把客户对象序列化进订单行。若客户是新对象,先保存客户并取得正式编号,再保存订单;如果两个对象必须同一事务完成,就由工作单元安排顺序并在失败时回滚。

可选关联用 <Term term="可空外键">可空外键</Term>表达:没有客户的订单把 customer_id 写成 NULL,而不是写入 0、空字符串或一个“未知客户”假编号。读取端也要接受 null,否则数据库允许的状态会在应用层变成异常。

删除与循环不是默认行为

删除父对象时,数据库或映射器需要一条明确的 <Term term="级联规则">级联规则</Term>:限制删除(RESTRICT)、把子行置空(SET NULL),或删除子行(CASCADE)。订单与客户通常不应该因为客户删除就自动抹掉历史订单;业务要求保留历史时,限制删除或迁移到归档客户更安全。

对象图里的 <Term term="反向关联">反向关联</Term>还会带来循环:订单指向客户,客户集合又指回订单。序列化、自动加载和级联遍历若没有停止条件,可能重复查询或无限递归。解决办法是设定聚合边界、只在一侧负责加载,或者在输出 DTO 时显式截断回指。

三步交互实验:让编号沿着边界走

先预测:如果只改对象里的 customer 引用,哪一步会把 7 写进 orders.customer_id?再用步点切换,观察对象图与关系表始终共享同一个编号;最后看空关联与删除策略为什么不能靠默认值猜测。

分步1 / 3

步骤 1:加载时把 FK 解析为对象引用

动手试:先找到表里的 orders.customer_id = 7,再沿着它查到 customers.id = 7。对象侧得到 Order.customer,但订单仍只拥有自己的字段;这一步的产物是可导航的引用,不是把客户整份复制进订单。

Foreign Key Mapping:对象引用 ↔ 外键列从关系表的编号建立可导航的对象引用Order(对象)id: 42customer: CustomerCustomerid: 7name: "Alice"引用orders(关系行)id: 42customer_id: 7customers(目标表)id: 7 ← PKname: "Alice"读取 FK → 解析引用FK → PK步骤 1 · 对象引用已由编号解析orders.customer_id=7 让 Order.customer 指向 Customer(id=7),客户字段仍由 customers 表拥有。
外键映射让对象引用与关系表编号互相转换;编号的有效性、空值语义和删除策略都必须可检查。

代码对照:让映射边界可测试

下面的 TypeScript 只表达映射契约,不依赖某个 ORM。fromRow 负责从编号恢复对象引用,toRow 负责把引用收敛成一个可空的外键值;真正的 SQL 约束仍应在数据库迁移中声明。

type CustomerRef = { id: number; name: string };
type Order = { id: number; customer: CustomerRef | null; totalCents: number };
type OrderRow = { id: number; customer_id: number | null; total_cents: number };
 
function fromRow(row: OrderRow, customer: CustomerRef | null): Order {
  if (row.customer_id !== null && customer?.id !== row.customer_id) {
    throw new Error("customer reference does not match foreign key");
  }
  return { id: row.id, customer, totalCents: row.total_cents };
}
 
function toRow(order: Order): OrderRow {
  return {
    id: order.id,
    customer_id: order.customer?.id ?? null,
    total_cents: order.totalCents,
  };
}

这个边界有三个可验收断言:客户编号不一致时拒绝构造对象;空引用只写 NULL;保存订单不改变客户行。若客户是新建对象,应先完成客户插入,再调用 toRow,而不是用临时编号绕过参照完整性。

常见误区

选择与拒绝矩阵

评审问题选择外键映射的证据应拒绝或改用其他方案的信号
连接点子表有稳定、可索引的父表编号只能靠姓名、地址等会变化的字段拼接
拥有方只有一侧负责写入 FK,反向集合职责清楚两边都能独立改关系,最后写入者胜出
可选关系NULL 的语义、查询和约束都有测试用 0、空字符串或“未知”伪造关联
删除历史保留要求与级联规则相互匹配框架默认级联会删除不该删除的对象
规模关联加载范围和索引由用例决定一次导航隐式拉出整个对象图

如果关系本身有独立属性、需要排序或频繁增删,多对多场景应比较关联表映射;如果对象从属于父对象且不能独立存在,也应比较依赖映射。外键映射的适用条件不是“有引用就用”,而是“一个可控编号足以表达关系,并且边界责任有人维护”。

本章小结

  • 外键列保存目标行的编号,不复制关联对象。
  • 加载把编号解析为引用,保存把引用收敛回编号。
  • 拥有方负责写 FK,反向关联不能偷偷成为第二个真相源。
  • 空关联、删除级联和循环加载都必须显式定义。
  • 评审时同时检查索引、约束、查询范围和失败传播。

本章练习

练习

问题 1: orders.customer_id = 7,但请求同时带来了 Customer { id: 8, name: "Alice" }。加载函数应该接受还是拒绝?请说明理由。

问题 2: 改上面的 toRow:让没有客户的订单写入 NULL,让有客户但编号缺失的对象直接失败。验收时至少写出两个断言。

问题 3: 客户历史必须保留,但业务允许删除不再活跃的客户。你会选择 RESTRICT、SET NULL 还是 CASCADE?还需要补哪一项测试?

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

外键

子表中指向另一张表唯一编号的列;它像一张小纸条,只记录“要找哪一行”。

参照完整性

数据库保证外键编号确实能找到目标行的规则,避免关系指向不存在的对象。

拥有方

负责把关联编号写入外键列的一侧;本章中是带有 customer_id 的订单行。

可空外键

允许没有关联时写入 NULL 的外键列,区别于用 0 或假编号伪造关系。

级联规则

父行变化时如何处理子行的明确策略,例如限制删除、置空或连带删除。

反向关联

从被引用对象回看引用它的对象集合,例如 Customer.orders;它不应自动成为第二个写入来源。

参考资料

讨论

评论区加载中…