存溢出排查與調(diào)優(yōu):從V8堆內(nèi)存到--max-old-space-size配置)
做Node.js開發(fā)久了幾乎每個人都會撞上這個報錯——FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory。我第一次看到這個紅字是在一個跑了十幾小時的數(shù)據(jù)處理腳本上當(dāng)時第一反應(yīng)是服務(wù)器內(nèi)存不夠第二反應(yīng)是代碼寫崩了結(jié)果查了一圈發(fā)現(xiàn)問題根本不是這兩件事。這個錯誤的本質(zhì)是Node.js默認(rèn)的舊空間堆內(nèi)存上限太低而官方給出的--max-old-space-size參數(shù)如果你只是臨時在命令行里加一次那等于沒配。這篇就結(jié)合我自己的踩坑過程把這個錯誤的成因、排查鏈路和真正能一勞永逸的配置方案從頭到尾捋一遍。1. 先搞明白“JavaScript heap out of memory”到底在說什么1.1 V8引擎的內(nèi)存模型不是“內(nèi)存不夠”而是“額度不夠”很多人看到out of memory就跑去加服務(wù)器內(nèi)存這其實是在錯誤的方向上使勁。Node.js運(yùn)行JavaScript代碼靠的是V8引擎而V8管理內(nèi)存的方式不是“用多少取多少”而是預(yù)先劃分出幾塊區(qū)域其中**老生代old space**負(fù)責(zé)存放存活時間較長的對象**新生代new space**負(fù)責(zé)存放短命對象。我們平時說的“堆內(nèi)存不足”絕大多數(shù)情況都指向老生代。V8給老生代設(shè)置了一個默認(rèn)上限64位系統(tǒng)下大約是1.4GB到2GB之間具體數(shù)值跟Node版本有關(guān)。如果你用node直接跑一個腳本這個腳本需要的內(nèi)存超過這個閾值V8就會反復(fù)嘗試?yán)厥栈厥胀炅诉€是不夠最終拋出一個致命錯誤然后直接崩潰。這里有個關(guān)鍵點這個上限是V8內(nèi)部的虛擬內(nèi)存額度不是操作系統(tǒng)的物理內(nèi)存上限。也就是說哪怕你的服務(wù)器有64GB內(nèi)存Node默認(rèn)也只肯用那1.4GB左右。這解釋了為什么很多人在自己的開發(fā)機(jī)上沒出過問題一部署到高配服務(wù)器上反而崩了——因為開發(fā)機(jī)的數(shù)據(jù)集小閾值沒被觸碰服務(wù)器的數(shù)據(jù)集大內(nèi)存額度一下子就撞頂了。1.2 同樣的報錯兩種完全不同的觸發(fā)場景我在排查過程中發(fā)現(xiàn)遇到這個錯誤的人大致分成兩類處理方式截然不同。第一類是“單次大任務(wù)”型。比如用Node做PDF合并、圖片批量處理、大型JSON解析、Webpack構(gòu)建前端項目。這類場景的特點是某一個時刻需要一次性分配大量內(nèi)存內(nèi)存峰值極高但代碼本身沒有內(nèi)存泄漏。Webpack打包大型項目報這個錯99%屬于這種。這種場景的解法很直接把堆內(nèi)存上限調(diào)大就行。第二類是“緩慢增長”型。腳本運(yùn)行初期一切正常跑幾小時后內(nèi)存占用緩慢上升最終在某次內(nèi)存分配時崩潰。這種場景通常是代碼里存在內(nèi)存泄漏——全局變量不斷堆積、閉包意外持有大對象、事件監(jiān)聽器持續(xù)綁定但沒有移除等等。只調(diào)大內(nèi)存上限是治標(biāo)不治本相當(dāng)于給漏水的水桶加了一個更大的水槽漏水速度沒變只是延遲了崩潰時間。判斷自己屬于哪一類有個很簡單的辦法看崩潰前內(nèi)存漲得多快。如果是幾秒內(nèi)瞬間沖頂多半是第一類如果是勻速爬升跑幾小時才爆那基本是第二類。后面我會展開講兩類場景的具體處理思路。2. 最直接的臨時方案--max-old-space-size參數(shù)怎么用才不出錯2.1 命令行的正確姿勢先給新手朋友演示最基礎(chǔ)的臨時方案。假設(shè)你的程序入口文件是app.js需要把堆內(nèi)存上限調(diào)到4GB命令應(yīng)該這樣寫node --max-old-space-size4096 app.js注意幾個容易踩的細(xì)節(jié)參數(shù)是在node和腳本文件名之間不是放在文件名后面。寫成node app.js --max-old-space-size4096是不生效的因為你把參數(shù)傳給了程序而不是Node運(yùn)行時。單位是MB不是GB。4096代表4GB8192代表8GB很多人一上來就寫--max-old-space-size4結(jié)果堆上限變成了4MB啟動即崩潰。64位系統(tǒng)和32位系統(tǒng)的可用上限不同32位老生代上限基本就在1GB上下硬調(diào)大也沒有意義?,F(xiàn)在開發(fā)環(huán)境基本全是64位但如果你在老的CI鏡像里跑需要注意這個問題。如果是通過npm腳本運(yùn)行比如npm run dev那就在package.json的scripts里寫好{ scripts: { start: node --max-old-space-size4096 app.js, build: node --max-old-space-size8192 build.js } }這種改法生效最快垮一秒鐘搞定而且只影響這一個腳本不影響系統(tǒng)上其他的Node進(jìn)程適合拿來應(yīng)急驗證“調(diào)大之后到底能不能跑通”。2.2 系統(tǒng)環(huán)境變量的全局方案如果你跑的Node程序不是你直接啟動的而是被某個進(jìn)程管理器比如PM2拉起來的或者你希望整個系統(tǒng)上所有Node進(jìn)程都默認(rèn)擁有更大的堆內(nèi)存那就需要通過環(huán)境變量NODE_OPTIONS來全局注入。Linux和macOS在~/.bashrc或~/.zshrc里加上export NODE_OPTIONS--max-old-space-size4096Windows系統(tǒng)在“系統(tǒng)屬性 - 環(huán)境變量 - 新建”里添加用戶變量或系統(tǒng)變量變量名NODE_OPTIONS變量值--max-old-space-size4096。配置完后記得重開終端或者執(zhí)行source ~/.bashrc否則當(dāng)前會話不會加載新配置。注意NODE_OPTIONS不僅會影響老生代大小它可以承載很多V8選項但注意它不支持一些不安全或有副作用的標(biāo)志比如--inspect相關(guān)。另外它會影響這臺機(jī)器上所有Node進(jìn)程如果同一臺機(jī)器上跑著多個服務(wù)每個都默認(rèn)4GB內(nèi)存壓力會明顯增大需要掂量一下再設(shè)。這個方案雖然能全局生效但嚴(yán)格來說還只是“環(huán)境級”的換一臺機(jī)器、換一個部署環(huán)境配置就沒了。真正能跟著項目走的、在不同環(huán)境里自動生效的是下面要講的永久配置方案。3. 永久配置方案讓配置跟著項目走而不是跟著機(jī)器走3.1 方案一在package.json里固化scripts適合大多數(shù)項目這是我最推薦的做法因為只要項目代碼在配置就在。把可能觸發(fā)內(nèi)存峰值的操作全部顯式加參數(shù)。假設(shè)項目里有這樣幾個常用操作對應(yīng)的寫法如下{ scripts: { dev: node --max-old-space-size4096 server.js, build: node --max-old-space-size8192 build.js, test: node --max-old-space-size2048 node_modules/.bin/jest } }這里有一個項目實踐中容易忽略的坑如果你通過npx jest跑測試npx本身會創(chuàng)建一個新的Node進(jìn)程而npx jest的寫法不一定能帶上你配置的--max-old-space-size。穩(wěn)妥的做法是像上面這樣直接調(diào)用node_modules/.bin/jest的完整路徑讓參數(shù)明確落在最終執(zhí)行的那個Node進(jìn)程上。用Webpack打包的讀者可能還會遇到一個問題Webpack 5有自己的構(gòu)建緩存和多進(jìn)程壓縮機(jī)制光調(diào)大Node堆內(nèi)存不一定能徹底解決OOM。這時候還需要在webpack.config.js里把performance.hints調(diào)低以及考慮用terser-webpack-plugin的并行壓縮配置來降低單進(jìn)程內(nèi)存峰值。這個是給“構(gòu)建類”項目額外提醒一句后面測試部分我不會再展開因為那取決于具體的項目技術(shù)棧。3.2 方案二寫一個.node-opts配置文件配合啟動腳本適合部署環(huán)境如果你的項目不是靠package.json啟動的或者你們的生產(chǎn)環(huán)境有一套自己的發(fā)布系統(tǒng)我建議把配置收斂到一個統(tǒng)一的啟動腳本里。我自己的做法是在項目根目錄放一個start.sh#!/bin/bash export NODE_OPTIONS--max-old-space-size6144 exec node server.js $然后給腳本執(zhí)行權(quán)限chmod x start.sh部署系統(tǒng)直接執(zhí)行./start.sh就行。這樣做的好處是發(fā)布系統(tǒng)不用關(guān)心Node內(nèi)存參數(shù)所有運(yùn)行時調(diào)優(yōu)細(xì)節(jié)都打包在項目代碼里。每個團(tuán)隊成員或CI機(jī)器執(zhí)行同樣的命令得到同樣的行為不會出現(xiàn)“我本地沒問題服務(wù)器上崩了”的情況。Windows環(huán)境沒有bash可以用start.cmdecho off set NODE_OPTIONS--max-old-space-size6144 node server.js %*兩種腳本一放跨平臺部署也統(tǒng)一了。3.3 方案三Docker容器里通過ENV注入適合容器化部署如果你們的服務(wù)是Docker化的那最干凈的方式是在Dockerfile里寫死FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm install --production COPY . . ENV NODE_OPTIONS--max-old-space-size6144 CMD [node, server.js]注意ENV放在CMD之前生效這樣容器啟動時所有Node進(jìn)程都會帶上這個參數(shù)。如果你們用docker-compose也可以在compose文件里通過environment字段設(shè)置效果一樣。這里提醒一個容器環(huán)境的專項問題容器里的Node進(jìn)程要注意--max-old-space-size的數(shù)值不能超過容器的內(nèi)存上限否則會被OOM Killer殺掉。比如容器只分配了2GB內(nèi)存但你給Node堆設(shè)了4GBV8還沒達(dá)到自己的上限容器就先被系統(tǒng)干掉了日志里看到的表現(xiàn)可能是直接Killed而不是“JavaScript heap out of memory”。3.4 方案四PM2進(jìn)程守護(hù)下的Node內(nèi)存配置如果項目是用PM2管理的那就需要在PM2的配置里一并處理。PM2有自己的內(nèi)存重啟機(jī)制跟Node的堆上限是兩回事但經(jīng)常被搞混。ecosystem.config.js里這樣寫module.exports { apps: [ { name: my-app, script: server.js, instances: 1, exec_mode: fork, max_memory_restart: 4G, node_args: --max-old-space-size3584, env: { NODE_OPTIONS: --max-old-space-size3584 } } ] };這個地方我要多說一句初學(xué)者很容易混淆三個概念max_memory_restart是PM2層面監(jiān)控的進(jìn)程物理內(nèi)存上限超過了它PM2會強(qiáng)制重啟進(jìn)程這里的單位是G/M。--max-old-space-size是V8層面的堆內(nèi)存上限單位是MB。NODE_OPTIONS是環(huán)境變量層面的注入方式跟命令行加參效果等價。如果max_memory_restart: 4G但--max-old-space-size6000PM2會在V8崩潰之前先把進(jìn)程殺掉。實際配置時要讓PM2的監(jiān)控閾值略高于V8的堆上限留出余量給非堆內(nèi)存部分比如原生綁定、緩沖區(qū)分區(qū)等我通常建議堆上限設(shè)為物理內(nèi)存的70%到80%。4. 比改配置更重要的定位“為什么會內(nèi)存暴漲”4.1 用--trace-gc看垃圾回收日志僅僅調(diào)大堆上限解決了崩潰的“表象”但如果你不搞明白內(nèi)存為什么漲到那個水平遲早會在更大的數(shù)據(jù)量面前再崩一次。排查內(nèi)存問題的第一個利器是V8自帶的GC跟蹤node --trace-gc app.js運(yùn)行后控制臺會不斷刷出類似這樣的日志[37512:0x559e2acb8000] 1024001 ms: Scavenge 1824.5 (1840.2) - 1200.3 (1840.2) MB, 15.2 / 0.0 ms (average mu 0.733, current mu 0.7) allocation failure [37512:0x559e2acb8000] 1024502 ms: Mark-sweep 1824.5 (1840.2) - 1600.3 (1840.2) MB, 200.2 / 0.0 ms (average mu 0.732, current mu 0.7) allocation failure注意日志里的allocation failure它表明GC是因為“內(nèi)存分配失敗”才被迫觸發(fā)的而不是周期性正?;厥铡H绻愕娜罩纠镱l繁出現(xiàn)allocation failure說明堆內(nèi)存已經(jīng)處在“瀕臨崩潰”的狀態(tài)即使這次沒崩也離崩潰不遠(yuǎn)了。另一個值得關(guān)注的指標(biāo)是Mark-sweep標(biāo)記-清除耗時。如果它從正常的幾十毫秒漲到幾百毫秒甚至幾秒說明堆里長期存活的對象已經(jīng)多到GC都要耗很久才能遍歷完成這往往意味著有對象“該回收的沒被回收”。4.2 用heapdump抓取堆快照定位泄漏點的經(jīng)典手段是生成堆快照然后用Chrome DevTools的Memory面板分析。分兩步。第一步在代碼里埋入快照觸發(fā)點或者用--heapsnapshot-near-heap-limit參數(shù)在堆內(nèi)存逼近上限時自動生成快照node --heapsnapshot-near-heap-limit512 app.js這個參數(shù)的意思是在堆使用率接近上限512MB時嘗試生成一份.heapsnapshot文件。第二步把生成的快照文件拖進(jìn)Chrome DevTools的Memory面板用“Comparison”對照早期快照和崩潰前的快照看哪個構(gòu)造函數(shù)Constructor的實例數(shù)量或Retained Size增長最離譜那基本就是泄漏點所在。我實際排查過一個典型案例一個定時抓取網(wǎng)頁數(shù)據(jù)后入庫的腳本每次抓取都會用全局?jǐn)?shù)組暫存結(jié)果但入庫成功后沒有array.length 0也不是用array []替換導(dǎo)致歷史數(shù)據(jù)全部殘留在內(nèi)存里。這種問題在堆快照里一眼就能看出來——Array構(gòu)造函數(shù)的Retained Size隨快照增長出現(xiàn)了大量增長的“字符串”和“對象”元素。4.3 癥狀相同的兩類問題處理手段完全不同我前文說了這個錯誤分兩類。如果你判斷自己屬于“緩慢增長”型那么單純調(diào)大--max-old-space-size只會推遲崩潰時間不會消除崩潰。正確的做法是在堆快照里找到泄漏源頭把對應(yīng)的引用關(guān)系打斷。常見的泄漏模式我列在下面你可以對著排查全局緩存不設(shè)上限。比如用全局Map做緩存只往里寫從不清理最終內(nèi)存被吃光。解法是給緩存加上LRU淘汰機(jī)制。事件監(jiān)聽器不解除。process.on、第三方庫內(nèi)部的監(jiān)聽器、EventEmitter實例反復(fù)創(chuàng)建導(dǎo)致監(jiān)聽器數(shù)組無限膨脹。閉包意外持有大對象。內(nèi)部函數(shù)被拋出外部長期引用外層作用域里的巨大數(shù)據(jù)就永遠(yuǎn)無法被回收。流式處理沒有監(jiān)聽data的合適消費(fèi)方式。比如把整個文件讀成字符串再做字符串拼接十幾GB的日志用fs.readFile一把梭內(nèi)存直接爆炸。應(yīng)該改用readline逐行處理或者stream管道。如果屬于“單次大任務(wù)”型比如Webpack構(gòu)建、一次性解析超大的JSON文件那調(diào)大堆內(nèi)存就是正確解法不必過于擔(dān)心泄漏問題。5. 實測踩坑我調(diào)大堆內(nèi)存后遇到的三件意料之外的事5.1 調(diào)大堆內(nèi)存反而導(dǎo)致進(jìn)程卡死有一回我把一個數(shù)據(jù)處理腳本的堆上限從默認(rèn)的1.4GB調(diào)到8GB結(jié)果運(yùn)行到某個階段后進(jìn)程突然像死了一樣CPU占用掉到0但進(jìn)程不退出、不報錯、什么都不做。一開始我以為是死鎖查了半天才發(fā)現(xiàn)是**GC停頓stop-the-world**時間過長。V8在做全堆垃圾回收時會暫停JavaScript執(zhí)行。當(dāng)堆內(nèi)存從1.4GB擴(kuò)大到8GB后單次全量GC需要遍歷的對象數(shù)量也隨之增加GC停頓時間從幾十毫秒暴漲到十幾秒。對于頻繁觸發(fā)GC的場景這種長停頓造成的“假死”比OOM更難以察覺因為程序不是說崩就崩而是明明活著卻不干活。解決思路不是把堆調(diào)回小而是在代碼里主動規(guī)避“大批量對象一次性堆積”的模式。比如我那個腳本原本是循環(huán)里不斷拼接一個大數(shù)組最后一次性寫庫改成每處理1000條就寫一次并釋放引用后堆內(nèi)存峰值顯著下降GC頻率和停頓時間都恢復(fù)正常。5.2NODE_OPTIONS里的參數(shù)居然被“忽略”了有次我在一個老項目中配置NODE_OPTIONS--max-old-space-size8192結(jié)果啟動后通過process.memoryUsage()看heapTotal峰值還是只有兩三百M(fèi)B跟沒配一樣。排查半天發(fā)現(xiàn)原來項目啟動時是通過node -r ts-node/register app.ts跑的入口是app.ts但啟動命令里-r的存在并沒有問題。真正的問題出在啟動腳本里有一行process.env.NODE_OPTIONS ;是在第三方庫初始化時被清掉的。這種事不常見但很坑。如果你遇到配了NODE_OPTIONS卻不生效的情況先在代碼里搜索一下有沒有對process.env.NODE_OPTIONS的讀寫操作再確認(rèn)你的啟動鏈路里有沒有中間層進(jìn)程比如nodemon、ts-node、babel-node會重置環(huán)境變量。另一個常見原因是用pm2 start --node-args--max-old-space-size4096啟動多個實例時參數(shù)只應(yīng)用到了其中一個進(jìn)程。PM2的--node-args和ecosystem.config.js里的node_args需要注意優(yōu)先級后者會覆蓋前者如果你都寫了但結(jié)果不對檢查優(yōu)先級是個好方向。5.3 32位Node帶來的“假OOM”在某個老項目中部署鏡像基于32位基礎(chǔ)鏡像構(gòu)建的Node也是32位的。線上頻繁報OOM本地怎么也復(fù)現(xiàn)不了。后來查到了——32位Node的V8堆內(nèi)存上限被限制在1GB左右不管你怎么加--max-old-space-size超過這個數(shù)也不會生效。這種情況唯一的解法是更換64位基礎(chǔ)鏡像。如果你發(fā)現(xiàn)自己加了參數(shù)但process.memoryUsage()里的heapTotal始終在1GB上下波動先看一眼process.arch是不是x64。這個細(xì)節(jié)在現(xiàn)在的新項目里幾乎遇不到但在維護(hù)老系統(tǒng)時很容易被坑到。6. 把內(nèi)存監(jiān)控做成常規(guī)活別等爆了才處理6.1 在應(yīng)用里暴露內(nèi)存指標(biāo)經(jīng)過了那次線上OOM事故之后我的習(xí)慣是任何要長期運(yùn)行的Node服務(wù)都加一個健康檢查接口返回當(dāng)前進(jìn)程的內(nèi)存使用情況。const os require(os); app.get(/health, (req, res) { const mem process.memoryUsage(); res.json({ status: ok, uptime: process.uptime(), memory: { rss: mem.rss, heapTotal: mem.heapTotal, heapUsed: mem.heapUsed, external: mem.external, systemFree: os.freemem(), systemTotal: os.totalmem() } }); });heapUsed / heapTotal這個比例比絕對數(shù)值更有參考價值。如果長期穩(wěn)定在95%以上說明堆內(nèi)存在高位運(yùn)行隨時可能爆炸需要及時排查。6.2 用定時采樣畫出“內(nèi)存曲線”對于腳本型任務(wù)我會在腳本里加一個簡單的采樣器每30秒記錄一次process.memoryUsage().heapUsed輸出到文件const fs require(fs); setInterval(() { const heapUsed process.memoryUsage().heapUsed; const timestamp new Date().toISOString(); fs.appendFileSync(memory-metrics.log, ${timestamp}, ${heapUsed}\n); }, 30000);跑完任務(wù)后把這份日志丟進(jìn)Excel或任何圖表工具里畫一條曲線。如果曲線是一條接近水平的線說明內(nèi)存使用是健康的如果是一條單調(diào)上升的線哪怕上升速度很慢也說明有問題。這個方法雖然原始但比任何監(jiān)控系統(tǒng)都更容易堅持執(zhí)行因為不需要額外部署任何東西。6.3 記住這三個“不要”最后分享幾個經(jīng)驗性總結(jié)都是我實際操作中的體會不要不管三七二十一就把--max-old-space-size拉到32GB。堆太大GC停頓長進(jìn)程看起來像死了一樣堆太小頻繁GCCPU浪費(fèi)大。我一般從當(dāng)前峰值的1.5倍開始設(shè)運(yùn)行一段時間觀察GC日志再微調(diào)。不要只依賴NODE_OPTIONS來統(tǒng)一配置。它雖然方便但不利于團(tuán)隊協(xié)作因為別人clone代碼后看不到這個配置存在。我更推薦把參數(shù)直接寫進(jìn)package.json的scripts或者項目自帶啟動腳本里讓配置成為項目的一部分。不要忽略Node版本升級帶來的默認(rèn)行為變化。不同版本的默認(rèn)最大堆內(nèi)存不完全一樣有的項目升級Node版本后突然不崩了有的則相反這都正常所以每次升級Node版本后建議重新壓一遍內(nèi)存相關(guān)的場景。內(nèi)存問題的排查節(jié)奏就是先看是“一次性峰值”還是“持續(xù)增長”再用GC日志和堆快照定位最后才是調(diào)參。把堆上限調(diào)大是手段但不是目的。真正健康的應(yīng)用會在一個合理的內(nèi)存水位上平穩(wěn)運(yùn)行而不是靠無數(shù)次崩潰-調(diào)參-再崩潰來維持運(yùn)轉(zhuǎn)。希望這篇經(jīng)驗?zāi)軒湍闵僮咭恍澛贰?