什么情况?MongoDB复合索引居然能引发 “灾难”?

前情提要

  1. 11月末我司商品服务的MongoDB主库曾出现过严重抖动、频繁锁库等情况。
  2. 由于诸多业务存在插入MongoDB、然后立即查询等逻辑,因此项目并未开启读写分离。
  3. 最终定位问题是由于:服务器自身磁盘 + 大量慢查询导致
  4. 基于上述情况,运维同学后续着重增强了对MongoDB慢查询的监控和告警

幸运的一点:在出事故之前刚好完成了缓存过期时间的升级且过期时间为一个月,C端查询都落在缓存上,因此没有造成P0级事故,仅仅阻塞了部分B端逻辑

image.png

事故回放

我司的各种监控做得比较到位,当天突然收到了数据库服务器负载较高的告警通知,于是我和同事们就赶紧登录了Zabbix监控,如下图所示,截图的时候是正常状态,当时事故期间忘记留图了,可以想象当时的数据曲线反正是该高的很低,该低的很高就是了。

Zabbix 分布式监控系统官网:www.zabbix.com/

image.png

开始分析

我们研发是没有操控服务器权限的,因此委托运维同学帮助我们抓取了部分查询记录,如下所示:

---------------------------------------------------------------------------------------------------------------------------+
Op          | Duration | Query                                                                                                                   ---------------------------------------------------------------------------------------------------------------------------+
query       | 5 s      | {"filter": {"orgCode": 350119, "fixedStatus": {"$in": [1, 2]}}, "sort": {"_id": -1}, "find": "sku_main"}               
query       | 5 s      | {"filter": {"orgCode": 350119, "fixedStatus": {"$in": [1, 2]}}, "sort": {"_id": -1}, "find": "sku_main"}               query       | 4 s      | {"filter": {"orgCode": 346814, "fixedStatus": {"$in": [1, 2]}}, "sort": {"_id": -1}, "find": "sku_main"}               query       | 4 s      | {"filter": {"orgCode": 346814, "fixedStatus": {"$in": [1, 2]}}, "sort": {"_id": -1}, "find": "sku_main"}              query       | 4 s      | {"filter": {"orgCode": 346814, "fixedStatus": {"$in": [1, 2]}}, "sort": {"_id": -1}, "find": "sku_main"}
...

查询很慢的话所有研发应该第一时间想到的就是索引的使用问题,所以立即检查了一遍索引,如下所示:

### 当时的索引

db.sku_main.ensureIndex({"_id": -1},{background:true});
db.sku_main.ensureIndex({"orgCode": 1, "_id": -1},{background:true});
db.sku_main.ensureIndex({"orgCode": 1, "upcCode": 1},{background:true});
....

我屏蔽了干扰项,反正能很明显地看出来,这个查询是完全可以命中索引的,所以就需要直面第一个问题:

上述查询记录中排首位的慢查询到底是不是出问题的根源?

我的判断是:它应该不是数据库整体缓慢的根源,因为第一它的查询条件足够简单暴力,完全命中索引,在索引之上有一点其他的查询条件而已,第二在查询记录中也存在相同结构不同条件的查询,耗时非常短。

在运维同学继续排查查询日志时,发现了另一个比较惊爆的查询,如下:

### 当时场景日志

query: { $query: { shopCategories.0: { $exists: false }, orgCode: 337451, fixedStatus: { $in: [ 1, 2 ] }, _id: { $lt: 2038092587 } }, $orderby: { _id: -1 } } planSummary: IXSCAN { _id: 1 } ntoreturn:1000 ntoskip:0 keysExamined:37567133 docsExamined:37567133 cursorExhausted:1 keyUpdates:0 writeConflicts:0 numYields:293501 nreturned:659 reslen:2469894 locks:{ Global: { acquireCount: { r: 587004 } }, Database: { acquireCount: { r: 293502 } }, Collection: { acquireCount: { r: 293502 } } } 

# 耗时
179530ms

耗时180秒且基于查询的执行计划可以看出,它走的是id索引,进行了全表扫描,扫描的数据总量为:37567133,不慢才怪。

迅速解决

定位到问题后,没办法立即修改,第一要务是:止损

结合当时的时间也比较晚了,因此我们发了公告,禁止了上述查询的功能并短暂暂停了部分业务,,过了一会之后进行了主从切换,再去看Zabbix监控就一切安好了。

分析根源

我们回顾一下查询的语句和我们预期的索引,如下所示:

### 原始Query
db.getCollection("sku_main").find({ 
        "orgCode" : NumberLong(337451), 
        "fixedStatus" : { 
            "$in" : [
                1.0, 
                2.0
            ]
        }, 
        "shopCategories" : { 
            "$exists" : false
        }, 
        "_id" : { 
            "$lt" : NumberLong(2038092587)
        }
    }
).sort(
    { 
        "_id" : -1.0
    }
).skip(1000).limit(1000);

### 期望的索引
db.sku_main.ensureIndex({"orgCode": 1, "_id": -1},{background:true});

乍一看,好像一切都很Nice啊,字段orgCode等值查询,字段_id按照创建索引的方向进行倒序排序,为啥会这么慢?

但是,关键的一点就在 $lt 上

知识点一:索引、方向及排序

在MongoDB中,排序操作可以通过从索引中按照索引的顺序获取文档的方式,来保证结果的有序性。

如果MongoDB的查询计划器没法从索引中得到排序顺序,那么它就需要在内存中对结果排序。

注意:在内存排序时,默认最大限制是32M,超过即会抛出错误

知识点二:单列索引不在乎方向

无论是MongoDB还是MySQL都是用的树结构作为索引,如果排序方向和索引方向相反,只需要从另一头开始遍历即可,如下所示:

# 索引
db.records.createIndex({a:1}); 

# 查询
db.records.find().sort({a:-1});

# 索引为升序,但是我查询要按降序,我只需要从右端开始遍历即可满足需求,反之亦然
MIN 0 1 2 3 4 5 6 7 MAX

MongoDB的复合索引结构

官方介绍:MongoDB supports compound indexes, where a single index structure holds references to multiple fields within a collection’s documents.

复合索引结构示意图如下所示:

image.png

该索引刚好和我们讨论的是一样的,userid顺序,score倒序,同时假设当前表存在单列索引: {"score": -1}

我们需要直面第二个问题:复合索引在使用时需不需要在乎方向?

假设两个查询条件:

# 查询 一
db.getCollection("records").find({ 
  "userid" : "ca2"
}).sort({"score" : -1.0});

# 使用索引
{"userid":1, "score":-1}

# 查询 二
db.getCollection("records").find({ 
  "userid" : "ca2"
}).sort({"score" : 1.0});

# 使用索引
{"userid":1, "score":-1}

上述的查询没有任何问题,因为受到score字段排序的影响,只是数据从左侧还是从右侧遍历的问题,那么下面的一个查询呢?

# 错误示范
db.getCollection("records").find({ 
  "userid" : "ca2",
  "score" : { 
    "$lt" : NumberLong(2038092587)
  }
}).sort({"score" : -1.0});

# 使用索引
{"score":-1}

错误原因如下:

  • 由于score字段按照倒序排序,因此为了使用该索引,所以需要从左侧开始遍历
  • 从倒序顺序中找小于某个值的数据,势必会扫描很多无用数据,然后丢弃,当前场景下找大于某个值才是最佳方案
  • 所以MongoDB为了更多场景考虑,在该种情况下,放弃了复合索引,选用其他的索引,如 score 的单列索引

针对性修改

仔细阅读了根源之后,再回顾线上的查询语句,针对性修改,把 lt 条件改为gt 观察优化结果:

# 原始查询
[TEMP INDEX] => lt: {"limit":1000,"queryObject":{"_id":{"$lt":2039180008},"categoryId":23372,"orgCode":351414,"fixedStatus":{"$in":[1,2]}},"restrictedTypes":[],"skip":0,"sortObject":{"_id":-1}}

# 原始耗时
[TEMP LT] => 超时 (超时时间10s)

# 优化后查询
[TEMP INDEX] => gt: {"limit":1000,"queryObject":{"_id":{"$gt":2039180008},"categoryId":23372,"orgCode":351414,"fixedStatus":{"$in":[1,2]}},"restrictedTypes":[],"skip":0,"sortObject":{"_id":-1}}

# 优化后耗时
[TEMP GT] => 耗时: 383ms , List Size: 999

修改方案

  1. 反向排序条件即可 # 上文提到了索引可以从左或者从右开始遍历,因此调整文档扫描方向即可 # 注: 需要主动申明首位(orgCode)字段查询方向,否则会按默认方向查找 sort({ "orgCode" : -1.0},{ "_id" : 1.0}) 复制代码
  2. 修改业务代码 预先查出查询条件下_id最小值(完全利用索引,速度非常快) 将 lt 查询换成 gt 查询即可

拓展场景:无其他索引干扰时的场景

上文中为了模拟线上事故,所以我们假定了一个复合索引以及一个单列索引,即:

{"userid": 1, "score": -1}
{"score": -1}

当我们删除单列索引,按不符合方向的查询,会有什么现象呢?

# 仅剩复合索引时
db.getCollection("records").find({ 
  "userid" : "ca2",
  "score" : { 
    "$lt" : NumberLong(2038092587)
  }
}).sort({"score" : -1.0});

# 使用索引
{"userid":1, "score":-1}

总结

分析了小2000字,其实改动就是两个字符而已,当然真正的改动需要考虑业务的需要,但是问题既然已经定位,修改什么的就不难了,回顾上述内容总结如下:

  • 学习数据库知识的时候可以用类比的方式,但是需要额外注意其不同的地方(MySQL、MongoDB索引、索引的方向)
  • MongoDB单列索引可以不在乎方向
  • MongoDB无法通过索引排序时会在内存中进行排序,超过默认大小(32M)限制后会报错
  • MongoDB数据库复合索引在使用中一定要注意其方向,要完全理解其逻辑,要么完全相同,要么完全相反,避免索引失效
  • 针对上一条:但当索引选择器没有更优解时,即使查询方向不符合索引方向,也会使用目标索引

作者:Kerwin_
原文链接:https://juejin.cn/post/6904045633661829133

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,658评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,482评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,213评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,395评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,487评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,523评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,525评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,300评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,753评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,048评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,223评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,905评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,541评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,168评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,417评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,094评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,088评论 2 352

推荐阅读更多精彩内容

  • MongoDB创建和查看数据库 MongoDB 将 BSON 文档(即数据记录)存储在集合中,数据库包含文档集合。...
    夜雨流云阅读 338评论 0 0
  • 索引用来优化查询,而且在某些特定类型的查询中,索引必不可少。 ---《MongoDB权威指南》 直接创建100...
    ChanZeeBm阅读 415评论 0 0
  • 前言 在MongoDB中,索引通常能够极大的提高查询的效率。如果没有索引,MongoDB在读取数据时必须扫描集合中...
    honehou阅读 1,370评论 0 1
  • ================ 索引 ================ 索引支持在MongoDB中高效地执行查询...
    wanminglei阅读 195评论 0 0
  • 索引的作用是用来加速查询,数据库索引与书籍索引类似,创建数据库索引好像确定何如组织书的索引一样。 explain ...
    JunChow520阅读 1,893评论 0 0