背景
近期,对一个老项目进行直出改造,将其从 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,来不及反映内存是否会被正常回收然后正常回落,进程就挂掉重启了。此时怀疑两种可能:
- 单个请求占用的内存较大,导致安平一扫描就使内存瞬时占用过大;
- 程序存在内存泄漏,导致内存没有被回收,内存占用就会不断上涨然后超出限制;
为了判断是否出现了内存泄漏,需要尽可能先避免安平扫描的干扰,使内存有空间来反映回落的过程,因为我对服务的内存进行了扩容,并提高了 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 看出哪些是明显增加的内容,明显增加的内容很可能就是泄漏的内容;
此次内存泄漏的特点
-
单个请求内存泄漏明显。每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);
}
}
顺应旧逻辑引起的坑
虽然这个地方现在看起来其实是一个比较明显的问题,且在搭新项目时,这也是几乎不可能踩的坑,但是当时之所以会写出与官网不一致的实践,也是有原因的:
-
为了顺应项目的原有逻辑
项目原有逻辑为:请求 → 渲染 ejs → 得到渲染结果 html;而
createBundleRenderer
需要传入的一个参数template
就是 html 字符串,而原有逻辑的 html 字符串是每个请求动态生成的,而不是一般静态模板,因此才出现了这个坑。 -
对 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);
});
很简单,可以理解为两个行为:
- 传递 webpack 打包的 bundle 内容,创建 renderer;
- 传递 context 上下文,渲染返回 HTML;
bundle 就是针对服务器渲染的入口文件打包后的内容,通常其代码行为简化如下:
// 首次执行部分
import { createApp } from './app'
export default context => {
// 每次渲染执行部分
const { app } = createApp()
return app
}
可以将其分为两部分:
- 首次执行部分。也就是引入的模块,这一部分代码通常只需在首次加载时执行即可;
- 每次渲染执行部分。这一部分代码会在每次请求中被执行得到渲染的内容;
值得关注的就是 bundle 代码的执行时机,bundle 代码是每次渲染都要重新执行还是只执行一次即可?实际上,两者都是可选的,并且 Vue 把 runInNewContext
留给使用者,如果你能确保服务端渲染的状态不会出现交叉污染,那么应该关闭来提升性能;反之,你可以打开来确保每次渲染的状态都是全新的。
然而,像本文的情况,并非通过 runInNewContext
来实现每次重新执行,而是通过每次执行 createBundleRenderer
的方式,其二者的主要区别就是是否会创建一个新的 V8 上下文;由于前者每次会在一个新的 V8 上下文下重新执行,因此也不会有内存泄漏的担忧;而后者会与服务器进程在同一个 global
上下文中运行,那么每次都重新执行后,则可能会有一些非预期的对象引用情况,导致对象不被回收造成内存泄漏,比如本文出现的对象无限嵌套引用的情况。
总结
由于对 createBundleRenderer
的行为不了解,导致将其放在每次请求的处理中去执行,进而导致每次都会同一个 V8 上下文重新执行 bundle 的内容,带来了不可控的对象引用导致内存泄漏。我想真正能帮你避坑的,还是你对代码行为的理解,而不是生搬硬套。
参考
- 《Node.js 调试指南》www.bookstack.cn/read/node-i…