如何定位 Node.js 的内存泄漏

co303778 8年前
   <p>在 <a href="http://www.open-open.com/lib/view/open1452752390136.html" rel="nofollow,noindex">《一次 Node.js 应用内存暴涨分析》</a> 中,我们处理了一个 Node.js vm 引发的内存泄漏问题,处理过程也是比较艰辛。而在我们实际开发中,可能经常会碰到内存泄漏的问题,但很多情况下,我们对于这种问题的处理是有些迷茫的,没有一定的操作流程,效率比较低。虽然这种问题对于经验的要求比较高,但如果有一个简单的排查流程,还是会有一定帮助的。</p>    <p>这里简单整理一个流程,欢迎一起探讨,补充。</p>    <h2>基础知识</h2>    <p>Node.js 进程的内存管理,都是有 V8 自动处理的,包括内存分配和释放。那么 V8 什么时候会将内存释放呢?</p>    <p>在 V8 内部,会为程序中的所有变量构建一个图,来表示变量间的关联关系,当变量从根节点无法触达时,就意味着这个变量不会再被使用了,就是可以回收的了。</p>    <p>而这个回收是一个过程性的,从快速 GC 到 最后的 Full GC,是需要一段时间的。</p>    <p>另外,Full GC 是有触发阈值的,所以可能会出现内存长期占用在一个高值,也可以算是一种内存泄漏,可以从《一次 Node.js 应用内存暴涨分析》中找到例子。还有一种就是引用不释放,导致无法进入 GC 环节,并且一直产生新的占用,这一般会发生在 Javascript 层面。</p>    <p>所以,定位内存泄漏问题,一般方案就是找那些不被使用又不会被释放的变量,处理了这些变量,问题一般就可以解决了。如果是 Node.js 底层变量不释放,除了提交 issue 等待解决外,只能通过优化启动参数来解决。</p>    <h2>如何找出并解决问题</h2>    <h3>工具</h3>    <p>工欲善其事必先利其器,在排查时,我们还是需要一些工具来帮忙的。</p>    <p>devTool</p>    <p>这个是今年初出的 Node.js 调试工具,基于 Electron 将 Node.js 和 Chromium 的功能融合在了一起。操作起来比 node-inspector 方便,开放的 Timeline 功能还是比较实用的,虽然不是实时显示。</p>    <p>仅需要 devtool xxx.js ,还可以通过 .devtoolrc 来进行参数定制,具体见 <a href="/misc/goto?guid=4958984394488493353" rel="nofollow,noindex">GitHub</a></p>    <p>heapdump + chrome devTool</p>    <p>这个是比较传统的定位内存泄漏的组合。heapdump 可以直接在代码中调用生成内存快照,然后将快照文件导入到 chrome devTool 进行分析,之后操作其实和前者就差不多了。不过,这个方案和前者有一点区别就是,前者实际还是在浏览器环境中,所以生成的内存快照会有一些 DOM 对象的存在,会有一定的干扰。而这个方案,是直接调用底层 V8 的方法,生成的快照只有 Node.js 环境中的对象。</p>    <p>memwatch</p>    <p>这个可以在代码里直接使用,实时检测内存动态,当发生内存泄漏的时候,会触发 ‘leak’ 事件,会传递当前的堆状态,配合 heapdump 有奇效。详见 <a href="/misc/goto?guid=4959670891445969571" rel="nofollow,noindex">memwatch</a> 。</p>    <h3>流程</h3>    <p>一、重现问题</p>    <p>对于垃圾回收,V8 引擎有很复杂的逻辑来决定什么时候进行回收。很多时候,当我们发现 Node.js 进程所使用的内存快速增长的时候,并不能确定是否是内存泄漏导致的,很有可能是程序设计问题,导致内存的不合理利用。只有当垃圾回收触发,未使用内存被释放后,内存增长还在持续,我们才能确定是发生了内存泄漏。</p>    <p>隐藏的内存泄漏问题,大多是有触发条件的,重现问题是需要这些条件的,所以我们在平时写代码的时候,可以将一些重要环节的参数细节打印在 log 中,这样我们在重现问题是就不会摸不着头脑,乱试一气。</p>    <p>有了参数可以用来重现问题,接下来要确定问题。我们要确定,这部分内存是否没有被 GC 正确释放。那么问题来了,我们如何知道程序进行了垃圾回收呢?很显然,等待并不是办法,我们要主动。</p>    <p>在 Node.js 的启动参数中,提供了暴露手动调用 GC 方法的参数,即 --expose-gc 。我们用这个参数来启动应用后,就可以在代码中调用 global.gc() 手动触发垃圾回收操作。同时,使用 process.memoryUsage().heapUsed 获取进程运行时所占用的内存。如果 GC 之后,内存依然没有下降,就可以确定是内存泄露了。</p>    <p>二、生成内存快照</p>    <p>既然内存是问题,我们就需要获取程序运行的内存快照来帮助定位问题。但内存快照并不是随便打得,是有一定技巧的。</p>    <p>我们 <strong>至少要生成三次内存快照</strong> ,才能更好的定位问题。这三次中又一次要在问题出现前生成,之后可以在问题持续的过程中生成两次或更多。</p>    <p>为什么要这样做呢?理解起来很简单。第一次是为了获取正常情况下的堆栈信息,而在问题出现后,堆栈信息一定会发生变化,有了第一次的信息,我们才好进行后面的比对,过滤一些无用的信息。而后两次的快照,用来比对某一对象的堆栈变化,来确定是否是有问题的对象。下面会详细应用到。</p>    <p>三、定位问题</p>    <p>用 devTool 的可以忽略下面的过程:</p>    <p>打开 Chrome Devtools ,进入到 Profiles 选项卡,点 Load 按钮,加载之前生成的快照。</p>    <p>对于内存快照,有四个视图,Summary,Comparison,Containment,Statistics,这里面常用的是前三个。</p>    <p>在 Summary 视图中,我们可以看到当前快照的全部信息,以及多个快照之间的信息。在列表里显示的都是对象的构造函数名字,可以先忽略被括号包裹的对象,优先观察其他的对象,最后再来看他们。后面的 shallow size 表示的是对象自身的大小, retained size 表示的是对象和它依赖对象的大小,一般是 GC 不可达的。</p>    <p>在 Comparison 视图中,我们可以进行多个快照之间的对比,这个用处比较大,如果我们将前两次快照进行对比,可能比较快速的定位出问题的对象。注意观察 New、Deleted、Delta,如果是内存泄漏的对象,可能是一直在 New,而没有 Deleted。</p>    <p>在 Containment 视图中,我们可以查看整个 GC 路径,当然一般不会用到。因为展开在 Summary 和 Comparison 列举的每一项,都可以看到从 GC roots 到这个对象的路径。通过这些路径,你可以看到这个对象的句柄被什么持有,从而定位问题产生的原因。值的注意的是,其中背景色黄色的,表示这个对象在 Javascript 中还存在引用,所以可能没有被清除。如果是红色的,表示的是这个对象在 Javascript 中不存在引用,但是依然存活在内存中,一般常见于 DOM 对象,它们存放的位置和 Javascript 中对象还是有不同的,在 Node.js 中很少遇见。</p>    <p>更多的操作方法,可以看这个视频 <a href="/misc/goto?guid=4959670891529013594" rel="nofollow,noindex">Memory Profiling with Chrome DevTools</a> 和 <a href="/misc/goto?guid=4959670891606909589" rel="nofollow,noindex">Memory Management Masterclass</a> 。还有 Chrome 的文档 <a href="/misc/goto?guid=4959625096191381825" rel="nofollow,noindex">Memory Profiling</a> (旧) 和 <a href="/misc/goto?guid=4959670891717264610" rel="nofollow,noindex">Memory Diagnosis</a> (新)。讲的还是很详细的。(请自备梯子)</p>    <p>四、解决问题</p>    <p>一般在 Javascript 中存在引用而导致内存泄漏的情况,是比较好处理的,只需要在使用后及时的将引用释放掉即可。</p>    <p>但像 <a href="http://www.open-open.com/lib/view/open1452752390136.html" rel="nofollow,noindex">《一次 Node.js 应用内存暴涨分析》</a> 所存在的那种内存问题,是属于底层机制的问题,如果等不了 bugfix,就只能先通过一些启动参数来优化内存管理。常用的参数:</p>    <ul>     <li>--max-old-space-size 限制老生区大小,可以控制内存占用的最大值,即使发生泄漏,也不会让内存占用保持很高。可以根据开启进程数以及是否同机部署来优化。</li>     <li>--gc_global 这其实是个 V8 的 debug flag,让 GC 永远都是 Full GC,使用上会有一定的性能损耗,根据应用复杂度不同,损耗不同。</li>    </ul>    <p>当我们找到问题,进行修复后,重复上面的步骤,确认问题已经被解决。有时可能一次并不能解决问题,所以耐心还是很重要的。</p>    <h2>实战</h2>    <p>可以在这里下载使用到的代码, <a href="/misc/goto?guid=4959670891811563761" rel="nofollow,noindex">GitHub</a> ,进入 memory-leak 文件夹。</p>    <p>我们来举个例子,应用上面的步骤排查问题,使用 leak-memory 的例子,代码还有另外一个例子,可以自己实践。</p>    <p>这里我们为了方便,我们使用了 devTool。</p>    <p>devTool leak-memory.js</p>    <p>然后在打开的界面中进入内存快照界面,生成第一次快照。当控制台有输出后,间隔的生成两次快照,结果如下。</p>    <p><img src="https://simg.open-open.com/show/a0b9d22d5b27e5c8786d7d6ab4dfcee4.jpg"></p>    <p>我们切换视图,对比下三次快照间的区别,可以看到 Foo 这个对象一直在创建而没有被删除。</p>    <p><img src="https://simg.open-open.com/show/5125ab368c32eb97abd6bf1ab65c67a9.jpg"></p>    <p><img src="https://simg.open-open.com/show/2812c486b20bf01949974afe46c88568.jpg"></p>    <p>我们展开 Foo ,选择下面的一个实例,查看它的 GC path,可以看到它一直被 neverRelease 持有引用(黄色),所以没有被释放,之后就可以进行问题的处理了。</p>    <p><img src="https://simg.open-open.com/show/37f398e41a340d9ba156f73c6292c195.jpg"></p>    <p>去掉 // neverRelease.splice(index, 1); 前的注释,然后在重复上面的步骤,你会发现内存的变化已经正常了。</p>    <p>在使用 devTool 时,可以查看运行时的 memory timeline,如果图像呈现阶梯式增长,一般就是存在内存泄漏问题了。正常的应用曲线会类似于锯齿,如图:</p>    <p><img src="https://simg.open-open.com/show/38047d81f1fdab543a5cc4b1b2e653ce.jpg"></p>    <p>来自: <a href="/misc/goto?guid=4959670891898008719" rel="nofollow">http://taobaofed.org/blog/2016/04/15/how-to-find-memory-leak/</a></p>