NodeJS
首发于NodeJS
一行 delete require.cache 引发的内存泄漏血案

一行 delete require.cache 引发的内存泄漏血案

线上使用 Node.js 技术作为中间层,进行前后端彻底的分离的方案现在越来越广泛地应用到企业开发中。我们也很高兴的看到许多开发者能深入阅读 Node.js 的底层代码,甚至在某些情况下能够使用一些非常规手段来解决项目遇到的问题,但是这个过程中很容易出现一些意想不到的状况,今天给大家分享的一个例子是因为试图清除模块缓存而引发的 Node.js 进程内存泄漏。具体的过程可以看下这篇非常不错的分享:

记录一次由一行代码引发的“血案” - CNode技术社区

很有幸我参与了这个问题的排查过程,这里进一步和大家一起分享下使用 Node.js 性能平台 分析这个内存泄漏案例的完整过程。

排查

经过客户 @Derek Yeung 的授权,我拿到了出问题的项目的管理权限,然后通过趋势图看到业务进程从启动开始,内存不断上涨,大概五六分钟后业务进程的的堆内内存已经高达 269M:

按照排查内存泄漏的常规操作,我们给疑似内存泄漏的进程做了一个 heapdump 和 heapprofile(需要 alinode-v3.9.0 及以上),首先得到的是 heapsnapshot,经过分析如下图:

好家伙,第一个可疑点占据了当前堆内存的 80%+,基本上就是这里有问题了,结合 dev tools,我们来仔细观察下这个 Module@14727 究竟是什么:

可以看到,Module@1427 的下一层是属性名为 children 的一个数组,里面包含了 151 个里层的 Module 实例,正是这 151 个 Module 实例占据了 80%+ 的内存。同时分别点开这 151 个 Module 实例,可以看到重复度非常高,基本都是 common/models/Operator.js、common/models/School.js、common/models/User.js、common/models/OperatorMenu.js 等文件 require 后形成的。

熟悉 Node.js 模块机制源码的朋友马上可以敏感的发现:Node.js 本身的模块导入机制是有缓存的,怎么会有这么多重复的实例引入?根据这些提示,我们到了产生泄漏点的地方:

接下来就是分析泄漏产生的原因了。因为 Node.js 每个 require 的文件实际上生成了一个 Module 类的实例,并且模块第一次 require 的时候会缓存起来:

// lib/module.js
var module = new Module(filename, parent);
Module._cache[filename] = module;

这个 Module 类的构造函数如下:

// lib/module.js
function Module(id, parent) {
  this.id = id;
  this.exports = {};
  this.parent = parent;
  updateChildren(parent, this, false);
  this.filename = null;
  this.loaded = false;
  this.children = [];
}

最后就是根据源代码中的映射关系,require.cache === Module._cache:

// lib/internal/module.js
function makeRequireFunction() {
  // 我们调用的 require 方法
  function require() {
    //...
  }
  //...
  require.cache = Module._cache;
  return require;
}

通过这三段简单的代码可以看到,产生泄漏的代码中 delete require.cache[缓存文件全路径] 确实清理掉了第一次 require 进来时的缓存引用,那为什么会产生泄漏呢?答案在上面的 Module 类的 updateChildren 方法中:

// lib/module.js
function updateChildren(parent, child, scan) {
  var children = parent && parent.children;
  if (children && !(scan && children.includes(child)))
    children.push(child);
}

可以看到,每次 require 操作,除了自己本身不存在时会缓存自身实例的引用到 Module._cache 中外,还会将自身实例的引用放入 parent.children (如果存在 parent 的话)这个数组中!那么产生泄漏的原因就很明了了:

  • delete require.cache 仅仅清除掉了 Module._cache 对文件第一次 require 的实例的引用
  • 此时父文件 require 实例的 children 数组中的缓存的引用依旧存在

下一次 再 require 此文件时,由于 Module._cache 中不存在,会再生成一次文件对应的 Module 实例,同时给其父文件实例的 children 数组再增加一个引用,这样就造成了泄漏。

最终客户删除掉这部分代码,泄漏问题终于解决,经过两天的测试,目前服务依旧比较稳定的运行,堆内内存耗费稳定在 60M 左右。

一些感想

首先 CNode 社区这篇文章的作者对这次的问题总结的有一点非常好:

遇到问题不要慌,一定要仔细排查,一步一步来,就算找不到问题点,也不要想各种野路子来解决。

我们在使用 Node.js 这样一门年轻的服务端语言进行开发时,难以避免会随着项目的复杂遇到一些奇奇怪怪的问题。如果遇到这些难以排查的问题在没有探根究底之前,我们通过升级版本、更换 npm 包或者采用一些野路子的方法来临时的解决掉问题,其实某种程度上是给的项目埋下了一颗定时炸弹,长期下来也会对开发者自身的信心和代码掌控力度产生不利的影响。

借助目前社区给出的应用层 Egg.js 框架 + 底层 Node.js 性能平台 这一套组合拳,我们可以很方便地进行团队风格约束开发和线上故障诊断,也许朴老师口中的 让天下没有难用的 Node.js 这一天,真的要到来了。

特别提醒

delete require.cache 这个操作是许多希望做 Node.js 热更新的同学喜欢写的代码,根据上面的分析要完整这个模块引入缓存的完整去除需要所有引用到此模块的 parent.children 数组里面的引用。

遗憾的是,module.parent 只是保存了第一次引用该模块的父模块,而其它引用到此文件的父模块需要开发者自己去构建载入模块间的相互依赖关系,所以在 Node.js 下做热更新并不是一条传统意义上的生路。

原文

原始文章来自 egg.js 团队的 Node.js 经验分享:Node.js 专栏

编辑于 2018-03-19

文章被以下专栏收录

    在 eggjs 团队的日常协作中,遵循「基于 GitLab 的硬盘式异步协作模式」。 先通过 issue 发起 RFC 召集讨论,再提交 Pull Request 和 Code Review,这样便于沉淀,即使是当时没有参与讨论的开发者,事后也能通过 issue 了解某个功能设计的前因后果。 因此,本专栏用于汇总近期值得关注的 Egg.js 和 Node.js 相关动态,将不定期发布。