【背景】
在使用ORACLE、MYSQL以及MongoDB数据库时,其中查询时经常遇到NULL的性能问题,例如Oracle的索引中不记录全是NULL的记录,MongoDB中默认索引中会记录全是null的文档,MongoDB查询等于null时,表示索引字段对应值是null同时还包括字段不存在的文档.因为MongoDB是动态模式,允许每一行的字段都不一样,例如记录1中包括包括字段A等于1,记录2包括字段A等于null,记录3不包括字段A,那么索引中不仅会包括A等于null的文档,同时也记录不包括A字段的文档,同样会赋予null值(空数组属于特殊的).正是由于这些设计规则不同,难免在使用过程中就会遇到各种性能问题.常见查询包括统计null总数以及对应明细数据.其中以汇总统计为例.
疑问:对于以上三个查询语句,大家可以先思考下能不能用上索引、如能用上索引会是什么样执行计划,例如IXSCAN+FETCH.是否有优化空间?接下来我们会重点分析这些问题点,也欢迎大家提出自己看法以及实际环境遇到相关性能问题.
备注:当前是基于4.4 版本来验证与测试.同时在集合xiaoxu上fld4字段存在正常索引{a:1},包括用到5.0以及6.0版本来解决性能问题(安装与升级不在本次中).另外索引不是multikey.
【性能问题之查询单个null值总数】
1、具体语句以及执行效率
db.xiaoxu.count()
54528512
db.xiaoxu.count({fld4:null})
550000
db.xiaoxu.explain("executionStats").count({fld4:null}).
executionStats.executionTimeMillis
900ms
从以上查询结果来看,null占比1%,整个集合5400万,如集合总数以及null呈现N倍数量级上升.例如null到500万,预计查询时间至少在10s.
2、分析具体的执行计划
其中IXSCAN+FETCH+FILTER+COUNT,消耗时间主要消耗在FETCH+FILTER,IXSCAN预估消耗才58ms.占比不到10%.说明MongoDB中查询null能够用上索引,需要关注效率问题
关键点:回表并没有过滤掉什么记录,都是满足记录,为什么不能使用覆盖查询来进行统计.如果是索引覆盖查询,直接采用COUNT_SCAN即可.如果把null换成其他常量是否可以?
3、查询等值字符串总数
db.xiaoxu.explain("executionStats").count({fld4:"sit"})
经过验证:
查询非空等值汇总时,执行计划走的是覆盖查询,直接COUNT_SCAN,并没有出现回表FETCH以及FILTER操作.符合预期行为.而且有114万满足条件只需要445ms.比查询55万null值还快500ms.
4、问题思考
1、查询等于null为什么不能使用覆盖查询,需进行FETCH+FILTER,对于存在少量满足null情况的过滤对性能影响小,如随着集合总数以及null呈现N倍数据量上升,此时进行FETCH+FILTER对性能影响非常大.
2、对于查询null,能否给这些字段赋予默认值,不用null,使用其他默认值来替代,避免去检查字段值等于null或者字段不存在的情况--这种虽然可行,需要提前设计就需要参考考虑进去,另外本身就是动态模式,这样限制它的灵活性.特定场景下是可以使用,例如模式是固定的.或者从关系型数据库改造到MongoDB.
5、性能优化思路
针对上述优化方案,对于第二条虽然可以,需要前期参与以及牺牲一定灵活性,所以重点考虑问题点1.
1、是否可以采用部分索引--这个跟exists:true不同,fld4:null可以直接定位到数据,已经实现与部分索引相同的作用.其中都需要进行回表进行过滤.无法实现覆盖查询.
主要性能在于回表过滤,理论上都满足覆盖查询条件,经过检索mongodb jira发现,这个是由于老的索引格式造成.从4.9版本开始,重新设计索引格式,只要索引是非multikey,
查询等于null可以使用覆盖查询.对于multikey索引,至少6.0还是不行.
2、 升级到5.0版本验证下能否实现覆盖查询
备注:升级5.0后使用覆盖查询,性能明细提升,响应时间从900ms下降到384ms.性能明显提升.第一个性能问题通过升级完美解决,至于赋予默认值方式也可以解决.
db.xiaoxu.explain("executionStats").count({fld4:null}).executionStats
{
"executionSuccess" : true,
"nReturned" : 0,
"executionTimeMillis" : 384,
"totalKeysExamined" : 550002,
"totalDocsExamined" : 0,
"executionStages" : {
"stage" : "COUNT",
"nReturned" : 0,
"executionTimeMillisEstimate" : 59,
"works" : 550002,
"advanced" : 0,
"needTime" : 550001,
"needYield" : 0,
"saveState" : 550,
"restoreState" : 550,
"isEOF" : 1,
"nCounted" : 550000,
"nSkipped" : 0,
"inputStage" : {
"stage" : "OR",
"nReturned" : 550000,
"executionTimeMillisEstimate" : 59,
"works" : 550002,
"advanced" : 550000,
"needTime" : 1,
"needYield" : 0,
"saveState" : 550,
"restoreState" : 550,
"isEOF" : 1,
"dupsTested" : 550000,
"dupsDropped" : 0,
"inputStages" : [
{
"stage" : "COUNT_SCAN",
"nReturned" : 0,
"executionTimeMillisEstimate" : 0,
"works" : 1,
"advanced" : 0,
"needTime" : 0,
"needYield" : 0,
"saveState" : 550,
"restoreState" : 550,
"isEOF" : 1,
"keysExamined" : 1,
"keyPattern" : {
"fld4" : 1
},
"indexName" : "fld4_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"fld4" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"indexBounds" : {
"startKey" : {
"fld4" : undefined
},
"startKeyInclusive" : true,
"endKey" : {
"fld4" : undefined
},
"endKeyInclusive" : true
}
},
{
"stage" : "COUNT_SCAN",
"nReturned" : 550000,
"executionTimeMillisEstimate" : 19,
"works" : 550001,
"advanced" : 550000,
"needTime" : 0,
"needYield" : 0,
"saveState" : 550,
"restoreState" : 550,
"isEOF" : 1,
"keysExamined" : 550001,
"keyPattern" : {
"fld4" : 1
},
"indexName" : "fld4_1",
"isMultiKey" : false,
"multiKeyPaths" : {
"fld4" : [ ]
},
"isUnique" : false,
"isSparse" : false,
"isPartial" : false,
"indexVersion" : 2,
"indexBounds" : {
"startKey" : {
"fld4" : null
},
"startKeyInclusive" : true,
"endKey" : {
"fld4" : null
},
"endKeyInclusive" : true
【性能问题之查询单个不等于null值总数】
1、具体SQL
db.xiaoxu.count({fld4:{$ne:null})
备注:对于查询不等于null的情况,从4.2版本就支持覆盖查询,通常情况下,不等于null数据非常大,此时查询速度无法保证.大部分场景下性能都存在瓶颈.这个例子中5300万耗时是38s.这个相对简单些,需要MongoDB 4.2版本才支持索引覆盖查询.
场景:适合不等于值少的.否则虽能使用覆盖查询,但对于大集合还是消耗时间.
【性能问题之查询组合null与其他等值总数】
1、查询语句以及问题
db.xiaoxu.count({fld4:{$in:[1,2,null]}})
550003
备注:4.4版本执行计划--没有走覆盖索引,根据第一个案例中提到这个属于正常.
问题来了
5.0版本执行计划--居然还没有走覆盖索引,根据第一个案例中提到升级5.0可以走覆盖查询,组合查询失效.
2、问题思考
1、5.0版本为什么查询单个null值或者其他非null等值组合查询时,可以使用覆盖查询,与null值组合到一起后不能使用覆盖查询?
2、5.0版本中所有值都进行回表过滤,执行计划与4.4版本单个等值null相同.5.0版本优化是对null进行拆分多个or然后合并?当null与非null组合出现,拆分成多个or场景并没有出现?--这个是我们的机会.
3、如何进行优化
如遇到上面的性能问题,5.0也无法解决,考虑如下2个思路:
1、能否继续升级到6.0版本--对于生产环境需要从多个角度进行考虑.这里只是验证能否解决性能问题.
2、如果已经是5.0版本,能否手动改写SQL来调优.搞MongoDB这么久,第一次尝试SQL改写来进行优化.
4、方案1-升级到6.0来验证
db.serverStatus().version;
6.0.0-rc8
重点:升级到6.0版本发现组合查询使用覆盖查询,查询时间是300ms.从850ms下降到300ms,提升性能明显.这个只是作为技术验证方案,是否升级需要看实际情况,如果新选型,通常建议选择新版本带来的红利.同时也要忍受一定BUG.
5、方案2--5.0中改写SQL进行优化
改写原因:
1、为什么会想到SQL改写,主要受到5.0中null优化思路影响,在5.0中把null查询拆分成2个OR,一个是查询null,一个是查询undefined,最终合并.
2、在使用ORACLE、MYSQL时由于优化器不足或者设计问题,导致在当前版本需要手动改写SQL来进行性能优化或者升级新版本来解决(升级版本已尝到甜头).
改写要点:
1、用到unionWith聚合管道,相当于关系型数据库中union all,注意不是union.unionWith是4.4版本新功能.在改写过程中遇到一个诡异事情.--主要研究这个如何改写.
2、在应用端进行拆分,然后应用端进行汇总(这里需要在应用端修改实现--这里不讨论),因为5.0中单个null已提升性能.
使用unionWith进行改写:
【原始SQL】
db.xiaoxu.count({fld4:{$in:[1,2,null]}})
550003
【改写后SQL】
db.xiaoxu.aggregate([
{$match: {fld4: null}},
{$group: {_id: '$fld4',total: {$sum: 1}}},
{
$unionWith: {coll: 'xiaoxu',
pipeline: [
{$match: {fld4: {$in: [1,2]}}},
{$group: {_id: '$fld4',total: {$sum: 1}}}]}},
{$group: {_id: null,total: {$sum: '$total'}}}])
{ "_id" : null, "total" : 550003 }
问题:发现改写后,依然存在FETCH阶段,并没有达到预期覆盖查询,问题点在哪里?
区别:前后2者区别在于FETCH+PROJECTTION_SIMPLE以及PROJECTION_COVERED.那么如果null这部分也能实现PROJECTION_COVERED,那么问题迎刃而解.问题来了:投影是怎么产生的?
[投影是如何产生]
拆解下:match+group2个管道组成,那么产生投影就是group作用.对应SQL:group:{_id:"fld4",total:{in:[1,2]可以投影覆盖,而null不能投影覆盖(有朋友知道可以告知下).--具体原因不得而知,如果按照语义来修改成group:{_id:null,total:{
【按照语义来改写语句】
db.xiaoxu.aggregate([
{$match: {fld4: null}},
{$group: {_id: null,total: {$sum: 1}}},
{
$unionWith: {coll: 'xiaoxu',
pipeline: [
{$match: {fld4: {$in: [1,2]}}},
{$group: {_id: null,total: {$sum: 1}}}]}},
{$group: {_id: null,total: {$sum: '$total'}}}])
[ { _id: null, total: 550003 } ]
经过改写后,执行时间从1326ms下降到432ms,性能提升70%.相比6.0版本,执行效率要稍微差一点.新版本红利.
【总结】
1、对于查询汇总单个null值总数,4.9版本开始(5.0版本)索引可以实现覆盖查询来解决FETCH+FILTER造成性能问题,表越大以及null越多效果越明显.4.9之前版本没有太好办法,只能在程序设计考虑使用默认值来替代null.
2、对于查询汇总组合null与其他等值总数,6.0版本可以完美使用索引实现覆盖查询来解决FETCH+FILTER造成性能问题,5.0版本需要使用unionWith改成或者在应用端拆分多个count来累加也可以.其实这个改写上一在MongoDB尝试过一次失败了,主要是单纯count与分组聚合count上语义理解偏差导致.这次也是偶然发现.
欢迎大家一起来交流与分享.