了解了这么多理论之后,现在来细化一下MongoDB中索引的概念。然后,我们将深入讨论索引管理的一些细节。
7.2.1 索引类型
MongoDB中的所有索引底层都使用相同的数据结构,但可以有很多不同的属性。尤其是唯一性索引、稀疏索引和多键索引,它们都很常用,本节会详细介绍它们。1
1. 注意,MongoDB还支持空间索引,但因为它的用途太专业了,我会在附录E中单独进行说明。
1. 唯一性索引
要创建唯一性索引,设置unique
选项即可:
db.users.ensureIndex({username: 1}, {unique: true})
唯一性索引保证了集合中所有索引项的唯一性。如果要向本书示例应用程序的用户集合users
插入一个文档,其中的用户名已经被索引过了,那么插入会失败,抛出如下异常:
E11000 duplicate key error index: gardening.users.$username_1 dup key : { : /"kbanker/" }
如果使用驱动,那么只有在使用驱动的安全模式执行插入时才会捕获该异常。第3章中有对此的相关讨论。
如果集合上需要唯一性索引,通常在插入数据前先创建索引会比较好。提前创建索引,能在一开始就保证唯一性约束。在已经包含数据的集合上创建唯一性索引时,会有失败的风险,因为集合里可能已经存在重复的键了。存在重复键时,创建索引会失败。
如果真有需要在一个已经建好的集合上创建唯一性索引,你有几个选择。首先是不停地重复创建唯一性索引,根据失败消息手动删除包含重复键的文档。如果数据不重要,还可以通过dropDups
选项告诉数据库自动删除包含重复键的文档。举个例子,如果用户集合users
里已经有数据了,而且你并不介意删除包含重复键的文档,可以像下面这样发起索引创建命令:
db.users.ensureIndex({username: 1}, {unique: true, dropDups: true})
请注意,要保留哪个重复键的文档是不确定的,因此在使用时要特别小心。
2. 稀疏索引
索引默认都是密集型的。也就是说,在一个有索引的集合里,每个文档都会有对应的索引项,哪怕文档中没有被索引键也是如此。例如,回想一下电子商务数据模型里的产品集合,假设你在产品属性category_ids
上构建了一个索引。现在假设有些产品没有分配给任何分类,对于每个无分类的产品,category_ids
索引中仍会存在像这样的一个null
项。可以这样查询null
值:
db.products.find({category_ids: null})
在查询缺少分类的所有产品时,查询优化器仍然能使用category_ids
上的索引定位对应产品。
但是有两种情况使用密集型索引会不太方便。一种是希望在并非出现在集合所有文档内的字段上增加唯一性索引时。举例来说,你明确希望在每个产品的sku
字段上增加唯一性索引。但是出于某些原因,假设产品在还未分配sku
时就加入系统了。如果sku
字段上有唯一性索引,而你希望插入多个没有sku
的产品,那么第一次插入会成功,但后续插入都会失败,因为索引里已经存在一个sku
为null
的项了。这种情况下密集型索引并不适合,你所需要的是稀疏索引(sparse index)。
在稀疏索引里,只会出现被索引键有值的文档。如果想创建稀疏索引,指定{sparse: true}
就可以了。例如,可以像下面这样在sku
上创建一个唯一性稀疏索引:
db.products.ensureIndex({sku: 1}, {unique: true, sparse: true})
另一种适用稀疏索引的情况:集合中大量文档都不包含被索引键。例如,假设允许对电子商务网站进行匿名评论。这种情况下,半数评论都可能缺少user_id
字段,如果那个字段上有索引,那么该索引中一半的项都会是null
。出于两个原因,这种情况的效率会很差。第一,这会增加索引的大小。第二,在添加和删除带null值user_id
字段的文档时也要求更新索引。
如果很少(或不会)对匿名评论进行查询,那么可以选择在user_id
上构建一个稀疏索引。设置sparse
选项同样非常简单:
db.reviews.ensureIndex({user_id: 1}, {sparse: true})
现在就只有那些通过user_id
字段关联了用户的评论才会被索引。
3. 多键索引
在之前的几章里,你已经见过好多索引字段的值是数组的例子了。2正是名为多键索引(multikey index)的东西让这些成为可能,它允许索引中的多个条目指向相同文档。我们可以举个简单的例子说明一下,假设有一个产品文档,包含几个标签:
2. 举例来说,分类ID。
{ name: /"Wheelbarrow/", tags: [/"tools/", /"gardening/", /"soil/"]}
如果在tags
上创建索引,标签数组里的每个值都会出现在索引里。也就是说,对数组中任意值的查询都能用索引来定位文档。多键索引背后的理念是这样的:多个索引项或键最终指向同一个文档。
MongoDB中的多键索引总是处于激活状态。被索引字段只要包含数组,每个数组值都会在索引里有自己的位置。
合理使用多键索引是正确设计MongoDB Schema时必不可少的一环,这在第4章到第6章的例子里已经很明显了;附录B的设计模式部分还会提供更多的示例。
7.2.2 索引管理
要在MongoDB中管理索引,你现有的知识可能还稍有不足。本节我们将详细介绍索引的创建和删除,并讨论与压紧(compaction)和备份相关的问题。
1. 索引的创建与删除
到目前为止,你已经创建了很多索引,因此对索引的创建语法应该并不陌生。在Shell或者所选语言里简单地调用索引创建辅助方法,会在特殊的system.indexes
集合中添加一个文档定义新的索引。
虽然通常情况下使用辅助方法创建索引会更方便一些,但也可以手工插入一个索引说明(辅助方法就是这么做的)。你只需确保指定了以下这些最起码的键:ns
、key
与name
。ns
是命名空间,key
是要索引的字段或字段的组合,name
是用来指向索引的名字。此处还能指定一些额外选项,比方说sparse
。例如,让我们在users
集合上创建一个索引:
spec = {ns: /"green.users/", key: {/'addresses.zip/': 1}, name: /'zip/'}db.system.indexes.insert(spec, true)
如果插入操作没有返回错误,那么索引就创建完毕了,可以查询system.indexes
集合进行确认:
db.system.indexes.find{ /"_id/" : ObjectId(/"4d2205c4051f853d46447e95/"), /"ns/" : /"green.users/", /"key/" : { /"addresses.zip/":1}, /"name/" : /"zip/", /"v/" : 1 }
如果你在使用MongoDB v2.0或后续版本,会看到一个额外的键v。这个版本字段能用于未来内部索引格式的变更,但应用程序开发者不用太在意它。
要删除索引,你可能会觉得就是删除system.indexes
里的索引文档,但这个操作是被禁止的,你必须使用数据库命令deleteIndexes
删除索引。和创建索引一样,删除索引也有辅助方法可用,如果希望直接运行该方法,也没有问题。该命令接受一个文档作为参数,其中包含集合名称、要删除的索引名称或者用*来删除所有索引。要手工删除刚刚创建的索引,使用如下命令:
use greendb.runCommand({deleteIndexes: /"users/", index: /"zip/"})
大多数情况下,只需简单地使用Shell里的辅助方法创建和删除索引:
use greendb.users.ensureIndex({zip: 1})
然后可通过getIndexSpecs
方法来检查索引说明:
> db.users.getIndexSpecs[ { /"v/":1, /"key/" : { /"_id/" : 1 }, /"ns/" : /"green.users/", /"name/" : /"_id_/" }, { /"v/":1, /"key/" : { /"zip/" : 1 }, /"ns/" : /"green.users/", /"name/" : /"zip_1/" }]
最后,可以使用dropIndex
方法删除索引。请注意,必须提供上述定义里的索引名称:
use greendb.users.dropIndex(/"zip_1/")
以上是基本的索引创建与删除,想知道索引创建以后该做些什么,请往下读。
2. 索引的构建
大多数时候,你会在把应用程序正式投入使用之前添加索引,这允许随着数据的插入增量地构建索引。但在两种情况下,你可能会选择相反的过程。第一种情况是在切换到生产环境之前需要导入大量数据。举例来说,你想将应用程序迁移到MongoDB,需要从数据仓库导入用户信息。你可以事先在用户数据上创建索引,但在数据导入之后再创建索引能从一开始就保证理想的平衡性和密集的索引,也能将构建索引的净时间降到最低。
第二种(更显而易见的)情况发生在为新查询进行优化的时候。
无论为什么要创建新索引,这个过程都很难让人愉快起来。对于大数据集,构建索引可能要花好几个小时,甚至好几天。但你可以从MongoDB的日志里监控索引的构建过程。来看一个例子。先声明要构建的索引:
db.values.ensureIndex({open: 1, close: 1})
声明索引时要小心
由于这个步骤太容易了,所以也很容易在无意间触发索引构建。如果数据集很大,构建会花很长时间。在生产环境里,这简直就是梦魇,因为没办法中止索引构建。如果发生了这种情况,你将不得不故障转移到从节点上——如果有从节点的话。最明智的建议是将索引构建当做某类数据库迁移来看待,确保应用程序的代码不会自动声明索引。
索引的构建分为两步。第一步,对要索引的值排序。经过排序的数据集在插入到B树时会更高效。注意,排序的进度会以已排序文档数和总文档数的比率来进行显示:
[conn1] building new index on { open: 1.0, close: 1.0 } for stocks.values 1000000/4308303 23% 2000000/4308303 46% 3000000/4308303 69% 4000000/4308303 92% Tue Jan 4 09:59:13 [conn1] external sort used : 5 files in 55 secs
第二步,排序后的值被插入索引中。进度显示方式与第一步相同,完成之后,完成索引构建所用的时间会显示出来,作为插入system.indexes
的耗时:
1200300/4308303 27% 2227900/4308303 51% 2837100/4308303 65% 3278100/4308303 76% 3783300/4308303 87% 4075500/4308303 94%Tue Jan 4 10:00:16 [conn1] done building bottom layer, going to commitTue Jan 4 10:00:16 [conn1] done for 4308303 records 118.942secsTue Jan 4 10:00:16 [conn1] insert stocks.system.indexes 118942ms
除了查看MongoDB的日志,还可以通过Shell的currentOp
方法检查构建索引的进度:2
2. 注意,如果是在MongoDB Shell里开始索引构建的,则必须打开一个新的Shell并发地运行currentOp
。关于db.currentOp
的更多内容,详见第10章。
> db.currentOp{ /"inprog/" : [ { /"opid/" : 58, /"active/" : true, /"lockType/" : /"write/", /"waitingForLock/" : false, /"secs_running/" : 55, /"op/" : /"insert/", /"ns/" : /"stocks.system.indexes/", /"query/" : { }, /"client/" : /"127.0.0.1:53421/", /"desc/" : /"conn/", /"msg/" : /"index: (1/3) external sort 3999999/4308303 92%/" } ]}
最后一个字段msg
描述了构建进度。还要注意lockType
,它说明索引构建用了写锁,也就是说其他客户端此时无法读写数据库。如果发生在生产环境里,这无疑是很糟糕的,这也是长时间索引构建让人抓狂的原因。我们接下来会看到两个可行的解决方案。
- 后台索引
如果是在生产环境里,经不住这样暂停数据库访问的情况,可以指定在后台构建索引。虽然索引构建仍会占用写锁,但构建任务会停下来允许其他读写操作访问数据库。如果应用程序大量使用MongoDB,后台索引会降低性能,但在某些情况下这是可接受的。例如,假设你知道可以在应用程序流量最低的时间窗口内完成索引的构建,那么这时后台索引会是个不错的选择。
要在后台构建索引,声明索引时需要指定{background: true}
。可以像下面这样在后台构建之前的索引:
db.values.ensureIndex({open: 1, close: 1}, {background: true})
- 离线索引
如果生产数据集太大,无法在几小时内完成索引,这时就需要其他方案了。通常这会涉及让一个副本节点下线,在该节点上构建索引,随后让其上的数据与主节点同步。一旦完成数据同步,将该节点提升为主节点,再让另一个从节点下线,构建它自己的索引。该策略假设你的复制oplog够大,能避免离线节点的数据在索引构建过程中变得过旧。下一章会详细讨论复制,应该能帮你计划这样的迁移过程。
3. 备份
因为索引很难构建,所以你可能会希望为它们做备份,可惜并非所有备份方法都包含索引。举例来说,你可能想使用mongodump
和mongorestore
,但这些工具仅保存了集合和索引声明。也就是说,当运行mongorestore
时,所备份的所有集合上声明的索引都会被重新创建一遍。如果数据集很大,那么构建索引所消耗的时间也是无法接受的。
因此,如果想要在备份中包含索引,需要直接备份MongoDB的数据文件。第10章里有更具体的讨论,以及常用的备份操作指南。
4. 压紧
如果应用程序会大量更新现有数据,或者执行很多大规模删除,其结果就是索引的碎片化程度很高。虽说B树会自己合并,但这并非总能抵消大量删除的影响。碎片过多的索引大小远超你对指定数据集大小的预期,也会让索引使用更多内存。这些情况下,你可能希望重建一个或多个索引:可以删除并重新创建单个索引,或者运行reIndex
命令(它会重建指定集合上的所有索引):
db.values.reIndex;
在重建索引时要小心:在重建过程中该命令会占用写锁,让你的MongoDB实例暂时无法使用。重建最好是在线下完成,就像之前提到的在从节点上构建索引一样。请注意第10章里将要介绍的compact
命令,它也会重建集合上的索引。