MongoDB 中没有外键:重新思考参照完整性

发布: (2025年12月26日 GMT+8 06:29)
19 min read
原文: Dev.to

Source: Dev.to

没有外键的 MongoDB?重新思考引用完整性

MongoDB 之所以不提供传统的外键约束,常常让人误以为它在数据完整性方面不如关系型数据库。但这并不意味着我们必须放弃对引用完整性的控制。相反,MongoDB 为我们提供了多种实现方式,只是需要我们在应用层面进行一些额外的工作。

下面我们将探讨:

  • 为什么 MongoDB 没有外键
  • 常见的替代方案
  • 如何在代码中手动维护引用完整性
  • 使用事务(transactions)来保证原子性

为什么 MongoDB 没有外键?

MongoDB 采用 文档模型(document model),鼓励将相关数据嵌入到同一个文档中,而不是像关系型数据库那样通过外键进行关联。这样做的好处包括:

  1. 查询更快:一次读取即可获取完整信息,避免了多表联接(JOIN)。
  2. 可伸缩性更好:分片(sharding)时不需要跨分片的事务。
  3. 灵活的模式(schema-less):可以随时向文档中添加新字段,而不必担心破坏外键约束。

然而,这并不意味着我们不能在 MongoDB 中实现 引用完整性(referential integrity),只是在实现方式上有所不同。


常见的替代方案

1. 嵌入式文档(Embedded Documents)

如果子文档的生命周期与父文档相同,最直接的做法是把子文档直接嵌入到父文档中。

{
  "_id": ObjectId("..."),
  "name": "John Doe",
  "address": {
    "street": "123 Main St",
    "city": "Springfield"
  }
}

优点:读取一次即可得到完整数据。
缺点:如果子文档需要被多个父文档共享,就不适合使用嵌入。

2. 手动引用(Manual References)

当子文档需要在多个父文档之间共享时,我们可以在文档中存储另一个文档的 _id,类似于外键的概念,但 MongoDB 本身不会检查 这些引用是否有效。

{
  "_id": ObjectId("..."),
  "title": "Post title",
  "authorId": ObjectId("5f8d0d55b54764421b7156c3")   // 引用 users 集合中的用户
}

注意:此时完整性检查必须在应用层完成。

3. DBRef(已不推荐)

MongoDB 仍然保留了 DBRef 类型,但它仅提供了一种 约定,并不执行任何约束检查。大多数驱动程序也不再默认支持它,因此一般不建议使用。


在代码中手动维护引用完整性

下面以 Node.js + Mongoose 为例,演示如何在创建、更新和删除操作时手动检查引用。

1. 创建文档时检查外键

// models/post.js
const mongoose = require('mongoose');

const postSchema = new mongoose.Schema({
  title: String,
  content: String,
  authorId: {
    type: mongoose.Schema.Types.ObjectId,
    required: true,
    ref: 'User'   // 仅用于 populate,MongoDB 不会强制检查
  }
});

module.exports = mongoose.model('Post', postSchema);
// services/postService.js
const Post = require('../models/post');
const User = require('../models/user');

async function createPost(data) {
  // 1️⃣ 确认作者存在
  const author = await User.findById(data.authorId);
  if (!author) {
    throw new Error('作者不存在,无法创建帖子');
  }

  // 2️⃣ 创建帖子
  const post = new Post(data);
  return await post.save();
}

2. 删除父文档时级联删除子文档

async function deleteUser(userId) {
  // 删除用户前,先删除其所有帖子(级联删除)
  await Post.deleteMany({ authorId: userId });
  await User.findByIdAndDelete(userId);
}

3. 使用事务保证原子性(MongoDB 4.0+)

async function transferOwnership(postId, newOwnerId) {
  const session = await mongoose.startSession();
  session.startTransaction();

  try {
    const newOwner = await User.findById(newOwnerId).session(session);
    if (!newOwner) throw new Error('新所有者不存在');

    await Post.findByIdAndUpdate(postId, { authorId: newOwnerId }).session(session);

    await session.commitTransaction();
  } catch (err) {
    await session.abortTransaction();
    throw err;
  } finally {
    session.endSession();
  }
}

要点:事务只能在 副本集(replica set)或 分片集群(sharded cluster)中使用。单节点的本地开发环境默认不支持事务。


何时选择嵌入,何时选择引用?

场景推荐方案说明
子文档生命周期与父文档相同嵌入读取一次即可获取全部信息,避免额外查询。
子文档需要被多个父文档共享手动引用 + 应用层检查通过 _id 关联,使用 populate 进行查询。
需要强一致性(如金融交易)事务 + 手动引用使用多文档事务确保原子性。
数据量极大且经常更新引用嵌入会导致文档频繁增长,影响写入性能。

小结

  • MongoDB 本身不提供外键约束,但这并不意味着我们必须牺牲数据完整性。
  • 通过 嵌入手动引用应用层检查 以及 事务,我们可以在不同场景下实现可靠的引用完整性。
  • 关键在于 理解业务模型,选择最合适的存储方式,并在代码中明确地执行完整性检查。

只要遵循这些原则,即使在没有外键的环境下,MongoDB 依然可以保持数据的一致性和可靠性。祝你在项目中玩得开心!

外键 vs. 应用层级验证

在 SQL 数据库中,外键 充当即时约束,在接受写入之前验证表之间关系的正确性。这是为用户可以直接向数据库提交任意查询的场景而设计的。因此,数据库负责通过规范化、完整性约束、存储过程和触发器来保护数据模型,而不是依赖于在应用程序与数据库交互之前进行的验证。

当关系完整性被破坏时,会产生错误,阻止用户进行更改。应用程序会回滚事务并抛出异常。

MongoDB 的 NoSQL 方法与关系数据库不同,它是为应用程序开发者而设计的。它依赖 应用代码 来强制执行这些规则。用例被明确定义,验证在应用层进行,业务逻辑优先于外键校验。消除与外键相关的额外可序列化读取,可显著提升写入性能和可扩展性。

异步参照完整性

参照完整性可以 异步 验证。MongoDB 不会抛出异常——这是一种应用可能尚未准备好的意外事件——而是允许写入继续进行,并提供以下工具来检测和记录错误:

这种方法能够进行错误分析、数据纠正和应用修复 而不影响应用的可用性,并且仍然包含业务逻辑。

示例:部门和员工

我们将建模一个经典场景,即 所有员工必须属于某个部门

两个带引用的集合

强关联(包括一对多)并不总是需要使用多个集合并通过引用来实现,尤其是当实体共享完全相同的生命周期时。根据业务上下文,你可以:

  • 嵌入 每个部门文档中的员工列表,以保证引用完整性并防止孤儿记录。
  • 嵌入 每个员工文档中的部门信息,当部门更新不频繁(例如,仅对部门描述进行一次简单的多文档更改)或部门变动通常伴随更大规模的组织重组时。

当两个实体 并非总是一起访问、具有 无限制的基数,或 独立更新 时,使用引用通常是更好的选择。例如,为每个员工存储一个 deptno,并维护一个单独的 departments 集合,每个文档都有唯一的 deptno

下面的脚本创建了集合、索引以及示例数据。

// -------------------------------------------------
// Reset collections
// -------------------------------------------------
db.departments.drop();
db.employees.drop();

// -------------------------------------------------
// Departments collection
// -------------------------------------------------
db.departments.createIndex(
  { deptno: 1 },               // deptno will be used as the referenced key
  { unique: true }             // must be unique for many‑to‑one relationships
);

db.departments.insertMany([
  { deptno: 10, dname: "ACCOUNTING",  loc: "NEW YORK" },
  { deptno: 20, dname: "RESEARCH",    loc: "DALLAS"   },
  { deptno: 30, dname: "SALES",       loc: "CHICAGO"  },
  { deptno: 40, dname: "OPERATIONS",  loc: "BOSTON"   }
]);

// -------------------------------------------------
// Employees collection
// -------------------------------------------------
db.employees.createIndex(
  { deptno: 1 }                // reference to departments (helps $lookup)
);

db.employees.insertMany([
  { empno: 7839, ename: "KING",   job: "PRESIDENT", deptno: 10 },
  { empno: 7698, ename: "BLAKE",  job: "MANAGER",   deptno: 30 },
  { empno: 7782, ename: "CLARK",  job: "MANAGER",   deptno: 10 },
  { empno: 7566, ename: "JONES",  job: "MANAGER",   deptno: 20 },
  { empno: 7788, ename: "SCOTT",  job: "ANALYST",   deptno: 20 },
  { empno: 7902, ename: "FORD",   job: "ANALYST",   deptno: 20 },
  { empno: 7844, ename: "TURNER", job: "SALESMAN",  deptno: 30 },
  { empno: 7900, ename: "JAMES",  job: "CLERK",     deptno: 30 },
  { empno: 7654, ename: "MARTIN", job: "SALESMAN",  deptno: 30 },
  { empno: 7499, ename: "ALLEN",  job: "SALESMAN",  deptno: 30 },
  { empno: 7521, ename: "WARD",   job: "SALESMAN",  deptno: 30 },
  { empno: 7934, ename: "MILLER", job: "CLERK",     deptno: 10 },
  { empno: 7369, ename: "SMITH",  job: "CLERK",     deptno: 20 },
  { empno: 7876, ename: "ADAMS",  job: "CLERK",     deptno: 20 }
]);

注意 – 我没有预先声明模式;文档会按照应用程序的原始结构写入。两侧的索引使得在员工和部门之间快速导航成为可能,且可以实现双向查询。

查询示例

该模式支持 所有基数,包括每个部门拥有数百万员工的情况(这在嵌入模式下是不可行的)。它是规范化的,更新只影响单个文档,同时仍然允许 双向查询

1. 员工 → 部门

db.employees.aggregate([
  {
    $lookup: {
      from: "departments",
      localField: "deptno",
      foreignField: "deptno",   // fast access thanks to the unique index
      as: "department"
    }
  },
  {
    $set: {
      // Keep only the first (and only) matching department document
      department: { $arrayElemAt: ["$department", 0] }
    }
  }
]);

2. 部门 → 员工

db.departments.aggreg

```javascript
ate([
  {
    $lookup: {
      from: "employees",
      localField: "deptno",
      foreignField: "deptno",
      as: "employees"
    }
  },
  {
    $project: {
      _id: 0,
      deptno: 1,
      dname: 1,
      loc: 1,
      employees: {
        empno: 1,
        ename: 1,
        job:   1,
        deptno: 1
      }
    }
  }
]);

这些管道在读取时模拟连接,让您在不牺牲写入性能或数据完整性的前提下,拥有嵌入式模型的灵活性。

要点

  • SQL:外键同步强制参照完整性;违规会立即抛出错误。
  • MongoDB:参照完整性通常在应用层实现,或通过聚合管道或变更流异步验证。
  • 建模选择
    • 嵌入:当关系紧密耦合、基数低且数据总是一起访问时。
    • 引用:当需要无限基数、独立更新或不同的访问模式时。

通过理解这些权衡,你可以设计出在保持数据一致性的同时最大化性能和可扩展性的 MongoDB 模式。

Source:

聚合示例

db.departments.aggregate([
  {
    $lookup: {
      from: "employees",
      localField: "deptno",
      foreignField: "deptno", // fast access by index on employees
      as: "employees"
    }
  }
]);

性能考虑

从性能角度来看,执行 $lookup 的成本高于从单个嵌入集合读取。然而,当浏览的文档只有几十或几百个时,这种开销并不显著。

在选择这种模型时,由于一个部门可能拥有上百万名员工,你不会一次性检索所有数据。相反:

  • 在第一个查询中,$match 会在 $lookup 之前过滤文档,或
  • 在第二个查询中,过滤会在 $lookup 管道 内部 进行。

我在之前的文章中已经介绍了这些变体。

参照完整性

如果插入的员工记录的 deptnodepartments 中不存在,$lookup 将找不到匹配:

  • 第一个查询会省略部门信息。
  • 第二个查询不会显示该新员工,因为它只列出已知的部门。

这正是未插入被引用部门的应用程序的预期行为。

关系型数据库管理员常常夸大这类问题的严重性,甚至称之为 数据损坏。因为 SQL 默认使用内连接,该员工在第一个查询的结果中会缺失。而在 MongoDB 中使用类似 $lookup 的外连接时,这 不会 发生——它更像 SQL 中的 NULL:信息尚未存在,所以不显示。你可以稍后再添加部门,查询结果会随之更新并显示相应信息。

如果想在一段时间后检测到引用的项目未被插入(例如因 bug 导致),仍然可以实现相应的检测逻辑。

外键定义为 $lookup 阶段

使用两个阶段来定义引用完整性:一个 $lookup 阶段和一个 $match 阶段,用于验证引用的文档是否存在。

// $lookup stage
const lookupStage = {
  $lookup: {
    from: "departments",
    localField: "deptno",
    foreignField: "deptno",
    as: "dept"
  }
};

// $match stage – keep docs where the lookup returned an empty array
const matchStage = { $match: { dept: { $size: 0 } } };

该定义简洁且类似于 SQL 外键。实际使用中可能会更复杂、更精确。文档数据库在业务逻辑超出静态外键所能表达的场景中表现出色。例如:

  • 某些员工可能暂时没有部门(例如,新入职员工)。
  • 在部门调整期间,某些员工可能同时属于两个部门。

MongoDB 的灵活模式支持这些情况,你可以相应地定义引用完整性规则。这里为了示例保持简洁。

一次性验证(使用聚合管道)

向部门 42(该部门尚不存在)插入一名新员工 Eliot:

db.employees.insertOne({
  empno: 9002,
  ename: "Eliot",
  job: "CTO",
  deptno: 42 // 缺失的部门
});

没有抛出错误。在所有查询中,该员工只能通过部门编号看到,且没有其他部门信息。

如果你决定应当检测此类情况,运行下面的聚合管道来列出违规记录:

db.employees.aggregate([lookupStage, matchStage]);

结果:

[
  {
    "_id": { "$oid": "694d8b6cd0e5c67212d4b14f" },
    "empno": 9002,
    "ename": "Eliot",
    "job": "CTO",
    "deptno": 42,
    "dept": []
  }
]

我们已经异步捕获了违规,可以决定后续处理方式(例如,修正 deptno、插入缺失的部门,或调整业务规则)。

何时运行此验证?

  • 取决于数据库规模和完整性风险。
  • 在进行重大数据重构后,作为额外检查运行。
  • 为避免对生产环境产生影响,可在只读副本上运行(这是异步验证的优势)。
  • 不需要高隔离级别;最坏情况下, 并发事务可能触发误报,后续可再审查。
  • 在进行灾难恢复测试时,对恢复后的副本运行验证,以确认恢复过程和数据完整性。

Source:

实时监视器与变更流

您可能还希望进行近实时验证,在更改发生后不久进行检查。

const cs = db.employees.watch([
  { $match: { operationType: { $in: ["insert", "update", "replace"] } } }
]);

print("👀 Watching employees for referential integrity violations...");

while (cs.hasNext()) {
  const change = cs.next(); // Get the next change event

  if (["insert", "update", "replace"].includes(change.operationType)) {
    const result = db.employees.aggregate([
      { $match: { _id: change.documentKey._id } }, // check the new document
      lookupStage, // lookup dept info by deptno
      matchStage   // keep only docs with NO matching dept
    ]).toArray();

    if (result.length > 0) {
      // Handle the violation (e.g., log, alert, corrective action)
      printjson(result[0]);
    }
  }
}

该变更流监视 employees 集合的插入、更新和替换。对于每个被更改的文档,它会:

  1. 通过 _id 检索文档。
  2. 执行 $lookup 以获取引用的部门信息。
  3. 使用 $match 只保留查找结果为空数组的文档(即没有匹配的部门)。

如果发现任何违规情况,您可以记录日志、发出警报或触发纠正操作。

摘要

  • $lookup 为在 MongoDB 中建模关系提供了灵活的方式。
  • 参照完整性可以 异步(定期聚合)或 近实时(变更流)进行强制执行。
  • MongoDB 的模式灵活性让您能够根据实际业务需求定制完整性规则,而不是被迫使用僵硬的外键模型。
print("\n⚠ Real-time Referential Integrity Violation Detected:");
printjson(result[0]);

在缺失的部门 42 中插入新员工(Dwight)

db.employees.insertOne({
  empno: 9001,
  ename: "Dwight",
  job: "CEO",
  deptno: 42 // missing department
});
Back to Blog

相关文章

阅读更多 »

Academic Suite 数据库设计

数据库是 Academic Suite 的核心基础。在在线考试系统中,数据库模式设计不当可能导致严重的瓶颈,尤其…

从块到意义:数据项与数据库

你好,我是Maneshwar。我正在开发FreeDevTools在线 https://hexmos.com/freedevtools,当前正在打造一个汇集所有 dev tools、cheat codes 和 TLDRs 的统一平台……