组织架构、商品类目、评论盖楼、权限继承、推荐链路——这些数据的共同点是"节点通过指向父节点形成树或图"。在关系型数据库里,这类递归查询要靠 WITH RECURSIVE;在 MongoDB 里,对应的工具是聚合管道阶段 $graphLookup。它不是"专门给树用的算子",而是一个通用递归查找算子:从一个起点出发,反复按指定字段做匹配,把命中的文档并入结果,直到没有新文档为止。
选对建模方式比选对算子更重要。本文先对比三种层次结构建模方式,再详解 $graphLookup 的参数与机制,然后给出组织架构与类目树的完整查询,最后说明它的性能边界与何时该换图数据库。
1. 三种层次结构建模
同一棵"类目树",在 MongoDB 里至少有三种存法。选哪种,取决于"读多还是写多"。
1.1 父引用(Parent Reference)
{ _id: "electronics", name: "电子", parent: null }
{ _id: "phones", name: "手机", parent: "electronics" }
{ _id: "smartphones", name: "智能手机", parent: "phones" }
每个节点只存父节点 ID。写入最简单,改父子关系只改一条文档。缺点是查子树要递归,也就是 $graphLookup 的用武之地。
1.2 物化路径(Materialized Path)
{ _id: "smartphones", name: "智能手机", path: "electronics,phones,smartphones" }
把从根到自身的路径存成一个字符串。查某节点的所有后代只需前缀匹配:
db.categories.find({ path: /^electronics,phones,/ })
一次查询搞定,无需递归。缺点是路径长度受限,且改一个中间节点的位置要更新其全部后代。
1.3 祖先数组(Ancestor Array)
{ _id: "smartphones", name: "智能手机", ancestors: ["electronics", "phones"] }
把祖先存成数组,可对 ancestors 建多键索引。查子树用 { ancestors: "phones" } 命中索引,速度极快;查祖先链直接读数组。缺点是同样存在"改位置要批量更新"的问题,且数组增长受 16 MB 文档上限约束(深度 16 层以内基本无虞)。
1.4 权衡与选择
| 维度 | 父引用 | 物化路径 | 祖先数组 |
|---|---|---|---|
| 查子树 | 慢(需递归) | 快(前缀匹配) | 快(数组索引) |
| 查祖先链 | 慢(需递归) | 快(解析 path) | 快(直接读) |
| 改节点位置 | 快(改一条) | 慢(改全部后代) | 慢(改全部后代) |
| 索引友好 | 一般 | 需前缀索引 | 多键索引友好 |
| 深度限制 | 无 | 路径长度 | 文档大小 |
| 适用 | 写多读少 | 读多写少、深度固定 | 读多写少、需按祖先过滤 |
选择原则:如果树很少变动而查询频繁(类目、行政区划),用祖先数组或物化路径;如果树频繁调整且深度不深(组织架构重组),用父引用 + $graphLookup。数据建模的一般原则可参考 https://plumephp.com/mongodb-data-modeling/。
还有一种混合方案在实践中很常见:同时保留 parent(用于快速改结构)与 ancestors(用于快速查子树),在变更时同步维护两者,用一次写入的复杂度换查询的便利。
1.5 建模决策清单
落到具体项目时,按以下顺序自问:
- 树的规模有多大? 几百个节点,任何方案都够用;数十万节点,必须优先考虑查询效率。
- 深度有多深? 深度 3~5 层且固定,物化路径最省事;深度不固定或可能很深,用祖先数组。
- 变更频率如何? 每天改结构 → 父引用;一年改一次 → 祖先数组。
- 查询模式是什么? 只查"某节点的后代" → 祖先数组;需要"从任意节点向上找路径" → 物化路径或
$graphLookup。 - 是否需要跨层级聚合? 需要按层级汇总(如"统计三级类目下的商品数")时,祖先数组的
ancestors数组长度天然给出了层级信息。
这五个问题答完,方案基本就定了。切忌"默认用父引用,反正有 $graphLookup"——在查询密集的场景下,这个默认选择会让每次查询都付出递归的代价。
2. $graphLookup 的语法与参数
2.1 完整语法
{
$graphLookup: {
from: <集合名>,
startWith: <表达式,起点字段或值>,
connectFromField: <本侧用于连接的字段>,
connectToField: <对侧用于匹配的字段>,
as: <输出数组字段名>,
maxDepth: <最大递归深度,可选>,
depthField: <记录深度的字段名,可选>,
restrictSearchWithMatch: <过滤条件,可选>
}
}
2.2 参数详解
| 参数 | 作用 | 常见取值 |
|---|---|---|
from | 在哪个集合里递归查找 | 通常是与当前集合同名的集合 |
startWith | 起点值(可以是字段引用或表达式) | "$parent"、"$_id"、"$ancestors" |
connectFromField | 从"已找到的文档"里取哪个字段继续匹配 | "parent"、"children" |
connectToField | 用上一步取到的值匹配对侧的哪个字段 | "_id"、"parent" |
as | 结果数组写入的字段名 | "ancestors"、"descendants" |
maxDepth | 递归层数上限(0 表示只做一轮) | 按业务深度设,避免无限递归 |
depthField | 给结果文档加一个深度字段 | "depth" |
restrictSearchWithMatch | 对递归过程中的候选文档做过滤 | { status: "active" } |
2.3 递归机制
$graphLookup 的机制可以拆成一轮轮循环:
- 取
startWith的值作为第一轮的匹配值。 - 在
from集合里用connectToField = 该值查找文档。 - 找到的文档里取
connectFromField作为下一轮的匹配值。 - 重复 2~3,直到某轮没有新文档或达到
maxDepth。
结果是一个去重后的数组(同一文档不会被加入两次),因此天然能处理有环的图——即使数据里存在 A → B → A 的环,递归也会因去重而终止。这是 $graphLookup 相比手写递归的一个重要优势。
方向的表达完全由 connectFromField 与 connectToField 的组合决定:
| 方向 | startWith | connectFromField | connectToField |
|---|---|---|---|
| 自底向上(查祖先) | "$parent" | "parent" | "_id" |
| 自顶向下(查后代) | "$_id" | "_id" | "parent" |
3. 实战:组织架构与类目树
3.1 查某个员工的所有上级
db.employees.aggregate([
{ $match: { _id: "emp-42" } },
{
$graphLookup: {
from: "employees",
startWith: "$managerId", // 从直接上级开始
connectFromField: "managerId", // 沿 managerId 继续向上
connectToField: "_id", // 匹配上级的 _id
as: "managementChain",
depthField: "level"
}
},
{ $project: { name: 1, managementChain: { name: 1, level: 1 } } }
])
depthField 让结果里每个文档多一个 level 字段,level: 0 是直接上级,level: 1 是隔一级,依次递增。
3.2 查某个类目的所有后代
db.categories.aggregate([
{ $match: { _id: "electronics" } },
{
$graphLookup: {
from: "categories",
startWith: "$_id",
connectFromField: "_id",
connectToField: "parent",
as: "descendants",
maxDepth: 5,
depthField: "depth"
}
},
{ $unwind: "$descendants" },
{ $sort: { "descendants.depth": 1 } },
{ $project: { "descendants._id": 1, "descendants.name": 1, "descendants.depth": 1 } }
])
3.3 限制搜索范围
restrictSearchWithMatch 可以在递归过程中过滤候选文档,例如只保留上架的类目:
{
$graphLookup: {
from: "categories",
startWith: "$_id",
connectFromField: "_id",
connectToField: "parent",
as: "descendants",
maxDepth: 5,
restrictSearchWithMatch: { status: "active" }
}
}
注意它的作用范围:只过滤递归过程中新找到的文档,不影响起点文档本身。如果起点自身不满足条件,仍会被返回。此外,被过滤掉的中间节点会截断其子树——如果某个中间类目是 inactive,它下面的 active 后代也不会被遍历到。
3.4 构造完整路径
给每个后代算出完整路径,可在 $graphLookup 之后拼接:
db.categories.aggregate([
{ $match: { _id: "electronics" } },
{ $graphLookup: {
from: "categories",
startWith: "$_id",
connectFromField: "_id",
connectToField: "parent",
as: "ancestors",
depthField: "level"
} },
{ $addFields: {
path: {
$concat: [
"electronics",
{ $reduce: {
input: { $reverseArray: { $sortArray: { input: "$ancestors", sortBy: { level: -1 } } } },
initialValue: "",
in: { $concat: ["$$value", "/", "$$this.name"] }
} }
]
}
} }
])
这段管道先把 ancestors 按 level 降序排(最远的祖先在前),再逐个拼接成 electronics/phones/smartphones 形式的路径。
3.5 结果的处理与分页
$graphLookup 的输出是一个数组,通常需要 $unwind 摊平后再排序、分页。这里有两个细节:
db.categories.aggregate([
{ $match: { _id: "electronics" } },
{ $graphLookup: { /* ... */ as: "descendants", depthField: "depth" } },
{ $unwind: { path: "$descendants", preserveNullAndEmptyArrays: true } },
{ $sort: { "descendants.depth": 1, "descendants._id": 1 } },
{ $skip: 0 },
{ $limit: 50 }
])
preserveNullAndEmptyArrays: true:若某节点没有后代,descendants是空数组,$unwind默认会丢弃该文档;加这个选项可以保留"空节点"。- 排序要带唯一键:只按
depth排序时,同层节点顺序不稳定,分页会漏数据或重复。加上_id作为次级排序键,保证顺序确定。
注意 $skip 在 $unwind 之后执行,意味着数据库仍然要展开全部后代再丢弃前面的,深分页依旧低效。子树规模大时应改用游标分页或直接限制 maxDepth。
4. 性能与限制
$graphLookup 好用,但有几个硬限制必须知道。
4.1 分片限制
在分片集群中,$graphLookup 的 from 集合若被分片,只能在单个分片内执行;如果被匹配的数据分布在多个分片,结果会不完整。分片环境下应让同一棵树的分片键设计保证数据共置(colocated),分片键设计方法见 https://plumephp.com/mongodb-sharding-key-design/。
4.2 内存限制
与 $group、$sort 一样,$graphLookup 默认受 100 MB 内存限制,超出会报错:
$graphLookup reached maximum memory consumption
可通过 { allowDiskUse: true } 放开,但性能会显著下降。控制结果规模的正确做法是用 maxDepth 限深,而不是放开内存。
4.3 索引要求
每一轮递归都要在 from 集合上按 connectToField 查找,必须为 connectToField 建索引:
db.categories.createIndex({ parent: 1 }) // 查后代时必需
db.employees.createIndex({ managerId: 1 }) // 查上级时必需
没有索引,每一轮都是全集合扫描,深度一深就雪崩。递归深度为 D、每层候选为 N 时,无索引的总代价约为 D × 集合大小;有索引时降为 D × log(集合大小)。
4.4 分页与位置限制
$graphLookup 一次性把整棵子树拉进内存,无法像普通查询那样边遍历边返回。对超大子树,应考虑用祖先数组建模改为普通查询。此外,$graphLookup 只能出现在聚合管道中,且必须放在 $match 之后尽早出现——放在管道后段会让前面的阶段先消耗大量资源,且无法享受 $match 的下推优化。
5. 物化路径与 $graphLookup 的取舍
5.1 性能对比
同样查"某类目的全部后代",两种方案的代价差异显著:
| 方案 | 查询形式 | 复杂度 | 数据变更代价 |
|---|---|---|---|
$graphLookup | 递归聚合 | O(深度 × 索引查找) | 低(改一条) |
| 祖先数组 | { ancestors: id } | O(命中文档数) | 高(改全部后代) |
| 物化路径 | 前缀正则 | O(命中文档数) | 高(改全部后代) |
读性能上,祖先数组与物化路径明显占优,因为它们把递归摊平成了单次索引查询。写性能上,父引用 + $graphLookup 占优。
5.2 混合方案的落地
实践中更常见的做法是混合:以祖先数组为主存储(读快),在节点移动时用一次批量更新重算受影响子树的 ancestors。子树规模不大时(几百个节点),一次 updateMany 就能完成,写代价可接受。
只有当树规模极大(数十万节点)且频繁重排时,才回退到父引用 + $graphLookup。
5.3 变更时的祖先数组维护
节点移动时,需要重算受影响子树的 ancestors。假设把 phones 从 electronics 移到 devices 下,受影响的不仅是 phones 本身,还有它的全部后代。做法是:
// 1. 找出 phones 的全部后代
const subtree = await db.categories.aggregate([
{ $match: { _id: "phones" } },
{ $graphLookup: { from: "categories", startWith: "$_id",
connectFromField: "_id", connectToField: "parent", as: "desc" } },
{ $unwind: "$desc" },
{ $replaceRoot: { newRoot: "$desc" } }
]).toArray();
// 2. 逐个替换旧的祖先前缀
for (const node of subtree) {
const newAncestors = node.ancestors.map(a => a === "electronics" ? "devices" : a);
await db.categories.updateOne({ _id: node._id }, { $set: { ancestors: newAncestors } });
}
注意这里用 $graphLookup 来做一次性维护——这正好体现了两种方案的分工:日常查询走祖先数组(快),结构性变更时才用 $graphLookup 递归(慢但罕见)。这种"读写分离"的建模思路,是层次结构设计里最实用的模式。
如果子树很大(上万节点),逐条 updateOne 会太慢,应改用 bulkWrite 批量提交,并在业务低峰执行。
6. 与图数据库的边界
6.1 $graphLookup 的能力边界
$graphLookup 能处理树、有向无环图,甚至带环的图(去重保证终止)。但它不适合多跳的复杂图查询:
- 它只沿单一字段方向递归,无法在一次操作里做"双向遍历"或"按边类型过滤"。
- 它无法做最短路径、可达性、中心性等图算法。
- 深递归 + 宽分支时内存与耗时不可控。
6.2 何时上图数据库
当出现"找两个用户之间的最短关系链"“按多种边类型做多跳推荐"“计算社区发现"这类需求时,就应转向专用图数据库。图数据库在建模与查询上的差异可参考 图数据库建模模式 ,多跳遍历的优化思路见 图数据库多跳遍历优化 。
判断标准很简单:如果查询的是"一棵树的祖先或后代”,MongoDB 的 $graphLookup 或祖先数组完全够用;如果查询涉及"任意两点之间的路径"“多种关系的组合遍历"“图算法”,才考虑引入图数据库。
7. 实践建议
- 先定建模再定查询:读多写少用祖先数组/物化路径,写多读少用父引用 +
$graphLookup。 connectToField一定要建索引,否则每轮递归都是全表扫描。- 始终设置
maxDepth,既控内存也防意外深递归。 - 分片环境下确认树数据共置,否则结果可能不完整。
- 子树规模可控时优先祖先数组,把递归变成一次索引查询,性能差一个量级。
- 注意
restrictSearchWithMatch会截断子树,过滤中间节点时要评估对后代的影响。 - 多跳、双向、带算法的图查询才考虑图数据库,纯树形层次结构不必。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。