Taobao FED

记一次 Node.js 应用内存暴涨分析

记一次 Node.js 应用内存暴涨分析

起因

之前 TMS 在运行时 CPU 中占用率和内存占用一直很高,导致应用运行状态不是很良好,需要频繁重启。经过排查,找出了部分原因:

  1. 使用的 html-minifier 模块有问题,如果输入的内容是一个有错误的 HTML 结构,会使解析进入死循环,导致 CPU 占用率 100%。

  2. 在使用 vm 模块时,使用姿势错误,导致内存占用无法释放,使内存占用暴涨。

第一个问题我们今天不予讨论,主要来说一下第二个问题。

VM(Virtual Machine) 模块

我们就先了解下 VM 这个模块。

从它的名字和暴露的 API 可以看出,它能创建一个拥有指定上下文的运行环境,可以在里面直接运行 JavaScript 代码,类似 eval。这样运行代码时,不会污染当前作用域,一旦出问题,也不会对当前环境造成很大影响。

虽然这个模块我们平时用的比较少,但它算是 Node.js 的核心模块,在 require 的实现中,你会发现它的身影。我们在使用 Node.js 时,会使用 require 引入很多外部模块,对于 Node.js 来说,我们引入的代码如果直接和运行环境交互,是十分危险的。所以在 Node.js 模块加载的过程中,会先将 .js 文件的内容进行包裹,变成类似 function(...) {}(...) 的形式,然后使用 vm.runInThisContext 去运行,同时将 module、require 等方法传入返回的函数中。具体的模块加载机制,可以在 lib/module.js 中看到实现,不是本文重点,就不细说了。

当然,我们也可以用它来执行我们的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const vm = require('vm');
const code = 'result = 2 * n;';
const script = new vm.Script(code); // 预编译后供之后使用
const sandbox = { n: 5 };
const _sandbox = { n: 10 };
const ctx = vm.createContext(_sandbox); // contextify

// 供 runInThisContext 使用
global.result = 0;
global.n = 16;
// 在当前上下文运行,32
vm.runInThisContext(code);
script.runInThisContext();
// 在新的上下文中运行,10
vm.runInNewContext(code, sandbox);
script.runInNewContext(sandbox);
// 在执行上下文中运行,20
vm.runInContext(code, _sandbox);
script.runInContext(_sandbox);

问题出现

在 TMS 中,需要压缩用户上传的代码,出于安全和稳定的考虑,需要和当前运行环境进行隔离,这里就可以使用 VM 模块。为了便于理解,简化了一个类似的 Demo,如下:

1
2
3
4
5
6
7
8
9
10
// fibonacci,计算斐波纳挈数列
http.createServer(function(req, res) {
let sandbox = {
fibonacci: fibonacci,
number: 10
};

vm.runInNewContext('a = fibonacci(number)', sandbox);
res.end();
}).listen(8999, '127.0.0.1');

运行 Demo。为了模拟实际环境中的并发,这里我们使用 ab 来发起请求。

1
ab -n 1000 -c 100  http://127.0.0.1:8999/

Apache HTTP server benchmarking tool,简称 ab,是一个常用的开源网站压力测试工具,官网

在运行期间,我们使用 top 来观察内存的占用情况。

实验一

可以发现一些问题,

  • 内存占用暴涨,大约 800M
  • 占用的内存在运行结束(没有请求)后,释放很慢
  • QPS 很低

Demo 应用比较简单,引发的问题不大。但如果在实际的应用场景中,一旦发生内存占用过高,无法分配内存空间的情况,会对应用稳定性照成很大影响,甚至导致应用崩溃。

接下来,我们再看一个例子,将上面的代码稍作修改,如下:

1
2
3
4
5
6
7
8
9
let sandbox = {
fibonacci: fibonacci,
number: 10
};

http.createServer(function server (req, res) {
vm.runInNewContext('a = fibonacci(number)', sandbox);
res.end();
}).listen(8999, '127.0.0.1');

用上面同样的方法观察,结果如下图:

实验二

这次,我们看到内存仅占用了 19M,而且增长很平缓,QPS 提高了不少。

仅仅是声明 sandbox 位置的不同,差别却如此之大,为什么呢?

探究原因

我们都知道,一般一个在函数中声明的变量,在函数运行完,就会被释放掉,所占用的空间也会被回收。但在之前的例子,很有可能 sandbox 变量没有被回收,导致的内存暴涨。它和其它变量有什么区别,导致它不能被正确释放呢?

翻了下 vm 的代码,发现在使用 vm.runInNewContext 时,会将你传入的 sandbox 进行 contextify,问题可能就出在这里。

contextify 大体流程如下(src/node_contextify.cc#L281 MakeContext):

  1. 检查传入的对象(sandbox)是否有 _contextifyHidden 这个隐藏的属性。
  2. 如果没有,则 new 一个 ContextifyContext 实例,并且挂载到 sandbox 的 _contextifyHidden 属性上。
  3. 如果存在,则返回,不做处理,防止在一个对象上多次进行 contextify。

如果我们用一个在函数外部声明的 sandbox,如同第二种写法,那么无论我们调用多少次 runInNewContext,都只会进行一次 contextify 操作,效果类似于 vm.runInContext。但是,如果像第一种写法那样,每次都使用一个新的对象,那么每次都要进行 contextify,而 contextify 过程中比较关键的一步是创建一个 ContextifyContext 实例,这个类有些特殊的地方,我们看下它的具体定义(在 src/node_contextify.cc#L49 ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ContextifyContext {
...
Persistent<Object> sandbox_;
Persistent<Context> context_;
Persistent<Object> proxy_global_;
...
public:
explicit ContextifyContext(Environment* env, Local<Object> sandbox) {
...
sandbox_.MarkIndependent();
...
context_.MarkIndependent();
...
proxy_global_.MarkIndependent();
...
}
...
}

它里面有三个被声明成 Persistent 类型的变量,重点就在这里。

在 V8 中,有三种概念, HandleLocalPersistent 。所有 JavaScript 数据都是有 GC 管理的。JavaScript 中的变量在 C++ 层面都是和 Handle 对应的,可以把它理解成一个普通的指针,用来指向数据的内存位置。而 Local 可以看做一个实际存储数据的空间,拥有 new 方法,当它 new 出来后,无论是否有变量接收,都会存在于 HandleScope 中。 HandleScope 可以理解成一个管理和回收 Handle 的东西。所以,一个 Local 可以有多个 Handle 指向。而 HandleScope 类似于函数的作用域,它管理着 Handle 和 Local,一旦 HandleScope 退出,其上的 Handle 和 Local 就会被释放掉,可以联系 JavaScript 中的函数作用域来理解。

如同 JavaScript 中的闭包一样,我们有时会需要一种在函数退出后依然存在的变量,这就是 Persistent 类型,它不由 HandleScope 管理,只要没有手动释放,它就一直可以被使用。可以简单用堆和栈的概念来理解,Persistent 是堆变量,HandleScope 是栈,Local 是栈变量,而 Handle 是一种引用。

对于 Persistent 类型变量,除了手动调用 Dispose() 释放外,V8 还提供了一种自动的,依赖 GC 的释放方式,就是 Weak Callback + MarkIndependent 的组合,显然,Node.js 就是使用的这种。这种方式的优势在于自动化,不用开发者去管理这部分内存,但是过分的依赖 GC,难免会产生各种各样的问题,比如:内存释放不及时,占用过多系统资源等。

要知道,GC 并不是实时的,它是需要程序停下来一段时间来让它来进行回收操作的,如果程序一直在运行,那么 GC 操作就会被延后,直到它觉得必需要运行的时候。这样,会造成要释放资源的积压。如果频繁执行 GC,则会影响程序的运行效率。

而且,Weak Callback 的执行是由 GC 决定的,一般是在 Full GC 前后。比较过分的是,GC 不保证一定会调用这个回调。。。

另外,在上述的场景中,通过试验,可以做这样的猜想:因为 old space 默认大小为 1G,而我们看到在 1000 次执行完后,old space 才 800M 左右,没有达到阈值,所以 V8 并不会处理这部分的内存占用。当我们把 old space 设为 200M 时,其值稳定在 180M 左右,可以大体印证这个猜想。

综上,问题的根源找到了。每次请求回调里都会创建一个新的 sandbox,并且它不能在使用完后立即释放,于是就形成很多无用的 Persistent Handle,堆积在内存中,导致内存占用暴涨。而且,它们的释放主要依赖于 MarkSweep,执行频率不高,所以占用释放很慢。可以想象,在一个高 QPS 的应用下,内存基本上是只增不降的,一点点被蚕食干净。

解决问题

问题既然找到了,那么就来看下如何解决。

方案一

把 sandbox 在回调外面声明,减少重复 contextify。因为脚本运行所需要的 context 对象实际上就是 sandbox 对象,只是在底层标识了一下(_contextifyHidden),这一点在 MakeContext 函数中以及获取 vm 里的返回值时可以看出来,所以修改 sandbox 的值即可以实现传递不同参数的效果。

1
2
3
4
5
6
7
8
9
10
11
let sandbox = {
fibonacci: fibonacci,
number: 10
};

http.createServer(function(req, res) {
// 传递不同的值
sandbox.number = Math.floor(Math.random() * 20);
vm.runInNewContext('a = fibonacci(number)', sandbox);
res.end();
}).listen(8999, '127.0.0.1');

方案二

vm 模块本身提供了复用的能力,ScriptcreateContext,所以可以利用它们来处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
const code = 'a = fibonacci(number)';
const script = new vm.Script(code);
let sandbox = {
fibonacci: fibonacci,
number: 10
};
let ctx = vm.createContext(sandbox);

http.createServer(function(req, res) {
sandbox.number = Math.floor(Math.random() * 20);
script.runInContext(ctx);
res.end();
}).listen(8999, '127.0.0.1');

从上面 contextify 的过程中,我们除了可以发现 context 和 sandbox 是关联的之外,还有一点就是 runInNewContext 会对 sandbox 做校验,所以这里使用 runInNewContext 也不会有上述的问题。

方案三

这种方案更有普适性,不一定针对于这个问题本身。

Node.js 本身提供了很多关于 GC 方面的参数。

MarkSweep,Full GC 的标记阶段

  • --trace_gc,打印 GC 日志
  • --expose-gc,暴露 GC 方法,可以手动调用 global.gc() 来强制执行 GC 过程,并不推荐使用。
  • --max-new-space-size,最大 new space 大小,执行 scavenge 回收,默认 16M,单位 KB
  • --max-old-space-size,最大 old space 大小,执行 MarkSweep 回收,默认 1G,单位 MB
  • --gc-global,强制每次执行 MarkSweep。

可以通过调节这些参数的配置,观察 GC 日志中 sweeping from(内存积压状况)、Mark-sweep(MarkSweep 用时)等,来优化 GC 过程,需要一定的耐心。当然,有些值不能太极端,比如把 --max-old-space-size 设置的很小,频繁触发 GC,会导致应用的执行效率下降。

以后如何发现问题

以后如果遇到一些性能问题,我们该如何去排查呢?这里介绍一些常用的方法。

v8 prof

使用 V8 自带的 profiler 功能,分析 JavaScript 各个函数的消耗和 GC 部分。

1
2
npm install profiler
node --prof xxx.js

会生成 xxxx-v8.log,之后使用工具转换成可读的。

1
2
npm install tick
node-tick-processor xxxx-v8.log

就可以查看相关的数据了。

node-inspector

这个工具就不多介绍了,大家应该很熟了,它可以使用 Chrome 开发者工具来调试 Node.js 应用。

node-heapdump

它可以对 Node.js 应用进行 heapdump。然后,可以使用 Chrome 开发者工具打开生成的 xxx.heapsnapshoot 文件,查看 heap 中的内容。

1
npm install heapdump

在应用中引入

1
var heapdump = require('heapdump');

执行一段时间后退出,或者在命令行中:

1
kill -USR2 <pid>

v8-profiler

这个被 node-inspector 集成了,可以提供 HeapDump 和 CPU Profile 功能。
详见 v8-profiler

node-memwatch

可以帮助发现代码存在的内存泄露问题,也可以做在不同时间点堆的比较。
详见 node-memwatch

当然,工具只是辅助作用,在平时写代码时多思考一下,善用 API,在处理问题时多积累些经验,才能写出更好的代码。

总结

V8 提供的内存释放方案有它的优势所在,但 GC 是个很复杂的过程,过分依赖自动化,也不一定是好事。特别在写 Node.js 底层的 C++ 部分时,我们还是要考虑下是否该手动释放的问题,不要把问题都抛给 V8。当然,对于 API 应用也要注意,本身 VM 模块提供了更好的方案,但我们却忽略了。

V8 比较复杂,理解有误的地方,欢迎指正,讨论。

参考资料: