Mark

Mark 查看完整档案

北京编辑铜陵学院  |  建筑学 编辑长江期货  |  技术总监 编辑 www.handsomemark.com/ 编辑
编辑

talk is cheap,show me the code

个人动态

Mark 关注了标签 · 10月2日

django

Django是一个开放源代码的Web应用框架,由Python写成。采用了MVC的软件设计模式,即模型M,视图V和控制器C。它最初是被开发来用于管理劳伦斯出版集团旗下的一些以新闻内容为主的网站的。并于2005年7月在BSD许可证下发布。这套框架是以比利时的吉普赛爵士吉他手Django Reinhardt来命名的。

Django的主要目标是使得开发复杂的、数据库驱动的网站变得简单。Django注重组件的重用性和“可插拔性”,敏捷开发和DRY法则(Don't Repeat Yourself)。在Django中Python被普遍使用,甚至包括配置文件和数据模型。

关注 4657

Mark 回答了问题 · 2019-07-08

解决python 不断传入列表计算交集,并返回最终交集结果

import itertools


class test():
    def __init__(self):
        self.res=set()
    def listcompile(self,list1, list2, list3):
        list_4 = list1 + list2 + list3
        if len(self.res) < 1:
            self.res = set(list_4)
        else:
            self.res = set(list_4) & self.res
        return self.res



if __name__ == '__main__':
    t=test()
    result=t.listcompile([1,2,3,4,5,6],[3,4,5,6],[5,6,7])
    print(result)

    result = t.listcompile([0,9], [3, 4], [5, 6])
    print(result)

输出:

{1, 2, 3, 4, 5, 6, 7}
{3, 4, 5, 6}

关注 3 回答 2

Mark 提出了问题 · 2019-06-21

思否编辑器上传图片变形

clipboard.png

关注 1 回答 0

Mark 提出了问题 · 2019-06-21

思否编辑器上传图片变形

clipboard.png

关注 1 回答 0

Mark 发布了文章 · 2019-06-21

MongoDB指南---18、聚合命令

上一篇文章:MongoDB指南---17、MapReduce
下一篇文章:

MongoDB为在集合上执行基本的聚合任务提供了一些命令。这些命令在聚合框架出现之前就已经存在了,现在(大多数情况下)已经被聚合框架取代。然而,复杂的group操作可能仍然需要使用JavaScript,count和distinct操作可以被简化为普通命令,不需要使用聚合框架。

 count

count是最简单的聚合工具,用于返回集合中的文档数量:

> db.foo.count()
0
> db.foo.insert({"x" : 1})
> db.foo.count()
1

不论集合有多大,count都会很快返回总的文档数量。
也可以给count传递一个查询文档,Mongo会计算查询结果的数量:

> db.foo.insert({"x" : 2})
> db.foo.count()
2
> db.foo.count({"x" : 1})
1

对分页显示来说总数非常必要:“共439个,目前显示0~10个”。但是,增加查询条件会使count变慢。count可以使用索引,但是索引并没有足够的元数据供count使用,所以不如直接使用查询来得快。

 distinct

distinct用来找出给定键的所有不同值。使用时必须指定集合和键。

> db.runCommand({"distinct" : "people", "key" : "age"})

假设集合中有如下文档:

{"name" : "Ada", "age" : 20}
{"name" : "Fred", "age" : 35}
{"name" : "Susan", "age" : 60}
{"name" : "Andy", "age" : 35}

如果对"age"键使用distinct,会得到所有不同的年龄:

> db.runCommand({"distinct" : "people", "key" : "age"})
{"values" : [20, 35, 60], "ok" : 1}

这里还有一个常见问题:有没有办法获得集合里面所有不同的键呢?MongoDB并没有直接提供这样的功能,但是可以用MapReduce(详见7.3节)自己写一个。

group

使用group可以执行更复杂的聚合。先选定分组所依据的键,而后MongoDB就会将集合依据选定键的不同值分成若干组。然后可以对每一个分组内的文档进行聚合,得到一个结果文档。
如果你熟悉SQL,那么这个group和SQL中的GROUP BY差不多。
假设现在有个跟踪股票价格的站点。从上午10点到下午4点每隔几分钟就会更新某只股票的价格,并保存在MongoDB中。现在报表程序要获得近30天的收盘价。用group就可以轻松办到。
股价集合中包含数以千计如下形式的文档:

{"day" : "2010/10/03", "time" : "10/3/2010 03:57:01 GMT-400", "price" : 4.23}
{"day" : "2010/10/04", "time" : "10/4/2010 11:28:39 GMT-400", "price" : 4.27}
{"day" : "2010/10/03", "time" : "10/3/2010 05:00:23 GMT-400", "price" : 4.10}
{"day" : "2010/10/06", "time" : "10/6/2010 05:27:58 GMT-400", "price" : 4.30}
{"day" : "2010/10/04", "time" : "10/4/2010 08:34:50 GMT-400", "price" : 4.01}

注意,由于精度的问题,实际使用中不要将金额以浮点数的方式存储,这个例子只是为了简便才这么做。
我们需要的结果列表中应该包含每天的最后交易时间和价格,就像下面这样:

[
    {"time" : "10/3/2010 05:00:23 GMT-400", "price" : 4.10},
    {"time" : "10/4/2010 11:28:39 GMT-400", "price" : 4.27},
    {"time" : "10/6/2010 05:27:58 GMT-400", "price" : 4.30}
]

先把集合按照"day"字段进行分组,然后在每个分组中查找"time"值最大的文档,将其添加到结果集中就完成了。整个过程如下所示:

> db.runCommand({"group" : {
... "ns" : "stocks",
... "key" : "day",
... "initial" : {"time" : 0},
... "$reduce" : function(doc, prev) {
...     if (doc.time > prev.time) {
...         prev.price = doc.price;
...         prev.time = doc.time;
...     }
... }}})

把这个命令分解开看看。

  • "ns" : "stocks"

指定要进行分组的集合。

  • "key" : "day"

指定文档分组依据的键。这里就是"day"键。所有"day"值相同的文档被分到一组。

  • "initial" : {"time" : 0}

每一组reduce函数调用中的初始"time"值,会作为初始文档传递给后续过程。每一组的所有成员都会使用这个累加器,所以它的任何变化都可以保存下来。

  • "$reduce" : function(doc, prev) { ... }

这个函数会在集合内的每个文档上执行。系统会传递两个参数:当前文档和累加器文档(本组当前的结果)。本例中,想让reduce函数比较当前文档的时间和累加器的时间。如果当前文档的时间更晚一些,则将累加器的日期和价格替换为当前文档的值。别忘了,每一组都有一个独立的累加器,所以不必担心不同日期的命令会使用同一个累加器。

在问题一开始的描述中,就提到只要最近30天的股价。然而,我们在这里迭代了整个集合。这就是要添加"condition"的原因,因为这样就可以只对必要的文档进行处理。

> db.runCommand({"group" : {
... "ns" : "stocks",
... "key" : "day",
... "initial" : {"time" : 0},
... "$reduce" : function(doc, prev) {
...     if (doc.time > prev.time) {
...            prev.price = doc.price;
...         prev.time = doc.time;
...     }},
... "condition" : {"day" : {"$gt" : "2010/09/30"}}
... }})

有些参考资料提及"cond"键或者"q"键,其实和"condition"键是完全一样的(就是表达力不如"condition"好)。
最后就会返回一个包含30个文档的数组,其实每个文档都是一个分组。每组都包含分组依据的键(这里就是"day" : string)以及这组最终的prev值。如果有的文档不存在指定用于分组的键,这些文档会被单独分为一组,缺失的键会使用"day : null"这样的形式。在"condition"中加入"day" : {"$exists" : true}就可以排除不包含指定用于分组的键的文档。group命令同时返回了用到的文档总数和"key"的不同值数量:

> db.runCommand({"group" : {...}})
{
    "retval" :
        [
            {
                "day" : "2010/10/04",
                "time" : "Mon Oct 04 2010 11:28:39 GMT-0400 (EST)"
                "price" : 4.27
            },
            ...
        ],
    "count" : 734,
    "keys" : 30,
    "ok" : 1
}

这里每组的"price"都是显式设置的,"time"先由初始化器设置,然后在迭代中进行更新。"day"是默认被加进去的,因为用于分组的键会默认加入到每个"retval"内嵌文档中。要是不想在结果集中看到这个键,可以用完成器将累加器文档变为任何想要的形态,甚至变换成非文档(例如数字或字符串)。

1. 使用完成器

完成器(finalizer)用于精简从数据库传到用户的数据,这个步骤非常重要,因为group命令的输出结果需要能够通过单次数据库响应返回给用户。为进一步说明,这里举个博客的例子,其中每篇文章都有多个标签(tag)。现在要找出每天最热门的标签。可以(再一次)按天分组,得到每一个标签的计数。就像下面这样:

> db.posts.group({
... "key" : {"day" : true},
... "initial" : {"tags" : {}},
... "$reduce" : function(doc, prev) {
...     for (i in doc.tags) {
...         if (doc.tags[i] in prev.tags) {
...             prev.tags[doc.tags[i]]++;
...         } else {
...             prev.tags[doc.tags[i]] = 1;
...         }
...     }
... }})

得到的结果如下所示:

[
    {"day" : "2010/01/12", "tags" : {"nosql" : 4, "winter" : 10, "sledding" : 2}},
    {"day" : "2010/01/13", "tags" : {"soda" : 5, "php" : 2}},
    {"day" : "2010/01/14", "tags" : {"python" : 6, "winter" : 4, "nosql": 15}}
]

接着可以在客户端找出"tags"文档中出现次数最多的标签。然而,向客户端发送每天所有的标签文档需要许多额外的开销——每天所有的键/值对都被传送给用户,而我们需要的仅仅是一个字符串。这也就是group有一个可选的"finalize"键的原因。"finalize"可以包含一个函数,在每组结果传递到客户端之前调用一次。可以使用"finalize"函数将不需要的内容从结果集中移除:

> db.runCommand({"group" : {
... "ns" : "posts",
... "key" : {"day" : true},
... "initial" : {"tags" : {}},
... "$reduce" : function(doc, prev) {
...     for (i in doc.tags) {
...         if (doc.tags[i] in prev.tags) {
...             prev.tags[doc.tags[i]]++;
...         } else {
...             prev.tags[doc.tags[i]] = 1;
...         }
...     },
... "finalize" : function(prev) {
...     var mostPopular = 0;
...     for (i in prev.tags) {
...         if (prev.tags[i] > mostPopular) {
...             prev.tag = i;
...             mostPopular = prev.tags[i];
...         }
...     }
...     delete prev.tags
... }}})

现在,我们就得到了想要的信息,服务器返回的内容可能如下:

[
    {"day" : "2010/01/12", "tag" : "winter"},
    {"day" : "2010/01/13", "tag" : "soda"},
    {"day" : "2010/01/14", "tag" : "nosql"}
]

finalize可以对传递进来的参数进行修改,也可以返回一个新值。

2. 将函数作为键使用

有时分组所依据的条件可能会非常复杂,而不是单个键。比如要使用group计算每个类别有多少篇博客文章(每篇文章只属于一个类别)。由于不同作者的风格不同,填写分类名称时可能有人使用大写也有人使用小写。所以,如果要是按类别名来分组,最后“MongoDB”和“mongodb”就是两个完全不同的组。为了消除这种大小写的影响,就要定义一个函数来决定文档分组所依据的键。
定义分组函数就要用到$keyf键(注意不是"key"),使用"$keyf"的group命令如下所示:

> db.posts.group({"ns" : "posts",
... "$keyf" : function(x) { return x.category.toLowerCase(); },
... "initializer" : ... })

有了"$keyf",就能依据各种复杂的条件进行分组了。

查看原文

赞 1 收藏 1 评论 0

Mark 发布了文章 · 2019-06-21

MongoDB指南---17、MapReduce

上一篇文章:MongoDB指南---16、聚合
下一篇文章:MongoDB指南---18、聚合命令

MapReduce是聚合工具中的明星,它非常强大、非常灵活。有些问题过于复杂,无法使用聚合框架的查询语言来表达,这时可以使用MapReduce。MapReduce使用JavaScript作为“查询语言”,因此它能够表达任意复杂的逻辑。然而,这种强大是有代价的:MapReduce非常慢,不应该用在实时的数据分析中。
MapReduce能够在多台服务器之间并行执行。它会将一个大问题分割为多个小问题,将各个小问题发送到不同的机器上,每台机器只负责完成一部分工作。所有机器都完成时,再将这些零碎的解决方案合并为一个完整的解决方案。
MapReduce需要几个步骤。最开始是映射(map),将操作映射到集合中的每个文档。这个操作要么“无作为”,要么“产生一些键和X个值”。然后就是中间环节,称作洗牌(shuffle),按照键分组,并将产生的键值组成列表放到对应的键中。化简(reduce)则把列表中的值化简成一个单值。这个值被返回,然后接着进行洗牌,直到每个键的列表只有一个值为止,这个值也就是最终结果。
下面会多举几个MapReduce的例子,这个工具非常强大,但也有点复杂。

 示例1:找出集合中的所有键

用MapReduce来解决这个问题有点大材小用,不过还是一种了解其机制的不错的方式。要是已经知道MapReduce的原理,则直接跳到本节最后,看看MongoDB中MapReduce的使用注意事项。
MongoDB会假设你的模式是动态的,所以并不跟踪记录每个文档中的键。通常找到集合中所有文档所有键的最好方式就是用MapReduce。在本例中,会记录每个键出现了多少次。内嵌文档中的键就不计算了,但给map函数做个简单修改就能实现这个功能了。
在映射环节,我们希望得到集合中每个文档的所有键。map函数使用特别的emit函数“返回”要处理的值。emit会给MapReduce一个键(类似于前面$group所使用的键)和一个值。这里用emit将文档某个键的计数(count)返回({count : 1})。我们想为每个键单独计数,所以为文档中的每个键调用一次emit。this就是当前映射文档的引用:

> map = function() {
... for (var key in this) {
...     emit(key, {count : 1});
... }};

这样就有了许许多多{count : 1}文档,每一个都与集合中的一个键相关。这种由一个或多个{count : 1}文档组成的数组,会传递给reduce函数。reduce函数有两个参数,一个是key,也就是emit返回的第一个值,还有另外一个数组,由一个或者多个与键对应的{count : 1}文档组成。

> reduce = function(key, emits) {
... total = 0;
... for (var i in emits) {
...     total += emits[i].count;
... }
... return {"count" : total};
... }

reduce一定要能够在之前的map阶段或者前一个reduce阶段的结果上反复执行。所以reduce返回的文档必须能作为reduce的第二个参数的一个元素。例如,x键映射到了3个文档{count : 1,id : 1}、{count : 1,id : 2}和{count : 1,id : 3},其中id键只用于区分不同的文档。MongoDB可能会这样调用reduce:

> r1 = reduce("x", [{count : 1, id : 1}, {count : 1, id : 2}])
{count : 2}
> r2 = reduce("x", [{count : 1, id : 3}])
{count : 1}
> reduce("x", [r1, r2])
{count : 3}

不能认为第二个参数总是初始文档之一(比如{count:1})或者长度固定。reduce应该能处理emit文档和其他reduce返回结果的各种组合。
总之,MapReduce函数可能会是下面这样:

> mr = db.runCommand({"mapreduce" : "foo", "map" : map, "reduce" : reduce})
{
    "result" : "tmp.mr.mapreduce_1266787811_1",
    "timeMillis" : 12,
    "counts" : {
        "input" : 6
        "emit" : 14
        "output" : 5
    },
    "ok" : true
}

MapReduce返回的文档包含很多与操作有关的元信息。

  • "result" : "tmp.mr.mapreduce_1266787811_1"

这是存放MapReduce结果的集合名。这是个临时集合,MapReduce的连接关闭后它就被自动删除了。本章稍后会介绍如何指定一个好一点的名字以及将结果集合持久化。

  • "timeMillis" : 12

操作花费的时间,单位是毫秒。

  • "counts" : { ... }

这个内嵌文档主要用作调试,其中包含3个键。

  • "input" : 6

发送到map函数的文档个数。

  • "emit" : 14

在map函数中emit被调用的次数。

  • "output" : 5

结果集合中的文档数量。
对结果集合进行查询会发现原有集合的所有键及其计数:
···

db[mr.result].find()
{ "_id" : "_id", "value" : { "count" : 6 } }
{ "_id" : "a", "value" : { "count" : 4 } }
{ "_id" : "b", "value" : { "count" : 2 } }
{ "_id" : "x", "value" : { "count" : 1 } }
{ "_id" : "y", "value" : { "count" : 1 } }
···
这个结果集中的每个"_id"对应原集合中的一个键,"value"键的值就是reduce的最终结果。

 示例2:网页分类

假设有个网站,人们可以提交其他网页的链接,比如reddit(http://www.reddit.com)。提交者可以给这个链接添加标签,表明主题,比如politics、geek或者icanhascheezburger。可以用MapReduce找出哪个主题最为热门,热门与否由最近的投票决定。
首先,建立一个map函数,发出(emit)标签和一个基于流行度和新旧程度的值。

map = function() {
    for (var i in this.tags) {
        var recency = 1/(new Date() - this.date); 
        var score = recency * this.score;

        emit(this.tags[i], {"urls" : [this.url], "score" : score});
    }
};

现在就化简同一个标签的所有值,以得到这个标签的分数:

reduce = function(key, emits) {
    var total = {urls : [], score : 0}
    for (var i in emits) {
        emits[i].urls.forEach(function(url) {
            total.urls.push(url);
        }
        total.score += emits[i].score;
    }
    return total;
};

最终的集合包含每个标签的URL列表和表示该标签流行程度的分数。

 MongoDB和MapReduce

前面两个例子只用到了mapreduce、map和reduce键。这3个键是必需的,但是MapReduce命令还有很多可选的键。

  • "finalize" : function

可以将reduce的结果发送给这个键,这是整个处理过程的最后一步。

  • "keeptemp" : boolean

如果为值为true,那么在连接关闭时会将临时结果集合保存下来,否则不保存。

  • "out" : string

输出集合的名称。如果设置了这选项,系统会自动设置keeptemp : true。

  • "query" : document

在发往map函数前,先用指定条件过滤文档。

  • "sort" : document

在发往map前先给文档排序(与limit一同使用非常有用)。

  • "limit" : integer

发往map函数的文档数量的上限。

  • "scope" : document

可以在JavaScript代码中使用的变量。

  • "verbose" : boolean

是否记录详细的服务器日志。

1. finalize函数

和group命令一样,MapReduce也可以使用finalize函数作为参数。它会在最后一个reduce输出结果后执行,然后将结果存到临时集合中。
返回体积比较大的结果集对MapReduce不是什么大不了的事情,因为它不像group那样有4 MB的限制。然而,信息总是要传递出去的,通常来说,finalize是计算平均数、裁剪数组、清除多余信息的好时机。

2. 保存结果集合

默认情况下,Mongo会在执行MapReduce时创建一个临时集合,集合名是系统选的一个不太常用的名字,将"mr"、执行MapReduce的集合名、时间戳以及数据库作业ID,用“.”连成一个字符串,这就是临时集合的名字。结果产生形如mr.stuff.18234210220.2这样的名字。MongoDB会在调用的连接关闭时自动销毁这个集合(也可以在用完之后手动删除)。如果希望保存这个集合,就要将keeptemp选项指定为true。
如果要经常使用这个临时集合,你可能想给它起个好点的名字。利用out选项(该选项接受字符串作为参数)就可以为临时集合指定一个易读易懂的名字。如果用了out选项,就不必指定keeptemp : true了,因为指定out选项时系统会将keeptemp设置为true。即便你取了一个非常好的名字,MongoDB也会在MapReduce的中间过程使用自动生成的集合名。处理完成后,会自动将临时集合的名字更改为你指定的集合名,这个重命名的过程是原子性的。也就是说,如果多次对同一个集合调用MapReduce,也不会在操作中遇到集合不完整的情况。
MapReduce产生的集合就是一个普通的集合,在这个集合上执行MapReduce完全没有问题,或者在前一个MapReduce的结果上执行MapReduce也没有问题,如此往复直到无穷都没问题!

3. 对文档子集执行MapReduce

有时需要对集合的一部分执行MapReduce。只需在传给map函数前使用查询对文档进行过滤就好了。
每个传递给map函数的文档都要先反序列化,从BSON对象转换为JavaScript对象,这个过程非常耗时。如果事先知道只需要对集合的一部分文档执行MapReduce,那么在map之前先对文档进行过滤可以极大地提高map速度。可以通过"query"、"limit"和"sort"等键对文档进行过滤。
"query"键的值是一个查询文档。通常查询返回的结果会传递给map函数。例如,有一个做跟踪分析的应用程序,现在我们需要上周的总结摘要,只要使用如下命令对上周的文档执行MapReduce就好了:

> db.runCommand({"mapreduce" : "analytics", "map" : map, "reduce" : reduce,
                 "query" : {"date" : {"$gt" : week_ago}}})

sort选项和limit一起使用时通常能够发挥非常大的作用。limit也可以单独使用,用来截取一部分文档发送给map函数。
如果在上个例子中想分析最近10 000个页面的访问次数(而不是最近一周的),就可以使用limit和sort:

> db.runCommand({"mapreduce" : "analytics", "map" : map, "reduce" : reduce,
                 "limit" : 10000, "sort" : {"date" : -1}})

query、limit、sort可以随意组合,但是如果不使用limit的话,sort就不能有效发挥作用。

4. 使用作用域

MapReduce可以为map、reduce、finalize函数都采用一种代码类型。但多数语言里,可以指定传递代码的作用域。然而MapReduce会忽略这个作用域。它有自己的作用域键"scope",如果想在MapReduce中使用客户端的值,则必须使用这个参数。可以用“变量名 : 值”这样的普通文档来设置该选项,然后在map、reduce和finalize函数中就能使用了。作用域在这些函数内部是不变的。例如,上一节的例子使用1/(newDate() - this.date)计算页面的新旧程度。可以将当前日期作为作用域的一部分传递进去:

> db.runCommand({"mapreduce" : "webpages", "map" : map, "reduce" : reduce,
                 "scope" : {now : new Date()}})

这样,在map函数中就能计算1/(now - this.date)了。

5. 获得更多的输出

还有个用于调试的详细输出选项。如果想看看MapReduce的运行过程,可以将"verbose"指定为true。
也可以用print把map、reduce、finalize过程中的信息输出到服务器日志上。

上一篇文章:MongoDB指南---16、聚合
下一篇文章:MongoDB指南---18、聚合命令
查看原文

赞 0 收藏 0 评论 0

Mark 发布了文章 · 2019-06-20

MongoDB指南---16、聚合

上一篇文章:MongoDB指南---15、特殊的索引和集合:地理空间索引、使用GridFS存储文件
下一篇文章:MongoDB指南---17、MapReduce

如果你有数据存储在MongoDB中,你想做的可能就不仅仅是将数据提取出来那么简单了;你可能希望对数据进行分析并加以利用。本章介绍MongoDB提供的聚合工具:

  • 聚合框架;
  • MapReduce;
  • 几个简单聚合命令:count、distinct和group。

 聚合框架

使用聚合框架可以对集合中的文档进行变换和组合。基本上,可以用多个构件创建一个管道(pipeline),用于对一连串的文档进行处理。这些构件包括筛选(filtering)、投射(projecting)、分组(grouping)、排序(sorting)、限制(limiting)和跳过(skipping)。
例如,有一个保存着杂志文章的集合,你可能希望找出发表文章最多的那个作者。假设每篇文章被保存为MongoDB中的一个文档,可以按照如下步骤创建管道。

  1. 将每个文章文档中的作者投射出来。
  2. 将作者按照名字排序,统计每个名字出现的次数。
  3. 将作者按照名字出现次数降序排列。
  4. 将返回结果限制为前5个。

这里面的每一步都对应聚合框架中的一个操作符:

  1. {"$project" : {"author" : 1}}

这样可以将"author"从每个文档中投射出来。
这个语法与查询中的字段选择器比较像:可以通过指定"fieldname" : 1选择需要投射的字段,或者通过指定"fieldname":0排除不需要的字段。执行完这个"$project"操作之后,结果集中的每个文档都会以{"_id" : id, "author" : "authorName"}这样的形式表示。这些结果只会在内存中存在,不会被写入磁盘。

  1. {"$group" : {"_id" : "$author", "count" : {"$sum" : 1}}}

这样就会将作者按照名字排序,某个作者的名字每出现一次,就会对这个作者的"count"加1。
这里首先指定了需要进行分组的字段"author"。这是由"_id" : "$author"指定的。可以将这个操作想象为:这个操作执行完后,每个作者只对应一个结果文档,所以"author"就成了文档的唯一标识符("_id")。
第二个字段的意思是为分组内每个文档的"count"字段加1。注意,新加入的文档中并不会有"count"字段;这"$group"创建的一个新字段。
执行完这一步之后,结果集中的每个文档会是这样的结构:

{"_id" : "authorName", "count" : articleCount}。
  1. {"$sort" : {"count" : -1}}

这个操作会对结果集中的文档根据"count"字段进行降序排列。

  1. {"$limit" : 5}

这个操作将最终的返回结果限制为当前结果中的前5个文档。
在MongoDB中实际运行时,要将这些操作分别传给aggregate()函数:

> db.articles.aggregate({"$project" : {"author" : 1}},
... {"$group" : {"_id" : "$author", "count" : {"$sum" : 1}}},
... {"$sort" : {"count" : -1}},
... {"$limit" : 5})
{
    "result" : [
        {
            "_id" : "R. L. Stine",
            "count" : 430
        }, 
        {
            "_id" : "Edgar Wallace",
            "count" : 175
        },
        {
            "_id" : "Nora Roberts",
            "count" : 145
        },
        {
            "_id" : "Erle Stanley Gardner",
            "count" : 140
        },
        {
            "_id" : "Agatha Christie",
            "count" : 85
        }
    ],
    "ok" : 1
}

aggregate()会返回一个文档数组,其中的内容是发表文章最多的5个作者。

如果管道没有给出预期的结果,就需要进行调试,调试时,可以先只指定第一个管道操作符。如果这时得到了预期结果,那就再指定第二个管道操作符。以前面的例子来说,首先要试着只使用"$project"操作符进行聚合;如果这个操作符的结果是有效的,就再添加"$group"操作符;如果结果还是有效的,就再添加"$sort";最后再添加"$limit"操作符。这样就可以逐步定位到造成问题的操作符。

本书写作时,聚合框架还不能对集合进行写入操作,因此所有结果必须返回给客户端。所以,聚合的结果必须要限制在16 MB以内(MongoDB支持的最大响应消息大小)。

管道操作符

每个操作符都会接受一连串的文档,对这些文档做一些类型转换,最后将转换后的文档作为结果传递给下一个操作符(对于最后一个管道操作符,是将结果返回给客户端)。
不同的管道操作符可以按任意顺序组合在一起使用,而且可以被重复任意多次。例如,可以先做"$match",然后做"$group",然后再做"$match"(与之前的"$match"匹配不同的查询条件)。

 $match

$match用于对文档集合进行筛选,之后就可以在筛选得到的文档子集上做聚合。例如,如果想对Oregon(俄勒冈州,简写为OR)的用户做统计,就可以使用{$match : {"state" : "OR"}}。"$match"可以使用所有常规的查询操作符("$gt"、"$lt"、"$in"等)。有一个例外需要注意:不能在"$match"中使用地理空间操作符。
通常,在实际使用中应该尽可能将"$match"放在管道的前面位置。这样做有两个好处:一是可以快速将不需要的文档过滤掉,以减少管道的工作量;二是如果在投射和分组之前执行"$match",查询可以使用索引。

 $project

相对于“普通”的查询而言,管道中的投射操作更加强大。使用"$project"可以从子文档中提取字段,可以重命名字段,还可以在这些字段上进行一些有意思的操作。
最简单的一个"$project"操作是从文档中选择想要的字段。可以指定包含或者不包含一个字段,它的语法与查询中的第二个参数类似。如果在原来的集合上执行下面的代码,返回的结果文档中只包含一个"author"字段。

> db.articles.aggregate({"$project" : {"author" : 1, "_id" : 0}})

默认情况下,如果文档中存在"_id"字段,这个字段就会被返回("_id"字段可以被一些管道操作符移除,也可能已经被之前的投射操作给移除了)。可以使用上面的代码将"_id"从结果文档中移除。包含字段和排除字段的规则与常规查询中的语法一致。
也可以将投射过的字段进行重命名。例如,可以将每个用户文档的"_id"在返回结果中重命名为"userId":

> db.users.aggregate({"$project" : {"userId" : "$_id", "_id" : 0}})
{
    "result" : [
        {
            "userId" : ObjectId("50e4b32427b160e099ddbee7")
        },
        {
            "userId" : ObjectId("50e4b32527b160e099ddbee8")
        }
        ...
    ],
    "ok" : 1
}

这里的"$fieldname"语法是为了在聚合框架中引用fieldname字段(上面的例子中是"_id")的值。例如,"$age"会被替换为"age"字段的内容(可能是数值,也可能是字符串),"$tags.3"会被替换为tags数组中的第4个元素。所以,上面例子中的"$_id"会被替换为进入管道的每个文档的"_id"字段的值。
注意,必须明确指定将"_id"排除,否则这个字段的值会被返回两次:一次被标为"userId",一次被标为"_id"。可以使用这种技术生成字段的多个副本,以便在之后的"$group"中使用。
在对字段进行重命名时,MongoDB并不会记录字段的历史名称。因此,如果在"originalFieldname"字段上有一个索引,聚合框架无法在下面的排序操作中使用这个索引,尽管人眼一下子就能看出下面代码中的"newFieldname"与"originalFieldname"表示同一个字段。

> db.articles.aggregate({"$project" : {"newFieldname" : "$originalFieldname"}},
... {"$sort" : {"newFieldname" : 1}})

所以,应该尽量在修改字段名称之前使用索引。

1. 管道表达式

最简单的"$project"表达式是包含和排除字段,以及字段名称("$fieldname")。但是,还有一些更强大的选项。也可以使用表达式(expression)将多个字面量和变量组合在一个值中使用。
在聚合框架中有几个表达式可用来组合或者进行任意深度的嵌套,以便创建复杂的表达式。

2. 数学表达式(mathematical expression)

算术表达式可用于操作数值。指定一组数值,就可以使用这个表达式进行操作了。例如,下面的表达式会将"salary"和"bonus"字段的值相加。

> db.employees.aggregate(
... {
...     "$project" : {
...          "totalPay" : {
...              "$add" : ["$salary", "$bonus"]
...          }
...      }
... })

可以将多个表达式嵌套在一起组成更复杂的表达式。假设我们想要从总金额中扣除为401(k)缴纳的金额。可以使用"$subtract"表达式:

401(k)是美国的一种养老金计划。——译者注
> db.employees.aggregate(
... {
...     "$project" : {
...         "totalPay" : {
...              "$subtract" : [{"$add" : ["$salary", "$bonus"]}, "$401k"]
...         }
...     }
... })

表达式可以进行任意层次的嵌套。
下面是每个操作符的语法:

  • "$add" : [expr1[, expr2, ..., exprN]]

这个操作符接受一个或多个表达式作为参数,将这些表达式相加。

  • "$subtract" : [expr1, expr2]

接受两个表达式作为参数,用第一个表达式减去第二个表达式作为结果。

  • "$multiply" : [expr1[, expr2, ..., exprN]]

接受一个或者多个表达式,并且将它们相乘。

  • "$divide" : [expr1, expr2]

接受两个表达式,用第一个表达式除以第二个表达式的商作为结果。

  • "$mod" : [expr1, expr2]

接受两个表达式,将第一个表达式除以第二个表达式得到的余数作为结果。

3. 日期表达式(date expression)

许多聚合都是基于时间的:上周发生了什么?上个月发生了什么?过去一年间发生了什么?因此,聚合框架中包含了一些用于提取日期信息的表达式:"$year"、“$month”、"$week"、"$dayOfMonth"、"$dayOfWeek"、"$dayOfYear"、"$hour"、"$minute"和"$second"。只能对日期类型的字段进行日期操作,不能对数值类型字段做日期操作。
每种日期类型的操作都是类似的:接受一个日期表达式,返回一个数值。下面的代码会返回每个雇员入职的月份:

> db.employees.aggregate(
... {
...     "$project" : {
...         "hiredIn" : {"$month" : "$hireDate"}
...     }
... })

也可以使用字面量日期。下面的代码会计算出每个雇员在公司内的工作时间:

> db.employees.aggregate(
... {
...     "$project" : { 
...         "tenure" : {
...             "$subtract" : [{"$year" : new Date()}, {"$year" : "$hireDate"}]
...         }
...     }
... })

4. 字符串表达式(string expression)

也有一些基本的字符串操作可以使用,它们的签名如下所示:

  • "$substr" : [expr, startOffset, numToReturn]

其中第一个参数expr必须是个字符串,这个操作会截取这个字符串的子串(从第startOffset字节开始的numToReturn字节,注意,是字节,不是字符。在多字节编码中尤其要注意这一点)expr必须是字符串。

  • "$concat" : [expr1[, expr2, ..., exprN]]

将给定的表达式(或者字符串)连接在一起作为返回结果。

  • "$toLower" : expr

参数expr必须是个字符串值,这个操作返回expr的小写形式。

  • "$toUpper" : expr

参数expr必须是个字符串值,这个操作返回expr的大写形式。
改变字符大小写的操作,只保证对罗马字符有效。
下面是一个生成 j.doe@example.com格式的email地址的例子。它提取"$firstname"的第一个字符,将其与多个常量字符串和"$lastname"连接成一个字符串:

> db.employees.aggregate(
... {
...     "$project" : {
...         "email" : {
...             "$concat" : [
...                 {"$substr" : ["$firstName", 0, 1]},
...                 ".",
...                 "$lastName",
...                 "@example.com"
...             ]
...         }
...     }
... })

5. 逻辑表达式(logical expression)

有一些逻辑表达式可以用于控制语句。
下面是几个比较表达式。

  • "$cmp" : [expr1, expr2]

比较expr1和expr2。如果expr1等于expr2,返回0;如果expr1 < expr2,返回一个负数;如果expr1 >expr2,返回一个正数。

  • "$strcasecmp" : [string1, string2]

比较string1和string2,区分大小写。只对罗马字符组成的字符串有效。

  • "$eq"/"$ne"/"$gt"/"$gte"/"$lt"/"$lte" : [expr1, expr2]

对expr1和expr2执行相应的比较操作,返回比较的结果(true或false)。

下面是几个布尔表达式。

  • "$and" : [expr1[, expr2, ..., exprN]]

如果所有表达式的值都是true,那就返回true,否则返回false。

  • "$or" : [expr1[, expr2, ..., exprN]]

只要有任意表达式的值为true,就返回true,否则返回false。

  • "$not" : expr

对expr取反。

还有两个控制语句。

  • "$cond" : [booleanExpr, trueExpr, falseExpr]

如果booleanExpr的值是true,那就返回trueExpr,否则返回falseExpr。

  • "$ifNull" : [expr, replacementExpr]

如果expr是null,返回replacementExpr,否则返回expr。

通过这些操作符,就可以在聚合中使用更复杂的逻辑,可以对不同数据执行不同的代码,得到不同的结果。
管道对于输入数据的形式有特定要求,所以这些操作符在传入数据时要特别注意。算术操作符必须接受数值,日期操作符必须接受日期,字符串操作符必须接受字符串,如果有字符缺失,这些操作符就会报错。如果你的数据集不一致,可以通过这个条件来检测缺失的值,并且进行填充。

6. 一个提取的例子

假如有个教授想通过某种比较复杂的计算为学生打分:出勤率占10%,日常测验成绩占30%,期末考试占60%(如果是老师最宠爱的学生,那么分数就是100)。可以使用如下代码:

> db.students.aggregate(
... {
...     "$project" : {
...         "grade" : {
...             "$cond" : [
...                 "$teachersPet",
...                 100, // if
...                 {    // else
...                     "$add" : [
...                         {"$multiply" : [.1, "$attendanceAvg"]},
...                         {"$multiply" : [.3, "$quizzAvg"]},
...                         {"$multiply" : [.6, "$testAvg"]}
...                     ]
...                 }
...             ]
...         }
...     }
... }) 

$group

$group操作可以将文档依据特定字段的不同值进行分组。下面是几个分组的例子。

  • 如果我们以分钟作为计量单位,希望找出每天的平均湿度,就可以根据"day"字段进行分组。
  • 如果有一个学生集合,希望按照分数等级将学生分为多个组,可以根据"grade"字段进行分组。
  • 如果有一个用户集合,希望知道每个城市有多少用户,可以根据"state"和"city"两个字段对集合进行分组,每个"city"/"state"对对应一个分组。不应该只根据"city"字段进行分组,因为不同的州可能拥有相同名字的城市。

如果选定了需要进行分组的字段,就可以将选定的字段传递给"$group"函数的"_id"字段。对于上面的例子,相应的代码如下:

{"$group" : {"_id" : "$day"}}
{"$group" : {"_id" : "$grade"}}
{"$group" : {"_id" : {"state" : "$state", "city" : "$city"}}}

如果执行这些代码,结果集中每个分组对应一个只有一个字段(分组键)的文档。例如,按学生分数等级进行分组的结果可能是:{"result" : [{"_id" : "A+"}, {"_id" : "A"}, {"_id" : "A-"}, ..., {"_id" : "F"}], "ok" : 1}。通过上面这些代码,可以得到特定字段中每一个不同的值,但是所有例子都要求基于这些分组进行一些计算。因此,可以添加一些字段,使用分组操作符对每个分组中的文档做一些计算。

1. 分组操作符

这些分组操作符允许对每个分组进行计算,得到相应的结果。7.1节介绍过"$sum"分组操作符的作用:分组中每出现一个文档,它就对计算结果加1,这样便可以得到每个分组中的文档数量。

2. 算术操作符

有两个操作符可以用于对数值类型字段的值进行计算:"$sum"和"$average"。

  • "$sum" : value

对于分组中的每一个文档,将value与计算结果相加。注意,上面的例子中使用了一个字面量数字1,但是这里也可以使用比较复杂的值。例如,如果有一个集合,其中的内容是各个国家的销售数据,使用下面的代码就可以得到每个国家的总收入:

> db.sales.aggregate(
... {
...     "$group" : {
...         "_id" : "$country",
...         "totalRevenue" : {"$sum" : "$revenue"}
...     }
... })
  • "$avg" : value

返回每个分组的平均值。
例如,下面的代码会返回每个国家的平均收入,以及每个国家的销量:

> db.sales.aggregate(
... {
...     "$group" : {
...         "_id" : "$country",
...         "totalRevenue" : {"$avg" : "$revenue"},
...         "numSales" : {"$sum" : 1}
...     }
... }) 

3. 极值操作符(extreme operator)

下面的四个操作符可用于得到数据集合中的“边缘”值。

  • "$max" : expr 返回分组内的最大值。
  • "$min" : expr

返回分组内的最小值。

  • "$first" : expr 返回分组的第一个值,忽略后面所有值。只有排序之后,明确知道数据顺序时这个操作才有意义。
  • "$last" : expr

与"$first"相反,返回分组的最后一个值。

"$max"和"$min"会查看每一个文档,以便得到极值。因此,如果数据是无序的,这两个操作符也可以有效工作;如果数据是有序的,这两个操作符就会有些浪费。假设有一个存有学生考试成绩的数据集,需要找到其中的最高分与最低分:

> db.scores.aggregate(
... {
...     "$group" : {
...         "_id" : "$grade",
...         "lowestScore" : {"$min" : "$score"},
...         "highestScore" : {"$max" : "$score"}
...         }
... })

另一方面,如果数据集是按照希望的字段排序过的,那么"$first"和"$last"操作符就会非常有用。下面的代码与上面的代码可以得到同样的结果:

> db.scores.aggregate(
... {
...     "$sort" : {"score" : 1}
... },
... {
...     "$group" : {
...         "_id" : "$grade",
...         "lowestScore" : {"$first" : "$score"},
...         "highestScore" : {"$last" : "$score"}
...     }
... })

如果数据是排过序的,那么$first和$last会比$min和$max效率更高。如果不准备对数据进行排序,那么直接使用$min和$max会比先排序再使用$first和$last效率更高。

4. 数组操作符

有两个操作符可以进行数组操作。

  • "$addToSet" : expr

如果当前数组中不包含expr ,那就将它添加到数组中。在返回结果集中,每个元素最多只出现一次,而且元素的顺序是不确定的。

  • "$push" : expr

不管expr是什么值,都将它添加到数组中。返回包含所有值的数组。

5. 分组行为

有两个操作符不能用前面介绍的流式工作方式对文档进行处理,"$group"是其中之一。大部分操作符的工作方式都是流式的,只要有新文档进入,就可以对新文档进行处理,但是"$group"必须要等收到所有的文档之后,才能对文档进行分组,然后才能将各个分组发送给管道中的下一个操作符。这意味着,在分片的情况下,"$group"会先在每个分片上执行,然后各个分片上的分组结果会被发送到mongos再进行最后的统一分组,剩余的管道工作也都是在mongos(而不是在分片)上运行的。

 $unwind

拆分(unwind)可以将数组中的每一个值拆分为单独的文档。例如,如果有一篇拥有多条评论的博客文章,可以使用$unwind将每条评论拆分为一个独立的文档:

> db.blog.findOne()
{
    "_id" : ObjectId("50eeffc4c82a5271290530be"),
    "author" : "k",
    "post" : "Hello, world!",
    "comments" : [
        {
            "author" : "mark",
            "date" : ISODate("2013-01-10T17:52:04.148Z"),
            "text" : "Nice post"
        },
        {
            "author" : "bill",
            "date" : ISODate("2013-01-10T17:52:04.148Z"),
            "text" : "I agree"
        }
    ]
}
> db.blog.aggregate({"$unwind" : "$comments"})
{
    "results" :
        {
            "_id" : ObjectId("50eeffc4c82a5271290530be"),
            "author" : "k",
            "post" : "Hello, world!",
            "comments" : {
                "author" : "mark",
                "date" : ISODate("2013-01-10T17:52:04.148Z"),
                "text" : "Nice post"
            }
        },
        {
            "_id" : ObjectId("50eeffc4c82a5271290530be"),
            "author" : "k",
            "post" : "Hello, world!",
            "comments" : {
                "author" : "bill",
                "date" : ISODate("2013-01-10T17:52:04.148Z"),
                "text" : "I agree"
            }
        }
    ],
    "ok" : 1
}

如果希望在查询中得到特定的子文档,这个操作符就会非常有用:先使用"$unwind"得到所有子文档,再使用"$match"得到想要的文档。例如,如果要得到特定用户的所有评论(只需要得到评论,不需要返回评论所属的文章),使用普通的查询是不可能做到的。但是,通过提取、拆分、匹配,就很容易了:

> db.blog.aggregate({"$project" : {"comments" : "$comments"}},
... {"$unwind" : "$comments"},
... {"$match" : {"comments.author" : "Mark"}})

由于最后得到的结果仍然是一个"comments"子文档,所以你可能希望再做一次投射,以便让输出结果更优雅。

$sort

可以根据任何字段(或者多个字段)进行排序,与在普通查询中的语法相同。如果要对大量的文档进行排序,强烈建议在管道的第一阶段进行排序,这时的排序操作可以使用索引。否则,排序过程就会比较慢,而且会占用大量内存。
可以在排序中使用文档中实际存在的字段,也可以使用在投射时重命名的字段:

> db.employees.aggregate(
... {
...     "$project" : {
...         "compensation" : {
...             "$add" : ["$salary", "$bonus"]
...         },
...         "name" : 1
...     }
... },
... {
...     "$sort" : {"compensation" : -1, "name" : 1}
... })

这个例子会对员工排序,最终的结果是按照报酬从高到低,姓名从A到Z的顺序排列。
排序方向可以是1(升序)和-1(降序)。
与前面讲过的"$group"一样,"$sort"也是一个无法使用流式工作方式的操作符。"$sort"也必须要接收到所有文档之后才能进行排序。在分片环境下,先在各个分片上进行排序,然后将各个分片的排序结果发送到mongos做进一步处理。

 $limit

$limit会接受一个数字n,返回结果集中的前n个文档。

 $skip

$skip也是接受一个数字n,丢弃结果集中的前n个文档,将剩余文档作为结果返回。在“普通”查询中,如果需要跳过大量的数据,那么这个操作符的效率会很低。在聚合中也是如此,因为它必须要先匹配到所有需要跳过的文档,然后再将这些文档丢弃。

 使用管道

应该尽量在管道的开始阶段(执行"$project"、"$group"或者"$unwind"操作之前)就将尽可能多的文档和字段过滤掉。管道如果不是直接从原先的集合中使用数据,那就无法在筛选和排序中使用索引。如果可能,聚合管道会尝试对操作进行排序,以便能够有效使用索引。
MongoDB不允许单一的聚合操作占用过多的系统内存:如果MongoDB发现某个聚合操作占用了20%以上的内存,这个操作就会直接输出错误。允许将输出结果利用管道放入一个集合中是为了方便以后使用(这样可以将所需的内存减至最小)。
如果能够通过"$match"操作迅速减小结果集的大小,就可以使用管道进行实时聚合。由于管道会不断包含更多的文档,会越来越复杂,所以几乎不可能实时得到管道的操作结果。

上一篇文章:MongoDB指南---15、特殊的索引和集合:地理空间索引、使用GridFS存储文件
下一篇文章:MongoDB指南---17、MapReduce
查看原文

赞 0 收藏 0 评论 0

Mark 发布了文章 · 2019-06-20

MongoDB指南---15、特殊的索引和集合:地理空间索引、使用GridFS存储文件

上一篇文章:MongoDB指南---14、特殊的索引和集合:固定集合、TTL索引、全文本索引
下一篇文章:MongoDB指南---16、聚合

地理空间索引

MongoDB支持几种类型的地理空间索引。其中最常用的是2dsphere索引(用于地球表面类型的地图)和2d索引(用于平面地图和时间连续的数据)。
2dsphere允许使用GeoJSON格式(http://www.geojson.org)指定点、线和多边形。点可以用形如[longitude, latitude]([经度,纬度])的两个元素的数组表示:

{
    "name" : "New York City",
    "loc" : {
        "type" : "Point",
        "coordinates" : [50, 2] 
    }
}

线可以用一个由点组成的数组来表示:

{
    "name" : "Hudson River",
    "loc" : {
        "type" : "Line",
        "coordinates" : [[0,1], [0,2], [1,2]]
    }
}

多边形的表示方式与线一样(都是一个由点组成的数组),但是"type"不同:

{
    "name" : "New England",
    "loc" : {
        "type" : "Polygon",
        "coordinates" : [[0,1], [0,2], [1,2]]
    }
}

"loc"字段的名字可以是任意的,但是其中的子对象是由GeoJSON指定的,不能改变。
在ensureIndex中使用"2dsphere"选项就可以创建一个地理空间索引:

> db.world.ensureIndex({"loc" : "2dsphere"}) 

地理空间查询的类型

可以使用多种不同类型的地理空间查询:交集(intersection)、包含(within)以及接近(nearness)。查询时,需要将希望查找的内容指定为形如{"$geometry" : geoJsonDesc}的GeoJSON对象。
例如,可以使用"$geoIntersects"操作符找出与查询位置相交的文档:

> var eastVillage = {
... "type" : "Polygon",
... "coordinates" : [
... [-73.9917900, 40.7264100],
... [-73.9917900, 40.7321400],
... [-73.9829300, 40.7321400],
... [-73.9829300, 40.7264100]
... ]}
> db.open.street.map.find(
... {"loc" : {"$geoIntersects" : {"$geometry" : eastVillage}}})

这样就会找到所有与East Village区域有交集的文档。
可以使用"$within"查询完全包含在某个区域的文档,例如:“East Village有哪些餐馆?”

> db.open.street.map.find({"loc" : {"$within" : {"$geometry" : eastVillage}}})

与第一个查询不同,这次不会返回那些只是经过East Village(比如街道)或者部分重叠(比如用于表示曼哈顿的多边形)的文档。
最后,可以使用"$near"查询附近的位置:

> db.open.street.map.find({"loc" : {"$near" : {"$geometry" : eastVillage}}})

注意,"$near"是唯一一个会对查询结果进行自动排序的地理空间操作符:"$near"的返回结果是按照距离由近及远排序的。
地理位置查询有一点非常有趣:不需要地理空间索引就可以使用"$geoIntersects"或者"$within"("$near"需要使用索引)。但是,建议在用于表示地理位置的字段上建立地理空间索引,这样可以显著提高查询速度。

 复合地理空间索引

如果有其他类型的索引,可以将地理空间索引与其他字段组合在一起使用,以便对更复杂的查询进行优化。上面提到过一种可能的查询:“East Village有哪些餐馆?”。如果仅仅使用地理空间索引,我们只能查找到East Village内的所有东西,但是如果要将“restaurants”或者是“pizza”单独查询出来,就需要使用其他索引中的字段了:

> db.open.street.map.ensureIndex({"tags" : 1, "location" : "2dsphere"})

然后就能够很快地找到East Village内的披萨店了:

> db.open.street.map.find({"loc" : {"$within" : {"$geometry" : eastVillage}},
... "tags" : "pizza"})

其他索引字段可以放在"2dsphere"字段前面也可以放在后面,这取决于我们希望首先使用其他索引的字段进行过滤还是首先使用位置进行过滤。应该将那个能够过滤掉尽可能多的结果的字段放在前面。

 2D索引

对于非球面地图(游戏地图、时间连续的数据等),可以使用"2d"索引代替"2dsphere":

> db.hyrule.ensureIndex({"tile" : "2d"})

"2d"索引用于扁平表面,而不是球体表面。"2d"索引不应该用在球体表面上,否则极点附近会出现大量的扭曲变形。
文档中应该使用包含两个元素的数组表示2d索引字段(写作本书时,这个字段还不是GeoJSON文档)。示例如下:

{
    "name" : "Water Temple",
    "tile" : [ 32, 22 ]
}

"2d"索引只能对点进行索引。可以保存一个由点组成的数组,但是它只会被保存为由点组成的数组,不会被当成线。特别是对于"$within"查询来说,这是一项重要的区别。如果将街道保存为由点组成的数组,那么如果其中的某个点位于给定的形状之内,这个文档就会与$within相匹配。但是,由这些点组成的线并不一定完全包含在这个形状之内。
默认情况下,地理空间索引是假设你的值都介于-180~180。可以根据需要在ensureIndex中设置更大或者更小的索引边界值:

> db.star.trek.ensureIndex({"light-years" : "2d"}, {"min" : -1000, "max" : 1000})

这会创建一个2000×2000大小的空间索引。
使用"2d"索引进行查询比使用"2dsphere"要简单许多。可以直接使用"$near"或者"$within",而不必带有"$geometry"子对象。可以直接指定坐标:

> db.hyrule.find({"tile" : {"$near" : [20, 21]}})

这样会返回hyrule集合内的全部文档,按照距离(20,21)这个点的距离排序。如果没有指定文档数量限制,默认最多返回100个文档。如果不需要这么多结果,应该根据需要设置返回文档的数量以节省服务器资源。例如,下面的代码只会返回距离(20,21)最近的10个文档:

> db.hyrule.find({"tile" : {"$near" : [20, 21]}}).limit(10)

"$within"可以查询出某个形状(矩形、圆形或者是多边形)范围内的所有文档。如果要使用矩形,可以指定"$box"选项:

> db.hyrule.find({"tile" : {"$within" : {"$box" : [[10, 20], [15, 30]]}}})

"$box"接受一个两元素的数组:第一个元素指定左下角的坐标,第二个元素指定右上角的坐标。
类似地,可以使用"$center"选项返回圆形范围内的所有文档,这个选项也是接受一个两元素数组作为参数:第一个元素是一个点,用于指定圆心;第二个参数用于指定半径:

> db.hyrule.find({"tile" : {"$within" : {"$center" : [[12, 25], 5]}}})

还可以使用多个点组成的数组来指定多边形:

> db.hyrule.find(
... {"tile" : {"$within" : {"$polygon" : [[0, 20], [10, 0], [-10, 0]]}}})

这个例子会查询出包含给定三角形内的点的所有文档。列表中的最后一个点会被连接到第一个点,以便组成多边形。

使用GridFS存储文件

GridFS是MongoDB的一种存储机制,用来存储大型二进制文件。下面列出了使用GridFS作为文件存储的理由。

  • 使用GridFS能够简化你的栈。如果已经在使用MongoDB,那么可以使用GridFS来代替独立的文件存储工具。
  • GridFS会自动平衡已有的复制或者为MongoDB设置的自动分片,所以对文件存储做故障转移或者横向扩展会更容易。
  • 当用于存储用户上传的文件时,GridFS可以比较从容地解决其他一些文件系统可能会遇到的问题。例如,在GridFS文件系统中,如果在同一个目录下存储大量的文件,没有任何问题。
  • 在GridFS中,文件存储的集中度会比较高,因为MongoDB是以2 GB为单位来分配数据文件的。

GridFS也有一些缺点。

  • GridFS的性能比较低:从MongoDB中访问文件,不如直接从文件系统中访问文件速度快。
  • 如果要修改GridFS上的文档,只能先将已有文档删除,然后再将整个文档重新保存。MongoDB将文件作为多个文档进行存储,所以它无法在同一时间对文件中的所有块加锁。

通常来说,如果你有一些不常改变但是经常需要连续访问的大文件,那么使用GridFS再合适不过了。

 GridFS入门

使用GridFS最简单的方式是使用mongofiles工具。所有的MongoDB发行版中都包含了mongofiles,可以用它在GridFS中上传文件、下载文件、查看文件列表、搜索文件,以及删除文件。
与其他的命令行工具一样,运行mongofiles --help就可以查看它的可用选项了。
在下面这个会话中,首先用mongofiles从文件系统中上传一个文件到GridFS,然后列出GridFS中的所有文件,最后再将之前上传过的文件从GridFS中下载下来:

$ echo "Hello, world" > foo.txt
$ ./mongofiles put foo.txt
connected to: 127.0.0.1
added file: { _id: ObjectId('4c0d2a6c3052c25545139b88'),
                filename: "foo.txt", length: 13, chunkSize: 262144,
                uploadDate: new Date(1275931244818),
                md5: "a7966bf58e23583c9a5a4059383ff850" }
done!
$ ./mongofiles list
connected to: 127.0.0.1
foo.txt 13
$ rm foo.txt
$ ./mongofiles get foo.txt
connected to: 127.0.0.1
done write to: foo.txt
$ cat foo.txt
Hello,world

在上面的例子中,使用mongofiles执行了三种基本操作:put、list和get。put操作可以将文件系统中选定的文件上传到GridFS;list操作可以列出GridFS中的文件;get操作与put相反,用于将GridFS中的文件下载到文件系统中。mongofiles还支持另外两种操作:用于在GridFS中搜索文件的search操作和用于从GridFS中删除文件的delete操作。

 在MongoDB驱动程序中使用GridFS

所有客户端驱动程序都提供了GridFS API。例如,可以用PyMongo(MongoDB的Python驱动程序)执行与上面直接使用mongofiles一样的操作:

>>> from pymongo import Connection
>>> import gridfs
>>> db = Connection().test
>>> fs = gridfs.GridFS(db)
>>> file_id = fs.put("Hello, world", filename="foo.txt")
>>> fs.list()
[u'foo.txt']
>>> fs.get(file_id).read()
'Hello, world'

PyMongo中用于操作GridFS的API与mongofiles非常像:可以很方便地执行put、get和list操作。几乎所有MongoDB驱动程序都遵循这种基本模式对GridFS进行操作,当然通常也会提供一些更高级的功能。关于特定驱动程序对GridFS的操作,可以查询相关驱动程序的文件。

 揭开GridFS的面纱

GridFS是一种轻量级的文件存储规范,用于存储MongoDB中的普通文档。MongoDB服务器几乎不会对GridFS请求做“特殊”处理,所有处理都由客户端的驱动程序和工具负责。
GridFS背后的理念是:可以将大文件分割为多个比较大的块,将每个块作为独立的文档进行存储。由于MongoDB支持在文档中存储二进制数据,所以可以将块存储的开销降到非常低。除了将文件的每一个块单独存储之外,还有一个文档用于将这些块组织在一起并存储该文件的元信息。
GridFS中的块会被存储到专用的集合中。块默认使用的集合是fs.chunks,不过可以修改为其他集合。在块集合内部,各个文档的结构非常简单:

{
    "_id" : ObjectId("..."),
    "n" : 0,
    "data" : BinData("..."),
    "files_id" : ObjectId("...")
}

与其他的MongoDB文档一样,块也都拥有一个唯一的"_id"。另外,还有如下几个键。

  • "files_id"

块所属文件的元信息。

  • "n"

块在文件中的相对位置。

  • "data"

块所包含的二进制数据。

每个文件的元信息被保存在一个单独的集合中,默认情况下这个集合是fs.files。这个文件集合中的每一个文档表示GridFS中的一个文件,文档中可以包含与这个文件相关的任意用户自定义元信息。除用户自定义的键之外,还有几个键是GridFS规范规定必须要有的。

  • "_id"

文件的唯一id,这个值就是文件的每个块文档中"files_id"的值。

  • "length"

文件所包含的字节数。

  • "chunkSize"

组成文件的每个块的大小,单位是字节。这个值默认是256 KB,可以在需要时进行调整。

  • "uploadDate"

文件被上传到GridFS的日期。

  • "md5"

文件内容的md5校验值,这个值由服务器端计算得到。

这些必须字段中最有意思(或者说能够见名知意)的一个可能是"md5"。"md5"字段的值是由MongoDB服务器使用filemd5命令得到的,这个命令可以用来计算上传到GridFS的块的md5校验值。这意味着,用户可以通过检查文件的md5校验值来确保文件上传正确。
如上面所说,在fs.files中,除了这些必须字段外,可以使用任何自定义的字段来保存必需的文件元信息。可能你希望在文件元信息中保存文件的下载次数、MIME类型或者用户评分。
只要理解了GridFS底层的规范,自己就可以很容易地实现一些驱动程序没有提供的辅助功能。例如,可以使用distinct命令得到GridFS中保存文件的文件名集合(集合中的每个文件名都是唯一的)。

> db.fs.files.distinct("filename")
[ "foo.txt" , "bar.txt" , "baz.txt" ]

这样,在加载或者收集文件相关信息时,应用程序可以拥有非常大的灵活性.

上一篇文章:MongoDB指南---14、特殊的索引和集合:固定集合、TTL索引、全文本索引
下一篇文章:MongoDB指南---16、聚合
查看原文

赞 1 收藏 1 评论 0

Mark 发布了文章 · 2019-06-20

MongoDB指南---14、特殊的索引和集合:固定集合、TTL索引、全文本索引

上一篇文章:MongoDB指南---13、索引类型、索引管理
下一篇文章:MongoDB指南---15、特殊的索引和集合:地理空间索引、使用GridFS存储文件

本章介绍MongoDB中一些特殊的集合和索引类型,包括:

  • 用于类队列数据的固定集合(capped collection);
  • 用于缓存的TTL索引;
  • 用于简单字符串搜索的全文本索引;
  • 用于二维平面和球体空间的地理空间索引;
  • 用于存储大文件的GridFS。

 固定集合

MongoDB中的“普通”集合是动态创建的,而且可以自动增长以容纳更多的数据。MongoDB中还有另一种不同类型的集合,叫做固定集合,固定集合需要事先创建好,而且它的大小是固定的(如图6-1所示)。说到固定大小的集合,有一个很有趣的问题:向一个已经满了的固定集合中插入数据会怎么样?答案是,固定集合的行为类似于循环队列。如果已经没有空间了,最老的文档会被删除以释放空间,新插入的文档会占据这块空间(如图6-2所示)。也就是说,当固定集合被占满时,如果再插入新文档,固定集合会自动将最老的文档从集合中删除。

clipboard.png

图6-1 新文档被插入到队列末尾

clipboard.png

图6-2 如果队列已经被占满,那么最老的文档会被之后插入的新文档覆盖
固定集合的访问模式与MongoDB中的大部分集合不同:数据被顺序写入磁盘上的固定空间。因此它们在碟式磁盘(spinning disk)上的写入速度非常快,尤其是集合拥有专用磁盘时(这样就不会因为其他集合的一些随机性的写操作而“中断”)。

固定集合不能被分片。

固定集合可以用于记录日志,尽管它们不够灵活。虽然可以在创建时指定集合大小,但无法控制什么时候数据会被覆盖。

 创建固定集合

不同于普通集合,固定集合必须在使用之前先显式创建。可以使用create命令创建固定集合。在shell中,可以使用createCollection函数:

> db.createCollection("my_collection", {"capped" : true, "size" : 100000});
{ "ok" : true }

上面的命令创建了一个名为my_collection大小为100 000字节的固定集合。
除了大小,createCollection还能够指定固定集合中文档的数量:

> db.createCollection("my_collection2",
... {"capped" : true, "size" : 100000, "max" : 100});
{ "ok" : true }

可以使用这种方式来保存最新的10则新闻,或者是将每个用户的文档数量限制为1000。
固定集合创建之后,就不能改变了(如果需要修改固定集合的属性,只能将它删除之后再重建)。因此,在创建大的固定集合之前应该仔细想清楚它的大小。

为固定集合指定文档数量限制时,必须同时指定固定集合的大小。不管先达到哪一个限制,之后插入的新文档就会把最老的文档挤出集合:固定集合的文档数量不能超过文档数量限制,固定集合的大小也不能超过大小限制。

创建固定集合时还有另一个选项,可以将已有的某个常规集合转换为固定集合,可以使用convertToCapped命令实现。下面的例子将test集合转换为一个大小为10 000字节的固定集合:

> db.runCommand({"convertToCapped" : "test", "size" : 10000});
{ "ok" : true }

无法将固定集合转换为非固定集合(只能将其删除)。

 自然排序

对固定集合可以进行一种特殊的排序,称为自然排序(natural sort)。自然排序返回结果集中文档的顺序就是文档在磁盘上的顺序(如图6-3所示)。

clipboard.png

图6-3 使用{"$natural" : 1}进行排序
对大多数集合来说,自然排序的意义不大,因为文档的位置经常变动。但是,固定集合中的文档是按照文档被插入的顺序保存的,自然顺序就是文档的插入顺序。因此,自然排序得到的文档是从旧到新排列的。当然也可以按照从新到旧的顺序排列(如图6-4所示)。

> db.my_collection.find().sort({"$natural" : -1})

clipboard.png

图6-4 使用{"$natural" : -1}进行排序

循环游标

循环游标(tailable cursor)是一种特殊的游标,当循环游标的结果集被取光后,游标不会被关闭。循环游标的灵感来自tail -f命令(循环游标跟这个命令有点儿相似),会尽可能久地持续提取输出结果。由于循环游标在结果集取光之后不会被关闭,因此,当有新文档插入到集合中时,循环游标会继续取到结果。由于普通集合并不维护文档的插入顺序,所以循环游标只能用在固定集合上。
循环游标通常用于当文档被插入到“工作队列”(其实就是个固定集合)时对新插入的文档进行处理。如果超过10分钟没有新的结果,循环游标就会被释放,因此,当游标被关闭时自动重新执行查询是非常重要的。下面是一个在PHP中使用循环游标的例子(不能在mongo shell中使用循环游标):

$cursor = $collection->find()->tailable();

while (true) {
    if (!$cursor->hasNext()) {
        if ($cursor->dead()) {
            break;
        }
        sleep(1);
    }
    else {
        while ($cursor->hasNext()) {
            do_stuff($cursor->getNext());
        }
    }
}

这个游标会不断对查询结果进行处理,或者是等待新的查询结果,直到游标被关闭(超过10分钟没有新的结果或者人为中止查询操作)。

没有_id索引的集合

默认情况下,每个集合都有一个"_id"索引。但是,如果在调用createCollection创建集合时指定autoIndexId选项为false,创建集合时就不会自动在"_id"上创建索引。实践中不建议这么使用,但是对于只有插入操作的集合来说,这确实可以带来速度的稍许提升。

如果创建了一个没有"_id"索引的集合,那就永远都不能复制它所在的mongod了。复制操作要求每个集合上都要有"_id"索引(对于复制操作,能够唯一标识集合中的每一个文档是非常重要的)。

在2.2版本之前,固定集合默认是没有"_id"索引的,除非显式地将autoIndexId置为true。如果正在使用旧版的固定集合,要确保你的应用程序能够填充"_id"字段(大多数驱动程序会自动填充"_id"字段),然后使用ensureIndex命令创建"_id"索引。
记住,"_id"索引必须是唯一索引。不同于其他索引,"_id"索引一经创建就无法删除了,因此在生产环境中创建索引之前先自己实践一下是非常重要的。所以创建"_id"索引必须一次成功!如果创建的"_id"索引不合规范,就只能删除集合再重建了。

 TTL索引

上一节已经讲过,对于固定集合中的内容何时被覆盖,你只拥有非常有限的控制权限。如果需要更加灵活的老化移出系统(age-out system),可以使用TTL索引(time-to-live index,具有生命周期的索引),这种索引允许为每一个文档设置一个超时时间。一个文档到达预设置的老化程度之后就会被删除。这种类型的索引对于缓存问题(比如会话的保存)非常有用。
在ensureIndex中指定expireAfterSecs选项就可以创建一个TTL索引:

> // 超时时间为24小时
> db.foo.ensureIndex({"lastUpdated" : 1}, {"expireAfterSecs" : 60*60*24})

这样就在"lastUpdated"字段上建立了一个TTL索引。如果一个文档的"lastUpdated"字段存在并且它的值是日期类型,当服务器时间比文档的"lastUpdated"字段的时间晚expireAfterSecs秒时,文档就会被删除。
为了防止活跃的会话被删除,可以在会话上有活动发生时将"lastUpdated"字段的值更新为当前时间。只要"lastUpdated"的时间距离当前时间达到24小时,相应的文档就会被删除。
MongoDB每分钟对TTL索引进行一次清理,所以不应该依赖以秒为单位的时间保证索引的存活状态。可以使用collMod命令修改expireAfterSecs的值:

> db.runCommand({"collMod" : "someapp.cache", "expireAfterSecs" : 3600})

在一个给定的集合上可以有多个TTL索引。TTL索引不能是复合索引,但是可以像“普通”索引一样用来优化排序和查询。

全文本索引

MongoDB有一个特殊类型的索引用于在文档中搜索文本。前面几章都是使用精确匹配和正则表达式来查询字符串,但是这些技术有一些限制。使用正则表达式搜索大块文本的速度非常慢,而且无法处理语言的理解问题(比如entry与entries应该算是匹配的)。使用全文本索引可以非常快地进行文本搜索,就如同内置了多种语言分词机制的支持一样。

创建任何一种索引的开销都比较大,而创建全文本索引的成本更高。在一个操作频繁的集合上创建全文本索引可能会导致MongoDB过载,所以应该是离线状态下创建全文本索引,或者是在对性能没要求时。创建全文本索引时要特别小心谨慎,内存可能会不够用(除非你有SSD)。

全文本索引也会导致比“普通”索引更严重的性能问题,因为所有字符串都需要被分解、分词,并且保存到一些地方。因此,可能会发现拥有全文本索引的集合的写入性能比其他集合要差。全文本索引也会降低分片时的数据迁移速度:将数据迁移到其他分片时,所有文本都需要重新进行索引。
写作本书时,全文本索引仍然只是一个处于“试验阶段”的功能,所以需要专门启用这个功能才能进行使用。启动MongoDB时指定--setParameter textSearch Enabled=true选项,或者在运行时执行setParameter命令,都可以启用全文本索引:

> db.adminCommand({"setParameter" : 1, "textSearchEnabled" : true})

假如我们使用这个非官方的Hacker News JSON API(http://api.ihackernews.com)将最近的一些文章加载到了MongoDB中。
为了进行文本搜索,首先需要创建一个"text"索引:

> db.hn.ensureIndex({"title" : "text"})

现在,必须通过text命令才能使用这个索引(写作本书时,全文本索引还不能用在“普通”查询中):

test> db.runCommand({"text" : "hn", "search" : "ask hn"})
{
    "queryDebugString" : "ask|hn||||||",
    "language" : "english",
    "results" : [
        {
            "score" : 2.25,
            "obj" : {
                "_id" : ObjectId("50dcab296803fa7e4f000011"),
                "title" : "Ask HN: Most valuable skills you have?",
                "url" : "/comments/4974230",
                "id" : 4974230,
                "commentCount" : 37,
                "points" : 31,
                "postedAgo" : "2 hours ago",
                "postedBy" : "bavidar"
            }
        },
        {
            "score" : 0.5625,
            "obj" : {
                "_id" : ObjectId("50dcab296803fa7e4f000001"),
                "title" : "Show HN: How I turned an old book...",
                "url" : "http://www.howacarworks.com/about",
                "id" : 4974055,
                "commentCount" : 44,
                "points" : 95,
                "postedAgo" : "2 hours ago",
                "postedBy" : "AlexMuir"
            }
        },
        {
            "score" : 0.5555555555555556,
            "obj" : {
                "_id" : ObjectId("50dcab296803fa7e4f000010"),
                "title" : "Show HN: ShotBlocker - iOS Screenshot detector...",
                "url" : "https://github.com/clayallsopp/ShotBlocker",
                "id" : 4973909,    
                "commentCount" : 10,
                "points" : 17,
                "postedAgo" : "3 hours ago",
                "postedBy" : "10char"
        }
    }
],
"stats" : {
    "nscanned" : 4,
    "nscannedObjects" : 0,
    "n" : 3,
    "timeMicros" : 89
},
"ok" : 1 } 

匹配到的文档是按照相关性降序排列的:"Ask HN"位于第一位,然后是两个部分匹配的文档。每个对象前面的"score"字段描述了每个结果与查询的匹配程度。
如你所见,这个搜索是不区分大小写不的,至少对于[a-zA-Z]这些字符是这样。全文本索引会使用toLower将单词变为小写,但这是与本地化相关的,所以某些语言的用户可能会发现MongoDB会不可预测性地变得区分大小写,这取决于toLower在不同字符集上的行为。MongoDB一直在努力提高对不同字符集的支持。
全文本索引只会对字符串数据进行索引:其他的数据类型会被忽略,不会包含在索引中。一个集合上最多只能有一个全文本索引,但是全文本索引可以包含多个字段:

> db.blobs.ensureIndex({"title" : "text", "desc" : "text", "author" : "text"})

与“普通”的多键索引不同,全文本索引中的字段顺序不重要:每个字段都被同等对待。可以为每个字段指定不同的权重来控制不同字段的相对重要性:

> db.hn.ensureIndex({"title" : "text", "desc" : "text", "author" : "text"},
... {"weights" : {"title" : 3, "author" : 2}})

默认的权重是1,权重的范围可以是1~1 000 000 000。使用上面的代码设置权重之后,"title"字段成为其中最重要的字段,"author"其次,最后是"desc"(没有指定,因此它的权重是默认值1)。
索引一经创建,就不能改变字段的权重了(除非删除索引再重建),所以在生产环境中创建索引之前应该先在测试数据集上实际操作一下。
对于某些集合,我们可能并不知道每个文档所包含的字段。可以使用"$**"在文档的所有字符串字段上创建全文本索引:这不仅会对顶级的字符串字段建立索引,也会搜索嵌套文档和数组中的字符串字段:

> db.blobs.ensureIndex({"$**" : "text"})

也可以为"$**"设置权重:

> db.hn.ensureIndex({"whatever" : "text"},
... {"weights" : {"title" : 3, "author" : 1, "$**" : 2}})

"whatever"可以指代任何东西。在设置权重时指明了是对所有字段进行索引,因此MongoDB并不要求你明确给出字段列表。

 搜索语法

默认情况下,MongoDB会使用OR连接查询中的每个词:“ask OR hn”。这是执行全文本查询最有效的方式,但是也可以进行短语的精确匹配,以及使用NOT。为了精确查询“ask hn”这个短语,可以用双引号将查询内容括起来:

> db.runCommand({text: "hn", search: "\"ask hn\""})
{
    "queryDebugString" : "ask|hn||||ask hn||",
    "language" : "english",
    "results" : [
        {
            "score" : 2.25,
            "obj" : {
                "_id" : ObjectId("50dcab296803fa7e4f000011"),
                "title" : "Ask HN: Most valuable skills you have?",
                "url" : "/comments/4974230",
                "id" : 4974230,
                "commentCount" : 37,
                "points" : 31,
                "postedAgo" : "2 hours ago",
                "postedBy" : "bavidar"
            }
        }
    ],
    "stats" : {
        "nscanned" : 4,
        "nscannedObjects" : 0,
        "n" : 1,
        "nfound" : 1,
        "timeMicros" : 20392
    },
    "ok" : 1
}

这比使用OR的匹配慢一些,因为MongoDB首先要执行一个OR匹配,然后再对匹配结果进行AND匹配。
可以将查询字符串的一部分指定为字面量匹配,另一部分仍然是普通匹配:

> db.runCommand({text: "hn", search: "\"ask hn\" ipod"})

这会精确搜索"ask hn"这个短语,也会可选地搜索"ipod"。
也可以使用"-"字符指定特定的词不要出现在搜索结果中:

> db.runCommand({text: "hn", search: "-startup vc"})

这样就会返回匹配“vc”但是不包含“startup”这个词的文档。

 优化全文本搜索

有几种方式可以优化全文本搜索。如果能够使用某些查询条件将搜索结果的范围变小,可以创建一个由其他查询条件前缀和全文本字段组成的复合索引:

> db.blog.ensureIndex({"date" : 1, "post" : "text"})

这就是局部的全文本索引,MongoDB会基于上面例子中的"date"先将搜索范围分散为多个比较小的树。这样,对于特定日期的文档进行全文本查询就会快很多了。
也可以使用其他查询条件后缀,使索引能够覆盖查询。例如,如果要返回"author"和"post"字段,可以基于这两个字段创建一个复合索引:

> db.blog.ensureIndex({"post" : "text", "author" : 1})

前缀和后缀形式也可以组合在一起使用:

> db.blog.ensureIndex({"date" : 1, "post" : "text", "author" : 1})

这里的前缀索引字段和后缀索引字段都不可以是多键字段。
创建全文本索引会自动在集合上启用usePowerOf2Sizes选项,这个选项可以控制空间的分配方式。这个选项能够提高写入速度,所以不要禁用它。

 在其他语言中搜索

当一个文档被插入之后(或者索引第一次被创建之后),MongoDB会查找索引字段,对字符串进行分词,将其减小为一个基本单元(essential unit)。然后,不同语言的分词机制是不同的,所以必须指定索引或者文档使用的语言。文本类型的索引允许指定"default_language"选项,它的默认值是"english",可以被设置为多种其他语言(MongoDB的在线文档提供了最新的支持语言列表)。
例如,要创建一个法语的索引,可以这么做:

> db.users.ensureIndex({"profil" : "text", "intérêts" : "text"},
... {"default_language" : "french"})

这样,这个索引就会默认使用法语的分词机制,除非指定了其他的分词机制。如果在插入文档时指定"language"字段,就可以为每个文档分别指定分词时使用的语言:

> db.users.insert({"username" : "swedishChef",
... "profile" : "Bork de bork", language : "swedish"})
上一篇文章:MongoDB指南---13、索引类型、索引管理
下一篇文章:MongoDB指南---15、特殊的索引和集合:地理空间索引、使用GridFS存储文件
查看原文

赞 1 收藏 1 评论 0

Mark 发布了文章 · 2019-06-20

MongoDB指南---13、索引类型、索引管理

上一篇文章:MongoDB指南---12、使用explain()和hint()、何时不应该使用索引
下一篇文章:MongoDB指南---14、特殊的索引和集合:固定集合、TTL索引、全文本索引

创建索引时可以指定一些选项,使用不同选项建立的索引会有不同的行为。接下来的小节会介绍常见的索引变种,更高级的索引类型和特殊选项会在下一章介绍。

唯一索引

唯一索引可以确保集合的每一个文档的指定键都有唯一值。例如,如果想保证同不文档的"username"键拥有不同的值,创建一个唯一索引就好了:

> db.users.ensureIndex({"username" : 1}, {"unique" : true})

如果试图向上面的集合中插入如下文档:

> db.users.insert({username: "bob"})
> db.users.insert({username: "bob"})
E11000 duplicate key error index: test.users.$username_1 dup key: { : "bob" }

如果检查这个集合,会发现只有第一个"bob"被保存进来了。发现有重复的键时抛出异常会影响效率,所以可以使用唯一索引来应对偶尔可能会出现的键重复问题,而不是在运行时对重复的键进行过滤。
有一个唯一索引可能你已经比较熟悉了,就是"_id"索引,这个索引会在创建集合时自动创建。这就是一个正常的唯一索引(但它不能被删除,而其他唯一索引是可以删除的)。

如果一个文档没有对应的键,索引会将其作为null存储。所以,如果对某个键建立了唯一索引,但插入了多个缺少该索引键的文档,由于集合已经存在一个该索引键的值为null的文档而导致插入失败。5.4.2节会详细介绍相关内容。

有些情况下,一个值可能无法被索引。索引储桶(index bucket)的大小是有限制的,如果某个索引条目超出了它的限制,那么这个条目就不会包含在索引里。这样会造成一些困惑,因为使用这个索引进行查询时会有一个文档凭空消失不见了。所有的字段都必须小于1024字节,才能包含到索引里。如果一个文档的字段由于太大不能包含在索引里,MongoDB不会返回任何错误或者警告。也就是说,超出8 KB大小的键不会受到唯一索引的约束:可以插入多个同样的8 KB长的字符串。

1. 复合唯一索引

也可以创建复合的唯一索引。创建复合唯一索引时,单个键的值可以相同,但所有键的组合值必须是唯一的。
例如,如果有一个{"username" : 1, "age" : 1}上的唯一索引,下面的插入是合法的:

db.users.insert({"username" : "bob"})
db.users.insert({"username" : "bob", "age" : 23})
db.users.insert({"username" : "fred", "age" : 23})
然而,如果试图再次插入这三个文档中的任意一个,都会导致键重复异常。
GirdFS是MongoDB中存储大文件的标准方式(详见6.5节),其中就用到了复合唯一索引。存储文件内容的集合有一个{"files_id" : 1, "n" : 1}上的复合唯一索引,因此文档的某一部分看起来可能会是下面这个样子:
{"files_id" : ObjectId("4b23c3ca7525f35f94b60a2d"), "n" : 1}
{"files_id" : ObjectId("4b23c3ca7525f35f94b60a2d"), "n" : 2}
{"files_id" : ObjectId("4b23c3ca7525f35f94b60a2d"), "n" : 3}
{"files_id" : ObjectId("4b23c3ca7525f35f94b60a2d"), "n" : 4}
注意,所有"files_id"的值都相同,但是"n"的值不同。

2. 去除重复

在已有的集合上创建唯一索引时可能会失败,因为集合中可能已经存在重复值了:

> db.users.ensureIndex({"age" : 1}, {"unique" : true})
E11000 duplicate key error index: test.users.$age_1 dup key: { : 12 }

通常需要先对已有的数据进行处理(可以使用聚合框架),找出重复的数据,想办法处理。
在极少数情况下,可能希望直接删除重复的值。创建索引时使用"dropDups"选项,如果遇到重复的值,第一个会被保留,之后的重复文档都会被删除。

> db.people.ensureIndex({"username" : 1}, {"unique" : true, "dropDups" : true})

"dropDups"会强制性建立唯一索引,但是这个方式太粗暴了:你无法控制哪些文档被保留哪些文档被删除(如果有文档被删除的话,MongoDB也不会给出提示说哪些文档被删除了)。对于比较重要的数据,千万不要使用"dropDups"。

 稀疏索引

前面的小节已经讲过,唯一索引会把null看做值,所以无法将多个缺少唯一索引中的键的文档插入到集合中。然而,在有些情况下,你可能希望唯一索引只对包含相应键的文档生效。如果有一个可能存在也可能不存在的字段,但是当它存在时,它必须是唯一的,这时就可以将unique和sparse选项组合在一起使用。

MongoDB中的稀疏索引(sparse index)与关系型数据库中的稀疏索引是完全不同的概念。基本上来说,MongoDB中的稀疏索引只是不需要将每个文档都作为索引条目。

使用sparse选项就可以创建稀疏索引。例如,如果有一个可选的email地址字段,但是,如果提供了这个字段,那么它的值必须是唯一的:

> db.ensureIndex({"email" : 1}, {"unique" : true, "sparse" : true})

稀疏索引不必是唯一的。只要去掉unique选项,就可以创建一个非唯一的稀疏索引。
根据是否使用稀疏索引,同一个查询的返回结果可能会不同。假如有这样一个集合,其中的大部分文档都有一个"x"字段,但是有些没有:

> db.foo.find()
{ "_id" : 0 }
{ "_id" : 1, "x" : 1 }
{ "_id" : 2, "x" : 2 }
{ "_id" : 3, "x" : 3 }

当在"x"上执行查询时,它会返回相匹配的文档:

> db.foo.find({"x" : {"$ne" : 2}})
{ "_id" : 0 }
{ "_id" : 1, "x" : 1 }
{ "_id" : 3, "x" : 3 }

如果在"x"上创建一个稀疏索引,"_id"为0的文档就不会包含在索引中。如果再次在"x"上查询,MongoDB就会使用这个稀疏索引,{"_id" : 0}的这个文档就不会被返回了:

> db.foo.find({"x" : {"$ne" : 2}})
{ "_id" : 1, "x" : 1 }
{ "_id" : 3, "x" : 3 }

如果需要得到那些不包含"x"字段的文档,可以使用hint()强制进行全表扫描。

索引管理

如前面的小节所述,可以使用ensuerIndex函数创建新的索引。对于一个集合,每个索引只需要创建一次。如果重复创建相同的索引,是没有任何作用的。
所有的数据库索引信息都存储在system.indexes集合中。这是一个保留集合,不能在其中插入或者删除文档。只能通过ensureIndex或者dropIndexes对其进行操作。
创建一个索引之后,就可以在system.indexes中看到它的元信息。可以执行db.collectionName.getIndexes()来查看给定集合上的所有索引信息:

> db.foo.getIndexes()
[
    {
        "v" : 1,
        "key" : {
            "_id" : 1
        },
        "ns" : "test.foo",
        "name" : "_id_"
    },
    {
        "v" : 1,
        "key" : {
            "y" : 1
        },
        "ns" : "test.foo",
        "name" : "y_1"
    },
    {
        "v" : 1,
        "key" : {
            "x" : 1,
            "y" : 1
        },
        "ns" : "test.foo",
        "name" : "x_1_y_1"
    }
]

这里面最重要的字段是"key"和"name"。这里的键可以用在hint、max、min以及其他所有需要指定索引的地方。在这里,索引的顺序很重要:{"x" : 1, "y" : 1}上的索引与{"y" : 1, "x" : 1}上的索引不同。对于很多的索引操作(比如dropIndex),这里的索引名称都可以被当作标识符使用。但是这里不会指明索引是否是多键索引。
"v"字段只在内部使用,用于标识索引版本。如果你的索引不包含"v" : 1这样的字段,说明你的索引是以一种效率比较低的旧方式存储的。将MongoDB升级到至少2.0版本,删除并重建这些索引,就可以把索引的存储方式升级到新的格式了。

 标识索引

集合中的每一个索引都有一个名称,用于唯一标识这个索引,也可以用于服务器端来删除或者操作索引。索引名称的默认形式是key name1_dir1_keyname2_dir2_..._keynameN_dirN,其中keynameX是索引的键,dirX是索引的方向(1或者-1)。如果索引中包含两个以上的键,这种命名方式就显得比较笨重了,好在可以在ensureIndex中指定索引的名称:

> db.foo.ensureIndex({"a" : 1, "b" : 1, "c" : 1, ..., "z" : 1},
... {"name" : "alphabet"})

索引名称的长度是有限制的,所以新建复杂索引时可能需要自定义索引名称。调用getLastError就可以知道索引是否成功创建,或者失败的原因。

 修改索引

随着应用不断增长变化,你会发现数据或者查询已经发生了改变,原来的索引也不那么好用了。这时可以使用dropIndex命令删除不再需要的索引:

> db.people.dropIndex("x_1_y_1")
{ "nIndexesWas" : 3, "ok" : 1 }

用索引描述信息里"name"字段的值来指定需要删除的索引。
新建索引是一件既费时又浪费资源的事情。默认情况下,MongoDB会尽可能快地创建索引,阻塞所有对数据库的读请求和写请求,一直到索引创建完成。如果希望数据库在创建索引的同时仍然能够处理读写请求,可以在创建索引时指定background选项。这样在创建索引时,如果有新的数据库请求需要处理,创建索引的过程就会暂停一下,但是仍然会对应用程序性能有比较大的影响。后台创建索引比前台创建索引慢得多。
在已有的文档上创建索引会比新创建索引再插入文档快一点。

上一篇文章:MongoDB指南---12、使用explain()和hint()、何时不应该使用索引
下一篇文章:MongoDB指南---14、特殊的索引和集合:固定集合、TTL索引、全文本索引
查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 232 次点赞
  • 获得 5 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 5 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2018-01-18
个人主页被 2k 人浏览