淺談 Node.js 熱更新

大廠技術(shù) 高級(jí)前端 Node進(jìn)階
點(diǎn)擊上方 程序員成長(zhǎng)指北,關(guān)注公眾號(hào)
回復(fù)1,加入高級(jí)Node交流群
記得在 15 16 年那會(huì) Node.js 剛起步的時(shí)候,我在去前東家的入職面試也被問(wèn)到了要如何實(shí)現(xiàn) Node.js 服務(wù)的熱更新。
其實(shí)早期從 Php-fpm / Fast-cgi 轉(zhuǎn)過(guò)來(lái)的 Noder,肯定非常喜歡這種更新業(yè)務(wù)邏輯代碼無(wú)需重啟服務(wù)器即可生效的部署方案,它的優(yōu)勢(shì)也非常明顯:
-
無(wú)需重啟服務(wù)意味著用戶連接不會(huì)中斷,尤其對(duì)于大量長(zhǎng)鏈接 hold 的應(yīng)用 -
文件更新加載緩存是一個(gè)非常快的過(guò)程,可以完成毫秒級(jí)別的應(yīng)用更新
熱更新的副作用也非常多,比如常見的內(nèi)存泄露(資源泄露),本文將以 clear-module 和 decache 這兩個(gè)下載量比較高的熱門熱更輔助模塊來(lái)探討下熱更究竟會(huì)給我們的應(yīng)用帶來(lái)哪些問(wèn)題。
在開始談熱更新的問(wèn)題之前,我們首先要了解下 Node.js 的模塊機(jī)制的概貌,這樣對(duì)于后面它帶來(lái)的問(wèn)題將能有更加深刻的理解和認(rèn)識(shí)。
Node.js 自己實(shí)現(xiàn)的模塊加載機(jī)制如下圖所示:

簡(jiǎn)單地說(shuō)父模塊 A 引入子模塊 B 的步驟如下:
-
判斷子模塊 B 緩存是否存在 -
如果不存在則對(duì) B 進(jìn)行編譯解析 -
添加 B 模塊緩存至 require.cache(其中 key 為模塊 B 的全路徑) -
添加 B 模塊引用至父模塊 A 的 children數(shù)組中 -
如果存在,判斷父模塊 A 的 children數(shù)組中是否存在 B,如不存在則添加 B 模塊引用。
其實(shí)到了這里,我們已經(jīng)可以發(fā)現(xiàn)要實(shí)現(xiàn)沒(méi)有內(nèi)存泄露的熱更新,需要斷開待熱更模塊的以下引用鏈路:

這樣當(dāng)我們?cè)俅稳?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;color: rgb(30, 107, 184);background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;">require子模塊 B 的時(shí)候,就會(huì)重新從磁盤讀取 B 模塊的內(nèi)容然后進(jìn)行編譯引入內(nèi)存,據(jù)此實(shí)現(xiàn)了熱更的能力。
實(shí)際上,第一節(jié)中提到的clear-module和decache兩個(gè)包都是按照這個(gè)思路實(shí)現(xiàn)的模塊熱更,當(dāng)然它們考慮的會(huì)更加完善一些,比如將子模塊 B 本身的依賴也一并清除,以及對(duì)于循環(huán)引用場(chǎng)景的處理。
那么,借助于這兩個(gè)模塊,Node.js 應(yīng)用的熱更新是不是就完美無(wú)缺了呢?我們接著看。
內(nèi)存泄露是一個(gè)非常有意思的問(wèn)題,凡是進(jìn)入 Node.js 全棧開發(fā)深水區(qū)的同學(xué)基本或多或少都會(huì)遇到內(nèi)存泄露的問(wèn)題,那么從我個(gè)人的故障排查定位經(jīng)驗(yàn)來(lái)說(shuō),開發(fā)者其實(shí)不需要畏懼內(nèi)存泄露,因?yàn)橄啾绕渌恢^腦的問(wèn)題,內(nèi)存泄露是一個(gè)只要你熟悉代碼并且肯花時(shí)間百分百可解的故障類型。
這里我們來(lái)看看看似清除了所有舊模塊引用的熱更方案,又會(huì)以怎樣的形式產(chǎn)生內(nèi)存泄露現(xiàn)象。
decache
考慮構(gòu)造以下熱更例子,先使用decache進(jìn)行測(cè)試:
'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);
這個(gè)例子中相當(dāng)于在不斷清理./update_mod.js這個(gè)模塊的緩存進(jìn)行熱更,它的內(nèi)容如下:
'use strict';
const array = new Array(10e5).fill('*');
let count = 0;
module.exports = () => {
console.log('update_mod', ++count, array.length);
};
為了能快速觀察到內(nèi)存泄露現(xiàn)象,這里構(gòu)造了一個(gè)大數(shù)組來(lái)替代常規(guī)的模塊閉包引用。
為了方便觀察我們可以在index.js中可以添加一個(gè)方法來(lái)定時(shí)打印當(dāng)前的內(nèi)存狀況:
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);
最后執(zhí)行node index.js文件,可以看到內(nèi)存迅速溢出:
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
抓取堆快照后進(jìn)行分析:
很明顯Module@39215的children數(shù)組中大量塞入了重復(fù)的熱更模塊update_mod.js的編譯結(jié)果導(dǎo)致了內(nèi)存泄露,而進(jìn)一步查看Module@39215信息:
可以看到其正是入口的index.js。
閱讀decache實(shí)現(xiàn)源代碼后發(fā)現(xiàn),產(chǎn)生泄露的原因則是我們?cè)跓岣鼘?shí)現(xiàn)原理一節(jié)中提到的要去掉全部的三條引用,而遺憾的是decache仍然只斷開了最基礎(chǔ)的require.cache這一條引用鏈路:
至此,decache由于最基本的熱更內(nèi)存問(wèn)題都尚未解決,白瞎了其 94w 的月下載量,可以直接排出我們的熱更方案參考。
參考:
decache 問(wèn)題源碼實(shí)際位置:https://github.com/dwyl/decache/blob/main/decache.js#L35
clear-module
接下來(lái)我們看看月下載量為 19w 的clear-module表現(xiàn)如何。
由于前一小節(jié)中的測(cè)試代碼代表了最基礎(chǔ)的模塊熱更場(chǎng)景,且clear-moduleAPI使用和decache基本一致,所以我們僅替換cleanCache引用即可進(jìn)行本輪測(cè)試:
// index.js
const cleanCache = require('clear-module');
同樣執(zhí)行node index.js文件,可以看到內(nèi)存變化如下:
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
這里可以發(fā)現(xiàn),clear-module內(nèi)存趨勢(shì)呈現(xiàn)波浪形,說(shuō)明它完美處理了原理一節(jié)中提到的舊模塊的全部引用,使得熱更前的舊模塊可以被正常 GC 掉。
經(jīng)過(guò)源代碼查閱,發(fā)現(xiàn)clear-module確實(shí)將父模塊對(duì)子模塊的引用也一并清除:
因此這個(gè)例子中熱更不會(huì)導(dǎo)致進(jìn)程內(nèi)存泄露 OOM。
詳細(xì)代碼可以參見:https://github.com/sindresorhus/clear-module/blob/main/index.js#L25-L31
那么是不是認(rèn)為clear-module就可以高枕無(wú)憂沒(méi)有內(nèi)存煩惱了呢?
其實(shí)不然,我們接著對(duì)上面的index.js進(jìn)行一些小小的改造:
'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);
對(duì)比之前新增了一個(gè)utils.js,它的邏輯相當(dāng)簡(jiǎn)單:
'use strict';
require('./update_mod.js')
setInterval(() => require('./update_mod.js'), 100);
對(duì)應(yīng)的場(chǎng)景其實(shí)就是index.js中清理掉update_mod.js后,同樣使用到的這個(gè)模塊的utils.js也重新進(jìn)行require引入保持使用最新的熱更模塊邏輯。
繼續(xù)執(zhí)行node index.js文件,可以看到這次又出現(xiàn)內(nèi)存迅速溢出的現(xiàn)象:
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
繼續(xù)抓取堆快照進(jìn)行分析:

這次是在Module@37543的children數(shù)組下有大量重復(fù)的熱更模塊upload_mod.js導(dǎo)致了內(nèi)存泄露,我們來(lái)看下Module@37543的詳細(xì)信息:

是不是感覺(jué)很奇怪,clear-module明明清理掉了父模塊對(duì)熱更子模塊的引用(反應(yīng)到這個(gè)例子中是index.js這個(gè)父模塊),但是utils.js里面卻還保留了這么多舊引用呢?
其實(shí)這里是因?yàn)?,Node.js 的模塊實(shí)現(xiàn)機(jī)制里,子模塊和父模塊其實(shí)本質(zhì)上是多對(duì)多的關(guān)系,而又因?yàn)槟K緩存的機(jī)制,子模塊僅會(huì)在第一次被加載的時(shí)候執(zhí)行構(gòu)造函數(shù)初始化:

這樣就意味著,clear-module里所謂的去掉父模塊對(duì)熱更模塊的舊引用僅僅是第一次引入熱更模塊對(duì)應(yīng)的這個(gè)父模塊,在這個(gè)例子中就是index.js,所以index.js對(duì)應(yīng)的children數(shù)組是干凈的。
而utils.js作為父模塊引入熱更模塊時(shí),讀取的是熱更模塊最新版本的緩存,更新children引用:

它會(huì)去判斷這個(gè)緩存對(duì)象在children數(shù)組中不存在的話則加入進(jìn)去,顯然熱更前后兩次編譯update_mod.js得到的內(nèi)存對(duì)象不是同一個(gè),因此在utils.js中產(chǎn)生了泄露。
至此在稍微復(fù)雜的點(diǎn)邏輯下,clear-module也敗下陣來(lái),考慮到實(shí)際開發(fā)中的邏輯負(fù)載度會(huì)比這個(gè)高很多,顯然在生產(chǎn)中使用熱更新,除非作者對(duì)模塊機(jī)制掌控十分透徹,否則還是在給自己給后人挖坑。
留一個(gè)有趣的思考:clear-module在這種場(chǎng)景下的泄露也并非無(wú)解,有興趣的同學(xué)可以參照原理思考下如何來(lái)規(guī)避在此場(chǎng)景下的熱更內(nèi)存泄露。
參考:
設(shè)置父模塊: 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
可能有同學(xué)會(huì)覺(jué)得上面這個(gè)例子還不夠典型,我們來(lái)看一個(gè)開發(fā)者完全無(wú)法控制的非冪等子依賴模塊因?yàn)闊岣鴮?dǎo)致重復(fù)加載產(chǎn)生的內(nèi)存泄露案例。
這里也不去為了構(gòu)造內(nèi)存泄露特意去找很偏門的包,我們就以周下載量高達(dá) 3900w 的非常常用的工具模塊 lodash 為例,繼續(xù)修改我們的 uploda_mod.js:
'use strict';
const lodash = require('lodash');
let count = 0;
module.exports = () => {
console.log('update_mod', ++count);
};
接著在 index.js 中去掉上面的 utils.js,保持只對(duì) update_mod.js 進(jìn)行重復(fù)熱更:
'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);
然后執(zhí)行 node index.js 文件,可以看到這次又雙叕泄露了,隨著 update_mod.js 熱更,堆內(nèi)存迅速上升最后 OOM。
在這個(gè)案例中,非冪等執(zhí)行的子模塊產(chǎn)生泄露的原因稍微復(fù)雜一些,涉及到 lodash 模塊重復(fù)編譯執(zhí)行會(huì)造成閉包循環(huán)引用。
其實(shí)會(huì)發(fā)現(xiàn),引入模塊對(duì)開發(fā)者是不可控的,換句話說(shuō)開發(fā)者是無(wú)法確認(rèn)自己是否引入了可以冪等執(zhí)行的公共模塊,那么對(duì)于像 lodash 這種無(wú)法冪等執(zhí)行的庫(kù),熱更就會(huì)造成其產(chǎn)生內(nèi)存泄露。
講完了熱更可能引發(fā)的內(nèi)存問(wèn)題場(chǎng)景,我們來(lái)看看熱更會(huì)導(dǎo)致的另一類相對(duì)更加無(wú)解一些資源泄露問(wèn)題。
我們依舊以簡(jiǎn)單的例子來(lái)進(jìn)行說(shuō)明,首先還是構(gòu)造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('-------- 熱更新結(jié)束 --------')
}, 1000);
這次我們直接使用clear-module進(jìn)行熱更新操作,引入待熱更模塊update_mod.js如下:
'use strict';
const start = new Date().toLocaleString();
setInterval(() => console.log(start), 1000);
在update_mod.js中我們創(chuàng)建了一個(gè)定時(shí)任務(wù),以 1s 的間隔輸出模塊第一次被引入時(shí)的時(shí)間。
最后執(zhí)行node index.js可以看到如下結(jié)果:
2022/1/21 上午9:37:29
-------- 熱更新結(jié)束 --------
2022/1/21 上午9:37:29
2022/1/21 上午9:37:30
-------- 熱更新結(jié)束 --------
2022/1/21 上午9:37:29
2022/1/21 上午9:37:30
2022/1/21 上午9:37:31
-------- 熱更新結(jié)束 --------
2022/1/21 上午9:37:29
2022/1/21 上午9:37:30
2022/1/21 上午9:37:31
2022/1/21 上午9:37:32
-------- 熱更新結(jié)束 --------
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
-------- 熱更新結(jié)束 --------
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雖然正確清除了熱更模塊舊引用,但是舊模塊內(nèi)部的定時(shí)任務(wù)并沒(méi)有被一起回收進(jìn)而產(chǎn)生了資源泄露。
實(shí)際上,這里的定時(shí)任務(wù)只是資源中的一種而已,包括socket、fd在內(nèi)的各種系統(tǒng)資源操作,均無(wú)法在僅僅清除掉舊模塊引用的場(chǎng)景下自動(dòng)回收。
不管是decache還是clear-module,都是在 Node.js 實(shí)現(xiàn)的 CommonJS 模塊機(jī)制的基礎(chǔ)上進(jìn)行的熱更邏輯整合。
但是整個(gè)前端發(fā)展到今天,原生 ECMA 規(guī)范定義的模塊機(jī)制為 ESModule(簡(jiǎn)稱 ESM),因?yàn)槭且?guī)范定義的,所以其實(shí)現(xiàn)是在引擎層面,對(duì)應(yīng)到 Node.js 這一層則是由 V8 實(shí)現(xiàn)的,因此目前的熱更無(wú)法作用于 ESM 模塊。
不過(guò)在我看來(lái),基于 CommonJS 的熱更因?yàn)閷?shí)現(xiàn)在更加上層,會(huì)暗藏各種坑所以非常不推薦在生產(chǎn)中使用,但是基于 ESM 的熱更如果規(guī)范能定義完整的模塊加載和卸載機(jī)制,反而是真正的熱更新方案的未來(lái)。
Node.js 在這一塊也有對(duì)應(yīng)的實(shí)驗(yàn)特性可以加以利用,詳情參見:ESM Hooks。(https://nodejs.org/dist/latest/docs/api/esm.html#esm_hooks)不過(guò)目前其僅處于 Stability: 1 的狀態(tài),需要持續(xù)觀望下。
Node.js 的熱更新實(shí)際上并不是很多同學(xué)想象中的那種全局舊模塊替換,因?yàn)榫彺鏅C(jī)制可能會(huì)導(dǎo)致內(nèi)存中同時(shí)存在多個(gè)被熱更模塊的不同版本,從而造成一些難以定位的奇怪 Bug。
我們繼續(xù)構(gòu)造一個(gè)小例子來(lái)進(jìn)行說(shuō)明,首先編寫待熱更模塊update_mod.js:
'use strict';
const version = 'v1';
module.exports = () => {
return version;
};
然后添加一個(gè)utils.js來(lái)正常使用此模塊:
'use strict';
const mod = require('./update_mod.js');
setInterval(() => console.log('utils', mod()), 1000);
接著編寫啟動(dòng)入口index.js進(jìn)行熱更新操作:
'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);
此時(shí)當(dāng)我們執(zhí)行node index.js且不更改update_mod.js時(shí)可以看到:
utils v1
index v1
utils v1
index v1
說(shuō)明內(nèi)存中的update_mod.js都是v1版本。
無(wú)需重啟剛才的服務(wù),我們修改update_mod.js中的version:
// update_mod.js
const version = 'v2';
接著觀察到輸出變成了:
index v1
utils v1
index v2
utils v1
index v2
utils v1
index.js中進(jìn)行了熱更新操作,因此它重新require到的update_mod.js變成了最新的v2版本,而utils.js中并不會(huì)有任何變化。
類似這種一個(gè)模塊多個(gè)版本的狀況,不僅會(huì)增加線上故障的問(wèn)題定位難度,某種程度上,它也造成了內(nèi)存泄露。
拋開場(chǎng)景談問(wèn)題都是耍流氓,雖然寫了這么多熱更新存在的問(wèn)題,但是確實(shí)也有非常模塊熱更新的使用場(chǎng)景,我們從線上和線下兩個(gè)維度來(lái)探討下。
對(duì)于線下場(chǎng)景,輕微的內(nèi)存和資源的泄露問(wèn)題可以讓位于開發(fā)效率,所以熱更新非常適合于框架在 dev 模式下的單模塊加載與卸載。
而對(duì)于線上場(chǎng)景,熱更新也并非一無(wú)用處,比如明確父子依賴一對(duì)一且不創(chuàng)建資源屬性的內(nèi)聚邏輯模塊,可以通過(guò)合適的代碼組織來(lái)進(jìn)行熱插拔,達(dá)到無(wú)縫發(fā)布更新的目的。
最后總的來(lái)說(shuō),因?yàn)椴皇煜ざo應(yīng)用下毒的風(fēng)險(xiǎn)與熱更的收益,就目前我個(gè)人還是比較反對(duì)將熱更新技術(shù)用戶線上的生產(chǎn)環(huán)境中;而如果后面對(duì) ESM 模塊的加載與卸載機(jī)制能明確下沉至規(guī)范由引擎實(shí)現(xiàn),可能才是熱更新真正可以廣泛和安全使用的恰當(dāng)時(shí)機(jī)。
前幾年參與維護(hù) AliNode 的過(guò)程中,處理了多起熱更新引起的內(nèi)存泄露問(wèn)題,恰好借著編寫本文的機(jī)會(huì)對(duì)以前的種種案例進(jìn)行了回顧。
目前實(shí)現(xiàn)熱更新的模塊其實(shí)都可以歸結(jié)到 “黑魔法” 一類中,與 “黑科技” 相比,“黑魔法” 是一把雙刃劍,使用之前還需要謹(jǐn)慎切勿傷到自己。
Node 社群
我組建了一個(gè)氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對(duì)Node.js學(xué)習(xí)感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。
如果你覺(jué)得這篇內(nèi)容對(duì)你有幫助,我想請(qǐng)你幫我2個(gè)小忙:
1. 點(diǎn)個(gè)「在看」,讓更多人也能看到這篇文章
2. 訂閱官方博客 www.inode.club 讓我們一起成長(zhǎng)
點(diǎn)贊和在看就是最大的支持??
