MongoDB的聚合框架是处理数据分析任务的核心工具,而$project阶段则是整个管道中最常用于调整文档形状的操作符。它的职责非常明确:决定最终输出文档里保留哪些字段、排除哪些字段、新增哪些计算字段。很多初学者在使用时容易被“保留”与“排除”的混用规则搞混,导致报错或者结果不符合预期。这篇文章就围绕$project的字段保留与排除展开,把语法规则、嵌套处理和实战技巧一次讲透。

$project的基本语法与两种工作模式
$project接受一个文档作为参数,其中每个键对应输出文档中的字段名,值决定该字段的处理方式。当值为1或true时,表示保留该字段;值为0或false时,表示排除该字段;如果值是一个表达式,比如字符串拼接、算术运算,则会生成一个计算字段。这两种模式看似可以随意组合,实际上MongoDB有严格限制:除了_id字段之外,保留模式和排除模式不能同时出现在同一个$project阶段中。
具体来说,如果你指定了若干个字段值为1,那么输出文档只包含这些字段加上_id;如果你指定了若干个字段值为0,那么输出文档会包含原文档中除这些字段之外的所有字段。例如集合中有一个包含name、age、email、address的文档,下面的写法只保留name和age:
db.users.aggregate([
{ $project: { name: 1, age: 1 } }
])
// 输出:{ _id: ..., name: "...", age: ... }反过来,如果想排除敏感字段email和address,保留其余所有内容,可以这样写:
db.users.aggregate([
{ $project: { email: 0, address: 0 } }
])
// 输出包含 _id、name、age 等其余全部字段需要注意_id字段是个特例。它默认始终被保留,即使在排除模式下也一样。如果不想在结果中看到_id,必须显式写_id: 0。这也是唯一一个允许与保留模式混用的排除字段,比如{ name: 1, _id: 0 }是完全合法的,输出中只有name字段。
嵌套文档与数组字段的保留与排除
实际业务中文档结构往往不是扁平的,嵌套对象和数组随处可见。$project对嵌套字段的支持非常完善,既可以用点号路径写法,也可以用嵌套文档写法。假设文档结构如下:
{
_id: 1,
name: "张三",
profile: {
city: "北京",
age: 28,
phone: "13800000000"
},
orders: [
{ orderId: "A001", amount: 200 },
{ orderId: "A002", amount: 350 }
]
}如果只想保留profile里的city和age,排除phone,可以使用点号路径精确控制嵌套层级:
db.users.aggregate([
{
$project: {
name: 1,
"profile.city": 1,
"profile.age": 1,
"profile.phone": 0
}
}
])上面这种写法属于“嵌套内排除”,MongoDB允许在保留某个父对象的同时排除其中的子字段,这类混用是合法的,前提是排除和保留发生在不同的层级或子路径上。如果对同一层级既写profile.city: 1又写profile.age: 0,同样符合“包含子路径同时排除兄弟子路径”的规则,可以正常执行。
数组字段的投影则需要借助$first、$slice、$filter等表达式。例如只取orders数组的第一条记录:
db.users.aggregate([
{
$project: {
name: 1,
firstOrder: { $first: "$orders" },
recentOrders: { $slice: ["$orders", 2] }
}
}
])$slice可以截取数组前N个元素,$filter则能按条件筛选数组元素。如果只是简单地写orders: 1,整个数组会原样输出,这在数组很大时会带来明显的性能开销,因此在报表场景中建议总是配合表达式裁剪数组内容。
计算字段、字段重命名与条件投影
$project的能力不止于字段裁剪,它还能在输出时创建新的计算字段。这是它区别于普通find投影的关键优势。常见的用法包括字段重命名、数值计算、字符串处理和条件逻辑。比如把price和quantity相乘生成total字段:
db.orders.aggregate([
{
$project: {
orderId: 1,
total: { $multiply: ["$price", "$quantity"] }
}
}
])字段重命名的本质是“新建一个计算字段引用旧字段,同时排除旧字段”。例如把createdAt改名为orderDate:
db.orders.aggregate([
{
$project: {
orderId: 1,
orderDate: "$createdAt",
amount: 1,
createdAt: 0
}
}
])条件投影也非常实用,结合$cond可以按文档内容决定输出值。比如根据分数判断是否及格:
db.scores.aggregate([
{
$project: {
name: 1,
level: {
$cond: {
if: { $gte: ["$score", 60] },
then: "及格",
else: "不及格"
}
}
}
}
])另一个容易被忽视的技巧是排除模式下的计算字段是允许的。当整个$project以排除为主(大多数字段值为0)时,可以额外添加计算字段,这样既保留了原文档的其余内容,又附加了新的派生值,在字段很多的宽表场景下比逐个写保留字段省事得多。
实战组合、常见报错与性能考量
在实际管道中,$project通常与$match、$group、$sort配合使用。一个重要的优化原则是:尽早用$match缩小数据量,然后紧跟一个$project裁剪字段,让后续阶段的处理对象尽可能小。尤其在使用$group或$lookup之前先做投影,能显著降低内存占用和执行时间。例如统计每个城市的订单总额:
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $project: { city: 1, amount: 1 } },
{
$group: {
_id: "$city",
total: { $sum: "$amount" }
}
},
{ $sort: { total: -1 } }
])常见报错主要有三类。第一类是路径冲突错误,比如同时写a: 1和"b.c": 0且a与b属于不同保留策略时,MongoDB会抛出Cannot do inclusion on field a in exclusion projection之类的错误,解决办法是统一该阶段的模式,或者拆成两个$project阶段。第二类是嵌套写法冲突,不能同时用点号路径和嵌套文档描述同一字段,例如写了profile: {city: 1}就不允许再写profile.age: 0指向同一父级时产生歧义。第三类是表达式字段中引用了不存在的路径,输出中该字段会被省略而不是报错,调试时容易误以为字段丢失,可先用$ifNull设置默认值排查。
性能方面有两点值得强调。其一,$project裁剪字段本身能减少网络传输和内存消耗,但它不会像find的projection那样利用覆盖索引,因为它处于聚合管道中,数据已经从磁盘读出。若想彻底避免读取大字段,应优先考虑在$match阶段利用索引过滤,再配合$project瘦身。其二,对于超大文档(接近16MB上限),尽早投影可以避免后续$unwind等阶段产生文档膨胀导致超限错误。
总结一下,$project的字段控制遵循“保留与排除二选一,_id除外”的核心规则;嵌套结构用点号路径精细操作,数组用$slice、$filter等表达式裁剪;计算字段让它兼具变形与派生能力。掌握这些规则后,再复杂的管道也能输出干净、精简、符合业务需要的数据结构。
MongoDB聚合管道$project字段过滤文档字段排除修改时间:2026-08-31 00:27:26