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

图片.png

图片.png

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

其实晚期从 Php-fpm / Fast-cgi 转过来的 Noder,必定十分喜爱这种更新业务逻辑代码无需重启服务器即可失效的部署计划,它的劣势也非常明显:

无需重启服务意味着用户连贯不会中断,尤其对于大量长链接 hold 的利用
文件更新加载缓存是一个十分快的过程,能够实现毫秒级别的利用更新
热更新的副作用也十分多,比方常见的内存泄露(资源泄露),本文将以 clear-module 和 decache 这两个下载量比拟高的热门热更辅助模块来探讨下热更到底会给咱们的利用带来哪些问题。

热更实现原理

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

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

image.gif图片.png

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

判断子模块 B 缓存是否存在
如果不存在则对 B 进行编译解析
增加 B 模块缓存至require.cache(其中 key 为模块 B 的全门路)
增加 B 模块援用至父模块 A 的children数组中
如果存在,判断父模块 A 的children数组中是否存在 B,如不存在则增加 B 模块援用。
其实到了这里,咱们曾经能够发现要实现没有内存泄露的热更新,须要断开待热更模块的以下援用链路:

image.gif图片.png

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

实际上,第一节中提到的clear-module和decache两个包都是依照这个思路实现的模块热更,当然它们思考的会更加欠缺一些,比方将子模块 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
抓取堆快照后进行剖析:

image.gif图片.png

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

image.gif图片.png

能够看到其正是入口的index.js。

浏览decache实现源代码后发现,产生泄露的起因则是咱们在热更实现原理一节中提到的要去掉全副的三条援用,而遗憾的是decache依然只断开了最根底的require.cache这一条援用链路:

image.gif图片.png

至此,decache因为最根本的热更内存问题都尚未解决,白瞎了其 94w 的月下载量,能够间接排出咱们的热更计划参考。

参考:

decache 问题源码理论地位:https://github.com/dwyl/decac...

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的确将父模块对子模块的援用也一并革除:

image.gif图片.png

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

具体代码能够参见:https://github.com/sindresorh...

那么是不是认为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
持续抓取堆快照进行剖析:

image.gif图片.png

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

image.gif图片.png

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

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

image.gif图片.png

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

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

image.gif图片.png

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

至此在略微简单的点逻辑下,clear-module也败下阵来,思考到理论开发中的逻辑负载度会比这个高很多,显然在生产中应用热更新,除非作者对模块机制掌控非常透彻,否则还是在给本人给前人挖坑。

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

参考:

设置父模块: https://github.com/nodejs/nod...
更新援用: https://github.com/nodejs/nod...

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尽管正确革除了热更模块旧援用,然而旧模块外部的定时工作并没有被一起回收进而产生了资源泄露。

实际上,这里的定时工作只是资源中的一种而已,包含socket、fd在内的各种系统资源操作,均无奈在仅仅革除掉旧模块援用的场景下主动回收。

问题三:ESM 喵喵喵?

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

然而整个前端倒退到明天,原生 ECMA 标准定义的模块机制为 ESModule(简称 ESM),因为是标准定义的,所以其实现是在引擎层面,对应到 Node.js 这一层则是由 V8 实现的,因而目前的热更无奈作用于 ESM 模块。

不过在我看来,基于 CommonJS 的热更因为实现在更加下层,会暗藏各种坑所以十分不举荐在生产中应用,然而基于 ESM 的热更如果标准能定义残缺的模块加载和卸载机制,反而是真正的热更新计划的将来。

Node.js 在这一块也有对应的试验个性能够加以利用,详情参见:ESM Hooks。(https://nodejs.org/dist/lates...)不过目前其仅处于 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/100...

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