【MongoDB】MongoDB的核心-索引原理及索引优化、及查询聚合优化实战案例(超详细)
文章目录
- 一、数据库查询效率问题引出索引需求
- 二、索引的基本原理及作用
- (一)索引的创建及数据组织
- (二)不同类型的索引
- (三)索引的额外属性
- 三、索引的优化与查询计划分析
- (一)通过profiling监测慢请求
- (二)查询计划分析优化索引使用
- 四、查询聚合优化
- (一)案例背景
- 问题描述
- 问题分析
- 1. 定位慢查询
- 2. 分析慢查询语句
- 第一步:`$match`操作
- 第二步:`$project`操作
- 第三步:`$group`操作
- 查看DB/Server/Collection的状态
- 1. DB状态
- 2. 查看`orders`这个collection的状态
- 性能优化
- 1. 性能优化 - 索引
- 2. 性能优化 - 聚合大量数据
- 小结
更多相关内容可查看
一、数据库查询效率问题引出索引需求
当在使用MongoDB等数据库进行集合查询时,如果遇到查询效率低下的情况,就可能需要考虑使用索引了。以MongoDB为例,在向集合插入多个文档后,每个文档经过底层存储引擎持久化会有一个位置信息(如mmapv1引擎里是『文件id + 文件内offset』,wiredtiger存储引擎里是其生成的一个key),通过这个位置信息能从存储引擎里读出该文档。
mongo-9552:PRIMARY> db.person.find()
{ "_id" : ObjectId("571b5da31b0d530a03b3ce82"), "name" : "jack", "age" : 19 }
{ "_id" : ObjectId("571b5dae1b0d530a03b3ce83"), "name" : "rose", "age" : 20 }
{ "_id" : ObjectId("571b5db81b0d530a03b3ce84"), "name" : "jack", "age" : 18 }
{ "_id" : ObjectId("571b5dc21b0d530a03b3ce85"), "name" : "tony", "age" : 21 }
{ "_id" : ObjectId("571b5dc21b0d530a03b3ce86"), "name" : "adam", "age" : 18 }
假设要执行一个查询操作,比如db.person.find( {age: 18} )
,如果没有索引,就需要遍历所有的文档(即进行“全表扫描”),根据位置信息读出文档后,再对比age
字段是否为18。当集合文档数量较少时,全表扫描的开销可能不大,但当文档数量达到百万、千万甚至上亿时,全表扫描的开销会非常大,一个查询耗费数十秒甚至几分钟都有可能。
二、索引的基本原理及作用
(一)索引的创建及数据组织
比如上面的例子里,person集合里包含插入了5个文档,假设其存储后位置信息如下
位置信息 | 文档 |
---|---|
pos1 | {“name” : “jack”, “age” : 19 } |
pos2 | {“name” : “rose”, “age” : 20 } |
pos3 | {“name” : “jack”, “age” : 18 } |
pos4 | {“name” : “tony”, “age” : 21} |
pos5 | {“name” : “adam”, “age” : 18} |
如果想加速 db.person.find( {age: 18} ),就可以考虑对person表的age字段建立索引。
db.person.createIndex( {age: 1} ) // 按age字段创建升序索引
建立索引后,MongoDB会额外存储一份按age字段升序排序的索引数据,索引结构类似如下,索引通常采用类似btree的结构持久化存储,以保证从索引里快速(O(logN)的时间复杂度)找出某个age值对应的位置信息,然后根据位置信息就能读取出对应的文档。
age | 位置信息 |
---|---|
18 | pos3 |
18 | pos5 |
19 | pos1 |
20 | pos2 |
21 | pos4 |
简单来说,索引就是将文档按照某个(或某些)字段顺序组织起来,以便能根据该字段高效地进行查询。它至少能优化以下场景的效率:
- 查询场景:比如查询年龄为18的所有人,有了索引就无需全表扫描,可直接通过索引快速定位到符合条件的文档。
- 更新/删除场景:在将年龄为18的所有人的信息进行更新或删除时,因为更新或删除操作需要先根据条件查询出所有符合条件的文档,所以本质上也是在优化查询环节。
- 排序场景:将所有人的信息按年龄排序时,如果没有索引,需要全表扫描文档,然后再对扫描的结果进行排序;而有了索引,可利用索引的有序性更高效地完成排序。
MongoDB默认会为插入的文档生成_id
字段(如果应用本身没有指定该字段),并且为了保证能根据文档id
快速查询文档,MongoDB默认会为集合创建_id
字段的索引。
mongo-9552:PRIMARY> db.person.getIndexes() // 查询集合的索引信息
[
{
"ns" : "test.person", // 集合名
"v" : 1, // 索引版本
"key" : { // 索引的字段及排序方向
"_id" : 1 // 根据_id字段升序索引
},
"name" : "_id_" // 索引的名称
}
]
(二)不同类型的索引
MongoDB支持多种类型的索引,每种类型适用于不同的使用场合:
-
单字段索引(Single Field Index):
- 通过
db.person.createIndex( {age: 1} )
语句可针对age
创建单字段索引,能加速对age
字段的各种查询请求,是最常见的索引形式,MongoDB默认创建的_id
索引也属于这种类型。 {age: 1}
代表升序索引,也可通过{age: -1}
来指定降序索引,对于单字段索引,升序/降序效果是一样的。db.person.createIndex( {age: 1, name: 1} )
- 通过
-
复合索引 (Compound Index):
- 它是单字段索引的升级版本,针对多个字段联合创建索引,先按第一个字段排序,第一个字段相同的文档按第二个字段排序,依次类推。例如,通过
db.person.createIndex( {age: 1, name: 1} )
可针对age
、name
这2个字段创建一个复合索引。 - 复合索引能满足的查询场景比单字段索引更丰富,不光能满足多个字段组合起来的查询(如
db.person.find( {age: 18, name: "jack"} )
),也能满足匹配复合索引前缀的查询(如{age: 1}
是{age: 1, name: 1}
的前缀,所以db.person.find( {age: 18} )
的查询也能通过该索引来加速),但像db.person.find( {name: "jack"} )
这种只涉及部分字段且不符合前缀规则的查询则无法使用该复合索引。在创建复合索引时,字段的顺序除了受查询需求影响,还需考虑字段的值分布情况。比如age
字段取值有限,相同age
的文档较多,而name
字段取值丰富,相同name
的文档较少,此时先按name
字段查找,再在相同name
的文档里查找age
字段会更为高效。db.person.createIndex( {name: 1, age: 1} )
- 它是单字段索引的升级版本,针对多个字段联合创建索引,先按第一个字段排序,第一个字段相同的文档按第二个字段排序,依次类推。例如,通过
-
多key索引 (Multikey Index):
- 当索引的字段为数组时,创建出的索引称为多key索引。例如,在
person
表加入一个habbit
字段(数组)用于描述兴趣爱好,通过db.person.createIndex( {habbit: 1} )
可自动创建多key索引,用于查询有相同兴趣爱好的人。{"name" : "jack", "age" : 19, habbit: ["football, runnning"]} db.person.createIndex( {habbit: 1} ) // 自动创建多key索引 db.person.find( {habbit: "football"} )
- 当索引的字段为数组时,创建出的索引称为多key索引。例如,在
-
其他类型索引:
- 哈希索引(Hashed Index):按照某个字段的hash值来建立索引,目前主要用于MongoDB Sharded Cluster的Hash分片,hash索引只能满足字段完全匹配的查询,不能满足范围查询等。
- 地理位置索引(Geospatial Index):能很好地解决O2O的应用场景,比如“查找附近的美食”、“查找某个区域内的车站”等。
- 文本索引(Text Index):能解决快速文本查找的需求,比如对于一个博客文章集合,可针对博客的内容建立文本索引,以便根据博客内容快速查找。
(三)索引的额外属性
MongoDB除了支持多种不同类型的索引,还能对索引定制一些特殊的属性:
- 唯一索引 (unique index):保证索引对应的字段不会出现相同的值,比如
_id
索引就是唯一索引。 - TTL索引:可以针对某个时间字段,指定文档的过期时间(经过指定时间后过期 或 在某个时间点过期)。
- 部分索引 (partial index):只针对符合某个特定条件的文档建立索引,在3.2版本才支持该特性。
- 稀疏索引(sparse index):只针对存在索引字段的文档建立索引,可看做是部分索引的一种特殊情况。
三、索引的优化与查询计划分析
(一)通过profiling监测慢请求
MongoDB支持对DB的请求进行profiling,目前支持3种级别的profiling:
- 0级:不开启profiling。
- 1级:将处理时间超过某个阈值(默认100ms)的请求都记录到DB下的system.profile集合(类似于mysql、redis的slowlog),生产环境通常建议使用此级别,并根据自身需求配置合理的阈值,用于监测慢请求的情况,以便及时进行索引优化。
- 2级:将所有的请求都记录到DB下的system.profile集合,生产环境需慎用。
(二)查询计划分析优化索引使用
当索引已经建立了,但查询还是很慢时,就需要深入分析索引的使用情况,可通过查看详细的查询计划来决定如何优化。通过执行计划可以看出以下问题:
- 根据某个/些字段查询,但没有建立索引。
- 根据某个/些字段查询,但建立了多个索引,执行查询时没有使用预期的索引。
例如,建立索引前,db.person.find( {age: 18} )
必须执行COLLSCAN
(全表扫描);
mongo-9552:PRIMARY> db.person.find({age: 18}).explain()
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.person",
"indexFilterSet" : false,
"parsedQuery" : {
"age" : {
"$eq" : 18
}
},
"winningPlan" : {
"stage" : "COLLSCAN",
"filter" : {
"age" : {
"$eq" : 18
}
},
"direction" : "forward"
},
"rejectedPlans" : [ ]
},
"serverInfo" : {
"host" : "localhost",
"port" : 9552,
"version" : "3.2.3",
"gitVersion" : "b326ba837cf6f49d65c2f85e1b70f6f31ece7937"
},
"ok" : 1
}
建立索引后,通过查询计划可以看出,先进行[IXSCAN](从索引中查找),然后
FETCH`,读取出满足条件的文档。
mongo-9552:PRIMARY> db.person.find({age: 18}).explain()
{
"queryPlanner" : {
"plannerVersion" : 1,
"namespace" : "test.person",
"indexFilterSet" : false,
"parsedQuery" : {
"age" : {
"$eq" : 18
}
},
"winningPlan" : {
"stage" : "FETCH",
"inputStage" : {
"stage" : "IXSCAN",
"keyPattern" : {
"age" : 1
},
"indexName" : "age_1",
"isMultiKey" : false,
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 1,
"direction" : "forward",
"indexBounds" : {
"age" : [
"[18.0, 18.0]"
]
}
}
},
"rejectedPlans" : [ ]
},
"serverInfo" : {
"host" : "localhost",
"port" : 9552,
"version" : "3.2.3",
"gitVersion" : "b326ba837cf6f49d65c2f85e1b70f6f31ece7937"
},
"ok" : 1
}
需要注意的是,索引并不是越多越好,集合的索引太多,会影响写入、更新的性能,因为每次写入都需要更新所有索引的数据。所以system.profile里的慢请求可能是索引建立得不够导致,也可能是索引过多导致。
四、查询聚合优化
(一)案例背景
我们有一个电商订单分析系统,使用MongoDB存储订单数据。当执行一个分析接口,获取特定店铺在某一周内的订单商品分类统计信息时,发现查询速度非常慢,严重影响用户体验。
问题描述
执行订单分析接口,查询特定店铺(假设店铺ID为“20001”)在某一周(2024 - 05 - 01T00:00:00.000Z到2024 - 05 - 07T23:59:59.999Z)内的订单商品分类统计,需要花费约12秒,这明显不符合性能要求。
问题分析
1. 定位慢查询
首先查看当前mongo profile的级别,通过db.getProfilingLevel()
发现其为0,即默认没有记录。设置profile级别为记录慢查询模式,设置阈值为1000ms,即db.setProfilingLevel(1, 1000)
。再次执行订单分析查询接口,查看Profile记录。
2. 分析慢查询语句
通过查看Profile记录,发现执行的查询是一个聚合管道(pipeline):
第一步:$match
操作
{
"$match": {
"storeId": "20001",
"$and": [
{
"orderTime": {
"$gte": ISODate("2024-05-01T00:00:00.000Z"),
"$lte": ISODate("2024-05-07T23:59:59.999Z")
}
}
]
}
}
用于匹配店铺ID为“20001”且订单时间在指定一周内的订单记录。
第二步:$project
操作
{
"$project": {
"productCategory": 1,
"orderDate": {
"$concat": [
{
"$substr": [
{
"$year": [
"$orderTime"
]
},
0,
4
]
},
"-",
{
"$substr": [
{
"$month": [
"$orderTime"
]
},
0,
2
]
},
"-",
{
"$substr": [
{
"$dayOfMonth": [
"$orderTime"
]
},
0,
2
]
}
]
}
}
}
除了提取productCategory
字段外,还对orderTime
字段进行处理,拼接为“yyyy - MM - dd”格式,并将其命名为orderDate
。
第三步:$group
操作
{
"$group": {
"_id": {
"orderDate": "$orderDate",
"productCategory": "$productCategory"
},
"count": {
"$sum": 1
}
}
}
对orderDate
和productCategory
进行分组,统计不同日期和商品分类对应的订单数量。
从Profile中可以看到相关指标:
millis
:花费了12010毫秒返回查询结果。ts
:命令执行时间。info
:命令内容。query
:代表查询。ns
:ecommerce.orders
(代表查询的库与集合)。nreturned
:返回记录数及用时。reslen
:返回的结果集大小(字节数)。nscanned
:扫描记录数量。
发现nscanned
数很大,接近记录总数,可能没有使用索引查询。
查看DB/Server/Collection的状态
1. DB状态
查看数据库整体状态,包括服务器版本、运行时间、连接数、各种操作计数器(如插入、查询、更新、删除等操作的次数)、存储引擎信息等。示例部分信息如下:
{
"host": "ECOMMONGODB",
"version": "6.0.5",
"process": "mongod",
"pid": NumberLong(2005),
"uptime": 12345678.0,
"uptimeMillis": NumberLong(12345678901),
"uptimeEstimate": NumberLong(12345678),
"localTime": ISODate("2024-05-08T10:20:30.123Z"),
"asserts": {
"regular": 0,
"warning": 0,
"msg": 0,
"user": 12345,
"rollovers": 0
},
"connections": {
"current": 120,
"available": 800,
"totalCreated": 13000
},
// 其他更多信息...
"ok": 1.0
}
2. 查看orders
这个collection的状态
{
"ns": "ecommerce.orders",
"size": 987654321,
"count": 3500000,
"avgObjSize": 282,
"storageSize": 234567890,
"capped": false,
"wiredTiger": {
// wiredTiger存储引擎相关详细信息...
},
"nindexes": 1,
"totalIndexSize": 30123456,
"indexSizes": {
"_id_": 30123456
},
"ok": 1.0
}
性能优化
1. 性能优化 - 索引
目前只有_id
索引,接下来对orders
集合创建storeId
、orderTime
和productCategory
字段的索引:
db.orders.ensureIndex({"storeId": 1, "orderTime": 1, "productCategory": 1});
db.orders.ensureIndex({"orderTime": 1});
db.orders.ensureIndex({"productCategory": 1});
db.orders.ensureIndex({"storeId": 1});
创建索引后,查询特定店铺一周内的订单商品分类统计信息,时间缩短到了500ms,效果显著。但当查询一个月的数据时,仍然需要15秒。
通过增加索引小结:添加索引解决了针对索引字段查询的效率问题,但对于大量数据的聚合操作,仅靠索引不能完全解决性能问题。例如,在没有索引的情况下,从500万条数据中找出特定店铺的订单可能需要全表扫描,耗时很长;而有了索引,命中索引查询(IXSCAN)速度提升明显。不过,对于聚合操作,随着数据量增大,性能问题依然存在。同时,判断效率优化情况应该看执行计划,而不仅仅是执行时间,因为执行时间可能受到多种因素影响。
2. 性能优化 - 聚合大量数据
对于这种查询聚合大量数据的问题,考虑到这是一个类似OLAP的操作,对其性能期望不能过高,因为大量数据的I/O操作远超OLTP操作。但仍有一定的优化空间:
- 在订单插入或更新时,对每个店铺每天的每个商品分类的订单数量进行实时计数,并存储在一个专门的缓存集合中。例如,以
{storeId: "20001", orderDate: "2024-05-01", productCategory: "Electronics", count: 10}
的形式存储。 - 每隔一段时间(如每天凌晨)对缓存集合进行一次完整的统计和更新,确保数据的准确性。这样在查询订单商品分类统计信息时,可以直接从缓存集合中获取数据,大大减少了查询和聚合的时间。
小结
- 慢查询定位:通过Profile分析慢查询。
- 查询优化:通过添加相应索引提升查询速度。
- 聚合大数据方案:对于类似OLAP的聚合操作,要合理降低性能期望。从源头入手,在数据插入或更新时做好部分统计工作,缓存结果,以便在查询时直接使用,从而提升整体性能。同时,要结合执行计划来评估优化效果。