首页

7.3MongoDB的聚合查询

关灯 护眼    字体:

上一章 目录 下一章




到目前为止,MongoDB只是作为一个保存数据的角色:开发者把数据保存到其中,等需要使用时,按照一定规则把数据提取出来,然后用Python或者Excel再对数据进行进一步处理。

但实际上,MongoDB  自带了一个聚合(Aggregation)功能。使用聚合功能,可以直接让MongoDB来处理数据。聚合功能可以把数据像放入传送带一样,先把原始数据按照一定的规则进行筛选处理,然后通过多个不同的数据处理阶段来处理数据,最终输出一个汇总的结果。

用一个形象的例子来说明什么是聚合操作。假设苹果树上的很多苹果就是“原始数据”,而“吃”这个动作就是“输出”。那么苹果从树上进入人的嘴巴,可能会有如图7-17所示的几种不同的情况。

图7-17  苹果入口的几种不同方式

图中的“筛选”“榨汁”“加入添加剂”“制作糖果”称为聚合操作的不同“阶段(Stage)”,前一个阶段的输出是后一个阶段的输入,通过接力的方式完成从原始数据到最终数据的转换。



7.3.1  聚合的基本语法


聚合操作的命令为“aggregate”,基本格式为:

聚合操作可以有0个、1个或者多个阶段。

如果有0个阶段,则查询命令写为:

collection.aggregate()

那么它的作用和“collection.find()”一样。请对比图7-18和图7-1。

图7-18  有0个阶段的aggregate作用和find()相同

如果聚合有至少一个阶段,那么每一个阶段都是一个字典。不同的阶段负责不同的事情,每一个阶段有一个关键字。有专门负责筛选数据的阶段“$match”,有专门负责字段相关的阶段“$project”,有专门负责数据分组的阶段“$group”等。聚合操作有几十个不同的阶段关键字,本书选择其中常用的一些来作讲解。



7.3.2  实例20:筛选数据


实例描述

从数据集example_data_1中,查询age大于等于27,且sex为“女”的所有记录。

一般情况下,并非所有的数据都需要被处理,因此大多数时候聚合的第一个阶段是数据筛选。就像“find()”一样,把某些满足条件的数据选出来以便后面做进一步处理。

数据筛选的关键字为“$match”,它的用法为:

例如,从example_data_1数据集中,查询age大于等于27,且sex为“女”的所有记录。聚合查询语句为:

查询结果如图7-19所示。

图7-19  使用聚合来查询数据

从查询结果来看,这一条聚合查询语句的作用完全等同于:

db.getCollection('example_data_1').find({'age':  {'$gte':  27},  'sex':  ’女’})

查询结果如图7-20所示。

图7-20  使用find可以实现相同的效果

这两种写法,核心查询语句“{'age':  {'$gte':  27},  'sex':  ’女’}”完全一样。

聚合查询操作中的,“{'$match':  {和find完全一样的查询表达式}}”,“$match”作为一个字典的Key,字典的Value和“find()”第1个参数完全相同。“find()”第1个参数能怎么写,这里就能怎么写。

例如,查询所有age大于28或者sex为“男”的记录,聚合查询语句就可以写为:

查询结果如图7-21所示。

图7-21  聚合查询的查询部分与“find()”第一个参数完全相同

从效果上看,使用聚合查询与直接使用“find()”效果完全相同,而使用聚合查询还要多敲几次键盘,那它的好处在哪里呢?

聚合操作的好处在于“组合”。接下来会讲到更多的聚合关键字,把这些关键字组合起来才能体现出聚合操作的强大。



7.3.3  实例21:筛选与修改字段


实例描述

对图7-1所示的数据集example_data_1,使用聚合操作实现以下功能:

(1)不返回_id字段,只返回age和sex字段。

(2)所有age大于28的记录,只返回age和sex。

(3)在$match返回的字段中,添加一个新的字段“hello”,值为“world”。

(4)在$match返回的字段中,添加一个新的字段“hello”,值复制age的值。

(5)在$match返回的字段中,把age的值修改为一个固定字符串。

(6)把user.name和user.user_id变成普通的字段并返回。

(7)在返回的数据中,添加一个字段“hello”,值为“$normalstring”,再添加一个字段“abcd”,值为1。

“$match”可以筛选出需要的记录,那么如果想只返回部分字段,又应该怎么做呢?这时就需要使用关键字“$project”。

1.返回部分字段

首先用“$project”来实现一个已经有的功能——只返回部分字段。格式如下:

这里的字段过滤语句与“find()”第2个参数完全相同,也是一个字典。字段名为Key,Value为1或者0(需要的字段Value为1,不需要的字段Value为0)。

例如,对于图7-1所示的数据集,不返回“_id”字段,只返回age和sex字段,则聚合语句如下:

查询结果如图7-22所示。

结合“$match”实现“先筛选记录,再过滤字段”。例如,选择所有age大于28的记录,只返回age和sex,则聚合语句写为:

图7-22  只返回age和sex不返回“_id”


查询结果如图7-23所示。

图7-23  先筛选记录再过滤字段

到目前为止,使用“$match”加上“$project”,多敲了几十次键盘,终于实现了“find()”的功能。使用聚合操作复杂又繁琐,好处究竟是什么?

2.添加新字段

(1)添加固定文本。

在“$project”的Value字典中添加一个不存在的字段,看看效果会怎么样。例如:

注意这里的字段名“hello”,example_data_1数据集是没有这个字段的,而且它的值也不是“0”或者“1”,而是一个字符串。

查询结果如图7-24所示。在查询的结果中直接增加了一个新的字段。

图7-24  增加新字段

(2)复制现有字段。

现在把上面代码中的“world”修改为“$age”,变为:



查询结果如图7-25所示。

图7-25  复制一个字段

(3)修改现有字段的数据。

接下来,把原有的age的值“1”改为其他数据,代码变为:

查询结果如图7-26所示。

图7-26  修改一个已有字段的输出

从图7-25和图7-26可以看出,在“$project”中,如果一个字段的值不是“0”或“1”,而是一个普通的字符串,那么最后的结果就是直接输出这个普通字符串,无论数据集中原本是否有这个字段。

从图7-26可以看出,如果一个字段后面的值是“$+一个已有字段的名字”(例如“$age”),那么这个字段就会把“$”标记的字段的内容逐行复制过来。这个复制功能初看起来似乎没有什么用,原样复制能干什么?那么现在来看看example_data_2的嵌套字段。

3.抽取嵌套字段

如果直接使用find(),想返回“user_id”和“name”,则查询语句为:

db.getCollection('example_data_2').find({},  {'user.name':  1,  'user.user_id':  1})

查询结果如图7-27所示。

图7-27  返回的结果仍然是嵌套字段

返回的结果仍然是嵌套字段,这样处理起来非常不方便。而如果使用“$project”,则可以把嵌套字段中的内容“抽取”出来,变成普通字段,具体代码如下:

查询结果如图7-28所示。

图7-28  使用“$project”把嵌套字段提取出来

普通字段处理起来显然是要比嵌套字段方便不少,这就是“复制字段”的妙用。

4.处理字段特殊值

看到这里,可能有读者要问:

●  如果想添加一个字段,但是这个字段的值就是数字“1”会怎么样?

●  如果添加一个字段,这个字段的值就是一个普通的字符串,但不巧正好以“$”开头,又会怎么样呢?

下面这段代码是图7-1所示的数据集的查询结果。

查询结果如图7-29所示。

图7-29  “hello”字段和“abcd”字段都没有添加成功

由于特殊字段的值和“$project”的自身语法冲突了,导致所有以“$”开头的普通字符串和数字都不能添加。要解决这个问题,就需要使用另一个关键字“$literal”,代码如下:

查询结果如图7-30所示。

图7-30  使用“$literal”显示特殊的内容



7.3.4  实例22:分组操作


实例描述

对于数据集example_data_4,使用分组操作实现以下功能:

(1)对name字段去重。

(2)对每个人计算他们得分的最大值、最小值、平均值、总分,并统计没人有多少条记录。

(3)以name字段为基准对文档进行去重,保留最新一条数据。

(4)以name字段为基准对文档进行去重,保留最老一条数据。

分组操作对应的关键字为“$group”,它的作用是根据给出的字段Key,把所有Key的值相同的记录放在一起进行运算。这些运行包括常见的“求和($sum)”“计算平均数($avg)”“最大值($max)”“最小值($min)”等。

假设有一个数据集,见表7-1。

表7-1  数据集

如果按照“姓名”分组,那么就可以得到三个组,见表7-2、表7-3、表7-4。

表7-2  张三组

表7-3  李四组

表7-4  王五组

分组以后,就可以对各组计算平均值、最大值、最小值,或者进行求和。

1.在分组操作阶段去重

要学习分组操作阶段,首先从“去重”功能谈起。在第3章中,介绍了一个去重函数“distinct”,使用该函数可以实现对重复数据的去重。在RoboMongo中,去重后会返回一个数组,在Python中去重以后会返回一个列表。

分组操作,天然就自带去重的功能。假设example_data_4数据集如图7-31所示。

图7-31  example_data_4数据集

如果使用“distinct”函数对“name”字段去重,可以得到如图7-32所示的内容,其中一共只有3个名字。

图7-32  使用“distinct”函数去重

现在使用分组操作来去重。分组操作去重的语法如下:

仍然对“name”字段去重为,使用分组操作的语句如下:

db.getCollection('example_data_4').aggregate([{'$group':  {'_id':  '$name'}}])

查询结果如图7-33所示。

图7-33  使用分组操作去重

如果从Robo  3T的文本模式看返回数据,可以发现分组操作返回的是3条记录,如图7-34所示。

图7-34  返回三条记录

分组操作虽然也能实现去重操作,但是它返回的数据格式与“distinct”函数是不一样的。“distinct”函数返回的是数组,而分组操作返回的是3条记录。

2.分组并计算统计值

既然分组操作能返回记录,而一条记录又可以有多个字段。现在就来计算每个人得分(score)的最大值、最小值、总分和平均值,并把这些字段都放到分组操作的返回结果中。

要计算最大值、最小值、总分和平均值,用到的语法如下:



例如,对数据集example_data_4进行分组聚合操作,计算每个人得分的最大值、最小值、得分之和还有平均分。具体见代码7-5。

代码7-5  分组操作并计算统计值

查询结果如图7-35所示。

图7-35  分组计算统计值

在这里引入了“$max”“$min”“$sum”和“$avg”四个关键字,它们的用法都很简单,全部都是:

{$关键字:  $已有的字段}

提示:

原则上,“$sum”和“$avg”的值对应的字段的值应该都是数字。如果强行使用值为非数字的字段,那么“$sum”会返回0,“$avg”会返回“null”。而字符串是可以比较大小的,所以,“$max”与“$min”可以正常应用到字符串型的字段。

其中,“$sum”的值还可以使用数字“1”,这样查询语句就变成了统计每一个分组内有多少条记录,如图7-36所示。

图7-36  使用“$sum”统计组内记录条数

从图7-36中可以看出,“王五”有3条记录,“李四”有4条记录,“张三”有5条记录。

3.去重并选择最新或最老的数据

除了计算统计值外,分组操作还有另一个用处。在第3章曾说过:去重时,需求往往不是去重那么简单,需求可能是对“name”相同的所有记录,取最新的一条。

例如,对于example_data_4数据集,最后3条恰好是3个人的记录。这时直接用“find”取最后3条就能满足要求。那么现在再加几条记录,变成如图7-37所示的样子。需求是把方框框住的3条记录取出来。

图7-37  需要取出方框框住的内容

有一个比较“笨”的办法:先使用“distinct”获取所有的“name”,然后逐一根据每一个“name”的值去查询,最后对查询结果倒序再取第一条记录。

而如果使用分组操作,那就非常简单了。有以下两种方法。

(1)以name为基准去重,然后取各个字段的最新数据,见代码7-6。

代码7-6  分组操作并去重

查询结果如图7-38所示。

这里的关键字“$last”表示取最后一条记录。在  MongoDB  中,老数据先插入,新数据后插入,所以每一组的最后一条就是最新插入的数据。

图7-38  去重以后取最后插入的记录

(2)以name为基准去重,然后取所有字段最老的值。

在英语中,“last”的反义词是“first”,所以关键字“$first”的意思是取第一条,即是最早插入的数据。“$first”的查询结果如图7-39  所示。

图7-39  去重后取最早插入的记录



7.3.5  实例23:拆分数组


实例描述

在数据集example_data_3中,拆分size字段和price字段。

拆分数组阶段使用的关键字为“$unwind”,它的作用是把一条包含数组的记录拆分为很多条记录,每条记录拥有数组中的一个元素。

“$unwind”的语法非常简单:

例如,对于example_data_3数据集,“size”和“price”都是数组。现在要把“size”拆开。使用的聚合语句如下:

db.getCollection('example_data_3').aggregate([{'$unwind':  '$size'}])

查询结果如图7-40所示。

图7-40  把“size”数组拆开

“$unwind”一次只能拆开一个数组,如果还要把“price”字段拆开,则可以让第一次运行的结果再走一次“$unwind”阶段,见下方代码:

查询结果如图7-41所示。

可以看出。同时拆开两个字段以后,数据量大增。因为原来是一条记录,现在一共有“size数组长度乘以price数组长度”条记录。例如第1条记录,“size”数组有4个元素,“price”数组有4个元素,把两个数组都拆开以后,则原来的第1条记录变为16条记录。

图7-41  同时拆开“$size”和“$price”



7.3.6  实例24:联集合查询


实例描述

使用聚合操作的联集合查询,实现以下功能:

(1)以微博集合为准,查询用户集合。

(2)把查询结果中用户数组展开。

(3)把返回字段中的“name”和“work”字段变为普通字段。

(4)以用户集合为基准,查询微博集合。

所谓的联集合查询,相当于SQL中的联表查询。在某些情况下,一些相关的数据需要保存到多个集合中,然后使用某一个字段来进行关联。

以一个简化版微博为例。这个微博涉及到两个集合——用户集合与微博集合。用户集合如图7-42所示。微博集合如图7-43所示。

图7-42  用户集合

图7-43  微博集合

其中,用户集合记录了用户的ID(id)、用户名(name)、注册时间(register_time)和用户的职业(work)。微博集合记录了用户的ID(user_id)、微博内容(content)和发微博的时间(post_time)。

1.同时查询多个集合

如果想同时知道微博内容和发微博的用户的名字与职业,那么有两种方式。

●  从微博集合中,把每一条微博对应的用户ID拿出来,然后去用户集合中查询用户的姓名和职业。

●  使用联集合查询。

联集合查询的关键字为“$lookup”,它的语法如下:

其中的“主集合”与“被查集合”需要搞清楚。如果顺序搞反了,则结果会不同。

例如,现在需要在微博集合中查询用户信息,那么主集合就是微博集合,被查集合就是用户集合。于是查询语句可以写为以下:

代码7-7  联集合查询

查询结果如图7-44所示。

图7-44  在微博集合中查询用户集合

在查询结果中,多出来的“user_info”字段是一个数组,在数组中是一个嵌入式的文档。使用Robo  3T的文本模式可以看清楚里面的内容,如图7-45所示。

可以看出,“user_info”字段中的嵌套字段就是对应用户的信息。

提示:

这里“user_info”字段之所以会是一个数组,是因为被查询集合中可能有多条记录都满足条件,只有使用数组才能把它们都保存下来。由于用户集合每一个记录都是唯一的,所以这个数组只有一个元素。

图7-45  在文本模式中观察返回内容

2.美化输出结果

虽然返回的内容有了,但是结果不方便阅读。于是就可以使用“$unwind”与“$project”来美化一下返回结果。

(1)将用户数组展开。

首先,使用“$unwind”把数组中的嵌入式文档拆分出来,见代码7-8。

代码7-8  联集合查询并美化结果



查询结果如图7-46所示。

图7-46  使用“$unwind”拆分数组

(2)提取出“name”字段和“work”字段。

接下来,使用“$project”提取出“name”和“work”这两个字段,见代码7-9。

代码7-9  联集合查询并拆分结果再返回特定内容



查询结果如图7-47所示。

图7-47  美化联集合查询的输出结果

1.以用户集合为准查询微博集合

(1)查询每个用户发微博情况。

现在换一个角度:已知每一个用户,想知道这些用户发了哪些微博。这时,主集合就变为了用户集合,被查询集合变成了微博集合。此时,聚合查询语句也需要做相应的修改,见代码7-10。

代码7-10  以用户为基准联集合查询

查询结果如图7-48所示。

图7-48  在用户集合查询微博集合

(2)美化返回结果。

由于一个用户可以发送多条微博,所以“weibo_info”字段中就会有多个嵌入式的文档。继续使用“$unwind”与“$project”来美化一下结果,见代码7-11。

代码7-11  以用户为基准联集合查询,再拆分结果,最后输出特定内容



查询结果如图7-49所示。

图7-49  根据用户查询微博并美化

3.聚合操作阶段的组合方式

在上面的两个例子中,聚合操作的三个阶段“$lookup”“$unwind”和“$project”都用到了。这也正是MongoDB聚合功能的强大之处。MongoDB的聚合操作可以把各个不同的阶段组合起来,上一个阶段的输出作为下一个阶段的输入,从而实现非常灵活而强大的功能。

请读者思考:如果现在只需要查询名为“张小二”的用户发送的微博,那么应该把“$match”放在哪里?

实际上,“$match”可以放在“$lookup”的前面,也可以放在“$project”的后面,甚至还可以放在“$lookup”和“$unwind”的中间,或者放在“$unwind”与“$project”的中间。

在用户集合作为主集合的例子中,如果放在“$lookup”的前面,那么写法如下:

代码7-12  聚合操作优先数据筛选的写法

查询结果如图7-50所示。

图7-50  “$match”放在“$lookup”前面

如果把“$match”放在“$lookup”与“$unwind”中间,那么写法如下:

代码7-13  聚合操作先联集合查询再筛选数据的写法

查询结果如图7-51所示。

图7-51  把“$match”放在“$lookup”与“$unwind”中间

请读者自行测试另外两种情况——“$match”放在“$lookup”和“$unwind”的中间,或者放在“$unwind”与“$project”的中间。

从性能上考虑,建议把“$match”放在最前面,这样可以充分利用到  MongoDB  的索引,提高查询效率。



7.3.7  实例25:使用Python执行聚合操作


实例描述

在Python中执行聚合操作,以用户集合为基准,查询每个用户发送了哪些微博,并把返回结果中的微博字段数组展开,另外把content字段和post_time字段变成普通字段。

聚合操作涉及的代码,99%都可以直接复制/粘贴到Python中运行。例如7.3.6小节中的“在用户集合中查询微博集合”,使用Python的写法如下:

代码7-14  使用Python实现完整的聚合操作

查询结果如图7-52所示。

图7-52  在Python中运行聚合查询



本章小结


本章介绍了MongoDB的一些高级操作,包括显式AND操作、OR操作、嵌入式文档与数组,以及MongoDB的聚合功能。MongoDB聚合功能的核心思想是:充分里面各个阶段的搭配与协作来提前处理数据,从而充分利用MongoDB的性能来提高查询效率。

需要注意的是,聚合功能远非本书所介绍的这些内容。但是在Python有更加强大、直观易用、易调试、易维护的数据分析库Pandas的情况下,是否还需更加深入的去学习MongoDB的聚合功能,需要读者自行权衡。

上一章 目录 下一章