文档数据库的灵活性让 MongoDB 成为不少业务系统的首选,但一旦涉及树形结构,比如商品分类、组织架构、评论回复或者文件夹目录,建模方式就变得非常关键。树形数据既要表达节点之间的父子关系,又要考虑后续查询的效率、写入的复杂度和数据迁移的代价。MongoDB 不像关系型数据库有现成的递归 CTE 可以用,它需要借助文档结构或额外字段来维护层级信息。下面从几种常用方案出发,分析各自结构和适用场景。

一、父引用:结构简单但递归成本高
父引用是最接近直觉的建模方式。每个节点文档里保存一个 parentId 字段,指向它的直接父节点,根节点的 parentId 设置为 null。比如一个简单的图书分类树,可以这样插入数据。
db.categories.insertMany([
{ _id: "books", name: "Books", parentId: null },
{ _id: "programming", name: "Programming", parentId: "books" },
{ _id: "mongodb", name: "MongoDB", parentId: "programming" },
{ _id: "fiction", name: "Fiction", parentId: "books" }
]);
查询直接子节点只需要一次 find 操作,例如 db.categories.find({ parentId: "books" }) 就能拿到 Programming 和 Fiction 两个子节点;查询某个节点的父节点也非常简单,根据 parentId 反向查一次即可。但如果要获取整棵子树或者某个节点的所有祖先,就需要在应用层循环查询。比如查询 mongodb 的完整路径,先取出当前节点,再根据 parentId 逐级向上查找,直到根节点。树的深度越大,产生的查询次数越多,网络往返开销也会线性增加。
这种模型的优点在于结构简单、写入方便,每个节点独立存储不会导致文档膨胀,一个节点改父级只影响自身文档。缺点则是查询子树或祖先路径的代价较高,需要递归或循环多次访问数据库。因此父引用适合只关心直接父子关系、层级不深且不做整树读取的场景,例如简单的两级分类或楼层回复。
二、子引用:把子树塞进父节点
子引用模型反过来,在每个节点文档中维护一个 children 数组,存放所有直接子节点的 _id。插入数据结构如下所示。
db.categories.insertOne({
_id: "books",
name: "Books",
children: ["programming", "fiction"]
});
db.categories.insertOne({
_id: "programming",
name: "Programming",
children: ["mongodb"]
});
如果要读取某个节点的整棵子树,可以先取出该节点,得到 children 数组,再根据这些 id 继续查询下一层,直到没有子节点。这种循环同样需要在应用层完成,但与父引用不同的是,向下扩展时每一层都能直接拿到子节点列表,不需要用 parentId 反向匹配。对于层级少且子树需要频繁读取的业务来说,子引用比父引用更加顺手。
但子引用有明显的限制。如果某个节点子节点特别多,children 数组会持续增长,可能超过单个文档的 16MB 上限。而且移动子树时,要同时更新原父节点和新父节点的 children 数组,两步操作需要放在事务中保证一致性。并发修改同一个父节点的 children 数组也容易产生冲突,比如两个请求同时添加子节点,后写的可能覆盖先写的结果。所以子引用适合树高有限、子节点数量可控的稳定结构,例如栏目数量固定的导航菜单。
三、祖先数组:用冗余换查询性能
如果业务中经常要获取某个节点的所有祖先,或者一次性查询整条路径,可以在每个节点保存一个 ancestors 数组,把从根节点到当前节点之前的所有祖先 _id 都记录下来。比如下例中,三级节点 mongodb 的 ancestors 数组包含 books 和 programming。
db.categories.insertOne({
_id: "mongodb",
name: "MongoDB",
ancestors: ["books", "programming"]
});
这样查询某个节点的完整路径,只需要一次 findOne 拿到 ancestors 数组,再根据这些 id 批量查询节点即可;查询某个节点的所有后代也可以使用 ancestors: nodeId 作为条件,直接匹配出所有将 nodeId 放在祖先数组里的节点,不需要递归。相比父引用和子引用,祖先数组对读操作非常友好,尤其适合路径展示和后代统计。
不过这种冗余也带来维护成本。当一棵子树被移动到新的父节点下面时,不仅要改这个子树根节点的 ancestors,还要更新它所有后代节点的 ancestors,涉及多个文档。可以用 updateMany 一次性更新,但在树很大时仍然是较重的写操作。因此祖先数组适合读多写少、层级相对固定、经常需要路径或后代查询的场景,例如组织架构展示或评论回复链。
四、物化路径:路径字符串配合正则
物化路径的思路更直观:给每个节点一个 path 字段,负责记录从根到当前节点的完整路径,通常用 / 或 . 分隔。例如根节点 books 的路径是 books,子节点 programming 的路径是 books/programming,孙节点 mongodb 的路径是 books/programming/mongodb。
db.categories.insertMany([
{ _id: "books", name: "Books", path: "books" },
{ _id: "programming", name: "Programming", path: "books/programming" },
{ _id: "mongodb", name: "MongoDB", path: "books/programming/mongodb" }
]);
查询子树时可以使用正则匹配路径前缀:要查询 programming 下的所有节点,条件可以写成 { path: /^books\/programming\// }。这里的反斜杠用于转义路径分隔符,表示路径以 books/programming/ 开头。祖先查询则可以通过拆分路径字符串得到各级祖先 id,再批量查询。这类正则在 MongoDB 中可以搭配索引提升查询效率,但如果模式写成不区分大小写或者使用了非前缀匹配,索引可能会失效。
物化路径的优点是层级关系一眼就能看出来,正则查询灵活,也方便做前缀匹配。但路径字符串的长度会随层级增加,MongoDB 对字段字符串长度没有像文档大小那样的硬限制,但索引长字符串会占用更多内存。移动子树时,所有后代节点的 path 都需要更新前缀,和祖先数组一样存在写放大。因此物化路径适合层级可预期、子树查询频繁且移动操作较少的场景。
五、嵌套集合:左右值区间适合读多写少
嵌套集合来自关系数据库时代,但在 MongoDB 中同样可以落地。每个节点存储 left 和 right 两个数字,形成区间。父节点的区间会完整包含所有子节点的区间。例如根节点 root 的 left=1、right=8,两个子节点分别为 (2,5) 和 (6,7)。
db.tree.insertMany([
{ _id: 1, name: "root", left: 1, right: 8 },
{ _id: 2, name: "child1", left: 2, right: 5 },
{ _id: 3, name: "child2", left: 6, right: 7 }
]);
查询某个节点的所有后代,只需要一个范围查询:{ left: { $gt: 2 }, right: { $lt: 5 } } 就能拿到所有落在这个区间内的节点。查询祖先也可以查找 left 小于当前节点且 right 大于当前节点的所有节点。这种查询效率非常高,不需要逐层递归,非常适合读多写少的分类树。
但嵌套集合的写入和更新非常麻烦。插入一个新节点需要先给位置后面的节点腾出区间,通常要更新大量节点的 left 和 right 值。删除节点也需要收缩区间。移动子树更是需要重新计算所有受影响节点的左右值。因此如果没有特别强的全树查询需求,不建议在 MongoDB 中优先选择嵌套集合。可以用物化路径或祖先数组替代大部分场景。
六、选型建议与综合对比
为了帮助快速选择,可以把几个方案按典型操作列出来对比。
| 方案 | 查询子节点 | 查询子树 | 查询祖先 | 移动子树 | 写入成本 |
|---|---|---|---|---|---|
| 父引用 | 快 | 慢(递归) | 慢(递归) | 快 | 低 |
| 子引用 | 快 | 较快(循环) | 慢(反向匹配) | 较快 | 中 |
| 祖先数组 | 快 | 快 | 快 | 慢(批量更新) | 中 |
| 物化路径 | 快 | 快(正则) | 较快(拆分) | 慢(批量更新) | 中 |
| 嵌套集合 | 快 | 快(区间) | 快(区间) | 很慢 | 高 |
如果树层级不超过 3 层,且主要以读写直接子节点为主,父引用或子引用足够。如果读多写少,需要频繁查询子树或路径,祖先数组或物化路径更合适。如果几乎不更新且需要高效率的子树与祖先查询,可以考虑嵌套集合,但一定要评估维护成本。实际项目中也可以将多种方案结合,例如父引用加上物化路径,既保留单节点修改的灵活性,又满足路径查询需求。
树形结构建模没有银弹,要依据业务特性权衡。建议先明确最常用的查询模式,再决定是否引入冗余字段,并通过索引优化常见查询。同时要注意 MongoDB 单个文档的大小限制和数组字段的更新行为,避免写入压力集中在少数文档上。