把我遇到过的跟索引相关的问题梳理一遍。
基础知识
索引的基础知识还是先过一遍,以免有人还不清楚。如果你还卡在这些问题的理解上面,需要反思一下自己基础知识是否学好了哟。
复合索引的顺序
MongoDB不会收集数据的统计信息,没有直方图之类的东西,所以创建复合索引时也不需要考虑数据倾斜的问题,用最基本的ESR
原则来思考就可以了:
- Equality – 等值条件
- Sort – 排序条件
- Range – 范围条件
顺便说句,虽然索引中字段的顺序很重要,但查询条件中只要是AND
的关系时,条件出现的顺序是无关紧要的。我也不知道为什么会有不少人跟我争论这个……关于这点如果你现在脑海中有不同的意见,冷静,再好好想想。
区分度
有些字段的区分度很低,比如性别
,它们适合放在复合索引里吗?至少从MongoDB的角度而言,区分度不是个大问题。如果频繁作为搜索条件出现的话,我甚至推荐把区分度低的字段放在前面。原因如下:
- MongoDB是把复合索引的所有字段连接在一起作为索引键的,所以单个字段的区分度影响不大,所有字段在一起有区分度就可以了;
- MongoDB的索引在任何时候都启用前缀压缩,这使得区分度低的字段在前面时,反而会因为前缀压缩而得到更大的收益。当然,能否放在前面还需要看你的查询条件是否能够命中索引前缀;
前缀压缩:当一个字符串在索引键出现过一次,再在其他键中出现时,就不需要再完整地存储下来了。例如,当出现过
apple
后再出现apple iphone
则后者不需要存储整个字符串,只需要存储一个指针指向apple
,再存储iphone
就可以了。在一些特定场景中,前缀压缩可以达到非常好的压缩效果。
索引前缀
当一个复合索引{a: 1, b: 1, c: 1}
存在时,所有能够命中它的前缀的查询,都能够使用这个索引。换句话说,当前述索引存在时,以下所有就没有存在的必要了,直接删除即可:
{a: 1, b: 1}
{a: 1}
索引的问题
唯一索引
在分片集中,唯一索引生效的充分必要条件是:唯一索引键正好是片键。除此之外唯一索引都不能保证全局唯一。另外有少数版本出现过唯一索引失效的bug,请参考SERVER-58936。
后台索引
这大概是遇到最多的问题之一了。在<4.2的版本中,MongoDB创建索引时索引分为前台和后台两种模式:
- 前台索引:创建更快,B树更均衡,但占用库级锁;
- 后台索引:创建更慢,但不占用锁;
因为库级锁
的原因,在4.2之前几乎没有机会使用前台索引,除非:
- 系统还没上线;
- 线上系统有足够的维护时间;
- 正在使用滚动维护来构建索引;
滚动维护:将复制集的从节点单独拆出来,以独立模式重启然后进行维护的工作方式。最后再进行主从切换后完成原主节点的维护。
如果在线上环境直接使用前台索引,可想而知会发生什么问题。在MongoDB 2.x的时代,曾经有过一个bug,即使在主节点上使用了{background: true}
来创建后台索引,在该操作复制到从节点时,{background: true}
没有正确复制,导致从节点上创建索引变为前台索引,进而造成一些问题。严格来讲这其实也不算bug,如果你从最终一致性的角度来思考这个问题,其实这种做法是有它的道理的。
不管怎么说,已经是陈芝麻烂谷子的事情,但印象特别深刻。如果你还在使用2.x版本,与其了解这些无用的知识还不如早日升级吧。
幸运的是,从4.2开始MongoDB支持混合索引,综合了两者的长处,所以不再需要区分前后台索引。
杀死索引构建的时机
如果一个索引构建过程已经开始了,想要结束它该怎么办?首先,索引是一个消耗极大的动作,即使使用了后台模式,索引不可避免地还是需要遍历整个数据表,这会造成:
- 大量冷数据需要从磁盘加载到内存中,它会:
- 需要驱逐一部分热数据来腾出空间;
- 显著增加磁盘读I/O;
- 消耗可观的CPU运算资源来构建B树;
- 索引的写入还会造成一定的写I/O增加;
所有这些原因都暗示着,索引应该是一个慎之又慎的动作,它
- 不应该在业务高峰期进行;
- 要么别开始,一旦开始就不应该轻易去停止它(消耗的资源已经消耗了,结束即意味着浪费);
但是,如果就是想要结束掉索引创建的进程,你需要先了解索引创建是怎样工作的(以下描述适用于<e4.2):
- 在主节点上创建索引(前台或后台);
- 索引完成时
createIndex
指令被写入oplog中; - 从节点开始以同样的方式创建索引;
- 索引完成时
createIndex
指令写入从节点的oplog中;
细心的朋友可能已经发现问题了,凡是进入oplog的东西,是一定会保证在主节点和从节点上是一致的。那么问题来了,想杀掉一个索引构建进程,就只能在createIndex
进入oplog之前。换句话说,只有在主节点的索引构建还没完成时,才可以终止索引。在这之后,由于要保证数据一致性,从节点必须完成该索引的创建,杀死索引创建进程只会让它从头开始而已。
那有人又要想了,既然不能直接杀死从节点上的索引构建,那我就在主节点上再下一条dropIndex
指令,我们就当一切没有发生过好不好?我只想说,你想得美。从因果一致性的角度考虑,dropIndex
必须发生在createIndex
之后,所以这并不能帮你越过创建索引的步骤去直接删除它。说人话就是,该等的还得等。并且,dropIndex
会被正在执行的createIndex
阻塞。后者在{background: true}
加持下虽然不会占用库级锁了,但前者会。结果就是,处在排队中的dropIndex
阻塞了整个从节点的所有操作。对于使用读写分离的用户来说,这个结果是灾难性的。更不幸的是这个时候除了等以外没有别的办法,非常被动。还有更坏的消息吗?有的!等了也是白等。因为索引一旦建完就会马上被删除。所以你经历了一大段宕机时间,最后什么也没得到,赔了夫人又折兵。
总结一下就是,对索引的操作要慎之又慎,不要随便乱来。
CommitQuorum
为了解决上述问题,从4.4开始对索引创建动作作出了大刀阔斧的改进。新的索引创建流程会同时在主从节点上创建索引,当足够多的节点完成索引时,主节点才会把索引标记为可用。多少节点算“足够多”呢?它就是由commitQuorum
来控制的(具体参考文档)。默认情况下它是所有有投票权的节点。而shell在执行命令时也会等到commitQuorum
满足之后才会结束阻塞(注意这里是shell阻塞,不影响服务器)。看出问题了吗?有仲裁节点,或者当前存在离线的节点时,你需要配置好合适的commitQuorum
才能正确执行创建索引指令,否则createIndex
将永远无法结束。那么当你无意中设置了过大的commitQuorum
时该怎么办?不用担心,还有setIndexCommitQuorum可以帮你修改错误的设置。
一同改进的还有dropIndex
的行为。当一个索引正在创建中时,现在你可以直接在主节点上发起dropIndex
来停止正在进行的索引构建工作,而不会造成前一章所说的问题。动手前请先阅读文档Aborts In-Progress Index Builds哦。但同样的,我更希望你在开始前就考虑好,而不是事后补救。
PS:除了解决上述问题以外,这个改进还带来一个额外的好处,那就是可以使创建索引花费的总时间降到原来的一半,因为原来是先主后从,现在是主从一起上。
结束语
说到底,解决问题的最好办法是防患于未然。与其问“从10楼掉下来怎么能不摔死”,不如问“怎么可以避免从10楼掉下来”。虽然后者需要长时间的经验积累,但还是不推荐尝试前者。我这么说好像很可笑,但换到技术问题上很多人就是转不过这个弯来。祝大家没有宕机时间,不用半夜加班吧!
参考链接
- SERVER-58936: https://jira.mongodb.org/browse/SERVER-58936
- createIndex: https://www.mongodb.com/docs/v4.4/reference/method/db.collection.createIndex/
- setIndexCommitQuorum: https://www.mongodb.com/docs/v4.4/reference/command/setIndexCommitQuorum/#mongodb-dbcommand-dbcmd.setIndexCommitQuorum
- Aborts In-Progress Index Builds: https://www.mongodb.com/docs/v4.4/reference/method/db.collection.dropIndex/#aborts-in-progress-index-builds
学习了 🙏