前滴滴出行技术专家,现任OPPO 文档数据库 mongodb 负责人,负责 oppo 千万级峰值 TPS/ 十万亿级数据量文档数据库 mongodb 研发和运维工作,一直专注于分布式缓存、高性能服务端、数据库、中间件等相关研发。后续持续分享《 MongoDB 内核源码设计、性能优化、最佳运维实践》, Github 账号地址 : https://github.com/y123456yz
《分布式数据库mongodb 内核源码设计实现、性能优化、最佳运维实践》专栏详见: http://blog.itpub.net/column/150/
线上某集群峰值TPS 超过 100 万 / 秒左右 ( 主要为写流量,读流量很低 ) ,峰值 tps 几乎已经到达集群上限,同时平均时延也超过 100ms ,随着读写流量的进一步增加,时延抖动严重影响业务可用性。该集群采用 mongodb 天然的分片模式架构,数据均衡的分布于各个分片中,添加片键启用分片功能后实现完美的负载均衡。集群每个节点流量监控如下图所示 :
从上图可以看出集群流量比较大,峰值已经突破120 万 / 秒,其中 delete 过期删除的流量不算在总流量里面 (delete 由主触发删除,但是主上面不会显示,只会在从节点拉取 oplog 的时候显示 ) 。如果算上主节点的 delete 流量,总 tps 超过 150 万 / 秒。
在不增加服务器资源的情况下,首先做了如下软件层面的优化,并取得了理想的数倍性能提升:
1. 业务层面优化
2. Mongodb 配置优化
3. 存储引擎优化
该集群总文档数百亿条,每条文档记录默认保存三天,业务随机散列数据到三天后任意时间点随机过期淘汰。由于文档数目很多,白天平峰监控可以发现从节点经常有大量delete 操作,甚至部分时间点 delete 删除操作数已经超过了业务方读写流量,因此考虑把 delete 过期操作放入夜间进行,过期索引添加方法如下 :
Db.collection.createIndex( { "expireAt": 1 }, { expireAfterSeconds: 0 } )
上面的过期索引中expireAfterSeconds=0
,代表
collection
集合中的文档的过期时间点在
expireAt
时间点过期,例如:
db.collection.insert( {
// 表示该文档在夜间凌晨 1 点这个时间点将会被过期删除
"expireAt": new Date('July 22, 2019 01:00:00'),
"logEvent": 2,
"logMessage": "Success!"
} )
通过随机散列expireAt 在三天后的凌晨任意时间点,即可规避白天高峰期触发过期索引引入的集群大量 delete ,从而降低了高峰期集群负载,最终减少业务平均时延及抖动。
Delete 过期 Tips1: expireAfterSeconds 含义
1. 在 expireAt 指定的绝对时间点过期,也就是 12.22 日凌晨 2:01 过期
Db.collection.createIndex( { "expireAt": 1 }, { expireAfterSeconds: 0 } )
db.log_events.insert( { "expireAt": new Date(Dec 22, 2019 02:01:00'),"logEvent": 2,"logMessage": "Success!"})
2. 在 expireAt 指定的时间往后推迟 expireAfterSeconds 秒过期,也就是当前时间往后推迟 60 秒过期
db.log_events.insert( {"createdAt": new Date(),"logEvent": 2,"logMessage": "Success!"} )
Db.collection.createIndex( { "expireAt": 1 }, { expireAfterSeconds: 60 } )
Delete 过期 Tips2: 为何 mongostat 只能监控到从节点有 delete 操作,主节点没有?
原因是过期索引只在master 主节点触发,触发后主节点会直接删除调用对应 wiredtiger 存储引擎接口做删除操作,不会走正常的客户端链接处理流程,因此主节点上看不到 delete 统计。
主节点过期delete 后会生存对于的 delete oplog 信息,从节点通过拉取主节点 oplog 然后模拟对于 client 回放,这样就保证了主数据删除的同时从数据也得以删除,保证数据最终一致性。从节点模拟 client 回放过程将会走正常的 client 链接过程,因此会记录 delete count 统计,详见如下代码 :
官方参考如下: https://docs.mongodb.com/manual/tutorial/expire-data/
由于集群tps 高,同时整点有大量推送,因此整点并发会更高, mongodb 默认的一个请求一个线程这种模式将会严重影响系统负载,该默认配置不适合高并发的读写应用场景。官方介绍如下 :
mongodb 默认网络模型架构是一个客户端链接, mongodb 会创建一个线程处理该链接 fd 的所有读写请求及磁盘 IO 操作。
Mongodb 默认网络线程模型不适合高并发读写原因如下 :
1. 在高并发的情况下,瞬间就会创建大量的线程,例如线上的这个集群,连接数会瞬间增加到 1 万左右,也就是操作系统需要瞬间创建 1 万个线程,这样系统 load 负载就会很高。
2. 此外,当链接请求处理完,进入流量低峰期的时候,客户端连接池回收链接,这时候 mongodb 服务端就需要销毁线程,这样进一步加剧了系统负载,同时进一步增加了数据库的抖动,特别是在 PHP 这种短链接业务中更加明显,频繁的创建线程销毁线程造成系统高负债。
3. 一个链接一个线程,该线程除了负责网络收发外,还负责写数据到存储引擎,整个网络 I/O 处理和磁盘 I/O 处理都由同一个线程负责,本身架构设计就是一个缺陷。
为了适应高并发的读写场景,mongodb-3.6 开始引入 serviceExecutor: adaptive 配置,该配置根据请求数动态调整网络线程数,并尽量做到网络 IO 复用来降低线程创建消耗引起的系统高负载问题。此外,加上 serviceExecutor: adaptive 配置后,借助 boost:asio 网络模块实现网络 IO 复用,同时实现网络 IO 和磁盘 IO 分离。这样高并发情况下,通过网络链接 IO 复用和 mongodb 的锁操作来控制磁盘 IO 访问线程数,最终降低了大量线程创建和消耗带来的高系统负载,最终通过该方式提升高并发读写性能。
在该大流量集群中增加serviceExecutor: adaptive 配置实现网络 IO 复用及网络 IO 与磁盘 IO 做分离后,该大流量集群时延大幅度降低,同时系统负载和慢日志也减少很多,具体如下 :
验证方式:
1.
该集群有多个分片,其中一个分片配置优化后的主节点和同一时刻未优化配置的主节点load
负载比较:
未优化配置的load
优化配置的load
验证方式:
该集群有多个分片,其中一个分片配置优化后的主节点和同一时刻未优化配置的主节点慢日志数比较:
同一时间的慢日志数统计:
未优化配置的慢日志数 (19621) :
优化配置后的慢日志数 (5222):
验证方式:
该集群所有节点加上网络IO 复用配置后与默认配置的平均时延对比如下 :
从上图可以看出,网络IO 复用后时延降低了 1-2 倍。
从上一节可以看出平均时延从200ms 降低到了平均 80ms 左右,很显然平均时延还是很高,如何进一步提升性能降低时延?继续分析集群,我们发现磁盘 IO 一会儿为 0 ,一会儿持续性 100% ,并且有跌 0 现象,现象如下 :
从图中可以看出, I/O 写入一次性到 2G ,后面几秒钟内 I/O 会持续性阻塞,读写 I/O 完全跌 0 , avgqu-sz 、 awit 巨大, util 次序性 100%, 在这个 I/O 跌 0 的过程中,业务方反应的 TPS 同时跌 0 。
此外,在大量写入IO 后很长一段时间 util 又持续为 0% ,现象如下:
总体IO 负载曲线如下 :
从图中可以看出IO 很长一段时间持续为 0% ,然后又飙涨到 100% 持续很长时间,当 IO util 达到 100% 后,分析日志发现又大量满日志,同时 mongostat 监控流量发现如下现象:
从上可以看出我们定时通过mongostat 获取某个节点的状态的时候,经常超时,超时的时候刚好是 io util=100% 的时候,这时候 IO 跟不上客户端写入速度造成阻塞。
有了以上现象,我们可以确定问题是由于IO 跟不上客户端写入速度引起,第 2 章我们已经做了 mongodb 服务层的优化,现在我们开始着手 wiredtiger 存储引擎层面的优化,主要通过以下几个方面:
1. cachesize 调整
2. 脏数据淘汰比例调整
3. checkpoint 优化
前面的IO 分析可以看出,超时时间点和 I/O 阻塞跌 0 的时间点一致,因此如何解决 I/O 跌 0 成为了解决改问题的关键所在。
这个集群平峰期( 总 tps50 万 /s) 查看当时该节点的 TPS ,发现 TPS 不是很高,单个分片也就 3-4 万左右,为何会有大量的刷盘,瞬间能够达到 10G/S ,造成 IO util 持续性跌 0( 因为 IO 跟不上写入速度 ) 。继续分析 wiredtiger 存储引擎刷盘实现原理, wiredtiger 存储引擎是一种 B+ 树存储引擎, mongodb 文档首先转换为 KV 写入 wiredtiger ,在写入过程中,内存会越来越大,当内存中脏数据和内存总占用率达到一定比例,就开始刷盘。同时当达到 checkpoint 限制也会触发刷盘操作,查看任意一个 mongod 节点进程状态,发现消耗的内存过多,达到 110G ,如下图所示 :
于是查看mongod.conf 配置文件,发现配置文件中配置的 cacheSizeGB: 110G , 可以看出,存储引擎中KV 总量几乎已经达到 110G ,按照 5% 脏页开始刷盘的比例,峰值情况下 cachesSize 设置得越大,里面得脏数据就会越多,而磁盘 IO 能力跟不上脏数据得产生速度,这种情况很可能就是造成磁盘 I/O 瓶颈写满,并引起 I/O 跌 0 的原因。
此外,查看该机器的内存,可以看到内存总大小为190G ,其中已经使用 110G 左右,几乎是 mongod 的存储引起占用,这样会造成内核态的 page cache 减少,大量写入的时候内核 cache 不足就会引起磁盘缺页中断,引起大量的写盘。
解决办法: 通过上面的分析问题可能是大量写入的场景,脏数据太多容易造成一次性大量I/O 写入,于是我们可以考虑把存储引起 cacheSize 调小到 50G ,来减少同一时刻 I/O 写入的量,从而规避峰值情况下一次性大量写入的磁盘 I/O 打满阻塞问题。
调整cachesize 大小解决了 5s 请求超时问题,对应告警也消失了,但是问题还是存在, 5S 超时消失了, 1s 超时问题还是偶尔会出现。
因此如何在调整cacheSize 的情况下进一步规避 I/O 大量写的问题成为了问题解决的关键,进一步分析存储引擎原理,如何解决内存和 I/O 的平衡关系成为了问题解决的关键, mongodb 默认存储因为 wiredtiger 的 cache 淘汰策略相关的几个配置如下 :
wiredtiger 淘汰相关配置 |
默认值 |
工作原理 |
eviction_target |
80 |
当用掉的内存超过总内存的百分比达到 eviction_target ,后台 evict 线程开始淘汰 |
eviction_trigger |
95 |
当用掉的内存超过总内存的 eviction_trigger ,用户线程也开始淘汰 |
eviction_dirty_target |
5 |
当 cache 中脏数据比例超过 eviction_dirty_target ,后台 evict 线程开始淘汰 |
eviction_dirty_trigger |
20 |
当 cache 中脏数据比例超过 eviction_dirty_trigger , 用户线程也开始淘汰 |
evict.threads_min |
4 |
后台 evict 线程最小数 |
evict.threads_max |
4 |
后台 evict 线程最大数 |
调整cacheSize 从 120G 到 50G 后,如果脏数据比例达到 5% ,则极端情况下如果淘汰速度跟不上客户端写入速度,这样还是容易引起 I/O 瓶颈,最终造成阻塞。
解决办法: 如何进一步减少持续性I/O 写入,也就是如何平衡 cache 内存和磁盘 I/O 的关系成为问题关键所在。从上表中可以看出,如果脏数据及总内占用存达到一定比例,后台线程开始选择 page 进行淘汰写盘,如果脏数据及内存占用比例进一步增加,那么用户线程就会开始做 page 淘汰,这是个非常危险的阻塞过程,造成用户请求验证阻塞。平衡 cache 和 I/O 的方法 : 调整淘汰策略,让后台线程尽早淘汰数据,避免大量刷盘,同时降低用户线程阀值,避免用户线程进行 page 淘汰引起阻塞。优化调整存储引起配置如下 :
eviction_target: 75%
eviction_trigger : 97%
eviction_dirty_target: %3
eviction_dirty_trigger : 25%
evict.threads_min : 8
evict.threads_min : 12
总体思想是让后台evict 尽量早点淘汰脏页 page 到磁盘,同时调整 evict 淘汰线程数来加快脏数据淘汰,调整后 mongostat 及客户端超时现象进一步缓解。
存储引擎得checkpoint 检测点,实际上就是做快照,把当前存储引擎的脏数据全部记录到磁盘。触发 checkpoint 的条件默认又两个,触发条件如下 :
1. 固定周期做一次checkpoint 快照,默认 60s
2. 增量的redo log( 也就是 journal 日志 ) 达到 2G
当journal 日志达到 2G 或者 redo log 没有达到 2G 并且距离上一次时间间隔达到 60s , wiredtiger 将会触发 checkpoint ,如果在两次 checkpoint 的时间间隔类 evict 淘汰线程淘汰的 dirty page 越少,那么积压的脏数据就会越多,也就是 checkpoint 的时候脏数据就会越多,造成 checkpoint 的时候大量的 IO 写盘操作。如果我们把 checkpoint 的周期缩短,那么两个 checkpoint 期间的脏数据相应的也就会减少,磁盘 IO 100% 持续的时间也就会缩短。
checkpoint 调整后的值如下 :
checkpoint=(wait=25,log_size=1GB)
通过上面三个方面的存储引擎优化后,磁盘IO 开始平均到各个不同的时间点, iostat 监控优化后的 IO 负载如下 :
从上面的io 负载图可以看出,之前的 IO 一会儿为 0% ,一会儿 100% 现象有所缓解,总结如下图所示 :
优化前后时延对比如下( 注 : 该集群有几个业务同时使用,优化前后时延对比如下 ):
从上图可以看出,存储引擎优化后时间延迟进一步降低并趋于平稳,从平均80ms 到平均 20ms 左右,但是还是不完美,有抖动。
如第3 节所述,当 wiredtiger 大量淘汰数据后,发现只要每秒磁盘写入量超过 500M/s ,接下来的几秒钟内 util 就会持续 100% , w/s 几乎跌 0 ,于是开始怀疑磁盘硬件存在缺陷。
从上图可以看出磁盘为nvMe 的 ssd 盘,查看相关数据可以看出该盘 IO 性能很好,支持每秒 2G 写入, iops 能达到 2.5W/S ,而我们线上的盘只能每秒写入最多 500M 。
于是考虑把该分片集群的主节点全部迁移到另一款服务器,该服务器也是ssd 盘, io 性能达到 2G/s 写入 ( 注意 : 只迁移了主节点,从节点还是在之前的 IO-500M/s 的服务器 ) 。 迁移完成后,发现性能得到了进一步提升,时延迟降低到 2-4ms/s ,三个不同业务层面看到的时延监控如下图所示:
从上图时延可以看出,迁移主节点到IO 能力更好的机器后,时延进一步降低到平均 2-4ms 。
虽然时延降低到了平均2-4ms ,但是还是有很多几十 ms 的尖刺,鉴于篇幅将在下一期分享大家原因,最终保存所有时延控制在 5ms 以内,并消除几十 ms 的尖刺。
此外,nvme 的 ssd io 瓶颈问题原因,经过和厂商确认分析, 最终定位到是linux 内核版本不匹配引起,如果大家 nvme ssd 盘有同样问题,记得升级 linux 版本到 3.10.0-957.27.2.el7.x86_64 版本,升级后nvme ssd 的 IO 能力达到 2G/s 以上写入。
通过mongodb 服务层配置优化、存储引擎优化、硬件 IO 提升三方面的优化后,该大流量写入集群的平均时延从之前的平均数百 ms 降低到了平均 2-4ms ,整体性能提升数十倍,效果明显。
但是, 从4.2 章节优化后的时延可以看出,集群偶尔还是会有抖动,鉴于篇幅,下期会分享如果消除 4.2 章节中的时延抖动,最终保持时间完全延迟控制在 2-4ms ,并且无任何超过 10ms 的抖动,敬请期待,下篇会更加精彩。
此外,在集群优化过程中采了一些坑,下期会继续分析大流量集群采坑记。
注意: 文章中的一些优化方法并不是一定适用于所有 mongodb 场景,请根据实际业务场景和硬件资源能力进行优化,而不是按部就班。
下期继续分享<<百万级高并发mongo集群性能数十倍提升优化实践(上)-2019年mongodb中文社区年度一等奖>>
来自 “ ITPUB博客 ” ,链接:http://blog.itpub.net/69984922/viewspace-2726334/,如需转载,请注明出处,否则将追究法律责任。