聚合操作
查询适合取出已有文档,聚合(Aggregation)则适合把多条文档转换为新的结果集。本页使用 taskdb.tasks 演示聚合管道:每个阶段接收上一个阶段的结果,并将处理后的文档传给下一个阶段。
前置条件
请先完成增删改查中的准备步骤,并确保 tasks 集合至少包含集合操作中的三条示例任务。为了让统计结果稳定,本页的查询只依赖这三条任务;如果你修改过数据,可以重新执行种子数据。
按状态统计任务数量
先筛选任务,再按 status 分组计数:
db.tasks.aggregate([
{ $match: {} },
{
$group: {
_id: "$status",
count: { $sum: 1 }
}
},
{ $sort: { _id: 1 } }
])预期结果类似:
{ _id: 'doing', count: 1 }
{ _id: 'done', count: 1 }
{ _id: 'todo', count: 1 }$group 的 _id 是分组键,$sum: 1 为每个文档贡献一个计数。$sort: { _id: 1 } 按分组值升序排列,字符串默认按二进制顺序比较,因此 doing 排在 done 前面。将 $match 换成 { $match: { priority: { $lte: 2 } } },就可以只统计高优先级任务。
计算总工时
下面的管道计算所有任务的预估工时,并把结果字段改成更容易阅读的名称:
db.tasks.aggregate([
{
$group: {
_id: null,
taskCount: { $sum: 1 },
totalHours: { $sum: "$estimateHours" },
averageHours: { $avg: "$estimateHours" }
}
},
{
$project: {
_id: 0,
taskCount: 1,
totalHours: 1,
averageHours: { $round: ["$averageHours", 1] }
}
}
])$project 可以保留、删除或计算字段。这里用 $round 将平均工时保留一位小数;它只改变输出,不会修改集合中的原始文档。
按负责人统计
任务文档只保存 assigneeId。先分组得到统计结果,再用 $lookup 关联 users 集合,显示负责人的姓名:
db.tasks.aggregate([
{
$group: {
_id: "$assigneeId",
taskCount: { $sum: 1 },
totalHours: { $sum: "$estimateHours" }
}
},
{
$lookup: {
from: "users",
localField: "_id",
foreignField: "_id",
as: "assignee"
}
},
{ $unwind: "$assignee" },
{
$project: {
_id: 0,
assigneeId: "$_id",
name: "$assignee.name",
taskCount: 1,
totalHours: 1
}
},
{ $sort: { totalHours: -1 } }
])$lookup 要求 localField 与 foreignField 的值能够精确等值匹配,类型不一致时不会自动转换,相关排查方法见本章末尾的“常见错误”。
按标签统计
一个任务可以包含多个标签。使用 $unwind 将数组拆成多条中间文档,再分组计数:
db.tasks.aggregate([
{ $unwind: "$tags" },
{
$group: {
_id: "$tags",
taskCount: { $sum: 1 },
totalHours: { $sum: "$estimateHours" }
}
},
{
$project: {
_id: 0,
tag: "$_id",
taskCount: 1,
totalHours: 1
}
},
{ $sort: { taskCount: -1, tag: 1 } }
])如果某条任务没有 tags 字段,默认情况下它不会产生任何输出行。需要保留空数组或缺失字段时,可以将 $unwind 改成:
{
$unwind: {
path: "$tags",
preserveNullAndEmptyArrays: true
}
}调试聚合管道
不要一开始就写很长的管道。先运行前两个阶段,检查中间文档的字段形状:
db.tasks.aggregate([
{ $match: { status: { $ne: "done" } } },
{ $project: { _id: 0, title: 1, status: 1, tags: 1 } }
])确认字段名称和类型正确后,再追加 $unwind、$group 或 $lookup。阶段顺序会影响结果:例如在 $unwind 前后执行 $group,得到的计数含义不同。
验证查询计划
聚合管道的首个 $match 可以利用匹配字段上的索引。先创建一个本地示例索引:
db.tasks.createIndex({ status: 1, assigneeId: 1 })查看查询计划:
db.tasks.explain("executionStats").aggregate([
{ $match: { status: "doing", assigneeId: "u002" } },
{ $project: { _id: 0, title: 1 } }
])这里的重点是学会使用 explain 观察计划,而不是为每个字段盲目创建索引。索引设计应结合真实查询、数据规模和写入成本。
常见错误
现象:
$lookup之后assignee数组为空,$unwind使整行结果消失。 原因:关联字段类型不一致,例如tasks.assigneeId是字符串而users._id是ObjectId,等值匹配失败。 修复:让两个集合的关联字段使用相同类型;插入数据前先确认 ID 约定。现象:
$group之后,原本文档里的字段“消失”了。 原因:$group输出的文档只包含_id和阶段内显式定义的字段。 修复:需要携带其他字段时,在$group中补充累加器,例如$first取第一个值、$push收集明细。现象:某条任务没有
tags字段或为空数组,标签统计的结果条数对不上。 原因:$unwind默认会丢弃没有数组元素可拆分的文档。 修复:按“按标签统计”一节的写法加上preserveNullAndEmptyArrays: true,并在业务上决定这类文档的分组键。现象:聚合随数据量增长明显变慢。 原因:大量文档进入后面的阶段,前面的
$match没有索引可用。 修复:把过滤和缩减文档数的阶段放在管道最前面,并用explain检查是否使用了索引。
小结
- 聚合管道由阶段组成,
$match、$group、$sort、$project、$unwind和$lookup覆盖大多数统计场景。 $group的_id决定分组键,_id: null表示对全部文档做整体聚合。- 阶段顺序会改变结果含义;先运行短管道检查中间文档,再逐步追加阶段。
- 管道开头的
$match尽量写在有索引的字段上,并用explain("executionStats")验证。
练习
- 修改“按负责人统计”的管道,只统计未完成(
status不为done)的任务,并保持按总工时降序排列。 - 在状态统计管道的
$sort后追加一个$limit: 1阶段,让结果只显示任务数量最多的状态,然后换一组数据验证是否仍然正确。
下一步:进入 Python API,将相同的 CRUD 和聚合操作放进 Python 程序。