本节我将对MongoDB的聚合函数做详细说明。
5.4.1 max
与min
通常总是需要找到给定集合里的最大和最小值。使用SQL的数据库提供了min
和max
函数,但MongoDB没有这样的函数,我们必须自己实现。要找到某个字段中的最大值,可以按照该字段降序排序,并限制结果集为一个文档;按照相反顺序排序就能取到对应的最小值。例如,如果希望找到投票数最多的评论,查询需要对投票的字段进行排序,限制返回一个文档:
db.reviews.find({}).sort({helpful_votes: -1}).limit(1)
返回文档中的helpful_votes
字段包含了该字段中的最大值。要获取最小值,只要逆序排列就行了:
db.reviews.find({}).sort({helpful_votes: 1}).limit(1)
如果要在生产环境中发起查询,helpful_votes
字段最好能有一个索引。如果想获得特定产品里投票数最多的评论,则需要一个product_id
和helpful_votes
的复合索引。如果不清楚这么做的原因,可以阅读第7章。
5.4.2 distinct
MongoDB的distinct
命令是获取特定字段中不同值列表的最简单工具。该命令既适用于单键,也适用于数组键。distinct
默认覆盖整个集合,但也可以通过查询选择器进行约束。
可以像下面这样使用distinct
获取产品集合里所有唯一标签的列表:
db.products.distinct(/"tags/")
这很简单。如果希望操作products
集合的一个子集,可以传入一个查询选择器作为第二个参数。这里的查询将不同的标签值限定到Gardening Tools分类里的产品:
db.products.distinct(/"tags/", {category_id: ObjectId(/"6a5b1476238d3b4dd5000048/")})
聚合命令限制
在实用性方面,
distinct
和group
有一个很大的限制:它们返回的结果集不能超过16 MB。16 MB的限制并不是这些命令本身所强加的阈值,这是所有的初始查询结果集大小。distinct
和group
是以命令的方式实现的,也就是对特殊的$cmd
集合的查询,它们赖以生存的查询则受制于该限制。如果distinct
或group
处理不了你的聚合结果集,那么就只能使用map-reduce
代替了,它的结果可以保存在集合中而非内联(inline)返回。
5.4.3 group
group
和distinct
一样,也是数据库命令,因此它的结果集也受制于同样的16 MB响应限制。而且,为了减少内存消耗,group
不会处理多于10 000个唯一键。如果聚合操作在此范围内,group
是个不错的选择,因为通常情况下它会比map-reduce
快。
我们已经看过根据用户对评论分组的例子了,那个示例只能算“半个”。让我们快速回顾一下传递给group
的选项。
key
,描述分组字段的文档。举例来说,要根据category_id
分组,可以将{category_id: true}
作为键。此处还可以使用复合键,比如,若想根据user_id
和rating
对一系列帖子做分组,键看起来是这样的:{user_id: true, rating: true}
。除非使用keyf
,否则key选项是必需的。keyf
,这是一个JavaScript函数,应用于文档之上,为该文档生成一个键,当用于分组的键需要计算时,这个函数非常有用。举例来说,如果想根据每个文档创建时是周几来对结果集进行分组,但又不实际存储该值,就可以用键函数来生成这个键:
function(doc) { return {day: doc.created_at.getDay;}
这个函数会生成类似{day: 1}
这样的键。请注意,如果没有指定标准的key
,那么keyf
是必需的。
initial
,作为聚合结果初始值的文档。reduce
函数第一次运行时,该初始文档会作为聚合器的第一个值,通常会包含所有要聚合的键。举例来说,如果正在为每个分组项计算总投票数和总文档数,那么初始文档看起来是这样的:{vote_sum: 0.0, doc_count: 0}
。
请注意,该参数是必需的。
reduce
,用于执行聚合的JavaScript函数。该函数接受两个参数:正被迭代的当前文档和用于存储聚合结果的聚合器文档。聚合器的初始值就是初始文档。下面是一个聚合投票和文档总数的reduce
函数示例:
function(doc, aggregator) { aggregator.doc_count += 1; aggregator.vote_sum += doc.vote_count;}
请注意,reduce
函数并不返回任何内容,它只不过是修改聚合器对象。reduce
函数也是必需的。
cond
,过滤要聚合文档的查询选择器。如果不希望分组操作处理整个集合,就必须提供一个查询选择器。例如,假设只想聚合那些拥有五个以上投票的文档,可以提供以下查询选择器:{vote_count: {$gt: 5}}
。finalize
,在返回结果集之前应用于每个结果文档的JavaScript函数。该函数支持对分组操作的结果进行后置处理。我们通常会用它计算平均值,在分组结果的现有值之外,再加另一个值来保存平均值:
function(doc) { doc.average = doc.vote_count / doc.doc_count;}
诚然,group
有这么多选项,上手比较麻烦。但是,稍加实践之后,你会很快习惯的。
5.4.4 map-reduce
既然group
和map-reduce
提供了类似的功能,你可能会想MongoDB为什么要同时对它们提供支持呢?其实,在添加map-reduce
之前,group
是MongoDB唯一的聚合器,map-reduce
是后来出于一些原因加入的。首先,MapReduce风格的操作正在成为主流,而且将这种思考方式融入产品之中看起来是很明智的。1其次,也是更实际的原因:对大数据集进行迭代,尤其是在分片配置中,需要有分布式的聚合器,而MapReduce(范式)恰恰提供所需的内容。
1. 很多开发者是在谷歌那篇著名的关于分布式计算的论文(http://labs.google.com/papers/mapreduce.html)里初次看到MapReduce的。其中的思想后来成了Hadoop的基础,而Hadoop是一个使用分布式MapReduce处理大数据集的开源框架。之后MapReduce的思想得到了广泛传播,例如CouchDB就用MapReduce的范式来声明索引。
map-reduce
包含很多选项。此处详细对这些选项做了说明。
map
,应用于每个文档之上的JavaScript函数。该函数必须调用emit
来选择要聚合的键和值。在函数上下文中,this
的值指向当前文档。例如,假设想根据用户ID对结果分组,计算出总投票数和总文档数,映射函数应该是这样的:
function { emit(this.user_id, {vote_sum: this.vote_count, doc_count: 1});}
reduce
,一个JavaScript函数,接受一个键和一个值列表。该函数对返回值的结构有严格要求,必须总是与values
数组所提供的结构一致。reduce
函数通常会迭代一个值的列表,在此过程中对其进行聚合。回到我们的示例,以下展示如何处理映射函数输出的内容:
function(key, values) { var vote_sum = 0; var doc_sum = 0; values.forEach(function(value) { vote_sum += value.vote_sum; doc_sum += value.doc_sum; }); return {vote_sum: vote_sum, doc_sum: doc_sum};}
请注意,通常在聚合过程中不会用到key
参数的值。
query
,用于过滤映射处理的集合的查询选择器。该参数的作用与group
的cond
参数相同。sort
,对于查询的排序。与limit
选项搭配使用时非常有用,这样就可以对1000个最近创建的文档运行map-reduce
。limit
,一个整数,指定了查询和排序的条数。out
,该参数决定了如何返回输出内容。要将所有输出作为命令本身的结果,传入{inline: 1}
。请注意,这仅适用于结果集符合16 MB返回限制的情况。
另一个选择是将结果放到一个输出集合里。此时,out
的值必须是一个字符串,标明用于保存结果的集合的名称。
将结果保存到输出集合时有一个问题:如果最近运行过类似的map-reduce
,那么可能会覆盖现有数据。因此,还有两个集合输出选项:一个用于合并结果和老数据,另一个对数据进行reduce
处理。在合并的场景中,使用{merge: /"collectionName/"}
,新结果会覆盖拥有相同键的现有项。如果使用{reduce: /"collectionName/"}
,会调用reduce
函数根据新值来处理现有键的值。尤其是在执行要反复运行的MapReduce任务时,希望把新数据整合到已有的聚合之中,reduce
格外有用。在对集合执行新的MapReduce任务时,只需简单添加一个查询选择器来限制聚合所需的数据集。
finalize
,一个JavaScript函数,在reduce
阶段完成后会应用于每个返回的文档上。scope
,该文档指定了map
、reduce
和finalize
函数可全局访问的变量的值。verbose
,一个布尔值,为true
时,在命令返回文档中会包含对map-reduce
任务执行时间的统计信息。
在考虑使用MongoDB的map-reduce
和group
时,还有一个重要的限制需要引起注意:速度。对于大的数据集,这些聚合函数通常执行起来满足不了用户对速度的需求。这几乎都要归咎于MongoDB的JavaScript引擎。一个单线程解释(非编译)运行的JavaScript引擎是很难实现高性能的。
但也不要沮丧,map-reduce和group
被广泛使用于很多场景之中,并能充分胜任这些任务。对于那些还不适用的场景,则有其他方案,并有望在未来提供支持。其他方案是指在别处执行聚合,拥有大数据集的用户已经在Hadoop集群上成功处理过数据。未来有望加入新的聚合函数,它们使用编译的多线程代码。这些功能计划于MongoDB v2.0之后的某个时间发布,你可以关注https://jira.mongodb.org/browse/SERVER-447。