浅谈 Node.js 热更新
大厂技术 高级前端 Node进阶
点击上方 程序员成长指北,关注公众号
回复1,加入高级Node交流群
记得在 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-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
抓取堆快照后进行分析:
很明显Module@39215
的children
数组中大量塞入了重复的热更模块update_mod.js
的编译结果导致了内存泄露,而进一步查看Module@39215
信息:
可以看到其正是入口的index.js
。
阅读decache
实现源代码后发现,产生泄露的原因则是我们在热更实现原理一节中提到的要去掉全部的三条引用,而遗憾的是decache
仍然只断开了最基础的require.cache
这一条引用链路:
至此,decache
由于最基本的热更内存问题都尚未解决,白瞎了其 94w 的月下载量,可以直接排出我们的热更方案参考。
参考:
decache 问题源码实际位置:https://github.com/dwyl/decache/blob/main/decache.js#L35
clear-module
接下来我们看看月下载量为 19w 的clear-module
表现如何。
由于前一小节中的测试代码代表了最基础的模块热更场景,且clear-module
API使用和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@37543
的children
数组下有大量重复的热更模块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
在这种场景下的泄露也并非无解,有兴趣的同学可以参照原理思考下如何来规避在此场景下的热更内存泄露。
参考:
设置父模块: https://github.com/nodejs/node/blob/v16.13.2/lib/internal/modules/cjs/loader.js#L176
更新引用: https://github.com/nodejs/node/blob/v16.13.2/lib/internal/modules/cjs/loader.js#L167
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/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 的过程中,处理了多起热更新引起的内存泄露问题,恰好借着编写本文的机会对以前的种种案例进行了回顾。
目前实现热更新的模块其实都可以归结到 “黑魔法” 一类中,与 “黑科技” 相比,“黑魔法” 是一把双刃剑,使用之前还需要谨慎切勿伤到自己。
Node 社群我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。如果你觉得这篇内容对你有帮助,我想请你帮我2个小忙:1. 点个「在看」,让更多人也能看到这篇文章2. 订阅官方博客 www.inode.club 让我们一起成长点赞和在看就是最大的支持❤️
浅谈 Node.js 热更新相关推荐
- 浅谈 Node.js 热更新,了解几点常见的内存泄漏
大厂技术 高级前端 Node进阶 点击上方 程序员成长指北,关注公众号 回复1,加入高级Node交流群 记得在 15 16 年那会 Node.js 刚起步的时候,我在去前东家的入职面试也被问到了要 ...
- 浅谈 Node.js
Node.js 是什么? Node.js® 是一个开源.跨平台的 JavaScript 运行时环境. 官网:https://nodejs.org/zh-cn 更多精彩内容,请微信搜索"前端爱 ...
- 浅谈Node.js(概念、安装、入门例子)
2019独角兽企业重金招聘Python工程师标准>>> 1.Node.js是什么? 你可以认为它是一个全新的语言,可以用于编写WEB服务端程序或独立的运行进程.但是呢,为了减少你学习 ...
- 浅谈Node.js模块系统
模块是什么?模块用于不同node.js文件相互调用功能.也就是说,一个js文件就可以是一个模块. 1.创建模块 main.js文件: var hello = require('./hello'); h ...
- 浅谈Vue.js的优势
写在前面 今天小梦跟小伙伴们简简单单聊一下Vue.js的优势.小梦也是刚刚接触Vue.js,在学习一门新的技术之前,我们当然要了解其优势,知道优势在哪更加有利于我们去学习并转换为自己的储备. 浅谈Vu ...
- ajax参数中有加号,浅谈在js传递参数中含加号(+)的处理方式
一般情况下,URL 中的参数应使用 url 编码规则,即把参数字符串中除了 -_. 之外的所有非字母数字字符都将被替换成百分号(%)后跟两位十六进制数,空格则编码为加号(+). 但是对于带有中文的参数 ...
- 图灵访谈系列之九:CNode社区谈Node.js技术及生态
Node.js在各种技术会议上的分享越来越火热,为了让更多的人了解Node.js以及Node.js中文技术社区CNode,12月10日在易宝支付举行第三期Node.js北京分享会--NodeParty ...
- [置顶] 图灵访谈系列之九:CNode社区谈Node.js技术及生态
Node.js在各种技术会议上的分享越来越火热,为了让更多的人了解Node.js以及Node.js中文技术社区CNode,12月10日在易宝支付举行第三期Node.js北京分享会--NodeParty ...
- Fundebug后端Node.js插件更新至0.2.0,支持监控Express慢请求
摘要: 性能问题也是BUG,也需要监控. Fundebug后端Node.js异常监控服务 Fundebug是专业的应用异常监控平台,我们Node.js插件fundebug-nodejs可以提供全方位的 ...
最新文章
- DNS基本原理与配置
- 前端模块管理器命令:npm bower
- springcloud 实战 feign使用中遇到的相关问题
- 小程序生成海报 详解
- 机器学习框架ML.NET学习笔记【5】多元分类之手写数字识别(续)
- PHP laravel框架Redis门面的误用
- Windows cmd终端美化:Windows terminal背景图
- Good Bye 2016 //智商再次下线,边界爆炸.....
- FM1288通过串口配置
- C语言:对包含10个整数的数组进行如下的操作,从下标为0的元素开始到最后一个元素,依次向前移动一个位置。
- c语言必背数据结构_严蔚敏数据结构(C语言版)知识点总结笔记课后答案
- tp交换机管理页面_TP-LINK交换机设置细节
- 百度搜索引擎工作原理
- 2022年6月青少年软件编程(图形化) 等级考试试卷(一级)
- 爱普生Epson LQ-680K 打印机驱动
- CAD LISP 施工、规划设计点位的编号注记
- 谷粒商城 高级篇 (七) --------- 性能压测
- android电视 美剧,天天美剧电视tv版apk
- 算法第四版学习(chapter1.3)
- 小微商家大调查:三成是夫妻店,八成受假钞困扰