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"
// }}
// 这种方式会把商品的所有评论全拉回来,数百条 → 浪费内存和网络

可能遇到的坑

  • $lookupfrom 字段是集合名(区分大小写),不是变量名。
  • 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 适用场景

进阶聚合管道适用

  1. 商品详情看板——一次请求返回销量、评分、库存、标签、价格带。
  2. 用户画像总览——用户基本信息 + 最近订单 + 消费统计 + 偏好标签。
  3. 门店运营大屏——实时 GMV、订单数、热销类目、库存预警。
  4. 评论分析——评分分布、高频词标签、好评率、差评聚类。
  5. 电商冷热数据分析——按价格区间、类目、时间维度的交叉钻取。

不适用场景

  1. 需要分页的关联查询——$lookup 后做 $skip + $limit 仍需扫描全部关联结果。
  2. 超大数据集的多级 $lookup(> 3 层)——性能急剧下降,应改用宽表设计。

4.3 注意事项

注意事项 说明
$lookup 的 from 指向分片集合 分片集群中跨分片 $lookup 性能较差,避免在分片键不同的集合间做关联
$unwind + preserveNullAndEmptyArrays 保留空数组会导致 null 行出现,后续分组统计要注意
$facet 内部管道共享前面的阶段 $facet 只并行执行其内部子管道,前面的 $match 等阶段是共享的,按序执行
$bucketboundaries 必须严格递增且至少两个值,边界值类型必须与 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 思考题

  1. 如果要实现"查找近 7 天 GMV 增长最快的 10 个类目",聚合管道应该怎么设计?(提示:需要比较两个时间窗口的数据)
  2. $lookuppipeline 版本(5.0+)与传统的 localField/foreignField 写法相比,有哪些不可替代的优势?请列举至少三个。

(答案将在第 11 章末尾揭晓)


上一章思考题答案

  1. 环比/同比计算单次聚合管道无法完成——因为需要同时比较两个不同时间窗口的数据(本周 vs 上周,本月 vs 去年同月)。常见替代方案:① 应用层发起两次聚合查询(本次和对比期),在内存中计算增长率;② 预计算模式——每天定时任务(或 Change Streams)把当日值写入"统计快照集合",查询时一行记录包含当日值和历史对比值;③ 窗口函数($setWindowFields,MongoDB 5.0+)可计算移动平均、差值等,但无法跨时间区间直接算同比增长率。

  2. $group 阶段,累加器表达式之间不能相互引用——因为它们是并行求值的,不存在先后顺序。b: { $multiply: ["$a", 2] } 中的 $a 会被当作字段路径 $a 而非累加器结果。如果需要在分组后做跨字段计算,必须在 $group 之后的 $project$addFields 阶段进行。

延伸阅读与资源

MongoDB 实战进阶与内核修炼
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战

大型语言模型(LLM) vLLM 高性能推理落地实战

Agent开发之LlamaIndex 实战修炼与源码进阶

大语言模型Transformers 实战修炼与源码剖析

posted on 2026-07-23 09:59  一天不进步,就是退步  阅读(2)  评论(0)    收藏  举报