MongoDB 谨防索引seek的效率问题(转)
目录
声明:本文同步发表于 MongoDB 中文社区,传送门:
http://www.mongoing.com/archives/27310
背景
最近线上的一个工单分析服务一直不大稳定,监控平台时不时发出数据库操作超时的告警。
运维兄弟沟通后,发现在每天凌晨1点都会出现若干次的业务操作失败,而数据库监控上并没有发现明显的异常。
在该分析服务的日志中发现了某个数据库操作产生了 SocketTimeoutException。
开发同学一开始希望通过调整 MongoDB Java Driver 的超时参数来规避这个问题。
但经过详细分析之后,这样是无法根治问题的,而且超时配置应该如何调整也难以评估。
下面是关于对这个问题的分析、调优的过程。
初步分析
从出错的信息上看,是数据库的操作响应超时了,此时客户端配置的 SocketReadTimeout 为 60s。
那么,是什么操作会导致数据库 60s 还没能返回呢?
业务操作
左边的数据库是一个工单数据表(t_work_order),其中记录了每张工单的信息,包括工单编号(oid)、最后修改时间(lastModifiedTime)
分析服务是Java实现的一个应用程序,在每天凌晨1:00 会拉取出前一天修改的工单信息(要求按工单号排序)进行处理。
由于工单表非常大(千万级),所以在处理时会采用分页的做法(每次取1000条),使用按工单号翻页的方式:
- 第一次拉取
db.t_work_order.find({
"lastModifiedTime":{
$gt: new Date("2019-04-09T09:44:57.106Z"),
$lt: new Date("2019-04-09T10:44:57.106Z")},
"oid": {$exists: true}})
.sort({"oid":1}).limit(1000)
- 第二次拉取,以第一次拉取的最后一条记录的工单号作为起点
db.t_work_order.find({
"lastModifiedTime":{
$gt: new Date("2019-04-09T09:44:57.106Z"),
$lt: new Date("2019-04-09T10:44:57.106Z")},
"oid": {$exists: true, $gt: "VXZ190"}})
.sort({"oid":1}).limit(1000)
..
根据这样的查询,开发人员给数据表使用的索引如下:
db.t_work_order.ensureIndexes({
"oid" : 1,
"lastModifiedTime" : -1
})
尽管该索引与查询字段基本是匹配的,但在实际执行时却表现出很低的效率:
第一次拉取时间非常的长,经常超过60s导致报错,而后面的拉取时间则会快一些
为了精确的模拟该场景,我们在测试环境中预置了小部分数据,对拉取记录的SQL执行Explain:
db.t_work_order.find({
"lastModifiedTime":{
$gt: new Date("2019-04-09T09:44:57.106Z"),
$lt: new Date("2019-04-09T10:44:57.106Z")}
"oid": {$exists: true}})
.sort({"oid":1}).limit(1000)
.explain("executionStats")
输出结果如下
"nReturned" : 1000,
"executionTimeMillis" : 589,
"totalKeysExamined" : 136661,
"totalDocsExamined" : 1000,
...
"indexBounds" : {
"oid" : [
"[MinKey, MaxKey]"
],
"lastModifiedTime" : [
"(new Date(1554806697106), new Date(1554803097106))"
]
},
"keysExamined" : 136661,
"seeks" : 135662,
在执行过程中发现,检索1000条记录,居然需要扫描 13.6 W条索引项!
其中,几乎所有的开销都花费在了 一个seeks操作上了。
索引seeks的原因
官方文档对于 seeks 的解释如下:
The number of times that we had to seek the index cursor to a new position in order to complete the index scan.
翻译过来就是:
seeks 是指为了完成索引扫描(stage),执行器必须将游标定位到新位置的次数。
我们都知道 MongoDB 的索引是B+树的实现(3.x以上),对于连续的叶子节点扫描来说是非常快的(只需要一次寻址),那么seeks操作太多则表示整个扫描过程中出现了大量的寻址(跳过非目标节点)。
而且,这个seeks指标是在3.4版本支持的,因此可以推测该操作对性能是存在影响的。
为了探究 seeks 是怎么产生的,我们对查询语句尝试做了一些变更:
去掉 exists 条件
exists 条件的存在是因为历史问题(一些旧记录并不包含工单号的字段),为了检查exists查询是否为关键问题,修改如下:
db.t_work_order.find({
"lastModifiedTime":{
$gt: new Date("2019-04-09T09:44:57.106Z"),
$lt: new Date("2019-04-09T10:44:57.106Z")}
})
.sort({"oid":1}).limit(1000)
.explain("executionStats")
执行后的结果为:
"nReturned" : 1000,
"executionTimeMillis" : 1533,
"totalKeysExamined" : 272322,
"totalDocsExamined" : 272322,
...
"inputStage" : {
"stage" : "FETCH",
"filter" : {
"$and" : [
{
"lastModifiedTime" : {
"$lt" : ISODate("2019-04-09T10:44:57.106Z")
}
},
{
"lastModifiedTime" : {
"$gt" : ISODate("2019-04-09T09:44:57.106Z")
}
}
]
},
...
"indexBounds" : {
"oid" : [
"[MinKey, MaxKey]"
],
"lastModifiedTime" : [
"[MaxKey, MinKey]"
]
},
"keysExamined" : 272322,
"seeks" : 1,
这里发现,去掉 exists 之后,seeks 变成了1次,但整个查询扫描了 27.2W 条索引项! 刚好是去掉之前的2倍。
seeks 变为1次说明已经使用了叶节点顺序扫描的方式,然而由于扫描范围非常大,为了找到目标记录,会执行顺序扫描并过滤大量不符合条件的记录。
在 FETCH 阶段出现了 filter可说明这一点。与此同时,我们检查了数据表的特征:同一个工单号是存在两条记录的!于是可以说明:
- 在存在exists查询条件时,执行器会选择按工单号进行seeks跳跃式检索,如下图:
- 在不存在exists条件的情况下,执行器选择了叶节点顺序扫描的方式,如下图:
gt 条件和反序
除了第一次查询之外,我们对后续的分页查询也进行了分析,如下:
db.t_work_order.find({
"lastModifiedTime":{
$gt: new Date("2019-04-09T09:44:57.106Z"),
$lt: new Date("2019-04-09T10:44:57.106Z")},
"oid": {$exists: true, $gt: "VXZ190"}})
.sort({"oid":1}).limit(1000)
.explain("executionStats")
上面的语句中,主要是增加了$gt: "VXZ190"这一个条件,执行过程如下:
"nReturned" : 1000,
"executionTimeMillis" : 6,
"totalKeysExamined" : 1004,
"totalDocsExamined" : 1000,
...
"indexBounds" : {
"oid" : [
"(\"VXZ190\", {})"
],
"lastModifiedTime" : [
"(new Date(1554806697106), new Date(1554803097106))"
]
},
"keysExamined" : 1004,
"seeks" : 5,
可以发现,seeks的数量非常少,而且检索过程只扫描了1004条记录,效率是很高的。
那么,是不是意味着在后面的数据中,满足查询的条件的记录非常密集呢?
为了验证这一点,我们将一开始第一次分页的查询做一下调整,改为按工单号降序的方式(从后往前扫描):
db.t_work_order.find({
"lastModifiedTime":{
$gt: new Date("2019-04-09T09:44:57.106Z"),
$lt: new Date("2019-04-09T10:44:57.106Z")},
"oid": {$exists: true}})
.sort({"oid":-1}).limit(1000)
.explain("executionStats")
新的"反序查询语句"的执行过程如下:
"nReturned" : 1000,
"executionTimeMillis" : 6,
"totalKeysExamined" : 1001,
"totalDocsExamined" : 1000,
...
"direction" : "backward",
"indexBounds" : {
"oid" : [
"[MaxKey, MinKey]"
],
"lastModifiedTime" : [
"(new Date(1554803097106), new Date(1554806697106))"
]
},
"keysExamined" : 1001,
"seeks" : 2,
可以看到,执行的效率更高了,几乎不需要什么 seeks 操作!
经过一番确认后,我们获知了在所有数据的分布中,工单号越大的记录其更新时间值也越大,基本上我们想查询的目标数据都集中在尾端。
于是就会出现一开始提到的,第一次查询非常慢甚至超时,而后面的查询就快了。
上面提到的两个查询执行路线如图所示:
- 加入$gt 条件,从中间开始检索
- 反序,从后面开始检索
优化思路
通过分析,我们知道了问题的症结在于索引的扫描范围过大,那么如何优化,以避免扫描大量记录呢?
从现有的索引及条件来看,由于同时存在gt、exists以及叶子节点的时间范围限定,不可避免的会产生seeks操作,
而且查询的性能是不稳定的,跟数据分布、具体查询条件都有很大的关系。
于是一开始所提到的仅仅是增加 socketTimeout 的阈值可能只是治标不治本,一旦数据的索引值分布变化或者数据量持续增大,可能会发生更严重的事情。
回到一开始的需求场景,定时器要求读取每天更新的工单(按工单号排序),再进行分批处理。
那么,按照化零为整的思路,新增一个lastModifiedDay字段,这个存储的就是lastModifiedTime对应的日期值(低位取整),这样在同一天内更新的工单记录都有同样的值。
建立组合索引 {lastModifiedDay:1, oid:1},相应的查询条件改为:
{
"lastModifiedDay": new Date("2019-04-09 00:00:00.000"),
"oid": {$gt: "VXZ190"}
}
-- limit 1000
执行结果如下:
"nReturned" : 1000,
"executionTimeMillis" : 6,
"totalKeysExamined" : 1000,
"totalDocsExamined" : 1000,
...
"indexBounds" : {
"lastModifiedDay" : [
"(new Date(1554803000000), new Date(1554803000000))"
],
"oid" : [
"(\"VXZ190\", {})"
]
},
"keysExamined" : 1000,
"seeks" : 1,
这样优化之后,每次查询最多只扫描1000条记录,查询速度是非常快的!
小结
本质上,这就是一种空间换时间的方法,即通过存储一个额外的索引字段来加速查询,通过增加少量的存储开销提升了整体的效能。
在对于许多问题进行优化时,经常是需要从应用场景触发,适当的转换思路。
比如在本文的问题中,是不是一定要增加字段呢?如果业务上可以接受不按工单号排序进行读取,那么仅使用更新时间字段进行分页拉取也是可以达到效果的,具体还是要由业务场景来定。
MongoDB 谨防索引seek的效率问题(转)的更多相关文章
- MongoDB 谨防索引seek的效率问题
目录 背景 初步分析 索引seeks的原因 优化思路 小结 声明:本文同步发表于 MongoDB 中文社区,传送门: http://www.mongoing.com/archives/27310 背景 ...
- MongoDB 谨防索引seek的效率问题【华为云技术分享】
目录 背景 初步分析 索引seeks的原因 优化思路 小结 声明:本文同步发表于 MongoDB 中文社区,传送门:http://www.mongoing.com/archives/27310 背景 ...
- MongoDB学习笔记(六) MongoDB索引用法和效率分析
MongoDB中的索引其实类似于关系型数据库,都是为了提高查询和排序的效率的,并且实现原理也基本一致.由于集合中的键(字段)可以是普通数据类型,也可以是子文档.MongoDB可以在各种类型的键上创建索 ...
- MongoDB的索引
一.索引详讲 索引是什么,索引就好比一本书的目录,当我们想找某一章节的时候,通过书籍的目录可以很快的找到,所以适当的加入索引可以提高我们查询的数据的速度. 准备工作,向MongoDB中插入20000条 ...
- Mongodb Geo2d索引原理
作者:孔德雨 MongoDB的geo索引是其一大特色,本文从原理层面讲述geo索引中的2d索引的实现. 2d 索引的创建与使用 通过 db.coll.createIndex({"lag&qu ...
- MongoDB数据库索引
前面的话 索引通常能够极大的提高查询的效率,如果没有索引,MongoDB在读取数据时必须扫描集合中的每个文件并选取那些符合查询条件的记录.这种扫描全集合的查询效率是非常低的,特别在处理大量的数据时,查 ...
- MongoDB索引(一) --- 入门篇:学习使用MongoDB数据库索引
这个系列文章会分为两篇来写: 第一篇:入门篇,学习使用MongoDB数据库索引 第二篇:进阶篇,研究数据库索引原理--B/B+树的基本原理 1. 准备工作 在学习使用MongoDB数据库索引之前,有一 ...
- MongoDb进阶实践之七 MongoDB的索引入门
一.引言 好久没有写东西了,MongoDB系列的文章也丢下好长时间了.今天终于有时间了,就写了一篇有关索引的文章.一说到"索引",用过关系型数据库的人都应该知道它是一个什么 ...
- mongodb数据库索引管理
1:ensureIndex() 方法 MongoDB使用 ensureIndex() 方法来创建索引. 语法 ensureIndex()方法基本语法格式如下所示: }) 语法中 Key 值为你要创建的 ...
随机推荐
- python - django (母版与组件)
# 把多个页面通用的部分提取出来 放在一个母版中 # 其它的页面只需要继承 母版就可以 # 使用步骤:( 继承的语句要放在第一行 ) """ 1. 把公用的 HTML 部 ...
- selenium之python源码解读-expected_conditions
一.expected_conditions 之前在 selenium之python源码解读-WebDriverWait 中说到,until方法中method参数,需要传入一个function对象,如果 ...
- 论文笔记-Deep Affinity Network for Multiple Object Tracking
作者: ShijieSun, Naveed Akhtar, HuanShengSong, Ajmal Mian, Mubarak Shah 来源: arXiv:1810.11780v1 项目:http ...
- unsafe包的学习和使用
Go语言之unsafe包介绍及使用 unsafe内容介绍 type ArbitraryType int type Pointer *ArbitraryType func Sizeof(x Arbitr ...
- php面向对象之静态方法使用
什么是静态方法? 不是所有的变量和方法都要通过创建对象来调用.可以通过给变量和方法加上static关键字来直接调用.无锡大理石测量平台 调用静态成员的语法格式为: 关键字::静态成员 关键字可以是: ...
- 二十二. 安装部署Tomcat服务器、使用Tomcat部署虚拟主机、使用Varnish加速Web
web proxy web1 web2 1.安装部署Tomcat服务器 web1 1.1 使用RPM安装JDK环境 ]# yum -y install java-1.8.0-openjdk ]# ...
- [Codevs] 一塔湖图
http://codevs.cn/problem/1024/ floyd 走起 #include <iostream> #include <cstdio> #include & ...
- 18.4.09 模拟考 zhx P75
题目链接 https://files.cnblogs.com/files/lovewhy/P75.pdf P75 竞赛时间: ????年??月??日??:??-??:?? 注意事项(请务必仔细阅读) ...
- c 输出是自动显示输出类型
显示0x i= print("%#x\n",i) 显示6位有效数字 i= print("l=%.6lf\n",i)
- win10下子系统的倒腾记录
本文是对于win10下安装ubuntu 18.04的安装.配置c语言开发环境的记录: 一.win10下面的设置,从win10的1709开始,可以完整的搞定linux子系统,如果win10版本不够的同学 ...