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 |
无精确等价 |
小胖:那 $match 跟 find 的过滤条件一样吗?
大师:一模一样。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 适用场景
聚合管道适用:
- 运营日报/周报/月报——按时间维度+多维度分组统计。
- 数据看板——按门店、类目、区域的多维交叉分析。
- 用户行为分析——按用户分组统计访问、下单、支付漏斗。
- ETL 数据处理——清洗、转换、聚合后写入下游系统。
- 简单实时统计——在线接口中做轻量级聚合(如商品评价均分)。
不适用场景:
- 机器学习特征工程——复杂的滑动窗口、序列计算,建议用 Spark/Pandas。
- 图计算(如社交关系链分析)——图数据库更擅长。
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 思考题
- 如果要对每天的 GMV 同时计算"环比增长率"和"同比增长率",聚合管道能一次完成吗?如果不能,有什么替代方案?
$group阶段内定义的累加器表达式,可以直接引用另一个累加器的结果吗(如b: { $sum: "$a" }后面跟c: { $multiply: ["$b", 2] })?为什么?
(答案将在第 10 章末尾揭晓)
上一章思考题答案:
- 三种博客文章模型设计:
- 全嵌入:文章、评论、标签全在一个文档。优点:一次查询获取完整页面,评论原子追加;缺点:热门文章评论数可能膨胀至数百条,文档超过 1MB,标签排名需要聚合。
- 全引用:文章、评论、标签独立集合,文章存
commentIds和tagIds。优点:各部分独立管理,评论支持分页;缺点:文章详情页需要$lookup拉评论和标签。- 混合(推荐):文章嵌入最近 20 条"精选评论",完整评论列表分页查询;标签 ID 数组嵌入文章,
$lookup一次获取标签名。兼顾性能和灵活性。
- MongoDB 的 16MB 文档上限不可配置(硬编码为
BSONObjMaxUserSize),调大它不会解决数据膨胀问题。官方不推荐调大的原因:① 大文档导致 WiredTiger 缓存效率降低(一个文档可能挤出多个小文档的缓存空间);② 网络传输和序列化开销急剧增加;③ Oplog 条目变大,复制延迟增加;④ 如果真需要存大文件,应使用 GridFS 拆分为多个 255KB 的 chunk,而非调大文档上限。
延伸阅读与资源
MongoDB 实战进阶与内核修炼
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经
Milvus向量数据库实战修炼:从 0 到 1精通向量检索与生产落地
后端工程师的 AI 转型第一课:Ollama 与私有化大模型实战
10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战

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