记一次惨痛的 Vue SSR 内存泄漏排查

6,010 阅读11分钟

背景

近期,对一个老项目进行直出改造,将其从 Vue 客户端渲染改造成支持 Vue 服务端渲染(SSR),然而在测试环境进行压测的过程中,就发现其存在明显的内存泄漏问题。这个问题可能是一般人在做 Vue SSR 时不容易踩的坑,但是一旦踩到,则可能是不容易排查出来的坑,尤其对于老项目的改造而言,会有更多的干扰因素。在此将排查过程记录作为避坑指南。

我是怎么发现内存泄漏的

为什么磁盘占用空间异常上涨?

当我把 Node 直出服务部署到测试环境的第二天,我就收到了很多服务告警,有一项数据很惊人——磁盘空间一夜之间就占用了 147 GB;于是赶紧上服务器定位到底是什么文件占用了这么多空间。

通过命令 du 很快定位到是在项目 src 目录下出现了很多 coredump 文件,每个 coredump 文件就有 1.7GB 的大小。

再看容器的监控信息,发现在某些时间点 CPU 的使用率异常高,在相同的时间点,内存使用率也异常高,而且内存在达到 1.7GB 左右就断崖式回落。 通过这些信息,可以大致判断是瞬时内存达到 1.7GB,也就是 Nodejs 的默认内存限制,导致 Node 服务异常崩溃终止,而且当系统设置了 ulimit -c unlimited,就会在这个时候自动生成 coredump 文件。Coredump 文件可以认为是进程崩溃时的内存快照信息,包括进程当时的运行堆栈信息等。

怎么判断是否是内存泄漏

虽然通过查看 coredump 文件可以判断就是 OOM(OutOfMemory)内存溢出,但是我们还是没法判断是不是内存泄漏导致了内存溢出。由于安平扫描会在短时间发出大量请求,因此瞬时大量请求占用了大量内存就会导致OOM,来不及反映内存是否会被正常回收然后正常回落,进程就挂掉重启了。此时怀疑两种可能

  1. 单个请求占用的内存较大,导致安平一扫描就使内存瞬时占用过大
  2. 程序存在内存泄漏,导致内存没有被回收,内存占用就会不断上涨然后超出限制

为了判断是否出现了内存泄漏,需要尽可能先避免安平扫描的干扰,使内存有空间来反映回落的过程,因为我对服务的内存进行了扩容,并提高了 Node 服务的内存限制。通过 --max-old-space-size 运行参数可以指定 V8 的内存空间上限,一定程度上来避免内存溢出的问题。

node --max-old-space-size=4096 app

扩大内存之后,进程就不会因为安平扫描就一下子挂掉了,这样就留给我们更多空间来观察内存占用率的走势。通过三次压测,很容易发现内存出现了阶梯式上涨:

这时候基本就能判断是出现了内存泄漏

使用 heapdump 分析内存泄漏

对于 Nodejs,目前用来分析内存泄漏的主要工具就是 heapdump。通过 heapdump 可以打印内存快照,并配合 Chrome DevTool 来查看内存快照。通过不同时间点的内存快照对比,有助于找到内存泄漏的内容,从而来判断内存泄漏的原因。

使用 heapdump 的方式非常简单,在项目中加入如下代码就可以打印快照:

heapdump.writeSnapshot('./' + Date.now() + '.heapsnapshot');

为了便于随时打快照,我在程序中加了一个接口专门用来打快照(这里要注意,使用这种方式最好通过某种方式来区分这个请求是你发出的,否则一旦安平扫描也能够打快照,那么你的磁盘空间很快就会被占满;顺便说一下,我是通过判断请求的 host 是域名还是 ip:port 的方式来区分安平的请求,安平扫描会以域名的方式请求)。

另外,为了更好地分析,通过建议在打印快照之前在代码中手动 gc() 。通过在启动时加上 --expose-gc 参数即可在代码中使用 gc() 来手动触发垃圾回收。

这里推荐打印 3 个内存快照来进行对比:

  • 服务刚启动的快照;
  • 少量请求后的快照;
  • 大量请求后的快照;

通过在 Chrome 的 Memory 中加载快照文件,就可以查看快照了。

在分析快照文件之前,需要先简单了解几个概念:

  • Distance:到 GC roots (GC 根对象)的距离。GC 根对象在浏览器中一般是 window 对象,在 Node.js 中是 global 对象。距离越大,则说明引用越深,有必要重点关注一下,极有可能是内存泄漏的对象。

  • Shallow Size:对象自身的大小,不包括它引用的对象。

  • Retained Size:对象自身的大小和它引用的对象的大小,即该对象被 GC 之后所能回收的内存大小。

分析技巧:

  • 通常在 Summary 页对 Retained Size 进行倒序,可以初步找到哪部分占比最大,则很有可能是泄漏的内容;

  • 在 Comparison 页选择要对比的快照,可以从 Delta 和 Size Delta 看出哪些是明显增加的内容,明显增加的内容很可能就是泄漏的内容;

    image-20200517224726424

此次内存泄漏的特点

  • 单个请求内存泄漏明显。每100个请求就有几十M的内存泄漏;

  • 泄漏的内容不集中。通过 heapdump 看到明显增加的内容有多处,不容易定位泄漏的主要原因;

以上特点导致通过 heapdump 也很难找到内存泄漏的突破口,较难抓住一个单一明显的增长特点来进行推导。

令人疑惑的内存泄漏现象

  • 期间发现 axios 对象会随请求数成比例增长,这是一个明显的内存泄漏特点。但是令人疑惑的是,事实上代码中 axios 相关的代码应该不会引起内存泄漏;然而我还是显式地将创建的 axios 对象设置为 null,才修复了这个小问题,然而发现这丝毫没有使内存泄漏有所改善。

  • **模块为什么多次执行?**偶然从 heapdump 的文件中观察到 Agent 对象同样跟请求数成比例增长,通过定位找到如下代码,发现其所在的 js 文件只被通过模块的方式引用过,那么即使被多次引用,也应该取缓存的值而不是多次执行。

    此时还是感到疑惑,直到发现一个更奇怪的地方:

    发现 originPost 有一个很深的自我嵌套引用,而且实测当请求越多,从快照中看到的引用深度越深,那么内存泄漏一定和这个有关系,这也是发现此次内存泄漏原因的关键。

    简化上述代码,再仔细分析,就会发现只能是模块被多次执行了才会出现上述现象:

    let originPost = axios.Axios.prototype.post;
    
    axios.Axios.prototype.post = function () {
    	...
    	return orginPost.call(...);
    }
    

    通过在模块中打日志并多次请求,从日志多次输出的情况也证实了模块多次执行的情况。

    那么问题就变成:模块被多次执行是否正常?如果不正常,又是什么原因导致的?

    由于代码是在 Node 端被执行的,所以模块是 CommonJS 规范,那么模块在第一次被执行后,其结果就会被缓存起来,下次引用则是直接返回缓存值,所以多次执行的行为是不正常也是不应该的。那么问题出在哪呢?

被忽略的细节

此次内存泄漏的关键原因

当局者迷,其实我的导师在 review 我的代码时早就怀疑一个关键的地方,那就是 createBundleRenderer 的使用位置。

在官网的 demo 中,createBundleRenderer 在请求响应逻辑外层,只执行一次:

const { createBundleRenderer } = require('vue-server-renderer')

const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false, // 推荐
  template, // (可选)页面模板
  clientManifest // (可选)客户端构建 manifest
})

// 在服务器处理函数中……
server.get('*', (req, res) => {
  const context = { url: req.url }
  renderer.renderToString(context, (err, html) => {
    // 处理异常……
    res.end(html)
  })
})

而我这里竟然将其放到每次请求响应中反复执行:

renderSSRApp(req, res, htmlData) {
  let renderer = createBundleRenderer(serverBundle, {
    runInNewContext: false,
    template: htmlData,
  });
  try {
    renderer.renderToString(context, (err, html) => {
      ...
    });
    renderer = null;
  } catch (err) {
      console.log(err);
  }
}

顺应旧逻辑引起的坑

虽然这个地方现在看起来其实是一个比较明显的问题,且在搭新项目时,这也是几乎不可能踩的坑,但是当时之所以会写出与官网不一致的实践,也是有原因的:

  1. 为了顺应项目的原有逻辑

    项目原有逻辑为:请求 → 渲染 ejs → 得到渲染结果 html;而 createBundleRenderer 需要传入的一个参数 template 就是 html 字符串,而原有逻辑的 html 字符串是每个请求动态生成的,而不是一般静态模板,因此才出现了这个坑。

  2. 对 createBundleRenderer 的行为不了解

与祸根插肩而过

早在导师第一次提到这个地方时,我的确将 createBundleRenderer 改成单例模式,但是发现问题并没有解决,于是当时就排除了这个地方。而实际上是因为之前为了排查泄漏问题加了 renderer = null 而导致了单例模式失效。

再度怀疑

当模块被多次执行的现象确认无疑后,导师再度怀疑了这个地方(不得不佩服敏锐的眼力 OTZ),问题才得以解决,原因仅仅是 createBundleRenderer 被放在错误的位置多次执行

理解 createBundleRenderer

实际上会犯这个错误,还是因为对 createBundleRenderer 并不了解,那么不妨再深入了解下 createBundleRenderer 的行为。

首先看下 createBundleRenderer 如何使用:

const { createBundleRenderer } = require('vue-server-renderer');

const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false, // 推荐
  template, // (可选)页面模板
  clientManifest // (可选)客户端构建 manifest
});

const context = {};
renderer.renderToString(context, (err, html) => {
  // 处理异常……
  res.end(html);
});

很简单,可以理解为两个行为:

  1. 传递 webpack 打包的 bundle 内容,创建 renderer;
  2. 传递 context 上下文,渲染返回 HTML;

bundle 就是针对服务器渲染的入口文件打包后的内容,通常其代码行为简化如下:

// 首次执行部分
import { createApp } from './app'

export default context => {
  // 每次渲染执行部分
  const { app } = createApp()
  return app
}

可以将其分为两部分:

  1. 首次执行部分。也就是引入的模块,这一部分代码通常只需在首次加载时执行即可;
  2. 每次渲染执行部分。这一部分代码会在每次请求中被执行得到渲染的内容;

值得关注的就是 bundle 代码的执行时机,bundle 代码是每次渲染都要重新执行还是只执行一次即可?实际上,两者都是可选的,并且 Vue 把 runInNewContext 留给使用者,如果你能确保服务端渲染的状态不会出现交叉污染,那么应该关闭来提升性能;反之,你可以打开来确保每次渲染的状态都是全新的。

然而,像本文的情况,并非通过 runInNewContext 来实现每次重新执行,而是通过每次执行 createBundleRenderer 的方式,其二者的主要区别就是是否会创建一个新的 V8 上下文;由于前者每次会在一个新的 V8 上下文下重新执行,因此也不会有内存泄漏的担忧;而后者会与服务器进程在同一个 global 上下文中运行,那么每次都重新执行后,则可能会有一些非预期的对象引用情况,导致对象不被回收造成内存泄漏,比如本文出现的对象无限嵌套引用的情况。

总结

由于对 createBundleRenderer 的行为不了解,导致将其放在每次请求的处理中去执行,进而导致每次都会同一个 V8 上下文重新执行 bundle 的内容,带来了不可控的对象引用导致内存泄漏。我想真正能帮你避坑的,还是你对代码行为的理解,而不是生搬硬套。

参考