基于我们的数据特性,在进行数据库选型时选择了 mongo 数据库。在文档数量很大的情况下,存在慢查询,影响服务端性能。合理地对数据库命令及索引进行优化,可以很大幅度提升接口性能

mongo 分页查询

在 Java 中使用 mongodb 的 MongoTemplate 进行分页时,一般的策略是使用 skip+limit 的方式,但是这种方式在需要略过大量数据的时候就显得很低效。

传统分页介绍

假设一页大小为 10 条。则:

//page 1
1-10

//page 2
11-20

//page 3
21-30
...

//page n
10*(n-1)+1-10*n

MongoDB 提供了 skip() 和 limit() 方法。

skip: 跳过指定数量的数据. 可以用来跳过当前页之前的数据,即跳过 pageSize*(n-1)。limit: 指定从 MongoDB 中读取的记录条数,可以当做页面大小 pageSize。
所以,分页可以这样做:

//Page 1
db.getCollection('file').find({}).limit(10)

//Page 2
db.getCollection('file').find({}).skip(10).limit(10)

//Page 3
db.getCollection('file').find({}).skip(20).limit(10)
........


存在问题

官方文档对 skip 的描述:
skip 方法从结果集的开头进行扫描后返回查询结果。这样随着偏移的增加,skip 将变得更慢
The cursor.skip() method requires the server to scan from the beginning of the input results set before beginning to return results. As the offset increases, cursor.skip() will become slower.
所以,需要一种更快的方式。其实和 mysql 数量大之后不推荐用 limit m,n 一样。
官方建议使用范围查询,可以使用 [索引] 分页相比,偏移量增加时通常会产生更好的性能。即指定开始位置解决方案是先查出当前页的第一条,然后顺序数 pageSize 条。


指定范围分页介绍

我们假设基于_id 的条件进行查询比较。事实上,这个比较的基准字段可以是任何你想要的有序的字段,比如时间戳。

//Page 1
db.getCollection('file').find({}).limit(pageSize);
//Find the id of the last document in this page
last_id =...

//Page 2
users =db.getCollection('file').find({
'_id':{"$gt":ObjectId("5b16c194666cd10add402c87")}
}).limit(10)

//Update the last id with the id of the last document in this page
last_id =...

显然,第一页和后面的不同。对于构建分页 API, 我们可以要求用户必须传递 pageSize, lastId。

●pageSize 页面大小
●lastId 上一页的最后一条记录的 id,如果不传,则将强制为第一页


降序

_id 降序,第一页是最大的,下一页的 id 比上一页的最后的 id 还小。

db.getCollection('file').find({ _id:{ $lt:lastId}})
.sort({ _id:-1})
.limit(pageSize)


升序

_id 升序,下一页的 id 比上一页的最后一条记录 id 还大。

db.getCollection('file').find({ _id:{ $gt:lastId}})
.sort({ _id:1})
.limit(pageSize )


总条数

还有一共多少条和多少页的问题。所以,需要先查一共多少条 count

db.getCollection('file').find({}).count();

ObjectId 的有序性问题
先看 ObjectId 生成规则:
比如"_id" : ObjectId("5b1886f8965c44c78540a4fc")
取 id 的前 4 个字节。由于 id 是 16 进制的 string,4 个字节就是 32 位,对应 id 前 8 个字符。即 5b1886f8, 转换成 10 进制为 1528334072. 加上 1970,就是当前时间。
事实上,更简单的办法是查看 org.mongodb:bson:3.4.3 里的 ObjectId 对象。

publicObjectId(Date date){
this(dateToTimestampSeconds(date), MACHINE_IDENTIFIER, PROCESS_IDENTIFIER, NEXT_COUNTER.getAndIncrement(),false);
}

//org.bson.types.ObjectId#dateToTimestampSeconds 
privatestatic int dateToTimestampSeconds(Date time){
return(int)(time.getTime()/ 1000L);
}

//java.util.Date#getTime
/**
 * Returns the number of milliseconds since January 1, 1970, 00:00:00 GMT
 * represented by this <tt>Date</tt> object.
 *
 * @return  the number of milliseconds since January 1, 1970, 00:00:00 GMT
 *          represented by this date.
 */
public long getTime(){
returngetTimeImpl();
}

MongoDB 的 ObjectId 应该是随着时间而增加的,即后插入的 id 会比之前的大。但考量 id 的生成规则,最小时间排序区分是秒,同一秒内的排序无法保证。当然,如果是同一台机器的同一个进程生成的对象,是有序的。
如果是分布式机器,不同机器时钟同步和偏移的问题。所以,如果你有个字段可以保证是有序的,那么用这个字段来排序是最好的。_id 则是最后的备选方案。


存在问题

上面的分页看起来看理想,虽然确实是,但有个问题是不能无法做到跳页。
我们的分页数据要和排序键关联,所以必须有一个排序基准来截断记录。而跳页,我只知道第几页,条件不足,无法分页了。
现实业务需求确实提出了跳页的需求,虽然几乎不会有人用,人们更关心的是开头和结尾,而结尾可以通过逆排序的方案转成开头。所以,真正分页的需求应当是不存在的。如果你是为了查找某个记录,那么查询条件搜索是最快的方案。如果你不知道查询条件,通过肉眼去一一查看,那么下一页足矣。
说了这么多,就是想扭转传统分页的概念,在互联网发展的今天,大部分数据的体量都是庞大的,跳页的需求将消耗更多的内存和 cpu,对应的就是查询慢。
当然,如果数量不大,如果不介意慢一点,那么 skip 也不是啥问题,关键要看业务场景。
我今天接到的需求就是要跳页,而且数量很小,那么 skip 吧,不费事,还快。
比如 google,看起来是有跳页选择的啊。再仔细看,只有 10 页,多的就必须下一页,并没有提供一共多少页,跳到任意页的选择。这不就是我们的 find-condition-then-limit 方案吗,只是他的一页数量比较多,前端或者后端把这一页给切成了 10 份。

同样,Facebook,虽然提供了总 count,但也只能下一页。

其他场景,比如 Twitter,微博,朋友圈等,根本没有跳页的概念的。
如果确实有跳页的需求,可以仍旧采用 skip 做分页,目前还没有发现性能问题

private List<DBObject> doFindItems(String collectionName,
      Map<String, Object> query, DBObject showFields, int skip,
      int limit, DBObject order) {

   List<DBObject> result = null;
   DBObject obj = genDBObject(query);
   DBCursor cursor = readDB.getCollection(collectionName)
         .find(obj, showFields);
   if (cursor != null) {
      try {
         if (order != null) {
            cursor.sort(order);
         }
         cursor.skip(skip).limit(limit);
         result = cursor.toArray();
      } finally {
         cursor.close();
      }
   }
  return result;
}


排序和性能

前面关注于分页的实现原理,但忽略了排序。既然分页,肯定是按照某个顺序进行分页的,所以必须要有排序的。

MongoDB 的 sort 和 find 组合

db.getCollection('file').find().sort({'createTime':1}).limit(5)
db.getCollection('file').find().limit(5).sort({'createTime':1})

这两个都是等价的,顺序不影响执行顺序。即,都是先 find 查询符合条件的结果,然后在结果集中排序。

我们条件查询有时候也会按照某字段排序的,比如按照时间排序。查询一组时间序列的数据,我们想要按照时间先后顺序来显示内容,则必须先按照时间字段排序,然后再按照 id 升序。

db.getCollection('file').find({productId:5}).sort({createTime:1, _id:1}).limit(5)

我们先按照 createTime 升序,然后 createTime 相同的 record 再按照_id 升序,如此可以实现我们的分页功能了。


多字段排序

db.getCollection('file').sort({taskRole:1,appId:-1})

表示先按照 taskRole 升序,再按 appId 降序

示例:

db.getCollection('file').find({});

结果:
/* 1 */
{
    "_id" : ObjectId("5e7179de0af8595d0bbe243f"),
    "fileName" : "test.apk",
    "fileCTime" : NumberLong(1584495748123),
    "version" : "1"
}
/* 2 */
{
    "_id" : ObjectId("5e7dc423e20bc4b7fa6c205a"),
"fileName" : "b.html",
"version" : "2"
}
/* 3 */
{
    "_id" : ObjectId("5e7dc423e20bc4b7fa6c205b"),
"fileName" : "b.html",
"version" : "3"
}

按照 fileName 升序,然后按照 version 降序

db.getCollection('file').find({}).sort({fileName:1,version:-1})

结果:
/* 1 */
{
    "_id" : ObjectId("5e7dc423e20bc4b7fa6c205a"),
"fileName" : "b.html",
"version" : "2 "
}
/* 2 */
{
    "_id" : ObjectId("5e7dc423e20bc4b7fa6c205b"),
"fileName" : " b.html",
"version" : "3"
}
/* 3 */
{
    "_id" : ObjectId("5e7179de0af8595d0bbe243f"),
    "fileName" : "test.apk",
    "fileCTime" : NumberLong(1584495748123),
    "version" : "1"
}

Mongo 慢查询优化

监控

mongodb 可以通过 profile 来监控查询,查出耗时查询,然后进行优化。
profile 常用命令:
db.getProfilingLevel();//查看当前是否开启 profile 功能用命令,返回 level 等级,值为 0-关闭、1-慢命令、2-全部
db.setProfilingLevel(level);//开启 profile 功能
level 为 1 的时候,慢命令默认值为 100ms,更改为 db.setProfilingLevel(level,slowms) 如 db.setProfilingLevel(1,50);//更改慢命令值为 50ms
db.system.profile.find() //当前的监控日志。
db.system.profile.find({millis:{$gt:500}});//返回查询时间在 500 毫秒以上的查询命令。

{
    "op" : "query",
    "ns" : "ones.file",//慢日志是所在库和集合
    "command" : {   //具体查询命令
        "find" : "file",
        "filter" : {
            "qbuildCid" : 449557
        },
        "projection" : {},
        "limit" : 1,
        "singleBatch" : true,
        "$db" : "ones",
        "lsid" : {
            "id" : UUID("a9086c77-b0ae-4de1-b0d2-9db19a455762")
        }
    },
    "keysExamined" : 0,
    "docsExamined" : 221258,//此次查询遍历文档个数
    "cursorExhausted" : true,
    "numYield" : 1728,
    "nreturned" : 1,
    "locks" : {
        "Global" : {
            "acquireCount" : {
                "r" : NumberLong(1731)
            }
        },
        "Database" : {
            "acquireCount" : {
                "r" : NumberLong(1729)
            }
        },
        "Collection" : {
            "acquireCount" : {
                "r" : NumberLong(1729)
            }
        }
    },
    "storage" : {},
    "responseLength" : 712,
    "protocol" : "op_msg",
    "millis" : 220,//查询耗时
    "planSummary" : "COLLSCAN",
    "execStats" : {
        "stage" : "LIMIT",
        "nReturned" : 1,
        "executionTimeMillisEstimate" : 10,
        "works" : 221260,
        "advanced" : 1,
        "needTime" : 221258,
        "needYield" : 0,
        "saveState" : 1728,
        "restoreState" : 1728,
        "isEOF" : 1,
        "invalidates" : 0,
        "limitAmount" : 1,
        "inputStage" : {
            "stage" : "COLLSCAN",
            "filter" : {
                "qbuildCid" : {
                    "$eq" : 449557
                }
            },
            "nReturned" : 1,
            "executionTimeMillisEstimate" : 10,
            "works" : 221259,
            "advanced" : 1,
            "needTime" : 221258,
            "needYield" : 0,
            "saveState" : 1728,
            "restoreState" : 1728,
            "isEOF" : 0,
            "invalidates" : 0,
            "direction" : "forward",
            "docsExamined" : 221258
        }
    },
    "ts" : ISODate("2020-05-27T10:50:15.394Z"),//命令执行时间
    "client" : "10.16.25.102",
    "allUsers" : [ 
        {
            "user" : "mongo",
            "db" : "admin"
        }
    ],
    "user" : "mongo@admin"
}

millis 为查询耗时,如果发现时间比较长,那么就需要作优化。
docsExamined 代表查询遍历文档数,如果该值很大,或者接近记录总数,那么可能没有用到索引查询。


索引

如果发现查询的时间较长,那么可能需要为待查询的字段建立索引

索引的原理是通过建立指定字段的 B-Tree,通过搜索 B-Tree 来查找对应 document 的地址。如果需要查询超过一半的集合数据,那直接遍历效率反而会更高,因为省去了搜索 B-Tree 的过程。

结果集在原集合中所占的比例越大,查询效率越慢。因为使用索引需要进行两次查找:一次查找索引条目,一次根据索引指针去查找相应的文档。而全表扫描只需要进行一次查询。在最坏的情况,使用索引进行查找次数会是全表扫描的两倍。效率会明显比全表扫描低。例如,在文件表中,我们拥有一个"type"列索引,如果在"type"列中,android 占了 50%,如果现在要查询一个类型为 android,文件名为 “test.apk"的文件,我们则需要在表的 50% 的数据中查询,这样有索引的性能会降低

而相反在提取较小的子数据集时,索引就非常有效,这就是我们为什么会使用分页。


索引设计原则

1.主键的设置:业务无关、显示指定、递增属性
2.数据区分度:原则上区分度高的字段优先做索引字段,如果是组合索引优先放前面
3.字段更新频率:频繁更新的字段是否做索引字段需要考虑对业务的影响及查询的代价
4.前缀索引问题:需要注意的是因前缀索引只包含部分值因此无法通过前缀索引优化排序
5.适当冗余设计:对于存储较长字符串字段可额外增加字段存储原字段计算后的值,创建索引时只需要对额外字段创建索引即可
6.避免无效索引:通常类似表已经含有主键 ID 就无需再创建额外唯一性的 ID 索引
7.查询覆盖率:设计一个索引我们需要考虑尽量覆盖更多的查询场景
8.控制字段数:如果你设计的索引例如含有 7、8 个字段通常需要考虑设计是否合理


Explain 查询计划

命令:

db.getCollection('file').find({qbuildId:441557}).explain()


Explain 结果

explain 结果将查询计划以阶段树的形式呈现。
每个阶段将其结果(文档或索引键)传递给父节点。
中间节点操纵由子节点产生的文档或索引键。
根节点是 MongoDB 从中派生结果集的最后阶段。
在看查询结果的阶段树的时候一定一定是从最里层一层一层往外看的,不是直接顺着读下来的。
在查询计划中出现了很多 stage,下面列举的经常出现的 stage 以及他的含义:
COLLSCAN:全表扫描
IXSCAN:索引扫描
FETCH:根据前面扫描到的位置抓取完整文档
SORT:进行内存排序,最终返回结果
SORT_KEY_GENERATOR:获取每一个文档排序所用的键值
LIMIT:使用 limit 限制返回数
SKIP:使用 skip 进行跳过
IDHACK:针对_id 进行查询
COUNTSCAN:count 不使用用 Index 进行 count 时的 stage 返回
COUNT_SCAN:count 使用了 Index 进行 count 时的 stage 返回
TEXT:使用全文索引进行查询时候的 stage 返回通过这些信息就能判断查询时如何执行的了


其他

如果数据文件大于系统内存,查询速度会下降几个数量级,因为 mongodb 是内存数据库。1000 万数据的时候没有索引情况下查询可能会几秒钟甚至更久。
另外一点是数据索引如果大于内存,速度也会下降很多。而且对于多条件查询,如果你查询的顺序和索引顺序不同,也不能使用索引。
如果你使用了 replica set,这个会影响写入速度的,三个 replica set,速度会降低到三分之一。


↙↙↙阅读原文可查看相关链接,并与作者交流