记一次 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 中看到实现,不是本文重点,就不细说了。

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

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,如下:

// 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 来发起请求。

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

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

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

实验一

可以发现一些问题,

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

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

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

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 ):

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 中,有三种概念, Handle 、 Local 、 Persistent 。所有 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 的值即可以实现传递不同参数的效果。

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 模块本身提供了复用的能力,Script 和 createContext,所以可以利用它们来处理。

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 部分。

npm install profiler
node --prof xxx.js

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

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

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

node-inspector

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

node-heapdump

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

npm install heapdump

在应用中引入

var heapdump = require('heapdump');

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

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 比较复杂,理解有误的地方,欢迎指正,讨论。

参考资料:

转载自:http://taobaofed.org/blog/2016/01/14/nodejs-memory-leak-analyze/
作者:   凌恒
目录
相关文章
|
11天前
|
JavaScript 前端开发 API
探索后端技术:Node.js的优势和实际应用
【10月更文挑战第6天】 在当今数字化时代,后端开发是任何成功软件应用的关键组成部分。本文将深入探讨一种流行的后端技术——Node.js,通过分析其核心优势和实际应用案例,揭示其在现代软件开发中的重要性和潜力。
49 2
|
5天前
|
存储 弹性计算 算法
前端大模型应用笔记(四):如何在资源受限例如1核和1G内存的端侧或ECS上运行一个合适的向量存储库及如何优化
本文探讨了在资源受限的嵌入式设备(如1核处理器和1GB内存)上实现高效向量存储和检索的方法,旨在支持端侧大模型应用。文章分析了Annoy、HNSWLib、NMSLib、FLANN、VP-Trees和Lshbox等向量存储库的特点与适用场景,推荐Annoy作为多数情况下的首选方案,并提出了数据预处理、索引优化、查询优化等策略以提升性能。通过这些方法,即使在资源受限的环境中也能实现高效的向量检索。
|
6天前
|
编解码 Android开发 UED
构建高效Android应用:从内存优化到用户体验
【10月更文挑战第11天】本文探讨了如何通过内存优化和用户体验改进来构建高效的Android应用。介绍了使用弱引用来减少内存占用、懒加载资源以降低启动时内存消耗、利用Kotlin协程进行异步处理以保持UI流畅,以及采用响应式设计适配不同屏幕尺寸等具体技术手段。
22 2
|
1月前
|
XML IDE 前端开发
IDEA忽略node_modules减少内存消耗,提升索引速度
在后端开发中,IDEA 在运行前端代码时,频繁扫描 `node_modules` 文件夹会导致高内存消耗和慢索引速度,甚至可能会导致软件卡死。为了改善这一问题,可以按照以下步骤将 `node_modules` 文件夹设为忽略:通过状态菜单右键排除该文件夹、在设置选项中将其加入忽略列表,并且手动修改项目的 `.iml` 文件以添加排除配置。这些操作可以有效提高IDE的运行性能、减少内存占用并简化项目结构,但需要注意的是,排除后将无法对该文件夹进行索引,操作文件时需谨慎。
56 4
IDEA忽略node_modules减少内存消耗,提升索引速度
|
14天前
|
数据采集 JSON 前端开发
JavaScript逆向爬虫实战分析
JavaScript逆向爬虫实战分析
17 4
|
3天前
|
运维 JavaScript Linux
容器内的Nodejs应用如何获取宿主机的基础信息-系统、内存、cpu、启动时间,以及一个df -h的坑
本文介绍了如何在Docker容器内的Node.js应用中获取宿主机的基础信息,包括系统信息、内存使用情况、磁盘空间和启动时间等。核心思路是将宿主机的根目录挂载到容器,但需注意权限和安全问题。文章还提到了使用`df -P`替代`df -h`以获得一致性输出,避免解析错误。
|
1月前
|
JavaScript 开发者
深入理解Node.js事件循环及其在后端开发中的应用
【8月更文挑战第57天】本文将带你走进Node.js的事件循环机制,通过浅显易懂的语言和实例代码,揭示其背后的工作原理。我们将一起探索如何高效利用事件循环进行异步编程,提升后端应用的性能和响应速度。无论你是Node.js新手还是有一定经验的开发者,这篇文章都能给你带来新的启发和思考。
|
15天前
|
JavaScript NoSQL 前端开发
使用 Node.js 和 MongoDB 构建实时聊天应用
【10月更文挑战第2天】使用 Node.js 和 MongoDB 构建实时聊天应用
|
15天前
|
前端开发 JavaScript
JavaScript动态渲染页面爬取——CSS位置偏移反爬案例分析与爬取实战
JavaScript动态渲染页面爬取——CSS位置偏移反爬案例分析与爬取实战
28 0
|
2月前
|
存储 编译器 C语言
【C语言篇】数据在内存中的存储(超详细)
浮点数就采⽤下⾯的规则表⽰,即指数E的真实值加上127(或1023),再将有效数字M去掉整数部分的1。
260 0