通过对MongoDB数据库索引等关键技术的深入研究和实践,极大提升了多样化复杂查询模式下的数据库查询效率,实现了海量数据下系统可扩展和自动伸缩能力,为中金支付未来业务发展提供了数百TB级数据治理能力的技术支撑。
前 言
随着中金支付支付业务的快速发展,各类支付核心业务日均交易量上百万笔,高峰期甚至近千万笔,给支付系统带来了很大的压力。尤其是数据库,作为整个交易系统最核心的组成部分,数据库的性能直接决定了整个系统的稳定性。
为了应对交易高峰期大数据量访问压力问题,我司数据库系统采用了“水平切分+前后台分离”的架构方案。对于前台商户侧交易访问,通过数据水平拆分实现了非常高的扩展性和可用性。对于后台运营侧查询访问,采用“前台与后台分离”架构,解决了在运营侧查询需求多样化、查询量较大情况下,交易库性能受到影响的问题。
根据前台商户侧访问和后台运营侧访问各自的特点,以“前台与后台分离”架构方案为指导,我们搭建了“Oracle+MongoDB”的数据治理模型。即:前台交易系统采用关系型数据库Oracle保障服务高可用和强一致性;后台运营系统采用非关系型数据库MongoDB应对后台大数据量查询、多样化查询的需求。
(数据库“水平切分+前后台分离”架构图)
在解决后台业务各类不同的查询效率问题上,我们对MongoDB的索引技术进行了深入的调研和实践,本文将以用户绑卡场景为例,向大家展示复杂查询条件下MongoDB最优索引选择的方法和思路,文中会通过观察查询语句的执行计划输出,分析MongoDB查询语句的执行过程。文末将给出我司在MongoDB数据库索引技术方面的最佳实践原则,以及MongoDB在我司真实场景下的压测效果。
数据准备
首先我们在MongoDB上建立一个用户绑卡集合(binding),该集合中实际的绑卡数据近百亿条,字段个数超过40个。为了方便展示索引挑选的过程,本文选取了其中4条典型数据和3个典型字段作为示例。每条绑卡数据都有交易时间timestamp和绑卡id,且其中三条是成功交易。
1. {timestamp:1,status: "成功",bindingid:"3" }
2. {timestamp:2,status: "成功",bindingid:"4" }
3. {timestamp:3,status: "失败",bindingid:"5" }
4. {timestamp:4,status: "成功",bindingid:"6" }
假设我们的查询需求是:绑卡时间在2到4之间的所有成功交易,并以绑卡id降序输出。下文将会分三个阶段建立查询语句,并借助MongoDB的explain()方法来分析最优索引选择的过程。
单范围查询
我们首先在binding集合上对timestamp字段做一个简单的范围查询。
1. >db.binding.find(
2. {timestamp: {$gte: 2, $lte: 4}}
3. )
很明显,查询返回文档数为3。通过下面的执行计划,我们能够看到MongoDB是如何找到这3条记录的。
1. >db.binding.find(
2. {timestamp: {$gte: 2, $lte: 4}}
3. ).explain(true)
4. ------------------------------------------
5. {
6. …
7. "executionStats" : {
8. "executionSuccess" : true,
9. "nReturned" : 3,
10. "executionTimeMillis" : 0,
11. "totalKeysExamined" : 0,
12. "totalDocsExamined" : 4,
13. "executionStages" : {
14. "stage" : "COLLSCAN",
15. …
16.}
通过查看执行计划的输出,我们可以看到MongoDB进行了全集合扫描(COLLSCAN)后返回了3条结果数据。在绑卡数据上千万条时,全集合扫描意味着MongoDB必须对所有的数据进行检查,此时的性能表现会非常差。因此,任何时候我们都应该避免全集合扫描。为了消除COLLSCAN,考虑对timestamp字段建立索引。
1. >db.binding.createIndex({timestamp: 1})
再次执行前面的查询,并查看执行计划。
1. >db.binding.find(
2. {timestamp: {$gte: 2, $lte: 4}}
3. ).explain(true)
4. ------------------------------------------
5. {
6. …
7. "executionStats" : {
8. "executionSuccess" : true,
9. "nReturned" : 3,
10. "executionTimeMillis" : 0,
11. "totalKeysExamined" : 3,
12. "totalDocsExamined" : 3,
13. "executionStages" : {
14. "stage" : "FETCH",
15. "nReturned" : 3,
16. "executionTimeMillisEstimate" : 0,
17. "docsExamined" : 3,
18. …
19. "inputStage" : {
20. "stage" : "IXSCAN",
21. "nReturned" : 3,
22. …
23.}
此时可以看到,之前的COLLSCAN变成了IXSCAN和FETCH,并且totalDocsExamined从4降为3。这是因为MongoDB通过索引扫描直接定位到了timestamp查询条件范围内的文档,而直接忽略了timestamp需求范围外的文档。因此不需要在文档上做额外的扫描。
在执行计划中,totalKeysExamined表示MongoDB扫描的范围内的索引键的个数,totalDocsExamined表示MongoDB寻找最终结果时扫描的文档数量,nReturned表示最终返回的结果文档数量。totalDocsExamined会至少包含所有要返回的文档nReturned。因此,对于带索引的查询,在一般情况(不包含交叉索引的情况)都会是:totalKeysExamined>= totalDocsExamined>=nReturned。但是,我们希望的索引应该是:totalKeysExamined=totalDocsExamined=nReturned,这样就意味着MongoDB在查询时使用了我们创建的理想索引。
等值与范围查询
当查询条件中的字段没有完全被包含在索引中时,MongoDB必须检查一些索引键指向的在查询条件匹配范围外的文档时,totalDocsExamined就会大于nReturned。例如,在仅有timestamp单字段索引的情况下,查询timestamp在2到4之间的所有绑卡成功的记录。
1. >db.binding.find(
2. {timestamp: {$gte: 2, $lte: 4},status: "成功"}
3. ).explain(true)
4. ------------------------------------------
5. { …
6. "executionStats" : {
7. "executionSuccess" : true,
8. "nReturned" : 2,
9. "executionTimeMillis" : 0,
10. "totalKeysExamined" : 3,
11. "totalDocsExamined" : 3,
12. …
13.}
从上面的执行计划输出,我们可以看到totalDocsExamined大于nReturned。与仅有timestamp的查询相比,nReturned降到了2,但是totalKeysExamined和totalDocsExamined仍然是3。这是因为MongoDB通过索引扫描了timestamp在2到4之间的数据,其中包括了status是成功和失败的交易,只有当MongoDB进行文档检查时,才能最终确定需要的成功交易。
那么,如何能让totalKeysExamined=totalDocsExamined=nReturned呢?下面建立timestamp和status复合索引,然后再查看执行计划。
1. >db.binding.createIndex({timestamp:1, status:1})
2. >db.binding.find(
3. {timestamp: {$gte: 2, $lte: 4},status: "成功"}
4. ).explain(true)
5. ------------------------------------------
6. { …
7. "executionStats" : {
8. "executionSuccess" : true,
9. "nReturned" : 2,
10. "executionTimeMillis" : 0,
11. "totalKeysExamined" : 3,
12. "totalDocsExamined" : 2,
13. …
14.}
此时totalDocsExamined从之前的3降到了2,但是totalKeysExamined仍然是3。这是因为MongoDB索引扫描了从{timestamp:2, status:"成功"}到{ timestamp:4, status: "成功"}间的所有项,其中包含了{ timestamp:3, status: "失败"}项,当索引扫描到这个中间项时,发现status不是需要的成功状态,就直接忽略了它,并没有做文档检查。因此,文档扫描的个数比索引扫描的数量少1个。
那么,如何能提高本次查询的执行性能,将totalKeysExamined降低到2呢?我们考虑下复合索引的顺序,前面建立的顺序是{timestamp, status},那现在考虑调换下顺序,创建{status, timestamp }索引。
1. >db.binding.createIndex({status:1, timestamp:1})
2. >db.binding.find(
3. {timestamp: {$gte: 2, $lte: 4},status: "成功"}
4. ).explain(true)
5. ------------------------------------------
6. { …
7. "executionSuccess" : true,
8. "nReturned" : 2,
9. "executionTimeMillis" : 0,
10. "totalKeysExamined" : 2,
11. "totalDocsExamined" : 2,
12. …
13.}
可以看出,索引键扫描数降到了我们期望的2个。当我们把status放在索引前面时,MongoDB直接跳到状态为成功的索引部分,然后向下对timestamp进行范围扫描。此时,我们得到了查询的最优执行结果totalKeysExamined=totalDocsExamined =nReturned。
对比{timestamp}单字段索引和{status, timestamp}两字段复合索引,我们知道增加status后的复合索引的查询效率更优。当每天的绑卡交易及查询次数均百万数量级以上时,降低totalDocsExamined和totalKeysExamined能获得很大的性能提升。但是,从内存空间占用的角度来说,两字段索引比单字段索引Size更大,占用的RAM会更多。因此,我们在进行索引选择时,一定是要综合考虑所有的代价。
等值、范围与排序
现在我们已经知道查找时间在2到4间的绑卡成功交易的最优索引。最后一步,将查询结果按照bindingid降序排列输出。
1. >db.binding.find(
2. {timestamp: {$gte: 2, $lte: 4},status: "成功"}
3. ).sort({bindingid: -1}).explain(true)
4. ------------------------------------------
5. { …
6. "executionStats" : {
7. "executionSuccess" : true,
8. "nReturned" : 2,
9. "executionTimeMillis" : 0,
10. "totalKeysExamined" : 2,
11. "totalDocsExamined" : 2,
12. "executionStages" : {
13. "stage" : "SORT",
14. "nReturned" : 2,
15. "memUsage" : 202,
16. "memLimit" : 33554432,
17. …
18.}
在使用{status, timestamp}索引下,加入对bindingid的排序后,执行计划中多了“memUsage”和“memLimit”字段,这表明本次查询MongoDB进行了内存排序操作。内存排序既消耗了一定RAM,也占用CPU资源,因此性能是相当差的。“memUsage”反应了本次排序占用的内存大小,如果超出了“memLimit”限制的32M,MongoDB则会报错。本例中只有4条数据,想象下如果是生产环境,数据量近亿条,MongoDB会直接拒绝该查询,进而应用程序会抛出异常。
那么,如何能避免内存排序呢?考虑将排序字段放入索引中,创建{status:1, bindingid:1},并查看执行计划。
1. >db.binding.createIndex({status:1,bindingid:1})
2. >db.binding.find(
3. {timestamp: {$gte: 2, $lte: 4},status: "成功"}
4. ).sort({bindingid: -1}).explain(true)
5. ------------------------------------------
6. { …
7. "executionStats" : {
8. "executionSuccess" : true,
9. "nReturned" : 2,
10. "executionTimeMillis" : 0,
11. "totalKeysExamined" : 3,
12. "totalDocsExamined" : 3,
13. "executionStages" : {
14. "stage" : "FETCH",
15. …
16.}
本次执行可以看到,没有再进行内存排序了。因为status条件是等值判断,在status="成功"的查找范围中,复合索引的第二个字段是有序的bindingid,按索引顺序检索出的结果已经是按照bindingid排序好的,所以不用再进行内存排序。但此时totalDocsExamined=3,而nReturned=2,说明索引没有完全包含查询条件,仍然在检索索引之后使用了文档的内容来判断是否符合查询结果。如果有大数据量的查询没有完全包含在索引之内,即totalDocsExamined和nReturned相差比较悬殊,说明这个查询性能仍有很大的提升空间。
因此,考虑创建一个三字段的复合索引{status:1,bindingid:1,timestamp:1},将所有查询条件包含在内。
1. >db.binding.createIndex({status:1,bindingid:1,timestamp:1})
2. >db.binding.find(
3. {timestamp: {$gte: 2, $lte: 4},status: "成功"}
4. ).sort({bindingid: -1}).explain(true)
5. ------------------------------------------
6. { …
7. "executionStats" : {
8. "executionSuccess" : true,
9. "nReturned" : 2,
10. "executionTimeMillis" : 0,
11. "totalKeysExamined" : 3,
12. "totalDocsExamined" : 2,
13. "executionStages" : {
14. "stage" : "FETCH"
15. …
16.}
可以看到,totalDocsExamined=nReturned=2,查询没有再继续用文档内容来判断条件,直接从索引就得到了所有结果。
当查询条件中同时包含等值、范围和排序三种时,如果在创建索引时将范围查询字段放在排序字段前,结果几乎全是无序的,需要进行内存排序;如果范围字段放在等值字段前,查询时扫描的文档数totalDocsExamine很有可能会大于nReturned,需要进行文档扫描。相反,如果采用{等值,排序,范围}的顺序创建索引,既能保证排序字段在索引中已经有序,避免内存排序的操作,又能使得文档扫描数与结果文档数相等,最小化文档扫描的数量。
多字段复合索引的好处是它所包含的字段更多,当查询条件更多时,多字段复合索引也有能力在索引范围内给出结果,而不必判断文档内容。而且多字段的复合索引会有多个前缀索引,条件不多时也可以完全使用索引,复用性更强。
多字段复合索引的缺点是它占用内存较多,索引只有保存在RAM上才有速度上的优势,所以当数据量很大时,占用内存较多的索引可能会有内存竞争,非热点索引无法一直保存在RAM上。
在进行多字段复合索引的创建时,还要考虑范围查找字段的选择性问题。选择性不强时,通过索引并不能筛选出较小范围内的结果,比如每次查询范围条件总是将90%以上的数据包含在内,那么该范围查询条件在复合索引中并没有起到理想作用,因为其并没有明显缩小检索索引的范围,并且增加了索引查询时间。相反,如果文档是内存中的热数据,直接检索文档的时间可能比在索引中检查范围条件还快一些,因为检索复合索引中的条件,当条件越靠后时,花费的时间代价是越高的。因此,范围查询条件要体现一定的选择性才有作为复合索引字段的价值。
索引实践原则
通过对MongoDB数据库索引技术的深入调研与实践,我们总结了MongoDB索引创建及优化的基本原则:
·消除全集合扫描COLLSCAN,确保所有查询至少落在一个可用索引上。
·尽可能的避免内存排序操作。内存排序既占用RAM也消耗CPU资源。当内存使用超过32M时,MongoDB会向应用程序抛出异常。
·创建复合索引时,最优的字段顺序是:{等值条件,排序条件,范围查找条件}。这样既能避免内存排序,也能最小化索引键和文档的扫描数量。
·在避免内存排序的前提下,尽可能的减小查询扫描的索引键数与文档数,即:totalKeysExamined=totalDocsExamined=nReturned。
·复合索引中字段(除排序字段外)应有具有一定的选择性。将选择性不高的字段放入索引里不仅不能提高查询的效率,反而会占用更多的RAM。在数据量很大时,还会产生内存竞争问题。
测试结果
以MongoDB索引实践原则为指导,我们在后台MongoDB数据库业务数据集合上创建了最佳复合索引组合,查询效率大幅提升。下表展示了用户绑卡场景在2亿数据集下的各种查询模式的性能表现。
未来展望
面对未来海量数据,中金支付采用的“水平切分+前后台分离”数据库架构,将后台大批量的查询与前台实时交易查询分离,既保障了交易系统的高可用、可伸缩,又实现了后台运营侧多样化、批量查询的需求。
通过对MongoDB数据库索引等关键技术的深入研究和实践,极大的提升了多样化复杂查询模式下的数据库查询效率,实现了海量数据下系统可扩展和自动伸缩能力,为未来业务发展提供了数百TB级数据治理能力的技术支撑。