MongoDB系列(三)MongoDB索引

索引是特殊的数据结构,它以易于遍历的形式存储部分集合数据集。索引存储特定字段或字段集的值,按字段值排序。

MongoDB的索引几乎与传统的关系型数据库索引一模一样,第二章提到的_id实际上也是一个索引,MongoDB的数据按照_id的顺序存储在内存页与磁盘块上。但是,_id与业务毫无关联,在业务相关的条件查询时,还是需要进行全表扫描才能找到对应页,效率并不高。

  • 为了避免性能瓶颈,可以根据常用的查询建立索引
  • 索引的值是按照一定的顺序排列的,因此,使用索引键对文档进行排序效率非常高。

不过,使用索引也是有代价的,不仅会增加磁盘与内存的消耗,对于添加的每一个索引,每次写操作(插入、更新、删除)都会耗费更多时间,这是因为,数据发生变动时,还需要额外的开销更新索引。

聚簇索引与非聚簇索引

在介绍索引之前,先了解下聚簇索引与非聚簇索引。
磁盘上的数据某一时刻只能有一种排序方式,而聚簇索引的特点是:索引顺序与数据存储顺序一致,所以聚簇索引只能有一个。

《数据库原理》中对聚簇索引的定义:聚簇索引的叶子节点是数据节点,非聚簇索引的叶子节点仍然是索引节点,只不过有指向对应数据块的指针。

所以Mysql的InnoDB引擎的主键索引是聚簇索引、MyIsam引擎使用的是非聚簇索引。

MongoDB不会将_id索引与文档内容放在一起,所以MongoDB的_id索引不是聚簇索引,mogoDB将数据与索引分开存放,通过RecordId间接引用。假设为字段”name“创建了索引,主键id为主键索引,那么该集合就通过索引查找RecordId,再查找数据。

1566960990983

主键索引

前面提到的_id索引是默认的主键索引,与业务相关联的项不适合用作主键(难以保障全局唯一、非null),建议使用_id作为主键。

单字段索引

即对单个filed建立索引,也是常说的“普通索引”;建立索引时可以指定索引数据的order:正序还是倒序。MongoDB 3.0后的版本,使用createIndexensureIndex是一样的,均是创建索引的命令。

1
2
db.mycollection.ensureIndex({"name":1}) //对score字段建立索引、1表示正序、-1表示倒序
db.mycollection.createIndex({"name":1}) // MongoDB 3.0后的版本,可以使用createIndex

复合索引

两个或两个以上的键建立索引,可以减小检索的范围。复合索引与Mysql一样,也是按照左侧匹配规则,这里不赘述,主要介绍下复合索引与排序共用的情况。

首先,在集合”myc“上创建一个复合索引:

1
db.myc.ensureIndex({"age":1,"name":1}); // 索引1

再创建一个:

1
db.myc.ensureIndex({"name":1,"age":1}); // 索引2

这两个复合索引的唯一区别就是键顺序不同,排序规则都是正序(1表示正序、-1表示倒序)。

由于存在了多个索引,使用hint命令指明使用哪个索引。

1
2
3
4
5
// 查询1使用索引1
db.myc.find({"age":{"$gte":21, "$lte":30}}).sort({"name":1}).hint({"age":1,"name":1});

// 查询2使用索引2
db.myc.find({"age":{"$gte":21, "$lte":30}}).sort({"name":1}).hint({"name":1,"age":1});
  • 对于查询1,先根据索引age查找复合条件的结果集,然后在内存中排序(age索引是有序的,但是排序规则用不到)
  • 对于查询2,遍历整个索引树,找出所有匹配的文档,不需要排序(name索引本身就是有序的),按正序遍历即可。

查询1和查询2究竟哪个性能更强,取决于结果集的大小,一般的,结果集越大,在内存中排序耗时越久,超过一定大小(32MB)后,MongoDB会抛出异常,拒绝对如此多的数据排序。一般的:

  • 结果集只有几条、十几条,使用查询1,排序的开销跟遍历树的开销相比并不大
  • 结果集有几百条、甚至几千条,使用查询2,排序的开销显得过大。
  • 结果集有几万条,使用查询1或查询2建议具体比较一下

结果集的大小可以使用limit关键字人为限制:

1
db.myc.find({"age":{"$gte":21, "$lte":30}}).sort({"name":1}).limit(1000).hint({"name":1,"age":1}); \\使用查询2,并限制结果集

最后,具体使用哪种查询,使用explain关键字在shell中比较一下再做选择。

1
2
3
db.myc.find({"age":{"$gte":21, "$lte":30}}).sort({"name":1}).hint({"age":1,"name":1}).explain()[`millis`]; \\获取查询1耗时

db.myc.find({"age":{"$gte":21, "$lte":30}}).sort({"name":1}).hint({"age":1,"name":1}).explain()[`millis`]; \\获取查询2耗时

唯一索引

唯一索引用来确保集合的每一个文档的指定键都有唯一值,允许null值。例如:在集合mycollection中,给”name“键建立唯一索引,试图插入重复name的值时,会抛出异常,也会影响效率。

1
db.mycollection.ensureIndex({"name":1}, {"unique":true});

使用场景:应对偶尔可能会出现重复的键重复问题,而不是在运行时对重复键进行过滤。比如:为避免消息重复消费,可以为”消息id“键创建唯一索引。

复合唯一索引

复合的唯一索引,单个键的值可以相同,但所有键的组合值必须是唯一的

例如,如果有一个{”username”:1, “age”:100}上的唯一索引,下面的插入是合法的,不会报错。

1
2
3
db.mycollection.insert({"username":"bob"});
db.mycollection.insert({"username":"bob", "age":23});
db.mycollection.insert({"username":"fred", "age":23});

去除重复

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

  • 找出重复数据,想办法去除

  • 直接删除重复的值,创建索引时使用”dropDups“选项,如果遇到重复的值,只会保留第一个值。正是由于这种不确定性(不确定哪条记录被删除),MongoDB 3.0以后移除了该选项。

  • 新建一个集合,建立索引,然后把旧集合的数据拷贝至新集合

    1
    db.mycollection.ensureIndex({"username":"bob"},{"unique":true,"dropDups":true})

稀疏索引

唯一索引会把null看做值,假如集合中有以下两个文档,假设对键”age“建立唯一索引,则文档2中的"age"就是null

1
2
{"name":"bob", "age":23} // 文档1
{"name":"bob"} // 文档2

现在想新增文档3,是无法添加的,因为文档3中”age“也是null,与文档2冲突了,违反了唯一性。

1
{"name":"bob", "addresss":"sz"} // 文档3

此时,应该使用稀疏索引(sparse index),就可以插入文档3,同时也能保证文档4无法插入,满足唯一性。

1
2
3
4
// 创建稀疏索引
db.ensureIndex({"age":1}, {"unique": true, "sparse": true});

{"name":"dod", "age":23} // 文档4

稀疏索引定义如下:如果集合中的文档存在索引键,则必须是唯一的,如果文档不存在索引键,则不要求该文档的唯一性。

注意事项:

根据是否使用稀疏索引,查询结果可能有所不同。例如:对于下面的查询,查询1和查询2是完全相同的语句,不同的是,查询1对应未创建稀疏索引的情况,查询2对应创建稀疏索引的情况。

1
2
db.mycollection.find({"age":{"$ne":23}}) // 查询1,未创建稀疏索引
db.mycollection.find({"age":{"$ne":23}}) // 查询2

查询结果如下,查询2没有查询到文档,这是因为建立了稀疏索引后,查询只根据索引查询,不再全表扫描,因此,会遗漏那些没有索引键的文档。如果一定要获取与查询1相同的结果,通过hint命令指明不使用索引,执行全表扫描。

1
2
3
4
5
6
// 查询1的查询结果
{"name":"bob"}
{"name":"bob", "addresss":"sz"}

// 查询2的查询结果
// nothing...

TTL索引

TTL(Time-to-live index)索引指具有生命周期的索引,这种索引会为文档设置一个超时时间,一旦文档存活时间超过该时间就会被删除。这种类型的索引可以用在:消息日志、服务器会话等具有时效性的场景。

"createdTime"字段上创建TTL索引:

1
db.mycollection.createIndex({"createdTime":1}, "expireAfterSecs": 60*60*24)

"createdTime"字段必需是日期类型,一般设置为当前时间,

​ 记录被删除的时间点="createdTime"字段对应的时间点+"expireAfterSecs"对应的单位为秒的时间段

为了避免活跃的会话被删除,可以在会话上有活动发生时,更新"createdTime"为当前时间。

一个集合上可以创建多个TTL索引。

全文索引

与Mysql一样,MongoDB也支持全文检索。创建全文索引的开销较大,MongoDB本身就很耗内存,在一个操作频繁的集合上创建全文索引更容易导致内存不足,全文本索引的集合写入性能更差、分片时迁移速度更慢,一般的,如果不是特别强烈的业务需要,不建议使用全文索引。

"mytext"字段上创建全文索引:

1
db.mycollection.ensureIndex({"mytext":"text"})

使用全文索引检索关键字"keyword"

1
db.mycollection.find({$text:{$search:"keyword"}})

地理空间索引

MongoDB支持几种类型的索引,最常见的是2dsphere索引(用于球面图)和2d索引(用于平面图)。这里只简单介绍下这两种索引的创建:

1
2
db.mycollection.ensureIndex({"myloc":"2dsphere"})
db.mycollection.ensureIndex({"myloc":"2d"})

优化

如果数据库中已有大量数据,此时建立索引将会导致大量的IO操作(内存,磁盘读写),耗时较长。MongoDB提供了2种方式:foreground和background。

  • foreground即前台操作,它会阻塞用户对数据的读写操作直到index构建完毕,即任何需要获取read、write锁的操作都会阻塞,默认情况下为foreground;
  • background即后台模式,不阻塞数据读写操作,独立的后台线程异步构建索引,此时仍然允许对数据的读写操作;其中background比foreground更加耗时。

查询优化

  • 对频繁访问的查询,尽量使用覆盖索引,如果一个索引包含(或者说覆盖)所有需要查询的数据,就称为“覆盖索引”,使用覆盖索引时,需要强制不显示objectId字段。

    1
    2
    db.mycollection.createIndex({"name", 1})
    db.mycollection.find({"name":bob, "_id":0}) // 0表示不显示该字段
  • 选用差异性较强的字段作为索引,不要选用类似于性别、国家这种字段作为索引键。

  • 需要哪些字段查询哪些字段,尽量不要查询整个文档

  • 使用hint强制使用特定的索引

  • 使用explain对比分析多种查询方式的性能

写操作优化

  • 尽量不要创建过多的索引,索引会增加该集合写入、更新、删除的开销,因为要额外维护索引
  • 合理设置journal相关参数,journal日志实现日志预写功能,开启journal保证了数据持久化,但也会存在一定的性能消耗,合理的设置commitIntercalMs控制journal写入磁盘的频率,该参数过大,影响MongoDB写操作的性能,该参数过小,MongoDB意外宕机期间预写日志未持久化的可能增大。