Mongo DB 服务挂起问题排查

时间:2024-01-27 作者:剧中人

2023 年初,小剧将稳定运行近十年的服务端代码,进行了一次彻底的重构。完成后写了《Node 迁移 TypeScript 记录》 作为回顾记录。这篇文章的侧重点在 NodeJS 的 TypeScript 改造,以及 Promise 异步逻辑链重构上。对于 mongo DB 的改动并未过多提及。

在这次改造期间,为了使用最新版本的、支持 Promise 模式的 Mongo DB 连接库。一狠心将使用了近十年 v1.8.1 版本的数据库,升级到了 v4.0.27 版本。

正是这一决定,在接下来的大半年里时不时的就来折腾一下小剧。

一、遇到了什么问题?

起初并没有任何异常,小博客也能平稳的运行。直到 2023 年 5 月的某一天,小剧觉得博客的重构彻底告一段落了,于是对服务做了些配置调整后,就不打算理会它了。

大概一个月之后吧,博客竟然毫无征兆的挂了,表现出来就是 API 无法正常返回数据。

经过一番排查,发现是数据库无响应导致的。

这倒很简单,运维问题一半可以通过重启解决,另一半可以通过多次重启来解决。

强制重启了 Mongo DB 数据库,一切又恢复了正常。

这一次的服务挂起只是开端,接下来多则半个月,短则两三天,服务都会冷不丁的挂掉。

因为要陪孩子和家人,博客也只是业余时间的消遣而已,所以没有花时间研究问题的根源的打算。

又编写了一个重启 Mongo DB 的 Shell 脚本丢在服务器上,再遇到服务挂起的时候直接运行脚本就行。

二、第一次尝试解决

很快就到了秋末,某一天闲下来无事可做,就想着翻翻 Mongo DB 服务的日志。发现每次服务挂起,都是因为系统资源耗尽导致的。

2.1、初步分析原因

有了日志的支撑,接下来就有了排查的方向。因为 Mongo DB 服务挂起只发生在服务端代码重构之后。因此重点关注重构前后的改动,就可以更接近问题的本源。

此次重构和数据库强相关的改动有三个,分别为:

  1. Mongo DB 版本 由 v1.8.1 升级到 v4.0.27
  2. Mongo DB 连接库由 v3.1.13 升级到 v4.17.0
  3. 删除了很多 API 的缓存逻辑,并且新增了凌晨三点清除全部缓存的逻辑

关于第 1 点,Mongo DB 官方没有给出直观的数据对比,但是通过本地不严谨的对比,新版本对内存、CPU 的占用确实有明显的上升。

第 2 点并未发现明显的问题,因为作为连接库,最有可能的问题就是内存泄漏,实际排查并无此问题。

第 3 点比较明显,因为博客作为一个纯个人的服务,内容更新实时性很低很低。除了少量接口需要动态获取数据,绝大多数的都可以使用缓存。

因此开启缓存,可以使绝大多数 API 直接返回数据,而跳过数据库连接。【查看缓存的实现

结合前面 1、2、3 点的整理来看,初步排查 1、3 是最有可能导致 Mongo DB 服务挂起的原因。

总结下来就是:升级后的服务对资源占用更高,并且因为缓存利用率不高,导致百度之类的爬虫抓取数据时,短时间内系统无法提供足够的资源,导致进程挂起。

忘了补充一件事,小剧的服务器是一核心、1G 内存、1M 带宽的丐中丐配置。所以服务挂起的问题是穷病,治不了,谁叫我没有钱升级服务器配置呢。

2.2、怎么解决呢?

数据库降级这条路不太可行,因为服务已经按照新的 API 重构了代码,调整比较耗时。

那就只有把缓存这块遮羞布再盖上,再禁用掉主动清除缓存,来减少数据库的读写压力,进而降低对系统资源的占用,大幅降低 Mongo DB 服务挂起的概率。

2.3、效果怎么样?

因为大幅降低了对数据库的读取操作,Mongo DB 服务挂起的的问题得到了大幅改善。平均两周左右才会挂起一次。

三、再次解决服务挂起的问题

转眼就要 2024 年了,又到了写年终总结的时候。小剧可不希望发出去一条年终链接后,打开却是不确定的服务挂起状态。为了避免这种尴尬的场景,小剧决定花点时间彻底解决 Mongo DB 服务挂起的问题。

正如上面说的一样,前期的解决方案只是块遮羞布,只能大幅降低 Mongo DB 服务挂起的概率。

其实核心问题很简单,配置较弱的机器,无法稳定提供 Mongo DB 对资源的需求

解决这个问题有三个方案,分别为:

  1. 降级 Mongo DB 至原始版本
  2. 提升服务器配置
  3. 将 Mongo DB 服务转移到其他机器

前文提到了,数据库降级需要大量重构逻辑代码。小剧现在并不像没娃的时候一样,有大量的时间做这些重复性的改动。并且开历史的倒车,也感觉不是很光彩,不到万不得已不会这么做,因此方案一直接就被否了。

提升服务器配置这一条倒挺简单。只要提前备份好服务数据,在腾讯云(小剧在各种云之间来回换,最近在用腾讯云)后台选择升级到的配置,提交等待重启就完事了。

但是云计算的资源就是这样,内存或者 CPU 提升一点点,价格就会成倍的上升。小剧客栈作为一个纯装X,没有任何收入来平摊成本的个人项目。成本的上升是我所不愿接受的。

3.1、思考 Mongo DB 转移方案

至此只剩下第三个方案了,直观地来看,这个方案和方案二「提升服务器配置」很像。甚至在花费上不一定比方案二便宜。

这个方案如果能实施落地,至少需要以下几个必要条件:

结合小剧手上的资源来看,还真有一台机器满足要求,后面叫这台服务器为「从服务器」。

文章里不方便介绍从服务器具体的操作系统、部署方式,以及物理位置等信息。如果你有小剧的联系方式,我们可以私下聊聊这台机器。

这里简单的根据网络结构来做下介绍。

它是一个内部网络之中的机器,没有公网 IP,并且和主服务器不在同一个内网,上下行带宽不低于 30M。

从服务器的配置并不算高,但是 8G 内存、双核 CPU 足够 Mongo DB 去驰骋了。

这台机器目前就是 7 * 24 小时运行,运行了一些轻量服务,无需为了这台机器额外付费。

看起来万事俱备,只欠东风了。

具体到实施,有两个很具体的问题需要解决。

第一个问题可以先放一放,因为博客更新实时性不高的特性,缓存这块遮羞布还能继续用。并且实际延迟平均是多少还不确定,不需要在尚未开工前将时间精力耗费在这里。

第二个问题比较棘手,毕竟大家都知道,没有公网 IP 是没办法直接在互联网上提供服务的。

3.2、内网服务组网方案

内网虽然无法直接提供服务,间接提供的方案倒有很多,我们可以按照机器上层网络的不同情况来看。

3.2.1、上层网络具备公网 IP,并且有操作权限。

3.2.1.1、如果上层网络的公网 IP 是固定的,在上层网络设置端口转发,就能以上层网络的身份提供服务。

有点像小剧住在固定的小区,并且告诉保安大爷,只要有人找小剧,就让 TA 去 X 栋 XXX 找我。

3.2.1.2、如果上层的公网 IP 是可变的,也不麻烦,同样是需要设置端口转发,额外再借助 DDNS 以域名的形式提供服务,或者在主服务器提供一个 API,用来动态接收变动后的 IP,使用服务的时候以动态 IP 来执行。

这种模式类似于小剧每隔一段时间就要搬一次家,小剧每搬完家就会把小区地址告诉小剧的朋友 Jim,Jim 有固定的住所。并且小剧会告诉保安大爷,只要有人找小剧,就让 TA 去 X 栋 XXX 找我。这样别人就可以通过小剧的朋友 Jim 找到小区,再通过保安大爷找到我。

3.2.2、上层网络不具备公网 IP,或者没有操作权限。

3.2.2.1、借助于 VPN,实现虚拟组网。

这种网路基础建设起来比较复杂,一般针对多个网络系统融合或者端到系统的接入。

类似于你来找小剧,必须要先到保安大爷那里审核登记,完事了给你颁发一张小区的登记卡,你得时刻拿着这张登记卡才能在小区内有限制的通行,在保安大爷的指引下最后找到小剧。

3.2.2.2、借助于 FRP、NGROK 之类的工具,提供内网穿透服务

这是一种反向代理技术,借助于主服务器提供内网应用转发。

类似于小剧没有固定住所,但小剧的朋友 Jim 有,小剧在 Jim 家里安装了一台电话机。只要小剧搬家就会给 Jim 打电话,电话一直占线不挂,如果挂了就重新拨过去。别人要来找小剧,就去找 Jim,拿起那部电话机进行通话。

3.3、实施 Mongo DB 服务转移

前端面分析的四种情况,除了 3.2.1.1 提到的固定公网 IP 不满足,剩余三种都可以使用。

其中 3.2.1.2 看起来是最理想的选择。但是 DDNS 本质上是用 DNS 来获取动态 IP,在域名服务商、网络、应用等各个环节都会有缓存,遇到 IP 变动时很难及时更新。动态 IP 方案看起来也不错,但是需要写客户端上报脚本、服务端接收、更新脚本、博客动态读取 IP 配置等代码,并且还得保证这些新的逻辑稳定运行。

而自建 VPN 会使得系统设计过于庞大,杀鸡用牛刀。

相比之下 3.2.2.2 内网穿透组网方式比较轻便灵活,特别适合服务间接入。

咨询了有 FRP 使用经验的小马(此人很懒,没有博客地址)后,决定使用 FRP 来实现服务间的组网。

至此调研阶段结束,正式开始 Mongo DB 服务转移工作。

3.3.1、Mongo DB 服务安装部署

因为从服务器内有 Docker 环境,安装异常简单,配置好数据库、账号、密码也就十几分钟完成。

在这期间 mongodump 了主服务器中的数据,从服务器 Mongo DB 准备完成后导入数据,数据库相关的操作就算完成了。

3.3.2、FRP 安装部署

关于 FRP 的具体使用可以参考 frp 官方仓库 ,安装部署很简单,并且支持 Docker 容器,就不介绍部署细节了。

小剧这里的从服务器使用 Docker 安装 FRPC,主服务器通过 Release 包安装配置 FRPS。

测试下 tcp 连通没问题,FRP 配置也算完成了。

3.3.3、NodeJS 博客服务更新数据库连接配置

经过前两步的操作,主服务器有两个 Mongo DB 服务可用,修改 NodeJS 的 .env 配置文件指向新的数据库,重启 NodeJS 服务,一切就大功告成了。

对了这里小剧多做了一个操作,就是在服务器的入站规则加了 Mongo DB 端口的禁用。

效果就是主服务器内可以访问 Mongo DB,外网无法借助于主服务器 IP + 端口访问数据库,安全性提高了一点点。

3.3.4、图解网络流转逻辑

这里简单画一下终端用户、NodeJS、FRPS、FRPC、Mongo DB 间的数据流转逻辑。

网络拓扑图

您作为在阅读这篇文章的 User,所看到的页面、数据都是主服务器中 NodeJS 的博客服务提供的。

在这篇文章渲染之前需要从 NodeJS 本机的 2222 端口访问数据库,读取文章详情。

当然,这个数据库服务是虚拟的。借助于主服务器的 FRPS 和从服务器的 FRPC 建立起来的连接,将从服务器中 1111 端口映射到了主服务器中的 2222 端口上。

类似于小剧没有固定住所,但小剧的朋友 Jim 有,小剧在 Jim 家里安装了一台电话机。只要小剧搬家就会给 Jim 打电话,电话一直占线不挂,如果挂了就重新拨过去。别人要来找小剧,就去找 Jim,拿起那部电话机进行通话。

这是前文用到过的比喻。主服务器就是这个比喻里的 Jim 家,FRPS 就是 Jim 家那部电话机,FRPC 就是小剧家里那部电话机,所以核心的关键在于 FRP 客户端和服务端之间建立稳定的连接。

四、Mongo DB 转移后的问题

4.1、API 延迟多久?

原本 NodeJS 与 Mongo DB 在同一台机器,数据读取几乎不会因为连接而增加耗时。如何尽量降低网络时延对体验的影响?

文章写到这里,服务挂起的问题就已经解决了。但前文还遗留了一个问题待解决,就是 MongoDB 转移到从服务器后,API 的响应有没有明显变慢?

以剧中人的朋友圈获取某位好友信息为例,仅对比 Waiting for server response 这一指标。

请求耗时

Mongo DB 转移前大约为 134ms,转移后大约 339ms,开启缓存后大约 85ms。

不精确地估算,耗时大约增长了 205ms。

205ms 略慢,但对于小剧客栈这样的小博客够用了,并且开启了缓存这块遮羞布之后,耗时更会大幅降低。

因此用局部、低频的耗时增加,换取更为稳定的服务,以及学习到的新的技能,还是值得的。

4.2、发现遗留的隐患

FRP 服务端自带了一个 Web 管理页面,用于查看连接客户端列表,数据吞吐量、以及代理的基本状况。

frp-dashboard.png

小剧客栈 NodeJS 服务端设计的数据库使用模式为:用后即销毁,没有连接复用的设计,理论上在没有并发请求的情况下,Current Connections 应该为零才对。

箭头处的数字不为零,只有两种解释:一是小剧出息了,同一时刻内确实有这么大的连接数;二是某个请求结束后,一直未执行断开连接操作,而 NodeJS 单进程的模式又会导致连接始终不被释放。

很显然小剧并没有这么出息,问题肯定出在断开连接操作上,但检查代码后发现所有数据库连接操作均有断开连接行为。

那只有一种可能,就是数据库连立连接后,在极端情况下遇到业务逻辑异常,比如 FS 读写冲突,局部逻辑健壮性不强导致的报错等问题,进而导致代码未正常直行到数据库断开操作。

此问题虽然不会立即导致 NodeJS 服务和 Mongo DB 崩溃,但累积的无用连接会拖慢服务的响应速度。

随后补充了一段通用逻辑:在建立数据库连接时,默认一秒内会处理完数据的读写操作。若一秒内仍未执行断连操作,则主动断开数据库连接。

export async function getDbConnect (): Promise<{
client: mongodb.MongoClient,
db: mongodb.Db
}> {
  // 省略逻辑 ……
  // close connect when timout
  const originCloseMethod = client.close
  function closeConnect () {
    return originCloseMethod.apply(client) as unknown as Promise<void>
  }
  client.close = function (): Promise<void> {
    clearTimeout(closeTimoutTimer)
    return closeConnect()
  }
  const closeTimoutTimer = setTimeout(() => {
    console.trace(’MongoDB missing close connect.‘)
    closeConnect()
  }, 1000)
  // 省略逻辑 ……
}

五、问题解决了吗?

在经历了第一次发现 Mongo DB 挂起后,及时启用缓存功能这块遮羞布;第二次分析到问题根源后,决定进行 Mongo DB 服务转移;以及最后补充的数据库超时断连逻辑。

三次操作让 Mongo DB 在起到决定性作用的硬件环境上,得到质了的改善。并且借助于缓存策略,极大降低了请求频次对 Mongo DB 服务的压力。以及极端异常的超时处理,避免了垃圾连接对 NodeJS 和 MongoDB 服务的负担。

经过一个多月的观察,Mongo DB 运行稳定,无任何挂起、无响应的情况。从服务器在每次 IP 变更时都能及时重连上 FRPS。并且随着对逻辑的加强,NodeJS 也极少打印超时断连的日志。

至此 Mongo DB 服务异常挂起的问题得到了的解决。

还额外获得了一个新的思考:小剧一直都是在单节点部署全部服务,这次接触了多节点服务转移,异地备份和负载均衡要不要尝试一下?

从小剧客栈的规模和访问量来看,完全没必要,若是业余时间充足,玩一玩倒也不是不行。