在MongoDB的文档建模场景中,自引用模型常用于表示具有层级关系的数据,比如部门架构、商品分类等。这类模型的核心特点是文档中会包含一个指向同集合其他文档的引用字段,用来标识父级节点。顶层文档就是没有父级节点的文档,也就是父级引用字段为null或者不存在的文档。

自引用模型的基本定义
首先我们来看一个典型的自引用模型定义,以部门模型为例,每个部门可以有一个父部门,顶层部门没有父部门:
const mongoose = require('mongoose');
const departmentSchema = new mongoose.Schema({
name: {
type: String,
required: true
},
// 自引用字段,指向父部门
parent: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Department',
default: null
}
});
const Department = mongoose.model('Department', departmentSchema);
常见的低效查询方式
很多开发者在查询顶层文档时,会使用以下两种方式,这两种方式都存在性能问题:
方式一:查询parent为null的文档
直接查询parent字段等于null的文档,这种方式的问题在于如果parent字段没有索引,会触发全集合扫描,数据量大的时候性能很差。另外如果部分文档没有parent字段,也会被查询出来,不符合预期。
// 低效查询方式
const topDepartments = await Department.find({ parent: null });
方式二:先查所有文档再过滤
查询所有文档后,在代码层面过滤parent为空的文档,这种方式会返回大量无用数据,网络和内存开销都很大,完全不可取。
// 更差的查询方式 const allDepartments = await Department.find(); const topDepartments = allDepartments.filter(item => !item.parent);
高效查询的最佳实践
1. 为自引用字段添加索引
首先必须为parent字段添加索引,这样查询parent相关条件的时候可以快速定位数据,避免全表扫描。索引可以在模型定义的时候添加:
const departmentSchema = new mongoose.Schema({
name: {
type: String,
required: true
},
parent: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Department',
default: null
}
});
// 为parent字段添加索引
departmentSchema.index({ parent: 1 });
const Department = mongoose.model('Department', departmentSchema);
2. 使用存在性判断查询顶层文档
推荐使用$or操作符结合存在性判断,明确查询parent字段不存在或者parent为null的文档,同时配合索引提升查询效率:
// 高效查询顶层文档
const topDepartments = await Department.find({
$or: [
{ parent: { $exists: false } },
{ parent: null }
]
}).lean(); // 使用lean()返回普通JS对象,减少Mongoose文档转换开销
3. 分页查询避免大结果集
如果顶层文档数量很多,一定要配合分页查询,避免一次性返回大量数据导致内存溢出和响应缓慢:
// 分页查询顶层文档
async function getTopDepartments(page = 1, pageSize = 10) {
const skip = (page - 1) * pageSize;
const [list, total] = await Promise.all([
Department.find({
$or: [
{ parent: { $exists: false } },
{ parent: null }
]
})
.lean()
.skip(skip)
.limit(pageSize),
Department.countDocuments({
$or: [
{ parent: { $exists: false } },
{ parent: null }
]
})
]);
return {
list,
total,
page,
pageSize
};
}
4. 预定义静态方法复用逻辑
可以把顶层文档的查询逻辑封装成模型的静态方法,方便多处复用,同时保证查询逻辑统一:
departmentSchema.statics.findTopDocuments = function (options = {}) {
const { page, pageSize } = options;
const query = this.find({
$or: [
{ parent: { $exists: false } },
{ parent: null }
]
}).lean();
if (page && pageSize) {
const skip = (page - 1) * pageSize;
query.skip(skip).limit(pageSize);
}
return query;
};
// 使用静态方法查询
const topDepartments = await Department.findTopDocuments({ page: 1, pageSize: 10 });
性能对比
我们可以通过一个简单的测试对比不同查询方式的性能,假设集合中有10万条文档,其中100条是顶层文档:
| 查询方式 | 耗时(毫秒) | 扫描文档数 |
|---|---|---|
| 无索引查询parent为null | 1200 | 100000 |
| 有索引查询parent为null | 15 | 100 |
| 有索引配合存在性判断查询 | 12 | 100 |
| 有索引分页查询(每页10条) | 3 | 10 |
注意事项
- 自引用模型的parent字段如果存储的是ObjectId,一定要保证引用的文档存在,避免出现脏数据
- 如果层级关系不深,也可以考虑在文档中冗余一个
isTop字段,插入顶层文档的时候标记为true,查询的时候直接查这个字段,性能会更高,但是需要维护字段的一致性 - 尽量避免在自引用模型中做递归查询,递归查询很容易出现性能问题,如果需要查询整个树形结构,可以考虑使用物化路径等其他的建模方式