在做数据统计的时候,求最小值是一个高频需求。比如电商系统里要找出每个分类下价格最低的商品,日志分析里要定位最早的一条记录,评分系统里要统计每个用户的最低得分。这类需求在MongoDB里都可以通过聚合管道中的$min操作符来完成。$min是MongoDB专门用来求最小值的聚合表达式,它主要工作在$group阶段,同时也能在其他支持聚合表达式的阶段使用。本文从基础语法讲起,逐步深入到嵌套文档、数组处理以及常见的坑,帮你彻底掌握这个操作符。

$min操作符的基本语法与工作原理
$min最常见的使用位置是$group阶段。$group阶段的作用是按照指定的字段对文档进行分组,然后把每一组文档交给累加器表达式处理,$min就是众多累加器中的一个。它的语法结构非常简单:在$group的输出字段里写上{$min: "$字段名"},管道就会在每一组文档中找出该字段的最小值并输出。
原理上,$min会对分组内的每个文档计算表达式的值,然后比较这些值,忽略掉值为null或者字段不存在的文档,最后返回最小的非空值。如果一组内所有文档该字段都不存在或者都是null,$min会返回null。这一点和很多编程语言的数学函数不同,它不会报错,而是优雅地返回null。
// 准备测试数据
db.products.insertMany([
{ name: "键盘", category: "外设", price: 199 },
{ name: "鼠标", category: "外设", price: 89 },
{ name: "显示器", category: "显示器", price: 1299 },
{ name: "入门显示器", category: "显示器", price: 699 }
])
// 按分类求每个分类的最低价格
db.products.aggregate([
{
$group: {
_id: "$category",
minPrice: { $min: "$price" }
}
}
])
// 输出结果:
// { _id: "外设", minPrice: 89 }
// { _id: "显示器", minPrice: 699 }
上面的例子中,$group按照category字段分组,然后$min在每组内比较price字段,返回最小值。可以看到外设组的最低价是鼠标的89,显示器组的最低价是699。这种写法是$min最经典的应用场景,理解了这一段,后面的进阶用法就都有了基础。
$min在其他阶段的使用与数组处理技巧
从MongoDB 3.2版本开始,$min不仅可以作为累加器用在$group阶段,还可以作为普通表达式用在$project、$addFields、$set等阶段。区别在于:在$group阶段它作用于一组文档,而在$project阶段它作用于单个文档,通常配合数组使用,用来求数组中的最小元素。
比如文档里有一个scores数组,想直接取出数组的最小值,可以在$project阶段这样写:
// 测试数据
db.students.insertOne({
name: "张三",
scores: [88, 92, 75, 60]
})
// 直接求数组最小值
db.students.aggregate([
{
$project: {
name: 1,
lowestScore: { $min: "$scores" }
}
}
])
// 输出:{ name: "张三", lowestScore: 60 }
如果数组里存的是对象而不是数字,$min无法直接比较对象,这时需要先配合$map把对象数组转换成数字数组,再交给$min处理。例如订单里有多个商品的明细,要找订单内单价最低的商品价格:
db.orders.insertOne({
orderId: "A1001",
items: [
{ goods: "苹果", unitPrice: 12.5 },
{ goods: "香蕉", unitPrice: 4.8 },
{ goods: "橙子", unitPrice: 8.0 }
]
})
db.orders.aggregate([
{
$project: {
orderId: 1,
lowestUnitPrice: {
$min: {
$map: {
input: "$items",
as: "item",
in: "$$item.unitPrice"
}
}
}
}
}
])
// 输出:{ orderId: "A1001", lowestUnitPrice: 4.8 }
这种$map加$min的组合非常实用,凡是遇到嵌套结构需要提取再比较的场景,都可以套用这个模式。需要注意$min比较的是BSON排序顺序,数字之间按数值比较,日期按时间先后比较,字符串按字典序比较,混合类型时会按照BSON类型排序规则处理,这一点在设计数据结构时要提前想清楚。
找出最小值所在的完整文档
很多时候我们不只是要一个最小值,还想拿到这个最小值对应的整条文档,比如价格最低的商品的完整信息。$group加$min只能返回值本身,拿不到原文档。这个需求有两种经典解法。
第一种是$sort加$limit的组合:先把文档按目标字段升序排列,再用$limit取第一条。如果还要按分组取每组的最大值文档,可以配合$group把整个文档用$first推入:
// 全表找价格最低的商品
db.products.aggregate([
{ $sort: { price: 1 } },
{ $limit: 1 }
])
// 每个分类找价格最低的商品完整信息
db.products.aggregate([
{ $sort: { category: 1, price: 1 } },
{
$group: {
_id: "$category",
cheapestProduct: { $first: "$$ROOT" }
}
}
])
注意使用$first前必须先排序,否则取到的只是分组中任意一个文档。第二种是用$top累加器(MongoDB 5.0及以上版本支持),它在$group内部完成排序并取第一条,写法更直观,还能减少一个管道阶段:
db.products.aggregate([
{
$group: {
_id: "$category",
cheapestProduct: {
$top: {
sortBy: { price: 1 },
output: "$$ROOT"
}
}
}
}
])
两种方式各有优劣:$sort加$first兼容性好,几乎所有版本都支持,但排序发生在分组之前,数据量大时开销较高;$top的排序范围局限在组内,语义更清晰,但要求较新的版本。实际项目中根据MongoDB版本和数据规模选择即可。
常见坑与性能优化建议
第一个常见的坑是null和缺失字段的处理。前面提到$min会忽略null和缺失字段,这在大多数情况下是合理的,但如果你希望字段缺失时显式报错或者给默认值,就需要自己加判断逻辑,比如先用$ifNull处理。第二个坑是日期字段,很多人以为存的是字符串也能正确比较,实际上ISO日期字符串和Date类型混存时,比较结果会遵循BSON类型顺序而不是时间顺序,建议统一用Date类型存储时间。
性能方面,如果$min作用的字段上建有索引,配合$match提前过滤可以显著减少参与比较的文档数量。另外,尽量把$match放在管道靠前的位置,让MongoDB能利用索引完成过滤。对于超大集合的全表最小值查询,如果只需要一个值,甚至可以直接用findOne配合sort的普通查询来代替聚合管道,性能往往更好:
// 普通查询求全表最小价格,等价于聚合但更轻量
db.products.find().sort({ price: 1 }).limit(1)
// 在price字段上建索引后,这条查询可以直接命中索引
db.products.createIndex({ price: 1 })
总结一下,$min是聚合管道中求最小值的核心工具,掌握它在$group阶段的分组统计、在$project阶段的数组处理、以及和$sort、$limit、$top配合取完整文档的技巧,就能覆盖绝大多数最小值相关的业务场景。写管道时多留意字段类型的一致性和null值的影响,再结合索引做优化,统计查询就能既准确又高效。