MongoDB 4.x索引介绍
- 1、索引简述
- 1.1、索引是什么
- 1.2、索引的分类
- 2、单键、复合索引
- 2.1、单字段索引
- 2.2、复合索引
- 3、数组索引
- 4、地理空间索引
- 5、唯一性约束
- 5.1、复合索引的唯一性
- 5.2、嵌套文档的唯一性
- 5.3、数组的唯一性
- 5.4、使用约束
- 6、TTL索引
- 6.1、可变的过期时间
- 6.2、使用约束
- 7、其他索引特性
- 7.1、条件索引
- 7.2、稀疏索引(sparse=true)
- 7.3、文本索引
- 7.4、模糊索引
- 8、使用explain命令验证优化
1、索引简述
1.1、索引是什么
索引在数据库技术体系中占据了非常重要的位置,其主要表现为一种目录式的数据结构,用来实现快速的数据查询。通常在实现上,索引是对数据库表(集合)中的某些字段进行抽取、排列之后,形成的一种非常易于遍历读取的数据集合。目前绝大多数的数据库对于索引技术都有非常强大且稳定的支持。
索引的作用非常类似于一本书的目录,如图所示。
通过目录中的关键词和页码,阅读者可以快速找到自己感兴趣的书籍内容。索引也是如此,其主要是通过缩短查询数据的路径来提升效率的,这同时也是成本最低的一种性能优化手段。我们几乎很难想象,数据库离开了索引会变成什么样子。
1.2、索引的分类
- 按照索引包含的字段数量,可以分为
单键索引和组合索引(或复合索引)。 - 按照索引字段的类型,可以分为
主键索引和非主键索引。 - 按照索引节点与物理记录的对应方式来分,可以分为
聚簇索引和非聚簇索引,其中聚簇索引是指索引节点上直接包含了数据记录,而后者则仅仅包含一个指向数据记录的指针。 - 按照索引的特性不同,又可以分为
唯一索引、稀疏索引、文本索引、地理空间索引等。
2、单键、复合索引
在MongoDB中,我们可以对集合中的某个字段或某几个字段创建索引,以book实体为例,代码如下:
{"_id":ObjectId("6a5cbf88fcf45abb4d8df387"),"title":"book-0","publishedDate":ISODate("2026-07-19T12:14:00.469Z"),"type":"sociality","tags":["document","developer","popular"],"favCount":73,"author":{name:"zale",age:35}}2.1、单字段索引
如果经常使用标题(title)这个字段进行搜索,我们可以为它创建一个单字段的索引,代码如下:
>db.book.createIndex({title:1}){"numIndexesBefore":1,"numIndexesAfter":2,"createdCollectionAutomatically":false,"ok":1}这里的“title:1”中的1表示索引采用的是升序排列。然而,在单字段的索引中,使用升序和降序并没有什么差别。
当然,我们也可以对内嵌的文档字段创建索引,比如根据作者的名称进行索引,代码如下:
>db.book.createIndex({author.name:1})2.2、复合索引
复合索引是多个字段组合而成的索引,其性质和单字段索引类似。但不同的是,复合索引中字段的顺序、字段的升降序对查询性能有直接的影响,因此在设计复合索引时则需要考虑不同的查询场景。
如果需要频繁地查询某分类下的book文档排名,那么可以按照分类、收藏数量创建一个复合索引,代码如下:
>db.book.createIndex({type:1,favCount:1}){"numIndexesBefore":2,"numIndexesAfter":3,"createdCollectionAutomatically":false,"ok":1}3、数组索引
数组索引也被称为多值索引(multikey index),当我们对数组型的字段创建索引时,这个索引就是多值的。
根据前面的文档模型,可以按如下场景建立索引:
>db.book.createIndex({tags:1}){"numIndexesBefore":3,"numIndexesAfter":4,"createdCollectionAutomatically":false,"ok":1}由于标签是一个数组字段,因此这个索引自然就是多值索引。多值索引在使用上与普通索引并没有什么不同,只是在索引键上会同时产生多个值,比如下面的文档:
{_id:0,tags:["t1","t2","t3","t4","t5"]}按标签建立索引后,将会产生如下的索引结构:
tags=t1->id=0,tags=t2->id=0,tags=t3->id=0,tags=t4->id=0,tags=t5->id=0,数组索引必然会使索引的条目和体积发生膨胀。比如,一个book文档中存在20个标签,那么就会产生20个索引条目,这些条目同时指向同一个文档。为了避免失控,有必要在文档的设计上做出一些限制。
复合的多值索引
多值索引很容易与复合索引产生混淆,复合索引是多个字段的组合,而多值索引则仅仅是在一个字段上出现了多值(multi key)。而实质上,多值索引也可以出现在复合字段上,代码如下:
>db.book.createIndex({type:1,tags:1}){"numIndexesBefore":4,"numIndexesAfter":5,"createdCollectionAutomatically":false,"ok":1}然而,MongoDB并不支持一个复合索引中同时出现多个数组字段,比如下面的定义是不被允许的:
db.book.createIndex({tags:1,versions:1})这里假设了versions也是一个数组字段。
4、地理空间索引
在移动互联网时代,基于地理位置的检索(LBS)功能几乎是所有应用系统的标配。
MongoDB为地理空间检索提供了非常方便的功能。地理空间索引(2dsphere index)就是专门用于实现位置检索的一种特殊索引。
下面来看一个案例。
在生活节奏逐渐加快的今天,我们已经习惯了使用在线订餐的APP。在挑选外卖商家时,除了餐食的口味、评价、优惠力度,还有一个几乎所有人都要考虑的因素,就是距离。因此,在订餐APP上呈现的商家检索通常都会有地理位置的限制,例如仅查询5千米内的商家。
那么,通过MongoDB如何实现“查询附近商家”这种功能呢?
首先,假设商家的数据模型如下:
db.restaurant.insert({restaurantId:0,restaurantName:"兰州牛肉面",location:{type:"Point",coordinates:[-122.158574,37.449157]}})location字段是一个内嵌型文档,用于表明商家的地理位置,其中的type表示这是地图上的一个点,coordinates则是经纬度。
接着,创建一个2dsphere索引,代码如下:
>db.restaurant.createIndex({location:"2dsphere"}){"numIndexesBefore":1,"numIndexesAfter":2,"createdCollectionAutomatically":false,"ok":1}最后,执行查询,实现检索附近5千米内的商家,代码如下:
db.restaurants.find({location:{$near:{$geometry:{type:"Point",coordinates:[-122.158574,37.449157]},$maxDistance:5000}}})这里使用了$near查询操作符,用于实现附近商家的检索,返回数据结果会按距离排序。
其中,$geometry操作符用于指定一个GeoJSON格式的地理空间对象,type=Point表示地理坐标点,coordinates则是用户当前所在的经纬度位置;$maxDistance限定了最大距离,单位是米。
综上所述,MongoDB实现地理空间检索需考虑的因素如下:
- 地理空间对象的存储,如location(地理位置)。
- 创建地理空间索引。
- 使用合适的地理位置操作符实现检索。
除此之外,MongoDB还可以实现一些更为强大的地理空间计算,比如区域的交集等。而地理空间对象也不局限于位置点(location point),具体类型由GeoJSON对象定义。
注意:
- MongoDB的地理空间检索基于WGS84坐标系,在与一些地图平台集成时需要注意转换,如GCJ-02(火星坐标系)、BD-09(百度中国坐标系)等。
- MongoDB 4.0版本之后,near可以用于分片集合(sharded collection),而在此版本之前可以使用geoNear聚合操作来代替。
5、唯一性约束
在现实场景中,唯一性是很常见的一种索引约束需求,重复的数据记录会带来许多处理上的麻烦,比如订单的编号、用户的登录名等。通过建立唯一性索引,可以保证集合中文档的指定字段拥有唯一值。
在创建索引时,通过指定unique=true选项可以将其声明为唯一性索引,代码如下:
db.book.createIndex({title:1},{unique:true})此后,如果尝试写入两个拥有相同标题的book文档,则将会得到如下错误提示:
对于指定字段已经存在重复记录的集合,如果尝试创建唯一性约束的索引,则会提示如下错误:
5.1、复合索引的唯一性
除了单字段索引,还可以为复合索引使用唯一性约束。如果只是希望分类下的书籍标题保持唯一性,那么可以建立复合式的唯一性索引,代码如下:
>db.book.createIndex({type:1,title:1},{unique:true})5.2、嵌套文档的唯一性
唯一性约束同样可以用于嵌套文档的某个字段,这和普通索引没有什么区别,比如:
>db.book.createIndex({author.name:1},{unique:true})但如果希望将整个嵌套文档作为唯一性的保证,那么在使用时可能会造成困扰,比如:
>db.book.createIndex({author:1},{unique:true})嵌套文档的唯一性约束是严格按照写入顺序进行比较的,如下代码所示,尽管写入的文档内容是一样的,但由于字段的顺序不一致,MongoDB仍然认为这是不同的文档。为了避免产生困扰,建议尽量少用这种做法。
5.3、数组的唯一性
如果对数组索引(multikey index)使用唯一性约束,那么可以保证所有的文档之间不会存在重叠的数组元素,代码如下:
但是,数组索引上的唯一性约束并无法保证同一个文档中包含重复的元素,如下面的语句是可以写入成功的:
注意,如果数组中的元素是嵌套的文档,那么同样会遇到字段次序的问题。
5.4、使用约束
- 唯一性索引对于文档中缺失的字段,会使用null值代替,因此不允许存在多个文档缺失索引字段的情况。
- 对于分片的集合,唯一性约束必须匹配分片规则。换句话说,为了保证全局的唯一性,分片键必须作为唯一性索引的前缀字段。
6、TTL索引
在一般的应用系统中,并非所有的数据都需要永久存储。例如一些系统事件、用户消息等,这些数据随着时间的推移,其重要程度逐渐降低。更重要的是,存储这些大量的历史数据需要花费较高的成本,因此项目中通常会对过期且不再使用的数据进行老化处理。
通常的做法如下。
- 方案一:为每个数据记录一个时间戳,应用侧开启一个定时器,按时间戳定期删除过期的数据。
- 方案二:数据按日期进行分表,同一天的数据归档到同一张表,同样使用定时器删除过期的表。
对于数据老化,MongoDB提供了一种更加便捷的做法:
TTL(Time To Live)索引。TTL索引需要声明在一个日期类型的字段中,假定写入的文档数据如下:
>db.systemlog.insert({createDate:newDate(),type:"alarm",message:"..."})执行下面的语句创建TTL索引:
>db.systemlog.createIndex({createDate:1},{expireAfterSeconds:3600}){"numIndexesBefore":1,"numIndexesAfter":2,"createdCollectionAutomatically":false,"ok":1}这里为systemlog集合声明了一个TTL索引,其指向createdDate字段,其中expireAfterSeconds=3600表示数据将在createdDate之后3600秒(1小时)后过期。
对集合创建TTL索引之后,MongoDB会在周期性运行的后台线程中对该集合进行检查及数据清理工作。除了数据老化功能,TTL索引具有普通索引的功能,同样可以用于加速数据的查询。
6.1、可变的过期时间
TTL索引在创建之后,仍然可以对过期时间进行修改。这需要使用collMod命令对索引的定义进行变更,代码如下:
>db.runCommand({collMod:"systemlog",index:{keyPattern:{createDate:1},expireAfterSeconds:7200}}){"expireAfterSeconds_old":3600,"expireAfterSeconds_new":7200,"ok":1}另外一种情形可能也比较常见,如每个文档可能拥有各自的存活时长,即老化的策略不同。
比如,在systemlog集合中,不同的事件类型的老化周期是不一样的,对于此类场景的解决办法是可以使用一个单独的字段expiredDate,用于表示每个文档的过期时间点,那么TTL索引创建的规则为:
>db.systemlog.createIndex({expireDate:1},{expireAfterSeconds:0}){"numIndexesBefore":2,"numIndexesAfter":3,"createdCollectionAutomatically":false,"ok":1}6.2、使用约束
TTL索引的确可以减少开发的工作量,而且通过数据库自动清理的方式会更加高效、可靠,但是在使用TTL索引时需要注意以下的限制:
- TTL索引只能支持单个字段,并且必须是非_id字段。
- TTL索引不能用于固定集合。
- TTL索引无法保证及时的数据老化,MongoDB会通过后台的TTL Monitor定时器来清理老化数据,典型的间隔时间是1分钟。当然如果在数据库负载过高的情况下,TTL的行为则会进一步受到影响。
- TTL索引对于数据的清理仅仅使用了remove命令,这种方式并不是很高效。因此TTLMonitor在运行期间对系统CPU、磁盘都会造成一定的压力。相比之下,按日期分表的方式操作会更加高效。
7、其他索引特性
7.1、条件索引
条件(partial)索引允许你只对部分文档建立索引,这是一种很特殊的用途。
例如,仅对业务上最常用于查询的数据集创建索引,可以节省一些空间。可根据如下代码创建条件索引:
>db.restaurants.createIndex({cuisine:1,name:1},{partialFilterExpression:{rating:{$gt:5}}})上述代码表示,只对评分高于5分的餐馆信息进行索引。
7.2、稀疏索引(sparse=true)
由于MongoDB非结构化的特性,一个集合中允许结构不完全相同的两个文档共存。这意味着,对某个索引字段来说,可能某些文档中并不存在该字段,但MongoDB索引会将不存在字段的情况等同于null值处理。稀疏索引则具备这样的特性:只对存在字段的文档进行索引(包括字段值为null的文档)。
例如:
>db.test.insert({x:1})>db.test.insert({x:2,z:null})这里写入两个文档,第1个文档仅包含x字段,而第2个文档包含x、z两个字段,其中z值为null。
对集合进行检索,代码如下:
>db.test.find({z:null}){"_id":ObjectId("6a5cf8e9c0cb67eab82ec241"),"x":1}{"_id":ObjectId("6a5cf8efc0cb67eab82ec242"),"x":2,"z":null}使用建立的稀疏索引进行查询,会发现只有包含z字段(值为null)的文档被返回了,结果如下:
>db.test.createIndex({z:1},{sparse:true}){"numIndexesBefore":1,"numIndexesAfter":2,"createdCollectionAutomatically":false,"ok":1}>db.test.find({z:null}).hint({"z":1}){"_id":ObjectId("6a5cf8efc0cb67eab82ec242"),"x":2,"z":null}7.3、文本索引
MongoDB支持全文检索功能,可通过建立文本索引来实现简易的分词检索。
预置数据如下:
>db.stories.insert({title:"the monkey's hair",summary:"the story about monkey"})>db.stories.insert({title:"two stars",summary:"long long hair in the sky"})创建文本索引,代码如下:
>db.stories.createIndex({title:"text",summary:"text"}){"numIndexesBefore":1,"numIndexesAfter":2,"createdCollectionAutomatically":false,"ok":1}这里为stories集合创建了一个文本索引,该索引同时包含对title、summary字段的分词检索。
使用$text操作符进行文本搜索,代码如下:
>db.stories.find({$text:{$search:"monkey sky"}}){"_id":ObjectId("6a5cf9a9c0cb67eab82ec243"),"title":"the monkey's hair","summary":"the story about monkey"}{"_id":ObjectId("6a5cf9afc0cb67eab82ec244"),"title":"two stars","summary":"long long hair in the sky"}$text操作符会将$search文本输入进行分词再检索,如上述代码会检索出含有monkey或sky关键词的文档。
MongoDB的文本索引功能存在诸多限制,而官方并未提供中文分词的功能,这使得该功能的应用场景十分受限。
7.4、模糊索引
MongoDB的文档模式是动态变化的,而模糊索引(wildcard index)可以建立在一些不可预知的字段上,以此实现查询的加速。需要注意的是,该功能是MongoDB 4.2版本才推出的新特性,在此之前的版本并不支持。
通过模糊索引,商品属性的检索变得更加容易了,例如:
>db.goods.insert({name:"wallet",attributes:{color:"red",price:130}})>db.goods.insert({name:"chair",attributes:{height:120,price:185}})其中,attributes作为嵌套文档存放了商品的多个属性,而不同商品所具有的属性很可能是不一样的。
接下来创建一个模糊索引,代码如下:
>db.goods.createIndex({"attributes.$**":1}){"numIndexesBefore":1,"numIndexesAfter":2,"createdCollectionAutomatically":false,"ok":1}attributes.$**表示该索引将匹配以attributes字段作为开始路径的任何一个字段。
>db.goods.find({"attributes.color":"red"}){"_id":ObjectId("6a5cfb35c0cb67eab82ec245"),"name":"wallet","attributes":{"color":"red","price":130}}>db.goods.find({"attributes.height":{$gt:100}}){"_id":ObjectId("6a5cfb3ac0cb67eab82ec246"),"name":"chair","attributes":{"height":120,"price":185}}>db.goods.find({"attributes.price":{$lt:120}})8、使用explain命令验证优化
前面说过,建立索引的目的是用来加速数据查询的。那么,如何判断索引是否合理,或者说怎样评估索引所起到的作用呢?
是否存在这么一个工具,可以提前告知我们一些预期的效果呢?答案是肯定的。MongoDB提供了explain命令,它可以帮助我们评估指定查询模型(query model)的计划。
通常,我们需要关心的问题如下:
- 查询是否使用了索引。
- 索引是否减少了扫描的记录数量。
- 是否存在低效的内存排序。
- …
接下来,继续使用之前创建的book集合,在没有任何索引的情况下尝试评估查询计划,代码如下:
返回的信息量有点多,但务必记住一点,现在的集合没有创建任何索引(除了自动创建的_id索引),所以查询计划显得有些糟糕:
- winningPlan表示获胜的计划,即数据库经过一系列评估后选择的最优计划,stage=COLLSCAN则说明这是一个全表扫描。
- executionStats描述了执行的过程信息,其中,nReturned=1是指返回了一条结果,而totalDocsExamined=50说明整个过程扫描了50条记录。
尽管集合中的数据量并不大,但至少可以看出在没有索引的情况下查询会多么低效。为了返回1个文档需要耗费50次的扫描,假设集合有1000万个文档,那么就需要扫描5亿次!
为了优化这个查询,我们为标题建立一个升序索引,代码如下:
继续评估查询计划,代码如下:
这次的结果明显有些不同,相比第一次查询计划,其优化如下:
- 不再使用全表扫描(COLLSCAN)方式,取而代之的是索引扫描(IXSCAN)。
- totalDocsExamined=1,意味着扫描的文档数量只有1个。同时由于使用了索引扫描,因此可以看到totalKeysExamined=1。
executionStats.executionTimeMillis这个属性描述了执行过程所消耗的时间,一般需要配合一定的数据规模才能看出差异。
本例所提供的数据量太小,因此表现出来的执行时间并没有什么不同。但通过explain结果中的查询计划、命中比率(返回数/扫描数),却能明显地看到索引对于查询效率带来的提升。