简介: 记得在 15 16 年那会 Node.js 刚起步的时候,我在去前东家的入职面试也被问到了要如何实现 Node.js 服务的热更新。

记得在 15 16 年那会 Node.js 刚起步的时候,我在去前东家的入职面试也被问到了要如何实现 Node.js 服务的热更新。

其实早期从 Php-fpm / Fast-cgi 转过来的 Noder,肯定非常喜欢这种更新业务逻辑代码无需重启服务器即可生效的部署方案,它的优势也非常明显:

  • 无需重启服务意味着用户连接不会中断,尤其对于大量长链接 hold 的应用
  • 文件更新加载缓存是一个非常快的过程,可以完成毫秒级别的应用更新

热更新的副作用也非常多,比如常见的内存泄露(资源泄露),本文将以 clear-module 和 decache 这两个下载量比较高的热门热更辅助模块来探讨下热更究竟会给我们的应用带来哪些问题。

热更实现原理

在开始谈热更新的问题之前,我们首先要了解下 Node.js 的模块机制的概貌,这样对于后面它带来的问题将能有更加深刻的理解和认识。

Node.js 自己实现的模块加载机制如下图所示:

简单地说父模块 A 引入子模块 B 的步骤如下:

  • 判断子模块 B 缓存是否存在
  • 如果不存在则对 B 进行编译解析
  • 添加 B 模块缓存至require.cache(其中 key 为模块 B 的全路径)
  • 添加 B 模块引用至父模块 A 的children数组中
  • 如果存在,判断父模块 A 的children数组中是否存在 B,如不存在则添加 B 模块引用。

其实到了这里,我们已经可以发现要实现没有内存泄露的热更新,需要断开待热更模块的以下引用链路:

这样当我们再次去require子模块 B 的时候,就会重新从磁盘读取 B 模块的内容然后进行编译引入内存,据此实现了热更的能力。

实际上,第一节中提到的clear-moduledecache两个包都是按照这个思路实现的模块热更,当然它们考虑的会更加完善一些,比如将子模块 B 本身的依赖也一并清除,以及对于循环引用场景的处理。

那么,借助于这两个模块,Node.js 应用的热更新是不是就完美无缺了呢?我们接着看。

问题一:内存泄露

内存泄露是一个非常有意思的问题,凡是进入 Node.js 全栈开发深水区的同学基本或多或少都会遇到内存泄露的问题,那么从我个人的故障排查定位经验来说,开发者其实不需要畏惧内存泄露,因为相比其它摸不着头脑的问题,内存泄露是一个只要你熟悉代码并且肯花时间百分百可解的故障类型。

这里我们来看看看似清除了所有旧模块引用的热更方案,又会以怎样的形式产生内存泄露现象。

decache

考虑构造以下热更例子,先使用decache进行测试:

'use strict';

const cleanCache = require('decache');

let mod = require('./update_mod.js');
mod();
mod(); setInterval(() => {
cleanCache('./update_mod.js');
mod = require('./update_mod.js');
mod();
}, 100);

这个例子中相当于在不断清理./update_mod.js这个模块的缓存进行热更,它的内容如下:

'use strict';

const array = new Array(10e5).fill('*');
let count = 0; module.exports = () => {
console.log('update_mod', ++count, array.length);
};

为了能快速观察到内存泄露现象,这里构造了一个大数组来替代常规的模块闭包引用。

为了方便观察我们可以在index.js中可以添加一个方法来定时打印当前的内存状况:

function printMemory() {
const { rss, heapUsed } = process.memoryUsage();
console.log(`rss: ${(rss / 1024 / 1024).toFixed(2)}MB, heapUsed: ${(heapUsed / 1024 / 1024).toFixed(2)}MB`);
} printMemory();
setInterval(printMemory, 1000);

最后执行node index.js文件,可以看到内存迅速溢出:

update_mod 1 1000000
update_mod 2 1000000
rss: 34.59MB, heapUsed: 11.51MB
update_mod 1 1000000
rss: 110.20MB, heapUsed: 80.09MB
update_mod 1 1000000
... rss: 921.63MB, heapUsed: 888.99MB
update_mod 1 1000000
rss: 998.09MB, heapUsed: 965.12MB
update_mod 1 1000000
update_mod 1 1000000 <--- Last few GCs ---> [50524:0x158008000] 13860 ms: Scavenge 1018.3 (1024.6) -> 1018.3 (1028.6) MB, 2.3 / 0.0 ms (average mu = 0.783, current mu = 0.576) allocation failure
[50524:0x158008000] 14416 ms: Mark-sweep (reduce) 1026.0 (1036.3) -> 1025.9 (1029.3) MB, 457.8 / 0.0 ms (+ 86.6 ms in 77 steps since start of marking, biggest step 8.7 ms, walltime since start of marking 555 ms) (average mu = 0.670, current mu = 0.360 <--- JS stacktrace ---> FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

抓取堆快照后进行分析:

很明显Module@39215children数组中大量塞入了重复的热更模块update_mod.js的编译结果导致了内存泄露,而进一步查看Module@39215信息:

可以看到其正是入口的index.js

阅读decache实现源代码后发现,产生泄露的原因则是我们在热更实现原理一节中提到的要去掉全部的三条引用,而遗憾的是decache仍然只断开了最基础的require.cache这一条引用链路:

至此,decache由于最基本的热更内存问题都尚未解决,白瞎了其 94w 的月下载量,可以直接排出我们的热更方案参考。

参考:

clear-module

接下来我们看看月下载量为 19w 的clear-module表现如何。

由于前一小节中的测试代码代表了最基础的模块热更场景,且clear-moduleAPI使用和decache基本一致,所以我们仅替换cleanCache引用即可进行本轮测试:

// index.js
const cleanCache = require('clear-module');

同样执行node index.js文件,可以看到内存变化如下:

update_mod 1 1000000
update_mod 2 1000000
rss: 35.00MB, heapUsed: 11.58MB
update_mod 1 1000000
rss: 110.69MB, heapUsed: 80.10MB
update_mod 1 1000000
rss: 187.36MB, heapUsed: 156.52MB
update_mod 1 1000000
rss: 256.28MB, heapUsed: 225.26MB
update_mod 1 1000000
rss: 332.78MB, heapUsed: 301.71MB
update_mod 1 1000000
rss: 401.61MB, heapUsed: 370.38MB
update_mod 1 1000000
rss: 42.67MB, heapUsed: 11.17MB
update_mod 1 1000000
rss: 65.63MB, heapUsed: 34.15MB
update_mod 1 1000000

这里可以发现,clear-module内存趋势呈现波浪形,说明它完美处理了原理一节中提到的旧模块的全部引用,使得热更前的旧模块可以被正常 GC 掉。

经过源代码查阅,发现clear-module确实将父模块对子模块的引用也一并清除:

因此这个例子中热更不会导致进程内存泄露 OOM。

详细代码可以参见:https://github.com/sindresorhus/clear-module/blob/main/index.js#L25-L31

那么是不是认为clear-module就可以高枕无忧没有内存烦恼了呢?

其实不然,我们接着对上面的index.js进行一些小小的改造:

'use strict';

const cleanCache = require('clear-module');

let mod = require('./update_mod.js');
mod();
mod(); require('./utils.js'); setInterval(() => {
cleanCache('./update_mod.js');
mod = require('./update_mod.js');
mod();
}, 100);

对比之前新增了一个utils.js,它的逻辑相当简单:

'use strict';

require('./update_mod.js')

setInterval(() => require('./update_mod.js'), 100); 

对应的场景其实就是index.js中清理掉update_mod.js后,同样使用到的这个模块的utils.js也重新进行require引入保持使用最新的热更模块逻辑。

继续执行node index.js文件,可以看到这次又出现内存迅速溢出的现象:

update_mod 1 1000000
update_mod 2 1000000
rss: 34.59MB, heapUsed: 11.51MB
update_mod 1 1000000
rss: 110.20MB, heapUsed: 80.09MB
update_mod 1 1000000
... rss: 921.63MB, heapUsed: 888.99MB
update_mod 1 1000000
rss: 998.09MB, heapUsed: 965.12MB
update_mod 1 1000000
update_mod 1 1000000 <--- Last few GCs ---> [53359:0x140008000] 13785 ms: Scavenge 1018.5 (1025.1) -> 1018.5 (1029.1) MB, 2.2 / 0.0 ms (average mu = 0.785, current mu = 0.635) allocation failure
[53359:0x140008000] 14344 ms: Mark-sweep (reduce) 1026.1 (1036.8) -> 1025.9 (1029.3) MB, 462.2 / 0.0 ms (+ 87.7 ms in 89 steps since start of marking, biggest step 7.5 ms, walltime since start of marking 559 ms) (average mu = 0.667, current mu = 0.296 <--- JS stacktrace ---> FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

继续抓取堆快照进行分析:

这次是在Module@37543children数组下有大量重复的热更模块upload_mod.js导致了内存泄露,我们来看下Module@37543的详细信息:

是不是感觉很奇怪,clear-module明明清理掉了父模块对热更子模块的引用(反应到这个例子中是index.js这个父模块),但是utils.js里面却还保留了这么多旧引用呢?

其实这里是因为,Node.js 的模块实现机制里,子模块和父模块其实本质上是多对多的关系,而又因为模块缓存的机制,子模块仅会在第一次被加载的时候执行构造函数初始化:

这样就意味着,clear-module里所谓的去掉父模块对热更模块的旧引用仅仅是第一次引入热更模块对应的这个父模块,在这个例子中就是index.js,所以index.js对应的children数组是干净的。

utils.js作为父模块引入热更模块时,读取的是热更模块最新版本的缓存,更新children引用:

它会去判断这个缓存对象在children数组中不存在的话则加入进去,显然热更前后两次编译update_mod.js得到的内存对象不是同一个,因此在utils.js中产生了泄露。

至此在稍微复杂的点逻辑下,clear-module也败下阵来,考虑到实际开发中的逻辑负载度会比这个高很多,显然在生产中使用热更新,除非作者对模块机制掌控十分透彻,否则还是在给自己给后人挖坑。

留一个有趣的思考:clear-module在这种场景下的泄露也并非无解,有兴趣的同学可以参照原理思考下如何来规避在此场景下的热更内存泄露。

参考:

lodash

可能有同学会觉得上面这个例子还不够典型,我们来看一个开发者完全无法控制的非幂等子依赖模块因为热更而导致重复加载产生的内存泄露案例。

这里也不去为了构造内存泄露特意去找很偏门的包,我们就以周下载量高达 3900w 的非常常用的工具模块  lodash 为例,继续修改我们的 uploda_mod.js:

'use strict';

const lodash = require('lodash');
let count = 0;
module.exports = () => {
console.log('update_mod', ++count);
};

接着在 index.js 中去掉上面的 utils.js,保持只对 update_mod.js 进行重复热更:

'use strict';

const cleanCache = require('clear-module');

let mod = require('./update_mod.js');
mod();
mod(); setInterval(() => {
cleanCache('./update_mod.js');
mod = require('./update_mod.js');
mod();
}, 10); function printMemory() {
const { rss, heapUsed } = process.memoryUsage();
console.log(`rss: ${(rss / 1024 / 1024).toFixed(2)}MB, heapUsed: ${(heapUsed / 1024 / 1024).toFixed(2)}MB`);
} printMemory();
setInterval(printMemory, 1000);

然后执行 node index.js 文件,可以看到这次又双叕泄露了,随着 update_mod.js 热更,堆内存迅速上升最后 OOM。

在这个案例中,非幂等执行的子模块产生泄露的原因稍微复杂一些,涉及到 lodash 模块重复编译执行会造成闭包循环引用。

其实会发现,引入模块对开发者是不可控的,换句话说开发者是无法确认自己是否引入了可以幂等执行的公共模块,那么对于像 lodash 这种无法幂等执行的库,热更就会造成其产生内存泄露。

问题二:资源泄露

讲完了热更可能引发的内存问题场景,我们来看看热更会导致的另一类相对更加无解一些资源泄露问题。

我们依旧以简单的例子来进行说明,首先还是构造index.js

'use strict';

const cleanCache = require('clear-module');

let mod = require('./update_mod.js');

setInterval(() => {
cleanCache('./update_mod.js');
mod = require('./update_mod.js');
console.log('-------- 热更新结束 --------')
}, 1000);

这次我们直接使用clear-module进行热更新操作,引入待热更模块update_mod.js如下:

'use strict';

const start = new Date().toLocaleString();

setInterval(() => console.log(start), 1000);

update_mod.js中我们创建了一个定时任务,以 1s 的间隔输出模块第一次被引入时的时间。

最后执行node index.js可以看到如下结果:

2022/1/21 上午9:37:29
-------- 热更新结束 --------
2022/1/21 上午9:37:29
2022/1/21 上午9:37:30
-------- 热更新结束 --------
2022/1/21 上午9:37:29
2022/1/21 上午9:37:30
2022/1/21 上午9:37:31
-------- 热更新结束 --------
2022/1/21 上午9:37:29
2022/1/21 上午9:37:30
2022/1/21 上午9:37:31
2022/1/21 上午9:37:32
-------- 热更新结束 --------
2022/1/21 上午9:37:29
2022/1/21 上午9:37:30
2022/1/21 上午9:37:31
2022/1/21 上午9:37:32
2022/1/21 上午9:37:33
-------- 热更新结束 --------
2022/1/21 上午9:37:29
2022/1/21 上午9:37:30
2022/1/21 上午9:37:31
2022/1/21 上午9:37:32
2022/1/21 上午9:37:33
2022/1/21 上午9:37:34

显然,clear-module虽然正确清除了热更模块旧引用,但是旧模块内部的定时任务并没有被一起回收进而产生了资源泄露。

实际上,这里的定时任务只是资源中的一种而已,包括socketfd在内的各种系统资源操作,均无法在仅仅清除掉旧模块引用的场景下自动回收。

问题三:ESM 喵喵喵?

不管是decache还是clear-module,都是在 Node.js 实现的 CommonJS 模块机制的基础上进行的热更逻辑整合。

但是整个前端发展到今天,原生 ECMA 规范定义的模块机制为 ESModule(简称 ESM),因为是规范定义的,所以其实现是在引擎层面,对应到 Node.js 这一层则是由 V8 实现的,因此目前的热更无法作用于 ESM 模块。

不过在我看来,基于 CommonJS 的热更因为实现在更加上层,会暗藏各种坑所以非常不推荐在生产中使用,但是基于 ESM 的热更如果规范能定义完整的模块加载和卸载机制,反而是真正的热更新方案的未来。

Node.js 在这一块也有对应的实验特性可以加以利用,详情参见:ESM Hooks。(https://nodejs.org/dist/latest/docs/api/esm.html#esm_hooks)不过目前其仅处于 Stability: 1 的状态,需要持续观望下。

问题四:模块版本混乱

Node.js 的热更新实际上并不是很多同学想象中的那种全局旧模块替换,因为缓存机制可能会导致内存中同时存在多个被热更模块的不同版本,从而造成一些难以定位的奇怪 Bug。

我们继续构造一个小例子来进行说明,首先编写待热更模块update_mod.js

'use strict';

const version = 'v1';

module.exports = () => {
return version;
};

然后添加一个utils.js来正常使用此模块:

'use strict';

const mod = require('./update_mod.js');

setInterval(() => console.log('utils', mod()), 1000);

接着编写启动入口index.js进行热更新操作:

'use strict';

const cleanCache = require('clear-module');

let mod = require('./update_mod.js');

require('./utils.js');

setInterval(() => {
cleanCache('./update_mod.js');
mod = require('./update_mod.js');
console.log('index', mod())
}, 1000);

此时当我们执行node index.js且不更改update_mod.js时可以看到:

utils v1
index v1
utils v1
index v1

说明内存中的update_mod.js都是v1版本。

无需重启刚才的服务,我们修改update_mod.js中的version

// update_mod.js
const version = 'v2';

接着观察到输出变成了:

index v1
utils v1
index v2
utils v1
index v2
utils v1

index.js中进行了热更新操作,因此它重新require到的update_mod.js变成了最新的v2版本,而utils.js中并不会有任何变化。

类似这种一个模块多个版本的状况,不仅会增加线上故障的问题定位难度,某种程度上,它也造成了内存泄露。

适合热更新的场景

抛开场景谈问题都是耍流氓,虽然写了这么多热更新存在的问题,但是确实也有非常模块热更新的使用场景,我们从线上和线下两个维度来探讨下。

对于线下场景,轻微的内存和资源的泄露问题可以让位于开发效率,所以热更新非常适合于框架在 dev 模式下的单模块加载与卸载。

而对于线上场景,热更新也并非一无用处,比如明确父子依赖一对一且不创建资源属性的内聚逻辑模块,可以通过合适的代码组织来进行热插拔,达到无缝发布更新的目的。

最后总的来说,因为不熟悉而给应用下毒的风险与热更的收益,就目前我个人还是比较反对将热更新技术用户线上的生产环境中;而如果后面对 ESM 模块的加载与卸载机制能明确下沉至规范由引擎实现,可能才是热更新真正可以广泛和安全使用的恰当时机。

一些总结

前几年参与维护 AliNode 的过程中,处理了多起热更新引起的内存泄露问题,恰好借着编写本文的机会对以前的种种案例进行了回顾。

目前实现热更新的模块其实都可以归结到 “黑魔法” 一类中,与 “黑科技” 相比,“黑魔法” 是一把双刃剑,使用之前还需要谨慎切勿伤到自己。

原文链接;http://click.aliyun.com/m/1000348406/

本文为阿里云原创内容,未经允许不得转载。

浅谈 Node.js 热更新的更多相关文章

  1. 【转】浅谈Node.js单线程模型

    Node.js采用 事件驱动 和 异步I/O 的方式,实现了一个单线程.高并发的运行时环境,而单线程就意味着同一时间只能做一件事,那么Node.js如何利用单线程来实现高并发和异步I/O?本文将围绕这 ...

  2. 【第三周读书笔记】浅谈node.js中的异步回调和用js-xlsx操作Excel表格

    在初步学习了node.js之后,我发现他的时序问题我一直都很模糊不清,所以我专门学习了一下这一块. 首先我们来形象地理解一下进程和线程: 进程:CPU执行任务的模块.线程:模块中的最小单元. 例如:c ...

  3. 浅谈Vue.js

    作为一名Vue.js的忠实用户,我想有必要写点文章来歌颂这一门美好的语言了,我给它的总体评价是“简单却不失优雅,小巧而不乏大匠”,下面将围绕这句话给大家介绍Vue.js,希望能够激发你对Vue.js的 ...

  4. 浅谈Android Studio3.0更新之路(遇坑必入)

    >可以参考官网设置-> 1 2 >> Fantasy_Lin_网友评论原文地址是:简书24K纯帅豆写的我也更新一下出处[删除]Fa 转自脚本之家 浅谈Android Studi ...

  5. Fundebug后端Node.js插件更新至0.2.0,支持监控Express慢请求

    摘要: 性能问题也是BUG,也需要监控. Fundebug后端Node.js异常监控服务 Fundebug是专业的应用异常监控平台,我们Node.js插件fundebug-nodejs可以提供全方位的 ...

  6. 浅尝NODE.js

    Node.js是Google公司开发的,安装好必要的环境以后,可以在服务端上跑的js,可以接收和回应http请求,所有方法都支持异步回调,大大提高事务执行效率. 学习地址:http://www.run ...

  7. Node.js热部署代码,实现修改代码后自动重启服务方便实时调试

    写PHP等脚本语言的时候,已经习惯了修改完代码直接打开浏览器去查看最新的效果.而Node.js 只有在第一次引用时才会去解析脚本文件,以后都会直接访问内存,避免重复载入,这种设计虽然有利于提高性能,却 ...

  8. 闲聊——浅谈前端js模块化演变

    function时代 前端这几年发展太快了,我学习的速度都跟不上演变的速度了(门派太多了,后台都是大牛公司支撑类似于facebook的react.google的angular,angular的1.0还 ...

  9. Vue 浅谈前端js框架vue

    Vue Vue近几年来特别的受关注,三年前的时候angularJS霸占前端JS框架市场很长时间,接着react框架横空出世,因为它有一个特性是虚拟DOM,从性能上碾轧angularJS,这个时候,vu ...

  10. 浅谈Node中的模块化

    关于这篇文章早在去年年初的时候我就想写一片关于模块化的文章,但是推到现在才来完成也有很多好处,巩固之前对Node的理解.毕竟在我目前的项目中还没有一款项目是用到了Node开发,所以导致我对Node的一 ...

随机推荐

  1. 3DCAT实时云渲染助力广府庙会元宇宙焕新亮相,开启线上奇趣之旅!

    超 400 万人次打卡,商圈营业额逾 3.6 亿元,2023 年广府庙会于2023年2月11日圆满落幕. 活动期间,佳境美如画,融合VR.AR.虚拟直播等技术的广府庙会元宇宙焕新亮相,群众只需点击一个 ...

  2. 开源推荐|简洁且强大的开源堡垒机OneTerm

    在运维的日常工作中,登陆服务器操作不可避免,为了更安全的管控服务器,但凡有点规模的公司都会上线堡垒机系统,堡垒机能够做到事前授权.事中监控.事后审计,同时也可以满足等保合规要求.提到堡垒机,大伙第一时 ...

  3. linux介绍、安装、shell

    1-Linux发展介绍 零 什么是Linux Linux:和我们常见的Windows一样,都是操作系统,但不同的是: Windows: 收费,不开源,主要用于日常办公.游戏.娱乐多一些. Linux: ...

  4. Java SE 22 新增特性

    Java SE 22 新增特性 作者:Grey 原文地址: 博客园:Java SE 22 新增特性 CSDN:Java SE 22 新增特性 源码 源仓库: Github:java_new_featu ...

  5. .NET开源免费的Windows快速文件搜索和应用程序启动器

    前言 今天大姚给大家分享一款.NET开源(MIT License).免费.功能强大的Windows快速文件搜索和应用程序启动器:Flow Launcher. 工具介绍 Flow Launcher 是一 ...

  6. RageFrame学习笔记:创建路由+导入layui

    这是我写的学习RageFrame的第二篇,这一篇给大家分享下我是如何创建路由,导入外部js,css文件的,这里写下我的全部流程,希望对大家有所帮助. 话不多说,直接开始,在上一章中,我们已经把项目实例 ...

  7. Java面试题【3】

    20)什么是线程安全? 含义:当多个线程访问某个方法时,不管你通过怎样的调用方式或者说这些线程如何交替的执行,我们在主程序中不需要去做任何的同步,这个类的结果行为都是我们设想的正确行为,那么我们就可以 ...

  8. C#添加自定义控件

    1.vs 控件工具箱添加选项卡 2.输入选项卡名称 我这里是Emgucv 3.点击选择项 4.点击浏览 找到Emgu.CV.Platform.NetFramework.dll 这是emgucv的C#控 ...

  9. Spring Boot 2.X系列教程:七天从无到有掌握Spring Boot-持续更新

    目录 简介 Spring Boot的基本操作 Spring Boot的构建和部署 Spring Boot工具 Spring Boot的测试 Spring Boot中使用JPA Spring Boot和 ...

  10. 【直播预告】HarmonyOS极客松赋能直播第四期:HarmonyOS开发经验分享