$$ROOT 是聚合管道中代表当前阶段输入文档完整副本的变量,仅在 $project、$addFields、$set 等支持表达式的阶段可用,用于透传或备份原始字段,避免手动列举;误用 $replaceRoot 或忽略其作用域易导致数据结构错乱。

聚合中保留原始文档所有字段,不用逐个写 $project
直接用 $$ROOT 就行,它代表当前输入文档的完整副本。很多人卡在想“怎么把没参与计算的字段全带过去”,其实根本不用列字段名——$$ROOT 就是那个“原封不动”的快捷方式。
常见错误现象:$project 里只写了新字段,结果其他字段全丢了;或者硬写一堆 field1: "$field1" 模板,一换集合就漏字段。
- 用法很简单:
{ $project: { _id: 0, original: "$$ROOT" } },这样就把整份文档塞进original字段 - 如果就想“透传+加字段”,写成
{ $project: { _id: 0, newField: { $add: [ "$a", "$b" ] }, doc: "$$ROOT" } } -
$$ROOT是表达式变量,只能在支持表达式的阶段用(比如$project、$addFields、$set),不能在$match或$sort里当值用
$addFields 和 $set 里用 $$ROOT 更自然
$addFields(或 4.2+ 的 $set)比 $project 更适合“只加不删”的场景,这时候 $$ROOT 不会意外丢字段,语义也更直白。
使用场景:做统计后还想带着原始数据查详情,比如先 $group 出用户订单数,再用 $lookup 关联回原始订单列表——但关联回来的是数组,你想每个订单还带用户基本信息,这时 $$ROOT 就能快速把用户文档挂上去。
-
{ $addFields: { userDoc: "$$ROOT" } }—— 安全,不覆盖原有字段 - 和
$project不同,$addFields默认保留所有输入字段,所以$$ROOT在这里只是“多存一份”,不是“替代” - 注意性能:如果文档很大,复制整份到新字段会增加内存和传输开销,别在高频聚合里无脑深拷贝
为什么不用 $replaceRoot?它和 $$ROOT 完全不是一回事
$replaceRoot 是替换整个文档结构,而 $$ROOT 是引用当前文档——名字像,但作用对象完全不同。踩坑最多的就是把 $replaceRoot 当成“保留字段”的工具,结果把 pipeline 后面的阶段全搞乱了。
错误示例:{ $replaceRoot: { newRoot: "$$ROOT" } },看起来没变,其实它强制把文档层级重设了一次,可能破坏后续 $unwind 或 $lookup 的路径匹配。
-
$$ROOT是变量,用于取值;$replaceRoot是阶段,用于重置文档根节点 - 真要“透传+改结构”,优先选
$set+$$ROOT,而不是绕一圈$replaceRoot - 只有当你需要把某个嵌套对象(比如
"data"字段)整个提成新文档根时,才用$replaceRoot
嵌套字段里访问 $$ROOT 要小心作用域
$$ROOT 总是指向“当前阶段输入文档”的顶层,不是当前表达式所在层级。这点在 $map、$filter 或 $cond 里特别容易误判。
比如你在 $addFields 里对数组做 $map,想在每个元素里引用原始文档的 userId,不能写 "$$this.userId"(那是数组元素自己的字段),得写 "$$ROOT.userId"。
-
$$ROOT的作用域固定,不会随表达式嵌套变深而改变 - 如果 pipeline 已经经过
$project过滤,那$$ROOT就是那个被过滤后的“根”,不是原始集合文档 - 调试技巧:临时加个
{ $addFields: { debugRoot: "$$ROOT" } },然后用mongosh查看输出,确认你拿到的到底是什么
$project,哪怕只写了一个字段,$$ROOT 就只包含那个投影后的结构——它不记得原始集合长什么样。所以 $$ROOT 不是“回滚键”,而是“当前上下文快照”。











