mongoDB应用篇-mongo聚合查询

上篇我们学习了MongoDB中的一些特殊集合,如TTL集合与固定大小的集合,特殊的索引-文本索引,以及mongo的高级功能GridFS文件存储功能的支持,本篇我们开始从数据分析的角度来学习,MongoDB中多种查询的数据聚合操作

如果我们在日常操作中,将部分数据存储在了MongoDB中,但是有需求要求我们将存储进去的文档数据,按照一定的条件进行查询过滤,得到想要的结果便于二次利用,那么我们就可以尝试使用MongoDB的聚合框架。

简单的聚合查询

前面我们在学习文档查询的过程中,也介绍过一些查询的操作符,其中就有一部分是简单的查询聚合函数,例如countdistinctgroup等,如果是简单的数据分析过滤,完全可以使用这些自带的聚合函数以及查询的操作符来完成文档的过滤查询操作

聚合框架

如果我们遇到了一些数据需要跨多个文本或者统计等操作,这个时候可能文档自身也较为复杂,查询操作符已经无法满足的时候,这个时候就需要使用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
}

通过这个简单的案例我们就能输出想要的数据和属性名,大概分析一下刚刚的聚合查询语句,则是按照group则是按照by_user字段进行分组,代表每个用户一条数据,而num_tutorial则是定义了数值类型计算的结果字段,$sum则是计算总和,相当于每个用户出现一次,都会+1,最终计算出来的总和通过num_tutorial字段进行输出

注:如果管道没有给出预期的结果,就需要进行调试操作,调试的时候,可以尝试先给一个管道操作符的条件,如果这个时候查询出来的结果是我们想要的,那么我们需要再去指定第二个管道操作符,依次操作,最后就会定位到出了问题的操作符

管道操作符

前面我们提到聚合查询会使用管道操作符,而每一个操作符就会接受一连串的文档,对这些文档进行一些类型转换,最后将转换以后的文档结果传递给下一个管道操作符来执行后续的操作,如果当前是最后一个管道操作符,那么则会显示给用户最后的文档数据。不同的管道操作符是可以按照顺序组合在一起使用,并且可以被重复执行多次,例如我们可以先使用$match然后再去、group,最后再去执行\match操作。

$match

match用于对文档集合进行筛选,之后就可以在筛选得到的文档子集上做聚合操作,\match管道操作符可以使用$gt、$lt、$in等操作符,进行过滤,不过需要注意的是不能在$match管道操作符中使用空间地理操作符。

在实际使用的过程中,尽可能的将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"
}

简单运算

我们在投影的时候,除了可以将某个字段映射成其他字段以外,还可以针对某个字段进行一些简单的运算,最常见的就是四则运算,即

加法(add**)、减法(**subtract)、乘法(multipy**)、除法(**divide)、求模($mod) ,

除此之外,还支持对字段进行关系运算(大小比较("cmp"**)、等于(**"eq")、大于("gt"**)、大于等于(**"gte")、小于("le"**)、小于等于(**"lte")、不等于("ne"**)、判断 null (**"ifNull") )、

逻辑运算(与("and"**)、或(**"or")、非 ("not"**) )以及**字符串操作**(连接(**"concat")、截取("substr"**)、转小写(**"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]} ]}}}]
)

这里需要注意的一点是,concat函数只能拼接字符串,如果我们使用{year:"date"}将当前的年份提取出来,是number类型的,这种是无法拼接的,因此我们可以使用\substr函数将date字段的结果截取成字符串即可实现拼接

$group

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
}
..........
  • min/\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
}
  • first/\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
}
limit / \skip

limit与我们之前学过的操作符作用一样,用于根据管道查询的结果集中,返回n条结果的管道操作符,我们基于刚刚的查询,加上\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
}

除了limit之外,我们常见的管道操作符还有\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则是等待所有数据完毕才会执行,因此可能会导致整体执行时间较久,也因为这样,才不建议在较高的实时查询需求上使用管道和查询,而是在设计的时候尽可能直接使用查询操作符进行数据查询,触发更多的索引,更快的销量查询出来想要的结果。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,108评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,699评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,812评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,236评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,583评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,739评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,957评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,704评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,447评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,643评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,133评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,486评论 3 256
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,151评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,889评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,782评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,681评论 2 272

推荐阅读更多精彩内容