MongoDB 中没有外键:重新思考参照完整性
Source: Dev.to
没有外键的 MongoDB?重新思考引用完整性
MongoDB 之所以不提供传统的外键约束,常常让人误以为它在数据完整性方面不如关系型数据库。但这并不意味着我们必须放弃对引用完整性的控制。相反,MongoDB 为我们提供了多种实现方式,只是需要我们在应用层面进行一些额外的工作。
下面我们将探讨:
- 为什么 MongoDB 没有外键
- 常见的替代方案
- 如何在代码中手动维护引用完整性
- 使用事务(transactions)来保证原子性
为什么 MongoDB 没有外键?
MongoDB 采用 文档模型(document model),鼓励将相关数据嵌入到同一个文档中,而不是像关系型数据库那样通过外键进行关联。这样做的好处包括:
- 查询更快:一次读取即可获取完整信息,避免了多表联接(JOIN)。
- 可伸缩性更好:分片(sharding)时不需要跨分片的事务。
- 灵活的模式(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管道 内部 进行。
我在之前的文章中已经介绍了这些变体。
参照完整性
如果插入的员工记录的 deptno 在 departments 中不存在,$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 集合的插入、更新和替换。对于每个被更改的文档,它会:
- 通过
_id检索文档。 - 执行
$lookup以获取引用的部门信息。 - 使用
$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
});