嵌套文档适合读多写少、层级固定且更新极少的树形结构,如静态目录或权限菜单;children数组嵌套可一次查询整棵树,但受16MB文档限制,更新需重写父文档,高并发易冲突。

嵌套文档适合读多写少的树形结构
如果层级深度固定、更新极少(比如静态目录、权限菜单),直接用 children 数组嵌套是最省事的。MongoDB 原生支持递归展开,find() 一次就能拿到整棵树。
但要注意:单文档有 16MB 上限,5 层深+每层 20 个子节点就容易逼近极限;更新任意子节点都要重写整个父文档,高并发下易触发写冲突。
- 插入时用
db.categories.insertOne({ name: "Electronics", children: [ { name: "Phones", children: [ { name: "iOS" } ] } ] }) - 查询整棵树只需
db.categories.findOne({ name: "Electronics" }) - 更新子节点必须用
$set显式定位路径,如"children.0.children.0.name",路径越深越难维护
引用式建模更适合动态树和频繁更新
当节点要独立增删改、或需跨集合关联(比如用户评论、组织架构变动频繁),必须拆成独立文档 + parent_id 字段引用。
此时 MongoDB 不再自动维护层级,你要自己实现遍历逻辑——但换来的是原子性更新、灵活索引和清晰的权限控制。
- 子文档必须包含
parent_id字段,值为父文档的_id(类型要一致,ObjectId 或字符串) - 务必在
parent_id上建索引:db.nodes.createIndex({ parent_id: 1 }),否则递归查询会全表扫描 - 避免用
JSON.stringify()比较_id,不同驱动序列化结果可能不一致;统一用ObjectId.equals()或字符串===
聚合管道实现无深度限制的树查询
想查某节点下的全部子孙,又不想写递归代码?用 $graphLookup 是唯一靠谱方案,它原生支持无限层级遍历。
但注意:该阶段不能用在分片集群的非路由集合上;且结果默认不保证顺序,需额外 $sort;内存占用随层级指数增长,超 10 层建议加 maxDepth 限制。
- 基础用法:
db.nodes.aggregate([ { $graphLookup: { from: "nodes", startWith: "$_id", connectFromField: "_id", connectToField: "parent_id", as: "descendants" } } ]) - 若只要两层子节点,设
maxDepth: 1;若需排除自身,加restrictSearchWithMatch: { _id: { $ne: "$_id" } } -
$graphLookup返回的是扁平数组,不是嵌套树结构;要转成树还得后处理(比如按parent_id分组 + 递归组装)
递归函数组装树结构时最容易踩的坑
前端或业务层手动递归组装树,看似简单,实际极易出错:死循环、重复引用、漏节点。
核心原则是——所有节点必须先加载到内存,再一次性遍历;绝不能边查边递归(N+1 查询)。
- 先用
db.nodes.find({ parent_id: { $in: [rootId, ...allChildIds] } })一次性拉取所有相关节点 - 用 Map 缓存所有节点:
const nodeMap = new Map(nodes.map(n => [n._id.toString(), n])) - 遍历时只用
nodeMap.get(child.parent_id)查父节点,避免find()循环搜索 - 检查
parent_id是否为空或不存在于nodeMap,否则该节点游离于树外,可能是数据不一致
真正麻烦的从来不是怎么写递归,而是怎么确保 parent_id 和 _id 在所有操作中始终一致——删除父节点时子节点没级联清理,树就断了。这点必须靠应用层逻辑兜底,MongoDB 自身不提供级联删除。


















