做数据统计的时候,单靠MongoDB的普通查询语句往往力不从心。比如要统计每个用户的订单总金额、每个月的销售额、每类商品的销售数量,这类需求都需要先分组再汇总,而聚合管道正是为此而生的工具。在众多累加类操作符里,$sum无疑是使用频率最高的一个,它可以在分组时对指定字段累加求和,也能在投影阶段快速统计文档或数组元素的数量。这篇文章围绕$sum的典型用法展开,配合实际场景把语法细节和常见坑都梳理清楚。

$sum的基本语法与工作原理
$sum主要出现在两个位置:一个是$group阶段,用于对分组内的文档字段做累加;另一个是$project、$addFields等投影类阶段,用于对数组元素求和或统计数量。两种位置的语法略有差异,先看$group中的写法:
db.orders.aggregate([
{
$group: {
_id: "$userId",
totalAmount: { $sum: "$amount" },
orderCount: { $sum: 1 }
}
}
])
上面的语句把订单集合按userId分组,totalAmount对每条记录的amount字段累加,orderCount则通过累加常量1来统计每个分组内的文档数量。这里有个值得注意的点:{ $sum: 1 }是统计文档条数的惯用写法,等价于统计文档数量。如果字段引用写的是"$amount",MongoDB会逐条取值累加;遇到缺失字段或null时会被跳过,不会报错,但也不会计入结果。
在$project或$addFields阶段,$sum的参数是一个表达式或数组。直接对数组使用时,可以对数组内的数值元素求和:
db.products.aggregate([
{
$project: {
name: 1,
totalScore: { $sum: [ "$score1", "$score2", "$score3" ] }
}
}
])
这种写法会把三个字段的值加在一起。需要注意的是,如果数组中含有非数值或者null,$sum在非数值上的行为是忽略它们而不是中断执行,这一点与手写$add不同,$add遇到非数值会直接报错。正是这个特性让$sum在处理脏数据时更加宽容。
典型场景:订单销售额分组统计
用一个电商订单集合来演示更完整的统计流程。假设文档结构如下:
{
"_id": ObjectId("..."),
"userId": "u1001",
"amount": 258.5,
"status": "paid",
"createdAt": ISODate("2024-03-15T08:30:00Z")
}
需求是统计每个用户的已支付订单总金额和订单数,并按金额倒序排列。完整的聚合管道需要串联$group、$sort两个阶段:
db.orders.aggregate([
{ $match: { status: "paid" } },
{
$group: {
_id: "$userId",
totalAmount: { $sum: "$amount" },
orderCount: { $sum: 1 }
}
},
{ $sort: { totalAmount: -1 } },
{ $limit: 10 }
])
这里有一个性能要点必须强调:过滤条件的$match要放在管道最前面。因为$match如果位于$group之前,可以直接利用索引缩小扫描范围;如果放在后面,MongoDB就要先对所有文档分组,再从分组结果里过滤,数据量大时差距会非常明显。这也是聚合管道优化的第一条原则——尽早过滤,尽早缩小数据集。
再进一步,如果要在分组统计后只保留金额超过1000的用户,不能在$group里直接判断,需要追加一个$match阶段,此时过滤的对象已经是分组后的结果字段了:
db.orders.aggregate([
{ $match: { status: "paid" } },
{
$group: {
_id: "$userId",
totalAmount: { $sum: "$amount" }
}
},
{ $match: { totalAmount: { $gt: 1000 } } }
])
同一个操作符在不同阶段语义不同,这是聚合管道初学者最容易混淆的地方。理解数据在管道中是逐阶段流动变换的,写复杂查询时思路会清晰很多。
按日期分组汇总与条件求和
按月或按天汇总销售额是另一个高频需求。直接按createdAt分组会把每条记录都当成独立分组,必须先用$dateToString或$dateTrunc提取时间维度:
db.orders.aggregate([
{ $match: { status: "paid" } },
{
$group: {
_id: {
year: { $year: "$createdAt" },
month: { $month: "$createdAt" }
},
monthlyTotal: { $sum: "$amount" }
}
},
{ $sort: { "_id.year": 1, "_id.month": 1 } }
])
分组键可以是复合对象,统计结果中_id会以子文档形式呈现年月信息。如果觉得嵌套结构不好处理,可以在后面追加一个$project把字段拍平。MongoDB 5.0之后还可以用$dateTrunc更简洁地按时间桶分组,写法上省去了手动提取年月。
条件求和也是常见变体:想在一次查询里同时统计已支付总额和已取消总额,可以借助$cond构造条件表达式,让满足条件的记录计入求和,不满足的加0:
db.orders.aggregate([
{
$group: {
_id: "$userId",
paidTotal: {
$sum: {
$cond: [
{ $eq: ["$status", "paid"] },
"$amount",
0
]
}
},
cancelledTotal: {
$sum: {
$cond: [
{ $eq: ["$status", "cancelled"] },
"$amount",
0
]
}
}
}
}
])
这种写法把原本需要两次查询的统计合并成一次,减少了数据库往返。$cond的第三个参数写0很关键,如果写成null虽然也会被忽略,但显式给0语义更明确,便于维护者理解意图。
常见陷阱与注意事项
第一个坑是字段类型不一致。$sum只会累加数值类型的值,字符串数字比如"100"会被静默跳过,导致统计结果偏小且没有任何报错提示。遇到统计数字明显不对时,优先检查字段类型,可以用下面的管道排查:
db.orders.aggregate([
{
$group: {
_id: { type: { $type: "$amount" } },
count: { $sum: 1 }
}
}
])
第二个坑是浮点精度。金额字段如果存的是double类型,累加后可能出现类似258.49999999999997的结果。对精度敏感的业务建议存分为单位的整数,或者使用Decimal128类型,聚合时配合$toDecimal转换。
第三个坑是分组后内存限制。默认情况下单个聚合阶段占用内存不能超过100MB,分组键基数特别大时容易触发报错。解决办法要么给查询字段建索引并利用索引加速,要么谨慎使用allowDiskPolicy选项让阶段溢出到磁盘,但磁盘溢出性能会明显下降,根源上还是要控制分组数量和前置过滤数据量。
最后区分一下$sum和几个相近操作符:$avg求平均值,$count统计文档数,$sum既能求和也能通过累加1统计数量。选择哪个取决于业务语义,统计金额用$sum,算客单价用$avg配$sum组合,单纯数记录数用$count更直观。把这些操作符搭配熟练,绝大多数统计报表需求都能用一条聚合管道搞定。
MongoDB聚合管道$sum分组统计修改时间:2026-09-15 22:52:40