mongodb索引数据结构
在这里我希望能够与mysql索引的数据结构进行比较。
mongodb索引的数据结构是b树,而mysql索引的数据结构是b+树。
关于为什么mongodb索引使用b树不是b+树可以参看这篇文章,本文不再赘述
https://www.cnblogs.com/rjzheng/p/12316685.html
我们可以大致了解到:
B树的两个明显特点
树内的每个节点都存储数据
叶子节点之间无指针相邻
B+树的两个明显特点
数据只出现在叶子节点,非叶子节点只存key
所有叶子节点之间存在一个链指针
(1)B树的非叶子节点也存储数据值,因此查询单条数据的时候,B树可能无需达到叶子节点从而减少io次数,最好的情况是O(1)。我们可以认为在做单一数据查询的时候,使用B树平均性能更好。但是,由于B树中各节点之间没有指针相邻,因此B树不适合做一些数据遍历操作。
(2)B+树的数据只出现在叶子节点上(每次单条查询都必须到达叶子节点,所以每次单条查询的io次数相同),在查询单条数据的时候,查询速度非常稳定。因此,在做单一数据的查询上,其平均性能并不如B树。但是,B+树的叶子节点上有指针进行相连,因此在做数据遍历的时候,只需要对叶子节点进行遍历即可,这个特性使得B+树非常适合做范围查询。
而之所以这样设计的原因还是归根于mongodb是一个支持分布式的非关系型数据库,而mysql是关系型数据库,这是因为他们的使用场景不同,mongodb是不支持join而mysql支持。而join操作意味着需要对主表进行范围性的遍历。
如果读者们想了解mysql索引数据结构相关知识可以参考本博客网站的mysql索引系列
虽然二者使用的数据结构有一些差异,但是适用于mysql的索引优化技巧也绝大部分同样适用于mongodb。
创建索引
接下来我们先用js脚本插入一千万条数据
var db = db.getSisterDB("zbp")
var data_arr = []
var times = 0
for (i=0; i<10000000; i++) {
var data = {
"i" : i,
"username" : "user"+i,
"age" : Math.floor(Math.random()*120),
"created" : new Date(),
"score" : {f1:Math.floor(Math.random()*10000), f2:Math.floor(Math.random()*10000)}
}
data_arr.push(data)
if(times >= 1000){
print(data_arr)
db.worker.insertMany(data_arr)
times = 0
data_arr = []
}
}
if(data_arr.length > 0){
db.worker.insertMany(data_arr)
}
在mongo客户端中执行下面的命令
load("C:\\Users\\86133\\Desktop\\insert.js")
默认情况下,所有集合在_id字段上都有一个索引。
1.建立单字段索引
db.worker.createIndex({"username" : 1}) // 升序索引
db.worker.createIndex({"username" : -1}) // 降序索引
上面我为username字段建立了两个索引(升序和降序的索引)。和mysql不同的是,mongodb在建立索引的时候可以决定为一个字段建立升序还是降序的索引。而mysql没有能指定升序还是降序的参数,默认都是升序索引。
当然,这里只是为了演示,在生产环境下上为一个单字段索引同时加升序索引和降序索引是没有意义的,反而还浪费空间,升序索引在根据单个字段升序排序或降序排序时都能用到,MongoDB可以在任一方向上遍历索引。
只有在创建符合索引并根据复合索引排序时,这时候同时建立升序索引和降序索引才有用。
需要注意的是,mongdb在建立索引时会阻塞对数据库的读和写请求。如果希望数据库在创建索引的同时仍然能够处理读写请求,可以在创建索引时指定background选项。这样在创建索引时,如果有新的数据库请求需要处理,创建索引的过程就会暂停一下,但是仍然会对应用程序性能有比较大的影响。而且background后台创建索引比前台创建索引慢得多。
2.为内嵌文档内部字段创建索引
db.worker.createIndex({"score.f1" : 1}) // 为score.f1创建升序索引,score就是一个内嵌文档
3.为整个内嵌文档字段创建索引
db.worker.createIndex({"score" : 1}) // 为score字段创建升序索引,score就是一个内嵌文档
需要注意的是:
当为整个内嵌文档创建索引后,在嵌入文档上执行相等匹配的查询时,字段顺序很重要,内嵌文档必须精确匹配,否则无法使用到索引。
例如上面的例子中,如果这样查是用到了score索引的:
db.worker.find({score:{f1:1, f2:2})
但是如果这样查是没有用到索引的:
db.worker.find({score:{f2:2, f1:1})
4.创建复合索引
db.users.createIndex({"age" : 1, "username" : 1})
这里就相当于创建了一个 age_username 的索引,请分析以下查询是否用到这个复合索引:
db.users.find({"age" : 21}) // 用到索引
db.users.find({"age" : 21, "username":"user111"}) // 用到索引
db.users.find({"age" : 21}).sort({"username" : -1}) // 用到索引
db.users.find({"age" : {"$gte" : 21, "$lte" : 30}}) // 用到索引
db.users.find({"age" : {"$gte" : 21, "$lte" : 30}}).sort({"username":1}) // age的查询用到索引,但username的排序没用到索引(因为在B树上,age从21到30之间的username是无序的,如果是age为某个值下的username才是有序的),需要在内存中排序
db.users.find({"age" : {"$gte" : 21, "$lte" : 30}}).sort({"age" : 1, "username" : 1}) // 查询和排序都用到了索引
db.users.find({"age" : {"$gte" : 21, "$lte" : 30}}).sort({"username" : 1, "age" : 1}) // 查询用到了索引,排序没有用到,因为username和age的顺序不对,这里先按username排序,而B数节点从左到右的的username字段是无序的
db.users.find({"age" : {"$gte" : 21, "$lte" : 30}}).sort({"age" : 1, "username" : -1}) // 查询用到了索引,排序没有用到
如果希望sort({"age" : 1, "username" : -1})或者sort({"age" : -1, "username" : 1})能够用到索引
则我们可以多创建一个复合索引:db.users.createIndex({"age" : 1, "username" : -1}),这个索引和db.users.createIndex({"age" : 1, "username" : 1})是不同的两个索引(会创建两个索引文件),前者是在age相同的情况下对username倒序排序的。
所以之前我们讲过,创建单字段索引的时候,键的排序顺序(1和-1)无关紧要,因为MongoDB可以在任一方向上遍历索引。但是,对于复合索引,键的排序顺序决定了索引是否支持结果集的排序。
对于mongodb复合索引的使用以及判定是否用到了索引,和mysql的索引规则以及判定基本相同。
另外要善用覆盖索引
当我们不查询所有的字段,而只查询特定的几个字段时,如果一个索引包含用户需要请求的所有字段,可以认为这个索引覆盖了 本次查询。在实际中,应该优先使用覆盖索引,而不是去获取实际的文档。
需要注意的是:
不能创建具有hashed索引类型的复合索引。如果试图创建包含hashed索引字段的复合索引,将收到一个错误。
使用索引是有代价的:对于添加的每一个索引,每次写操作(插入、更新、删除)都将耗费更多的时间。这是因为,当数据发生变动时,MongoDB不仅要更新文档,还要更新集合上的索引。因此,MongoDB限制每个集合上最多只能有64个索引。通常,在一个特定的集合上,不应该拥有两个以上的索引。
5.为数组字段创建索引(多键索引)
例如有一个形如这样的集合(文章表),其中的comments(评论字段)是一个数组字段。我们可以为comments中的元素的某一个内部字段(name或email或content字段)建立索引
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "...",
"comments" : [
{
"name" : "joe",
"email" : "joe@example.com",
"content" : "nice post."
},
{
"name" : "bob",
"email" : "bob@example.com",
"content" : "good post."
},
{
"name" : "bob",
"email" : "bob@example.com",
"content" : "good post.11111"
}
]
}
对数组(中的字段)建立索引,实际上是对数组的每一个元素建立一个索引条目(无论数组里面是内嵌文档还是普通的数字或字符串),所以如果一篇文章有20条评论,那么它就拥有20个索引。因此数组索引的代价比单值索引高:对于单次插入、更新或者删除,每一个数组条目可能都需要更新(可能有上千个索引条目)。
注意事项:
1.无法将整个数组作为一个实体建立索引:我们这里说的对数组建立索引,实际上是对数组中的每个元素建立索引,而不是对数组本身建立索引。(之后文章出现“数组索引”字样默认是指数组中元素的某一字段的索引)
2.无法使用数组索引查找特定位置的数组元素,比如"comments.4" 。
3.一个复合索引中的数组字段最多只能有一个。这是为了避免在多键索引中索引个数爆炸性增长:每一对可能的元素都要被索引,这样导致每个文档拥有n*m 个索引条目。假如有一个这样的索引{"x" : 1, "y" : 1} :
> // x是一个数组—— 这是合法的
> db.multi.insert({"x" : [1, 2, 3], "y" : 1})
>
> // y是一个数组——这也是合法的
> db.multi.insert({"x" : 1, "y" : [4, 5, 6]})
>
> // x和y都是数组——这是非法的!
> db.multi.insert({"x" : [1, 2, 3], "y" : [4, 5, 6]})
cannot index parallel arrays [y] [x]
如果MongoDB要为上面的最后一个例子创建索引,它必须要创建这么多索引条目:{"x" : 1, "y" : 4} 、{"x" : 1, "y" : 5} 、{"x" : 1, "y" : 6} 、{"x" : 2, "y" : 4} 、{"x" : 2, "y" : 5} ,{"x" : 2, "y" : 6} 、{"x" : 3, "y" : 4} 、{"x" : 3, "y" : 5} 和{"x" : 3, "y" : 6} 。
所以如果x,y都是一个数组字段,那么我们就不能建立一个包含x和y的复合索引{"x" : 1, "y" : 1}
PS:我们可以查询命令后面接.explain()方法来分析查询语句的性能。可以得到如这样的内容
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "zbp.worker",
"indexFilterSet" : false,
"parsedQuery" : {
"username" : {
"$eq" : "user1001"
}
},
"queryHash" : "379E82C5",
"planCacheKey" : "965E0A67",
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"username" : 1
},
"indexName" : "username_1", // 使用了username这个索引
"isMultiKey" : false, // 不是多键索引
"multiKeyPaths" : {
"username" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"direction" : "forward",
"indexBounds" : {
"username" : [
"[\"user1001\", \"user1001\"]"
]
}
}
},
"rejectedPlans" : [ ]
},
"serverInfo" : {
"host" : "DESKTOP-1DHFFH1",
"port" : 27017,
"version" : "4.4.4",
"gitVersion" : "8db30a63db1a9d84bdcad0c83369623f708e0397"
},
"ok" : 1
}
注意:
有些情况下,一个值可能无法被索引。索引储桶(index bucket)的大小是有限制的,如果某单个索引条目超出了它的单个索引大小的限制,那么这个条目就不会包含在索引里。这样会造成使用这个索引进行查询时会有一个文档凭空消失不见了。所有的字段值都必须小于1024字节,才能包含到索引里。如果一个文档的字段由于太大不能包含在索引里,MongoDB不会返回任何错误或者警告(这段话的意思是某个字段的字段值如果内容太长超过了1024字节,这个值就不会被存到索引中)。
有一些查询完全无法使用索引,也有一些查询能够比其他查询更高效地使用索引。这里将讲述MongoDB对各种不同查询操作符的处理。
A.低效率的操作符
$where:完全无法使用索引
$exists:无法使用索引,如查询有key1这个字段的所有文档({"key1" : {"$exists" : true}})
$ne:可以使用索引,但并不是很有效。因为必须要查看所有的索引条目,而不只是"$ne" 指定的条目,不得不扫描整个索引。
例如:
db.example.find({"i" : {"$ne" : 3}}) 它确实使用了索引,但是他要扫描i>3和i<3的所有索引条目,并返回i>3和i<3的所有文档,假如集合的文档个数有几十万条,那么就和不使用索引没什么区别。
$not:有时能够使用索引(对基本的范围(比如将{"key " : {"$lt" : 7}} 变成 {"key " : {"$gte" : 7}} )和正则表达式进行反转),但大多数使用"$not" 的查询都会退化为进行全表扫描
B.OR查询
如果你在{"x" : 1} 上有一个索引,在{"y" : 1} 上也有一个索引,使用条件{"x" : 123, "y" : 456} 进行查询(相当于是and查询)时,MongoDB会使用其中的一个索引,而不是两个一起用。因为MongoDB在一次查询中只能使用一个索引。
"$or" 是个例外,"$or" 可以对每个子句都使用索引,因为"$or" 实际上是执行两次查询然后将结果集合并。
虽然$or能够用到索引,但是$or的性能没有$in高,因为$or毕竟是多次查询(相当于mysql中的union),因此能用in的情况下尽量用in代替or。
如果or连接的多个条件匹配到重复的文档,MongoDB会检查每次查询的结果集并且从中移除重复的文档
删除一个索引
db.collection.dropIndexes(IndexObj)
例如
db.zbp.dropIndexes( { a: 1, b: 1 } ) // zbp集合删除 a_1_b_1 这个复合索引,dropIndexes也可以传数组包含多个要删除的索引
db.zbp.dropIndexes( "a_1_b_1" ) // 效果同上
db.zbp.getIndexes() // 查看zbp集合的所有索引
我们可以通过getIndexes()获取所有的索引和他们的名称。在创建索引的时候也可以为索引指定名称,比如:
db.foo.createIndex({"a" : 1, "b" : 1, "c" : 1, ..., "z" : 1},{"name" : "alphabet"})
索引的名称默认是 字段名1_1_字段名2_1 的形式
mongodb的其他索引类型
A.唯一索引:唯一索引可以确保集合的每一个文档的指定键都有唯一值。
db.users.createIndex({"username" : 1}, {"unique" : true})
如果对某个字段设置了唯一索引,但是插入文档时没有设置这个字段,索引会将其作为null存储。所以,如果对某个键建立了唯一索引,但插入了多个缺少该索引键的文档,由于集合已经存在一个该索引键的值为null 的文档而导致插入失败。例如有一个集合中的文档如下:
{
"_id" : ObjectId("603704f5d48efa1f174ac2a5"),
"name" : {
"first" : "Joe",
"last" : "Schmoe"
},
"age" : 45
}
我对name.first建立唯一索引:
p.createIndex({"name.first":1},{"unique":1})
然后插入两次下面的文档(缺少name.first),第一次插入成功,第二次失败。:
{
"_id" : ObjectId("6040301f8c4860ff45dc085d"),
"name" : {
"last" : "gaogao"
},
"age" : 20
}
如果插入的某个字段值(该字段值建立了唯一索引),但是这个字段值大小超过单个索引限制的最大大小,他就不会被假如到索引中。也就是说,那么此时就可以重复插入该字段值,因为该值没有被加到索引中。
在已有的集合上创建唯一索引时可能会失败,因为集合中可能已经存在重复值了。
在极少数情况下,可能希望直接删除重复的值再建立唯一索引。创建索引时使用"dropDups"选项,如果遇到重复的值,第一个会被保留,之后的重复文档都会被删除。
db.people.createIndex({"username" : 1}, {"unique" : true, "dropDups" : true})
"dropDups" 会强制性建立唯一索引,但是这个方式太粗暴了:你无法控制哪些文档被保留哪些文档被删除。
复合唯一索引
也可以创建复合的唯一索引。创建复合唯一索引时,单个键的值可以相同,但所有键的组合值必须是唯一的。
如果有一个{"username" : 1, "age" : 1} 上的唯一索引,下面的插入是合法的:
db.users.insert({"username" : "bob"})
db.users.insert({"username" : "bob", "age" : 23})
db.users.insert({"username" : "fred", "age" : 23})
如果试图再次插入这三个文档中的任意一个,都会导致键重复异常。(重复插入第一个相当于重复插入{"username" : "bob","age":null})
B.稀疏索引
MongoDB中的稀疏索引(sparse index)与关系型数据库中的稀疏索引是完全不同的概念。
如果使用了稀疏索引,索引的稀疏属性可确保索引仅包含具有索引字段的文档的条目。索引会跳过没有索引字段的文档。也就是说,如果给一个name字段建立索引的时候添加了稀疏属性,那么只有name为非null的值会被添加到name索引中,而name为null的值不记录到name索引中。
可以将稀疏索引与唯一索引结合使用,以防止插入索引字段值重复的文档,并跳过索引缺少索引字段的文档。
使用sparse 选项就可以创建稀疏索引。例如,如果有一个可选的email地址字段,如果提供了这个字段,那么它的值必须是唯一的
db.createIndex({"email" : 1}, {"unique" : true, "sparse" : true})
稀疏索引不必是唯一的。只要去掉unique 选项,就可以创建一个非唯一的稀疏索引。
下面这个例子展现了使用稀疏属性和没有使用稀疏属性的索引在查询行为上的差异:
p.createIndex({"age":1},{"sparse":1}) // 对age建立稀疏索引
p.insert({ "name" : { "first" : "Joe", "last" : "111" }, "age" : 20 })
p.insert({ "name" : { "first" : "Joe", "last" : "111" }, "age" : 20 })
p.insert({ "name" : { "first" : "Joe", "last" : "111" }, "age" : 21 })
p.insert({ "name" : { "first" : "Joe", "last" : "111" }})
p.insert({ "name" : { "first" : "Joe", "last" : "111" }})
d = p.find({"age":{"$ne":20}}).toArray() // 结果为后3条,此时我们用explain分析发现他是没有用到age索引的(如果使用了索引就不可能查询到age为null的数据了)
d2 = p.find({"age":{"$ne":20}}).hint({"age":1}).toArray() // 强制使用age这个索引进行查询,结果只要1条(第三条),因为age为null没有存在age索引文件中
C. TTL索引
TTL索引是有生命周期(有超时时间)的索引,这种索引允许为每一个文档设置一个超时时间。一个文档到达预设置的超时时间之后就会被删除,不过前提是设置了TTL索引的字段必须是一个日期类型而且该文档必须有这个字段才能自动删除。这种类型的索引对于缓存问题(比如会话的保存)非常有用。
在createIndex中指定expireAfterSeconds属性就可以创建一个TTL索引。例如:
test.createIndex({lastUpdated:1}, {"expireAfterSeconds":60})
这样就在"lastUpdated"字段上建立了一个TTL索引。如果一个文档的"lastUpdated"字段存在并且它的值是日期类型,当服务器时间比文档的"lastUpdated"字段的时间晚expireAfterSeconds秒时,文档就会被删除。但是如果我们在文档被删除之前更新lastUpdated字段使得lastUpdated字段的时间与设置TTL索引时的时间差小于expireAfterSeconds的话,那么该文档就不会被删除(如果之后不更新lastUpdated字段的时间的话,那么之后还是会被删的)。
在一个给定的集合上可以有多个TTL索引。TTL索引不能是复合索引,但是可以像“普通”索引一样用来优化排序和查询。
_id列不支持TTL索引
固定集合(capped collection)不支持TTL索引
如果一个列已经存在索引,则需要先将该索引drop后才能重建为TTL索引,不能直接转换
如果一个字段已经建立了TTL索引,但是想要修改该索引的过期时间,此时可以使用collMod命令修改:
db.runCommand({"collMod":"test", index:{keyPattern:{lastUpdated:1}, "expireAfterSeconds" : 5}})
固定集合
MongoDB中有一种不同类型的集合叫做固定集合,固定集合的大小是固定的(固定的数据量大小或者固定的行数)。如果向一个已经满了的固定集合中插入数据会怎么样?答案是,固定集合的行为类似于循环队列。如果已经没有空间了,最老的文档会被删除以释放空间,新插入的文档会占据这块空间(即新文档会覆盖旧文档)。
固定集合不能被分片。
固定集合可以用于记录日志。虽然可以在创建时指定集合大小,但无法控制什么时候数据会被覆盖。
创建一个固定集合
db.createCollection("my_collection", {"capped":true, "size":100000});
创建了一个名为my_collection
大小为100 000字节的固定集合。
除了大小,createCollection
还能够指定固定集合中文档的数量,但同时也必须指定size大小:
db.createCollection("my_collection2", {"capped" : true, "size" : 100000, "max" : 100});
不管先达到哪一个限制,之后插入的新文档就会把最老的文档挤出集合
固定集合创建之后,就不能改变了(如果需要修改固定集合的大小,只能将它删除之后再重建)。
可以将已有的某个常规集合转换为固定集合,可以使用convertToCapped命令实现。下面的例子将test
集合转换为一个大小为10 000字节的固定集合
db.runCommand({"convertToCapped" : "test", "size" : 10000});
但是无法将固定集合转换为非固定集合(只能将其删除)。