1. 项目背景
业务场景:运营总监已经不满足于简单的 GMV 统计了。新需求是做一个"商品详情看板"——一次查询就要看到商品的销量趋势图、评分分布直方图、热门评论标签词云、库存预警状态、同类商品的价格带分布。开发一看需求文档,心里默算:这至少需要 5 个独立的 SELECT + GROUP BY + 若干应用层拼装。"一次查询返回这么多维度的数据?不可能!" 而 MongoDB 的 $unwind、$lookup、$facet、$bucket 等聚合进阶能力,正好可以用一个聚合管道完成这些需求。
痛点:不会用聚合进阶阶段的团队,通常拆分成多次数据库查询,增加了网络往返和事务复杂度;$lookup 用不好导致相当于全量数据的草率 JOIN,性能比 MySQL 还差;$unwind 展开大数组时,文档数量爆炸(1000 个文档 × 每个 100 个元素 = 10 万条记录),内存直接被打爆;不知道 $facet 可以并行执行多个聚合管道,白白跑多次全量查询;评分分布(0-1 分、1-2 分……)不得不在应用层手动分桶。
2. 项目设计
小胖(满头大汗):大师救命!运营要我一个接口返回 5 种分析数据——销量趋势、评分分布、热门标签、库存预警、同类对比。我用 for 循环查了 5 次数据库,接口 P99 延迟已经到 8 秒了!
大师:5 次全量查询写成一次就够了。MongoDB 有几个聚合进阶阶段专门解决这类"一个请求多种统计"的场景。核心武器是 $facet——它可以在一个管道里并执行多个统计分支,每个分支独立计算,结果合并返回。
小胖:并行?是不是就像超市的收银台——原来只有 1 个通道,排长队;现在开了 5 个通道,每个负责一种商品类型,同时结账?
大师:精准!$facet 就是 MongoDB 的"多通道并行收银台"。每个子管道独立运行,互不干扰,最终汇总到一个结果里。
技术映射:$facet 内部有多个子管道(每个子管道都是一个阶段数组),MongoDB 优化器会尽可能并行执行它们。但注意:$facet 之前的阶段是共享的,之后才是并行的。
小白:那 $lookup 呢?之前我一直听说 MongoDB 没有 JOIN,现在怎么又有了?
大师:$lookup 是 MongoDB 3.2 引入的,可以理解为一个"受限的 LEFT OUTER JOIN"。它在聚合管道中从另一个集合拉数据,用本地字段匹配外键。但它和 SQL JOIN 的本质区别在于:SQL 的 JOIN 在查询计划的任意位置都可以发生;$lookup 作为管道的一个阶段,输入是上一个阶段的结果,输出是拼接后的结果。
小胖:那 $unwind 是干嘛的?我经常在同事的代码里看到。
大师:$unwind 是"数组展开器"——把数组字段的每个元素拆成独立的文档。比如一个订单有 3 个商品明细 items,$unwind: "$items" 后,这个订单就变成 3 个文档,每个文档的 items 字段变成了单个对象。
技术映射:$unwind = 1 变 N。如果一个集合有 1000 个文档,每个文档的数组平均有 10 个元素,$unwind 后变成 10000 个文档。使用前务必用 $match 削减文档数。
小白(警觉):那如果有些文档的数组是空数组呢?$unwind 会直接把这条记录删掉?
大师:对,默认行为是丢弃空数组和不存在该字段的文档。如果你需要保留这些记录,用参数:{ $unwind: { path: "$items", preserveNullAndEmptyArrays: true } }。
小胖:还有评分分布!我需要统计 0-1 分、1-2 分、2-3 分……的用户数量,MongoDB 能做吗?
大师:$bucket 和 $bucketAuto 就是干这个的。$bucket 是你自己定义区间边界,$bucketAuto 让 MongoDB 自动划分区间。
技术映射:
// $bucket 手动分桶
{ $bucket: {
groupBy: "$rating",
boundaries: [0, 1, 2, 3, 4, 5], // [0,1), [1,2), [2,3), [3,4), [4,5]
default: "未知",
output: { count: { $sum: 1 } }
}}
小胖:这些组合起来威力巨大啊!快带我们实战吧!
大师:走。记住今天的口诀——$facet 并行多问、$unwind 拆数组、$lookup 跨集合关联、$bucket 区间分桶。四个合在一起,就是数据看板的终极武器。
3. 项目实战
3.1 环境准备
延续第 9 章的 sales_agg 数据,额外创建评论集合:
docker compose -f mongodb-lab/docker-compose.yml ps
3.2 分步实现
步骤一:构造商品、评论关联数据
目标:为商品、评论、用户建立关联集合,模拟 $lookup 场景。
use local_life
// 商品详情集合
db.product_detail.drop()
const productIds = []
for (let i = 1; i <= 30; i++) {
const pid = new ObjectId()
productIds.push(pid)
db.product_detail.insertOne({
_id: pid,
name: `进阶商品_${String(i).padStart(3, '0')}`,
category: ["数码影音","手机配件","家居生活","美妆个护","食品饮料"][i % 5],
brand: ["华为","小米","苹果","三星","OPPO"][i % 5],
price: NumberDecimal((Math.random() * 2000 + 50).toFixed(2)),
stock: Math.floor(Math.random() * 500),
rating: parseFloat((Math.random() * 5).toFixed(1)),
sales: Math.floor(Math.random() * 20000),
tags: [["新品","热销","限时","包邮"][i % 4], ["推荐","秒杀","清仓"][i % 3]].filter(Boolean),
status: i % 6 === 0 ? "下架" : "在售",
createdAt: new Date(Date.now() - i * 7 * 24 * 3600 * 1000)
})
}
// 评论集合
db.product_reviews.drop()
const reviewTexts = [
"质量很好,物流很快", "性价比高,值得购买", "一般般,能用", "不太满意",
"客服态度差", "包装精美", "物流太慢了", "跟描述不符", "好评!推荐",
"第二次购买了", "颜色好看", "手感不错", "功能强大", "有点贵",
"朋友推荐的,确实不错", "差评!大家别买", "还行吧", "超级喜欢",
"不好用", "完美", "尺寸合适", "有点小", "做工精细", "声音很响",
"外观漂亮", "给妈妈买的,很喜欢", "后悔买了", "快递真快"
]
const reviewTags = ["物流快", "质量好", "性价比高", "包装差", "颜色正", "手感好"]
// 为每个商品生成 3-30 条评论
for (const pid of productIds) {
const reviewCount = Math.floor(Math.random() * 28) + 3
const reviews = []
for (let j = 0; j < reviewCount; j++) {
const rating = parseFloat((Math.random() * 5).toFixed(1))
reviews.push({
productId: pid,
userId: "USER_" + Math.floor(Math.random() * 200),
rating: rating,
text: reviewTexts[Math.floor(Math.random() * reviewTexts.length)],
tags: [
reviewTags[Math.floor(Math.random() * reviewTags.length)],
reviewTags[Math.floor(Math.random() * reviewTags.length)]
],
helpfulCount: Math.floor(Math.random() * 100),
createdAt: new Date(Date.now() - Math.floor(Math.random() * 60) * 24 * 3600 * 1000)
})
}
db.product_reviews.insertMany(reviews, { ordered: false })
}
print(`商品: ${productIds.length} 个, 评论: ${db.product_reviews.countDocuments()} 条`)
// 创建索引
db.product_reviews.createIndex({ productId: 1 })
db.product_reviews.createIndex({ productId: 1, rating: -1 })
db.product_reviews.createIndex({ productId: 1, createdAt: -1 })
步骤二:$unwind——数组展开与商品标签统计
目标:展开 tags 数组,统计每个标签下有多少商品。
// $unwind 展开标签数组
const tagDistribution = db.product_detail.aggregate([
{ $match: { status: "在售" } },
{ $unwind: "$tags" }, // 1 个文档有 2 个 tags → 变成 2 个文档
{
$group: {
_id: "$tags", // 按标签分组
count: { $sum: 1 },
avgPrice: { $avg: "$price" }
}
},
{ $sort: { count: -1 } }
]).toArray()
print("=== 商品标签分布 ===")
tagDistribution.forEach(t => {
print(` [${t._id}] ${t.count} 个商品, 均价 ¥${Number(t.avgPrice).toFixed(0)}`)
})
// 注意:$unwind 后对空 tags 数组的商品会被丢弃
// 需要保留空数组时:
const tagWithEmpty = db.product_detail.aggregate([
{
$unwind: {
path: "$tags",
preserveNullAndEmptyArrays: true // 空数组/无 tags 字段的保留
}
},
{
$group: {
_id: { $ifNull: ["$tags", "无标签"] },
count: { $sum: 1 }
}
}
]).toArray()
print("\n含空标签的商品:", tagWithEmpty.find(t => t._id === "无标签")?.count || 0)
步骤三:$lookup——评论关联查询与商品评分
目标:查看商品详情时,带出该商品的最新 5 条评论。
// $lookup 关联评论集合
const productWithReviews = db.product_detail.aggregate([
{ $match: { status: "在售" } },
{ $limit: 3 },
{
$lookup: {
from: "product_reviews",
localField: "_id",
foreignField: "productId",
// pipeline 版本:对关联的评论做过滤、排序、裁剪
pipeline: [
{ $sort: { createdAt: -1 } },
{ $limit: 5 },
{ $project: { productId: 0 } } // 不返回外键
],
as: "recentReviews"
}
},
{
$project: {
name: 1,
rating: 1,
price: 1,
reviewCount: { $size: "$recentReviews" },
recentReviews: 1
}
}
]).toArray()
print("=== 商品 + 最近 5 条评论 ($lookup) ===")
productWithReviews.forEach(p => {
print(`\n商品: ${p.name} | 评分: ${p.rating} | ¥${p.price}`)
print(`最近评论 (${p.reviewCount}):`)
p.recentReviews.forEach(r => {
print(` [${r.rating}分] ${r.text} | ${r.tags} | ${r.helpfulCount}人觉得有用`)
})
})
// 传统 $lookup 写法(不含 pipeline,无法对子结果过滤):
// { $lookup: {
// from: "product_reviews",
// localField: "_id",
// foreignField: "productId",
// as: "allReviews"
// }}
// 这种方式会把商品的所有评论全拉回来,数百条 → 浪费内存和网络
可能遇到的坑:
$lookup的from字段是集合名(区分大小写),不是变量名。pipeline版本的$lookup(MongoDB 5.0+)支持过滤、排序和裁剪,强烈推荐。$lookup不会使用索引优化关联条件——在外键字段(productId)上建索引可加速匹配。
步骤四:$bucket——评分区间分布
目标:统计商品评分在各区间的分布。
// $bucket 手动分桶:评分分布
const ratingDistribution = db.product_reviews.aggregate([
{
$bucket: {
groupBy: "$rating",
boundaries: [0, 1, 2, 3, 4, 5], // 区间:左闭右开 [0,1) [1,2) ...
default: "其他",
output: {
count: { $sum: 1 },
avgHelpful: { $avg: "$helpfulCount" } // 每组平均"有用"数
}
}
}
]).toArray()
print("=== 评分分布 ===")
ratingDistribution.forEach(b => {
print(` [${b._id}] ${b.count} 条 (均有用数: ${b.avgHelpful.toFixed(1)})`)
})
// $bucketAuto 自动分桶:按商品销售额均匀分 4 组
const salesBuckets = db.product_detail.aggregate([
{ $match: { status: "在售" } },
{
$bucketAuto: {
groupBy: "$sales",
buckets: 4, // 自动分成 4 组
output: {
count: { $sum: 1 },
minSales: { $min: "$sales" },
maxSales: { $max: "$sales" },
names: { $push: "$name" }
}
}
}
]).toArray()
print("\n=== 商品销量自动分 4 组 ===")
salesBuckets.forEach(b => {
print(` [${b.minSales}-${b.maxSales}] ${b.count} 个商品: ${b.names.slice(0,3).join(",")}...`)
})
步骤五:$facet——数据看板,一次查询多组统计
目标:用 $facet 在一个请求中返回销量趋势、评分分布、热门评论标签、库存预警。
// $facet 数据看板——一次查询,多组统计同时输出
const now = new Date()
const sevenDaysAgo = new Date(now - 7 * 24 * 3600 * 1000)
const dashboard = db.product_detail.aggregate([
{ $match: { status: "在售" } },
{
$facet: {
// 统计1:按类目的商品数和平均价格
categoryStats: [
{
$group: {
_id: "$category",
productCount: { $sum: 1 },
avgPrice: { $avg: "$price" },
avgRating: { $avg: "$rating" }
}
},
{ $sort: { productCount: -1 } }
],
// 统计2:库存预警(库存 < 50)
lowStock: [
{ $match: { stock: { $lt: 50 } } },
{
$project: {
_id: 0, name: 1, category: 1,
stock: 1, sales: 1
}
},
{ $sort: { stock: 1 } }
],
// 统计3:价格带分布
priceDistribution: [
{
$bucket: {
groupBy: { $toDouble: "$price" },
boundaries: [0, 100, 300, 500, 1000, 5000],
default: "其他",
output: { count: { $sum: 1 } }
}
}
],
// 统计4:热门标签 Top 5
topTags: [
{ $unwind: "$tags" },
{ $group: { _id: "$tags", count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 5 }
],
// 统计5:评分分布
ratingDist: [
{
$bucket: {
groupBy: "$rating",
boundaries: [0, 1, 2, 3, 4, 5],
default: "其他",
output: { count: { $sum: 1 } }
}
}
]
}
}
]).toArray()
// 输出看板结果
print("╔════════════════ 商品数据看板 ════════════════╗")
print("║ 类目统计:")
dashboard[0].categoryStats.forEach(c => {
print(`║ ${c._id}: ${c.productCount}个, 均价¥${Number(c.avgPrice).toFixed(0)}, 均分${c.avgRating.toFixed(1)}`)
})
print("║ 库存预警 (stock < 50):")
dashboard[0].lowStock.slice(0, 3).forEach(p => {
print(`║ ⚠ ${p.name} | ${p.category} | 库存仅${p.stock}`)
})
if (dashboard[0].lowStock.length > 3) print(`║ ... 共 ${dashboard[0].lowStock.length} 个预警`)
print("║ 价格分布:")
dashboard[0].priceDistribution.forEach(b => {
print(`║ ${b._id}: ${b.count} 个商品`)
})
print("║ 热门标签:")
dashboard[0].topTags.forEach(t => {
print(`║ #${t._id}: ${t.count} 个商品`)
})
print("║ 评分分布:")
dashboard[0].ratingDist.forEach(b => {
print(`║ [${b._id}]: ${b.count} 个商品 (共 ${db.product_detail.countDocuments()} 个)`)
})
print("╚══════════════════════════════════════════════╝")
步骤六:$dateTrunc 处理时间维度
目标:按周、月对销售数据做聚合。
// 使用 $dateTrunc 按周分组(MongoDB 5.0+)
const weeklyReport = db.sales_agg.aggregate([
{ $match: { isPaid: true } },
{
$group: {
_id: {
$dateTrunc: {
date: "$createdAt",
unit: "week", // 按周截断
startOfWeek: "mon" // 周一起始
}
},
weeklyGMV: { $sum: "$amount" },
weeklyOrders: { $sum: 1 },
weeklyAvg: { $avg: "$amount" }
}
},
{ $sort: { _id: -1 } },
{ $limit: 8 }
]).toArray()
print("\n=== 近 8 周周报 ($dateTrunc) ===")
weeklyReport.forEach(w => {
print(` ${w._id.toISOString().slice(0,10)} | GMV:¥${Number(w.weeklyGMV).toFixed(0)} | ` +
`订单:${w.weeklyOrders} | 均价:¥${Number(w.weeklyAvg).toFixed(2)}`)
})
// $dateTrunc 也支持 unit: "month"、"day"、"hour"、"minute"
3.3 完整代码清单
| 文件 | 用途 |
|---|---|
mongodb-lab/scripts/ch10-create-review-data.js |
评论关联数据生成 |
mongodb-lab/scripts/ch10-unwind-demo.js |
$unwind 数组展开演示 |
mongodb-lab/scripts/ch10-lookup-demo.js |
$lookup 关联查询演示 |
mongodb-lab/scripts/ch10-bucket-demo.js |
$bucket 分桶统计演示 |
mongodb-lab/scripts/ch10-facet-dashboard.js |
$facet 数据看板 |
mongodb-lab/scripts/ch10-datetrunc-demo.js |
$dateTrunc 时间分组 |
3.4 测试验证
use local_life
// 1. 验证 $unwind 的数量 = 所有文档的 tags 元素总数
const totalDocs = db.product_detail.countDocuments({ status: "在售" })
const tagElements = db.product_detail.aggregate([
{ $match: { status: "在售" } },
{ $project: { tagCount: { $size: { $ifNull: ["$tags", []] } } } },
{ $group: { _id: null, total: { $sum: "$tagCount" } } }
]).toArray()
const unwoundCount = db.product_detail.aggregate([
{ $match: { status: "在售" } },
{ $unwind: "$tags" },
{ $count: "total" }
]).toArray()
print(`原始文档:${totalDocs}, tags 元素总数:${tagElements[0].total}, unwind 后:${unwoundCount[0].total}`,
tagElements[0].total === unwoundCount[0].total ? "PASS" : "FAIL")
// 2. 验证 $lookup 结果正确
const sampleProduct = db.product_detail.findOne({ status: "在售" })
const joined = db.product_detail.aggregate([
{ $match: { _id: sampleProduct._id } },
{ $lookup: {
from: "product_reviews",
localField: "_id",
foreignField: "productId",
as: "reviews"
}}
]).toArray()
const reviewCount = db.product_reviews.countDocuments({ productId: sampleProduct._id })
print(`\$lookup 评论数:${joined[0].reviews.length}, 实际评论数:${reviewCount}`,
joined[0].reviews.length === reviewCount ? "PASS" : "FAIL")
// 3. 验证 $bucket 全覆盖(无数据丢失)
const ratedCount = db.product_reviews.countDocuments()
const bucketedCount = db.product_reviews.aggregate([
{ $bucket: {
groupBy: "$rating",
boundaries: [0,1,2,3,4,5],
default: "其他",
output: { count: { $sum: 1 } }
}}
]).toArray()
const sumBuckets = bucketedCount.reduce((s, b) => s + b.count, 0)
print(`原数据:${ratedCount}, 分桶总计:${sumBuckets}`,
ratedCount === sumBuckets ? "PASS" : "FAIL")
// 4. 验证 $facet 多维度并行
const facetResult = db.product_detail.aggregate([
{ $facet: {
a: [{ $count: "n" }],
b: [{ $count: "n" }]
}}
]).toArray()
print("$facet 双通道:", JSON.stringify(facetResult))
print("\n=== 全部验证通过 ===")
4. 项目总结
4.1 进阶聚合阶段对比
| 阶段 | 功能 | 性能风险 | 优化建议 |
|---|---|---|---|
$unwind |
数组展开 | 文档数量爆炸 | 前置 $match 削减文档数 |
$lookup |
跨集合关联 | 无索引全表扫描 | 外键字段必建索引 |
$facet |
并行多统计 | 每个子管道独立消耗资源 | 控制子管道数量,避免超过 5-8 个 |
$bucket |
手动区间分组 | 区间边界定义不当 | 提前了解数据分布,边界值能覆盖 [min, max) |
$bucketAuto |
自动区间分组 | 需全量排序再分区 | 数据量 < 100 万时可用 |
$dateTrunc |
时间维度截断 | 无额外开销 | 直接替代复杂的时间格式化 |
4.2 适用场景
进阶聚合管道适用:
- 商品详情看板——一次请求返回销量、评分、库存、标签、价格带。
- 用户画像总览——用户基本信息 + 最近订单 + 消费统计 + 偏好标签。
- 门店运营大屏——实时 GMV、订单数、热销类目、库存预警。
- 评论分析——评分分布、高频词标签、好评率、差评聚类。
- 电商冷热数据分析——按价格区间、类目、时间维度的交叉钻取。
不适用场景:
- 需要分页的关联查询——
$lookup后做$skip + $limit仍需扫描全部关联结果。 - 超大数据集的多级
$lookup(> 3 层)——性能急剧下降,应改用宽表设计。
4.3 注意事项
| 注意事项 | 说明 |
|---|---|
$lookup 的 from 指向分片集合 |
分片集群中跨分片 $lookup 性能较差,避免在分片键不同的集合间做关联 |
$unwind + preserveNullAndEmptyArrays |
保留空数组会导致 null 行出现,后续分组统计要注意 |
$facet 内部管道共享前面的阶段 |
$facet 只并行执行其内部子管道,前面的 $match 等阶段是共享的,按序执行 |
$bucket 的 boundaries |
必须严格递增且至少两个值,边界值类型必须与 groupBy 字段一致 |
$dateTrunc 需要 MongoDB 5.0+ |
低版本可用 $dateToString 替代,但性能稍差 |
4.4 常见踩坑经验
故障案例一:$unwind 数组过大导致 OOM
某社交分析系统的帖子集合,每个帖子的 likedUserIds 数组平均 2000 个元素。$unwind 展开时,1 万个帖子变成 2000 万条中间记录,直接打爆内存。根因:没有先 $match 过滤,也没有设置 allowDiskUse。解决:先按帖子创建时间过滤,只保留近 7 天的数据(3000 帖 → 600 万条),再到应用层统计高频点赞用户。
故障案例二:$lookup 无索引导致 30 秒查询
某订单详情页用 $lookup 关联用户表获取用户昵称,用户表 100 万条,订单表 500 万条。$lookup 的外键 userId 在订单表中没有索引——MongoDB 对每个订单都在用户表中做全表扫描查找匹配的 userId。100 个订单的详情页查询耗时 30 秒。根因:忽略了 $lookup 的外表也需要索引。解决:在 orders 表的 userId 字段上建索引。
故障案例三:$facet 与 $limit 组合的错误预期
某报表希望"在售商品按类目统计 Top 3,同时统计剩余的全部"。在 $facet 的一个子管道中写了 { $limit: 3 },预期只影响该子管道。运行正常但数据不对——另一个子管道的计数也被影响了。根因:开发把 $limit 放在了 $facet 之前(共享阶段),而非 $facet 的子管道内。$facet 按顺序先执行前置阶段,再并行执行子管道。解决:把 $limit 移入对应的子管道中。
4.5 思考题
- 如果要实现"查找近 7 天 GMV 增长最快的 10 个类目",聚合管道应该怎么设计?(提示:需要比较两个时间窗口的数据)
$lookup的pipeline版本(5.0+)与传统的localField/foreignField写法相比,有哪些不可替代的优势?请列举至少三个。
(答案将在第 11 章末尾揭晓)
上一章思考题答案:
环比/同比计算单次聚合管道无法完成——因为需要同时比较两个不同时间窗口的数据(本周 vs 上周,本月 vs 去年同月)。常见替代方案:① 应用层发起两次聚合查询(本次和对比期),在内存中计算增长率;② 预计算模式——每天定时任务(或 Change Streams)把当日值写入"统计快照集合",查询时一行记录包含当日值和历史对比值;③ 窗口函数(
$setWindowFields,MongoDB 5.0+)可计算移动平均、差值等,但无法跨时间区间直接算同比增长率。在
$group阶段,累加器表达式之间不能相互引用——因为它们是并行求值的,不存在先后顺序。b: { $multiply: ["$a", 2] }中的$a会被当作字段路径$a而非累加器结果。如果需要在分组后做跨字段计算,必须在$group之后的$project或$addFields阶段进行。
延伸阅读与资源
MongoDB 实战进阶与内核修炼
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号