1. 项目背景

业务场景:本地生活电商的运营总监在周一晨会上拍桌子:"我要看每天的总成交额、订单数、客单价、按类目拆分的销售排行,还要看每个门店的业绩排名。现在开发给我的报表是 Python 脚本全量查询 MongoDB、在内存里 for 循环统计,跑一次 40 分钟,电脑风扇呼呼转。我周一早上 8 点要的数据,中午 12 点才给我!" 开发也很委屈:"MongoDB 没有 GROUP BY,也没有 SUM、AVG,我不用代码循环能怎么办?" 事实是,MongoDB 有强大的聚合管道(Aggregation Pipeline),一条命令能完成 SQL 中 GROUP BY + HAVING + ORDER BY + LIMIT 的全部工作,根本不需要应用层循环。

痛点:不会用聚合管道的团队,大量数据不得不拉到应用层做统计,网络传输压力大、内存占用高、CPU 浪费严重;应用层代码实现复杂统计逻辑时容易出错——忘了处理 null、边界值、空数组;需要多组统计时不得不多次查询数据库,产生重复 IO;对聚合管道的执行顺序不理解,$match 放 $group 后面导致全量数据参与分组,内存溢出。

2. 项目设计

小胖(急匆匆跑来):大师!运营要我出日报,我写了 200 行 Python 代码结果还是慢。同事问 MongoDB 有没有类似 GROUP BY 的东西?

大师:有,叫 Aggregation Pipeline——聚合管道。你可以把它理解成一条"数据处理流水线"。数据从一端流进去,经过一道道工序(阶段),变成你想要的结果从另一端出来。每个工序叫一个"阶段(Stage)"——$match 是过滤、$group 是分组汇总、$sort 是排序、$project 是字段裁剪。

小胖:流水线?听起来就跟汽车生产线一样——先在车架上喷漆($match),再安装发动机($group),最后贴标签($project)。

大师:完全正确!聚合管道从左到右依次执行,每个阶段的输入是上一个阶段的输出。关键优化点你也提到了——先过滤再分组。如果 $group$match 前面,等于所有数据都参与分组计算,内存直接爆炸。

技术映射:聚合管道(Aggregation Pipeline)是 MongoDB 的数据处理框架,由一系列阶段(Stage)组成。管道顺序直接影响性能——先 $match 削减数据量,再 $group 做聚合,最后 $sort 排序。

小白(好奇):那 $group 里能用的累加器有哪些?有 SUM、AVG、COUNT 吗?

大师:常用的都在了:

累加器 功能 SQL 等价
$sum 求和 / 计数(给 1 就是计数) SUM / COUNT
$avg 平均值 AVG
$max / $min 最大值 / 最小值 MAX / MIN
$first / $last 每组第一个/最后一个值 无精确等价(需窗口函数)
$push 将分组内所有值放入数组 无精确等价
$addToSet 去重的 $push 无精确等价

小胖:那 $matchfind 的过滤条件一样吗?

大师:一模一样。find() 里的所有查询操作符($gt$in$regex 等)在 $match 里面都能用。更重要的是——$match 能用索引!如果你在 $match 的第一个阶段做了过滤而且命中了索引,整个聚合管道只需要处理过滤后的少量数据。

小白:那 $project 能不能少写点?像 SQL 的 SELECT a, b, c 那样。

大师$project 就是 MongoDB 版的 SELECT——你可以选择保留或排除特定字段,还能做字段重命名和计算新字段:

{
  $project: {
    name: 1,            // 保留 name
    price: 1,           // 保留 price
    _id: 0,             // 排除 _id
    discountPrice: { $multiply: ["$price", 0.85] }  // 生成计算字段
  }
}

小胖:听起来很好用!那和 SQL 对比大概是这样的?

大师:映射表:

SQL 子句 聚合管道阶段
SELECT a, b, COUNT(*) $group + $project
FROM orders 无(集合名在 aggregate() 参数中)
WHERE status='done' $match: { status: "done" }
GROUP BY category $group: { _id: "$category" }
HAVING count > 10 $match: { count: { $gt: 10 } }
ORDER BY total DESC $sort: { total: -1 }
LIMIT 10 $limit: 10

小白:我注意到 $group_id 是关键——它等于 GROUP BY 的字段?

大师:精准。$group: { _id: "$category" } 就是按 category 分组。_id 是分组键的固定写法,即使你按多个字段分组,也必须放在 _id 下面——比如按城市和类目双分组:_id: { city: "$city", category: "$category" }

小胖:分组的时候 _id: null 是什么意思?

大师:不分组的全量聚合——相当于 SQL 中 SELECT COUNT(*) FROM table 没有 GROUP BY。_id: null 把所有文档放进同一组。

大师:最后说一个新手最容易栽跟头的坑——管道顺序。很多人写 $group$sort$match,实际上 $match 应该放在最前面。错误的顺序会导致所有数据参与分组、排序,然后再过滤掉大部分结果——浪费大量计算和内存。

技术映射:管道执行顺序严格遵循代码书写顺序。优化原则:$match 最早 → $project 尽早裁剪字段 → $group$sort 最后。MongoDB 优化器会自动尝试把 $match$sort 推到管道最前端(谓词下推),但不能完全依赖它。

3. 项目实战

3.1 环境准备

docker compose -f mongodb-lab/docker-compose.yml ps

3.2 分步实现

步骤一:构造销售数据

目标:生成覆盖 30 天、5 个类目、10 个门店的 50 万条销售记录。

use local_life
db.sales_agg.drop()

const categories = ["数码影音", "手机配件", "家居生活", "美妆个护", "食品饮料"]
const stores = ["深圳南山店", "深圳福田店", "广州天河店", "广州海珠店", "北京朝阳店",
                "上海浦东店", "上海徐汇店", "成都锦江店", "武汉光谷店", "杭州西湖店"]

function generateSales(total = 50000) {
  for (let batch = 0; batch * 1000 < total; batch++) {
    const docs = []
    const batchSize = Math.min(1000, total - batch * 1000)
    for (let i = 0; i < batchSize; i++) {
      const daysAgo = Math.floor(Math.random() * 30)
      docs.push({
        orderNo: "SALE" + String(batch * 1000 + i).padStart(8, '0'),
        category: categories[Math.floor(Math.random() * 5)],
        store: stores[Math.floor(Math.random() * 10)],
        amount: NumberDecimal((Math.random() * 500 + 10).toFixed(2)),
        quantity: Math.floor(Math.random() * 5) + 1,
        customerCity: ["深圳","广州","北京","上海","成都","武汉","杭州"][Math.floor(Math.random() * 7)],
        isPaid: Math.random() > 0.05,        // 95% 已支付
        createdAt: new Date(Date.now() - daysAgo * 24 * 3600 * 1000)
      })
    }
    db.sales_agg.insertMany(docs, { ordered: false })
  }
  print(`插入完成: ${db.sales_agg.countDocuments()} 条`)
}
generateSales(50000)

// 建索引(按时间查询最频繁)
db.sales_agg.createIndex({ createdAt: -1 })
db.sales_agg.createIndex({ store: 1, createdAt: -1 })
db.sales_agg.createIndex({ category: 1, createdAt: -1 })

步骤二:按天统计 GMV、订单数、客单价

目标:用聚合管道实现运营日报的核心指标。

const dailyReport = db.sales_agg.aggregate([
  // 阶段1:过滤已支付订单
  { $match: { isPaid: true } },

  // 阶段2:按日期分组
  {
    $group: {
      _id: {
        $dateToString: { format: "%Y-%m-%d", date: "$createdAt" }
      },
      totalGMV: { $sum: "$amount" },           // 日 GMV
      orderCount: { $sum: 1 },                  // 日订单数
      totalQuantity: { $sum: "$quantity" },      // 日销量
      avgOrderAmount: { $avg: "$amount" },       // 平均客单价
      maxOrderAmount: { $max: "$amount" },       // 最大单笔
      minOrderAmount: { $min: "$amount" }        // 最小单笔
    }
  },

  // 阶段3:只保留日均客单价 >= 50 元的日子
  { $match: { avgOrderAmount: { $gte: NumberDecimal("50") } } },

  // 阶段4:按日期排序
  { $sort: { _id: -1 } },

  // 阶段5:限制返回最近 7 天
  { $limit: 7 }
]).toArray()

print("=== 最近 7 天日报(日均客单价 >= 50) ===")
dailyReport.forEach(row => {
  print(`  ${row._id} | GMV:¥${Number(row.totalGMV).toFixed(2)} | ` +
        `订单:${row.orderCount} | 客单价:¥${Number(row.avgOrderAmount).toFixed(2)}`)
})

步骤三:按类目和门店双维度统计

目标:输出"每个门店下每个类目的销售额排名"。

const storeCategoryReport = db.sales_agg.aggregate([
  { $match: { isPaid: true } },
  {
    $group: {
      _id: { store: "$store", category: "$category" },  // 双维度分组
      totalSales: { $sum: "$amount" },
      orderCount: { $sum: 1 },
      avgAmount: { $avg: "$amount" }
    }
  },
  { $sort: { totalSales: -1 } },
  // 字段重命名让输出更清晰
  {
    $project: {
      _id: 0,
      store: "$_id.store",
      category: "$_id.category",
      totalSales: 1,
      orderCount: 1,
      avgAmount: 1
    }
  },
  { $limit: 10 }
]).toArray()

print("\n=== 门店-类目销售排行 Top 10 ===")
storeCategoryReport.forEach(row => {
  print(`  ${row.store} | ${row.category} | ¥${Number(row.totalSales).toFixed(2)} | ${row.orderCount}单`)
})

步骤四:条件计数与分组统计技巧

目标:在同一个 $group 中做多种条件统计。

const multiStats = db.sales_agg.aggregate([
  {
    $group: {
      _id: "$store",
      totalOrders: { $sum: 1 },
      paidOrders: {
        $sum: { $cond: [{ $eq: ["$isPaid", true] }, 1, 0] }  // 已支付订单数
      },
      unpaidOrders: {
        $sum: { $cond: [{ $eq: ["$isPaid", false] }, 1, 0] }  // 未支付订单数
      },
      highValueOrders: {
        $sum: { $cond: [{ $gt: ["$amount", NumberDecimal("300")] }, 1, 0] }  // 高额订单
      },
      totalRevenue: { $sum: "$amount" },
      paidRevenue: {
        $sum: { $cond: [{ $eq: ["$isPaid", true] }, "$amount", NumberDecimal("0")] }
      },
      // 支付率
      paymentRate: {
        $avg: { $cond: [{ $eq: ["$isPaid", true] }, 1, 0] }
      }
    }
  },
  { $sort: { totalRevenue: -1 } }
]).toArray()

print("\n=== 各门店综合统计 ===")
multiStats.forEach(row => {
  const rate = (row.paymentRate * 100).toFixed(1)
  print(`  ${row._id}: 总${row.totalOrders}单 | 已支付${row.paidOrders} | ` +
        `支付率${rate}% | 高额${row.highValueOrders} | 营收¥${Number(row.totalRevenue).toFixed(0)}`)
})

步骤五:管道顺序对性能的影响

目标:用 explain 对比错误顺序和正确顺序的差异。

// ---- 错误顺序:$group → $match(匹配过滤后置) ----
const wrongOrder = db.sales_agg.explain("executionStats").aggregate([
  { $group: { _id: "$store", count: { $sum: 1 } } },
  { $match: { count: { $gt: 100 } } }       // 在 $group 之后才过滤
])
// 注意:$group 处理了全部数据

// ---- 正确顺序:$match → $group ----
// $match 先过滤,减少进入 $group 的数据量
const rightOrder = db.sales_agg.explain("executionStats").aggregate([
  { $match: { isPaid: true, amount: { $gt: NumberDecimal("100") } } },
  { $group: { _id: "$store", count: { $sum: 1 } } },
  { $match: { count: { $gt: 10 } } },
  { $sort: { count: -1 } }
])

print("\n=== 管道顺序性能对比 ===")
print("错误顺序 ($group 在前):",
  "处理文档数:", wrongOrder.stages[0].nReturned || "全量")
print("正确顺序 ($match 在前):", JSON.stringify(rightOrder.executionStats?.executionTimeMillis))

步骤六:allowDiskUse 应对大数据聚合

目标:当 $group 内存不够时,启用磁盘溢写。

// 模拟大数据分组——按 customerCity + category 的笛卡尔积分组
const manyGroups = db.sales_agg.aggregate([
  {
    $group: {
      _id: {
        city: "$customerCity",
        category: "$category",
        hour: { $hour: "$createdAt" }
      },
      total: { $sum: "$amount" },
      count: { $sum: 1 }
    }
  },
  { $sort: { total: -1 } }
], {
  allowDiskUse: true          // 内存不够时写入临时文件(默认 100MB 内存限制)
}).toArray()

print("\n=== 细粒度分组(allowDiskUse) ===")
print("分组数:", manyGroups.length)
manyGroups.slice(0, 5).forEach(g => {
  print(`  ${g._id.city}/${g._id.category}/${g._id.hour}h: ¥${Number(g.total).toFixed(0)} (${g.count}单)`)
})

// 注意:allowDiskUse 会显著变慢(涉及磁盘 IO),仅在必要时使用
// 更好的做法是优化管道,减少分组数量

3.3 完整代码清单

文件 用途
mongodb-lab/scripts/ch09-create-sales.js 生成销售测试数据
mongodb-lab/scripts/ch09-daily-report.js 日报聚合管道
mongodb-lab/scripts/ch09-store-category.js 门店-类目双维度报表

3.4 测试验证

use local_life

// 1. 验证日 GMV 统计正确性
const today = db.sales_agg.aggregate([
  { $match: { createdAt: { $gte: new Date(Date.now() - 24 * 3600 * 1000) }, isPaid: true } },
  { $group: { _id: null, gmv: { $sum: "$amount" }, cnt: { $sum: 1 } } }
]).toArray()
print("过去 24 小时 已支付 GMV:", today[0] ? "¥" + Number(today[0].gmv).toFixed(2) : "无数据")

// 2. 验证分组计数
const storeGroups = db.sales_agg.aggregate([
  { $group: { _id: "$store", cnt: { $sum: 1 } } }
]).toArray()
print("门店数:", storeGroups.length, storeGroups.every(g => g.cnt > 0) ? "PASS" : "FAIL")

// 3. 验证 $cond 条件统计
const condCheck = db.sales_agg.aggregate([
  {
    $group: {
      _id: null,
      total: { $sum: 1 },
      paid: { $sum: { $cond: ["$isPaid", 1, 0] } },
      unpaid: { $sum: { $cond: [{ $not: "$isPaid" }, 1, 0] } }
    }
  }
]).toArray()
print("条件统计: 总${condCheck[0].total}=已付${condCheck[0].paid}+未付${condCheck[0].unpaid}",
      condCheck[0].total === condCheck[0].paid + condCheck[0].unpaid ? "PASS" : "FAIL")

// 4. 清理
// db.sales_agg.drop()

4. 项目总结

4.1 聚合管道 vs SQL vs 应用层循环

维度 聚合管道 SQL (MySQL) 应用层循环
数据位置 数据库内计算 数据库内计算 数据传输到应用层
网络开销 仅返回结果 仅返回结果 全量数据传输
内存占用 数据库管理 数据库管理 应用服务器承担
开发复杂度 中(JSON 嵌套) 低(声明式) 高(手写循环+边界)
索引利用 $match$sort 可用索引 WHERE、ORDER BY 可用索引 全凭 SQL 前置
可扩展性 好(分片集群可并行) 差(单机瓶颈)

4.2 适用场景

聚合管道适用

  1. 运营日报/周报/月报——按时间维度+多维度分组统计。
  2. 数据看板——按门店、类目、区域的多维交叉分析。
  3. 用户行为分析——按用户分组统计访问、下单、支付漏斗。
  4. ETL 数据处理——清洗、转换、聚合后写入下游系统。
  5. 简单实时统计——在线接口中做轻量级聚合(如商品评价均分)。

不适用场景

  1. 机器学习特征工程——复杂的滑动窗口、序列计算,建议用 Spark/Pandas。
  2. 图计算(如社交关系链分析)——图数据库更擅长。

4.3 注意事项

注意事项 说明
$group 内存限制 100MB 单次 $group 的所有分组结果必须在 100MB 以内,超出用 allowDiskUse
$match 尽早出现 聚合优化器会尝试下推 $match$sort,但不能 100% 依赖
$project 裁剪字段 尽早剪掉不需要的字段,减少后续阶段的数据传输
Decimal128 在聚合中 $sum$avg 对 Decimal128 友好,但 $multiply 等数学运算注意精度
索引与 $match 只有管道的第一个 $match 能充分使用索引,后续 $match 在前置阶段加工后索引可能失效

4.4 常见踩坑经验

故障案例一:$group$match 顺序写反

某报表把 $group 写在 $match 前面,50 万数据全部参与分组——按日期、门店、类目三维度分组产生数万组,每组只保留 top 10。$group 阶段内存超限,加了 allowDiskUse 后速度仍然极慢。修复:把时间范围 $match 移到第一个阶段,数据量从 50 万降到 5000,$group 秒级完成。根因:开发以为 MongoDB 会自动优化管道顺序。

故障案例二:$sum: "$amount" 在字段缺失时返回 0 而非 null

某对账报表用 $sum: "$amount" 统计收入,部分文档 amount 字段缺失。$sum 对缺失字段视为 0,导致统计结果比实际大——看起来"多赚了钱",但实际是数据脏的。修复:在 $match 阶段先过滤掉 amount 为 null 的文档,确保完整数据参与统计。

故障案例三:Decimal128 在 $toDouble 转换时丢失精度

某统计聚合中显式用了 $toDouble: "$amount" 将 Decimal128 转 Double 来做后续计算,然后累加求和。10 万条金额的累计误差达 0.02 元,对账不平。根因:Double 的浮点累积误差。修复:全程保持 Decimal128 类型,不使用 $toDouble$sum 默认保留 Decimal128 精度。

4.5 思考题

  1. 如果要对每天的 GMV 同时计算"环比增长率"和"同比增长率",聚合管道能一次完成吗?如果不能,有什么替代方案?
  2. $group 阶段内定义的累加器表达式,可以直接引用另一个累加器的结果吗(如 b: { $sum: "$a" } 后面跟 c: { $multiply: ["$b", 2] })?为什么?

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


上一章思考题答案

  1. 三种博客文章模型设计:
  • 全嵌入:文章、评论、标签全在一个文档。优点:一次查询获取完整页面,评论原子追加;缺点:热门文章评论数可能膨胀至数百条,文档超过 1MB,标签排名需要聚合。
  • 全引用:文章、评论、标签独立集合,文章存 commentIdstagIds。优点:各部分独立管理,评论支持分页;缺点:文章详情页需要 $lookup 拉评论和标签。
  • 混合(推荐):文章嵌入最近 20 条"精选评论",完整评论列表分页查询;标签 ID 数组嵌入文章,$lookup 一次获取标签名。兼顾性能和灵活性。
  1. MongoDB 的 16MB 文档上限不可配置(硬编码为 BSONObjMaxUserSize),调大它不会解决数据膨胀问题。官方不推荐调大的原因:① 大文档导致 WiredTiger 缓存效率降低(一个文档可能挤出多个小文档的缓存空间);② 网络传输和序列化开销急剧增加;③ Oplog 条目变大,复制延迟增加;④ 如果真需要存大文件,应使用 GridFS 拆分为多个 255KB 的 chunk,而非调大文档上限。

延伸阅读与资源

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

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

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

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

posted on 2026-07-22 11:19  一天不进步,就是退步  阅读(7)  评论(0)    收藏  举报