上篇我们学习了MongoDB中的一些特殊集合,如TTL集合与固定大小的集合,特殊的索引-文本索引,以及mongo的高级功能GridFS文件存储功能的支持,本篇我们开始从数据分析的角度来学习,MongoDB中多种查询的数据聚合操作
如果我们在日常操作中,将部分数据存储在了MongoDB中,但是有需求要求我们将存储进去的文档数据,按照一定的条件进行查询过滤,得到想要的结果便于二次利用,那么我们就可以尝试使用MongoDB的聚合框架。
简单的聚合查询
前面我们在学习文档查询的过程中,也介绍过一些查询的操作符,其中就有一部分是简单的查询聚合函数,例如count
、distinct
、group
等,如果是简单的数据分析过滤,完全可以使用这些自带的聚合函数以及查询的操作符来完成文档的过滤查询操作
聚合框架
如果我们遇到了一些数据需要跨多个文本或者统计等操作,这个时候可能文档自身也较为复杂,查询操作符已经无法满足的时候,这个时候就需要使用MongoDB的聚合查询框架了。
使用聚合框架可以对集合中的文档进行变换和组合查询,基本上我们使用的时候,都是使用多个构件创建一个管道,用于对一连串的文档进行处理。这里的构件包括筛选(filter)
、 投射(projecting)
、分组(grouping)
、排序(sorting)
、限制(limiting)
以及跳过(skipping)
aggregate函数
MongoDB中需要使用聚合操作,一般使用aggregate函数来完成多个聚合之间的连接,aggregate() 方法的基本语法格式如下 :
db.COLLECTION_NAME.aggregate(AGGREGATE_OPERATION)
现在假设我们有个集合articles,里面存储了文章的集合,大致如下:
{
_id: ObjectId(7df78ad8902c)
title: 'MongoDB Overview',
description: 'MongoDB is no sql database',
by_user: 'runoob.com',
url: 'http://www.runoob.com',
tags: ['mongodb', 'database', 'NoSQL'],
likes: 100
},
{
_id: ObjectId(7df78ad8902d)
title: 'NoSQL Overview',
description: 'No sql database is very fast',
by_user: 'runoob.com',
url: 'http://www.runoob.com',
tags: ['mongodb', 'database', 'NoSQL'],
likes: 10
},
{
_id: ObjectId(7df78ad8902e)
title: 'Neo4j Overview',
description: 'Neo4j is no sql database',
by_user: 'Neo4j',
url: 'http://www.neo4j.com',
tags: ['neo4j', 'database', 'NoSQL'],
likes: 750
}
但这时我们需要查询出来每一个作者写的文章数量,需要使用aggregate()计算 ,大致如下:
db.articles.aggregate([{$group : {_id : "$by_user", num_tutorial : {$sum : 1}}}])
输出的结果为:
{
"result" : [
{
"_id" : "runoob.com",
"num_tutorial" : 2
},
{
"_id" : "Neo4j",
"num_tutorial" : 1
}
],
"ok" : 1
}
通过这个简单的案例我们就能输出想要的数据和属性名,大概分析一下刚刚的聚合查询语句,by_user字段进行分组,代表每个用户一条数据,而num_tutorial则是定义了数值类型计算的结果字段,$sum则是计算总和,相当于每个用户出现一次,都会+1,最终计算出来的总和通过num_tutorial字段进行输出
注:如果管道没有给出预期的结果,就需要进行调试操作,调试的时候,可以尝试先给一个管道操作符的条件,如果这个时候查询出来的结果是我们想要的,那么我们需要再去指定第二个管道操作符,依次操作,最后就会定位到出了问题的操作符
管道操作符
前面我们提到聚合查询会使用管道操作符,而每一个操作符就会接受一连串的文档,对这些文档进行一些类型转换,最后将转换以后的文档结果传递给下一个管道操作符来执行后续的操作,如果当前是最后一个管道操作符,那么则会显示给用户最后的文档数据。不同的管道操作符是可以按照顺序组合在一起使用,并且可以被重复执行多次,例如我们可以先使用$match然后再去、match操作。
$match
match管道操作符可以使用$gt、$lt、$in等操作符,进行过滤,不过需要注意的是不能在$match管道操作符中使用空间地理操作符。
在实际使用的过程中,尽可能的将match操作符以后,再去投射或者执行分组操作的话,是可以利用索引的。
$project
相比较一般的查询操作而言,使用管道操作,尤其是其中的投射操作更加强大。我们可以在查询文档结束以后利用$project
操作符从文档中进行字段的提取,甚至于我们可以重命名字段,将部分字段映射成我们想要展示出去的字段,也可以对一部分字段进行一些有意义的处理。需要注意的是,$project
操作符可以传入两个参数,第一个是需要处理的属性名称,第二个则是0或者1,如果传入1,则代表当前的属性是需要显示出来的,如果是0或者不写,默认都是代表这个字段不需要显示出来
当然第二个参数也可以是一个表达式或者查询条件,满足当前表达式的数据也可以进行显示,接下来我们先准备一点数据:
db.project.insertMany([
{ "_id" : 1, "item" : "abc", "price" : 10, "quantity" : 2, "date" : ISODate("2014-03-01T08:00:00Z") },
{ "_id" : 2, "item" : "jkl", "price" : 20, "quantity" : 1, "date" : ISODate("2014-03-01T09:00:00Z") },
{ "_id" : 3, "item" : "xyz", "price" : 5, "quantity" : 10, "date" : ISODate("2014-03-15T09:00:00Z") },
{ "_id" : 4, "item" : "xyz", "price" : 5, "quantity" : 20, "date" : ISODate("2014-04-04T11:21:39.736Z") },
{ "_id" : 5, "item" : "abc", "price" : 10, "quantity" : 10, "date" : ISODate("2014-04-04T21:23:13.331Z") }
])
接下来,我们来查询,条件是item字段为abc,quantity要大于5,并且我们只要item和price字段的结果,其他都排除掉:
db.project.aggregate(
[{"$match":{"item":"abc","quantity":{"$gt":5}}},{"$project":{"_id":0,"item":1,"price":1}}]
)
可以看到结果为:
{
"item" : "abc",
"price" : 10.0
}
如果我们想要在原基础上改变某个字段的名称,例如将item改为item_code,可以利用$来完成,如下:
db.project.aggregate(
[{"$match":{"item":"abc","quantity":{"$gt":5}}},{"$project":{"_id":0,"item_code":"$item","price":1}}]
)
可以看到我们指定的名称item_code,而这个别名对应的字段item使用$作为前缀标记,代表将item字段映射为item_code,可以看到结果:
{
"price" : 10.0,
"item_code" : "abc"
}
简单运算
我们在投影的时候,除了可以将某个字段映射成其他字段以外,还可以针对某个字段进行一些简单的运算,最常见的就是四则运算,即
加法(subtract)、乘法(divide)、求模($mod) ,
除此之外,还支持对字段进行关系运算(大小比较("eq")、大于("gte")、小于("lte")、不等于("ifNull") )、
逻辑运算(与("or")、非 ("concat")、截取("toLower") )等
我们基于上面的需求,假设每一个价格是按照元为单位,现在要求输出W为单位,那么我们就需要对price进行除法运算,如下:
db.project.aggregate(
[{"$match":{"item":"abc","quantity":{"$gt":5}}},{"$project":{"_id":0,"item_code":"$item","price":{"$divide":["$price",10000]}}}]
)
除此之外,我们也可以将计算完毕的price改名为priceW,即:
db.project.aggregate(
[{"$match":{"item":"abc","quantity":{"$gt":5}}},{"$project":{"_id":0,"item_code":"$item","priceW":{"$divide":["$price",10000]}}}]
)
可以看到输出的结果为:
{
"item_code" : "abc",
"priceW" : 0.001
}
这时有一个需求,要求我们返回数据的同时还要yyyy-MM-dd格式的时间字符串,这个时候我们就需要对date字段进行时间函数和字符串混合处理了,如下:
db.project.aggregate(
[{"$match":{"item":"abc","quantity":{"$gt":5}}},{"$project":{"_id":0,"item_code":"$item","price":{"$divide":["$price",10000]},"date_str":{$concat:[{$substr:["$date",0,4]},"-",{$substr:["$date",5,2]},"-",{$substr:["$date",8,2]} ]}}}]
)
这里需要注意的一点是,year:"substr函数将date字段的结果截取成字符串即可实现拼接
$group
group的_id上,代表按照当前字段进行分组,例如,我们这里根据item进行分组:
db.project.aggregate([
{$group:{"_id":"$item"}}
])
//结果为:
{
"_id" : "jkl"
}
{
"_id" : "xyz"
}
{
"_id" : "abc"
}
分组操作符
在我们针对某个字段进行分组以后,我们可以针对每个分组进行一些操作符的使用,常见的例如:$sum
、$avg
、$min
、$max
、$first
、$last
。
-
$sum:value
$sum
函数可以将我们用来分组的每一个分组的值进行累计,例如我们按照item分组,有三个结果,其中任何一个结果出现了几次,就可以进行累加几次,例如:
db.project.aggregate([
{
$group : {
_id : "$item",
count: { $sum : 1}
}
}
])
//输出的结果为:
{
"_id" : "jkl",
"count" : 1.0
}
// ----------------------------------------------
{
"_id" : "xyz",
"count" : 2.0
}
// ----------------------------------------------
{
"_id" : "abc",
"count" : 3.0
}
- $avg : field
$avg
操作符用来返回每一个分组内的平均值
现在我们基于前面item的分组,我们想要算出来每个组内的平均价格是多少,如下:
db.project.aggregate([
{
$group : {
_id : "$item",
count: { $sum : 1},
avg:{$avg:"$price"}
}
}
])
//可见,结果为
{
"_id" : "jkl",
"count" : 1.0,
"avg" : 20.0
}
..........
- max : field
$min
和$max
操作符用于返回分组内最大的值和最小的值
除了平均值以外,我们现在将最贵的和最便宜的价格也要列出来,这个时候就可以使用这两个操作符了,如下:
db.project.aggregate([
{
$group : {
_id : "$item",
count: { $sum : 1},
avg:{$avg:"$price"},
max_price:{$max:"$price"},
min_price:{$min:"$price"}
}
}
])
//结果
{
"_id" : "jkl",
"count" : 1.0,
"avg" : 20.0,
"max_price" : 20.0,
"min_price" : 20.0
}
- last: filed
$first
、$last
则是可以获取当前分组中第一个或者最后一个的某个字段的结果,如下:
db.project.aggregate([
{
$group : {
_id : "$item",
count: { $sum : 1},
avg:{$avg:"$price"},
max_price:{$max:"$price"},
min_price:{$min:"$price"},
first_price:{$first:"$price"},
last_price:{$last:"$price"}
}
}
])
//结果
{
"_id" : "jkl",
"count" : 1.0,
"avg" : 20.0,
"max_price" : 20.0,
"min_price" : 20.0,
"first_price" : 20.0,
"last_price" : 20.0
}
除此之外,我们还可以在分组的时候使用数组操作符,例如$addToSet
可以判断,当前数组如果不包含某个条件,就添加到当前数组中,$push
则不管元素是否存在,都直接添加到数组中
注意:大部分管道操作符都是流式处理的,只要有新的文档进入,就可以对新的文档进行处理,但是$group
代表必须收到全部文档以后才可以进行分组操作,才会将结果传递给后续的管道操作符,这就意味着,如果当前mongo是存在分片的,会先在每个分片上执行完毕以后,再把结果传递mongos进行统一的分组,剩下的管道操作符也不会在每个分片,而是mongos上执行了
$unwind
如果我们现在遇到一些文档比较复杂,比如存在内嵌文档的存在,某个属性里面嵌套了一个数组,但是我们需要对内嵌的数组文档进行分析过滤等查询处理,这个时候就可以使用$unwind
操作符将每一个文档中的嵌套数组文件拆分为一个个独立的文档便于进行后续的处理,例如我们需要将之前的set集合中关于请求的url以及ip的信息拆分出来,原始的格式如下:
{
"_id" : ObjectId("5f5e6a73cf72b68c1d21c471"),
"url" : "www.baidu.com",
"count" : 7.0,
"update_time" : "2020-08-13 12:00:00",
"ip_array" : [
{
"ip" : "192.168.1.3"
},
{
"ip" : "192.168.1.4"
}
]
}
我们可以使用命令进行拆分,如下:
db.set.aggregate([
{$project:{"url":1,"ip_array":1}},
{$unwind:"$ip_array"},
{$project:{"url":1,"ip":"$ip_array.ip"}}
])
结果为:
{
"_id" : ObjectId("5f5e6a73cf72b68c1d21c471"),
"url" : "www.baidu.com",
"ip" : "192.168.1.3"
},
{
"_id" : ObjectId("5f5e6a73cf72b68c1d21c471"),
"url" : "www.baidu.com",
"ip" : "192.168.1.4"
}
可以看到数据则是按照每一条信息的方式展示出来了,方便后续的计算以及输出,但是需要注意的一点是,这种方式,如果该文档中没有拆分的字段,或者是空数组,默认会直接排除,如果我们需要空数组等也输出计算出来,则可以指定preserveNullAndEmptyArrays
参数,设置为true,则代表空数组或者不存在的文档也要拆分输出出来,即:
db.set.aggregate([
{$project:{"url":1,"ip_array":1}},
{$unwind:{"path":"$ip_array","preserveNullAndEmptyArrays":true}},
{$project:{"url":1,"ip":"$ip_array.ip"}}
])
$sort
我们可以在管道查询的过程中,按照某个属性值或者多个属性的结果进行顺序排序,排序的方式与普通查询操作符中的sort操作符表现一致,与其他管道操作符一样,可以在任何阶段使用,但是,需要注意的一点是,建议在管道操作符第一阶段进行排序,因为此时的排序是可以触发索引的,如果在后续阶段进行排序,会消耗大量内存,并且耗时会很久,尤其是在有$group
的情况下,如果放在$group
操作符后面,会发现等到的时间很久,不仅仅是无法触发索引的问题,还和$group
操作符是等待所有数据完毕才会触发的特性有关,因此需要格外注意。
db.project.aggregate([
{$project:{"_id":0,"item":1,"price":1,"quantity":1}},
{$sort:{"price":1,"quantity":1}}
])
结果如下,按照我们想要的结果进行了排序:
{
"item" : "xyz",
"price" : 5.0,
"quantity" : 10.0
},
{
"item" : "xyz",
"price" : 5.0,
"quantity" : 20.0
}
skip
limit,只返回前两条数据,如下:
db.project.aggregate([
{$project:{"_id":0,"item":1,"price":1,"quantity":1}},
{$sort:{"price":1,"quantity":1}},
{$limit:2}
])
结果如下:
{
"item" : "xyz",
"price" : 5.0,
"quantity" : 10.0
},
{
"item" : "xyz",
"price" : 5.0,
"quantity" : 20.0
}
除了skip,与之前的查询操作符作用也是一样的,用于在已经查询完毕的结果集中跳过前N条数据以后进行返回,我们将$skip加在刚刚的查询后面,如下:
db.project.aggregate([
{$project:{"_id":0,"item":1,"price":1,"quantity":1}},
{$sort:{"price":1,"quantity":1}},
{$limit:2},
{$skip:2},
])
这个时候可以看到返回的结果为空,什么结果都没有了,这是因为前一步管道已经限制了仅仅返回2条,而接着我们又跳过了前两条文档,因此返回的结果为空,我们将顺序调换一下,看看:
db.project.aggregate([
{$project:{"_id":0,"item":1,"price":1,"quantity":1}},
{$sort:{"price":1,"quantity":1}},
{$skip:2},
{$limit:2},
])
可以看到结果如下,与刚才的结果无异:
{
"item" : "abc",
"price" : 10.0,
"quantity" : 2.0
},
{
"item" : "abc",
"price" : 10.0,
"quantity" : 2.0
}
管道操作符使用总结
管道查询操作符有很多,除了上面学习的常用的部分,还有几十个,需要了解全部的可以参考官网:
https://docs.mongodb.com/manual/reference/command/aggregate/
除此之外,我们在学习的过程中了解到,部分查询操作符是可以触发索引的,例如$project
、$group
或者$unwind
操作符,因此我们也建议如果可以的话,尽量先使用这类管道操作符进行数据过滤,可以有效减少数据集大小和数量,而且管道如果不是直接从原先的集合中使用数据,那就无
法在筛选和排序中使用索引,例如我们先进行管道操作,再去将过滤好的数据进行$sort
排序,会导致无法使用索引,效率大幅度下降,因此如果我们需要涉及到$sort
操作的时候,如果可以尽可能在最开始就处理,这个时候可以使用索引,效率较高,然后再去进行管道查询筛选与分组等其他操作,可以有效的提高查询的效率。另外需要注意的一点是,在MongoDB中会对每一个管道查询做限制,例如某一步管道查询操作导致内存占用超过20%,这个时候就会报错,无法继续使用管道,因为mongoDB本身每次最大是16Mb的数据量,为了尽可能避免或者减少这种问题,建议可以考虑尽可能的使用$match
操作符过滤无用数据,减少数据总大小。同时也因为管道查询是多步执行,例如$group
则是等待所有数据完毕才会执行,因此可能会导致整体执行时间较久,也因为这样,才不建议在较高的实时查询需求上使用管道和查询,而是在设计的时候尽可能直接使用查询操作符进行数据查询,触发更多的索引,更快的销量查询出来想要的结果。
网友评论